refactor(outbound): 出库单据与领用物料合并成一张表

这两者本来就是同一件事(这台设备对应 MOM 的哪些出库单、领了哪些料),
却因为粒度不同被拆成两张表、界面上两张卡:用户要面对两个入口两个删除按钮,
还会问「我在那边挂的怎么这边看不见」。更糟的是**单据级那张没有 mom_line_id,
挂上去的料根本报不了废**。

- 新建 product_outbound_materials,统一到**明细级**(只有它带 mom_line_id,
  而报废要用它定位)。单据级信息(申请单号/备注/撤回)作为冗余列落在每条明细上。
  task_id 改为可空 —— 任务只是溯源信息,不再是组织维度,展示/报废/删除按设备走。
- 接口从 7 个收敛成 3 个(GET/POST/DELETE /products/{id}/outbound-materials,
  外加整单删 by-order)。任务级那套连同 TaskResponse.outbound_materials 一起删掉:
  保留第二个入口只会让「同一个东西两个地方」重新长出来。
- MOM 回调存档改为按 outbound_no 去 MOM **现查明细**逐行落 —— 不查的话
  这台设备「领了什么料」永远是空的,也就报不了废。查不到时退化成单据级存档,
  宁可显示「有这张单但看不到明细」,也不要静默丢掉这张单。
- 扫码响应补 outbound_materials(附「谁挂上去的」中文名,服务端解析)。
  ⚠️ 依赖 task_tree_loader 的 selectinload —— 异步 session 下懒加载会
  MissingGreenlet。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent 551819e0e3
commit 5d5aea1015
15 changed files with 2030 additions and 15 deletions

View File

@ -1,5 +1,9 @@
import api from "./api";
import type { ProductScanResponse } from "../types/api";
import type {
ProductOutboundMaterial,
ProductScanResponse,
ProductScrap,
} from "../types/api";
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
@ -8,3 +12,114 @@ export async function scanProduct(serialNumber: string): Promise<ProductScanResp
);
return data;
}
// ============================================================
// 设备的 MOM 出库明细(统一后只有这一组)
//
// 原先这里是两组接口,对应两张表:
// · outbound-orders —— 产品 ↔ 出库**单**(单据级 product_outbounds
// · materials —— 任务 ↔ 出库**明细**(明细级 task_outbound_materials
// 两者是同一个概念,却因为粒度不同被拆开:用户要面对两个入口两张卡,
// 而且走单据级挂的料**没有明细行 id报不了废**。
// 现已合并成一张表、一组接口、界面上只有一张卡。
// ============================================================
/**
* 列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
* 对应后端 GET /api/v1/products/{productId}/outbound-materials
*
* 一行 = 一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
* MOM 回调时查不到明细),能看、能标撤回,但**不能报废**。
*/
export async function getProductOutboundMaterials(
productId: string,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.get<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials`,
);
return data;
}
/**
* 给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
* 对应后端 POST /api/v1/products/{productId}/outbound-materials
*
* ⚠️ 只传 `mom_line_ids`,物料快照由后端现查 MOM —— 前端不传快照。
* ⚠️ `taskId` 可空,仅作溯源(这条料挂在哪条任务上),不参与展示/报废/删除。
* 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
*/
export async function mountProductOutboundMaterials(
productId: string,
momLineIds: number[],
taskId?: string | null,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.post<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials`,
{ mom_line_ids: momLineIds, task_id: taskId || null },
);
return data;
}
/**
* 摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/{materialId}
*
* ⚠️ 只能删 `source='manual'` 的。MOM 回调自动存档的行后端返回 409 ——
* 那是系统事实,要撤得去 MOM 撤回。返回该设备**剩余**的全部出库明细。
*/
export async function removeProductOutboundMaterial(
productId: string,
materialId: number,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.delete<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials/${materialId}`,
);
return data;
}
/**
* 整张出库单一起摘掉(挂错了要能撤)。
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/by-order/{outboundNo}
*
* 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
* 规则与逐条删一致:含 webhook 存档记录的单整单删不掉(后端 409
*/
export async function removeProductOutboundOrder(
productId: string,
outboundNo: string,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.delete<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`,
);
return data;
}
// ============================================================
// 生产报废
// ============================================================
/**
* 列出该设备的报废记录(含实时回查 MOM 的状态与金额)。
* 对应后端 GET /api/v1/products/{productId}/scraps
*/
export async function listProductScraps(productId: string): Promise<ProductScrap[]> {
const { data } = await api.get<ProductScrap[]>(`/products/${productId}/scraps`);
return data;
}
/**
* 提交一条生产报废(领用的料在生产中损坏)。
* 对应后端 POST /api/v1/products/{productId}/scraps
*
* ⚠️ `track_ref` 必须在**打开弹窗时生成一次**并在重试时复用 ——
* 每次提交都换新的话,用户重试会在 MOM 里多报一张报废单。
*/
export async function submitProductScrap(
productId: string,
payload: { mom_line_id: number; quantity: number; track_ref: string; reason?: string | null },
): Promise<ProductScrap> {
const { data } = await api.post<ProductScrap>(
`/products/${productId}/scraps`, payload,
);
return data;
}