xsh_1997 1 неделя назад
Родитель
Сommit
84a57978f3

+ 241 - 0
doc/店铺后台/库存管理/商品出库/商品出库前端技术方案.md

@@ -0,0 +1,241 @@
1
+# 商品出库 — 前端技术方案
2
+
3
+> **依据:** 《商品出库功能需求.md》v1.0.1、《商品出库技术方案.md》v1.0.1  
4
+> **前端规范:** `doc/前端设计/前端设计.md`  
5
+> **范围:** 仅 **ruoyi-ui 商家端** 当前店铺出库单列表、高级检索、**手工出库**、详情查看;**不含** 支付扣减写入、C 端、平台端、库存调整页。  
6
+> **实现状态:** `index.vue`、`detail.vue`、`form.vue`、`api/agri/seller/stock/outbound.js` **已按 v1.0.1 落地**;待菜单配置及 `/agri/seller/stock/outbound` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与写法约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | Vue 2 + Element UI |
15
+| 请求 | `@/utils/request` + `sellerShopHeaders()` 携带 **`X-Shop-Id`** |
16
+| 参考页面 | `agri/seller/stock/inbound/*`(对称实现) |
17
+| 布局 | 检索 `el-card` + `<br/>` + 列表 `el-card` + `border` 表格 |
18
+| 详情 | 右侧 `el-drawer`(`size="72%"`,`append-to-body`) |
19
+| 手工出库 | 右侧 `el-drawer`(`size="85%"`,底部固定「取消/确认」) |
20
+| 图片 | 全局 `image-preview` |
21
+| 店铺切换 | **仅 Navbar**;业务页禁止展示店铺选择器 |
22
+
23
+---
24
+
25
+## 2. 业务要点(前端需体现)
26
+
27
+| 项 | 说明 |
28
+|----|------|
29
+| 出库类型 | `0` 下单扣减 / `1` 删除产品规格(自动)/ `2` 订单出库 / `3` 过期出库 / `4` 其他出库 |
30
+| 手工表单 | **仅 2~4**;默认 **过期出库(3)**(对齐 UI 稿) |
31
+| 命名 | **「下单扣减」≠「订单出库」**;列表/详情统一后端 `outboundTypeText` |
32
+| 自动出库 | 支付扣减、删规格 **仅留痕**;详情展示提示文案 |
33
+| 手工出库 | 确认后 **减少可用库存**;单据 **不可编辑/删除** |
34
+| 库存校验 | 选品 **stock>0**;出库数量 **≤ 当前可用库存** |
35
+| 关联订单 | 类型 `0` 详情展示 `refOrderNo`(只读文本) |
36
+
37
+---
38
+
39
+## 3. 文件清单
40
+
41
+| 类型 | 路径 | 说明 |
42
+|------|------|------|
43
+| 列表页 | `ruoyi-ui/src/views/agri/seller/stock/outbound/index.vue` | 检索、表格、分页、打开出库/详情抽屉 |
44
+| 详情抽屉 | `ruoyi-ui/src/views/agri/seller/stock/outbound/detail.vue` | 基本信息 + 出库明细(只读) |
45
+| 出库抽屉 | `ruoyi-ui/src/views/agri/seller/stock/outbound/form.vue` | 手工出库表单、选品、条码、明细编辑 |
46
+| 出库 API | `ruoyi-ui/src/api/agri/seller/stock/outbound.js` | list、detail、create、operators、goods/options、goods/byBarcode |
47
+| 店铺上下文 | `api/agri/seller/context.js` + `utils/sellerShop.js` | X-Shop-Id |
48
+
49
+**组件 name(keep-alive):**
50
+
51
+| 组件 | name |
52
+|------|------|
53
+| 列表页 | `AgriSellerStockOutbound` |
54
+| 详情抽屉 | `SellerStockOutboundDetail` |
55
+| 出库抽屉 | `SellerStockOutboundForm` |
56
+
57
+---
58
+
59
+## 4. 菜单与路由
60
+
61
+| 菜单名称 | 组件路径 | 路由 path(建议) | 权限标识 |
62
+|----------|----------|-------------------|----------|
63
+| 商品出库 | `agri/seller/stock/outbound/index` | `seller/stock/outbound` | `agri:seller:stock:outbound:list` |
64
+
65
+**上级菜单:** 店铺经营管理端 → **库存管理**
66
+
67
+| 按钮权限 | 标识 | 页面落点 |
68
+|----------|------|----------|
69
+| 列表 | `agri:seller:stock:outbound:list` | 进入列表页、经办人下拉 |
70
+| 详情 | `agri:seller:stock:outbound:query` | 操作列「详情」 |
71
+| 手工出库 | `agri:seller:stock:outbound:add` | 「商品出库」、选品、条码 |
72
+
73
+---
74
+
75
+## 5. 页面结构(与代码一致)
76
+
77
+```text
78
+商品出库 index.vue
79
+├── 检索区 search-card
80
+│   ├── 出库搜索 keyword(单号/商品名/关联订单号模糊)
81
+│   ├── 出库时间 dateRange → beginTime/endTime
82
+│   ├── 出库类型 outboundType(0~4 下拉)
83
+│   ├── 经办人 operatorId
84
+│   └── 搜索 / 重置
85
+├── 列表区 table-card
86
+│   ├── 商品出库按钮(add 权限)+ right-toolbar
87
+│   ├── el-table border
88
+│   │   ├── 出库单号 outboundNo
89
+│   │   ├── 出库类型 outboundTypeText
90
+│   │   ├── 出库时间 outboundTime
91
+│   │   ├── 备注 remark
92
+│   │   ├── 经办人 operatorName
93
+│   │   └── 操作:详情
94
+│   └── pagination
95
+├── 详情抽屉 SellerStockOutboundDetail(见 §6)
96
+└── 出库抽屉 SellerStockOutboundForm(见 §7)
97
+```
98
+
99
+**不提供:** 页内店铺选择、出库单编辑/作废、打印/导出、跳转订单详情(非本期)。
100
+
101
+---
102
+
103
+## 6. 详情抽屉 detail.vue
104
+
105
+### 6.1 对外接口
106
+
107
+| 类型 | 名称 | 说明 |
108
+|------|------|------|
109
+| props | `visible` | 抽屉显隐(`.sync`) |
110
+| props | `outboundId` | 出库单主键 |
111
+| emit | `update:visible` | 关闭抽屉 |
112
+| 方法 | `reload()` | 预留重载 |
113
+
114
+### 6.2 分区结构
115
+
116
+| 区块 | 内容 |
117
+|------|------|
118
+| 基本信息 | 出库单号、类型、关联订单号(有则展示)、时间、经办人、备注 |
119
+| 系统提示 | type=`0` 支付留痕;type=`1` 删规格留痕(变化量可能为 0) |
120
+| 出库明细 | 商品信息、规格、出库数量、变化前/后库存 |
121
+
122
+**无底部操作栏**(确认后只读)。
123
+
124
+---
125
+
126
+## 7. 手工出库抽屉 form.vue
127
+
128
+### 7.1 基本信息
129
+
130
+| 字段 | 组件 | 校验 |
131
+|------|------|------|
132
+| 出库类型 | `el-radio-group`:过期(3)/订单(2)/其他(4),默认 **3** | 必选 |
133
+| 备注 | `textarea`,maxlength=200 | ≤200 字 |
134
+
135
+### 7.2 出库商品区
136
+
137
+| 能力 | 实现 |
138
+|------|------|
139
+| 选择商品 | 分页多选 → `GET /goods/options`(**仅 stock>0**) |
140
+| 导入商品 | 按钮占位,提示暂未开放 |
141
+| 条码录入 | Enter/确定 → `GET /goods/byBarcode`;无库存提示 |
142
+| 明细表格 | 商品信息、规格、当前可用库存、出库数量(`:max=stock`)、删除 |
143
+
144
+### 7.3 提交 payload
145
+
146
+```json
147
+{
148
+  "outboundType": "3",
149
+  "remark": "optional",
150
+  "items": [{ "goodsId": 1001, "skuId": null, "quantity": 5 }]
151
+}
152
+```
153
+
154
+| 前端校验 | 说明 |
155
+|----------|------|
156
+| 明细至少一行 | OUT2 |
157
+| quantity 为正整数 | OUT3 |
158
+| quantity ≤ stock | 库存不足提示 |
159
+| 重复商品合并数量 | 合并时校验不超 stock |
160
+
161
+成功:`msgSuccess('出库成功')` → 关闭抽屉 → 刷新列表与经办人下拉。
162
+
163
+---
164
+
165
+## 8. 店铺上下文(X-Shop-Id)
166
+
167
+| 步骤 | 说明 |
168
+|------|------|
169
+| `created` | `GET /agri/seller/context` → `setSellerShopContext` |
170
+| 全部 API | `sellerShopHeaders()` |
171
+| Navbar 切店 | `location.reload()` 整页刷新 |
172
+
173
+---
174
+
175
+## 9. API 封装
176
+
177
+**模块:** `@/api/agri/seller/stock/outbound.js`
178
+
179
+| 方法 | HTTP | 路径 | 权限 |
180
+|------|------|------|------|
181
+| `listSellerStockOutbound` | GET | `/list` | list |
182
+| `getSellerStockOutbound` | GET | `/{outboundId}` | query |
183
+| `createSellerStockOutbound` | POST | `/` | add |
184
+| `listStockOutboundOperators` | GET | `/operators` | list |
185
+| `listStockOutboundGoodsOptions` | GET | `/goods/options` | add |
186
+| `getStockOutboundGoodsByBarcode` | GET | `/goods/byBarcode?barcode=` | add |
187
+
188
+**列表 Query:** `pageNum`、`pageSize`、`keyword`、`outboundType`、`operatorId`、`beginTime`、`endTime`。
189
+
190
+**排序:** 后端固定 `outbound_time DESC`。
191
+
192
+---
193
+
194
+## 10. 空状态与错误提示
195
+
196
+| 场景 | 前端表现 |
197
+|------|----------|
198
+| 无数据 | 「暂无出库记录」 |
199
+| 有筛选无结果 | 「未找到符合条件的出库单」 |
200
+| 未选商品 | 「请添加出库商品」 |
201
+| 数量非法 | 「请输入正确的出库数量」 |
202
+| 超库存 | 「出库数量不能大于可用库存」 |
203
+| 条码无库存 | 「该商品无可用库存」 |
204
+| 提交成功 | 「出库成功」 |
205
+
206
+---
207
+
208
+## 11. 与兄弟模块边界(前端)
209
+
210
+| 模块 | 关系 |
211
+|------|------|
212
+| **商品入库** | 对称模块;退货加库存走入库 |
213
+| **库存日志** | 出库成功后写入流水;日志页只读展示 |
214
+| **全部订单** | 支付成功自动出库单在本列表可见;**不在** 订单页手工出库 |
215
+| **商品列表** | 删规格自动出库单(type=1)在本列表可见 |
216
+
217
+---
218
+
219
+## 12. 联调检查清单
220
+
221
+- [ ] 菜单挂载 `agri/seller/stock/outbound/index`
222
+- [ ] 列表含自动单(下单扣减/删规格)与手工单
223
+- [ ] 关键词可搜出库单号、商品名、关联订单号
224
+- [ ] 手工出库:过期/订单/其他类型提交后库存减少
225
+- [ ] 出库数量大于可用库存时前后端均拦截
226
+- [ ] 详情 type=0 展示 refOrderNo 与留痕提示
227
+- [ ] 详情 type=1 明细 quantity 可能为 0
228
+- [ ] Navbar 切店后列表仅当前店数据
229
+- [ ] 权限:无 `add` 不显示「商品出库」
230
+
231
+---
232
+
233
+## 13. 版本记录
234
+
235
+| 版本 | 说明 |
236
+|------|------|
237
+| **v1.0** | 首版:列表/检索/详情抽屉/手工出库抽屉/API 封装;对齐需求 v1.0.1、入库对称实现与 UI 稿 |
238
+
239
+---
240
+
241
+*文档版本:v1.0 · 依据《商品出库功能需求.md》v1.0.1、《商品出库技术方案.md》v1.0.1*

