# Shipment API 文档

出货管理模块 API 文档，包含出货单和销售品相关接口。

## 目录

- [查询出货单](#查询出货单)
- [出货单状态流转接口](./shipment_status_api.md)
- [销售品删除接口](./sales_item_delete_api.md)
- [创建出货单](#创建出货单)
- [查询有待出货销售品的客户](#查询有待出货销售品的客户)
- [按客户查询销售品](#按客户查询销售品)
- [查询销售品详情](#查询销售品详情)
- [通过生产订单查询销售品](#通过生产订单查询销售品)

---

## 查询出货单

### 出货单列表

- **URL**: `/api/v1/shipment/shipments/`
- **Method**: `GET`
- **认证**: 需要登录（JWT Token）

#### 查询参数（可选）

| 参数 | 类型 | 说明 |
|------|------|------|
| limit | int | 分页大小（LimitOffsetPagination） |
| offset | int | 偏移量 |
| customer | int | 客户ID |
| status | int | 状态（1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核） |
| delivery_id | int | 送货单ID。仅当传入具体ID时过滤；传 `null`、`None`、空字符串时不触发过滤 |
| delivery_isnull | bool | 是否按送货单是否为空过滤。传 `true/1/yes` 仅返回 `delivery_id = null`；传 `false/0/no` 仅返回 `delivery_id != null` |
| external_id | string | 外部订单号（精确匹配） |
| shipment_date_from | string | 出货日期起始（YYYY-MM-DD） |
| shipment_date_to | string | 出货日期结束（YYYY-MM-DD） |

#### 响应格式

使用 LimitOffsetPagination：

```json
{
  "count": 2,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 1,
      "merchant_id": 1,
      "merchant_name": "测试印花厂",
      "customer": 1,
      "customer_name": "客户A",
      "fabric": "棉布 40S",
      "order_description": "滚筒预警",
      "shipment_date": "2026-01-14",
      "status": 1,
      "status_display": "草稿(未发布)",
      "delivery_id": null,
      "status_modified_at": null,
      "cancelled_by_id": null,
      "cancelled_by_name": null,
      "approved_by_id": null,
      "approved_by_name": null,
      "remark": "备注信息",
      "items_count": 3,
      "sales_items": [],
      "external_finished_products": [],
      "created_by_id": 1,
      "created_by_name": "张三",
      "created_at": "2026-01-14T10:00:00Z",
      "updated_at": "2026-01-14T10:00:00Z"
    }
  ]
}
```

说明：

- `sales_items` 现在返回的是带图片字段的销售品详情结构
- `delivery_id` 始终返回送货单ID；未绑定送货单时为 `null`
- `fabric` 来自该出货单所关联销售品对应生产订单的 `fabric`；若当前出货单没有销售品，则返回 `null`
- `order_description` 当前返回该出货单所关联销售品对应生产订单的 `rolling_warn`；若当前出货单没有销售品，则返回 `null`
- `delivery_id=null` 不会触发过滤；如需按是否绑定送货单筛选，请使用 `delivery_isnull`
- 若销售品关联的 `PrintingJob.product` 存在主图，则会返回 `product_image_url`
- 若无关联图片，则 `product_image_url` 为 `null`
- 若出货单已经被驳回，则其销售品会在驳回时被自动解绑，因此此处的 `sales_items` 会变为空数组

### 出货单详情

- **URL**: `/api/v1/shipment/shipments/<id>/`
- **Method**: `GET`
- **认证**: 需要登录（JWT Token）

#### 查询参数（可选）

| 参数 | 类型 | 说明 |
|------|------|------|
| status | int | 可选状态过滤；取值为 1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核。传入后仅当该出货单状态匹配时才返回详情 |

说明：

- 详情响应同样始终包含 `delivery_id`；未绑定送货单时返回 `null`
- 详情中的 `sales_items` 同样返回带 `product_image_url` 的销售品详情结构

---

### 更新出货单

- **URL**: `/api/v1/shipment/shipments/<id>/`
- **Method**: `PATCH` / `PUT`
- **认证**: 需要登录（JWT Token）

说明：

- 仅允许修改业务数据字段：`customer`、`shipment_date`、`address`、`contact_name`、`contact_phone`、`area`、`remark`、`external_id`
- 该接口不允许修改 `status`
- 仅“未关联送货单”的出货单允许修改
- 一旦出货单已关联送货单，会返回 `400 Bad Request`

请求体示例：

```json
{
  "address": "宁波市滨海路 9 号",
  "contact_name": "王五",
  "contact_phone": "13700137000",
  "area": "更新地区",
  "remark": "更新备注"
}
```

错误示例：

```json
{
  "detail": "已关联送货单的出货单不允许修改"
}
```

---

## 创建出货单

创建出货单并关联销售品。

业务规则补充：

- 当 `sales_items` 非空时，所有销售品都必须关联有效的 `printing_job`
- 并且这些销售品必须全部来自同一个 `printing_order`
- 如果存在缺少生产任务、生产任务不存在、或跨生产订单混装，接口会拒绝创建
- 新创建的出货单默认状态为 `草稿(未发布)`
- 创建成功后会触发 `shipment_created` 领域信号，并通过异步 Celery task 发送企业微信通知

### 接口信息

- **URL**: `/api/v1/shipment/shipments/`
- **Method**: `POST`
- **认证**: 需要登录（JWT Token）

### 请求参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| customer | int | 是 | 客户ID |
| shipment_date | string | 是 | 出货日期（YYYY-MM-DD） |
| address | string | 否 | 地址（可空字符串，长度<=255） |
| contact_name | string | 否 | 联系人（可空字符串，长度<=100） |
| contact_phone | string | 否 | 联系电话（可空字符串，长度<=50） |
| area | string | 否 | 出货地区（可空字符串，长度<=30） |
| remark | string | 否 | 备注 |
| sales_items | array[int] | 否 | 要关联的销售品ID列表 |

### 请求示例

```json
{
    "customer": 1,
    "shipment_date": "2026-01-14",
    "address": "杭州市测试路 1 号",
    "contact_name": "张三",
    "contact_phone": "13800138000",
    "area": "华东",
    "remark": "备注信息",
    "sales_items": [1, 2, 3]
}
```

### 响应格式

```json
{
    "id": 1,
    "merchant_id": 1,
    "merchant_name": "测试印花厂",
    "customer": 1,
    "customer_name": "客户A",
    "shipment_date": "2026-01-14",
    "address": "杭州市测试路 1 号",
    "contact_name": "张三",
    "contact_phone": "13800138000",
    "area": "华东",
    "remark": "备注信息",
    "status": 1,
    "status_display": "草稿(未发布)",
    "delivery_id": null,
    "status_modified_at": null,
    "cancelled_by_id": null,
    "cancelled_by_name": null,
    "approved_by_id": null,
    "approved_by_name": null,
    "items_count": 3,
    "sales_items": [],
    "external_finished_products": [],
    "created_by_id": 1,
    "created_by_name": "张三",
    "created_at": "2026-01-14T10:00:00Z",
    "updated_at": "2026-01-14T10:00:00Z"
}
```

### 响应字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 出货单ID |
| merchant_id | int | 所属商户ID（从当前登录用户推导） |
| merchant_name | string | 所属商户名称（从当前登录用户推导） |
| customer | int | 客户ID |
| customer_name | string | 客户名称 |
| fabric | string/null | 面料；来自关联生产订单，若当前出货单没有销售品则为 null |
| order_description | string/null | 滚筒预警；来自关联生产订单的 `rolling_warn`，若当前出货单没有销售品则为 null |
| shipment_date | string | 出货日期 |
| delivery_id | int/null | 关联送货单ID；未绑定时为 null |
| address | string | 地址 |
| contact_name | string | 联系人 |
| contact_phone | string | 联系电话 |
| area | string | 出货地区 |
| remark | string | 备注 |
| status | int | 状态枚举（1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核） |
| status_display | string | 状态显示名称 |
| status_modified_at | string/null | 最后一次状态修改时间 |
| cancelled_by_id | int/null | 取消人 ID，仅当进入已取消时可能有值 |
| cancelled_by_name | string/null | 取消人名称，仅当进入已取消时可能有值 |
| approved_by_id | int/null | 审核人 ID，仅当进入已审核时可能有值 |
| approved_by_name | string/null | 审核人名称，仅当进入已审核时可能有值 |
| items_count | int | 关联的销售品数量 |
| created_by_id | int | 创建人ID |
| created_by_name | string | 创建人名称 |
| created_at | string | 创建时间 |
| updated_at | string | 更新时间 |

说明：

- 后端内部会在驳回时记录 `rejected_sales_item_ids` 审计快照，但该字段当前不对前端返回

### 状态流转规则

- `草稿(未发布)` 只能流转到 `已发布`
- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消`
- `已驳回` 目前只能流转到 `已取消`
- `已审核` 可以流转到 `已取消`
- `已取消` 不可再流转到其他状态
- 重复设置同一状态保持幂等，不报错
- 进入 `已审核` 时必须提供审核人
- 进入 `已驳回` 时，系统会自动解绑当前出货单下的销售品，并将它们退回待分配池
- 驳回时解绑前的销售品 ID 会保存到后端审计字段 `rejected_sales_item_ids`

### 错误响应

#### 400 Bad Request - 客户不存在

```json
{
    "detail": "客户 999 不存在"
}
```

#### 400 Bad Request - 销售品不存在

```json
{
    "detail": "以下销售品不存在: [999]"
}
```

#### 400 Bad Request - 销售品已关联其他出货单

```json
{
    "detail": "以下销售品已关联到其他出货单: [1, 2]"
}
```

#### 400 Bad Request - 销售品缺少关联生产任务

```json
{
    "detail": "以下销售品缺少关联生产任务，无法创建出货单: [1]"
}
```

#### 400 Bad Request - 销售品关联的生产任务不存在

```json
{
    "detail": "以下销售品关联的生产任务不存在，无法创建出货单: [1]"
}
```

#### 400 Bad Request - 销售品来自不同生产订单

```json
{
    "detail": "出货单中的销售品必须来自同一个生产订单"
}
```

---

## 创建出货单（external 版）

创建出货单但**不绑定任何销售品**，同时写入并关联“外部成品表”（用于兼容外部遗留系统）。

### 接口信息

- **URL**: `/api/v1/shipment/shipments/external/`
- **Method**: `POST`
- **认证**: 需要登录（JWT Token）

### 请求参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| customer | int | 是 | 客户ID |
| shipment_date | string | 是 | 出货日期（YYYY-MM-DD） |
| address | string | 否 | 地址（可空字符串，长度<=255） |
| contact_name | string | 否 | 联系人（可空字符串，长度<=100） |
| contact_phone | string | 否 | 联系电话（可空字符串，长度<=50） |
| area | string | 否 | 出货地区（可空字符串，长度<=30） |
| remark | string | 否 | 备注 |
| external_id | string | 是 | 外部订单号（长度<=120） |
| external_finished_products | array[object] | 是 | 外部成品表结构数组（至少 1 条） |

`external_finished_products` 每项结构：

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| style_name | string | 是 | 款式名称 |
| num_of_rolls | int | 是 | 卷数 |
| remark | string | 否 | 备注（可空） |

### 请求示例

```json
{
  "customer": 1,
  "shipment_date": "2026-01-14",
  "address": "绍兴市仓库 2 号",
  "contact_name": "李四",
  "contact_phone": "13900139000",
  "area": "华南",
  "remark": "external 备注（可选）",
  "external_id": "EXT-ORDER-001",
  "external_finished_products": [
    {"style_name": "款式A", "num_of_rolls": 2, "remark": "A备注"},
    {"style_name": "款式B", "num_of_rolls": 5}
  ]
}
```

### 响应示例

```json
{
  "id": 1,
  "merchant_id": 1,
  "merchant_name": "测试印花厂",
  "customer": 1,
  "customer_name": "客户A",
  "shipment_date": "2026-01-14",
  "address": "绍兴市仓库 2 号",
  "contact_name": "李四",
  "contact_phone": "13900139000",
  "area": "华南",
  "remark": "external 备注（可选）",
  "status": 1,
  "status_display": "草稿(未发布)",
  "external_id": "EXT-ORDER-001",
  "status_modified_at": null,
  "cancelled_by_id": null,
  "cancelled_by_name": null,
  "approved_by_id": null,
  "approved_by_name": null,
  "items_count": 0,
  "external_finished_products_count": 2,
  "created_by_id": 1,
  "created_by_name": "张三",
  "created_at": "2026-01-14T10:00:00Z",
  "updated_at": "2026-01-14T10:00:00Z"
}
```

### 错误响应

#### 400 Bad Request - external_id 为空

```json
{
  "external_id": ["external_id 不能为空"]
}
```

#### 400 Bad Request - external_finished_products 为空

```json
{
  "external_finished_products": ["external_finished_products 不能为空"]
}
```

---

## 查询有待出货销售品的客户

查询当前商户下“存在未出货销售品”的客户列表。

### 接口信息

- **URL**: `/api/v1/shipment/sales-items/customers/`
- **Method**: `GET`
- **认证**: 需要登录（JWT Token）

### 查询参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| limit | int | 否 | 分页大小（LimitOffsetPagination） |
| offset | int | 否 | 偏移量 |

### 业务逻辑

1. 仅统计当前商户下的销售品
2. 只统计 `shipment` 为空的销售品（未出货）
3. 只返回至少拥有 1 条未出货销售品的客户
4. 返回客户基础信息及未出货销售品数量

补充说明：

- 若某销售品原先绑定在一个出货单上，而该出货单后来被驳回，这些销售品会被自动解绑，因此会重新计入这里的“未出货销售品”统计

### 响应格式

```json
{
  "count": 2,
  "next": null,
  "previous": null,
  "results": [
    {
      "customer_id": 12,
      "customer_name": "客户A",
      "mobile": "13800000000",
      "area": "杭州",
      "unshipped_sales_items_count": 18
    }
  ]
}
```

### 响应字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| count | int | 结果总数 |
| next | string/null | 下一页链接 |
| previous | string/null | 上一页链接 |
| results[].customer_id | int | 客户ID |
| results[].customer_name | string | 客户名称 |
| results[].mobile | string/null | 客户手机号 |
| results[].area | string/null | 客户地区 |
| results[].unshipped_sales_items_count | int | 该客户未出货销售品数量 |

---

## 按客户查询销售品

查询指定客户下的销售品列表，默认仅返回未出货销售品。

### 接口信息

- **URL**: `/api/v1/shipment/sales-items/by-customer/<customer_id>/`
- **Method**: `GET`
- **认证**: 需要登录（JWT Token）

### 路径参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| customer_id | int | 是 | 客户ID |

### 查询参数

| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| include_already_has_shipment | bool | 否 | false | 是否包含已关联出货单的销售品 |
| external_order_id | string | 否 | - | 按生产订单外部订单号精确筛选 |
| limit | int | 否 | 800 | 分页大小 |
| offset | int | 否 | 0 | 偏移量 |

### 业务逻辑

1. 仅允许查询当前商户下的客户
2. 仅查询当前商户下、`customer_id` 匹配的销售品
3. 默认只返回 `shipment` 为空的销售品（待出货）
4. 可通过 `include_already_has_shipment=true` 包含已出货销售品
5. 可通过 `external_order_id` 按关联生产订单的外部订单号精确筛选

补充说明：

- 被驳回出货单解绑的销售品会重新满足 `shipment = null` 条件，因此默认查询会再次返回它们

### 响应格式

```json
{
  "count": 2,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 101,
      "name": "赛扬 190g",
      "quantity": "1200.00",
      "unit": 1,
      "unit_display": "米",
      "position": "A1-01",
      "remark": "",
      "printing_job_id": 88,
      "printing_order_id": 23,
      "external_order_id": "KD20135142",
      "customer_id": 12,
      "customer_name": "客户A",
      "shipment_id": null,
      "shipment_date": null,
      "created_at": "2026-03-28T10:00:00Z",
      "created_by_id": 1,
      "created_by_name": "张三"
    }
  ]
}
```

### 错误响应

#### 404 Not Found - 客户不存在或无权限

```json
{
  "detail": "客户 999 不存在"
}
```

---

## 查询销售品详情

查询单个销售品详情，返回列表接口中的全部字段，并额外补充关联生产任务产品图片。

### 接口信息

- **URL**: `/api/v1/shipment/sales-items/<id>/`
- **Method**: `GET`
- **认证**: 需要登录（JWT Token）

### 路径参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | int | 是 | 销售品ID |

### 业务逻辑

1. 仅允许查询当前商户下的销售品
2. 返回销售品基础信息、关联客户信息、关联出货信息
3. 额外返回关联 `PrintingJob` 产品图 URL：`product_image_url`
4. 若无关联图片，则 `product_image_url` 返回 `null`

### 响应格式

```json
{
  "id": 101,
  "name": "赛扬 190g",
  "quantity": "1200.00",
  "unit": 1,
  "unit_display": "米",
  "position": "A1-01",
  "remark": "",
  "printing_job_id": 88,
  "printing_order_id": 23,
  "external_order_id": "KD20135142",
  "customer_id": 12,
  "customer_name": "客户A",
  "shipment_id": null,
  "shipment_date": null,
  "created_at": "2026-03-28T10:00:00Z",
  "created_by_id": 1,
  "created_by_name": "张三",
  "product_image_url": "https://cdn.example.com/products/88/main.jpg"
}
```

### 响应字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 销售品ID |
| name | string | 销售品名称 |
| quantity | string | 数量（Decimal，保留2位小数） |
| unit | int | 单位编码（1=米, 2=件, 3=码, 4=个） |
| unit_display | string | 单位显示名称 |
| position | string | 货位（可能为空） |
| remark | string | 备注（可能为空） |
| printing_job_id | int/null | 关联的生产任务ID |
| printing_order_id | int/null | 关联的生产订单ID |
| external_order_id | string/null | 关联生产订单的外部订单号 |
| customer_id | int/null | 销售品关联客户ID |
| customer_name | string/null | 销售品关联客户名称 |
| shipment_id | int/null | 关联的出货单ID，null 表示未出货 |
| shipment_date | string/null | 出货日期（YYYY-MM-DD），null 表示未出货 |
| created_at | string | 创建时间（ISO 8601） |
| created_by_id | int/null | 创建人ID |
| created_by_name | string/null | 创建人名称 |
| product_image_url | string/null | 关联产品主图 URL，无图时为 `null` |

### 错误响应

#### 404 Not Found - 销售品不存在或无权限

```json
{
  "detail": "销售品 999 不存在"
}
```

### 使用示例

```bash
curl -X GET \
  'https://api.example.com/api/v1/shipment/sales-items/101/' \
  -H 'Authorization: Bearer <token>'
```

---

## 通过生产订单查询销售品

查询与指定生产订单（PrintingOrder）关联的所有销售品（SalesItem）。

### 接口信息

- **URL**: `/api/v1/shipment/sales-items/by-printing-order/<printing_order_id>/`
- **Method**: `GET`
- **认证**: 需要登录（JWT Token）

### 路径参数

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| printing_order_id | string | 是 | 生产订单内部ID，或 `external_order_id` |

### 查询参数

| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| include_already_has_shipment | bool | 否 | false | 是否包含已关联出货单的销售品 |

### 业务逻辑

1. 先尝试将路径参数按内部生产订单ID查询
2. 若内部ID未命中，则按 `external_order_id` 查询生产订单
3. 若 `external_order_id` 命中多条生产订单，则返回 `400 Bad Request`
4. 获取该生产订单下所有 `PrintingJob` 的 ID
5. 查询 `SalesItem`，过滤 `printing_job_id` 在这些 job ID 中的记录
6. 根据 `include_already_has_shipment` 参数决定是否过滤已关联出货单的销售品：
   - `false`（默认）：只返回 `shipment` 为空的销售品（待出货）
   - `true`：返回所有销售品（包含已出货的）

补充说明：

- 若某销售品所在出货单已被驳回，该销售品会在驳回时自动解绑，因此默认查询会再次将其视为待出货销售品

### 响应格式

```json
{
    "count": 2,
    "results": [
        {
            "id": 1,
            "name": "产品A - 红色",
            "quantity": "100.00",
            "unit": 1,
            "unit_display": "米",
            "position": "A1-01",
            "remark": "加急处理",
            "printing_job_id": 123,
            "printing_order_id": 456,
            "customer_id": null,
            "customer_name": null,
            "shipment_id": null,
            "shipment_date": null,
            "created_at": "2026-01-14T10:00:00Z",
            "created_by_id": 1,
            "created_by_name": "张三"
        },
        {
            "id": 2,
            "name": "产品B - 蓝色",
            "quantity": "50.50",
            "unit": 1,
            "unit_display": "米",
            "position": "",
            "remark": "",
            "printing_job_id": 124,
            "printing_order_id": 456,
            "customer_id": 10,
            "customer_name": "客户A",
            "shipment_id": 5,
            "shipment_date": "2026-01-13",
            "created_at": "2026-01-13T15:30:00Z",
            "created_by_id": 2,
            "created_by_name": "李四"
        }
    ]
}
```

### 响应字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| count | int | 结果总数 |
| results | array | 销售品列表 |
| results[].id | int | 销售品ID |
| results[].name | string | 销售品名称 |
| results[].quantity | string | 数量（Decimal，保留2位小数） |
| results[].unit | int | 单位编码（1=米, 2=件, 3=码, 4=个） |
| results[].unit_display | string | 单位显示名称 |
| results[].position | string | 货位（可能为空） |
| results[].remark | string | 备注（可能为空） |
| results[].printing_job_id | int/null | 关联的生产任务ID |
| results[].printing_order_id | int/null | 关联的生产订单ID |
| results[].customer_id | int/null | 销售品级别的客户ID |
| results[].customer_name | string/null | 销售品关联客户名称 |
| results[].shipment_id | int/null | 关联的出货单ID，null 表示未出货 |
| results[].shipment_date | string/null | 出货日期（YYYY-MM-DD），null 表示未出货 |
| results[].created_at | string | 创建时间（ISO 8601） |
| results[].created_by_id | int/null | 创建人ID |
| results[].created_by_name | string/null | 创建人名称 |

### 错误响应

#### 404 Not Found - 生产订单不存在

```json
{
    "detail": "生产订单 999 不存在"
}
```

#### 400 Bad Request - external_order_id 匹配多条生产订单

```json
{
    "detail": "external_order_id KD20135142 匹配到多个生产订单，请改用内部ID查询"
}
```

#### 401 Unauthorized - 未登录

```json
{
    "detail": "Authentication credentials were not provided."
}
```

### 使用示例

#### 查询待出货的销售品（默认）

```bash
curl -X GET \
  'https://api.example.com/api/v1/shipment/sales-items/by-printing-order/123/' \
  -H 'Authorization: Bearer <token>'
```

#### 使用 external_order_id 查询

```bash
curl -X GET \
  'https://api.example.com/api/v1/shipment/sales-items/by-printing-order/KD20135142/' \
  -H 'Authorization: Bearer <token>'
```

#### 查询所有销售品（包含已出货）

```bash
curl -X GET \
  'https://api.example.com/api/v1/shipment/sales-items/by-printing-order/123/?include_already_has_shipment=true' \
  -H 'Authorization: Bearer <token>'
```

---

## 单位编码对照表

| 编码 | 名称 |
|------|------|
| 1 | 米 |
| 2 | 件 |
| 3 | 码 |
| 4 | 个 |

---

## 相关模块

- `shipment/services.py`: 业务逻辑层
- `api_v1/views/shipment/`: API 视图层
- `shipment/models.py`: 数据模型（SalesItem, Shipment）

---

## 外部系统兼容（数据模型说明）

为兼容外部遗留系统，Shipment 模块新增了“外部成品表”模型：

- **关系**：`Shipment` 1 → N `ExternalFinishedProduct`
  - 外键在 `ExternalFinishedProduct.shipment`（可空）
- **Shipment 外部字段**：
  - `external_id`：外部订单号（可空）
