# 物流快递下单、扫码发货与状态同步实施方案

## 一、需求范围

来源：`doc/jykj/软件开发需求功能点.md` 第 34-37 行。

需求包括：

1. 快递下单：产品完成质检并入库后，可一键下快递单、打印快递面单并贴到产品上；支持扫码枪；支持对接 2 个快递渠道。
2. 扫码发货：快递上门取件时，扫码快递面单，快速将产品标记为已发货，并录入快递单信息。
3. 物流状态：支持追踪物流快递明细，展示当前快递实时进度。
4. 状态同步：将已发货信息及快递单号同步到 Shopify 等电商门店；生产中、组装中、海运物流等状态明细同步给 Shopify 供用户查看。

## 二、现有系统基础

### 2.1 发货计划

现有 `DeliveryPlan` 已承担销售订单发货计划职责：

- `app/Model/DeliveryPlan.php`
- `app/Model/DeliveryPlanDetail.php`
- 关联销售订单、发货明细、快递任务。
- 已有 `generateExpressTask()` 可基于发货计划生成 `ExpressTask`。
- 已有 `finish()` 会将发货计划置为完成，并将 `delivery_status` 设置为 `DeliverySet::STATUS_SHIPPED`。

本需求继续复用 `DeliveryPlan` 作为发货计划主对象，不新增一套独立物流单主表。

### 2.2 快递任务

现有 `ExpressTask` 已承担快递下单与面单职责：

- `app/Model/ExpressTask.php`
- `app/Controller/Admin/ExpressTaskController.php`
- 已支持 `service`、`task_id`、`express_com`、`label_url`、`delivery_plan_id`、`sale_order_id`、`status`、`is_print` 等字段。
- 已接入或预留多个快递渠道：`ShipSaving`、`PostPony`、`Uniuni`、`Yuntu`、快递100、快宝。
- 已有快递下单、取消、运费试算、面单打印等接口。

本需求继续复用 `ExpressTask` 作为快递面单和渠道下单对象。

### 2.3 定制履约状态同步

现有 `CustomOrderStageSyncService` 已能根据定制履约状态同步 Shopify 自定义订单状态：

- `app/Service/Common/CustomOrderStageSyncService.php`
- `CUSTOM_ORDER_STAGE` 包含准备中、组装中、质检中、已生产入库、已发货、异常等阶段。
- 当前主要同步 Shopify 订单 metafield。

本需求应在发货节点继续调用该服务，并补充真实物流单号同步能力。

### 2.4 云途物流

已整理云途接口文档并实现基础服务：

- `doc/jykj/云途物流API接口开发规范OMS-20260717.md`
- `app/Service/Third/Express/Realize/Yuntu.php`

云途可作为本需求中的一个重点渠道。第二个渠道可按已有业务账号选择 `ShipSaving`、`Uniuni` 或快递100。

## 三、总体设计原则

1. 发货计划仍由 `delivery_plan` 承载，快递下单仍由 `express_task` 承载。
2. 产品完成质检并入库后，才允许进入可发货状态并生成快递任务。
3. 快递面单生成、打印、扫码发货、物流轨迹查询都围绕 `ExpressTask` 展开。
4. 扫码枪本质是键盘输入，后台接口按条码字符串处理，条码优先匹配 `express_task.task_id`，其次匹配 `express_task.order_id`、`delivery_plan.code`、`delivery_plan.express_num`。
5. 扫码发货必须幂等：已发货的快递任务再次扫码不重复扣库存、不重复推送 Shopify。
6. Shopify 同步分两层：
   - 生产/组装/质检/海运等过程状态：继续同步 `CUSTOM_ORDER_STAGE` 或扩展自定义物流阶段字段。
   - 已发货和快递单号：优先通过 Shopify Fulfillment / Tracking API 同步真实物流信息。
7. 接口全部定义在 Admin 接口组，通过菜单权限、公司权限和操作日志控制，不放在 Open 接口组。

## 四、业务流程

### 4.1 定制产品质检入库后生成可发货计划

前置条件：

- 定制订单已完成组装。
- 质检工序完成。
- 成品已经入库。
- 三个配件库存已在生产入库环节被正确消耗，最终库存是一个成品。

流程：

