# AGENTS.md 本仓库(Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。 ## 架构速览 - `backend/` FastAPI + SQLAlchemy 2.x(async) + Alembic,PostgreSQL。 - `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:/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`), 避免前端再抄一份映射开始漂移。 - 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。