+ 172 - 0
doc/店铺后台/库存管理/库存日志/库存日志前端技术方案.md

@@ -0,0 +1,172 @@
1
+# 库存日志 — 前端技术方案
2
+
3
+> **依据:** 《库存日志功能需求.md》v1.0、《库存日志技术方案.md》v1.0  
4
+> **前端规范:** `doc/前端设计/前端设计.md`  
5
+> **范围:** 仅 **ruoyi-ui 商家端** 当前店铺库存变化流水 **只读列表**、方向 Tab 筛选、关键词检索;**不含** 写入/编辑/删除、详情页、导出、平台端。  
6
+> **实现状态:** `index.vue`、`api/agri/seller/stock/log.js` **已按 v1.0 落地**;待菜单配置及 `/agri/seller/stock/log` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与写法约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | Vue 2 + Element UI |
15
+| 请求 | `@/utils/request` + `sellerShopHeaders()` 携带 **`X-Shop-Id`** |
16
+| 参考页面 | `agri/seller/stock/inbound/index.vue`(检索+页签+列表)、`agri/seller/aftersale/index.vue`(Tab 方向筛选) |
17
+| 布局 | 检索 `el-card` + `<br/>` + 列表 `el-card` + `border` 表格 |
18
+| 店铺切换 | **仅 Navbar**;业务页禁止展示店铺选择器 |
19
+
20
+---
21
+
22
+## 2. 业务要点(前端需体现)
23
+
24
+| 项 | 说明 |
25
+|----|------|
26
+| 只读 | **无** 创建/编辑/删除/跳转单据;日志由入/出库、调整、订单等模块写入 |
27
+| 方向 Tab | 全部 / 入库(`direction=1`)/ 出库(`direction=2`) |
28
+| 关键词 | 模糊匹配 **商品名称**、**商品规格** |
29
+| 「操作」列 | 展示 **变化原因** 文案(`changeReasonText`),**非** 业务按钮 |
30
+| 变化量 | 非负整数展示;**删除产品规格** 可能为 **0** |
31
+| 排序 | 后端固定 **操作时间倒序** |
32
+| 勾选列 | **预留** 导出等非本期能力;**无** 批量操作 |
33
+
34
+---
35
+
36
+## 3. 文件清单
37
+
38
+| 类型 | 路径 | 说明 |
39
+|------|------|------|
40
+| 列表页 | `ruoyi-ui/src/views/agri/seller/stock/log/index.vue` | 关键词检索、方向 Tab、表格、分页 |
41
+| 日志 API | `ruoyi-ui/src/api/agri/seller/stock/log.js` | list(只读) |
42
+| 店铺上下文 | `api/agri/seller/context.js` + `utils/sellerShop.js` | X-Shop-Id |
43
+
44
+**组件 name(keep-alive):** `AgriSellerStockLog`
45
+
46
+**不提供:** 详情抽屉、入/出库跳转、导出、时间区间筛选、变化原因下拉。
47
+
48
+---
49
+
50
+## 4. 菜单与路由
51
+
52
+| 菜单名称 | 组件路径 | 路由 path(建议) | 权限标识 |
53
+|----------|----------|-------------------|----------|
54
+| 库存日志 | `agri/seller/stock/log/index` | `seller/stock/log` | `agri:seller:stock:log:list` |
55
+
56
+**上级菜单:** 店铺经营管理端 → **库存管理**
57
+
58
+| 按钮权限 | 标识 | 说明 |
59
+|----------|------|------|
60
+| 列表查看 | `agri:seller:stock:log:list` | 进入页面、GET /list |
61
+
62
+---
63
+
64
+## 5. 页面结构(与代码一致)
65
+
66
+```text
67
+库存日志 index.vue
68
+├── 检索区 search-card(v-show 受 right-toolbar 控制)
69
+│   ├── 关键词 keyword(商品名称/规格模糊,Enter 或搜索图标)
70
+│   └── 重置
71
+├── 列表区 table-card
72
+│   ├── 方向页签 directionTab:全部(all) | 入库(1) | 出库(2)
73
+│   ├── right-toolbar
74
+│   ├── el-table border(empty-text 动态)
75
+│   │   ├── selection(预留,无批量操作)
76
+│   │   ├── 商品名称(ID + 名称快照)
77
+│   │   ├── 商品规格 specText(空则 —)
78
+│   │   ├── 变化前库存 stockBefore
79
+│   │   ├── 变化后库存 stockAfter
80
+│   │   ├── 变化量 changeQty
81
+│   │   ├── 操作 → changeReasonText(变化原因)
82
+│   │   └── 操作时间 createTime
83
+│   └── pagination
84
+```
85
+
86
+---
87
+
88
+## 6. 变化原因展示(操作列)
89
+
90
+后端 `StockSupport.changeReasonText` 返回完整文案;前端仅对 **`ORDER_CANCEL`** 简写为 **「取消订单返库」**(对齐需求 §3.3 UI 说明)。
91
+
92
+| code | 典型文案 |
93
+|------|----------|
94
+| `NEW_SPEC` | 新增商品规格 |
95
+| `PURCHASE_IN` | 采购入库 |
96
+| `RETURN_IN` | 退货入库 |
97
+| `OTHER_IN` | 其他入库 |
98
+| `ORDER_CANCEL` | 取消订单返库(前端简写) |
99
+| `STOCK_EDIT` | 库存编辑 |
100
+| `ORDER_DEDUCT` | 下单扣减库存 |
101
+| `DELETE_SPEC` | 删除产品规格 |
102
+| `MANUAL_ORDER_OUT` | 订单出库 |
103
+| `EXPIRED_OUT` | 过期出库 |
104
+| `OTHER_OUT` | 其他出库 |
105
+
106
+---
107
+
108
+## 7. 店铺上下文(X-Shop-Id)
109
+
110
+| 步骤 | 说明 |
111
+|------|------|
112
+| `created` | `GET /agri/seller/context` → `setSellerShopContext` |
113
+| GET /list | `sellerShopHeaders()` |
114
+| Navbar 切店 | `location.reload()` 整页刷新 |
115
+
116
+---
117
+
118
+## 8. API 封装
119
+
120
+**模块:** `@/api/agri/seller/stock/log.js`
121
+
122
+| 方法 | HTTP | 路径 | 权限 |
123
+|------|------|------|------|
124
+| `listSellerStockLog` | GET | `/list` | list |
125
+
126
+**Query:** `pageNum`、`pageSize`、`keyword`、`direction`(空=全部,`1`=入库,`2`=出库)。
127
+
128
+**Row 字段:** `logId`、`goodsId`、`goodsName`、`specText`、`stockBefore`、`stockAfter`、`changeQty`、`direction`、`changeReason`、`changeReasonText`、`createTime`。
129
+
130
+---
131
+
132
+## 9. 空状态
133
+
134
+| 场景 | 文案 |
135
+|------|------|
136
+| 无流水 | 「暂无库存日志」 |
137
+| 有筛选无结果 | 「未找到符合条件的库存日志」 |
138
+
139
+---
140
+
141
+## 10. 与兄弟模块边界(前端)
142
+
143
+| 模块 | 关系 |
144
+|------|------|
145
+| **商品入库 / 出库** | 确认后写入日志;本页只读展示 |
146
+| **库存调整** | 库存编辑写入日志;本页只读 |
147
+| **库存查询** | 按商品查明细复用同源 `biz_stock_log`;本页为 **全店时间序** |
148
+| **全部订单** | 支付扣减、关单回滚经后端写日志;**非本期** 从日志行跳转订单 |
149
+
150
+---
151
+
152
+## 11. 联调检查清单
153
+
154
+- [ ] 菜单挂载 `agri/seller/stock/log/index`
155
+- [ ] 列表默认按操作时间倒序,仅当前店铺
156
+- [ ] Tab:全部 / 入库 / 出库 与 `direction` 联动
157
+- [ ] 关键词:商品名称、规格组合过滤
158
+- [ ] 「操作」列展示变化原因,无点击行为
159
+- [ ] 勾选列可见但无批量按钮
160
+- [ ] Navbar 切店后列表刷新
161
+
162
+---
163
+
164
+## 12. 版本记录
165
+
166
+| 版本 | 说明 |
167
+|------|------|
168
+| **v1.0** | 首版:只读列表、方向 Tab、关键词检索、API 封装;对齐需求 v1.0 与 UI 稿 |
169
+
170
+---
171
+
172
+*文档版本:v1.0 · 依据《库存日志功能需求.md》v1.0、《库存日志技术方案.md》v1.0*