1. 质检完成入库后，更新 `custom_order_item.status` 为已生产入库。
2. 触发 `CustomOrderStageSyncService::dispatchBySaleOrderId()`，同步 Shopify 为已生产入库。
3. 检查销售订单是否已存在可发货 `DeliveryPlan`。
4. 不存在时，基于销售订单收货信息和成品 SKU 生成 `DeliveryPlan` 与 `DeliveryPlanDetail`。
5. 存在时，更新发货计划明细关联的成品信息和可发货数量。

### 4.2 一键下快递单

操作入口：发货计划列表或定制履约发货页。

流程：

1. 选择待发货的 `DeliveryPlan`。
2. 系统读取订单收货地址、包裹重量、尺寸、渠道配置。
3. 调用 `DeliveryPlan::generateExpressTask()` 生成 `ExpressTask`。
4. 调用渠道服务完成下单：
   - `ExpressTask::pay()` 或 `factory()->sendExpress()`。
   - 云途走 `Yuntu`。
   - 第二渠道按公司配置选择 `ShipSaving`、`Uniuni` 或快递100。
5. 写入 `ExpressTask.task_id`、`express_com`、`label_url`、`price`、`status`。
6. 记录操作日志。

### 4.3 打印面单

流程：

1. 根据 `express_task.id` 批量获取 `label_url`。
2. 复用现有 `ExpressTaskController::printLabel()` 合并面单 PDF。
3. 打印完成后更新 `express_task.is_print = 1`。
4. 前端支持打印后聚焦扫码输入框，便于连续操作。

### 4.4 扫码发货

快递上门取件时，仓库人员使用扫码枪扫描面单条码。

流程：

1. 后台接收 `barcode`。
2. 根据条码查找 `ExpressTask`：
   - 优先 `task_id = barcode`。
   - 其次 `order_id = barcode`。
   - 再匹配 `DeliveryPlan.express_num` 或 `DeliveryPlan.code`。
3. 校验：
   - 快递任务属于当前公司。
   - 快递任务已下单成功。
   - 面单号不为空。
   - 关联发货计划存在。
   - 发货计划未完成。
4. 如需要发货出库，触发现有出库单生成和审核逻辑，扣减成品库存。
5. 更新：
   - `delivery_plan.delivery_status = DeliverySet::STATUS_SHIPPED`
   - `delivery_plan.status = DeliveryPlanSet::STATUS_FINISHED`
   - `sale_order.delivery_status` 按全部/部分发货重新计算。
   - `delivery_plan.express_num` 写入快递单号。
6. 触发 Shopify 同步：
   - 同步 `CUSTOM_ORDER_STAGE = 50` 已发货。
   - 同步 Shopify fulfillment tracking number。
7. 记录扫码发货日志。

幂等规则：

- 如果 `delivery_plan.status` 已完成且 `delivery_status` 已发货，接口直接返回成功，并提示“已发货”。
- 如果 `ExpressTask.task_id` 已同步过 Shopify，不重复创建 fulfillment，只补充同步失败重试任务。

### 4.5 物流轨迹追踪

流程：

1. 用户在后台打开发货计划或订单物流详情。
2. 系统读取 `ExpressTask.task_id`。
3. 调用对应渠道查询接口获取轨迹。
4. 将轨迹明细落库，便于历史查看和异常分析。
5. 定时任务轮询未签收运单，更新最新物流状态。

推荐状态：

| 状态值 | 状态说明 |
| --- | --- |
| `0` | 待下单 |
| `10` | 已下单/已生成面单 |
| `20` | 已揽收 |
| `30` | 运输中 |
| `40` | 派送中 |
| `50` | 已签收 |
| `60` | 投递失败 |
| `80` | 异常 |
| `90` | 已取消 |

### 4.6 Shopify 状态同步

生产履约过程同步：

| 内部节点 | Shopify 状态 |
| --- | --- |
| 订单审核通过，开始备料 | `CUSTOM_ORDER_STAGE = 10` 准备中 |
| 组装开始 | `CUSTOM_ORDER_STAGE = 20` 组装中 |
| 质检开始 | `CUSTOM_ORDER_STAGE = 30` 质检中 |
| 成品质检入库完成 | `CUSTOM_ORDER_STAGE = 40` 已生产入库 |
| 已发货 | `CUSTOM_ORDER_STAGE = 50` 已发货 |
| 海运/国际物流中 | 建议新增物流阶段 metafield 或写入阶段明细 |
| 异常 | `CUSTOM_ORDER_STAGE = 80` 异常 |

发货信息同步：

