36 KiB
接口文档:反查 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 路由分发机制
@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 方法
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
@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 —— 单仓 / 多仓分发
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 —— 反查 + 应用(公共入口)
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 —— 确定要反查哪些任务号
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 请求体:
{ "whs_id": "<warehouseCode>", "task_id_list": ["任务号1", "任务号2", ...] }
6.3.1 findUnfinishedReceiptCodes —— 待反查任务的"领取"
从本地未完成的 Shopee 入库单中领出一批任务号,核心是 Redis 分布式锁 + customInboundSearchNextTime 节流字段:
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 —— 时间范围解析
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
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)
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 —— 列表层
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 被写回:
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 —— 单据级(第二层锁)
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 —— 单据对账主流程
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
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:
- 锁外:快速判断是否需要处理(
shouldApplyFetchedTaskDetail),无需处理直接跳过,避免无谓加锁。 - 锁内:重新
getEntity拉最新数据再算一次,防止加锁前被其它事务改动造成漂移。
这是典型的"先查后锁再查"并发安全模式。
7.4 buildFetchedTaskApplyPlan —— 对账计划(核心数量逻辑)
逐明细计算"应该怎么改",输出 FetchedTaskApplyPlan:
private static class FetchedTaskApplyPlan {
BigDecimal openQty = 0 // 重算后的待上架数量
Boolean remoteDetailMissing = false // 上游该明细是否已不存在
Boolean shouldUpdateQty = false // 是否需要更新数量
Boolean shouldCheckPendingSerialNumbers = false // 是否需要校验/清理 UID
}
构造逻辑:
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)
}
数量计算的两个关键函数:
// 上游未上架数量 = 上游 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 —— 判断数量是否真的变了:
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 —— 是否需要任何处理:
private Boolean shouldApplyFetchedTaskDetail(FetchedTaskApplyPlan plan) {
return plan.remoteDetailMissing || plan.shouldUpdateQty || plan.shouldCheckPendingSerialNumbers
}
三个标志位任一为真才处理;若上游和本地完全一致,明细被跳过,减少无谓写入。
7.5 applyFetchedTaskReturnedDetail —— 上游有该明细:同步
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(一次性重算三个数量字段):
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 —— 上游无该明细:删 / 调
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 —— 判断明细是否"原封未动":
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 —— 整单删除判断
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 —— 删单前清理关联数据(顺序很重要):
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:
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 层固定立即返回成功:
{ "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。