"""MOM 出库单只读查询 — 直连 MOM 库(跨库,无 ORM) 为什么直连库,而不是调 MOM 现成的 `GET /api/v1/outbound`: 那个接口要 JWT + `permission_required`,且对非特权账号按 `consumer_name` 做**行级隔离**(非特权用户只能看到自己名下的单)。Track 用服务账号去调只会 拿到该账号名下的数据、不是全量。Track 已有只读 MOM 连接 (`app/core/mom_database.py`,mom_cache 也在用),直连才是对的。 分页必须两段式(照抄 MOM `outbound_service.get_grouped_list` 的做法): 1) 先 `GROUP BY outbound_no` 分页,拿到本页的**单据号** 2) 再 `WHERE outbound_no IN (...)` 捞这些单的明细 ⚠️ 绝不能对 join 后的宽表直接分页 —— 那是**明细行数**不是单据数。 `outbound_no` 不唯一(批量出库多商品共用),一张单最多 55 条明细, 实测 517 张单平均 2.64 条、65% 是单条。 物料名解析(MOM 自己没有视图,是代码里硬拼的): `trans_outbound.(source_table, stock_id)` 多态指向三张库存表之一,再经它们的 `base_id` 回到 `material_base`。用 COALESCE 三路 LEFT JOIN 一次拉全。 ⚠️ **不要用 `trans_outbound.sku` 做 JOIN** —— 它只是库存表 sku 的冗余快照, 不唯一,历史还可能漂移。 """ import logging from datetime import datetime, timedelta, timezone from sqlalchemy import text from app.core.config import settings from app.core.mom_database import MomSessionLocal logger = logging.getLogger(__name__) # MOM 的 outbound_time 是 `timestamp without time zone`,存的是**北京墙上时间**。 BEIJING_TZ = timezone(timedelta(hours=8)) # 跨部门例外领用人 —— 这几个领用人的出库单,即使物料分类不属于本部门,本实例 # 也要能看见。定义与理由见 config.EXTRA_VISIBLE_CONSUMERS。 # 本模块只负责**执行**这条例外、不负责判定;改范围请改配置,不要改这里。 _EXTRA_CONSUMERS: list[str] = settings.EXTRA_VISIBLE_CONSUMERS_LIST # 三张库存表 → material_base 的 JOIN 片段。 # 抽成常量是因为「搜索命中」与「拉明细」两处都要用,写歪一处两边就不一致了。 _STOCK_JOIN = """ LEFT JOIN stock_buy sb ON o.source_table = 'stock_buy' AND sb.id = o.stock_id LEFT JOIN stock_semi ss ON o.source_table = 'stock_semi' AND ss.id = o.stock_id LEFT JOIN stock_product sp ON o.source_table = 'stock_product' AND sp.id = o.stock_id LEFT JOIN material_base b ON b.id = COALESCE(sb.base_id, ss.base_id, sp.base_id) """ # 明细行的公共 SELECT 列表(两处查询共用,保证返回字段一致) _LINE_COLUMNS = """ o.id AS line_id, o.outbound_no, o.sku, b.name AS material_name, b.spec_model, o.quantity, o.unit_price, o.returned_quantity, o.outbound_type, o.consumer_name, o.operator_name, o.warehouse_location, o.outbound_time, a.request_no """ # `trans_outbound.outbound_type` 的中文名 —— 码表源头是 MOM 前端的 # `inventory-web/src/views/outbound/index.vue::formatType`,这里照抄一份**统一 # 下发**(响应里的 outbound_type_label),前端不再自建映射,否则两边会开始漂移。 # ⚠️ 只用于展示,**不做任何业务判断** —— MOM 码表未冻结(`types/api.ts` 同注)。 # 库里存的只有 SALES/USE/PRODUCTION 三种,其余是按 MOM 下拉预留的。 _OUTBOUND_TYPE_LABELS = { "SALES": "销售出库", "USE": "内部领用", "PRODUCTION": "生产出库", "SCRAP": "报废", "LOSS": "盘亏出库", "REPAIR": "维修出库", } def describe_outbound_type(code: str | None) -> str: """出库类型码 → 中文名。 码表里没有的码**原样返回**(不吞成空串、也不显示「未知」):宁可把英文码 露在界面上让人一眼看出是漏配的码表,也不要静默成一句看不出问题的中文。 """ raw = (code or "").strip() return _OUTBOUND_TYPE_LABELS.get(raw.upper(), raw) def _as_beijing(dt: datetime | None) -> datetime | None: """把 MOM 的 naive 北京时间补上 +08:00 偏移。 ⚠️ 少了这一步,上层(尤其写进 Track 的 timestamptz 列时)会把它当 **UTC** 处理,前端在北京显示会整整差 8 小时。这个坑在 MOM 仓的 822f897 刚踩过 一次,这里统一在数据出口处理,不留给调用方去记得。 """ if dt is None: return None if dt.tzinfo is None: return dt.replace(tzinfo=BEIJING_TZ) return dt.astimezone(BEIJING_TZ) def _line_dict(row) -> dict: """把一行查询结果转成明细 dict(字段名与 Track 侧快照列对齐)。""" return { "line_id": row.line_id, "sku": row.sku or "", "material_name": row.material_name or "", "spec_model": row.spec_model or "", "quantity": row.quantity, "unit_price": row.unit_price, "returned_quantity": row.returned_quantity, "outbound_type": row.outbound_type or "", "outbound_type_label": describe_outbound_type(row.outbound_type), "consumer_name": row.consumer_name or "", "operator_name": row.operator_name or "", "warehouse_location": row.warehouse_location or "", "outbound_time": _as_beijing(row.outbound_time), # MOM 的 request_id 是最近才加的列,存量 1364 条**全为 NULL**(无从回填, # 见该仓 models/outbound.py 的注释)。UI 要对空值显示「无关联申请单」。 "request_no": row.request_no or "", } def _fetch_lines(db, outbound_nos: list[str]) -> dict[str, list[dict]]: """捞出指定单据的明细,按出库单号分组。 申请单必须 LEFT JOIN —— trans_outbound.request_id 存量全为 NULL,用 INNER 会把这批历史单的明细整批吞掉。 """ if not outbound_nos: return {} rows = db.execute( text(f""" SELECT {_LINE_COLUMNS} FROM trans_outbound o {_STOCK_JOIN} LEFT JOIN outbound_approval a ON a.id = o.request_id WHERE o.outbound_no = ANY(:nos) ORDER BY o.outbound_time DESC, o.id """), {"nos": list(outbound_nos)}, ).fetchall() grouped: dict[str, list[dict]] = {} for r in rows: grouped.setdefault(r.outbound_no, []).append(_line_dict(r)) return grouped def search_outbound_orders( keyword: str = "", start_date: str = "", end_date: str = "", skip: int = 0, limit: int = 20, consumers: set[str] | None = None, ) -> tuple[list[dict], int]: """按**单据**分页搜索 MOM 出库单,返回 `(单据列表, 单据总数)`。 keyword 命中范围:出库单号 / 物料SKU / 物料名称 / **规格型号** / 领用人。 start_date、end_date 为 `YYYY-MM-DD`,**含端点的整日**。 consumers:界面筛选的**领用人姓名**集合(AND 语义,只收窄)。 · `None` → 不限 · 非空集合 → 只返回这些领用人的单 · **空集合** → 一条都不返回(绝不退化成「不过滤」) ⚠️ 本函数只负责**执行**范围,不负责**判定**范围 —— 可见范围(公司前缀 + 跨部门例外)在本模块内以常量化形式钉死,避免出现第二份口径。 """ kw = keyword.strip() # ⚠️ 日期参数用 None 而不是空串:写成 `:start = ''` 时 PostgreSQL **不保证 # 短路求值**,规划器仍会去算 `CAST('' AS timestamp)` 并直接报 # InvalidDatetimeFormat(实测踩到)。传 NULL + 显式 text 转换才安全。 params = { "kw": kw, "kw_like": f"%{kw}%" if kw else "", "start": start_date.strip() or None, "end": end_date.strip() or None, # 公司隔离:与物料选择器同一套口径(material_base.category 前缀)。 # ⚠️ 必须是**前缀** LIKE,不能写成 ILIKE '%IRIS%':MOM 里 LICA 的物料是 # `LICA/<中文>`,而 IRIS 分类树里另有 `IRIS/成品/LICA/…`(171 条)—— # 后者本就属于本部门,前缀匹配天然区分得开。 "cat_prefix": f"{settings.MATERIAL_CATEGORY_PREFIX}%", } # 可见范围 = 公司前缀(范围)∪ 跨部门例外(放行)。 # · 前缀是**范围**:本部门物料开出去的单; # · 例外是 OR:那几个领用人跨部门领料,只按前缀过滤会把他们的单整批漏掉。 # ⚠️ 例外为空时**整段不拼**:`= ANY(ARRAY[])` 虽然返回 false(不放大范围, # 语义安全),但留一个恒假子句只会让这条 SQL 更难排查。 if _EXTRA_CONSUMERS: company_clause = ( "(b.category LIKE :cat_prefix OR o.consumer_name = ANY(:extra_consumers))" ) params["extra_consumers"] = _EXTRA_CONSUMERS else: company_clause = "b.category LIKE :cat_prefix" where = f""" WHERE (:kw = '' OR o.outbound_no ILIKE :kw_like OR o.sku ILIKE :kw_like OR b.name ILIKE :kw_like OR b.spec_model ILIKE :kw_like OR o.consumer_name ILIKE :kw_like) AND {company_clause} AND (CAST(:start AS text) IS NULL OR o.outbound_time >= CAST(:start AS timestamp)) AND (CAST(:end AS text) IS NULL OR o.outbound_time < CAST(:end AS timestamp) + interval '1 day') """ if consumers is not None: if not consumers: # ⚠️ 空集合必须**显式** false。绝不能拼成 `= ANY(empty)` 或让条件消失 —— # 空范围退化成不过滤就是全量泄漏,这是本模块的底线。 where += "\n AND false" else: where += "\n AND o.consumer_name = ANY(:consumers)" params["consumers"] = sorted(consumers) db = MomSessionLocal() try: total = db.execute( text(f""" SELECT count(DISTINCT o.outbound_no) FROM trans_outbound o {_STOCK_JOIN} {where} """), params, ).scalar() or 0 if total == 0: return [], 0 order_rows = db.execute( text(f""" SELECT o.outbound_no, max(o.outbound_time) AS outbound_time, max(o.outbound_type) AS outbound_type, max(o.consumer_name) AS consumer_name, max(o.operator_name) AS operator_name, count(*) AS line_count, sum(o.quantity) AS total_quantity FROM trans_outbound o {_STOCK_JOIN} {where} GROUP BY o.outbound_no ORDER BY outbound_time DESC, o.outbound_no DESC LIMIT :lim OFFSET :off """), {**params, "lim": limit, "off": skip}, ).fetchall() lines_by_no = _fetch_lines(db, [r.outbound_no for r in order_rows]) orders = [ { "outbound_no": r.outbound_no, "outbound_time": _as_beijing(r.outbound_time), "outbound_type": r.outbound_type or "", "outbound_type_label": describe_outbound_type(r.outbound_type), "consumer_name": r.consumer_name or "", "operator_name": r.operator_name or "", "line_count": r.line_count, "total_quantity": r.total_quantity, "lines": lines_by_no.get(r.outbound_no, []), } for r in order_rows ] return orders, total finally: db.close() def get_lines_by_ids(mom_line_ids: list[int]) -> list[dict]: """按 MOM 明细行 ID 取快照,供挂载到任务时生成 Track 侧快照。 查不到的 ID 会被**静默跳过** —— 由调用方比对数量后决定要不要提示用户 (MOM 库清理过数据时会命中这种情况)。 """ if not mom_line_ids: return [] db = MomSessionLocal() try: rows = db.execute( text(f""" SELECT {_LINE_COLUMNS} FROM trans_outbound o {_STOCK_JOIN} LEFT JOIN outbound_approval a ON a.id = o.request_id WHERE o.id = ANY(:ids) """), {"ids": [int(i) for i in mom_line_ids]}, ).fetchall() out = [] for r in rows: d = _line_dict(r) d["outbound_no"] = r.outbound_no out.append(d) return out finally: db.close() def get_lines_by_outbound_no(outbound_no: str) -> list[dict]: """按**出库单号**取该单的全部明细快照(供 MOM 出库回调存档用)。 为什么需要它:统一后的 `product_outbound_materials` 是**明细级**,而 MOM 的出库回调只带单号、不带明细 —— 不现查的话,这台设备上「领了什么料」 就永远是空的。 查不到返回空列表(调用方据此退化成单据级存档,而不是丢掉这张单)。 ⚠️ 批量出库多个商品共用一个单号,所以这里可能返回多行,也可能一行都没有。 """ no = (outbound_no or "").strip() if not no: return [] db = MomSessionLocal() try: rows = db.execute( text(f""" SELECT {_LINE_COLUMNS} FROM trans_outbound o {_STOCK_JOIN} LEFT JOIN outbound_approval a ON a.id = o.request_id WHERE o.outbound_no = :no ORDER BY o.outbound_time DESC, o.id """), {"no": no}, ).fetchall() out = [] for r in rows: d = _line_dict(r) d["outbound_no"] = r.outbound_no out.append(d) return out finally: db.close() def list_consumer_names(consumers: set[str] | None = None) -> list[str]: """本部门出库单里出现过的**领用人姓名**(去重、按出现次数降序)。 给前端下拉用。`consumers` 的含义与 search_outbound_orders 完全一致 (None=不限 / 空集=空结果),**同样受可见范围约束** —— 下拉里不能出现 用户本来就看不到的人名,否则等于把范围外的人员信息漏出去。 可见范围的构造与 search_outbound_orders 保持一致(前缀 ∪ 跨部门例外)。 """ if consumers is not None and not consumers: return [] params: dict = {"cat_prefix": f"{settings.MATERIAL_CATEGORY_PREFIX}%"} if _EXTRA_CONSUMERS: company_clause = ( "(b.category LIKE :cat_prefix OR o.consumer_name = ANY(:extra_consumers))" ) params["extra_consumers"] = _EXTRA_CONSUMERS else: company_clause = "b.category LIKE :cat_prefix" sql = """ SELECT o.consumer_name, count(*) AS cnt FROM trans_outbound o {stock_join} WHERE {company_clause} AND o.consumer_name IS NOT NULL AND o.consumer_name <> '' """.format(stock_join=_STOCK_JOIN, company_clause=company_clause) if consumers is not None: sql += " AND o.consumer_name = ANY(:consumers)" params["consumers"] = sorted(consumers) sql += " GROUP BY o.consumer_name ORDER BY cnt DESC, o.consumer_name" db = MomSessionLocal() try: return [r[0] for r in db.execute(text(sql), params).fetchall()] finally: db.close() def get_orders_by_line_ids(mom_line_ids: list[int]) -> list[dict]: """按明细行 ID 归并出**单据级**信息(同一张单只返回一条)。 用户是按整张出库单勾选的,而 product_outbounds 是单据级(一行 = 一张单), 所以提交上来的明细 ID 要先归并回单据再落库。 单据头的字段(出库时间/类型/领用人/操作员/申请单号)同单内必然一致, 取任意一条即可。 """ merged: dict[str, dict] = {} for ln in get_lines_by_ids(mom_line_ids): merged.setdefault(ln["outbound_no"], { "outbound_no": ln["outbound_no"], "outbound_time": ln["outbound_time"], "outbound_type": ln["outbound_type"], "consumer_name": ln["consumer_name"], "operator_name": ln["operator_name"], "request_no": ln["request_no"], }) return list(merged.values())