# 接口文档:反查 Shopee 入库任务列表 > 接口地址:`POST /api/wms/schedule/searchShopeeInboundOrderList` > 模块:`wms-schedule`(计划任务入口)/ `wms-api`(异步消费)/ `wms-receiving`(业务处理) > 业务域:Shopee 2.5 WES 反查入库任务 --- ## 1. 功能概述 定时(或手工触发)反向调用 Shopee,按入库任务号查询 Shopee 侧**最新任务状态**,并将结果回写、应用到本地入库单(Receipt)。 适用场景: - 本地存在未完成(未关闭)的 Shopee 入库单,需要周期性反查上游状态(如明细变更、任务被取消等)。 - 多仓库场景:不指定 `warehouseCode` 时,自动遍历所有启用仓库逐仓反查。 底层对接的 Shopee 协议:**`2_5_shopee_inbound_order_searchInboundOrderList`**。 --- ## 2. 调用链(请求流转) ``` HTTP POST /api/wms/schedule/searchShopeeInboundOrderList │ ▼ ScheduleApiController.process() ← 路由分发 通过反射调用方法名 == {type}(即 searchShopeeInboundOrderList) │ ▼ ScheduleApiController.searchShopeeInboundOrderList() ← 组装 AisMessage(异步) msg.queue = "wms.api.searchShopeeInboundOrderList" msgSender.sendMessage(msg) → 同步返回 success │ ▼ (异步消费队列) SearchShopeeInboundOrderList.process() ← @AisConsumer(queue=...) insertLog { shopeeInboundService.searchInboundOrderList(session, body) } │ ▼ ShopeeInboundService.searchInboundOrderList() ← 入口:单仓/多仓分发 │ ├─ 指定 warehouseCode → fetchAndApplyInboundTasks() └─ 未指定 → 遍历所有启用仓库 → fetchAndApplyInboundTasks() │ ▼ fetchInboundTasks() ← 决定要反查哪些 taskId │ ▼ fetchInboundTasksFromShopee() ← 经 EDI 调用 Shopee 2.5 │ ediRouteReqService.routeShopeeReq(SEARCH_INBOUND_ORDER_LIST_API, ...) ▼ applyFetchedInboundTaskResult() ← 结果落库 ReceiptInboundOrderService.applyFetchedInboundTaskResult() ``` > 设计要点:HTTP 层只做"投递异步消息",真正反查与落库在 AIS 消费端执行,避免计划任务 HTTP 请求长时间阻塞。 --- ## 3. 涉及类与文件 | 角色 | 文件 | |------|------| | Controller(HTTP 入口 + 反射分发) | `wms-schedule/.../schedule/controller/ScheduleApiController.groovy` | | AIS 消费者(异步处理) | `wms-api/.../api/endpoint/SearchShopeeInboundOrderList.groovy` | | 业务服务(反查/应用主逻辑) | `wms-receiving/.../receiving/service/shopee/ShopeeInboundService.groovy` | | 落库服务(Receipt 应用) | `wms-receiving/.../receiving/service/ReceiptInboundOrderService.groovy` | | EDI 转发 | `wms-general/.../general/service/EdiRouteReqService.groovy` | --- ## 4. 入口:ScheduleApiController ### 4.1 路由分发机制 ```groovy @RestController @RequestMapping('/api/wms/schedule') class ScheduleApiController extends BaseController { @RequestMapping(value = "{type}") ResponseMessage process(HttpServletRequest request, @PathVariable String type, @RequestParam(required = false) Map params, @RequestBody(required = false) Map body) { // 用 type 作为方法名反射调用:this.getClass().getMethod(type, ...) // 找不到方法 → 返回 MSG_INTF_0003(无法识别的接口操作类型) // 调用异常 → 返回 MSG_GNRL_0000(通用错误) } } ``` - 访问 `/api/wms/schedule/searchShopeeInboundOrderList` 时,`type = "searchShopeeInboundOrderList"`,反射调用同名方法。 - `params`(query 参数)和 `body`(请求体)都会传入。 ### 4.2 searchShopeeInboundOrderList 方法 ```groovy ResponseMessage searchShopeeInboundOrderList(TtxSession session, Map params, Map body) { Map request = ((params ?: [:]) + (body ?: [:])) as Map // 合并 query + body String warehouseCode = request.get('warehouseCode') as String session.params[WmsConstants.CURRENT_WAREHOUSE] = warehouseCode AisMessage msg = new AisMessage<>() msg.session = session msg.msgSubject = warehouseCode msg.queue = "wms.api.searchShopeeInboundOrderList" msg.body = request msgSender.sendMessage(msg) // 异步投递 return ResponseMessageFactory.success() // 立即返回 } ``` > 该层仅完成"参数合并 + 仓库上下文设置 + 异步消息投递",HTTP 调用立即返回成功,不代表业务已处理完成。 --- ## 5. 异步消费:SearchShopeeInboundOrderList ```groovy @AisConsumer(queue = 'wms.api.searchShopeeInboundOrderList') class SearchShopeeInboundOrderList extends AisService> implements SchedulerLogTrait { @Autowired ShopeeInboundService shopeeInboundService @Override void process(AisMessage msg) { insertLog(msg.session) { shopeeInboundService.searchInboundOrderList(msg.session, msg.body as Map) } } } ``` - 监听队列 `wms.api.searchShopeeInboundOrderList`。 - 通过 `insertLog`(`SchedulerLogTrait`)包裹执行,自动记录计划任务执行日志。 --- ## 6. 核心业务:ShopeeInboundService 核心业务分两个阶段:**①反查 Shopee**(拿到上游最新任务)+ **②应用到本地 Receipt**(对账并落库)。下面逐方法展开到字段级、SQL 级。 --- ### 6.1 searchInboundOrderList —— 单仓 / 多仓分发 ```groovy ResponseMessage searchInboundOrderList(TtxSession session, Map request) { String warehouseCode = request.warehouseCode as String if (warehouseCode) { return fetchAndApplyInboundTasks(session, request) // 单仓 } // 未指定 → 遍历所有启用仓库(status = 1) List whs = whSvc.findMaps([status: 1], ['code']) List> results = [] for (Map wh : whs) { String code = wh.code as String Map warehouseRequest = (request + [warehouseCode: code]) as Map session.params[CURRENT_WAREHOUSE] = code // 每仓切换上下文 ResponseMessage rsp = fetchAndApplyInboundTasks(session, warehouseRequest) results << [warehouseCode: code, success: !rsp.hasError(), message: rsp.msg, data: rsp.data] } return ResponseMessageFactory.success(results) } ``` 要点: - 多仓遍历时**任一仓库失败不会中断其它仓**,结果按仓库维度汇总;多仓总响应仍为 `success`,单仓错误体现在 `results[i].success/message`。 - 单仓路径直接返回 `fetchAndApplyInboundTasks` 的结果(成功或失败)。 --- ### 6.2 fetchAndApplyInboundTasks —— 反查 + 应用(公共入口) ```groovy private ResponseMessage fetchAndApplyInboundTasks(TtxSession session, Map request) { ResponseMessage rsp = fetchInboundTasks(session, request) // ① 反查 Shopee if (rsp.hasError()) return rsp return receiptInboundOrderSvc.applyFetchedInboundTaskResult( // ② 应用到本地 session, rsp.data, request.warehouseCode, request.companyCode) } ``` > 反查失败直接短路返回,不进入落库阶段。 --- ### 6.3 fetchInboundTasks —— 确定要反查哪些任务号 ```groovy ResponseMessage fetchInboundTasks(TtxSession session, Map request) { String warehouseCode = request.get('warehouseCode') String companyCode = request.get('companyCode') if (!warehouseCode) return error('warehouseCode不能为空') // 是否显式指定了 taskIdList boolean searchByTaskIds = request.containsKey('taskIdList') && request.get('taskIdList') != null List taskIds = normalizeList(request.get('taskIdList')) // 去重 + 去空 if (searchByTaskIds && !taskIds) return error('taskIdList不能为空') // 未指定 → 从本地未完成 Shopee 入库单中领取一批 if (!taskIds) { Integer months = resolveCreatedWithinMonths(request.get('createdWithinMonths')) // 默认 6 taskIds = findUnfinishedReceiptCodes(warehouseCode, companyCode, 50 /*DEFAULT_CLAIM_TASK_COUNT*/, months) } if (!taskIds) return success([total: 0, results: []]) // 没有可反查的任务 if (taskIds.size() > 2000) return error(WmsMessages.MSG_BASE_0024) // 单次上限 2000 String ediBaseUrl = ediRouteReqService.resolveEdiBaseUrl(session, SHOPEE_EDI_SERVER_IDENTIFIER) Map body = buildInboundTaskSearchRequestBody(warehouseCode, taskIds, request) // {whs_id, task_id_list} return fetchInboundTasksFromShopee(session, companyCode, ediBaseUrl, body, searchByTaskIds) } ``` `searchByTaskIds` 标志位贯穿后续逻辑:它决定"上游返回空"是否视为错误(见 6.5)。 `buildInboundTaskSearchRequestBody` 组装的 Shopee 2.5 请求体: ```json { "whs_id": "", "task_id_list": ["任务号1", "任务号2", ...] } ``` #### 6.3.1 findUnfinishedReceiptCodes —— 待反查任务的"领取" 从本地未完成的 Shopee 入库单中**领**出一批任务号,核心是 **Redis 分布式锁 + `customInboundSearchNextTime` 节流字段**: ```groovy private List findUnfinishedReceiptCodes(String warehouseCode, String companyCode, Integer limit, Integer createdWithinMonths) { RLock lock = RedissonLockService.getAndTryLock( getLockKey(ReceiptHeader.table, 'shopeeInboundSearch', warehouseCode, companyCode ?: '')) if (!lock) return [] // 抢锁失败 → 直接放弃本轮,避免多实例重复领取 LocalDateTime now = LocalDateTime.now() LocalDateTime createdFrom = now.minusMonths(createdWithinMonths) // 选取条件: // warehouseCode = ? [+ companyCode = ? 可选] // AND sourceErp = SHOPEE // AND trailingSts < CLOSED (未关闭) // AND created >= createdFrom (近 N 个月,默认 6) // AND (customInboundSearchNextTime IS NULL OR customInboundSearchNextTime <= now) ← 节流:未到下次反查时间 // 排序: // CASE WHEN customInboundSearchNextTime IS NULL THEN 0 ELSE 1 END ← 从未反查过的优先 // customInboundSearchNextTime, id // LIMIT :limit (默认 50) rows = namedTemplate().queryForList(SQL, params) // 关键:领取后立即把 nextTime 推后,避免下一轮被重复选中 LocalDateTime nextTime = now.plusMinutes(DEFAULT_SEARCH_INTERVAL_MONTHS) // 常量名虽叫 MONTHS,实际 plusMinutes rows.each { rhSvc.updateByCondition([customInboundSearchNextTime: nextTime], [id: it.id]) } return rows.collect { it.code } // 返回入库单号(即 Shopee 任务号) } finally { if (lock) unlock(lock) } ``` 设计要点: - **Redis 锁粒度**:`(表, 'shopeeInboundSearch', warehouseCode, companyCode)`。同仓同货主同一时间只有一个实例在领取,避免同一批单据被多节点并发反查。 - **节流字段 `customInboundSearchNextTime`**:领取即推后下次反查时间,降低单据被高频反查;从未反查过的单据(NULL)优先。 - **抢锁失败即返回空**:当前实例本轮不领取,等下轮再试,保证不重复。 #### 6.3.2 resolveCreatedWithinMonths —— 时间范围解析 ```groovy private Integer resolveCreatedWithinMonths(Object value) { Integer months = value ? value as Integer : 6 // DEFAULT_CREATED_WITHIN_MONTHS return months > 0 ? months : 6 // 非正数回退默认值,避免扫描范围异常 } ``` --- ### 6.4 fetchInboundTasksFromShopee —— 经 EDI 调用 Shopee 2.5 ```groovy private ResponseMessage fetchInboundTasksFromShopee(session, companyCode, ediBaseUrl, requestBody, searchByTaskIds) { logInboundTaskSearchStart(session, requestBody, companyCode) // ProcessHistory: "开始" ResponseMessage remoteRsp = ediRouteReqService.routeShopeeReq( session, '2_5_shopee_inbound_order_searchInboundOrderList', // SEARCH_INBOUND_ORDER_LIST_API requestBody, receiptDataWrapperListType(), // List ediBaseUrl) if (remoteRsp.hasError()) { logInboundTaskSearchResult(session, requestBody, remoteRsp, companyCode, null) // "失败" return remoteRsp } // 关键分支:显式按 taskIdList 查询,但上游无任何返回 → 判定为"任务在上游已不存在" if (searchByTaskIds && !hasRemoteReceiptData(remoteRsp.data)) { ResponseMessage rsp = error('未查询到入库任务') logInboundTaskSearchResult(session, requestBody, rsp, companyCode, remoteRsp.data) return rsp } logInboundTaskSearchResult(session, requestBody, remoteRsp, companyCode, remoteRsp.data) // "完成" return remoteRsp } ``` - 返回类型用 Jackson `List` 泛型解析,EDI 侧返回的 JSON 直接映射成本地领域包装类。 - `searchByTaskIds` 与自动领取的差异:自动领取若上游返回空(任务可能已完成关闭),不报错;显式按 ID 查返回空则视为异常。 #### 6.4.1 日志埋点(logInboundTaskSearchStart / Result) 通过 `ProcessHistoryService.logProcess` 写入处理历史,关键参数: | 项 | 值 | |----|----| | `type` | `WmsConstants.ProcessHistoryType.RECEIPT` | | `action` | `UPDATE` | | `refType` | `'ShopeeInboundOrderSearch'` | | `refId` | warehouseCode | | `companyCode` | 货主 | | `count` | `task_id_list.size()` | | `result` | 成功为返回总数,失败为错误信息 | | `level` | 成功 INFO / 失败 ERROR | 日志消息模板: - 开始:`Shopee反查入库任务开始,仓库:{0},货主:{1},任务号数量:{2}` - 成功:`Shopee反查入库任务完成,仓库:{0},货主:{1},任务号数量:{2},返回总数:{3}` - 失败:`Shopee反查入库任务失败,仓库:{0},货主:{1},任务号数量:{2},错误信息:{3}` > 日志写入用 try-catch 包裹,**日志失败绝不影响反查主流程**。 #### 6.4.2 EDI 转发(EdiRouteReqService.routeShopeeReq) ```groovy static final String SHOPEE_EDI_SERVER_IDENTIFIER = 'shopee-edi-wms' static final String SHOPEE_ROUTE_REQ_PATH = 'api/edi/shopee/routeReq' static final String SHOPEE_EDI_CUSTOMER_ID = 'shopee' ResponseMessage routeShopeeReq(session, api, requestBody, JavaType dataType, baseUrl) { return routeReq(session, SHOPEE_EDI_SERVER_IDENTIFIER, SHOPEE_ROUTE_REQ_PATH, api, requestBody, SHOPEE_EDI_CUSTOMER_ID, baseUrl, dataType) } ``` WES 只负责把请求体交给 EDI 的 `api/edi/shopee/routeReq`,由 EDI 完成 Shopee 平台协议转换、签名、HTTP 转发和响应解析。 --- ## 7. 结果落库:ReceiptInboundOrderService 反查拿到 `List` 后,进入对账落库阶段。这是最复杂的部分,涉及**三层锁、明细级对账、数量重算、UID 清理、整单删除**。 ### 7.1 applyFetchedInboundTaskResult —— 列表层 ```groovy ResponseMessage applyFetchedInboundTaskResult(TtxSession session, Object data, String warehouseCode, String companyCode) { List receipts = convertToReceiptDataWrapperList(data) // List 或单对象统一成列表 if (!receipts) return error('未查询到入库任务') receipts.each { removeOutboundSerialNumbers(it) } // ★ 数据清洗:过滤已出库 UID List> results = [] StringBuilder errors = new StringBuilder() for (ReceiptDataWrapper receipt : receipts) { ReceiptHeader header = receipt?.header?.receiptHeader fillFetchedInboundTaskHeaderDefaults(header, warehouseCode, companyCode) // 补默认仓/货主 ResponseMessage rsp = applyFetchedInboundTaskReceipt(session, receipt) // 单据级应用 results << [code: header?.code, success: !rsp.hasError(), message: rsp.msg] if (rsp.hasError()) errors.append(rsp.msg) } if (errors) return error(errors.toString()) // 任一单据失败 → 整体失败,错误聚合 return success([total: receipts.size(), results: results]) } ``` #### 7.1.1 removeOutboundSerialNumbers —— UID 清洗 递归过滤掉状态为 `OUTBOUND`(已出库)的 UID,避免上游回传或本地缓存的脏 UID 被写回: ```groovy private void removeOutboundSerialNumbers(ReceiptDataWrapper receipt) { receipt?.serialNumbers = filterAvailable(receipt?.serialNumbers) receipt?.details?.each { detail -> detail.serialNumbers = filterAvailable(detail?.serialNumbers) detail.containers?.each { container -> container.serialNumbers = filterAvailable(container?.serialNumbers) // 头/明细/容器三层全清 } } } // filterAvailable: findAll { it.status != SerialNumberStatus.OUTBOUND } ``` > 与列表层不同:多单据中**任一单据失败会导致整批返回 error**(错误信息拼接)。这是与 6.1 多仓遍历"单仓失败不影响其它仓"的显著区别。 --- ### 7.2 applyFetchedInboundTaskReceipt —— 单据级(第二层锁) ```groovy private ResponseMessage applyFetchedInboundTaskReceipt(TtxSession session, ReceiptDataWrapper receiptData) { ReceiptHeader h = receiptData?.header?.receiptHeader if (!h?.warehouseCode || !h.companyCode || !h.code) return error(INVALID_MESSAGE_BODY) RLock lock = null try { lock = rhSvc.tryLockByCode(h.warehouseCode, h.companyCode, h.code) // ★ 按单号锁 if (!lock) return error(WmsMessages.MSG_GNRL_0003) // 锁占用 → 失败 return doApplyFetchedInboundTaskReceipt(session, receiptData, h) } finally { if (lock) unlock(lock) } } ``` 锁粒度:`(warehouseCode, companyCode, code)`,即**按入库单号串行化**,防止同一单据被并发反查/收货/上架交叉修改。 --- ### 7.3 doApplyFetchedInboundTaskReceipt —— 单据对账主流程 ```groovy private ResponseMessage doApplyFetchedInboundTaskReceipt(session, receiptData, incomingHeader) { // 1. 定位本地入库单 Map cv = [warehouseCode: incomingHeader.warehouseCode, code: incomingHeader.code] if (incomingHeader.companyCode) cv.companyCode = incomingHeader.companyCode ReceiptHeader receipt = rhSvc.findFirstEntity(cv) if (!receipt) return error(MSG_INBD_0003) // 本地无此单 // 2. 可用性校验(如是否被锁定/作废) ResponseMessage availableRsp = rhSvc.checkIsAvailable(session, receipt) if (availableRsp.hasError()) return availableRsp // 3. 加载本地明细 + 上游明细,按键匹配 List localDetails = rdSvc.findEntities([receiptId: receipt.id]) Map incomingMap = buildFetchedTaskDetailMap(receiptData.details) Map localMap = buildLocalFetchedTaskDetailMap(localDetails) // ★ 上游有而本地没有的明细 → 直接整体失败(不允许凭空新增明细) if (incomingMap.keySet().any { key -> !localMap.containsKey(key) }) { return error(MSG_INBD_0004) } // 4. 逐明细对账 List syncedDetailIds = [], deletedDetailIds = [] for (ReceiptDetail localDetail : localDetails) { ReceiptDetailDataWrapper incoming = incomingMap[fetchedTaskDetailKey(localDetail)] ResponseMessage planRsp = buildFetchedTaskApplyPlan(receipt, localDetail, incoming) if (planRsp.hasError()) return planRsp if (!shouldApplyFetchedTaskDetail(planRsp.data)) continue // 无变化跳过 // ★ 第三层锁:明细级 RLock detailLock = getAndTryLock(rdSvc.getLockKey(session, localDetail.id)) if (!detailLock) return error(MSG_GNRL_0003) try { // 锁内重新加载,避免并发漂移(双重检查) localDetail = rdSvc.getEntity(localDetail.id) incoming = incomingMap[fetchedTaskDetailKey(localDetail)] planRsp = buildFetchedTaskApplyPlan(receipt, localDetail, incoming) if (planRsp.hasError()) return planRsp if (!shouldApplyFetchedTaskDetail(planRsp.data)) continue if (incoming?.receiptDetail) { applyFetchedTaskReturnedDetail(receipt, localDetail, incoming, planRsp.data) // 上游有 → 同步 syncedDetailIds << localDetail.id } else { ResponseMessage rsp = applyFetchedTaskMissingDetail(receipt, localDetail, planRsp.data) // 上游无 → 删/调 if (rsp.hasError()) return rsp if ((rsp.data as Map)?.deleted) deletedDetailIds << localDetail.id } } finally { if (detailLock) unlock(detailLock) } } // 5. 整单是否应删除(所有明细都没了) if (shouldDeleteFetchedTaskReceipt(receipt.id)) { deleteFetchedTaskReceiptRelatedData(receipt.id) receipt = rhSvc.getEntity(receipt.id) ResponseMessage rsp = rhSvc.deleteEntity(receipt) if (rsp.hasError()) return rsp return success([deleted: true, syncedDetailIds: syncedDetailIds, deletedDetailIds: deletedDetailIds]) } // 6. 刷新单据状态 + 统计 rhSvc.updateStatus(session, receipt.id) rhSvc.updateStatistics(session, receipt.id) return success([syncedDetailIds: syncedDetailIds, deletedDetailIds: deletedDetailIds]) } ``` #### 7.3.1 明细匹配键 fetchedTaskDetailKey ```groovy private String fetchedTaskDetailKey(ReceiptDetail detail) { return detail?.itemCode ? "${detail.itemCode}|${detail.batch ?: ''}" : null } ``` - **匹配维度 = itemCode + batch**。同一 itemCode + batch 视为同一明细行。 - map 构建时 `if (!result.containsKey(key))` 保证**首次出现优先**,重复键忽略。 #### 7.3.2 双重检查(锁外 + 锁内) 明细对账执行了两次 `buildFetchedTaskApplyPlan`: 1. **锁外**:快速判断是否需要处理(`shouldApplyFetchedTaskDetail`),无需处理直接跳过,避免无谓加锁。 2. **锁内**:重新 `getEntity` 拉最新数据再算一次,防止加锁前被其它事务改动造成漂移。 这是典型的"先查后锁再查"并发安全模式。 --- ### 7.4 buildFetchedTaskApplyPlan —— 对账计划(核心数量逻辑) 逐明细计算"应该怎么改",输出 `FetchedTaskApplyPlan`: ```groovy private static class FetchedTaskApplyPlan { BigDecimal openQty = 0 // 重算后的待上架数量 Boolean remoteDetailMissing = false // 上游该明细是否已不存在 Boolean shouldUpdateQty = false // 是否需要更新数量 Boolean shouldCheckPendingSerialNumbers = false // 是否需要校验/清理 UID } ``` 构造逻辑: ```groovy private ResponseMessage buildFetchedTaskApplyPlan( ReceiptHeader localHeader, ReceiptDetail localDetail, ReceiptDetailDataWrapper incoming) { // 分支A:上游该明细已不存在 if (!incoming?.receiptDetail) { FetchedTaskApplyPlan plan = new FetchedTaskApplyPlan( openQty: 0, remoteDetailMissing: true, shouldUpdateQty: shouldUpdateFetchedTaskDetailQty(localDetail, plan_with_openQty_0) ) return success(plan) } // 分支B:上游有该明细 → 算数量差额 BigDecimal remoteUnputawayQty = resolveRemoteUnputawayQty(incoming) // 上游"未上架数量" BigDecimal localCheckedInNotPutawayQty = resolveLocalCheckedInNotPutawayQty(localDetail) // 本地"已收货未上架" // ★ 核心保护:上游未上架数 < 本地已收货未上架数 → 数据矛盾,拒绝 if (remoteUnputawayQty < localCheckedInNotPutawayQty) { return error(MSG_INBD_0040) } FetchedTaskApplyPlan plan = new FetchedTaskApplyPlan() plan.openQty = remoteUnputawayQty - localCheckedInNotPutawayQty // 新的待上架量 plan.shouldUpdateQty = shouldUpdateFetchedTaskDetailQty(localDetail, plan) // 指定UID模式(customSpecifyUid=1) 且上游带回了 UID 列表 → 需要校验 plan.shouldCheckPendingSerialNumbers = (localHeader.customSpecifyUid == 1 && incoming?.serialNumbers != null) return success(plan) } ``` 数量计算的两个关键函数: ```groovy // 上游未上架数量 = 上游 totalQty - 上游 fulfillQty(已上架),负数归 0 private BigDecimal resolveRemoteUnputawayQty(ReceiptDetailDataWrapper incoming) { BigDecimal remotePutawayQty = toBigDecimal(incoming.receiptDetail.fulfillQty) BigDecimal remoteUnputawayQty = toBigDecimal(incoming.receiptDetail.totalQty) - remotePutawayQty return remoteUnputawayQty < 0 ? 0 : remoteUnputawayQty } // 本地已收货未上架数量 = 本地 fulfillQty(已收货) - 已关闭容器数量(已上架),负数归 0 private BigDecimal resolveLocalCheckedInNotPutawayQty(ReceiptDetail localDetail) { BigDecimal checkedInQty = toBigDecimal(localDetail.fulfillQty) BigDecimal putawayQty = findClosedReceiptContainerQty(localDetail.id) // sum(receipt_container.quantity where status=CLOSED) BigDecimal notPutawayQty = checkedInQty - putawayQty return notPutawayQty < 0 ? 0 : notPutawayQty } ``` `shouldUpdateFetchedTaskDetailQty` —— 判断数量是否真的变了: ```groovy private Boolean shouldUpdateFetchedTaskDetailQty(ReceiptDetail localDetail, FetchedTaskApplyPlan plan) { // 期望的 totalQty = 已收货 + 已拒收 + 新算出的待上架量 BigDecimal expectedQty = localDetail.fulfillQty + localDetail.rejectedQty + plan.openQty // totalQty 或 openQty 任一不一致 → 需要更新 return !sameQty(localDetail.totalQty, expectedQty) || !sameQty(localDetail.openQty, plan.openQty) } ``` `shouldApplyFetchedTaskDetail` —— 是否需要任何处理: ```groovy private Boolean shouldApplyFetchedTaskDetail(FetchedTaskApplyPlan plan) { return plan.remoteDetailMissing || plan.shouldUpdateQty || plan.shouldCheckPendingSerialNumbers } ``` > 三个标志位任一为真才处理;若上游和本地完全一致,明细被跳过,减少无谓写入。 --- ### 7.5 applyFetchedTaskReturnedDetail —— 上游有该明细:同步 ```groovy private void applyFetchedTaskReturnedDetail(receipt, localDetail, incoming, plan) { if (plan.shouldUpdateQty) updateFetchedTaskDetailQty(localDetail.id, plan.openQty) // 改数量 if (plan.shouldCheckPendingSerialNumbers) { clearFetchedTaskPendingSerialNumbers(receipt, localDetail, collectSerialNumbers(incoming.serialNumbers)) // 清理本地"待分配(CREATED)"状态的 UID,保留上游回传的 UID 列表 } } ``` `updateFetchedTaskDetailQty` 的 SQL(一次性重算三个数量字段): ```sql update receipt_detail set totalQty = coalesce(fulfillQty,0) + coalesce(rejectedQty,0) + :openQty, quantity = coalesce(fulfillQty,0) + coalesce(rejectedQty,0) + :openQty, openQty = :openQty, version = version + 1 where id = :receiptDetailId ``` > `totalQty` 与 `quantity` 同步保持一致,`openQty` 为新的待上架量,版本号自增防并发。 --- ### 7.6 applyFetchedTaskMissingDetail —— 上游无该明细:删 / 调 ```groovy private ResponseMessage applyFetchedTaskMissingDetail(receipt, localDetail, plan) { if (isFetchedTaskDetailUnworked(localDetail)) { // 该明细"完全没动过"? clearFetchedTaskPendingSerialNumbers(receipt, localDetail) // 先清 UID ResponseMessage rsp = rdSvc.deleteEntity(localDetail) // 再删明细 if (rsp.hasError()) return rsp return success([deleted: true]) } // 已动过(收过货/有容器)→ 不能删,只能把 openQty 清零对齐上游 if (plan.shouldUpdateQty) updateFetchedTaskDetailQty(localDetail.id, plan.openQty) // openQty=0 return success([deleted: false]) } ``` `isFetchedTaskDetailUnworked` —— 判断明细是否"原封未动": ```groovy private Boolean isFetchedTaskDetailUnworked(ReceiptDetail d) { return sameQty(d.fulfillQty, 0) // 未收货 && sameQty(d.totalQty, toBigDecimal(d.openQty)) // totalQty == openQty(没收过也没拒过) && !existsReceiptContainer(d.receiptId, d.id) // 没有任何收货容器记录 } ``` > 设计意图:上游把明细删了,本地若完全没处理过就跟着删;若已经收过货(有容器/有数量),只能把待上架量清零,保留已收货事实,避免库存凭空消失。 --- ### 7.7 shouldDeleteFetchedTaskReceipt —— 整单删除判断 ```groovy private Boolean shouldDeleteFetchedTaskReceipt(Long receiptId) { Integer count = template().queryForObject( 'SELECT COUNT(1) FROM receipt_detail WHERE receiptId = ?', Integer.class, receiptId) ?: 0 return count == 0 // 明细全被删光 → 整单也删 } ``` `deleteFetchedTaskReceiptRelatedData` —— 删单前清理关联数据(顺序很重要): ```sql delete from receipt_container where receiptId = ?; -- 收货容器 delete from serial_number where receiptId = ?; -- 序列号 -- 池子里的不删记录,只解绑: update serial_number_pool set receiptId = 0, receiptDetailId = 0, receiptCode = null, assignedAt = null, assignedBy = null, version = version + 1 where receiptId = ?; ``` > 容器、UID 先删,再删单头(`rhSvc.deleteEntity`)。`serial_number_pool` 采用**解绑而非删除**,保留池中 UID 供复用。 --- ### 7.8 clearFetchedTaskPendingSerialNumbers —— UID 清理 清理本地 `CREATED`(待分配)状态的 UID,可选保留上游回传的 UID: ```groovy private void clearFetchedTaskPendingSerialNumbers(receipt, localDetail, List keepSerialNumbers = null) { // 1. 查出要清的 UID(status=CREATED 且 非保留列表) // keepCondition: 若 keepSerialNumbers 非空,加 "AND serialNumber NOT IN (:keep)" List clearSerialNumbers = query(...) // 2. 物理删除 delete from serial_number where receiptId = :receiptId and itemCode = :itemCode and status = :createdStatus and serialNumber in (:clearSerialNumbers) } ``` 只删 `CREATED` 状态,**不影响已收货/已上架的 UID**。 --- ### 7.9 三层锁总览 | 层级 | 锁键 | 作用 | |------|------|------| | ① 任务领取 | `(receipt_header表, 'shopeeInboundSearch', whsCode, companyCode)` | 防多实例并发领取同一批单据 | | ② 单据级 | `(warehouseCode, companyCode, code)` via `rhSvc.tryLockByCode` | 防同一入库单被并发反查/收货/上架 | | ③ 明细级 | `rdSvc.getLockKey(session, detailId)` | 防同一明细被并发修改;锁内二次查证 | 任一层抢锁失败都直接返回 `MSG_GNRL_0003`(资源占用),不再继续。 --- ### 7.10 落库结果数据流总结 ``` List (上游返回) │ removeOutboundSerialNumbers 清洗 ▼ for each receipt: ├─ tryLockByCode (单据锁) │ ├─ findFirstEntity → 本地无单? → MSG_INBD_0003 │ ├─ checkIsAvailable → 不可用? → 错误 │ ├─ 明细键匹配 → 上游多余明细? → MSG_INBD_0004 │ └─ for each localDetail: │ ├─ buildPlan (锁外快判) → 无变化? continue │ ├─ getAndTryLock (明细锁) → 抢不到? → MSG_GNRL_0003 │ │ ├─ getEntity 重载 + buildPlan (锁内复判) │ │ ├─ 上游有 → applyReturnedDetail (改数量/清UID) → syncedDetailIds │ │ └─ 上游无 → applyMissingDetail (删明细 or openQty=0) → deletedDetailIds? │ └─ unlock ├─ 明细全删? → deleteFetchedTaskReceiptRelatedData + deleteEntity → deleted:true └─ else → updateStatus + updateStatistics → synced/deleted ids ``` --- ## 8. 请求 / 响应 ### 8.1 HTTP 请求 `POST /api/wms/schedule/searchShopeeInboundOrderList` 请求体(JSON,query 参数同名亦可): | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `warehouseCode` | string | 否 | 仓库编码。**为空时遍历所有启用仓库**;指定时只处理单仓 | | `companyCode` | string | 否 | 货主编码。用于过滤未完成任务领取范围 | | `taskIdList` | array\ | 否 | 显式指定要反查的任务号列表。**不传**则自动从本地未完成 Shopee 入库单中领取一批(默认 50 条) | | `createdWithinMonths` | integer | 否 | 自动领取时只扫近 N 个月的单据,默认 6,非正数回退到 6 | > 常见调用形态: > - 定时任务:不传 `taskIdList`,由系统按节流策略自动领取。 > - 手工反查:传 `taskIdList` 精确反查某些任务号。 ### 8.2 HTTP 响应 由于 Controller 仅投递异步消息,**HTTP 层固定立即返回成功**: ```json { "success": true, "code": "...", "msg": "..." } ``` > 实际反查/落库结果不会通过此 HTTP 响应返回,需通过 `ProcessHistory`(处理历史日志)或队列消费结果观察。日志类型 `ShopeeInboundOrderSearch`,级别 INFO/ERROR,内容包含仓库、货主、任务号数量、返回总数或错误信息。 ### 8.3 业务层(消费端)返回结构(参考) - 单仓成功:`applyFetchedInboundTaskResult` 返回 `{ total, results: [{code, success, message}] }` - 多仓成功:`searchInboundOrderList` 返回 `[{warehouseCode, success, message, data}, ...]` --- ## 9. 关键设计点与约束 | 项 | 说明 | |----|------| | 异步化 | HTTP 入口只投递 `AisMessage`,重逻辑在 `wms.api.searchShopeeInboundOrderList` 队列消费端执行,避免计划任务超时 | | 单次任务上限 | `MAX_SEARCH_TASK_COUNT = 2000`,超出返回 `MSG_BASE_0024` | | 默认领取数 | `DEFAULT_CLAIM_TASK_COUNT = 50`(未指定 taskIdList 时每轮领取条数) | | 反查节流 | 通过 `ReceiptHeader.customInboundSearchNextTime` 实现;领取后推进下次时间,降低重复反查 | | 并发控制 | 三层锁:领取阶段 Redis 锁(按仓库+货主);应用阶段按入库单号锁;明细应用阶段按明细锁 | | 数据来源 | `sourceErp = SHOPEE` 过滤;仅处理 Shopee 来源单据 | | 数据清洗 | 落库前 `removeOutboundSerialNumbers` 过滤已出库 UID,避免脏数据回写 | | 容错 | 反查过程日志失败不影响主流程(`logInboundTaskSearchProcess` 内部 catch Throwable) | | 上游协议 | Shopee `2_5_shopee_inbound_order_searchInboundOrderList`;请求体 `{whs_id, task_id_list}`;返回 `List` | | 边界 | 显式 `taskIdList` 查询且上游返回空 → 视为"未查询到入库任务"错误 | --- ## 10. 配套接口(同模块相关) | 方法 | 用途 | |------|------| | `searchInboundOrderByCode` | 按单个本地入库单号反查并应用(封装成单元素 taskIdList) | | `fetchInboundOrder` | 按搜索码(容器号/单号)向 Shopee 拉单,**只查不存** | | `requestInboundOrder` | `fetchInboundOrder` + `save`,拉取并落库 | | `searchInboundOrderListToWms` | 反向:Shopee 查询 WES 本地任务(Shopee 2.3 协议),查本地 `ReceiptHeader/Detail` 返回 | | `submitPutaway` | 上架确认回传 Shopee(2.7 协议) | | `checkUidList` | 批量校验待上架 UID(2.9 协议) | | `applyUpdatedInboundOrder` | Shopee 下发入库单变更(2.2 协议) | --- *文档基于源码生成,涉及类:`ScheduleApiController` / `SearchShopeeInboundOrderList` / `ShopeeInboundService` / `ReceiptInboundOrderService` / `EdiRouteReqService`。*