1. 通过 Shopify Fulfillment API 同步快递单号、物流公司、tracking URL。
2. 如果 Shopify 订单已经存在 fulfillment，则按订单行和 tracking number 做幂等更新。
3. 如果订单来源不是 Shopify，则跳过 Shopify 同步，但保留内部物流状态。
4. 同步失败时记录日志并进入重试队列，不阻塞内部扫码发货。

## 五、数据模型设计

### 5.1 复用表

继续复用：

- `delivery_plan`
- `delivery_plan_detail`
- `express_task`
- `sale_order`
- `sale_order_detail`

### 5.2 `express_task` 建议补充字段

用于提升物流状态和扫码发货的可追踪性。

```sql
ALTER TABLE `express_task`
  ADD COLUMN `tracking_status` tinyint NOT NULL DEFAULT 0 COMMENT '物流轨迹状态：0待下单 10已下单 20已揽收 30运输中 40派送中 50已签收 60投递失败 80异常 90已取消' AFTER `status`,
  ADD COLUMN `tracking_status_title` varchar(64) DEFAULT NULL COMMENT '物流轨迹状态标题' AFTER `tracking_status`,
  ADD COLUMN `tracking_last_time` datetime DEFAULT NULL COMMENT '物流轨迹最后更新时间' AFTER `tracking_status_title`,
  ADD COLUMN `tracking_sync_time` datetime DEFAULT NULL COMMENT '物流轨迹最后同步时间' AFTER `tracking_last_time`,
  ADD COLUMN `shipped_time` datetime DEFAULT NULL COMMENT '扫码确认发货时间' AFTER `tracking_sync_time`,
  ADD COLUMN `scan_admin_id` int DEFAULT NULL COMMENT '扫码发货操作人' AFTER `shipped_time`,
  ADD COLUMN `shopify_fulfillment_id` varchar(128) DEFAULT NULL COMMENT 'Shopify fulfillment id' AFTER `scan_admin_id`,
  ADD COLUMN `shopify_tracking_synced_at` datetime DEFAULT NULL COMMENT 'Shopify物流单号同步时间' AFTER `shopify_fulfillment_id`;
```

说明：

- `task_id` 继续作为快递单号/跟踪号。
- `label_url` 继续保存面单地址。
- `routing_info` 可继续保存渠道下单返回的原始扩展信息。

### 5.3 新增 `express_tracking_log`

用于保存物流轨迹明细。

