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

20 KiB
Raw Blame History

开发文档:出库单上传凭证

需求:在「全部出库单」(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 已用值)
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 字段枚举注释(可选 / 可不执行)

仅刷注释、便于阅读:

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 校验脚本现查一次
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

public class OutCertificateOutput {
    private Long   outOrderId;
    private String memo;                            // 无记录则空串
    private List<WarehouseInOutPicOut> picList;     // 复用现有 DTO
}

逻辑

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)

public class OutCertificateSaveInput {
    private Long       outOrderId;     // 必填
    private String     memo;           // ≤500
    private List<Long> fileIds;        // 当前保留的图片 fileId 列表(前端 ❌ 掉的不传)
}

逻辑(一次性 upsert,幂等)

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 接口要点

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 同包结构):

每天凌晨:
  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 块追加:

certificateGet:    '/myWarehouseOut/certificate/get',
certificateSave:   '/myWarehouseOut/certificate/save',

src/api/warehouseService.js → outManageService:

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

骨架:

<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:

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;
}

模板末尾:

<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>
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 不用改)
    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 "一键清空"按钮:不做。用户自行 ❌ 每个文件 + 清空备注 + 提交。