+ 12 - 0
ruoyi-ui/src/api/agri/seller/stock/log.js

@@ -0,0 +1,12 @@
1
+import request from '@/utils/request'
2
+import { sellerShopHeaders } from '@/utils/sellerShop'
3
+
4
+// 库存日志列表(只读)
5
+export function listSellerStockLog(query) {
6
+  return request({
7
+    url: '/agri/seller/stock/log/list',
8
+    method: 'get',
9
+    params: query,
10
+    headers: sellerShopHeaders()
11
+  })
12
+}

+ 60 - 0
ruoyi-ui/src/api/agri/seller/stock/outbound.js

@@ -0,0 +1,60 @@
1
+import request from '@/utils/request'
2
+import { sellerShopHeaders } from '@/utils/sellerShop'
3
+
4
+// 出库单列表
5
+export function listSellerStockOutbound(query) {
6
+  return request({
7
+    url: '/agri/seller/stock/outbound/list',
8
+    method: 'get',
9
+    params: query,
10
+    headers: sellerShopHeaders()
11
+  })
12
+}
13
+
14
+// 出库单详情
15
+export function getSellerStockOutbound(outboundId) {
16
+  return request({
17
+    url: '/agri/seller/stock/outbound/' + outboundId,
18
+    method: 'get',
19
+    headers: sellerShopHeaders()
20
+  })
21
+}
22
+
23
+// 手工创建出库单
24
+export function createSellerStockOutbound(data) {
25
+  return request({
26
+    url: '/agri/seller/stock/outbound',
27
+    method: 'post',
28
+    data: data,
29
+    headers: sellerShopHeaders()
30
+  })
31
+}
32
+
33
+// 经办人下拉
34
+export function listStockOutboundOperators() {
35
+  return request({
36
+    url: '/agri/seller/stock/outbound/operators',
37
+    method: 'get',
38
+    headers: sellerShopHeaders()
39
+  })
40
+}
41
+
42
+// 商品选择器(stock>0)
43
+export function listStockOutboundGoodsOptions(query) {
44
+  return request({
45
+    url: '/agri/seller/stock/outbound/goods/options',
46
+    method: 'get',
47
+    params: query,
48
+    headers: sellerShopHeaders()
49
+  })
50
+}
51
+
52
+// 条码/商品编号录入
53
+export function getStockOutboundGoodsByBarcode(barcode) {
54
+  return request({
55
+    url: '/agri/seller/stock/outbound/goods/byBarcode',
56
+    method: 'get',
57
+    params: { barcode },
58
+    headers: sellerShopHeaders()
59
+  })
60
+}

+ 192 - 0
ruoyi-ui/src/views/agri/seller/stock/log/index.vue

