Files
track/AGENTS.md
duxingchen e45c97bd1f feat: 组织隔离(IRIS 单实例)与出料功能基础
本轮之前累积的未提交工作,一并固化:

- 组织隔离:同一份代码部署给不同部门只需改 config 的 ORG_DEPARTMENT 与
  MATERIAL_CATEGORY_PREFIX。过滤点在登录/人员列表/物料/MOM 出库单四处,
  全部服务端钉死,客户端传什么都放不大。
  ★ 物料必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
  `LICA/<中文>`,而本部门分类树里另有 `IRIS/成品/LICA/…`(本就属于本部门),
  前缀匹配天然区分得开。
- MOM 出库单只读查询(直连 MOM 库):不走 MOM 现成的 /outbound 接口 ——
  那个要 JWT + permission_required,且对非特权账号按 consumer_name 做行级
  隔离,服务账号只能拿到自己名下的单。分页必须两段式(先按单号 GROUP BY
  分页,再 IN 捞明细),对宽表直接分页会得到明细行数而不是单据数。
- 出料功能:产品 ↔ 出库单存档(product_outbounds)与任务 ↔ 出库明细
  (task_outbound_materials),供「这台设备对应 MOM 哪张单」的展示。
  ⚠️ 快照一律由后端拿 ID 去 MOM 现查,不接受前端传入,否则前端可伪造单据。
2026-09-23 15:18:06 +08:00

