1
0
forked from erp-dev/erp

feat: app verion && some field modify (printing & shipment)

This commit is contained in:
2026-07-08 20:36:51 +08:00
parent ddbf798665
commit 36e4bb6de6
32 changed files with 3465 additions and 26 deletions

View File

@@ -0,0 +1,224 @@
# API v1 Customer Address API
## Scope
客户地址 API 用于维护客户的一对多地址资料。地址归属 `basic_info.Customer`,并通过 `merchant` 做商户隔离。
所有接口需要登录。普通员工只能访问自己可见客户的地址;拥有 `basic_info.view_all_customers` 权限的员工可以访问本商户全部客户地址;superuser 可以访问全部数据。
## Data Model
| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | 客户地址 ID |
| `merchant` | integer | 所属商户 ID,创建时由后端根据客户自动写入 |
| `merchant_name` | string | 所属商户名称,只读 |
| `customer` | integer | 客户 ID |
| `customer_name` | string | 客户名称,只读 |
| `address` | string | 地址 |
| `contact_name` | string | 联系人,可为空字符串 |
| `contact_phone` | string | 联系电话,可为空字符串 |
| `area` | string | 地区,可为空字符串 |
| `coordinates` | string/null | 经纬度字符串,格式由调用方决定 |
| `is_default` | boolean | 是否默认地址 |
| `remark` | string | 备注,可为空字符串 |
| `extra` | object/null | 扩展 JSON |
| `created_by` | integer/null | 创建员工 ID,只读 |
| `created_by_name` | string/null | 创建员工姓名,只读 |
| `deleted_at` | datetime/null | 软删除时间,只读 |
| `deleted_by` | integer/null | 删除员工 ID,只读 |
| `deleted_by_name` | string/null | 删除员工姓名,只读 |
| `created_at` | datetime | 创建时间,只读 |
| `updated_at` | datetime | 更新时间,只读 |
## Endpoints
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/customer-addresses/` | 查询客户地址列表 |
| POST | `/api/v1/customer-addresses/` | 创建客户地址 |
| GET | `/api/v1/customer-addresses/{id}/` | 查询客户地址详情 |
| PUT | `/api/v1/customer-addresses/{id}/` | 完整更新客户地址 |
| PATCH | `/api/v1/customer-addresses/{id}/` | 部分更新客户地址 |
| DELETE | `/api/v1/customer-addresses/{id}/` | 软删除客户地址 |
| POST | `/api/v1/customer-address/{id}/set-default/` | 将客户地址设为默认地址 |
## Visibility Rules
普通员工可访问的客户地址必须同时满足:
- 地址所属商户等于当前员工商户。
- 地址所属客户对当前员工可见。
客户可见性规则:
- `customer.created_by == 当前员工`
- 或当前员工在 `customer.visible_employees` 中。
权限例外:
- `basic_info.view_all_customers`:可访问本商户全部客户地址。
- superuser:可访问全部客户地址。
创建地址时,`customer` 也必须在当前用户可访问范围内。`merchant` 不由前端传入,后端使用客户所属商户自动写入。
## Query Parameters
`GET /api/v1/customer-addresses/` 支持以下查询参数:
| Parameter | Description |
| --- | --- |
| `customer` | 按客户 ID 精确过滤 |
| `customer_name` | 按客户名称模糊过滤 |
| `address` | 按地址模糊过滤 |
| `contact_name` | 按联系人模糊过滤 |
| `contact_phone` | 按联系电话模糊过滤 |
| `area` | 按地区模糊过滤 |
| `is_default` | 按默认地址过滤,支持 `true/false/1/0/yes/no` |
| `include_deleted` | 是否包含软删除记录,支持 `true/false/1/0/yes/no` |
| `only_deleted` | 是否只查询软删除记录,支持 `true/false/1/0/yes/no` |
| `search` | 对客户名称、地址、联系人、电话、地区、备注做模糊搜索 |
| `ordering` | 排序字段,支持 `id`、`created_at`、`updated_at`、`is_default` 及其 `-` 倒序形式 |
| `limit` | 分页数量 |
| `offset` | 分页偏移 |
默认行为:
- 不传 `include_deleted` 和 `only_deleted` 时,只返回未删除地址。
- `only_deleted=true` 优先于 `include_deleted=true`。
## Create
`POST /api/v1/customer-addresses/`
Request body:
```json
{
"customer": 3,
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "杭州",
"coordinates": "120.1,30.2",
"is_default": true,
"remark": "默认收货地址",
"extra": {
"source": "manual"
}
}
```
Success response: HTTP 201
```json
{
"id": 10,
"merchant": 1,
"merchant_name": "测试印花厂",
"customer": 3,
"customer_name": "客户A",
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "杭州",
"coordinates": "120.1,30.2",
"is_default": true,
"remark": "默认收货地址",
"extra": {
"source": "manual"
},
"created_by": 5,
"created_by_name": "业务员A",
"deleted_at": null,
"deleted_by": null,
"deleted_by_name": null,
"created_at": "2026-07-07T12:00:00Z",
"updated_at": "2026-07-07T12:00:00Z"
}
```
## Update
`PATCH /api/v1/customer-addresses/{id}/`
Example:
```json
{
"address": "杭州市更新路 2 号",
"contact_phone": "13900139000",
"coordinates": null
}
```
Notes:
- 不允许修改 `customer`。
- 不允许修改 `merchant`。
- `created_by`、`deleted_at`、`deleted_by` 为只读字段。
## Default Address
同一客户最多一个未删除默认地址。
以下操作会自动取消同一客户其他未删除地址的默认状态:
- 创建地址时传 `is_default=true`。
- 更新地址时传 `is_default=true`。
- 调用 `set-default` 接口。
`POST /api/v1/customer-address/{id}/set-default/`
Success response: HTTP 200,返回更新后的地址对象。
如果地址已经软删除,返回 HTTP 400。
## Soft Delete
`DELETE /api/v1/customer-addresses/{id}/`
删除是软删除,不会物理删除记录。后端会写入:
- `deleted_at`
- `deleted_by`
- `is_default=false`
Success response: HTTP 204。
查询软删除记录:
```http
GET /api/v1/customer-addresses/?include_deleted=true
GET /api/v1/customer-addresses/?only_deleted=true
```
## Error Cases
| Condition | Status | Notes |
| --- | --- | --- |
| 未登录 | 401 | 全部接口需要登录 |
| 查询不可见地址详情 | 404 | 不暴露不可见数据存在性 |
| 给不可见客户创建地址 | 400 | `customer` 字段校验失败 |
| 修改 `customer` | 400 | 不允许地址跨客户移动 |
| 已删除地址设为默认 | 400 | 通过 `set-default` 接口返回校验错误 |
## Test Coverage
Dedicated tests:
```bash
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py test api_v1.test_customer_address_api --keepdb --noinput
```
Covered behavior:
- 商户隔离和客户可见性。
- `visible_employees` 授权后可见。
- `basic_info.view_all_customers` 可访问本商户全部客户地址。
- 创建地址时自动写入 `merchant` 和 `created_by`。
- 不可见客户创建地址失败。
- 默认地址互斥。
- `set-default` 接口互斥设置默认地址。
- 软删除和 `include_deleted` / `only_deleted` 查询。
- 常用过滤参数。

View File

@@ -0,0 +1,826 @@
# Shipment 模块 API v1 文档
面向前端对接,基于当前代码生成。接口统一前缀为 `/api/v1/`。
## 通用说明
- 认证:所有接口均要求登录认证。
- 商户隔离:普通用户只能访问自己员工所属商户的数据;超级管理员部分接口可跨商户,具体见接口说明。
- 分页:列表接口使用 `limit`、`offset`,分页响应一般为 `{ "count": 0, "next": null, "previous": null, "results": [] }`。
- 错误:业务校验失败通常返回 `400 { "detail": "错误原因" }`;不存在通常返回 `404`。
## 枚举
### 出货单状态 `ShipmentStatus`
| 值 | 含义 |
| --- | --- |
| `1` | 草稿(未发布) |
| `2` | 已发布 |
| `3` | 已取消 |
| `4` | 已驳回 |
| `5` | 已审核 |
### 送货单状态 `ShipmentDeliveryStatus`
| 值 | 含义 |
| --- | --- |
| `1` | 待送货 |
| `2` | 送货中 |
| `3` | 已送达 |
### 销售品单位 `UnitChoices`
| 值 | 含义 |
| --- | --- |
| `1` | 米 |
| `2` | 件 |
| `3` | 码 |
| `4` | 个 |
## 公共响应结构
### Shipment
```json
{
"id": 1,
"merchant_id": 1,
"merchant_name": "测试印花厂",
"customer": 10,
"customer_name": "客户A",
"address_id": 5,
"fabric": "面料信息",
"order_description": "订单描述",
"shipment_date": "2026-01-14",
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "华东",
"coordinates": "120.1551,30.2741",
"remark": "备注",
"status": 1,
"status_display": "草稿(未发布)",
"external_id": null,
"geo_coordinates": null,
"extra": {"source": "customer_address"},
"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": 2,
"created_by_id": 1,
"created_by_name": "张三",
"external_finished_products_count": 0,
"sales_items": [],
"external_finished_products": [],
"created_at": "2026-01-14T10:00:00Z",
"updated_at": "2026-01-14T10:00:00Z"
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `address_id` | integer/null | 关联客户地址 ID,只表示关联关系 |
| `address` | string | 出货单地址快照,由请求体写入 |
| `contact_name` | string | 联系人快照,由请求体写入 |
| `contact_phone` | string | 联系电话快照,由请求体写入 |
| `area` | string | 地区快照,最大 100 字符 |
| `coordinates` | string/null | 经纬度字符串,最大 100 字符 |
| `geo_coordinates` | object/array/null | 历史 Geo 字段,保留兼容老数据 |
| `extra` | object/array/null | 扩展 JSON |
重要行为:传 `address_id` 时,后端只校验并保存关联,不会用 `CustomerAddress` 自动覆盖 `address/contact_name/contact_phone/area/coordinates/extra`。这些快照字段仍以请求体为准。
### SalesItem
```json
{
"id": 1,
"name": "销售品A",
"quantity": "100.00",
"unit": 1,
"unit_display": "米",
"position": "A1-01",
"remark": "备注",
"printing_job_id": 123,
"printing_order_id": 456,
"external_order_id": "KD20441758",
"customer_id": 10,
"customer_name": "客户A",
"shipment_id": null,
"shipment_date": null,
"merge_remark": null,
"created_at": "2026-01-14T10:00:00Z",
"created_by_id": 1,
"created_by_name": "张三"
}
```
详情接口额外包含:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `product_image_url` | string/null | 关联生产任务产品主图 URL |
### ShipmentDelivery
```json
{
"id": 1,
"merchant_id": 1,
"merchant_name": "测试印花厂",
"driver_name": "司机A",
"vehicle_trip": "浙A12345",
"contact_phone": "13800138000",
"vehicle_capacity": "4.2米",
"remark": "备注",
"internal_remark": "内部备注",
"shipment_order_ids": [1, 2, 3],
"status": 1,
"status_display": "待送货",
"started_at": null,
"delivered_at": null,
"cancelled_at": null,
"shipments_count": 2,
"shipments": [],
"created_by_id": 1,
"created_by_name": "张三",
"operator_id": null,
"operator_name": null,
"cancelled_by_id": null,
"cancelled_by_name": null,
"created_at": "2026-01-14T10:00:00Z",
"updated_at": "2026-01-14T10:00:00Z"
}
```
`shipments` 内元素为送货单内出货单摘要:
```json
{
"id": 1,
"customer": 10,
"customer_name": "客户A",
"fabric": "面料信息",
"order_description": "订单描述",
"shipment_date": "2026-01-14",
"status": 5,
"status_display": "已审核",
"external_id": null,
"delivery_id": 1
}
```
## 出货单接口
### 查询出货单列表
`GET /api/v1/shipment/shipments/`
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `customer` | integer | 否 | 客户 ID |
| `status` | integer | 否 | 出货单状态 |
| `delivery_id` | integer | 否 | 送货单 ID;空字符串、`null`、`none` 不触发过滤 |
| `delivery_isnull` | boolean | 否 | `true/1/yes` 查未绑定送货单,`false/0/no` 查已绑定送货单 |
| `external_id` | string | 否 | 外部订单号精确匹配 |
| `shipment_date_from` | date | 否 | 出货日期起始,`YYYY-MM-DD` |
| `shipment_date_to` | date | 否 | 出货日期结束,`YYYY-MM-DD` |
| `only_address_null` | boolean | 否 | `true` 查地址为空字符串,`false` 查地址非空 |
| `limit` | integer | 否 | 分页大小 |
| `offset` | integer | 否 | 分页偏移 |
响应:分页 `Shipment[]`。
### 创建出货单
`POST /api/v1/shipment/shipments/`
请求体:
```json
{
"customer": 10,
"address_id": 5,
"shipment_date": "2026-01-14",
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "华东",
"coordinates": "120.1551,30.2741",
"extra": {"source": "customer_address"},
"remark": "备注",
"sales_items": [1, 2, 3]
}
```
请求字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `customer` | integer | 是 | 客户 ID |
| `address_id` | integer/null | 否 | 客户地址 ID;仅校验和关联 |
| `shipment_date` | date | 是 | 出货日期 |
| `address` | string | 否 | 地址快照,默认 `""`,最大 255 |
| `contact_name` | string | 否 | 联系人快照,默认 `""`,最大 100 |
| `contact_phone` | string | 否 | 联系电话快照,默认 `""`,最大 50 |
| `area` | string | 否 | 地区快照,默认 `""`,最大 100 |
| `coordinates` | string/null | 否 | 经纬度字符串,最大 100 |
| `extra` | JSON/null | 否 | 扩展信息 |
| `remark` | string | 否 | 出货单备注,默认 `""` |
| `sales_items` | integer[] | 否 | 要关联的销售品 ID 列表,默认 `[]`,后端会去重 |
业务规则:
- 用户必须关联商户。
- 客户必须属于当前商户。
- `address_id` 存在时,客户地址必须存在、未软删除、属于当前商户、属于请求中的 `customer`。
- `sales_items` 非空时,销售品必须存在、未删除、未绑定其他出货单、属于当前商户,并且都来自同一个生产订单。
响应:`201 Shipment`。
### 创建外部出货单
`POST /api/v1/shipment/shipments/external/`
用于没有内部销售品、但需要记录外部成品表的出货单。
请求体:
```json
{
"customer": 10,
"shipment_date": "2026-01-14",
"address": "杭州市测试路 1 号",
"contact_name": "张三",
"contact_phone": "13800138000",
"area": "华东",
"coordinates": "120.1551,30.2741",
"extra": {"source": "external"},
"remark": "备注",
"external_id": "EXT-001",
"external_finished_products": [
{
"style_name": "款式A",
"num_of_rolls": 10,
"remark": "成品备注"
}
]
}
```
请求字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `customer` | integer | 是 | 客户 ID |
| `shipment_date` | date | 是 | 出货日期 |
| `address` | string | 否 | 地址,最大 255 |
| `contact_name` | string | 否 | 联系人,最大 100 |
| `contact_phone` | string | 否 | 联系电话,最大 50 |
| `area` | string | 否 | 地区,最大 100 |
| `coordinates` | string/null | 否 | 经纬度字符串,最大 100 |
| `extra` | JSON/null | 否 | 扩展信息 |
| `remark` | string | 否 | 备注 |
| `external_id` | string | 是 | 外部订单号,不能为空,最大 120 |
| `external_finished_products` | array | 是 | 外部成品表,不能为空 |
`external_finished_products` 元素:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `style_name` | string | 是 | 款式名,最大 200 |
| `num_of_rolls` | integer | 是 | 卷数,最小 0 |
| `remark` | string/null | 否 | 备注,最大 200 |
响应:`201 Shipment`。
### 获取出货单详情
`GET /api/v1/shipment/shipments/{id}/`
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `status` | integer | 否 | 可选状态过滤;不匹配则返回 404 |
响应:`200 Shipment`。
### 更新出货单
`PATCH /api/v1/shipment/shipments/{id}/`
`PUT /api/v1/shipment/shipments/{id}/`
当前 `PUT` 行为等同 `PATCH`,允许只传部分字段。
请求体:
```json
{
"customer": 10,
"shipment_date": "2026-01-15",
"address": "新地址",
"contact_name": "李四",
"contact_phone": "13900139000",
"area": "华南",
"coordinates": null,
"extra": null,
"remark": "新备注",
"external_id": "EXT-002",
"geo_coordinates": {"lng": 120.1, "lat": 30.2}
}
```
可更新字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `customer` | integer | 客户 ID |
| `shipment_date` | date | 出货日期 |
| `address` | string | 地址,最大 255 |
| `contact_name` | string | 联系人,最大 100 |
| `contact_phone` | string | 联系电话,最大 50 |
| `area` | string | 地区,最大 100 |
| `coordinates` | string/null | 经纬度字符串;传 `null` 可清空 |
| `extra` | JSON/null | 扩展信息;传 `null` 可清空 |
| `remark` | string | 备注 |
| `external_id` | string/null | 外部订单号;空字符串或 `null` 会清空 |
| `geo_coordinates` | JSON/null | 当前 serializer 接收该字段,但业务更新服务未保存它,前端不建议使用 |
业务规则:
- 已关联送货单的出货单不允许修改业务数据。
- 修改客户时,新客户必须存在且属于同一商户。
响应:`200 Shipment`。
### 修改出货单状态
`PATCH /api/v1/shipment/shipments/{id}/status/`
`PUT /api/v1/shipment/shipments/{id}/status/`
请求体:
```json
{
"status": 5
}
```
响应:`200 Shipment`。
## 送货单接口
### 查询送货单列表
`GET /api/v1/shipment/deliveries/`
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `status` | integer | 否 | 送货单状态 |
| `driver_name` | string | 否 | 司机名模糊匹配 |
| `vehicle_trip` | string | 否 | 车次模糊匹配 |
| `limit` | integer | 否 | 分页大小 |
| `offset` | integer | 否 | 分页偏移 |
响应:分页 `ShipmentDelivery[]`。
### 创建送货单
`POST /api/v1/shipment/deliveries/`
请求体:
```json
{
"driver_name": "司机A",
"vehicle_trip": "浙A12345",
"contact_phone": "13800138000",
"vehicle_capacity": "4.2米",
"remark": "备注",
"internal_remark": "内部备注",
"shipment_order_ids": [3, 2, 1],
"shipments": [1, 2, 3]
}
```
请求字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `driver_name` | string | 是 | 司机名,最大 100 |
| `vehicle_trip` | string | 是 | 车次,最大 100 |
| `contact_phone` | string | 否 | 联系电话,默认 `""`,最大 50 |
| `vehicle_capacity` | string | 否 | 车辆容量,默认 `""`,最大 100 |
| `remark` | string | 否 | 备注,默认 `""`,最大 200 |
| `internal_remark` | string | 否 | 内部备注,默认 `""`,最大 200 |
| `shipment_order_ids` | array/null | 否 | 前端自管出货单排序 ID 数组,可为 `null` |
| `shipments` | integer[] | 否 | 要关联的出货单 ID 列表,默认 `[]`,后端会去重 |
响应:`201 ShipmentDelivery`。
### 按生产订单查询送货单
`GET /api/v1/shipment/deliveries/by-printing-order/{printing_order_id}/`
路径参数:
- `printing_order_id` 支持内部 `PrintingOrder.id` 或 `external_order_id`。
- 如果 `external_order_id` 匹配多个生产订单,返回 `500 { "message": "...请改用内部ID查询" }`。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `status` | integer | 否 | 送货单状态 |
| `driver_name` | string | 否 | 模糊匹配 |
| `vehicle_trip` | string | 否 | 模糊匹配 |
| `contact_phone` | string | 否 | 模糊匹配 |
| `vehicle_capacity` | string | 否 | 模糊匹配 |
| `remark` | string | 否 | 模糊匹配 |
| `internal_remark` | string | 否 | 模糊匹配 |
| `started_at_from` | date/datetime | 否 | 开始送货时间起 |
| `started_at_to` | date/datetime | 否 | 开始送货时间止 |
| `delivered_at_from` | date/datetime | 否 | 送达时间起 |
| `delivered_at_to` | date/datetime | 否 | 送达时间止 |
| `cancelled_at_from` | date/datetime | 否 | 取消时间起 |
| `cancelled_at_to` | date/datetime | 否 | 取消时间止 |
| `created_at_from` | date/datetime | 否 | 创建时间起 |
| `created_at_to` | date/datetime | 否 | 创建时间止 |
| `shipment_date_from` | date | 否 | 关联出货日期起 |
| `shipment_date_to` | date | 否 | 关联出货日期止 |
| `ordering` | string | 否 | 可选 `id/created_at/updated_at/started_at/delivered_at/cancelled_at/status`,支持前缀 `-` |
| `limit` | integer | 否 | 分页大小 |
| `offset` | integer | 否 | 分页偏移 |
响应:分页 `ShipmentDelivery[]`,字段使用专用 DTO,不包含 `cancelled_by_id/cancelled_by_name`。
### 获取送货单详情
`GET /api/v1/shipment/deliveries/{id}/`
响应:`200 ShipmentDelivery`。
### 更新送货单
`PATCH /api/v1/shipment/deliveries/{id}/`
`PUT /api/v1/shipment/deliveries/{id}/`
当前 `PUT` 行为等同 `PATCH`。
请求体:
```json
{
"driver_name": "司机B",
"vehicle_trip": "浙B12345",
"contact_phone": "13900139000",
"vehicle_capacity": "9.6米",
"remark": "新备注",
"internal_remark": "新内部备注",
"shipment_order_ids": [2, 1],
"shipments": [1, 2]
}
```
可更新字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `driver_name` | string | 司机名,最大 100,不允许空字符串 |
| `vehicle_trip` | string | 车次,最大 100,不允许空字符串 |
| `contact_phone` | string | 联系电话,最大 50 |
| `vehicle_capacity` | string | 车辆容量,最大 100 |
| `remark` | string | 备注,最大 200 |
| `internal_remark` | string | 内部备注,最大 200 |
| `shipment_order_ids` | array/null | 前端自管排序 ID 数组,传入即更新 |
| `shipments` | integer[] | 传入即替换当前绑定出货单 |
响应:`200 ShipmentDelivery`。
### 删除送货单
`DELETE /api/v1/shipment/deliveries/{id}/`
响应:`204 No Content`。
### 修改送货单状态
`POST /api/v1/shipment/deliveries/{id}/status/`
请求体:
```json
{
"status": 2
}
```
允许状态:`1`、`2`、`3`。
响应:`200 ShipmentDelivery`。
### 取消送货单
`POST /api/v1/shipment/deliveries/{id}/cancel/`
权限:需要 `shipment.cancel_shipmentdelivery`。
响应:`200 ShipmentDelivery`。
### 追加绑定出货单到送货单
`POST /api/v1/shipment/deliveries/{id}/bind-shipments/`
请求体:
```json
{
"shipments": [4, 5]
}
```
请求字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `shipments` | integer[] | 是 | 要追加绑定的出货单 ID,不能为空,后端会去重 |
响应:`200 ShipmentDelivery`。
## 销售品接口
### 查询当前可出货客户
`GET /api/v1/shipment/sales-items/customers/`
从未出货销售品反推出客户列表。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `merchant` | integer | 超级管理员必填 | 超级管理员必须传商户 ID;普通用户忽略 |
| `customer_name` | string | 否 | 客户名称模糊匹配 |
| `limit` | integer | 否 | 分页大小 |
| `offset` | integer | 否 | 分页偏移 |
响应:
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"customer_id": 10,
"customer_name": "客户A",
"mobile": "13900139000",
"area": "杭州",
"unshipped_sales_items_count": 3
}
]
}
```
### 按客户查询销售品
`GET /api/v1/shipment/sales-items/by-customer/{customer_id}/`
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `include_already_has_shipment` | boolean | 否 | 是否包含已关联出货单销售品,默认 `false`;只有字符串 `true` 会视为真 |
| `external_order_id` | string | 否 | 按生产订单外部订单号过滤 |
| `limit` | integer | 否 | 分页大小 |
| `offset` | integer | 否 | 分页偏移 |
响应:分页 `SalesItem[]`。
### 按客户查询销售品分组
`GET /api/v1/shipment/sales-items/by-customer/{customer_id}/group/`
第一层按 `external_order_id` 分组并分页,第二层按 `name` 分组。
查询参数同“按客户查询销售品”。
响应:
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"external_order_id": "KD20441758",
"count": 2,
"name_groups": [
{
"name": "销售品A",
"count": 2,
"items": []
}
]
}
]
}
```
排序:`external_order_id = null` 排最后。
### 按生产订单查询销售品
`GET /api/v1/shipment/sales-items/by-printing-order/{printing_order_id}/`
路径参数:
- `printing_order_id` 支持内部 `PrintingOrder.id` 或 `external_order_id`。
- 如果 `external_order_id` 匹配多个生产订单,此接口返回 `400 { "detail": "...请改用内部ID查询" }`。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `include_already_has_shipment` | boolean | 否 | 是否包含已关联出货单销售品,默认 `false`;只有字符串 `true` 会视为真 |
响应:
```json
{
"count": 2,
"results": []
}
```
`results` 为 `SalesItem[]`,该接口不使用标准分页。
### 按生产订单查询销售品分组
`GET /api/v1/shipment/sales-items/by-printing-order/{printing_order_id}/group/`
按 `printing_job_id` 分组并分页。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `include_already_has_shipment` | boolean | 否 | 是否包含已关联出货单销售品,默认 `false` |
| `limit` | integer | 否 | 分页大小,作用于分组数量 |
| `offset` | integer | 否 | 分页偏移,作用于分组数量 |
响应:
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"printing_job_id": 123,
"count": 2,
"items": []
}
]
}
```
### 手动创建销售品
`POST /api/v1/shipment/sales-items/`
请求体:
```json
{
"printing_job_id": 123,
"name": "销售品A",
"quantity": "100.50",
"unit": 1,
"customer_id": 10,
"remark": "备注",
"position": "A1-01",
"merge_remark": {
"merge_type": "merge",
"jobs": [1, 2],
"main_job": 1,
"quantity": "100.50",
"unit": "米",
"job_count": 2
}
}
```
请求字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `printing_job_id` | integer | 是 | 生产任务 ID,最小 1 |
| `name` | string | 是 | 销售品名称,最大 200 |
| `quantity` | string | 是 | 数量,最大 20,支持小数 |
| `unit` | integer | 是 | 单位:`1/2/3/4` |
| `customer_id` | integer/null | 否 | 客户 ID;不传时默认从生产订单获取 |
| `remark` | string | 否 | 备注,最大 200 |
| `position` | string | 否 | 货位,最大 200 |
| `merge_remark` | object/null | 否 | 合卷备注 JSON,结构严格校验 |
`merge_remark` 必须只包含这些字段:`merge_type`、`jobs`、`main_job`、`quantity`、`unit`、`job_count`。
响应:`201 SalesItem`。
### 获取销售品详情
`GET /api/v1/shipment/sales-items/{id}/`
响应:`200 SalesItem`,包含 `product_image_url`。
### 更新销售品
`PATCH /api/v1/shipment/sales-items/{id}/`
请求体只能包含以下字段:
```json
{
"quantity": "80.00",
"remark": "新备注",
"position": "B2-01"
}
```
可更新字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `quantity` | string | 数量,支持小数 |
| `remark` | string | 备注,最大 200 |
| `position` | string | 货位,最大 200 |
如果请求包含其他字段,返回 `400`。
响应:`200 SalesItem`,包含 `product_image_url`。
### 删除销售品
`DELETE /api/v1/shipment/sales-items/{id}/`
权限:需要 `shipment.soft_delete_salesitem`。
响应:
```json
{
"detail": "销售品已标记为删除"
}
```
### 重建销售品
`POST /api/v1/shipment/sales-items/{id}/rebuild/`
仅销售品创建者可执行。
请求体:
```json
{
"new_printing_job_id": 456,
"quantity": "50.00"
}
```
请求字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `new_printing_job_id` | integer | 是 | 新生产任务 ID,最小 1 |
| `quantity` | string | 否 | 新数量;不传则沿用原销售品数量 |
响应:`200 SalesItem`。
## 前端重点注意
- 创建出货单时,`address_id` 不会自动填充地址快照。前端若选择客户地址,需要同时把地址表中的 `address/contact_name/contact_phone/area/coordinates/extra` 放入创建请求。
- `coordinates` 是新的字符串字段,前端不要再把新数据写到 `geo_coordinates`。
- `geo_coordinates` 是历史兼容字段,读取时可能存在旧值,但更新接口目前不保存它。
- `area` 现在最大 100 字符,可以直接承载客户地址的 `area`。
- `extra` 可用于承载客户地址扩展信息,允许对象、数组或 `null`。
- 更新出货单时,传 `coordinates: null` 或 `extra: null` 可以清空对应字段;不传则不修改。
- 按生产订单查送货单的 `external_order_id` 多匹配错误返回 `500 + message`;按生产订单查销售品的同类错误返回 `400 + detail`。

View File

@@ -0,0 +1,198 @@
# API v1 Shipment Deliveries By Printing Order
## Scope
该接口用于按生产订单查询相关送货单,也就是司机/车次相关的送货数据。
查询链路:
```text
PrintingOrder -> PrintingJob -> SalesItem -> Shipment -> ShipmentDelivery
```
只返回已经关联到送货单的出货数据。没有绑定送货单的 `Shipment` 不会出现在结果中。
## Endpoint
```http
GET /api/v1/shipment/deliveries/by-printing-order/{printing_order_id}/
```
需要登录。
## Path Parameters
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `printing_order_id` | string | yes | 支持生产订单内部 ID,也支持 `PrintingOrder.external_order_id` |
解析规则:
- 如果 `printing_order_id` 是纯数字,优先按内部 `PrintingOrder.id` 查询。
- 如果内部 ID 未命中,再按 `external_order_id` 查询。
- 如果不是纯数字,按 `external_order_id` 查询。
- 如果 `external_order_id` 匹配多个生产订单,返回 HTTP 500 和明确 `message`。
## Query Parameters
| Parameter | Description |
| --- | --- |
| `status` | 送货单状态精确过滤 |
| `driver_name` | 司机名模糊过滤 |
| `vehicle_trip` | 车次模糊过滤 |
| `contact_phone` | 联系电话模糊过滤 |
| `vehicle_capacity` | 车辆容量模糊过滤 |
| `remark` | 备注模糊过滤 |
| `internal_remark` | 内部备注模糊过滤 |
| `started_at_from` | 开始送货时间起始 |
| `started_at_to` | 开始送货时间截止 |
| `delivered_at_from` | 送达时间起始 |
| `delivered_at_to` | 送达时间截止 |
| `cancelled_at_from` | 取消时间起始 |
| `cancelled_at_to` | 取消时间截止 |
| `created_at_from` | 送货单创建时间起始 |
| `created_at_to` | 送货单创建时间截止 |
| `shipment_date_from` | 关联出货单出货日期起始,格式 `YYYY-MM-DD` |
| `shipment_date_to` | 关联出货单出货日期截止,格式 `YYYY-MM-DD` |
| `ordering` | 排序字段 |
| `limit` | 分页数量 |
| `offset` | 分页偏移 |
时间参数说明:
- `*_at_from` / `*_at_to` 支持日期或 datetime 字符串。
- 如果传日期,例如 `2026-07-01`,会按对应字段的日期部分过滤。
- 如果传 datetime,例如 `2026-07-01T09:00:00+08:00`,会按完整时间过滤。
`ordering` 支持:
```text
id
-id
created_at
-created_at
updated_at
-updated_at
started_at
-started_at
delivered_at
-delivered_at
cancelled_at
-cancelled_at
status
-status
```
默认排序:`-created_at, -id`。
## Response
分页响应。
```json
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 12,
"merchant_id": 1,
"merchant_name": "测试印花厂",
"driver_name": "张司机",
"vehicle_trip": "KD-001",
"contact_phone": "13800138000",
"vehicle_capacity": "9.6米厢车",
"remark": "外部备注",
"internal_remark": "内部备注",
"shipment_order_ids": [101, 102],
"status": 2,
"status_display": "送货中",
"started_at": "2026-07-01T09:00:00+08:00",
"delivered_at": null,
"cancelled_at": null,
"shipments_count": 2,
"shipments": [
{
"id": 101,
"customer": 3,
"customer_name": "客户A",
"fabric": "全棉",
"order_description": "订单描述",
"shipment_date": "2026-07-01",
"status": 5,
"status_display": "已审核",
"external_id": null,
"delivery_id": 12
}
],
"created_by_id": 5,
"created_by_name": "业务员A",
"operator_id": null,
"operator_name": null,
"created_at": "2026-07-01T08:00:00+08:00",
"updated_at": "2026-07-01T09:00:00+08:00"
}
]
}
```
## Examples
按内部生产订单 ID 查询:
```http
GET /api/v1/shipment/deliveries/by-printing-order/9986/
```
按外部生产订单号查询:
```http
GET /api/v1/shipment/deliveries/by-printing-order/KD20441758/
```
按司机、状态和出货日期过滤:
```http
GET /api/v1/shipment/deliveries/by-printing-order/KD20441758/?status=2&driver_name=张&shipment_date_from=2026-07-01&shipment_date_to=2026-07-31
```
分页和排序:
```http
GET /api/v1/shipment/deliveries/by-printing-order/KD20441758/?ordering=id&limit=20&offset=0
```
## Error Responses
| Condition | Status | Response |
| --- | --- | --- |
| 未登录 | 401 | 全局认证错误结构 |
| 当前商户下找不到生产订单 | 404 | `{ "detail": "生产订单 {printing_order_id} 不存在" }` |
| `external_order_id` 匹配多个生产订单 | 500 | `{ "message": "external_order_id ... 匹配到多个生产订单,请改用内部ID查询" }` |
## Notes
- 普通用户只能查询自己员工所属商户下的生产订单和送货单。
- superuser 不受商户限制。
- 一个送货单可能通过多个销售品命中同一个生产订单,接口会对送货单去重。
- 查询只统计未软删除的 `SalesItem`。
- 响应使用专用 DTO:`ShipmentDeliveryByPrintingOrderSerializer`,后续可以独立调整字段,不影响普通送货单列表接口。
## Test Coverage
Dedicated tests:
```bash
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py test api_v1.views.shipment.test_api.ShipmentDeliveryByPrintingOrderAPITestCase --keepdb --noinput
```
Covered behavior:
- 按内部 `PrintingOrder.id` 查询。
- 按 `external_order_id` 查询。
- 司机、状态、车次、电话、车辆容量、备注、内部备注过滤。
- 开始送货时间和出货日期范围过滤。
- 分页和排序。
- 送货单去重。
- `external_order_id` 多匹配返回 HTTP 500 和明确 `message`。