"""设备出库明细 — 业务逻辑层(合并后的唯一入口) 「这台设备对应 MOM 的哪些出库单、领了哪些料」在本模块只有一个概念、 一张表(`product_outbound_materials`)、一组函数。原先那两张表 (`product_outbounds` 单据级 / `task_outbound_materials` 明细级)已停止写入, 只作回滚备份保留 —— 详见该模型的模块注释。 ═══ 两条写入路径 ═══ · `link_outbound_lines` —— 人在界面上挂(网页端/移动端选 MOM 出库单) → source='manual',可删 · `archive_from_webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备) → source='webhook',系统事实,不可删(要撤得去 MOM 撤回) 两条路径共用同一张表、同一套幂等约束,但**来源不同、删除规则不同** —— 这是它们唯一的差别,`source` 列把它记下来。 """ import logging import uuid from fastapi.concurrency import run_in_threadpool from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.product import Product from app.models.product_outbound_material import ProductOutboundMaterial from app.schemas.product import ProductOutboundMaterialResponse from app.services import mom_outbound_service logger = logging.getLogger(__name__) async def list_product_materials( db: AsyncSession, product_id: uuid.UUID, ) -> list[ProductOutboundMaterialResponse]: """列出某设备挂载的全部出库明细。 排序:先按 MOM 出库时间倒序,没有时间的沉底,再按写入时间兜底 —— 避免 outbound_time 为空的行插在最前面。 """ rows = ( await db.execute( select(ProductOutboundMaterial) .where(ProductOutboundMaterial.product_id == product_id) .order_by( ProductOutboundMaterial.outbound_time.desc().nullslast(), ProductOutboundMaterial.created_at.desc(), ProductOutboundMaterial.id.desc(), ) ) ).scalars().all() items = [ProductOutboundMaterialResponse.model_validate(r) for r in rows] fill_added_by_names(items) return items def fill_added_by_names( items: list[ProductOutboundMaterialResponse], ) -> list[ProductOutboundMaterialResponse]: """就地补上 `added_by_name`(谁挂上去的,中文姓名),并**返回同一个列表**。 返回列表是为了能写成 `fill(x)` 直接当值用 —— 就地修改却返回 None 的话, `fill(x) or []` 会静默变成空数组(写这行时就差点踩到)。 界面要显示「谁挂的」——`added_by` 存的是 Track 用户名(如 `zhangsan01`), 直接摆出来现场看不懂。解析交给服务端:移动端与网页端各抄一份 username→姓名 的映射迟早会漂移,而且 MOM 的 username 是「姓名/账号」格式,规则不止一条。 ⚠️ 解析失败**不能影响列表**:MOM 连不上时姓名降级为空串,前端回落到显示 用户名。查一次是批量 SQL(mom_cache 还带 2h TTL),不给每行单独打库。 """ from app.services.mom_cache import get_display_names names = [i.added_by for i in items if i.added_by] if not names: return items try: mapping = get_display_names(list(dict.fromkeys(names))) except Exception as e: logger.warning(f"[OutboundMaterial] 解析挂载人姓名失败,降级显示用户名: {e}") return items for i in items: if i.added_by: i.added_by_name = mapping.get(i.added_by, "") return items async def link_outbound_lines( db: AsyncSession, product: Product, mom_line_ids: list[int], *, task_id: uuid.UUID | None = None, added_by: str | None = None, ) -> int: """把 MOM 出库**明细行**挂到设备上(人工路径)。返回实际新增行数。 用户勾的是**整张出库单**,提交时把该单全部明细行 id 一起带过来 —— 本表按明细行成行,所以一张单会展开成 N 行。 ⚠️ 只接受 `mom_line_ids`,物料快照一律由后端拿 id 去 MOM 现查, 否则前端可以伪造「挂的是什么」。 ⚠️ MOM 查询是同步 psycopg2,用 run_in_threadpool 扔出去,别阻塞事件循环。 ⚠️ 调用方负责 commit —— 本函数只 flush,好让挂载与产品创建同事务。 幂等:已挂过的明细跳过(部分唯一索引兜底)。 """ ids = list(dict.fromkeys(int(i) for i in mom_line_ids)) # 去重且保持顺序 if not ids: return 0 lines = await run_in_threadpool(mom_outbound_service.get_lines_by_ids, ids) if not lines: # MOM 侧查不到(数据被清理 / ID 传错)—— 静默返回 0,由调用方比对数量 return 0 existing = set( ( await db.execute( select(ProductOutboundMaterial.mom_line_id).where( ProductOutboundMaterial.product_id == product.id, ProductOutboundMaterial.mom_line_id.in_( [ln["line_id"] for ln in lines]), ) ) ).scalars().all() ) added = 0 for ln in lines: if ln["line_id"] in existing: continue db.add(ProductOutboundMaterial( product_id=product.id, serial_number=product.serial_number, task_id=task_id, mom_line_id=ln["line_id"], outbound_no=ln["outbound_no"], request_no=ln.get("request_no") or None, applicant_name=None, # 人工挂载拿不到申请人:MOM 的 request_id 存量全为 NULL remark=None, sku=ln.get("sku") or None, material_name=ln.get("material_name") or None, spec_model=ln.get("spec_model") or None, quantity=ln.get("quantity"), unit_price=ln.get("unit_price"), outbound_type=ln.get("outbound_type") or None, consumer_name=ln.get("consumer_name") or None, operator_name=ln.get("operator_name") or None, warehouse_location=ln.get("warehouse_location") or None, outbound_time=ln.get("outbound_time"), source="manual", added_by=added_by, )) added += 1 await db.flush() return added async def archive_from_webhook( db: AsyncSession, product: Product, payload, *, company_name: str | None = None, ) -> bool: """MOM 出库回调 → 存档到本表(webhook 路径)。返回是否有新写入。 按 `payload.outbound_no` 去 MOM **现查明细**,逐行落 —— 本表是明细级, 只写一条单据级信息的话,这台设备上「领了什么料」就永远是空的。 ⚠️ 查不到明细(MOM 数据被清理、或该单确实没有可解析的明细)时, 退化成写**一行 `mom_line_id=NULL` 的存档**:宁可显示「有这张单但看不到明细」, 也不要静默丢掉这张单 —— 用户会以为出库记录丢了。 ⚠️ 幂等:已有存档就跳过(webhook 可能因运维重放而重入)。 """ outbound_no = (getattr(payload, "outbound_no", "") or "").strip() if not outbound_no: return False # 已有该单的存档 → 不重复写。**按 outbound_no 判**(不是按明细行), # 因为一张单的所有明细必然一起写入,任一行存在即整单已存过。 already = ( await db.execute( select(ProductOutboundMaterial.id).where( ProductOutboundMaterial.product_id == product.id, ProductOutboundMaterial.outbound_no == outbound_no, ).limit(1) ) ).scalar_one_or_none() if already is not None: return False lines = await run_in_threadpool( mom_outbound_service.get_lines_by_outbound_no, outbound_no) if not lines: # 退化成单据级存档:能看、能标撤回,但没有明细行 id,**不能报废** db.add(ProductOutboundMaterial( product_id=product.id, serial_number=product.serial_number, task_id=None, mom_line_id=None, outbound_no=outbound_no, request_no=getattr(payload, "request_no", None) or None, applicant_name=getattr(payload, "applicant_name", None) or None, remark=getattr(payload, "remark", None) or None, outbound_type=getattr(payload, "outbound_type", None) or None, consumer_name=getattr(payload, "consumer_name", None) or None, operator_name=(getattr(payload, "operator", None) or "")[:100] or None, outbound_time=getattr(payload, "outbound_time", None), source="webhook", )) logger.info(f"[OutboundMaterial] {outbound_no} MOM 查不到明细,退化为单据级存档") return True for ln in lines: db.add(ProductOutboundMaterial( product_id=product.id, serial_number=product.serial_number, task_id=None, mom_line_id=ln["line_id"], outbound_no=outbound_no, request_no=getattr(payload, "request_no", None) or None, applicant_name=getattr(payload, "applicant_name", None) or None, remark=getattr(payload, "remark", None) or None, sku=ln.get("sku") or None, material_name=ln.get("material_name") or None, spec_model=ln.get("spec_model") or None, quantity=ln.get("quantity"), unit_price=ln.get("unit_price"), outbound_type=(getattr(payload, "outbound_type", None) or ln.get("outbound_type") or None), consumer_name=(getattr(payload, "consumer_name", None) or ln.get("consumer_name") or None), operator_name=(getattr(payload, "operator", None) or ln.get("operator_name") or None), warehouse_location=ln.get("warehouse_location") or None, outbound_time=(getattr(payload, "outbound_time", None) or ln.get("outbound_time")), source="webhook", )) return True async def mark_revoked( db: AsyncSession, product_id: uuid.UUID, outbound_no: str | None = None, ) -> bool: """MOM 撤回回调 → 把该单在本设备上的存档标为已撤回。返回是否有改动。 **只置位不删行** ——「出过又撤了」本身就是要看得见的历史。 :param outbound_no: 指定单号则只标那一单;为空则标**最近一条未撤回的**。 MOM 的撤回载荷不保证带单号,而「最近一条未撤回的」是这批里最可能被撤的那张 —— 一批里同一台设备理论上不该出现两条未撤回的出库单(出库后设备已不在 仓库池,再出库匹配不到)。真出现时标错一条,也好过把历史全标脏。 """ from app.core.time_utils import get_beijing_time q = select(ProductOutboundMaterial).where( ProductOutboundMaterial.product_id == product_id, ProductOutboundMaterial.is_revoked.is_(False), ) if outbound_no: q = q.where(ProductOutboundMaterial.outbound_no == outbound_no) rows = (await db.execute(q)).scalars().all() else: # 先定位到「哪一张单」,再把那张单的**全部明细行**一起标 —— # 只标一行的会让同一张单呈现「一半撤回一半没撤」的鬼状态 latest = ( await db.execute( q.order_by( ProductOutboundMaterial.outbound_time.desc().nullslast(), ProductOutboundMaterial.created_at.desc(), ).limit(1) ) ).scalars().first() if latest is None: return False rows = ( await db.execute( select(ProductOutboundMaterial).where( ProductOutboundMaterial.product_id == product_id, ProductOutboundMaterial.outbound_no == latest.outbound_no, ) ) ).scalars().all() changed = False for r in rows: if not r.is_revoked: r.is_revoked = True r.revoked_at = get_beijing_time() changed = True return changed async def remove_product_order( db: AsyncSession, product_id: uuid.UUID, outbound_no: str, ) -> list[ProductOutboundMaterialResponse]: """整张出库单一起摘掉(挂错了要能撤)。 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下,所以补这个。 规则与逐条删**完全一致**:只要这张单在本设备上有一行是 `webhook` (MOM 回调自动存档的系统事实),整单就不给删 —— 要撤得去 MOM 撤回。 这样「整单删」不会成为绕过单行规则的后门。 """ no = (outbound_no or "").strip() if not no: raise HTTPException(status_code=400, detail="出库单号不能为空") rows = ( await db.execute( select(ProductOutboundMaterial).where( ProductOutboundMaterial.product_id == product_id, ProductOutboundMaterial.outbound_no == no, ) ) ).scalars().all() if not rows: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="这张出库单没有挂在这台设备上") if any(r.source != "manual" for r in rows): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="该出库单含 MOM 回调自动存档的记录,不能在 Track 里删除;如需撤销请在 MOM 中撤回", ) for r in rows: await db.delete(r) await db.commit() return await list_product_materials(db, product_id) async def remove_product_material( db: AsyncSession, product_id: uuid.UUID, material_id: int, ) -> list[ProductOutboundMaterialResponse]: """摘掉一条**人工挂载**的出库明细(挂错了要能撤)。 只允许删 `source='manual'`:webhook 存档是 MOM 出库回调留下的系统事实, 删了 Track 与 MOM 就对不上(MOM 那边单还在)。要撤该去 MOM 撤回, 由回调置 `is_revoked` 留痕。 """ row = ( await db.execute( select(ProductOutboundMaterial).where( ProductOutboundMaterial.id == material_id, ProductOutboundMaterial.product_id == product_id, ) ) ).scalars().first() if row is None: # 不属于这台设备的一律按「查不到」处理,不泄漏别的设备挂了什么 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="这条出库明细没有挂在这台设备上") if row.source != "manual": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="该出库单由 MOM 回调自动存档,不能在 Track 里删除;如需撤销请在 MOM 中撤回", ) await db.delete(row) await db.commit() return await list_product_materials(db, product_id)