# 接口文档：反查 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`。*
