814 lines
36 KiB
Markdown
814 lines
36 KiB
Markdown
# 接口文档:反查 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<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. 查出要清的 UID(status=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` | 上架确认回传 Shopee(2.7 协议) |
|
||
| `checkUidList` | 批量校验待上架 UID(2.9 协议) |
|
||
| `applyUpdatedInboundOrder` | Shopee 下发入库单变更(2.2 协议) |
|
||
|
||
---
|
||
|
||
*文档基于源码生成,涉及类:`ScheduleApiController` / `SearchShopeeInboundOrderList` / `ShopeeInboundService` / `ReceiptInboundOrderService` / `EdiRouteReqService`。*
|