1. 什么时候先看这篇文档

出现以下任一现象时,先定位:

  • 同一个采购单中有两行相同物料,但“未发货关闭”列表只显示一行;
  • 页面显示的待关闭数量为 0,或同一数量被重复扣减;
  • getInvPoInfo / getInvPoInfoFormat 返回的采购明细和 OPS 数量不一致;
  • 关闭校验提示“可关闭数量不足”,但按采购明细实际数量应该可以关闭。

首个代码入口:

/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/models/scm/InvPoModel.php::queryInvPoInfo()

2. 结论先看

采购数量必须按“采购订单 ID + 采购明细 ID”归属,不能按“订单 + 物料 + 赠品标识”归属。

正确链路是:

flowchart LR
    PO[采购主单 iid] --> D[采购明细 id]
    D --> O[出库明细 srcOrderEntryId]
    O --> I[入库明细 srcOrderEntryId 指向 po_out_info.id]
    I --> O2[回到出库明细 srcOrderEntryId]
    O2 --> D
    D --> C[关闭明细 srcOrderEntryId]
    D --> W[waitQty = qty - outQty - inQty - closeQty]

修复规则:

  1. 出库量、关闭量按 srcOrderId + srcOrderEntryId 聚合。
  2. 入库量通过 pu_invoice_info.srcOrderEntryId -> po_out_info.id -> po_out_info.srcOrderEntryId 找回采购明细。
  3. 已有有效采购明细 ID 时,不再把 isGift 当作唯一关系条件;同一 isGift 可能有多行。
  4. srcOrderEntryId=0/NULL 的历史入库记录没有可验证的行归属,不在查询层按最小 ID、物料或价格猜测绑定。

3. 多入口请求链路

子模块入口代码链路关键事实
列表查询scm/InvPo/getInvPoInfoInvPo.php::getInvPoInfo -> InvPoService::getInvPoInfo -> InvPoModel::queryInvPoInfo用 waitQty 和 status=5 条件决定列表是否显示
关闭预览scm/InvPo/getInvPoInfoFormatInvPo.php::getInvPoInfoFormat -> InvPoService::getInvPoInfoFormat -> queryInvPoInfo根据选中的采购明细 ID重新计算可关闭数量
提交关闭scm/InvPo/closeInvPoInvPo.php::closeInvPo -> InvPoService::closeInvPo -> 再次调用 queryInvPoInfo先重新校验出库/入库数量,再进入在线退款或挂账关闭
在线关闭金额closeInvPo -> closeOnlineListInvPoService.php::closeOnlineList按实际 closeQty 和折扣/一口价规则重新计算;不直接使用列表原始 amount

4. 代码修改位置

本次最小修复文件:

/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/models/scm/InvPoModel.php

  • queryInvPoInfo():约第 91-205 行;三个数量子查询改为按采购明细 ID连接。
  • 出库和关闭:连接条件为 a.iid = srcOrderId AND a.id = srcOrderEntryId。
  • 入库:先连接 pu_invoice_info 和 po_out_info.id,再使用出库行的 srcOrderEntryId 回到采购明细。
  • 历史零来源:不参与明细级 inQty,避免把数量伪造到某一行。

相关读取和金额代码:

  • /Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/service/scm/InvPoService.php:855-989:关闭预览返回采购明细原始 amount。
  • /Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/service/scm/InvPoService.php:2136-2148:提交关闭前按选中的采购明细重新查询数量。
  • /Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/service/scm/InvPoService.php:2215-2295:按实际 closeQty计算在线关闭金额。

金额要分开理解:

  • 列表的 amount 是原采购行金额,例如原采购数量乘原单价;
  • 本次关闭金额是实际 closeQty 乘关闭单价,再叠加既有折扣/一口价规则;
  • 因此不能用列表原始 amount 直接作为退款或关闭金额。

5. 日志与证据矩阵

证据搜索/查看位置成功信号关联键
列表接口scm/InvPo/getInvPoInfo 返回 JSON明细行、waitQty、outQty、inQty、closeQty 与页面一致iid + detail id
关闭预览scm/InvPo/getInvPoInfoFormat选中的每个明细均返回,can_close_qty 不被同物料其他行影响采购明细 id
关闭提交InvPoService::closeInvPo、closeOnlineList重新查询后校验通过,关闭/退款结果可回读token + detail id
业务页面未发货关闭详情/退款结果页数量、实际关闭金额、状态与接口回读一致采购单号、采购明细 ID
数据库只读核验t_scm_po_order_info_<sid%32>、t_scm_po_out_info_<sid%32>、t_scm_pu_invoice_info_<sid%32>、t_scm_po_close_info精确来源分别落到对应明细;无来源记录不被猜测归属sid + srcOrderId + srcOrderEntryId