@@ -0,0 +1,192 @@
1
+<template>
2
+  <div class="app-container">
3
+    <!-- 检索区 -->
4
+    <el-card shadow="never" class="search-card">
5
+      <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="70px">
6
+        <el-form-item label="关键词" prop="keyword">
7
+          <el-input
8
+            v-model="queryParams.keyword"
9
+            placeholder="输入关键词"
10
+            clearable
11
+            style="width: 220px"
12
+            @keyup.enter.native="handleQuery"
13
+          >
14
+            <el-button slot="append" icon="el-icon-search" @click="handleQuery" />
15
+          </el-input>
16
+        </el-form-item>
17
+        <el-form-item>
18
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
19
+        </el-form-item>
20
+      </el-form>
21
+    </el-card>
22
+
23
+    <br/>
24
+
25
+    <!-- 列表区 -->
26
+    <el-card shadow="never" class="table-card">
27
+      <el-tabs v-model="directionTab" @tab-click="handleTabClick">
28
+        <el-tab-pane label="全部" name="all" />
29
+        <el-tab-pane label="入库" name="1" />
30
+        <el-tab-pane label="出库" name="2" />
31
+      </el-tabs>
32
+
33
+      <el-row :gutter="10" class="mb8">
34
+        <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
35
+      </el-row>
36
+
37
+      <el-table border v-loading="loading" :data="logList" :empty-text="emptyTableText">
38
+        <!-- <el-table-column type="selection" width="50" align="center" /> -->
39
+        <el-table-column label="商品名称" align="left" min-width="100" :show-overflow-tooltip="true">
40
+          <template slot-scope="scope">
41
+            <div class="goods-name-cell">
42
+              <div v-if="scope.row.goodsId" class="goods-id">ID {{ scope.row.goodsId }}</div>
43
+              <div>{{ scope.row.goodsName || '—' }}</div>
44
+            </div>
45
+          </template>
46
+        </el-table-column>
47
+        <el-table-column label="商品规格" align="center" min-width="280" :show-overflow-tooltip="true">
48
+          <template slot-scope="scope">
49
+            <span>{{ specText(scope.row.specText) }}</span>
50
+          </template>
51
+        </el-table-column>
52
+        <el-table-column label="变化前库存" align="center" prop="stockBefore" width="160">
53
+          <template slot-scope="scope">
54
+            <span>{{ scope.row.stockBefore != null ? scope.row.stockBefore : '—' }}</span>
55
+          </template>
56
+        </el-table-column>
57
+        <el-table-column label="变化后库存" align="center" prop="stockAfter" width="160">
58
+          <template slot-scope="scope">
59
+            <span>{{ scope.row.stockAfter != null ? scope.row.stockAfter : '—' }}</span>
60
+          </template>
61
+        </el-table-column>
62
+        <el-table-column label="变化量" align="center" prop="changeQty" width="160">
63
+          <template slot-scope="scope">
64
+            <span>{{ scope.row.changeQty != null ? scope.row.changeQty : '—' }}</span>
65
+          </template>
66
+        </el-table-column>
67
+        <el-table-column label="操作" align="center" min-width="130" :show-overflow-tooltip="true">
68
+          <template slot-scope="scope">
69
+            <span>{{ operationText(scope.row) }}</span>
70
+          </template>
71
+        </el-table-column>
72
+        <el-table-column label="操作时间" align="center" width="180">
73
+          <template slot-scope="scope">
74
+            <span>{{ parseTime(scope.row.createTime) || '—' }}</span>
75
+          </template>
76
+        </el-table-column>
77
+      </el-table>
78
+
79
+      <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
80
+    </el-card>
81
+  </div>
82
+</template>
83
+
84
+<script>
85
+import { listSellerStockLog } from "@/api/agri/seller/stock/log"
86
+import { getSellerContext } from "@/api/agri/seller/context"
87
+import { setSellerShopContext } from "@/utils/sellerShop"
88
+
89
+/** UI 可将「取消订单返库存」简写 */
90
+const REASON_TEXT_MAP = {
91
+  ORDER_CANCEL: "取消订单返库"
92
+}
93
+
94
+export default {
95
+  name: "AgriSellerStockLog",
96
+  data() {
97
+    return {
98
+      loading: false,
99
+      showSearch: true,
100
+      total: 0,
101
+      logList: [],
102
+      directionTab: "all",
103
+      queryParams: {
104
+        pageNum: 1,
105
+        pageSize: 10,
106
+        keyword: undefined,
107
+        direction: undefined
108
+      }
109
+    }
110
+  },
111
+  computed: {
112
+    hasSearchFilter() {
113
+      return !!(this.queryParams.keyword || this.queryParams.direction)
114
+    },
115
+    emptyTableText() {
116
+      return this.hasSearchFilter ? "未找到符合条件的库存日志" : "暂无库存日志"
117
+    }
118
+  },
119
+  created() {
120
+    this.initPage()
121
+  },
122
+  methods: {
123
+    specText(text) {
124
+      const val = text ? String(text).trim() : ""
125
+      return val && val !== "—" ? val : "—"
126
+    },
127
+    operationText(row) {
128
+      if (row.changeReasonText) {
129
+        if (row.changeReason === "ORDER_CANCEL") {
130
+          return REASON_TEXT_MAP.ORDER_CANCEL
131
+        }
132
+        return row.changeReasonText
133
+      }
134
+      return row.changeReason || "—"
135
+    },
136
+    initPage() {
137
+      this.loadShopContext().then(() => {
138
+        this.getList()
139
+      }).catch(() => {
140
+        this.getList()
141
+      })
142
+    },
143
+    loadShopContext() {
144
+      return getSellerContext().then(response => {
145
+        const data = response.data || {}
146
+        if (data.shopId != null) {
147
+          setSellerShopContext(data.shopId, data.shopName)
148
+        }
149
+      })
150
+    },
151
+    getList() {
152
+      this.loading = true
153
+      listSellerStockLog(this.queryParams).then(response => {
154
+        this.logList = response.rows || []
155
+        this.total = response.total || 0
156
+        this.loading = false
157
+      }).catch(() => {
158
+        this.loading = false
159
+      })
160
+    },
161
+    handleTabClick() {
162
+      this.queryParams.direction = this.directionTab === "all" ? undefined : this.directionTab
163
+      this.queryParams.pageNum = 1
164
+      this.getList()
165
+    },
166
+    handleQuery() {
167
+      this.queryParams.pageNum = 1
168
+      this.getList()
169
+    },
170
+    resetQuery() {
171
+      this.resetForm("queryForm")
172
+      this.directionTab = "all"
173
+      this.queryParams.direction = undefined
174
+      this.queryParams.pageNum = 1
175
+      this.getList()
176
+    }
177
+  }
178
+}
179
+</script>
180
+
181
+<style scoped lang="scss">
182
+.mb8 {
183
+  margin-bottom: 8px;
184
+}
185
+.goods-name-cell {
186
+  line-height: 1.4;
187
+}
188
+.goods-id {
189
+  font-size: 12px;
190
+  color: #909399;
191
+}
192
+</style>

+ 150 - 0
ruoyi-ui/src/views/agri/seller/stock/outbound/detail.vue

