diff --git a/inventory-backend/app/services/audit_export_service.py b/inventory-backend/app/services/audit_export_service.py index b6814b3..a380891 100644 --- a/inventory-backend/app/services/audit_export_service.py +++ b/inventory-backend/app/services/audit_export_service.py @@ -30,6 +30,9 @@ import json import re from collections import Counter, defaultdict +from sqlalchemy import text + +from app.extensions import db from app.models.base import MaterialBase from app.utils.audit_labels import ( BOOLEAN_FIELDS, @@ -351,6 +354,96 @@ _SNAPSHOT_BASE_ID_SOURCES = (SNAPSHOT_CREATED, SNAPSHOT_DELETED) # 库存三表 —— 入库数据分别落在采购/半成品/成品库存表里 STOCK_TABLES = ('stock_buy', 'stock_semi', 'stock_product') +# SKU 形状:实测库里 2178 个 SKU **全部**是 10 位纯数字。 +# +# ★ 这个护栏不是为了好看,是为了防误配:target_name 里混着 bom_no +# ('IH-L2V1J')、单号('BOR-2026…')、角色码('PURCHASER')等等, +# 若不加形状限制就拿去当 SKU 查,理论上可能撞上某个 SKU 而给出**错的**物料名。 +# 实测字母开头的 target_name 与 SKU 集合命中数为 0,即护栏不误伤也不漏。 +_SKU_RE = re.compile(r'^\d{10}$') + + +def is_sku_like(text): + """该字符串是否长得像物料编码(10 位纯数字)""" + return bool(isinstance(text, str) and _SKU_RE.match(text.strip())) + + +# target_name 的**内部兜底形态**:audit_listener._get_target_name 在找不到 +# 可读名称时会写成 'stock_buy ID:1667' —— 表名 + 主键,对业务人员零价值。 +_TARGET_NAME_FALLBACK_RE = re.compile(r'^[\w.]+ ID:\d+$') + + +def _display_code(row, sku): + """ + 操作对象的「编码段」该用谁。 + + ★ 优先用库里的 target_name:单号('BOR-20260721-0001')、BOM 编号 + ('IH-L2V1J')本身就是业务标识,比 SKU 更能定位一条记录 —— + 把借还记录的「BOR-…」换成 SKU 是把有用的信息**换掉**了,不是补充。 + 实测借还/报废这类单据表的 target_name 就是单号,而它们的快照里带着 sku。 + + 只有 target_name 是内部兜底形态('stock_buy ID:1667')或为空时, + 才退而用 SKU —— 那种串比 SKU 还不可读。 + """ + tname = (getattr(row, 'target_name', '') or '').strip() + if tname and not _TARGET_NAME_FALLBACK_RE.match(tname): + return tname + return (sku or '').strip() + + +_sku_tables_cache = None + + +def _sku_source_tables(): + """ + 同时含 `sku` 与 `base_id` 的物理表名(进程内查一次并缓存)。 + + ★ 不硬编表名列表:实测有 7 张(stock_buy / stock_semi / stock_product / + stock_adjustment / stock_service / trans_defective_goods / trans_repair), + 将来加业务表就会变。硬编的清单在加表时会**静默漏掉**新表 —— + 这正是本项目反复踩过的漂移坑(前端 moduleMap、双胞胎页面都是这么坏的)。 + 改为从 information_schema 枚举,自维护。 + """ + global _sku_tables_cache + if _sku_tables_cache is None: + rows = db.session.execute(text(""" + SELECT a.table_name + FROM information_schema.columns a + JOIN information_schema.columns b + ON a.table_name = b.table_name AND b.column_name = 'base_id' + WHERE a.column_name = 'sku' AND a.table_schema = current_schema() + """)).fetchall() + _sku_tables_cache = tuple(r[0] for r in rows) + return _sku_tables_cache + + +def load_sku_base_map(skus): + """ + {sku: base_id} —— SKU → 物料。**对应多个物料的 SKU 不返回**。 + + ★ SKU 不在 material_base 上(那张表没有 sku 列),而在流水/库存表上。 + 故把这些表 UNION 起来取 (sku, base_id),并要求一个 SKU 只对应一个物料。 + 实测并集 2178 个 SKU、0 个歧义;但「歧义即丢弃」的判断仍然保留 —— + 数据是会变的,不能靠今天的一次实测兜底。 + """ + wanted = {s.strip() for s in skus if isinstance(s, str) and s.strip()} + if not wanted: + return {} + tables = _sku_source_tables() + if not tables: + return {} + + union = " UNION ALL ".join( + f"SELECT sku, base_id FROM {t} WHERE sku IS NOT NULL AND sku <> ''" + for t in tables + ) + rows = db.session.execute(text(f""" + SELECT sku, count(DISTINCT base_id) AS n, min(base_id) AS base_id + FROM ({union}) u WHERE sku = ANY(:skus) + GROUP BY sku + """), {'skus': sorted(wanted)}).fetchall() + return {r[0]: r[2] for r in rows if r[1] == 1} + def _stock_row_lookup(target_ids): """ @@ -388,60 +481,131 @@ def _stock_row_lookup(target_ids): for sid, tbls in hits.items() if len(tbls) == 1} +def _material_ref_of(row, sku_map, stock_map): + """ + 单行审计记录 → (base_id, code) 或 None。 + + **按特征解析,不按 module 名单** —— 同一个 module(如「报废管理」)里 + 不同记录的可用线索并不一样(有的快照带 base_id、有的只带 sku、 + UPDATE 行两样都没有),按 module 判断必然覆盖不全。 + + 五级,按可靠性从高到低: + 1. 快照 base_id —— 库存行的权威线索 + 2. 快照 sku → SKU 索引 —— 报废/退回 的快照里带 sku + 3. target_name 是 SKU —— 库存/退回/报废 的 UPDATE 行(无快照) + 4. 快照 parent_id —— BOM 关系行(见下) + 5. target_id 唯一命中股票表 —— target_name 是 'stock_buy ID:N' 的入库行 + + ★ 第 4 条为什么取 parent_id 而不是 child_id:BOM 行是「父件用 N 个 子件」 + 的边,而这条记录的 bom_no(操作对象)属于**父件**。实测同一 bom_no 的 + 所有子行 parent_id 一致('IH-L2V1J' → parent 44),故用父件才与 + 操作对象对得上;用子件会显示成一个只在 BOM 里出现一次的小零件名。 + + ★ 两条查找结果(sku_map / stock_map)由调用方**批量**查好传进来: + 逐行查库在 50 行的页面上就是上百次查询。 + + 定位不到返回 None —— 宁可不补,也不能补错。 + """ + snaps = [s for s in (snapshot_of(row, w) for w in _SNAPSHOT_BASE_ID_SOURCES) if s] + tname = (row.target_name or '').strip() + ref = None # (base_id, 用于编码段的 sku 或 None) + + # 1) 快照 base_id + for snap in snaps: + bid = as_int(snap.get('base_id')) + if bid: + ref = (bid, (snap.get('sku') or '').strip() or None) + break + + # 2) 快照 sku(报废/退回/借还这类单据表:有 sku、没有 base_id) + if ref is None: + for snap in snaps: + s = str(snap.get('sku') or '').strip() + if s and sku_map.get(s): + ref = (sku_map[s], s) + break + + # 3) target_name 本身就是 SKU + if ref is None and is_sku_like(tname) and sku_map.get(tname): + ref = (sku_map[tname], tname) + + # 4) BOM 关系行:parent_id 与 child_id 同时在,说明这是一条父子关系边 + if ref is None: + for snap in snaps: + if 'parent_id' in snap and 'child_id' in snap: + pid = as_int(snap.get('parent_id')) + if pid: + ref = (pid, None) + break + + # 5) target_id 唯一命中一张股票表 + if ref is None: + tid = as_int(getattr(row, 'target_id', None)) + hit = stock_map.get(tid) if tid is not None else None + if hit and hit[0]: + ref = (hit[0], (hit[1] or '').strip() or None) + + if ref is None: + return None + base_id, sku = ref + return base_id, _display_code(row, sku) + + def load_material_context(rows): """ - 一批审计行 → {row.id: {'sku','name','spec','unit','base_id'}}。 + 一批审计行 → {row.id: {'base_id','code','name','spec','unit'}}。 - 只为「能无歧义定位到物料」的行给出结果: - 1. 快照里有 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义; - 2. 否则 target_id 唯一命中一张股票表 —— 实测与 (1) 100% 一致。 + 解析线索见 _material_ref_of。**先扫一遍收集线索、再批量查**, + 最后逐行套用 —— 全程 5 次查询(SKU 索引 1 次 + 股票表 3 次 + + material_base 1 次),与页大小无关。 - 定位不到就**不放进去**,由调用方回落到原始 target_name —— + 解析不到的行**不放进去**,由调用方回落到原始 target_name —— 宁可不补,也不能补错。 """ - # 1) 先从快照取 base_id / sku - snap_ctx = {} # row_pk -> (base_id, sku) - target_ids = [] - for r in rows: - base_id, sku = None, '' - for which in _SNAPSHOT_BASE_ID_SOURCES: - snap = snapshot_of(r, which) - if not snap: - continue - base_id = as_int(snap.get('base_id')) or base_id - sku = sku or (snap.get('sku') or '') - if base_id: - snap_ctx[r.id] = (base_id, sku) - elif as_int(getattr(r, 'target_id', None)) is not None: - # 2) 没有快照 base_id 的(典型是 UPDATE),留给股票表兜底 - target_ids.append(r.target_id) - - stock_ctx = _stock_row_lookup(target_ids) - - # 汇总所有要查的 base_id,一次查完 - need_base = {b for b, _ in snap_ctx.values()} - need_base |= {b for b, _ in stock_ctx.values() if b} - if not need_base: + if not rows: return {} + # ---------- 第 1 遍:收集线索 ---------- + sku_candidates = set() + target_ids = [] + for r in rows: + for w in _SNAPSHOT_BASE_ID_SOURCES: + s = snapshot_of(r, w).get('sku') + if s: + sku_candidates.add(str(s).strip()) + tname = (r.target_name or '').strip() + if is_sku_like(tname): + sku_candidates.add(tname) + tid = as_int(getattr(r, 'target_id', None)) + if tid is not None: + target_ids.append(tid) + + # ---------- 批量查 ---------- + sku_map = load_sku_base_map(sku_candidates) + stock_map = _stock_row_lookup(target_ids) + + # ---------- 第 2 遍:解析 ---------- + refs = {} + for r in rows: + ref = _material_ref_of(r, sku_map, stock_map) + if ref: + refs[r.id] = ref + if not refs: + return {} + + need_base = {bid for bid, _ in refs.values()} bases = { m.id: m for m in MaterialBase.query.filter(MaterialBase.id.in_(need_base)).all() } out = {} - for r in rows: - ctx = snap_ctx.get(r.id) - if ctx is None: - ctx = stock_ctx.get(as_int(getattr(r, 'target_id', None))) - if not ctx: - continue - base_id, sku = ctx + for rid, (base_id, code) in refs.items(): b = bases.get(base_id) if b is None: continue - out[r.id] = { + out[rid] = { 'base_id': base_id, - 'sku': (sku or '').strip(), + 'code': (code or '').strip(), 'name': (b.name or '').strip(), 'spec': (b.spec_model or '').strip(), 'unit': (b.unit or '').strip(), @@ -449,16 +613,17 @@ def load_material_context(rows): return out -def format_target_display(ctx, fallback=''): +def format_target_display(ctx): """ - 物料上下文 → 「SKU - 物料名称 (规格型号)」。取不到就返回空串(调用方回落)。 + 物料上下文 → 「编码 - 物料名称 (规格型号)」。取不到就返回空串(调用方回落)。 + ★ 编码这段逐行不同:物料行走 SKU,BOM 行走 bom_no(见 _material_ref_of)。 ★ 缺哪段就省哪段,不留空括号/空横杠:材料名查不到时显示 「0000001180 - 」比只显示「0000001180」更让人困惑。 """ if not ctx: return '' - head = ctx.get('sku') or '' + head = ctx.get('code') or '' name, spec = ctx.get('name') or '', ctx.get('spec') or '' if not (head or name): return '' @@ -708,8 +873,18 @@ def _fmt_number(val): def _core_attrs(snap, material=None): - """快照 → ['入库数量 10 件', '库位 ZZTEST', …],每个槽位取一个。""" + """ + 快照 + 已解析的物料 → 摘要里的核心属性片段,每个槽位取一个。 + + ★ 物料名取自**解析结果**(material['name']),不是快照里的某个字段: + 快照里只有 sku('0000001180'),名称在 material_base 上。这类单据 + (报废/退回)的操作对象虽然也显示名称,但摘要要能独立读懂 —— + 导出成 Excel、或被复制粘贴出来时,旁边那一列不一定在。 + """ parts = [] + name = (material or {}).get('name') or '' + if name: + parts.append(truncate(name, 24)) for _slot, keys in _SUMMARY_SLOTS: for key in keys: if key not in snap or is_blank(snap[key]):