137 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md
本仓库Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。
## 架构速览
- `backend/` FastAPI + SQLAlchemy 2.x(async) + AlembicPostgreSQL。
- `frontend/` React 19 + Vite + antd + Tailwind。路由见 `src/App.tsx`
管理端菜单见 `src/components/layout/AdminLayout.tsx``MENU` 数组)。
- 登录不走 Track 自己的用户表,而是**只读** MOM(KCGL) 的 `sys_user`
`sys_user.username``"真实姓名/登录账号"``login()`
`WHERE username LIKE '%/<账号>'` 匹配,`display_name``/` 拆解得到。
MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。
## 组织隔离2026-09 新增)
本实例只服务 IRIS 部门,开关是 `app/core/config.py` 的两个值:`ORG_DEPARTMENT`
(对 MOM `sys_user.department`)与 `MATERIAL_CATEGORY_PREFIX`(对 MOM
`material_base.category` 前缀)。**同一份代码部署给别的部门只需改这两处。**
过滤点共四处,全部服务端钉死,客户端传什么参数都不采纳:
| 位置 | 过滤条件 |
|---|---|
| 登录 `auth_service.login` | `department = ORG_DEPARTMENT OR role = SUPER_ADMIN` |
| 人员列表 `endpoints/users.py` | `department = ORG_DEPARTMENT` |
| 物料 `endpoints/materials.py` | `category LIKE 'IRIS/%'``groups``items` 都要加) |
| MOM 出库单 `services/mom_outbound_service.py` | 同上前缀 + 跨部门领用人例外(见下) |
- **登录是四处里唯一区分角色的**`SUPER_ADMIN` 跨部门放行IRIS 超管也能登
LICA 实例反之亦然供运维在两个实例之间切换。其余角色INBOUND /
SUPERVISOR / WAREHOUSE_MGR / SALES必须严格属于本部门。
- ⚠️ 物料必须用**前缀** `LIKE 'IRIS/%'`,不能反推成 `ILIKE '%IRIS%'`MOM 里
LICA 的物料是 `LICA/<中文>`(生产配件 687 / 销售产品 89 / 维修服务 16…
而本部门分类树里另有 `IRIS/成品/LICA/…`(野外便携 59 / 无人机 38 /
实验室内 34 / 高塔监测 30共 171 条)—— 那是**挂在 IRIS 名下、给 LICA 做的
成品**,本来就属于本部门。前缀匹配天然把前者排除、后者包含,不需要特例。
- 刻意**不做**「查询失败退回全表」的降级 —— 那是跨部门数据泄漏。宁可查不出,
不可查过头。
- 已实测:人员列表 20 人IRIS 部门)、物料 100 个分类 / 2248 条、
`LICA/``IRIS/` 前缀交叉命中 0 条4 个 LICA 普通账号全部登不进来,
2 个超管(含 LICA 的)正常放行。
### 出库单的跨部门领用人例外
`config.EXTRA_VISIBLE_CONSUMERS`(默认 `依锐思,石利LICA`)里的领用人,其在 MOM
`trans_outbound.consumer_name` 名下的单据,**即使物料分类不属于本部门也放行** ——
他们跨两个部门领料,只按物料前缀过滤会把他们的单整批漏掉。
⚠️ 这是**放行**条件SQL 里是 `OR`),与界面筛选(`AND`,只收窄)方向相反,
两者的集合运算在 `mom_outbound_service` 里必须分开写。名单为空时整段不拼,
退化成纯前缀过滤。已实测:空白名单 438 单 → 填入一个真实跨部门领用人后 440 单,
填入不存在的名字仍是 438 单(不放大范围)。
## 出料功能2026-09 新增)
回答两个问题:**这台设备对应 MOM 的哪张出库单**、**这个任务用了哪些出库物料**。
- `product_outbounds` —— 产品 ↔ 出库单存档。两条写入路径共用一张表,靠 `source`
区分:`webhook`MOM 出库回调自动存档)/ `manual`(人在界面上挂的)。
一次出库一行;**撤回只置 `is_revoked` 不删行**(「出过又撤了」也是历史)。
唯一约束是 `(serial_number, outbound_no)` 而非只约束单号 —— MOM 的批量出库
是多个商品共用一个单号,只约束单号会把正常的批量单误杀。
- `task_outbound_materials` —— 任务挂载的出库物料,**明细级快照**(一行 = MOM
`trans_outbound` 的一行。为什么存快照而不只存单号MOM 的物料名/规格要经
`COALESCE` 三表 JOIN`stock_buy`/`stock_semi`/`stock_product``material_base`
才能解析Track 跨库 JOIN 不了,只存单号则 MOM 一挂就看不到已挂内容。
纯引用**不记用量**`quantity` 是出库单原值,**不是**本任务用量)。
- `mom_outbounds.py` / `mom_outbound_service.py` —— 直连 MOM 库的只读查询,供选择器
搜索用。不走 MOM 现成的 `GET /api/v1/outbound`:那个接口要 JWT +
`permission_required`,且对非特权账号按 `consumer_name` 做行级隔离Track 用
服务账号调只能拿到该账号名下的单,不是全量。分页必须**两段式**(先
`GROUP BY outbound_no` 分页拿单号,再 `WHERE outbound_no IN (…)` 捞明细),
绝不能对 join 后的宽表直接分页 —— 那是明细行数不是单据数。
- ⚠️ 快照一律由后端拿 ID 去 MOM 现查,**不接受前端传入**,否则前端可伪造单据。
- ⚠️ 写挂载行时**不要** `append` 到 ORM 集合(`task.outbound_materials`):集合在
flush 后处于「未加载」态,碰它会触发懒加载,异步 session 下直接抛
`MissingGreenlet`。只写 FK响应构造前走一次真正的查询。
**与 LICA 实例(`~/track-lica`)的差异**LICA 那边出库单还要按**业务分组数据
范围**再收敛一层(范围 ∩ 组 ∩ 个人),本实例没有分组体系,故 `mom-outbounds`
**没有 `group_id` 参数**,挂载时也不做额外的可见性校验。不要为了「对齐」加回来 ——
那会引入一份没有数据支撑的过滤。
⚠️ 两套实例共用同一个 MOM 库,但**数据卷相互独立**。永远不要在 `/home/yueli/track`
下执行 `docker compose down -v`
## 本地起环境(关键,踩过的坑都在这)
1. **本机没有 Postgres 时需要先装**(容器内 `sudo` 可用):
`sudo -n apt-get install -y --fix-missing postgresql postgresql-contrib`
然后 `sudo -n pg_ctlcluster 17 main start`
本仓库不使用 pgvector无需额外扩展。
2. **数据库端口与生产默认值不同**,必须用环境变量覆盖:
- `DATABASE_URL=postgresql+asyncpg://track:track_prod_2026@127.0.0.1:5432/track_production`
- `MOM_DB_HOST=127.0.0.1``MOM_DB_PORT=5432`
- `SECRET_KEY=<≥32 字符>``DEBUG=false` 时配置项会**拒绝**默认 SECRET_KEY
(见 `app/core/config.py` 的校验),不设会直接 import 失败。
3. 迁移:`cd backend && python3 -m alembic upgrade head`(没有全局 `alembic` 命令,
要用 `python3 -m alembic`)。校验纯 SQL 用 `alembic upgrade head --sql`
4. 前端 proxy 指向 Docker 服务名 `backend:8000`。本机跑要么把
`127.0.0.1 backend` 写进 `/etc/hosts`,要么直接给
`VITE_API_BASE_URL=http://localhost:<port>/api/v1` 绕过 proxy。
注意 dev server 由 `basicSsl` 起 HTTPS跨域需要后端
`CORS_ORIGINS` 加上 `https://localhost:1420`
## 测试的坑(重要)
- **不要用 `starlette.testclient.TestClient` 测异步 SQLAlchemy 应用。**
它每个请求新建事件循环,而引擎是模块级单例、池里挂着 asyncpg 连接,
跨循环复用会报 `got Future attached to a different loop`,表现为随机 500。
正确做法:`httpx.AsyncClient(transport=httpx.ASGITransport(app=app))`
并在单个 `asyncio.run()` 里跑完全部请求。生产 uvicorn 单循环无此问题。
- 仓库目前**没有** pytest 基建,也没有前端测试脚本。
## 已知的待修问题(截至 1.0应用 分支)
- **读接口大面积未鉴权**(已实测,非推测):无 token 直接 200 的包括
`/api/v1/users/`、全部 `/api/v1/dashboard/*`(含
`people-history/export` —— 匿名即可批量导出个人工时台账)、
`/api/v1/analytics/*``/api/v1/screen/*``/api/v1/orders/`
写操作和 `/api/v1/tasks``/api/v1/products` 是有鉴权的。
新增接口请统一用 `app/core/deps.py``require_admin` / `require_roles`
- 「管理员角色」这份规则此前散在 4 处(后端 `task_service``products.py` 内联、
前端 `constants/task.ts``AdminProductsPage` 内联),已因此出过事故。
**后端唯一事实来源是 `app/core/roles.py`,前端用 `constants/task.ts::isAdminRole`。**
新增判断不要手写 `===` 比较。
- `task_logs.task_id` 是 NOT NULL 外键,只能挂任务,不是通用审计。通用审计是
`audit_logs`(本轮新增,由 `app/core/audit_middleware.py` 自动采集)。
## 约定
- 时间统一北京时间(`app/core/time_utils.py`),库里存 timestamptz。
- 中文枚举标签尽量由服务端下发(如审计接口的 `module_label`/`action_label`
避免前端再抄一份映射开始漂移。
- 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。