当前查询代码没有专门的“聚合成功”审计日志,所以不能只看 HTTP 200;必须同时看接口行数据和真实业务页面/数据库结果。

6. 排查步骤

  1. 固定 sid、采购主单 ID、采购明细 ID,先算出对应分表后缀 sid % 32。
  2. 查采购明细,确认同一 iid 下是否有相同 invId、相同 isGift 的多行。
  3. 查出库明细,确认 srcOrderId 和 srcOrderEntryId 是否指向采购主单/采购明细。
  4. 查入库明细,不要直接把 pu_invoice_info.srcOrderEntryId 当成采购明细 ID;先按 po_out_info.id 中转。
  5. 查关闭明细,确认是否按采购明细 ID记录。
  6. 对每一行分别计算:waitQty = qty - outQty - inQty - closeQty。
  7. 同时验证列表查询、关闭预览和提交关闭前的再次查询,避免只修了列表而提交校验仍使用旧口径。

7. 只读 SQL 骨架

以下仅为排查骨架,执行时必须替换实际分表和参数;生产环境使用只读连接,不执行 UPDATE/DELETE。

-- 1. 采购明细:确认是否同物料多行
SELECT id, iid, invId, isGift, qty, price, amount
FROM t_scm_po_order_info_<sid_mod_32>
WHERE iid = :po_order_id AND isDelete = 0
ORDER BY id;

-- 2. 出库:按采购明细来源核对
SELECT srcOrderId, srcOrderEntryId, SUM(qty) AS out_qty
FROM t_scm_po_out_info_<sid_mod_32>
WHERE sid = :sid AND isDelete = 0 AND billStatus = 0
GROUP BY srcOrderId, srcOrderEntryId;

-- 3. 入库:先确认入库明细指向哪一条出库明细
SELECT p.srcOrderEntryId AS outbound_detail_id,
       o.srcOrderId, o.srcOrderEntryId AS purchase_detail_id,
       SUM(p.qty) AS in_qty
FROM t_scm_pu_invoice_info_<sid_mod_32> p
JOIN t_scm_po_out_info_<sid_mod_32> o ON o.id = p.srcOrderEntryId
WHERE p.sid = :sid AND p.isDelete = 0 AND o.isDelete = 0
GROUP BY p.srcOrderEntryId, o.srcOrderId, o.srcOrderEntryId;

-- 4. 关闭:按采购明细来源核对
SELECT srcOrderId, srcOrderEntryId, SUM(qty) AS close_qty
FROM t_scm_po_close_info
WHERE sid = :sid AND isDelete = 0
GROUP BY srcOrderId, srcOrderEntryId;

8. 验证记录

  • 等价 SQL 回归覆盖:同物料多明细、精确来源、精确来源与历史零来源混合、出库/入库/关闭同时存在等场景。
  • 规划单元测试:python3 -m unittest discover -s doc/ai/tests,18 项通过。
  • 代码检查:git diff --check 通过。
  • 内网目标容器 PHP 7.3 lint 通过,部署文件 SHA-256 为 68af3f944b7ed12b3c1d908e226070d679b0879c02f8aca28995c13db382fe9f。
  • 真实业务回读:两个采购明细分别显示 84、12;关闭金额分别为 840、120,合计 960。
  • 任务包:BUG-20260928-PURCHASE-DETAIL-AGGREGATION 已完成并归档,详细证据位于 DGJ2 仓库 doc/ai/changes/archive/2026/。

9. 失败路径与历史边界

  • 旧逻辑以物料维度汇总后连接多条采购明细,同一数量可能被每行重复扣减,导致有效行被 waitQty > 0 过滤。
  • 不能用 物料 + 价格 或 物料 + isGift 推断历史行归属;这些字段都可能重复。
  • srcOrderEntryId=0/NULL 的历史入库记录暂不参与明细级 inQty。这可能使部分历史单据显示未扣除入库量,但比错误扣到另一条采购明细更安全。
  • 历史零来源数据需要单独审计/补偿规则,不能作为本次查询修复的隐式兜底。
  • 本次修复只改查询,不修改订单、入库、关闭数据;如果页面仍异常,应继续检查数据源关系和部署版本,不要直接改状态或数量。

10. 回滚与文件定位

  • 代码文件:application/models/scm/InvPoModel.php。
  • 当前分支保存的修复提交以 Git 记录为准;部署内网时使用目标文件备份和 rollback.sh,不删除原备份。
  • 若只需恢复代码,回滚本次提交前先确认是否包含其他未发布改动;若需恢复内网文件,使用部署批次目录中的回滚入口并重新执行 PHP lint 和业务回读。