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

36 KiB
Raw Permalink Blame History

接口文档:反查 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 路由分发机制

@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",反射调用同名方法。
  • paramsquery 参数)和 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
  • 通过 insertLogSchedulerLogTrait)包裹执行,自动记录计划任务执行日志。

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

  1. 锁外:快速判断是否需要处理(shouldApplyFetchedTaskDetail),无需处理直接跳过,避免无谓加锁。
  2. 锁内:重新 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

totalQtyquantity 同步保持一致,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. 查出要清的 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 层固定立即返回成功

{ "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 批量校验待上架 UID2.9 协议)
applyUpdatedInboundOrder Shopee 下发入库单变更(2.2 协议)

文档基于源码生成,涉及类:ScheduleApiController / SearchShopeeInboundOrderList / ShopeeInboundService / ReceiptInboundOrderService / EdiRouteReqService