@@ -0,0 +1,150 @@
1
+<template>
2
+  <el-drawer
3
+    :title="'出库单详情 · ' + (detail.outboundNo || '')"
4
+    :visible.sync="localVisible"
5
+    direction="rtl"
6
+    size="72%"
7
+    append-to-body
8
+    custom-class="detail-drawer"
9
+  >
10
+    <div v-loading="loading" class="drawer-content">
11
+      <h4 class="section-header">基本信息</h4>
12
+      <el-descriptions :column="2" border size="small" class="mb16">
13
+        <el-descriptions-item label="出库单号">{{ detail.outboundNo || '—' }}</el-descriptions-item>
14
+        <el-descriptions-item label="出库类型">{{ detail.outboundTypeText || outboundTypeLabel(detail.outboundType) }}</el-descriptions-item>
15
+        <el-descriptions-item v-if="detail.refOrderNo" label="关联订单">{{ detail.refOrderNo }}</el-descriptions-item>
16
+        <el-descriptions-item label="出库时间">{{ parseTime(detail.outboundTime) || '—' }}</el-descriptions-item>
17
+        <el-descriptions-item label="经办人">{{ detail.operatorName || '—' }}</el-descriptions-item>
18
+        <el-descriptions-item label="备注" :span="detail.refOrderNo ? 1 : 2">{{ detail.remark || '—' }}</el-descriptions-item>
19
+      </el-descriptions>
20
+      <p v-if="detail.outboundType === '0'" class="section-tip">系统自动出库:库存已在支付成功时扣减,本单仅留痕。</p>
21
+      <p v-if="detail.outboundType === '1'" class="section-tip">删除产品规格留痕:变化量可能为 0,库存不在本单再次扣减。</p>
22
+
23
+      <h4 class="section-header">出库明细</h4>
24
+      <el-table border size="small" :data="detail.items || []" empty-text="暂无明细">
25
+        <el-table-column label="商品信息" min-width="220">
26
+          <template slot-scope="scope">
27
+            <div class="goods-cell">
28
+              <image-preview v-if="scope.row.mainPic" :src="scope.row.mainPic" :width="44" :height="44" />
29
+              <span>{{ scope.row.goodsName || '—' }}</span>
30
+            </div>
31
+          </template>
32
+        </el-table-column>
33
+        <el-table-column label="商品规格" width="120" align="center">
34
+          <template slot-scope="scope">
35
+            <span>{{ specText(scope.row.specText) }}</span>
36
+          </template>
37
+        </el-table-column>
38
+        <el-table-column label="出库数量" width="100" align="center">
39
+          <template slot-scope="scope">
40
+            <span>{{ scope.row.quantity != null ? scope.row.quantity : '—' }}</span>
41
+          </template>
42
+        </el-table-column>
43
+        <el-table-column label="变化前库存" width="100" align="center">
44
+          <template slot-scope="scope">
45
+            <span>{{ scope.row.stockBefore != null ? scope.row.stockBefore : '—' }}</span>
46
+          </template>
47
+        </el-table-column>
48
+        <el-table-column label="变化后库存" width="100" align="center">
49
+          <template slot-scope="scope">
50
+            <span>{{ scope.row.stockAfter != null ? scope.row.stockAfter : '—' }}</span>
51
+          </template>
52
+        </el-table-column>
53
+      </el-table>
54
+    </div>
55
+  </el-drawer>
56
+</template>
57
+
58
+<script>
59
+import { getSellerStockOutbound } from "@/api/agri/seller/stock/outbound"
60
+
61
+const OUTBOUND_TYPE_MAP = {
62
+  "0": "下单扣减",
63
+  "1": "删除产品规格",
64
+  "2": "订单出库",
65
+  "3": "过期出库",
66
+  "4": "其他出库"
67
+}
68
+
69
+export default {
70
+  name: "SellerStockOutboundDetail",
71
+  props: {
72
+    visible: { type: Boolean, default: false },
73
+    outboundId: { type: [Number, String], default: null }
74
+  },
75
+  data() {
76
+    return {
77
+      localVisible: false,
78
+      loading: false,
79
+      detail: {}
80
+    }
81
+  },
82
+  watch: {
83
+    visible(val) {
84
+      this.localVisible = val
85
+      if (val && this.outboundId) {
86
+        this.loadDetail()
87
+      }
88
+    },
89
+    localVisible(val) {
90
+      this.$emit("update:visible", val)
91
+    },
92
+    outboundId(val) {
93
+      if (val && this.localVisible) {
94
+        this.loadDetail()
95
+      }
96
+    }
97
+  },
98
+  methods: {
99
+    outboundTypeLabel(type) {
100
+      return OUTBOUND_TYPE_MAP[type] || "—"
101
+    },
102
+    specText(text) {
103
+      const val = text ? String(text).trim() : ""
104
+      return val || "—"
105
+    },
106
+    loadDetail() {
107
+      if (!this.outboundId) {
108
+        return
109
+      }
110
+      this.loading = true
111
+      getSellerStockOutbound(this.outboundId).then(response => {
112
+        this.detail = response.data || {}
113
+        this.loading = false
114
+      }).catch(() => {
115
+        this.loading = false
116
+      })
117
+    },
118
+    reload() {
119
+      this.loadDetail()
120
+    }
121
+  }
122
+}
123
+</script>
124
+
125
+<style scoped lang="scss">
126
+.drawer-content {
127
+  padding: 0 20px 20px;
128
+}
129
+.section-header {
130
+  margin: 16px 0 10px;
131
+  font-size: 15px;
132
+  color: #303133;
133
+  border-left: 3px solid #409EFF;
134
+  padding-left: 8px;
135
+}
136
+.section-tip {
137
+  margin: -8px 0 12px;
138
+  font-size: 12px;
139
+  color: #909399;
140
+  line-height: 1.5;
141
+}
142
+.mb16 {
143
+  margin-bottom: 16px;
144
+}
145
+.goods-cell {
146
+  display: flex;
147
+  align-items: center;
148
+  gap: 8px;
149
+}
150
+</style>

+ 467 - 0
ruoyi-ui/src/views/agri/seller/stock/outbound/form.vue

