Files
xiaoqu_mcp_service/xiaoqu-franchisee/doc/out-certificate-dev.md
T
xiaoqu b6d4bf6126 导入三个参考项目: xiaoqu-franchisee / xiaoqu-publicbeanandmap / elasticsearchpublic
作为 xiaoqu-agent-service(MCP 服务) 的开发参考。新系统与旧系统零代码耦合,
仅照搬其行级数据权限规则与数据源结构。设计方案见 xiaoqu-franchisee/doc/agent-mcp-service-design.md
2026-09-01 23:00:47 +08:00

458 lines
20 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 开发文档:出库单上传凭证
> **需求**:在「全部出库单」(web_id=211) 每行新增「上传凭证」按钮。
> - 弹窗可上传图片 / PDF + 填备注(最多 10 个,单文件 ≤ 100MB,仅前端校验);
> - 打开时若已上传过则回显文件列表 + 备注;
> - 弹窗内点 ❌ 不真删,点「提交」时才落库;
> - 「提交」自动判定新增 / 更新。
> **环境**:dev `192.168.1.181:3306`(root/kaixinjiuhao),PRD 上线需把 `authority_id` 替换为 PRD 实际值后重跑。
> **涉及库**:`xiaoqu_franchisee_d`(业务数据)+ `xiaoqu_comples_d`(按钮权限)。
---
## 1. 数据库变更
### 1.1 业务表 — `xiaoqu_franchisee_d`
#### 1.1.1 新表 `warehouse_out_certificate`(出库单凭证 - 备注)
图片本身复用现有 `warehouse_in_out_pic`,本表只保存 **备注**。约定:
- `warehouse_in_out_pic.aggregate_id = warehouse_out.id`
- `warehouse_in_out_pic.file_type = 4`(**新枚举值**:出库凭证)
- `warehouse_in_out_pic.in_out_allocation_type = 99`(**新枚举值**:出库凭证类型,避开 0/1/2/10-14 已用值)
```sql
USE xiaoqu_franchisee_d;
CREATE TABLE `warehouse_out_certificate` (
`id` bigint(8) NOT NULL AUTO_INCREMENT COMMENT '主键',
`out_order_id` bigint(8) NOT NULL COMMENT '出库单ID(warehouse_out.id)',
`out_order_no` varchar(50) DEFAULT '' COMMENT '出库单号(冗余,便于查询)',
`memo` varchar(500) DEFAULT '' COMMENT '备注信息',
`create_id` bigint(8) DEFAULT '0' COMMENT '创建id',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modify_id` bigint(8) DEFAULT '0' COMMENT '修改id',
`modify_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`soft_delete` int(4) DEFAULT '0' COMMENT '数据状态',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_out_order_id` (`out_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单凭证(备注)';
```
> `uk_out_order_id` 唯一索引保证一张出库单只有一条凭证记录;图片可多张挂 `warehouse_in_out_pic`。
#### 1.1.2 扩充 `warehouse_in_out_pic` 字段枚举注释(可选 / 可不执行)
仅刷注释、便于阅读:
```sql
ALTER TABLE `warehouse_in_out_pic`
MODIFY COLUMN `file_type` int(4) DEFAULT '0'
COMMENT '文件类型0:申请图片;1:入库确认图片;2:出库确认图片;3:送货工单签收;4:出库凭证',
MODIFY COLUMN `in_out_allocation_type` int(4) DEFAULT '0'
COMMENT '出入库类型(0:入库;1:出库;2:调拨;10:装机工单;11:送货工单;12:取货工单;13:营销客户申请;14:营销单申请;99:出库凭证)';
```
### 1.2 按钮权限(权限库)
「上传凭证」是行内按钮(`type=0`),挂在「全部出库单」(`web_id=211`) 上,按钮权限**复用该页面当前已有的授权范围**(不写死 `authority_id`,避免环境差异)。
> **环境对应的权限 schema**:
> - dev → `xiaoqu_comples_d`
> - test → `test`
> - prod → `xiaoqu_comples_p`
>
> 切环境只需改第一行 `USE`,其余语句通用。
> **各环境实测授权范围**(2026-05-14 核对):
> - dev (`xiaoqu_comples_d`) `web_id=211` → 授权 2 个 authority:`1, 102`
> - test (`test`) `web_id=211` → 授权 26 个 authority(导自线上)
> - prod (`xiaoqu_comples_p`) 上线前再用下面 §4 校验脚本现查一次
```sql
USE xiaoqu_comples_d; -- dev;test 改 USE test; prod 改 USE xiaoqu_comples_p;
START TRANSACTION;
-- ─────────────────────────────────────────────────────────────
-- ① button:按钮本体
-- type=0 行内按钮 / type=1 顶部 tableButton
-- theme: primary(蓝) / danger(红) / info(灰) / 空
-- 注意:不要硬编码 id,让 AUTO_INCREMENT 决定(不同 schema id 必然不同)
-- ─────────────────────────────────────────────────────────────
INSERT INTO `button`
(web_id, cn_name, en_name, icon, class_name, function_path,
`type`, `status`, order_by, theme, memo, app_en_name)
VALUES
(211, '上传凭证', 'uploadCertificate', '', '', '',
0, 1, 0, 'primary', '出库单上传凭证(图片+备注)', '');
SET @new_button_id := LAST_INSERT_ID();
SELECT @new_button_id AS new_button_id;
-- ─────────────────────────────────────────────────────────────
-- ② button_webpage_authority:动态复用 web_id=211 当前所有授权组
-- 这样不管 dev/test/prod,新按钮始终对所有能进该页的用户可见
-- ─────────────────────────────────────────────────────────────
INSERT INTO `button_webpage_authority`
(web_id, authority_id, cn_name, en_name, button_id,
order_by, check_status, param, icon, `type`, class_name, theme, memo)
SELECT
211,
wa.authority_id,
'上传凭证',
'uploadCertificate',
@new_button_id,
0, 1, '', '', 0, '', 'primary', ''
FROM (
SELECT DISTINCT authority_id
FROM `webpage_authority`
WHERE web_id = 211
AND soft_delete = 0
) wa;
-- ─────────────────────────────────────────────────────────────
-- ③ 校验
-- ─────────────────────────────────────────────────────────────
SELECT id, web_id, cn_name, en_name, `type`, theme FROM `button` WHERE id = @new_button_id;
SELECT authority_id, COUNT(*) AS cnt FROM `button_webpage_authority`
WHERE button_id = @new_button_id AND soft_delete=0
GROUP BY authority_id;
SELECT COUNT(*) AS total_grants FROM `button_webpage_authority`
WHERE button_id = @new_button_id AND soft_delete=0;
-- total_grants 应等于「全部出库单」当前的 webpage_authority 数:
-- dev=2 / test=26 / prod=待现查
COMMIT;
-- 失败请 ROLLBACK
```
> **2026-05-14 已执行情况**:
> - dev (`xiaoqu_comples_d`):button.id=789,2 条 button_webpage_authority(authority 1, 102)
> - test (`test`):button.id=790,26 条 button_webpage_authority(authority 1, 22, 23, 32, 34, 41, 49, 50, 52, 55, 56, 64, 65, 66, 68, 70, 71, 75, 85, 92, 94, 96, 105, 108, 118, 120)
---
## 2. 后端改动 — `xiaoqu-franchisee`
### 2.1 包结构
```
xiaoqu-publicbeanandmap/src/main/java/me/iiv/iivframework/
├── beans/b2/WarehouseOutCertificate.java # @TableName("warehouse_out_certificate")
├── mapper/m2/WarehouseOutCertificateMapper.java # extends BaseMapper
└── service/s2/
├── WarehouseOutCertificateService.java # interface IService
└── Impl/WarehouseOutCertificateServiceImpl.java # extends ServiceImpl
xiaoqu-franchisee/src/main/java/me/iiv/iivframework/controller/warehouse/
├── WarehouseOutController.java # 追加 3 个方法
├── input/OutCertificateSaveInput.java # 新建
└── output/OutCertificateOutput.java # 新建(位置参考已有 WarehouseInOutPicOut 同包)
```
> 文件 `OutCertificateOutput.java` / `OutCertificateSaveInput.java` 实际路径要与项目既有约定一致,参考 `me.iiv.iivframework.controller.output.WarehouseInOutPicOut` / `me.iiv.iivframework.controller.insert.WarehouseInOutPicInsert`。
### 2.2 API(新增 2 个端点)
挂在 `WarehouseOutController` 下(`@RequestMapping("/myWarehouseOut")`):
| Method | URL | 说明 |
|---|---|---|
| GET | `/myWarehouseOut/certificate/get` | 查询出库单的凭证(备注+图片列表) |
| POST | `/myWarehouseOut/certificate/save` | 保存(自动 upsert 备注 + 真删用户去掉的图片 + 绑定新图片) |
#### 2.2.1 `GET /certificate/get`
**Query**
```
outOrderId: Long
```
**Output**
```java
public class OutCertificateOutput {
private Long outOrderId;
private String memo; // 无记录则空串
private List<WarehouseInOutPicOut> picList; // 复用现有 DTO
}
```
**逻辑**
```java
WarehouseOutCertificate cert = certService.getOne(
Wrappers.<WarehouseOutCertificate>lambdaQuery()
.eq(WarehouseOutCertificate::getOutOrderId, outOrderId)
.eq(WarehouseOutCertificate::getSoftDelete, 0)
.last("limit 1"));
List<WarehouseInOutPic> pics = warehouseInOutPicService.list(
Wrappers.<WarehouseInOutPic>lambdaQuery()
.eq(WarehouseInOutPic::getAggregateId, outOrderId)
.eq(WarehouseInOutPic::getFileType, 4)
.eq(WarehouseInOutPic::getInOutAllocationType, 99)
.eq(WarehouseInOutPic::getSoftDelete, 0));
// for each pic: OSSClientUtil.getNormalImageUrl(bucket, folder, fileName) → 组装 WarehouseInOutPicOut
```
#### 2.2.2 `POST /certificate/save`
**Body (JSON)**
```java
public class OutCertificateSaveInput {
private Long outOrderId; // 必填
private String memo; // ≤500
private List<Long> fileIds; // 当前保留的图片 fileId 列表(前端 ❌ 掉的不传)
}
```
**逻辑**(一次性 upsert,幂等)
```text
0. 校验:outOrderId 必填、memo ≤500、fileIds 元素必须均归属本出库单或 aggregate_id=0 的孤儿(防越权改别人的图)。
1. 备注 upsert
- SELECT … WHERE out_order_id=? AND soft_delete=0
- 存在 → UPDATE memo / modify_id / modify_time
- 不存在 → INSERT (out_order_id, out_order_no(冗余取自 warehouse_out), memo, …)
2. 真删被去掉的图片
- dbIds = warehouse_in_out_pic WHERE aggregate_id=outOrderId AND file_type=4 AND in_out_allocation_type=99 AND soft_delete=0
- toDelete = dbIds - fileIds
- 对 toDelete:
a) SET soft_delete=1, modify_time=NOW()
b) OSSClientUtil.deleteFile(bucket, folder, fileName) // 参考 UploadFileController.deleteFile
3. 绑定新上传的图片
- newOnes = fileIds 中 aggregate_id=0 的(即刚通过 /uploadFile/warehouseCertificate 落库的孤儿)
- UPDATE warehouse_in_out_pic
SET aggregate_id=outOrderId, file_type=4, in_out_allocation_type=99,
modify_id=?, modify_time=NOW()
WHERE id IN (newOnes) AND aggregate_id=0 AND soft_delete=0
```
> ⚠️ 上传接口 `/uploadFile/warehouseCertificate` 是**通用接口**(同样用于装机/送货/调拨等),落库时不设 `aggregate_id/file_type/in_out_allocation_type`,因此**必须**靠"提交"时绑定。
> ⚠️ 用户点「返回」不提交时,这次上传的图片在 DB 里 `aggregate_id=0`,会成为孤儿 → 见 §2.4。
### 2.3 Service 接口要点
```java
public interface WarehouseOutCertificateService extends IService<WarehouseOutCertificate> {
OutCertificateOutput getByOutOrderId(Long outOrderId);
void save(Long userId, OutCertificateSaveInput input);
}
```
事务:`save()` 加 `@Transactional`(备注 upsert + 图片软删/绑定 必须原子)。
### 2.4 孤儿图片清理(可选 / 推荐)
用户连续上传多张图后点「返回」会留下 `aggregate_id=0` 的记录。加一个 XXL-Job 定时任务(参考 `task/AutoOutWarehouseTask.java` 同包结构):
```text
每天凌晨:
SELECT id, ossbucketname, ossfolder, file_name
FROM warehouse_in_out_pic
WHERE aggregate_id=0
AND soft_delete=0
AND create_time < NOW() - INTERVAL 1 DAY;
→ 软删 + OSSClientUtil.deleteFile
```
如本期不做,先记录到运维 backlog。
---
## 3. 前端改动 — `vue-manage-system`
### 3.1 API 端点
`src/api/api.js` → `outManage` 块追加:
```js
certificateGet: '/myWarehouseOut/certificate/get',
certificateSave: '/myWarehouseOut/certificate/save',
```
`src/api/warehouseService.js` → `outManageService`:
```js
certificateGet: (params) => request.get($api.outManage.certificateGet, { params }),
certificateSave: (data) => request.post($api.outManage.certificateSave, data),
```
### 3.2 新建组件:`outCertificateForm.vue`
路径:`src/components/page/warehouseInOutManage/outManage/components/outCertificateForm.vue`
props:`outOrderId: Number`;emit:`success`、`cancel`
骨架:
```vue
<template>
<div class="cert-form">
<!-- 上传区:支持图片 + PDF;picList 含图片/PDF 两类,统一用文件名样式 -->
<el-upload
class="cert-upload"
:action="uploadUrl"
:headers="uploadHeaders"
name="image"
:show-file-list="false"
:limit="10"
:before-upload="beforeUpload"
:on-success="handleUploadSuccess"
accept="image/jpeg,image/jpg,image/png,image/gif,image/bmp,application/pdf"
>
<div class="upload-trigger"><i class="el-icon-plus"></i><span>上传</span></div>
</el-upload>
<div class="tip"><b>上传凭证</b> 最多10个文件,每个文件大小限制100M</div>
<el-form label-width="60px">
<el-form-item label="备注:">
<el-input
v-model="memo"
type="textarea"
:rows="6"
maxlength="500"
show-word-limit
placeholder="请输入备注信息(选填)" />
</el-form-item>
</el-form>
<el-divider content-position="center">凭证</el-divider>
<div v-if="picList.length === 0" class="empty">暂无数据</div>
<div v-else class="pic-list">
<span v-for="(p, i) in picList" :key="p.fileId" class="pic-item" @click="preview(p)">
<i :class="isPdf(p.fileName) ? 'el-icon-document' : 'el-icon-picture-outline'"></i>
{{ p.fileName }}
<i class="el-icon-error remove" @click.stop="removePic(i)"></i> <!-- ❌ 仅本地移除 -->
</span>
</div>
<div class="footer">
<el-button @click="$emit('cancel')">返回</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</div>
</template>
```
关键交互:
- `mounted` → 调 `outManageService.certificateGet({outOrderId})` → 回显 `memo` + `picList`
- `beforeUpload(file)` 前端校验(仅前端,不走后端兜底):
- 扩展名必须在 `['jpg','jpeg','png','gif','bmp','pdf']`
- 大小 ≤ `100 * 1024 * 1024`(100MB)
- 当前列表 + 待上传不超过 10 个
- 任一不满足 `this.$message.error('...')` + `return false` 终止上传
- 上传成功回调把 `{fileId, fileName, url}` push 进 `picList`,**不调保存接口**
- `removePic(i)` 仅 `splice`,不调接口
- `preview(p)`:图片走 `el-image-viewer` 大图预览;PDF `window.open(p.url)` 新窗口打开
- 「提交」:`certificateSave({outOrderId, memo, fileIds: picList.map(p=>p.fileId)})` → 成功 emit `success`
### 3.3 `outAllDoc.vue` 接入
引入组件、加 dialog、在 `buttonsClickFun` 加 handler:
```js
import outCertificateForm from './components/outCertificateForm.vue';
components: { ..., outCertificateForm },
data() { return {
...,
showCertificate: false,
certificateOrderId: null,
} },
// mounted() 内 buttonsClickFun 追加:
uploadCertificate: () => {
this.certificateOrderId = this.currentForm.id;
this.showCertificate = true;
}
```
模板末尾:
```vue
<el-dialog
title="上传凭证"
:visible.sync="showCertificate"
width="600px"
v-if="showCertificate"
:close-on-click-modal="false">
<out-certificate-form
:outOrderId="certificateOrderId"
@success="onCertificateSaved"
@cancel="showCertificate = false" />
</el-dialog>
```
```js
onCertificateSaved() {
this.showCertificate = false;
this.$message.success('保存成功');
}
```
> **复用范围**:本期只在「全部出库单」(`outAllDoc.vue` / web_id=211) 开放;「我的出库单」「待办出库单」暂不上。
---
## 4. 上线执行顺序
1. **DEV (`xiaoqu_franchisee_d` + `xiaoqu_comples_d`)**
- 业务库执行 §1.1.1(建 `warehouse_out_certificate`)
- 权限库执行 §1.2(按钮 + 授权,`USE xiaoqu_comples_d`)
- 后端编码 → `mvn -pl xiaoqu-publicbeanandmap install` → `mvn -pl xiaoqu-franchisee package` → 部署 Tomcat
- 前端编码 → `yarn build-dev` → 部署
2. **DEV 冒烟**
- 管理员(authority=1)打开「全部出库单」→ 每行操作里出现「上传凭证」
- 点开 → 空态显示「暂无数据」 / 备注空白
- 上传 1 张 → ❌ → 提交 → 重新进入弹窗 → 凭证为空 ✅(验证"未提交不持久 / 提交才删")
- 上传 2 张 + 写备注 + 提交 → 重新进入 → 两张图回显 + 备注回显 ✅
- 改备注 + ❌ 掉 1 张 + 提交 → 重新进入 → 1 张图 + 新备注 ✅
3. **TEST (`xiaoqu_franchisee_d` + `test`)**
- 业务表 `warehouse_out_certificate` 已经存在于 `xiaoqu_franchisee_d`(dev/test 共享业务库),**无需再建**
- 权限库执行 §1.2(按钮 + 授权,`USE test`)
- §1.2 第二段 INSERT 用动态 SELECT 自动复用 26 个 authority,不需要改 SQL
- 部署后端 / 前端到 test 环境
4. **PRD 上线前必查**(仅校验,§1.2 SQL 不用改)
```sql
USE xiaoqu_comples_p;
-- ① 「全部出库单」web_id 是否仍是 211
SELECT id, title FROM webpage WHERE path='warehouseInOutManage/outAllDoc';
-- ② 「全部出库单」当前授权范围(INSERT 后 button_webpage_authority 应等于这个数)
SELECT COUNT(DISTINCT authority_id) AS total_authorities
FROM webpage_authority WHERE web_id=211 AND soft_delete=0;
-- ③ 防重复执行:确认 uploadCertificate 按钮尚未存在
SELECT id, web_id, en_name FROM button WHERE en_name='uploadCertificate' AND web_id=211;
```
5. **PRD 上线**:§1.2 SQL 把 `USE` 改成 `USE xiaoqu_comples_p` 后**整段照跑**(不需要替换硬编码值,因为已动态化)+ 发包
---
## 5. 风险与注意
| 项 | 风险 | 处理 |
|---|---|---|
| ~~`authority_id=1,102` 硬编码~~ | 已用动态 SELECT 替换 | §1.2 INSERT button_webpage_authority 用子查询自动复用 webpage_authority 当前授权范围,dev/test/prod 通用 |
| `web_id=211` 硬编码 | 各环境 webpage 主键大概率一致,但 prod 上线前仍要校验 | §4 ③ 校验脚本 |
| 跨环境 button.id 不同 | dev=789, test=790 是不同自增值 | SQL 用 `LAST_INSERT_ID()` 拿,前后端代码不耦合 button.id |
| `file_type=4` / `in_out_allocation_type=99` 是新枚举 | 与既有数据冲突 | 已查 dev:`file_type` 现仅 0-3、`in_out_allocation_type` 现仅 0-2/10-14,安全 |
| `/uploadFile/warehouseCertificate` 是通用上传接口 | 不能在那里硬编码 `file_type=4`(会污染装机/送货流程) | 用"上传只落 pic 记录,aggregate_id=0;提交时再补 file_type/aggregate_id/type"两步法 |
| 用户上传后不提交导致孤儿图 | OSS 浪费 + DB 冗余 | §2.4 加定时清理(推荐);或留 backlog |
| `uk_out_order_id` 唯一索引 | 同一出库单二次新增会冲突 | service 强制走 `selectOne→update` 路径,禁止裸 INSERT |
| `warehouse_in_out_pic.aggregate_id` 默认 `0` 而非 `NULL` | 不能用 `IS NULL` 判孤儿 | 全程用 `=0` 判断(已在 §2.2.2 / §2.4 体现) |
| 文件数 / 大小限制仅前端 | 绕过前端直接调上传接口可超限 | 决策上接受;后续若发现滥用再补后端兜底 |
| PDF 预览 | 浏览器原生 PDF viewer 在内嵌 dialog 里可能体验差 | 用 `window.open(url)` 新标签页打开;图片走 `el-image-viewer` |
---
## 6. PM 确认结论(已敲定,2026-05-14)
| # | 决议 |
|---|---|
| 1 | **复用范围**:本期仅 `outAllDoc.vue`(web_id=211)。我的出库 / 待办出库 暂不上。 |
| 2 | **列表角标**:**不做**。砍掉 `/certificate/exists` 接口。 |
| 3 | **文件类型**:图片 + PDF。复用现有 `/uploadFile/warehouseCertificate`(实测 OSSClientUtil.getContentType 已覆盖 jpg/png/gif/bmp/pdf 等 MIME,方法名 `uploadImageStream2OSS` 是历史遗留命名)。 |
| 4 | **数量/大小限制**:最多 10 个、单个 ≤100MB,**仅前端校验**(`before-upload` 拦截)。 |
| 5 | **"一键清空"按钮**:**不做**。用户自行 ❌ 每个文件 + 清空备注 + 提交。 |