# 发货模块改造:部分发货 + 批量发货 + 解除完工限制
## 背景
发货模块本次改造三项能力:
1. **部分发货**:系统不再强制要求订单 100% 完工才可发货,发货弹窗可输入"本次发货数量",审批通过后仅按该数量扣减库存。
2. **解除发货按钮锁定**:不再因产品无库存记录 / 产量不足(未完工)锁定"发货"按钮,只要存在剩余可发数量即可发起发货。
3. **批量发货**:支持同一客户多条订单勾选后批量发货,无需逐条操作。
## 涉及页面
- 销售台账列表页(`salesLedger/index.vue`)— 发货弹窗、批量发货入口
- 发货信息管理页(`deliveryLedger/index.vue`)— 发货数量展示
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /shippingInfo/add | 创建发货单并发起发货审批(新增本次发货数量参数) |
| POST | /shippingInfo/batchAdd | 批量发货:同一客户多条订单,逐条创建发货单并发起审批 |
| GET | /sales/product/list | 产品明细列表(响应新增已发数量、剩余可发数量) |
### 1. POST /shippingInfo/add(请求参数变更)
**请求参数:** 在原有字段基础上新增 `quantity`(本次发货数量)。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| salesLedgerProductId | Long | 是 | 销售台账产品明细ID |
| quantity | BigDecimal | 否 | 本次发货数量,不传时默认取产品明细全量数量 |
| type | String | 否 | 审批备注前缀(如 部分发货/批量发货) |
| approveUserIds | Array | 否 | 审批人ID列表 |
| tempFileIds | Array | 否 | 临时附件ID列表 |
| expressNumber | String | 否 | 快递单号 |
| expressCompany | String | 否 | 快递公司 |
| shippingCarNumber | String | 否 | 发货车牌号 |
| shippingDate | String | 否 | 发货日期 |
**校验规则:** `quantity` 必须大于 0 且不超过"剩余可发数量",否则接口返回错误。
**响应:** `{ "code": 200, "msg": "操作成功" }`
### 2. POST /shippingInfo/batchAdd(新增接口)
**请求体:** 数组,元素为发货单数据(字段同 `/shippingInfo/add`)。
```json
[
{
"salesLedgerId": 1,
"salesLedgerProductId": 100,
"quantity": 10,
"type": "批量发货"
},
{
"salesLedgerId": 2,
"salesLedgerProductId": 200,
"quantity": 5,
"type": "批量发货"
}
]
```
**校验规则:** 所有产品明细所属销售台账必须为**同一客户**,否则返回错误"批量发货仅支持同一客户的多条订单"。每条数据独立生成发货编号并独立发起审批。
**响应:** `{ "code": 200, "msg": "操作成功" }`
### 3. GET /sales/product/list(响应新增字段)
响应中每条产品明细新增两个字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| alreadyShippedQuantity | BigDecimal | 已发数量(状态为"审核通过 + 已发货"的发货记录数量之和) |
| unshippedQuantity | BigDecimal | 剩余可发数量 = 产品明细数量 − 已发数量 |
## 前端修改点
### 1. 解除发货按钮锁定(canShip)
`canShip` 原逻辑要求 `hasStockInventory`(产品必须有库存记录)且发货状态为"待发货/审核拒绝",导致未完工/产量不足时发货按钮被锁定。改造后**移除 `!row.hasStockInventory` 限制**,改为判断"剩余可发数量 > 0"。
```js
// 原逻辑
canShip(row) {
return row.hasStockInventory
&& (row.shippingStatus === '待发货' || row.shippingStatus === '审核拒绝');
}
// 新逻辑:解除完工/库存锁定,仅需存在剩余可发数量
canShip(row) {
return row.unshippedQuantity > 0
&& (row.shippingStatus === '待发货' || row.shippingStatus === '审核拒绝');
}
```
### 2. 发货弹窗新增"本次发货数量"
发货弹窗(`deliveryForm`)新增"本次发货数量"输入框,默认值为 `unshippedQuantity`(剩余可发数量),并做上限校验。
```html
剩余可发:{{ deliveryForm.unshippedQuantity }}
```
```js
// data 中初始化
deliveryForm: {
salesLedgerProductId: null,
salesLedgerId: null,
quantity: null, // 本次发货数量
unshippedQuantity: 0, // 剩余可发数量(打开弹窗时赋值)
type: '部分发货',
approveUserIds: [],
tempFileIds: [],
},
// 打开发货弹窗
openDelivery(row) {
this.deliveryForm = {
salesLedgerProductId: row.id,
salesLedgerId: row.salesLedgerId,
quantity: row.unshippedQuantity, // 默认取剩余可发数量
unshippedQuantity: row.unshippedQuantity,
type: '部分发货',
approveUserIds: [],
tempFileIds: [],
};
this.deliveryOpen = true;
},
// 提交发货
submitDelivery() {
const qty = Number(this.deliveryForm.quantity);
if (!qty || qty <= 0) {
this.$modal.msgError('本次发货数量必须大于 0');
return;
}
if (qty > Number(this.deliveryForm.unshippedQuantity)) {
this.$modal.msgError('本次发货数量不能超过剩余可发数量');
return;
}
addShipping(this.deliveryForm).then(res => {
if (res.code === 200) {
this.$modal.msgSuccess('发货成功,已发起审批');
this.deliveryOpen = false;
this.getList(); // 刷新
}
});
}
```
### 3. 批量发货按钮 + 弹窗
主表已有 `type="selection"` 复选框。新增"批量发货"按钮,勾选**同一客户**的多条订单后点击,弹出批量发货弹窗。
```html
批量发货
```
```js
data() {
return {
selectedLedgers: [], // 勾选的销售台账(订单)
batchShippingOpen: false, // 批量发货弹窗开关
batchShippingRows: [], // 可发货产品行列表
};
},
// 勾选变化
handleSelectionChange(selection) {
this.selectedLedgers = selection;
},
// 批量发货:校验同一客户后拉取可发产品行
async handleBatchShipping() {
if (!this.selectedLedgers.length) {
this.$modal.msgWarning('请先勾选要发货的订单');
return;
}
// 同一客户校验(后端也会校验)
const customerNames = [...new Set(this.selectedLedgers.map(l => l.customerName))];
if (customerNames.length > 1) {
this.$modal.msgError('批量发货仅支持同一客户的多条订单');
return;
}
// 拉取各订单的产品明细,收集剩余可发数量 > 0 的行
this.batchShippingRows = [];
for (const ledger of this.selectedLedgers) {
const res = await productList({ salesLedgerId: ledger.id, type: 1 });
const rows = (res.data || []).filter(r => Number(r.unshippedQuantity) > 0);
rows.forEach(r => {
r.salesLedgerId = ledger.id;
r.shipQuantity = r.unshippedQuantity; // 默认本次发货数量 = 剩余可发
});
this.batchShippingRows = this.batchShippingRows.concat(rows);
}
if (!this.batchShippingRows.length) {
this.$modal.msgWarning('所选订单无剩余可发货产品');
return;
}
this.batchShippingOpen = true;
},
// 批量提交
submitBatchShipping() {
// 逐行校验数量
for (const row of this.batchShippingRows) {
const qty = Number(row.shipQuantity);
if (!qty || qty <= 0) {
this.$modal.msgError('存在本次发货数量为空或为 0 的行');
return;
}
if (qty > Number(row.unshippedQuantity)) {
this.$modal.msgError('存在超过剩余可发数量的行');
return;
}
}
const reqs = this.batchShippingRows.map(r => ({
salesLedgerId: r.salesLedgerId,
salesLedgerProductId: r.id,
quantity: r.shipQuantity,
type: '批量发货',
approveUserIds: [],
}));
batchAdd(reqs).then(res => {
if (res.code === 200) {
this.$modal.msgSuccess('批量发货成功,已发起审批');
this.batchShippingOpen = false;
this.getList();
}
});
}
```
批量发货弹窗表格:
```html
```
### 4. 发货信息管理页展示
`deliveryLedger/index.vue` 列表建议展示本次发货数量列(对应后端 `shipping_info.quantity`),区分于订单数量:
```html
```
## 注意事项
- **剩余可发数量口径**:仅"审核通过 + 已发货"状态发货单占用数量;"待审核 / 审核中 / 审核拒绝"不占数量,防止超发。
- **批量发货的客户校验**:前端与后端均有校验,仅支持同一客户多条订单。
- **部分发货扣库存**:审批通过后按"本次发货数量"扣减库存,而非订单全量。
- **删除产品明细**:会级联删除其发货单,剩余可发数量随之恢复。
- **历史数据**:存量发货单已由数据库迁移回填 `quantity`(等于对应产品明细数量),旧数据剩余可发数量显示不受影响。