@@ -0,0 +1,467 @@
1
+<template>
2
+  <el-drawer
3
+    title="商品出库"
4
+    :visible.sync="localVisible"
5
+    direction="rtl"
6
+    size="85%"
7
+    append-to-body
8
+    :wrapper-closable="false"
9
+    custom-class="outbound-form-drawer"
10
+    @close="handleClose"
11
+  >
12
+    <div class="drawer-body">
13
+      <h4 class="section-header">基本信息</h4>
14
+      <el-form ref="baseFormRef" :model="form" :rules="baseRules" label-width="90px" size="small" class="base-form">
15
+        <el-form-item label="出库类型" prop="outboundType">
16
+          <el-radio-group v-model="form.outboundType">
17
+            <el-radio label="3">过期出库</el-radio>
18
+            <el-radio label="2">订单出库</el-radio>
19
+            <el-radio label="4">其他出库</el-radio>
20
+          </el-radio-group>
21
+        </el-form-item>
22
+        <el-form-item label="备注" prop="remark">
23
+          <el-input
24
+            v-model="form.remark"
25
+            type="textarea"
26
+            :rows="3"
27
+            placeholder="请输入备注"
28
+            maxlength="200"
29
+            show-word-limit
30
+            style="max-width: 640px"
31
+          />
32
+        </el-form-item>
33
+      </el-form>
34
+
35
+      <h4 class="section-header">出库商品</h4>
36
+      <div class="goods-toolbar">
37
+        <el-button type="primary" size="mini" icon="el-icon-goods" @click="openGoodsPicker">选择商品</el-button>
38
+        <!-- <el-button size="mini" icon="el-icon-upload2" @click="handleImportTip">导入商品</el-button>
39
+        <div class="barcode-wrap">
40
+          <el-input
41
+            v-model="barcodeInput"
42
+            placeholder="请输入商品条码"
43
+            clearable
44
+            size="small"
45
+            style="width: 280px"
46
+            @keyup.enter.native="handleBarcodeConfirm"
47
+          >
48
+            <i slot="prefix" class="el-input__icon el-icon-full-screen" />
49
+            <el-button slot="append" type="primary" @click="handleBarcodeConfirm">确定(Enter)</el-button>
50
+          </el-input>
51
+        </div> -->
52
+      </div>
53
+
54
+      <el-table border size="small" :data="formItems" class="items-table" empty-text="请添加出库商品">
55
+        <el-table-column label="商品信息" min-width="220">
56
+          <template slot-scope="scope">
57
+            <div class="goods-cell">
58
+              <image-preview v-if="scope.row.mainPic" :src="scope.row.mainPic" :width="44" :height="44" />
59
+              <span>{{ scope.row.goodsName || '—' }}</span>
60
+            </div>
61
+          </template>
62
+        </el-table-column>
63
+        <el-table-column label="商品规格" width="120" align="center">
64
+          <template slot-scope="scope">
65
+            <span>{{ specText(scope.row.specText) }}</span>
66
+          </template>
67
+        </el-table-column>
68
+        <el-table-column label="当前可用库存" width="120" align="center">
69
+          <template slot-scope="scope">
70
+            <span>{{ scope.row.stock != null ? scope.row.stock : '—' }}</span>
71
+          </template>
72
+        </el-table-column>
73
+        <el-table-column label="出库数量" width="160" align="center">
74
+          <template slot-scope="scope">
75
+            <el-input-number
76
+              v-model="scope.row.quantity"
77
+              :min="1"
78
+              :max="scope.row.stock != null ? scope.row.stock : undefined"
79
+              :precision="0"
80
+              controls-position="right"
81
+              size="small"
82
+              style="width: 120px"
83
+            />
84
+          </template>
85
+        </el-table-column>
86
+        <el-table-column label="操作" width="80" align="center">
87
+          <template slot-scope="scope">
88
+            <el-button type="text" size="mini" @click="removeItem(scope.$index)">删除</el-button>
89
+          </template>
90
+        </el-table-column>
91
+      </el-table>
92
+    </div>
93
+
94
+    <div class="drawer-footer">
95
+      <el-button @click="handleCancel">取 消</el-button>
96
+      <el-button type="primary" :loading="submitting" @click="handleSubmit">确 认</el-button>
97
+    </div>
98
+
99
+    <!-- 商品选择器 -->
100
+    <el-dialog title="选择商品" :visible.sync="pickerOpen" width="820px" append-to-body @close="resetPicker">
101
+      <p class="pick-tip">仅展示当前可用库存 &gt; 0 的商品</p>
102
+      <el-form :inline="true" size="small" @submit.native.prevent>
103
+        <el-form-item label="商品名称">
104
+          <el-input v-model="pickerQuery.keyword" placeholder="名称/编号" clearable style="width: 200px" @keyup.enter.native="searchGoodsOptions" />
105
+        </el-form-item>
106
+        <el-form-item>
107
+          <el-button type="primary" icon="el-icon-search" @click="searchGoodsOptions">搜索</el-button>
108
+        </el-form-item>
109
+      </el-form>
110
+      <el-table
111
+        ref="pickerTable"
112
+        border
113
+        v-loading="pickerLoading"
114
+        :data="pickerList"
115
+        max-height="360"
116
+        @selection-change="handlePickerSelection"
117
+      >
118
+        <el-table-column type="selection" width="50" align="center" />
119
+        <el-table-column label="商品信息" min-width="200">
120
+          <template slot-scope="scope">
121
+            <div class="goods-cell">
122
+              <image-preview v-if="scope.row.mainPic" :src="scope.row.mainPic" :width="40" :height="40" />
123
+              <span>{{ scope.row.goodsName || '—' }}</span>
124
+            </div>
125
+          </template>
126
+        </el-table-column>
127
+        <el-table-column label="规格" width="100" align="center">
128
+          <template slot-scope="scope">
129
+            <span>{{ specText(scope.row.specText) }}</span>
130
+          </template>
131
+        </el-table-column>
132
+        <el-table-column label="编号" prop="goodsSn" width="120" :show-overflow-tooltip="true" />
133
+        <el-table-column label="可用库存" prop="stock" width="90" align="center" />
134
+      </el-table>
135
+      <pagination
136
+        v-show="pickerTotal > 0"
137
+        :total="pickerTotal"
138
+        :page.sync="pickerQuery.pageNum"
139
+        :limit.sync="pickerQuery.pageSize"
140
+        @pagination="loadGoodsOptions"
141
+      />
142
+      <div slot="footer" class="dialog-footer">
143
+        <el-button type="primary" @click="confirmPicker">确 定</el-button>
144
+        <el-button @click="pickerOpen = false">取 消</el-button>
145
+      </div>
146
+    </el-dialog>
147
+
148
+    <!-- 条码多匹配选择 -->
149
+    <el-dialog title="选择商品规格" :visible.sync="barcodePickOpen" width="640px" append-to-body>
150
+      <p class="pick-tip">该条码匹配到多个商品,请选择其一:</p>
151
+      <el-table border :data="barcodeCandidates" @row-click="selectBarcodeCandidate">
152
+        <el-table-column label="商品信息" min-width="180">
153
+          <template slot-scope="scope">
154
+            <div class="goods-cell">
155
+              <image-preview v-if="scope.row.mainPic" :src="scope.row.mainPic" :width="40" :height="40" />
156
+              <span>{{ scope.row.goodsName || '—' }}</span>
157
+            </div>
158
+          </template>
159
+        </el-table-column>
160
+        <el-table-column label="规格" width="100" align="center">
161
+          <template slot-scope="scope">
162
+            <span>{{ specText(scope.row.specText) }}</span>
163
+          </template>
164
+        </el-table-column>
165
+        <el-table-column label="可用库存" prop="stock" width="90" align="center" />
166
+        <el-table-column label="操作" width="80" align="center">
167
+          <template slot-scope="scope">
168
+            <el-button type="text" size="mini" @click.stop="selectBarcodeCandidate(scope.row)">选择</el-button>
169
+          </template>
170
+        </el-table-column>
171
+      </el-table>
172
+    </el-dialog>
173
+  </el-drawer>
174
+</template>
175
+
176
+<script>
177
+import {
178
+  createSellerStockOutbound,
179
+  listStockOutboundGoodsOptions,
180
+  getStockOutboundGoodsByBarcode
181
+} from "@/api/agri/seller/stock/outbound"
182
+
183
+export default {
184
+  name: "SellerStockOutboundForm",
185
+  props: {
186
+    visible: { type: Boolean, default: false }
187
+  },
188
+  data() {
189
+    return {
190
+      localVisible: false,
191
+      submitting: false,
192
+      form: {
193
+        outboundType: "3",
194
+        remark: undefined
195
+      },
196
+      formItems: [],
197
+      barcodeInput: "",
198
+      baseRules: {
199
+        outboundType: [{ required: true, message: "请选择出库类型", trigger: "change" }]
200
+      },
201
+      pickerOpen: false,
202
+      pickerLoading: false,
203
+      pickerList: [],
204
+      pickerTotal: 0,
205
+      pickerSelection: [],
206
+      pickerQuery: {
207
+        pageNum: 1,
208
+        pageSize: 10,
209
+        keyword: undefined
210
+      },
211
+      barcodePickOpen: false,
212
+      barcodeCandidates: []
213
+    }
214
+  },
215
+  watch: {
216
+    visible(val) {
217
+      this.localVisible = val
218
+      if (val) {
219
+        this.resetForm()
220
+      }
221
+    },
222
+    localVisible(val) {
223
+      this.$emit("update:visible", val)
224
+    }
225
+  },
226
+  methods: {
227
+    specText(text) {
228
+      const val = text ? String(text).trim() : ""
229
+      return val || "—"
230
+    },
231
+    resetForm() {
232
+      this.form = {
233
+        outboundType: "3",
234
+        remark: undefined
235
+      }
236
+      this.formItems = []
237
+      this.barcodeInput = ""
238
+      this.submitting = false
239
+      this.$nextTick(() => {
240
+        if (this.$refs.baseFormRef) {
241
+          this.$refs.baseFormRef.clearValidate()
242
+        }
243
+      })
244
+    },
245
+    handleClose() {
246
+      this.resetForm()
247
+    },
248
+    handleCancel() {
249
+      this.localVisible = false
250
+    },
251
+    openGoodsPicker() {
252
+      this.pickerOpen = true
253
+      this.pickerQuery.pageNum = 1
254
+      this.loadGoodsOptions()
255
+    },
256
+    resetPicker() {
257
+      this.pickerSelection = []
258
+      this.pickerQuery.keyword = undefined
259
+      if (this.$refs.pickerTable) {
260
+        this.$refs.pickerTable.clearSelection()
261
+      }
262
+    },
263
+    searchGoodsOptions() {
264
+      this.pickerQuery.pageNum = 1
265
+      this.loadGoodsOptions()
266
+    },
267
+    loadGoodsOptions() {
268
+      this.pickerLoading = true
269
+      listStockOutboundGoodsOptions(this.pickerQuery).then(response => {
270
+        this.pickerList = response.rows || []
271
+        this.pickerTotal = response.total || 0
272
+        this.pickerLoading = false
273
+      }).catch(() => {
274
+        this.pickerLoading = false
275
+      })
276
+    },
277
+    handlePickerSelection(rows) {
278
+      this.pickerSelection = rows || []
279
+    },
280
+    confirmPicker() {
281
+      if (!this.pickerSelection.length) {
282
+        this.$modal.msgWarning("请选择商品")
283
+        return
284
+      }
285
+      this.pickerSelection.forEach(row => {
286
+        this.appendGoodsRow(row)
287
+      })
288
+      this.pickerOpen = false
289
+    },
290
+    handleImportTip() {
291
+      this.$modal.msgWarning("批量导入功能暂未开放,请使用选择商品或条码录入")
292
+    },
293
+    handleBarcodeConfirm() {
294
+      const code = this.barcodeInput ? String(this.barcodeInput).trim() : ""
295
+      if (!code) {
296
+        this.$modal.msgWarning("请输入商品条码")
297
+        return
298
+      }
299
+      getStockOutboundGoodsByBarcode(code).then(response => {
300
+        const rows = response.data || []
301
+        if (!rows.length) {
302
+          this.$modal.msgError("未找到该条码对应的商品")
303
+          return
304
+        }
305
+        const validRows = rows.filter(row => row.stock != null && row.stock > 0)
306
+        if (!validRows.length) {
307
+          this.$modal.msgError("该商品无可用库存")
308
+          return
309
+        }
310
+        if (validRows.length === 1) {
311
+          this.appendGoodsRow(validRows[0])
312
+          this.barcodeInput = ""
313
+          return
314
+        }
315
+        this.barcodeCandidates = validRows
316
+        this.barcodePickOpen = true
317
+      })
318
+    },
319
+    selectBarcodeCandidate(row) {
320
+      this.appendGoodsRow(row)
321
+      this.barcodePickOpen = false
322
+      this.barcodeCandidates = []
323
+      this.barcodeInput = ""
324
+    },
325
+    itemKey(row) {
326
+      const skuId = row.skuId != null ? row.skuId : ""
327
+      return `${row.goodsId}_${skuId}`
328
+    },
329
+    appendGoodsRow(row) {
330
+      if (!row || row.goodsId == null) {
331
+        return
332
+      }
333
+      if (row.stock == null || row.stock <= 0) {
334
+        this.$modal.msgWarning("该商品无可用库存")
335
+        return
336
+      }
337
+      const key = this.itemKey(row)
338
+      const exist = this.formItems.find(item => this.itemKey(item) === key)
339
+      if (exist) {
340
+        const nextQty = (exist.quantity || 0) + 1
341
+        if (nextQty > exist.stock) {
342
+          this.$modal.msgWarning("出库数量不能大于可用库存")
343
+          return
344
+        }
345
+        exist.quantity = nextQty
346
+        this.$modal.msgSuccess("该商品已在明细中,已合并数量")
347
+        return
348
+      }
349
+      this.formItems.push({
350
+        goodsId: row.goodsId,
351
+        skuId: row.skuId || null,
352
+        goodsName: row.goodsName,
353
+        mainPic: row.mainPic,
354
+        specText: row.specText,
355
+        stock: row.stock,
356
+        quantity: 1
357
+      })
358
+    },
359
+    removeItem(index) {
360
+      this.formItems.splice(index, 1)
361
+    },
362
+    validateItems() {
363
+      if (!this.formItems.length) {
364
+        this.$modal.msgWarning("请添加出库商品")
365
+        return false
366
+      }
367
+      for (let i = 0; i < this.formItems.length; i++) {
368
+        const item = this.formItems[i]
369
+        const qty = item.quantity
370
+        if (qty == null || !Number.isInteger(Number(qty)) || Number(qty) < 1) {
371
+          this.$modal.msgWarning("请输入正确的出库数量")
372
+          return false
373
+        }
374
+        if (item.stock != null && Number(qty) > Number(item.stock)) {
375
+          this.$modal.msgWarning("出库数量不能大于可用库存")
376
+          return false
377
+        }
378
+      }
379
+      return true
380
+    },
381
+    handleSubmit() {
382
+      this.$refs.baseFormRef.validate(valid => {
383
+        if (!valid || !this.validateItems()) {
384
+          return
385
+        }
386
+        const payload = {
387
+          outboundType: this.form.outboundType,
388
+          remark: this.form.remark ? String(this.form.remark).trim() : undefined,
389
+          items: this.formItems.map(item => ({
390
+            goodsId: item.goodsId,
391
+            skuId: item.skuId || null,
392
+            quantity: Number(item.quantity)
393
+          }))
394
+        }
395
+        this.submitting = true
396
+        createSellerStockOutbound(payload).then(response => {
397
+          const data = response.data || {}
398
+          this.$modal.msgSuccess("出库成功")
399
+          this.localVisible = false
400
+          this.$emit("success", data)
401
+          this.submitting = false
402
+        }).catch(() => {
403
+          this.submitting = false
404
+        })
405
+      })
406
+    }
407
+  }
408
+}
409
+</script>
410
+
411
+<style scoped lang="scss">
412
+.drawer-body {
413
+  padding: 0 20px 72px;
414
+}
415
+.section-header {
416
+  margin: 8px 0 12px;
417
+  font-size: 15px;
418
+  color: #303133;
419
+  border-left: 3px solid #409EFF;
420
+  padding-left: 8px;
421
+}
422
+.base-form {
423
+  margin-bottom: 8px;
424
+}
425
+.goods-toolbar {
426
+  display: flex;
427
+  flex-wrap: wrap;
428
+  align-items: center;
429
+  gap: 10px;
430
+  margin-bottom: 12px;
431
+}
432
+.barcode-wrap {
433
+  margin-left: auto;
434
+}
435
+.items-table {
436
+  margin-top: 4px;
437
+}
438
+.goods-cell {
439
+  display: flex;
440
+  align-items: center;
441
+  gap: 8px;
442
+}
443
+.drawer-footer {
444
+  position: absolute;
445
+  right: 0;
446
+  bottom: 0;
447
+  left: 0;
448
+  z-index: 10;
449
+  padding: 12px 20px;
450
+  text-align: right;
451
+  background: #fff;
452
+  border-top: 1px solid #EBEEF5;
453
+}
454
+.pick-tip {
455
+  margin: 0 0 12px;
456
+  font-size: 13px;
457
+  color: #606266;
458
+}
459
+</style>
460
+
461
+<style lang="scss">
462
+.outbound-form-drawer .el-drawer__body {
463
+  position: relative;
464
+  padding: 0;
465
+  overflow: auto;
466
+}
467
+</style>