```sql
CREATE TABLE `express_tracking_log` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `company_id` int NOT NULL DEFAULT 0 COMMENT '公司ID',
  `express_task_id` int NOT NULL DEFAULT 0 COMMENT '快递任务ID',
  `service` varchar(64) DEFAULT NULL COMMENT '快递渠道',
  `tracking_no` varchar(128) NOT NULL DEFAULT '' COMMENT '快递单号',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '轨迹状态',
  `status_title` varchar(64) DEFAULT NULL COMMENT '轨迹状态标题',
  `event_time` datetime DEFAULT NULL COMMENT '物流事件时间',
  `location` varchar(255) DEFAULT NULL COMMENT '物流位置',
  `description` varchar(1024) DEFAULT NULL COMMENT '物流描述',
  `raw_data` json DEFAULT NULL COMMENT '渠道原始数据',
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_company_tracking` (`company_id`, `tracking_no`),
  KEY `idx_express_task` (`express_task_id`),
  KEY `idx_event_time` (`event_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快递物流轨迹明细';
```

### 5.4 可选新增 `express_scan_log`

如果后续需要完整记录重复扫码、异常扫码、人工修正，可新增扫码日志表。

第一阶段也可以先使用 `AdminOperationLog` 记录成功和失败原因，避免过度建表。

## 六、服务设计

### 6.1 `CustomLogisticsFulfillmentService`

建议新增：

```text
app/Service/Common/CustomLogisticsFulfillmentService.php
```

职责：

1. `ensureDeliveryPlanAfterStockIn(int $customOrderItemId)`  
   成品质检入库后确保销售订单有可发货计划。

2. `createExpressTask(int $deliveryPlanId, array $payload)`  
   根据发货计划创建快递任务。

3. `placeExpressOrder(int $expressTaskId, array $payload)`  
   调用具体快递渠道下单。

4. `printLabel(array $expressTaskIds)`  
   复用现有面单打印能力。

5. `scanShip(string $barcode, int $adminId, int $companyId)`  
   扫码确认发货，更新发货状态、触发出库和 Shopify 同步。

6. `syncTracking(int $expressTaskId)`  
   查询并保存物流轨迹。

7. `syncShopifyAfterShip(int $saleOrderId, int $expressTaskId)`  
   同步 Shopify 已发货状态和物流单号。

### 6.2 快递渠道接口补充

现有 `ExpressInterface` 只有：

```php
public function imputedExpress();
public function sendExpress();
public function cancelExpress();
```

建议补充可选能力接口：

```php
interface ExpressTrackableInterface
{
    public function queryTracking(string $trackingNo): array;
}

interface ExpressLabelPrintableInterface
{
    public function printLabelByTrackingNo(string $trackingNo): array;
}
```

已有渠道逐步实现：

- 云途：优先实现下单、面单、轨迹查询。
- ShipSaving / Uniuni / 快递100：按实际账号和 API 能力实现轨迹查询。

### 6.3 Shopify 同步服务补充

建议在 `Shopify` 服务中补充：

```php
public function fulfillOrderWithTracking(
    string $shopifyOrderId,
    string $trackingNo,
    string $trackingCompany,
    ?string $trackingUrl = null,
    array $lineItems = []
): array;
```

并在 `CustomOrderStageSyncService` 之外新增或扩展一个统一同步入口：

```text
app/Service/Common/OrderShipmentSyncService.php
```

职责：

- 同步内部发货状态到第三方订单。
- Shopify 订单走 Fulfillment API。
- 非 Shopify 订单按渠道类型扩展。
- 失败记录日志并可重试。

## 七、Admin 接口设计

所有接口放在 Admin 路由组，受后台登录、公司权限和菜单权限控制。

### 7.1 快递下单

```text
POST /admin/custom-logistics/createExpressTask
POST /admin/custom-logistics/placeExpressOrder
```

也可以直接扩展现有：

```text
POST /admin/express-task/purchase
POST /admin/express-task/kbPlace
POST /admin/express-task/csentOrder
```

建议前端业务入口使用 `custom-logistics`，底层复用 `express-task` 能力。

### 7.2 面单打印

```text
POST /admin/custom-logistics/printLabel
```

底层复用：

```text
POST /admin/express-task/printLabel
```

### 7.3 扫码发货

```text
POST /admin/custom-logistics/scanShip
```

请求示例：

```json
{
  "barcode": "YT1907911202000015"
}
```

响应示例：

```json
{
  "express_task_id": 123,
  "delivery_plan_id": 456,
  "sale_order_id": 789,
  "tracking_no": "YT1907911202000015",
  "delivery_status": 5,
  "already_shipped": false,
  "shopify_sync_status": "queued"
}
```

### 7.4 物流轨迹

```text
GET  /admin/custom-logistics/tracking?id={express_task_id}
POST /admin/custom-logistics/syncTracking
```

### 7.5 Shopify 同步

```text
POST /admin/custom-logistics/syncShopifyShipment
```

用于人工重试发货同步。

## 八、状态流转

### 8.1 发货计划状态

| 节点 | `delivery_plan.status` | `delivery_plan.delivery_status` |
| --- | --- | --- |
| 发货计划创建 | `0` 待处理 | `0` 未发货 |
| 已备货/成品可发 | `40` 备货完成 | `0` 未发货 |
| 生成快递任务 | 保持原状态 | `1` 发货中，可选 |
| 扫码确认交接快递 | `100` 已完成 | `5` 已发货 |
| 物流签收 | `100` 已完成 | `10` 已签收，可选 |

### 8.2 快递任务状态

| 节点 | `express_task.status` | `express_task.tracking_status` |
| --- | --- | --- |
| 快递任务创建 | `0` | `0` 待下单 |
| 渠道下单成功 | `1` | `10` 已下单 |
| 取消面单 | 取消状态按现有逻辑 | `90` 已取消 |
| 扫码发货 | `1` | `20` 已揽收或保持已下单等待轨迹 |
| 物流运输中 | `1` | `30` 运输中 |
| 已签收 | `1` | `50` 已签收 |
| 物流异常 | `1` | `80` 异常 |

### 8.3 Shopify 状态

| 内部事件 | 同步动作 |
| --- | --- |
| 备料开始 | 更新 `CUSTOM_ORDER_STAGE` 为准备中 |
| 组装开始 | 更新 `CUSTOM_ORDER_STAGE` 为组装中 |
| 质检开始 | 更新 `CUSTOM_ORDER_STAGE` 为质检中 |
| 成品入库 | 更新 `CUSTOM_ORDER_STAGE` 为已生产入库 |
| 扫码发货 | 创建/更新 Shopify fulfillment，写入 tracking number，同时更新 `CUSTOM_ORDER_STAGE` 为已发货 |
| 物流异常 | 可更新异常 metafield，不应自动取消 Shopify 订单 |
| 已签收 | 可同步内部物流状态；Shopify 通常不需要重复 fulfillment |

## 九、权限与安全

1. 后台用户必须登录。
2. 使用现有公司隔离逻辑过滤数据范围。
3. 快递下单、取消、扫码发货、Shopify 同步重试需要独立菜单权限。
4. 扫码发货必须记录操作人、操作时间、快递单号、发货计划 ID、销售订单 ID。
5. 导出物流数据时，按角色过滤敏感字段。
6. 供应商后台不直接访问客户原始订单号、SKU 价格等敏感信息。

## 十、定时任务与队列

### 10.1 物流轨迹轮询

新增定时任务：

```text
每 30-60 分钟轮询 express_task.tracking_status < 50 且 status = 1 的运单。
```

处理：

1. 按 `service` 分组。
2. 调用渠道轨迹接口。
3. 写入 `express_tracking_log`。
4. 更新 `express_task.tracking_status`、`tracking_last_time`、`tracking_sync_time`。
5. 异常状态写入日志并可推送后台提醒。

### 10.2 Shopify 同步重试

新增队列任务：

```text
OrderShipmentSyncJob
```

触发时机：

- 扫码发货成功后。
- 手动点击重试。
- 定时扫描同步失败记录。

重试策略：

- 失败后延迟重试。
- 连续失败写入异常日志。
- 不回滚内部发货状态。

## 十一、异常场景

| 场景 | 处理方式 |
| --- | --- |
| 扫码找不到快递任务 | 返回明确错误，记录异常扫码日志 |
| 快递任务未下单成功 | 禁止扫码发货 |
| 面单号为空 | 禁止扫码发货 |
| 发货计划已完成 | 幂等返回成功 |
| 库存不足无法出库 | 阻止发货，提示先处理库存 |
| Shopify 同步失败 | 内部发货成功，Shopify 同步进入重试 |
| 物流轨迹查询失败 | 保留上一次轨迹，记录渠道错误 |
| 物流异常或退回 | 更新 `tracking_status = 80`，订单面板展示异常 |

## 十二、实施步骤

### 第一阶段：复用现有快递下单与打印

1. 梳理现有 `ExpressTask` 下单入口，统一返回字段。
2. 在定制成品质检入库后生成或激活 `DeliveryPlan`。
3. 发货页面支持基于发货计划创建 `ExpressTask`。
4. 发货页面支持调用已有面单打印接口。

### 第二阶段：扫码发货

1. 新增 `CustomLogisticsFulfillmentService::scanShip()`。
2. 新增 Admin 扫码发货接口。
3. 补齐发货计划、销售订单、快递任务状态更新。
4. 接入出库扣库存逻辑。
5. 补齐操作日志和幂等处理。

### 第三阶段：物流轨迹

1. 增加 `express_tracking_log`。
2. 为云途实现轨迹查询。
3. 为第二快递渠道实现轨迹查询。
4. 新增手动同步和定时轮询。
5. 订单面板展示最新物流状态和轨迹明细。

### 第四阶段：Shopify 同步

1. 扩展 Shopify 服务，支持 fulfillment tracking 同步。
2. 扫码发货后触发 `OrderShipmentSyncJob`。
3. 发货成功后同步 `CUSTOM_ORDER_STAGE = 50`。
4. 支持人工重试同步失败订单。

## 十三、验收标准

1. 定制产品质检入库后，后台能看到可发货计划。
2. 发货计划可一键生成快递任务，并能选择至少两个快递渠道下单。
3. 快递下单成功后可打印面单，面单号和面单地址写入 `express_task`。
4. 扫描面单条码后，系统能自动找到发货计划并标记已发货。
5. 重复扫描同一面单不会重复出库、不会重复创建 Shopify fulfillment。
6. 已发货订单能在销售订单面板看到快递单号和物流状态。
7. 物流轨迹能手动同步，也能通过定时任务自动更新。
8. Shopify 订单能看到发货单号；定制状态能同步为已发货。
9. Shopify 同步失败不会影响内部发货，但后台可查看失败原因并手动重试。
10. 所有接口均在 Admin 接口组，并受权限控制。
