Files
ttx-project-doc/接口文档_searchShopeeInboundOrderList.md
2026-07-10 14:44:54 +08:00

814 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接口文档:反查 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. 涉及类与文件
| 角色 | 文件 |
|------|------|
| ControllerHTTP 入口 + 反射分发) | `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<String, String> 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<String, String> params, Map body) {
Map<String, Object> request = ((params ?: [:]) + (body ?: [:])) as Map<String, Object> // 合并 query + body
String warehouseCode = request.get('warehouseCode') as String
session.params[WmsConstants.CURRENT_WAREHOUSE] = warehouseCode
AisMessage<Map> 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<AisMessage<Map>> implements SchedulerLogTrait {
@Autowired
ShopeeInboundService shopeeInboundService
@Override
void process(AisMessage<Map> msg) {
insertLog(msg.session) {
shopeeInboundService.searchInboundOrderList(msg.session, msg.body as Map<String, Object>)
}
}
}
```
- 监听队列 `wms.api.searchShopeeInboundOrderList`
- 通过 `insertLog``SchedulerLogTrait`)包裹执行,自动记录计划任务执行日志。
---
## 6. 核心业务:ShopeeInboundService
核心业务分两个阶段:**①反查 Shopee**(拿到上游最新任务)+ **②应用到本地 Receipt**(对账并落库)。下面逐方法展开到字段级、SQL 级。
---
### 6.1 searchInboundOrderList —— 单仓 / 多仓分发
```groovy
ResponseMessage searchInboundOrderList(TtxSession session, Map<String, Object> request) {
String warehouseCode = request.warehouseCode as String
if (warehouseCode) {
return fetchAndApplyInboundTasks(session, request) // 单仓
}
// 未指定 → 遍历所有启用仓库(status = 1)
List<Map> whs = whSvc.findMaps([status: 1], ['code'])
List<Map<String, Object>> results = []
for (Map wh : whs) {
String code = wh.code as String
Map warehouseRequest = (request + [warehouseCode: code]) as Map<String, Object>
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<String, Object> 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<String, Object> 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<String> 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": "<warehouseCode>", "task_id_list": ["任务号1", "任务号2", ...] }
```
#### 6.3.1 findUnfinishedReceiptCodes —— 待反查任务的"领取"
从本地未完成的 Shopee 入库单中**领**出一批任务号,核心是 **Redis 分布式锁 + `customInboundSearchNextTime` 节流字段**
```groovy
private List<String> 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<ReceiptDataWrapper>
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<ReceiptDataWrapper>` 泛型解析,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<ReceiptDataWrapper>` 后,进入对账落库阶段。这是最复杂的部分,涉及**三层锁、明细级对账、数量重算、UID 清理、整单删除**。
### 7.1 applyFetchedInboundTaskResult —— 列表层
```groovy
ResponseMessage applyFetchedInboundTaskResult(TtxSession session, Object data,
String warehouseCode, String companyCode) {
List<ReceiptDataWrapper> receipts = convertToReceiptDataWrapperList(data) // List 或单对象统一成列表
if (!receipts) return error('未查询到入库任务')
receipts.each { removeOutboundSerialNumbers(it) } // ★ 数据清洗:过滤已出库 UID
List<Map<String, Object>> 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<ReceiptDetail> localDetails = rdSvc.findEntities([receiptId: receipt.id])
Map<String, ReceiptDetailDataWrapper> incomingMap = buildFetchedTaskDetailMap(receiptData.details)
Map<String, ReceiptDetail> localMap = buildLocalFetchedTaskDetailMap(localDetails)
// ★ 上游有而本地没有的明细 → 直接整体失败(不允许凭空新增明细)
if (incomingMap.keySet().any { key -> !localMap.containsKey(key) }) {
return error(MSG_INBD_0004)
}
// 4. 逐明细对账
List<String> syncedDetailIds = [], deletedDetailIds = []
for (ReceiptDetail localDetail : localDetails) {
ReceiptDetailDataWrapper incoming = incomingMap[fetchedTaskDetailKey(localDetail)]
ResponseMessage<FetchedTaskApplyPlan> 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<FetchedTaskApplyPlan> 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<String> keepSerialNumbers = null) {
// 1. 查出要清的 UIDstatus=CREATED 且 非保留列表)
// keepCondition: 若 keepSerialNumbers 非空,加 "AND serialNumber NOT IN (:keep)"
List<String> 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<ReceiptDataWrapper> (上游返回)
│ 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\<string\> | 否 | 显式指定要反查的任务号列表。**不传**则自动从本地未完成 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<ReceiptDataWrapper>` |
| 边界 | 显式 `taskIdList` 查询且上游返回空 → 视为"未查询到入库任务"错误 |
---
## 10. 配套接口(同模块相关)
| 方法 | 用途 |
|------|------|
| `searchInboundOrderByCode` | 按单个本地入库单号反查并应用(封装成单元素 taskIdList |
| `fetchInboundOrder` | 按搜索码(容器号/单号)向 Shopee 拉单,**只查不存** |
| `requestInboundOrder` | `fetchInboundOrder` + `save`,拉取并落库 |
| `searchInboundOrderListToWms` | 反向:Shopee 查询 WES 本地任务(Shopee 2.3 协议),查本地 `ReceiptHeader/Detail` 返回 |
| `submitPutaway` | 上架确认回传 Shopee2.7 协议) |
| `checkUidList` | 批量校验待上架 UID(2.9 协议) |
| `applyUpdatedInboundOrder` | Shopee 下发入库单变更(2.2 协议) |
---
*文档基于源码生成,涉及类:`ScheduleApiController` / `SearchShopeeInboundOrderList` / `ShopeeInboundService` / `ReceiptInboundOrderService` / `EdiRouteReqService`。*