+ 232 - 0
ruoyi-ui/src/views/agri/seller/stock/outbound/index.vue

@@ -0,0 +1,232 @@
1
+<template>
2
+  <div class="app-container">
3
+    <!-- 检索区 -->
4
+    <el-card shadow="never" class="search-card">
5
+      <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="90px">
6
+        <el-form-item label="出库搜索" prop="keyword">
7
+          <el-input
8
+            v-model="queryParams.keyword"
9
+            placeholder="出库单号/商品名称/订单号"
10
+            clearable
11
+            style="width: 200px"
12
+            @keyup.enter.native="handleQuery"
13
+          />
14
+        </el-form-item>
15
+        <el-form-item label="出库时间">
16
+          <el-date-picker
17
+            v-model="dateRange"
18
+            type="daterange"
19
+            value-format="yyyy-MM-dd"
20
+            range-separator="至"
21
+            start-placeholder="开始日期"
22
+            end-placeholder="结束日期"
23
+            style="width: 240px"
24
+          />
25
+        </el-form-item>
26
+        <el-form-item label="出库类型" prop="outboundType">
27
+          <el-select v-model="queryParams.outboundType" placeholder="全部" clearable style="width: 150px">
28
+            <el-option v-for="item in outboundTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
29
+          </el-select>
30
+        </el-form-item>
31
+        <el-form-item label="经办人" prop="operatorId">
32
+          <el-select v-model="queryParams.operatorId" placeholder="全部" clearable filterable style="width: 140px">
33
+            <el-option
34
+              v-for="item in operatorOptions"
35
+              :key="item.operatorId"
36
+              :label="item.operatorName"
37
+              :value="item.operatorId"
38
+            />
39
+          </el-select>
40
+        </el-form-item>
41
+        <el-form-item>
42
+          <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
43
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
44
+        </el-form-item>
45
+      </el-form>
46
+    </el-card>
47
+
48
+    <br/>
49
+
50
+    <!-- 列表区 -->
51
+    <el-card shadow="never" class="table-card">
52
+      <el-row :gutter="10" class="mb8">
53
+        <el-col :span="1.5">
54
+          <el-button type="primary" icon="el-icon-minus" size="mini" @click="handleCreate" v-hasPermi="['agri:seller:stock:outbound:add']">商品出库</el-button>
55
+        </el-col>
56
+        <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
57
+      </el-row>
58
+
59
+      <el-table border v-loading="loading" :data="outboundList" :empty-text="emptyTableText">
60
+        <el-table-column label="出库单号" align="center" prop="outboundNo" min-width="180" :show-overflow-tooltip="true" />
61
+        <el-table-column label="出库类型" align="center" width="130">
62
+          <template slot-scope="scope">
63
+            <span>{{ scope.row.outboundTypeText || outboundTypeLabel(scope.row.outboundType) }}</span>
64
+          </template>
65
+        </el-table-column>
66
+        <el-table-column label="出库时间" align="center" width="160">
67
+          <template slot-scope="scope">
68
+            <span>{{ parseTime(scope.row.outboundTime) || '—' }}</span>
69
+          </template>
70
+        </el-table-column>
71
+        <el-table-column label="备注" align="center" min-width="120" :show-overflow-tooltip="true">
72
+          <template slot-scope="scope">
73
+            <span>{{ scope.row.remark || '—' }}</span>
74
+          </template>
75
+        </el-table-column>
76
+        <el-table-column label="经办人" align="center" prop="operatorName" width="100" :show-overflow-tooltip="true">
77
+          <template slot-scope="scope">
78
+            <span>{{ scope.row.operatorName || '—' }}</span>
79
+          </template>
80
+        </el-table-column>
81
+        <el-table-column label="操作" align="center" width="90" fixed="right">
82
+          <template slot-scope="scope">
83
+            <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)" v-hasPermi="['agri:seller:stock:outbound:query']">详情</el-button>
84
+          </template>
85
+        </el-table-column>
86
+      </el-table>
87
+
88
+      <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
89
+    </el-card>
90
+
91
+    <!-- 详情抽屉 -->
92
+    <seller-stock-outbound-detail
93
+      ref="outboundDetail"
94
+      :visible.sync="detailOpen"
95
+      :outbound-id="currentOutboundId"
96
+    />
97
+
98
+    <!-- 手工出库抽屉 -->
99
+    <seller-stock-outbound-form
100
+      :visible.sync="formOpen"
101
+      @success="handleFormSuccess"
102
+    />
103
+  </div>
104
+</template>
105
+
106
+<script>
107
+import {
108
+  listSellerStockOutbound,
109
+  listStockOutboundOperators
110
+} from "@/api/agri/seller/stock/outbound"
111
+import { getSellerContext } from "@/api/agri/seller/context"
112
+import { setSellerShopContext } from "@/utils/sellerShop"
113
+import SellerStockOutboundDetail from "./detail"
114
+import SellerStockOutboundForm from "./form"
115
+
116
+const OUTBOUND_TYPE_MAP = {
117
+  "0": "下单扣减",
118
+  "1": "删除产品规格",
119
+  "2": "订单出库",
120
+  "3": "过期出库",
121
+  "4": "其他出库"
122
+}
123
+
124
+export default {
125
+  name: "AgriSellerStockOutbound",
126
+  components: { SellerStockOutboundDetail, SellerStockOutboundForm },
127
+  data() {
128
+    return {
129
+      loading: false,
130
+      showSearch: true,
131
+      total: 0,
132
+      outboundList: [],
133
+      dateRange: [],
134
+      operatorOptions: [],
135
+      detailOpen: false,
136
+      formOpen: false,
137
+      currentOutboundId: null,
138
+      queryParams: {
139
+        pageNum: 1,
140
+        pageSize: 10,
141
+        keyword: undefined,
142
+        outboundType: undefined,
143
+        operatorId: undefined
144
+      },
145
+      outboundTypeOptions: [
146
+        { value: "0", label: "下单扣减" },
147
+        { value: "1", label: "删除产品规格" },
148
+        { value: "2", label: "订单出库" },
149
+        { value: "3", label: "过期出库" },
150
+        { value: "4", label: "其他出库" }
151
+      ]
152
+    }
153
+  },
154
+  computed: {
155
+    hasSearchFilter() {
156
+      return !!(this.queryParams.keyword || this.queryParams.outboundType || this.queryParams.operatorId || (this.dateRange && this.dateRange.length))
157
+    },
158
+    emptyTableText() {
159
+      return this.hasSearchFilter ? "未找到符合条件的出库单" : "暂无出库记录"
160
+    }
161
+  },
162
+  created() {
163
+    this.initPage()
164
+  },
165
+  methods: {
166
+    outboundTypeLabel(type) {
167
+      return OUTBOUND_TYPE_MAP[type] || "—"
168
+    },
169
+    initPage() {
170
+      this.loadShopContext().then(() => {
171
+        this.loadOperators()
172
+        this.getList()
173
+      }).catch(() => {
174
+        this.loadOperators()
175
+        this.getList()
176
+      })
177
+    },
178
+    loadShopContext() {
179
+      return getSellerContext().then(response => {
180
+        const data = response.data || {}
181
+        if (data.shopId != null) {
182
+          setSellerShopContext(data.shopId, data.shopName)
183
+        }
184
+      })
185
+    },
186
+    loadOperators() {
187
+      listStockOutboundOperators().then(response => {
188
+        this.operatorOptions = response.data || []
189
+      }).catch(() => {
190
+        this.operatorOptions = []
191
+      })
192
+    },
193
+    getList() {
194
+      this.loading = true
195
+      listSellerStockOutbound(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
196
+        this.outboundList = response.rows || []
197
+        this.total = response.total || 0
198
+        this.loading = false
199
+      }).catch(() => {
200
+        this.loading = false
201
+      })
202
+    },
203
+    handleQuery() {
204
+      this.queryParams.pageNum = 1
205
+      this.getList()
206
+    },
207
+    resetQuery() {
208
+      this.dateRange = []
209
+      this.resetForm("queryForm")
210
+      this.queryParams.pageNum = 1
211
+      this.getList()
212
+    },
213
+    handleCreate() {
214
+      this.formOpen = true
215
+    },
216
+    handleDetail(row) {
217
+      this.currentOutboundId = row.outboundId
218
+      this.detailOpen = true
219
+    },
220
+    handleFormSuccess() {
221
+      this.loadOperators()
222
+      this.getList()
223
+    }
224
+  }
225
+}
226
+</script>
227
+
228
+<style scoped lang="scss">
229
+.mb8 {
230
+  margin-bottom: 8px;
231
+}
232
+</style>