昨天 684e4306a08feeae188070f264148e6765da8dcf
feat(account): 更新往来付款回款流水表及接口

- 创建 payment_record 表存储客户/供应商/合作商往来流水
- 客户往来取 party_type=1 and direction=1 作为回款列
- 供应商往来取 party_type=2 and direction=2 作为付款列
- 合作商往来取 party_type=3 and direction=1 作为已付款列
- 添加 paymentRecord 相关增删改查接口
- 在客户往来页面显示回款金额列
- 在供应商往来页面显示付款金额列
- 实现销售台账审批流程
- 采购台账编辑时先回滚旧备件流水再重建新流水
- 删除采购台账时同步回滚备件库存和流水记录
- 储罐信息新增是否启用、用途、租客信息字段
- 出库时自动生成已发货台账记录
- 备件领用时来源单号显示设备名称而非单号
已添加41个文件
已修改46个文件
5568 ■■■■■ 文件已修改
doc/20260920_add_usage_to_tank_info.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_create_outsourcing.sql 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_create_payment_record.sql 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_outsourcing_menu.sql 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_trust_fund_and_deposit_ledger.sql 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_vehicle_and_residual_oil.sql 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_信托基金与代储台账前端联调文档.md 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_储罐号带入库存前端联调文档.md 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_储罐启用与租赁信息前端联调文档.md 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_出库即发货与往来备件前端联调文档.md 104 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_委外管理前端联调文档.md 725 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_油品出库联动发货台账前端联调文档.md 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_车辆管理与残油联动前端联调文档.md 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/controller/PaymentRecordController.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/enums/PartyTypeEnum.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/account/pojo/PaymentRecord.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/bean/vo/ApprovalInstanceVo.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/ApprovalInstanceServiceImpl.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/controller/VehicleController.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/enums/TankUsageTypeEnum.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/mapper/VehicleMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/pojo/Customer.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/pojo/TankInfo.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/pojo/Vehicle.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/IVehicleService.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/CustomerServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/TankInfoServiceImpl.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/service/impl/VehicleServiceImpl.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/vo/CustomerVo.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/config/IgnoreTableConfig.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/enums/StockOutQualifiedRecordTypeEnum.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/common/enums/TypeEnums.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/DeviceMaintenanceServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/device/service/impl/DeviceRepairServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/mock/service/impl/DataGenerateServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/controller/OutsourcingOrderController.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/controller/OutsourcingPartnerController.java 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/controller/OutsourcingPartnerReportController.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/dto/OutsourcingOrderDto.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/dto/OutsourcingPartnerDto.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/enums/OutsourcingOrderStatusEnum.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/excel/OutsourcingOrderExcelDto.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/excel/OutsourcingPartnerExcelDto.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/mapper/OutsourcingOrderMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/mapper/OutsourcingOrderProductMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/mapper/OutsourcingPartnerMapper.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/pojo/OutsourcingOrder.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/pojo/OutsourcingOrderProduct.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/pojo/OutsourcingPartner.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/service/IOutsourcingOrderService.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/service/IOutsourcingPartnerService.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/service/impl/OutsourcingOrderServiceImpl.java 427 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/service/impl/OutsourcingPartnerServiceImpl.java 211 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/vo/OutsourcingPartnerTransactionsDetailsVo.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/vo/OutsourcingPartnerTransactionsVo.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/outsourcing/vo/OutsourcingPartnerVo.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java 70 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/vo/SupplierTransactionsDetailsVo.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/purchase/vo/SupplierTransactionsVo.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/CustomerDepositAmountDto.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/SalesLedgerDto.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/dto/ShippingInfoDto.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/mapper/SalesLedgerMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/SalesLedger.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/pojo/ShippingInfo.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/vo/CustomerTransactionsDetailsVo.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/sales/vo/CustomerTransactionsVo.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/controller/StockInventoryController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/controller/StockOutRecordController.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/dto/OilOutAvailableDto.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockInventoryMapper.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/mapper/StockOutRecordMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/pojo/StockOutRecord.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/StockInventoryService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/StockOutRecordService.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java 303 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/CustomerMapper.xml 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/basic/SupplierManageMapper.xml 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/outsourcing/OutsourcingPartnerMapper.xml 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/SalesLedgerMapper.xml 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/sales/ShippingInfoMapper.xml 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockInventoryMapper.xml 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/stock/StockOutRecordMapper.xml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260920_add_usage_to_tank_info.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
-- å‚¨ç½ä¿¡æ¯è¡¨æ–°å¢žâ€œæ˜¯å¦å¯ç”¨â€â€œç”¨é€”(租赁/自用)”
-- æ‰§è¡Œåº“:product-inventory-management(各租户库同)
-- is_enabled å­˜é‡æ•°æ®é»˜è®¤ 1(启用),行为与改动前一致;usage_type ç•™ç©ºè¡¨ç¤ºæœªé€‰æ‹©ï¼ˆå­˜é‡æ•°æ®ï¼‰
ALTER TABLE `tank_info`
    ADD COLUMN `is_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用 1启用 0未启用' AFTER `remaining`,
    ADD COLUMN `usage_type` tinyint NULL DEFAULT NULL COMMENT '用途 1租赁 2自用,启用后才需要选,为空表示未选择' AFTER `is_enabled`;
doc/20260920_create_outsourcing.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
#合作商档案表(委外管理 - åˆä½œå•†ï¼Œç‹¬ç«‹äºŽä¾›åº”商 supplier_manage)
drop table if exists outsourcing_partner;
create table outsourcing_partner
(
    id                           bigint auto_increment primary key,
    partner_name                 varchar(100) not null comment '合作商名称',
    taxpayer_identification_num  varchar(50)  null comment '纳税人识别号',
    company_address              varchar(255) null comment '公司地址',
    company_phone                varchar(50)  null comment '公司电话',
    bank_account_name            varchar(100) null comment '开户行名称',
    bank_account_num             varchar(50)  null comment '银行账号',
    contact_user_name            varchar(50)  null comment '联系人姓名',
    contact_user_phone           varchar(50)  null comment '联系人电话',
    maintain_user_id             bigint       null comment '维护人id',
    maintain_time                date         null comment '维护时间',
    partner_type                 varchar(50)  null comment '合作商类型',
    is_white                     tinyint(1)   default 1 comment '是否白名单 1是 0否',
    remark                       varchar(500) null comment '备注',
    tenant_id                    bigint       null comment '租户ID',
    dept_id                      bigint       null comment '部门ID',
    create_user                  bigint       null comment '创建人ID',
    create_time                  datetime     null comment '创建时间',
    update_time                  datetime     null comment '更新时间',
    index idx_partner_name (partner_name),
    index idx_tenant_partner (tenant_id, partner_name)
);
#委外订单主表
drop table if exists outsourcing_order;
create table outsourcing_order
(
    id                     bigint auto_increment primary key,
    order_no               varchar(50)    not null comment '委外订单号',
    partner_id             bigint         null comment '合作商id',
    partner_name           varchar(100)   null comment '合作商名称',
    apply_date             date           null comment '申请日期',
    expected_delivery_date date           null comment '期望交付日期',
    project_name           varchar(100)   null comment '项目名称',
    contract_amount        decimal(18, 2) null comment '委外金额(含税总价)',
    payment_method         varchar(255)   null comment '付款方式',
    remarks                text           null comment '备注',
    status                 tinyint        not null default 0 comment '状态 0草稿 1已确认 2已完成 3已作废',
    confirm_time           datetime       null comment '确认时间',
    cancel_reason          varchar(255)   null comment '作废原因',
    tenant_id              bigint         null comment '租户ID',
    dept_id                bigint         null comment '部门ID',
    create_user             bigint        null comment '创建人ID',
    create_time             datetime      null comment '创建时间',
    update_time             datetime      null comment '更新时间',
    index idx_order_no (order_no),
    index idx_partner_id (partner_id),
    index idx_status (status)
);
#委外订单产品明细表
drop table if exists outsourcing_order_product;
create table outsourcing_order_product
(
    id                        bigint auto_increment primary key,
    outsourcing_order_id      bigint         not null comment '委外订单id',
    product_id                bigint         null comment '产品id',
    product_model_id          bigint         null comment '产品规格id',
    product_category          varchar(100)   null comment '产品大类',
    specification_model       varchar(100)   null comment '规格型号',
    unit                      varchar(20)    null comment '单位',
    quantity                  decimal(18, 2) not null default 0 comment '数量',
    tax_rate                  decimal(5, 0)  null comment '税率',
    tax_inclusive_unit_price  decimal(18, 2) null comment '含税单价',
    tax_inclusive_total_price decimal(18, 2) null comment '含税总价',
    tax_exclusive_total_price decimal(18, 2) null comment '不含税总价',
    invoice_type              varchar(50)    null comment '发票类型',
    is_scrap                  tinyint(1)     not null default 0 comment '是否报废品选料 0否 1是',
    stock_inventory_id        bigint         null comment '选用的报废品合格库存id(stock_inventory.id,只读引用,不回写库存)',
    batch_no                  varchar(64)    null comment '报废品批号',
    available_quantity        decimal(18, 2) null comment '下单时该批次可用量快照',
    remark                    varchar(255)   null comment '备注',
    tenant_id                 bigint         null comment '租户ID',
    dept_id                   bigint         null comment '部门ID',
    create_user               bigint         null comment '创建人ID',
    create_time               datetime       null comment '创建时间',
    update_time               datetime       null comment '更新时间',
    index idx_outsourcing_order_id (outsourcing_order_id),
    index idx_stock_inventory_id (stock_inventory_id)
);
# æŠ¥åºŸå“é€‰æ–™æ”¹èµ°åˆæ ¼åº“存(stock_inventory),明细列随之改名;已建过表的环境执行(2026-09-20 å·²åœ¨å¼€å‘库执行):
# ALTER TABLE outsourcing_order_product
#     CHANGE COLUMN stock_uninventory_id stock_inventory_id bigint null comment '选用的报废品合格库存id(stock_inventory.id,只读引用,不回写库存)';
# ALTER TABLE outsourcing_order_product RENAME INDEX idx_stock_uninventory_id TO idx_stock_inventory_id;
# åˆä½œå•†å¾€æ¥å– payment_record(party_type=3 and direction=1) ä½œä¸ºã€Œå·²ä»˜æ¬¾ã€åˆ—
# å·²å»ºè¿‡è¡¨çš„环境执行:
# ALTER TABLE payment_record MODIFY COLUMN party_type tinyint not null comment '往来方类型 1客户 2供应商 3合作商';
doc/20260920_create_payment_record.sql
@@ -1,11 +1,13 @@
# å¾€æ¥ä»˜æ¬¾å›žæ¬¾æµæ°´è¡¨
# å®¢æˆ·å¾€æ¥å– party_type=1 and direction=1 ä½œä¸ºã€Œä»˜æ¬¾ã€åˆ—
# ä¾›åº”商往来取 party_type=2 and direction=2 ä½œä¸ºã€Œå›žæ¬¾ã€åˆ—
# å®¢æˆ·å¾€æ¥å– party_type=1 and direction=1 ä½œä¸ºã€Œå›žæ¬¾ã€åˆ—(返回字段 receiptRecordAmount)
# ä¾›åº”商往来取 party_type=2 and direction=2 ä½œä¸ºã€Œä»˜æ¬¾ã€åˆ—(返回字段 paymentRecordAmount)
# æ³¨æ„ï¼šdirection å–值按页面固定,不要按下面 Â§æ–¹å‘ çš„字面(1付款 2回款)去选
# åˆä½œå•†å¾€æ¥å– party_type=3 and direction=1 ä½œä¸ºã€Œå·²ä»˜æ¬¾ã€åˆ—
drop table if exists payment_record;
create table payment_record
(
    id             bigint auto_increment primary key,
    party_type     tinyint        not null comment '往来方类型 1客户 2供应商',
    party_type     tinyint        not null comment '往来方类型 1客户 2供应商 3合作商',
    party_id       bigint         not null comment '客户id或供应商id',
    direction      tinyint        not null comment '方向 1付款 2回款',
    amount         decimal(18, 2) not null default 0 comment '金额',
doc/20260920_outsourcing_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
#委外管理 èœå•脚本
#前端页面路径约定:outsourcingManagement/{partner|order|transactions}/index
#sys_menu å±žäºŽç§Ÿæˆ·å¿½ç•¥è¡¨ï¼Œæ— éœ€ tenant_id
#说明:显式指定 menu_id(当前库 sys_menu æœ€å¤§ id ä¸º 2355),可重复执行
#先清理旧数据,保证脚本可重复执行
delete from sys_menu where menu_id between 2360 and 2380;
#1. ç›®å½•:委外管理
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2360, '委外管理', 0, 6, 'outsourcingManagement', null, 1, 0, 'M', '0', '0', '', 'shopping', 'admin', sysdate(), '', null, '委外管理目录');
#2. åˆä½œå•†æ¡£æ¡ˆ
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2361, '合作商档案', 2360, 1, 'partner', 'outsourcingManagement/partner/index', 1, 0, 'C', '0', '0', 'outsourcing:partner:list', '#', 'admin', sysdate(), '', null, '合作商档案菜单');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2362, '合作商档案查询', 2361, 1, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:partner:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2363, '合作商档案新增', 2361, 2, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:partner:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2364, '合作商档案修改', 2361, 3, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:partner:edit', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2365, '合作商档案删除', 2361, 4, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:partner:remove', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2366, '合作商档案导出', 2361, 5, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:partner:export', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2367, '合作商档案导入', 2361, 6, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:partner:import', '#', 'admin', sysdate(), '', null, '');
#3. å§”外订单
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2368, '委外订单', 2360, 2, 'order', 'outsourcingManagement/order/index', 1, 0, 'C', '0', '0', 'outsourcing:order:list', '#', 'admin', sysdate(), '', null, '委外订单菜单');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2369, '委外订单查询', 2368, 1, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:order:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2370, '委外订单新增', 2368, 2, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:order:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2371, '委外订单修改', 2368, 3, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:order:edit', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2372, '委外订单删除', 2368, 4, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:order:remove', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2373, '委外订单导出', 2368, 5, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:order:export', '#', 'admin', sysdate(), '', null, '');
#4. åˆä½œå•†å¾€æ¥
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2374, '合作商往来', 2360, 3, 'transactions', 'outsourcingManagement/transactions/index', 1, 0, 'C', '0', '0', 'outsourcing:report:list', '#', 'admin', sysdate(), '', null, '合作商往来菜单');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2375, '合作商往来查询', 2374, 1, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:report:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
values (2376, '合作商往来付款', 2374, 2, '#', '', 1, 0, 'F', '0', '0', 'outsourcing:report:pay', '#', 'admin', sysdate(), '', null, '');
doc/20260920_trust_fund_and_deposit_ledger.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
#信托基金 + é”€å”®å°è´¦ç±»åž‹åŒºåˆ†ï¼ˆå–æ²¹/代储)+ é”€å”®å°è´¦å®¡æ‰¹
#对应需求:需求清单第 5 æ¡ã€Œå®¢æˆ·æ¡£æ¡ˆåŠ ä¿¡æ‰˜åŸºé‡‘å­—æ®µï¼ˆè·Ÿä»£å‚¨é‡‘é¢è”åŠ¨ï¼‰ã€ã€
#          ç¬¬ 6 æ¡ã€Œé”€å”®å°è´¦åŒºåˆ†å–æ²¹/卖存储位置,接已有 OA å®¡æ‰¹æµç¨‹ï¼ŒåˆåŒé‡‘额超信托基金要标红提醒」
#目标库:product-inventory-management-rbhb
#说明:纯新增列,不改动任何既有列
#1. å®¢æˆ·ä¿¡æ‰˜åŸºé‡‘
alter table customer
    add column trust_fund decimal(16, 2) null comment '信托基金(元)';
#2. é”€å”®å°è´¦ï¼šç±»åž‹åŒºåˆ† + ä»£å‚¨çš„æ²¹åº“/储罐 + å®¡æ‰¹çŠ¶æ€
alter table sales_ledger
    add column ledger_type     varchar(20)  null comment '销售类型:卖油/代储' after customer_name,
    add column oil_depot_id    bigint       null comment '油库id(oil_depot.id),类型=代储时使用' after ledger_type,
    add column oil_depot_name  varchar(100) null comment '油库名称' after oil_depot_id,
    add column tank_id         bigint       null comment '储罐id(tank_info.id),类型=代储时使用' after oil_depot_name,
    add column tank_no         varchar(50)  null comment '储罐号' after tank_id,
    add column approval_status varchar(20)  null comment '审批状态:待审批/审核中/通过/拒绝' after tank_no;
#3. å­˜é‡å°è´¦å›žå¡«ä¸ºå–油(存量都是走产品明细的销售单,历史单据不补审,approval_status ç•™ç©ºï¼‰
update sales_ledger set ledger_type = '卖油' where ledger_type is null;
#4. é”€å”®å°è´¦å®¡æ‰¹æ¨¡æ¿ï¼ˆæŒ‚已有 OA å®¡æ‰¹å¼•擎,business_type=19 ä¸ºç©ºé—²å€¼ï¼›å®¡æ‰¹äºº éƒ‘湘俞 ä¸ŽæŠ¥ä»·å®¡æ‰¹æ¨¡æ¿ä¸€è‡´ï¼‰
insert into approval_template (template_name, enabled, description, template_type, business_type, deleted)
values ('销售台账审批', 1, '销售台账(含代储)提交后走 OA å®¡æ‰¹', 1, 19, 0);
set @tid = last_insert_id();
insert into approval_template_node (template_id, level_no, approve_type)
values (@tid, 1, 'AND');
set @nid = last_insert_id();
insert into approval_template_node_approver (node_id, template_id, approver_id, approver_name, sort_no, deleted)
values (@nid, @tid, 323, '郑湘俞', 1, 0);
doc/20260920_vehicle_and_residual_oil.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
#车辆主数据 + å‡ºåº“残油
#对应需求:需求清单第 7 æ¡ã€Œç»™è½¦è¾†åšå”¯ä¸€æ ‡è¯†â€¦è®°å½•每次最后送完货后剩余的油量,下次发货为上次剩余量+库存量」
#目标库:product-inventory-management-rbhb
#说明:纯新增,不改动任何既有列,可重复执行时请先判断是否存在
#1. è½¦è¾†ä¿¡æ¯è¡¨
create table vehicle
(
    id            bigint          not null auto_increment comment '车辆id',
    plate_number  varchar(20)     not null comment '车牌号(车辆唯一标识)',
    driver_name   varchar(50)     null comment '默认司机',
    driver_phone  varchar(20)     null comment '司机电话',
    load_capacity decimal(16, 4)  null comment '核定载重(吨)',
    status        varchar(20)     null default '启用' comment '状态:启用/停用',
    remark        varchar(255)    null comment '备注',
    create_time   datetime        null comment '创建时间',
    create_user   bigint          null comment '创建人',
    update_time   datetime        null comment '更新时间',
    update_user   bigint          null comment '更新人',
    tenant_id     bigint          null comment '租户id',
    dept_id       bigint          null comment '部门id',
    primary key (id),
    unique key uk_plate_number (plate_number)
) engine = InnoDB
  default charset = utf8mb4
  row_format = DYNAMIC comment ='车辆信息表';
#2. å‡ºåº“记录:车辆 + æ®‹æ²¹ + å®žé™…扣减量
alter table stock_out_record
    add column vehicle_id      bigint        null comment '车辆ID(vehicle.id)' after truck_plate_no,
    add column residual_oil    decimal(16, 4) null comment '本次送完货后剩余油量(吨)' after vehicle_id,
    add column deduct_quantity decimal(16, 4) null comment '审批通过时实际从库存扣减的数量(=出库数量-上次剩余油量)' after residual_oil;
#3. èœå•(2026-09-20 å·²åœ¨å¼€å‘库执行,menu_id=2377)
#注意:前端页面 src/views/basicData/vehicle/index.vue å°šæœªåˆ›å»ºï¼Œç‚¹è¿›åŽ»æš‚æ—¶æ˜¯ 404,页面做好即自动可用
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
values ('车辆信息', 2000, 4, 'vehicle', 'basicData/vehicle/index', 1, 0, 'C', 0, 0, '', '#', 'admin', now(), '');
#3.1 èœå•授权:与同级的油库信息/储罐信息保持一致,授权给「技术质量」角色(role_id=2)
insert into sys_role_menu (role_id, menu_id)
select 2, menu_id from sys_menu where menu_name = '车辆信息' and parent_id = 2000;
doc/20260920_ÐÅÍлù½ðÓë´ú´¢Ì¨ÕËǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
# ä¿¡æ‰˜åŸºé‡‘与销售台账类型/代储/审批 å‰ç«¯è”调文档
更新时间:2026-09-20
适用版本:本次后端变更后
对应需求:需求清单第 5 æ¡ + ç¬¬ 6 æ¡
- **第 5 æ¡ï¼ˆå®¢æˆ·æ¡£æ¡ˆï¼‰**:代储和销售分开两个页面,加一个**信托基金**字段(要跟**代储金额**联动,超过相应金额要做提醒)。
- **第 6 æ¡ï¼ˆé”€å”®æŠ¥ä»·/销售台账)**:
  1. ä¸¤ç§ç±»åž‹ï¼š**卖油** / **卖存储位置**。选完类别后,卖油就是选以前的产品大类和规格型号;卖存储位置就选**油库和储罐**。
  2. **加入销售审批流程**(= æŽ¥ç³»ç»Ÿ**已有 OA å®¡æ‰¹**,不是新写一套)。
  3. è¦è·Ÿå®¢æˆ·çš„**信托基金**字段联动:**合同金额超过信托基金要做标红提醒**,相应的**审批流程也要做出提醒字眼**。
**一句话口径**:
- å°è´¦åˆ†ä¸¤ç±» â€”— **卖油**:走产品明细,合同金额由明细汇总,和以前一样;**代储**:卖存储位置,**没有产品明细**,选**油库 + å‚¨ç½**å¹¶**手工填合同金额**。
- å°è´¦**保存后立即发起 OA å®¡æ‰¹**(新增、修改都发),审批结果只回写台账上的**审批状态**。
- æœ¬ç¬”合同金额 > è¯¥å®¢æˆ·ä¿¡æ‰˜åŸºé‡‘ â†’ å°è´¦åˆ—表行**标红**(后端给布尔,前端染色)+ å®¡æ‰¹**标题**带提醒字眼。
---
## 1. å˜æ›´èŒƒå›´
| å†…容 | å½±å“é¢ |
| --- | --- |
| `customer` åŠ  `trust_fund` | å®¢æˆ·æ¡£æ¡ˆæ–°å¢ž/修改表单、客户列表、导出 |
| å®¢æˆ·æŽ¥å£è¿”回代储金额与超限标记 | å®¢æˆ·æ¡£æ¡ˆåˆ—表、客户详情 |
| `sales_ledger` åŠ  6 åˆ— + `ledgerType` å¿…å¡« | é”€å”®å°è´¦æ–°å¢ž/修改表单(**破坏性,见 3.4**) |
| å°è´¦ä¿å­˜åŽè‡ªåŠ¨èµ° OA å®¡æ‰¹ | é”€å”®å°è´¦ä¿å­˜æŒ‰é’®ï¼›å®¡æ‰¹ä¸­å¿ƒåˆ—表 |
| å°è´¦åˆ—表/详情/导出新增字段 | é”€å”®å°è´¦åˆ—表、导出 Excel |
| `ledgerType` / `approvalStatus` æ”¯æŒç­›é€‰ | å–油、代储**两个页面的拆分**靠它实现 |
> æœ¬ä»“库是**后端仓库**,前端在 `D:/牛马/南通/前端/product-inventory-management`,本文档只描述后端已就绪的能力。
> **第 5 æ¡ã€Œä»£å‚¨å’Œé”€å”®åˆ†å¼€ä¸¤ä¸ªé¡µé¢ã€ç”±å‰ç«¯æ‹†**,后端不新增列表接口,靠同一个 `/sales/ledger/listPage` ä¼  `ledgerType` åŒºåˆ†ã€‚
---
## 2. å‰ç½®ï¼šDDL ä¸Žå®¡æ‰¹æ¨¡æ¿å·²æ‰§è¡Œ
文件:`doc/20260920_trust_fund_and_deposit_ledger.sql`,**已于 2026-09-20 åœ¨å¼€å‘库 `product-inventory-management-rbhb` æ‰§è¡Œå®Œæ¯•**,其他环境上线按同一文件执行即可。
执行内容(纯新增列,不改动既有列):
| è¡¨ | å˜æ›´ |
| --- | --- |
| `customer` | æ–°å¢ž `trust_fund decimal(16,2)` â€”— ä¿¡æ‰˜åŸºé‡‘(元) |
| `sales_ledger` | æ–°å¢ž `ledger_type`(varchar20)、`oil_depot_id`(bigint)、`oil_depot_name`(varchar100)、`tank_id`(bigint)、`tank_no`(varchar50)、`approval_status`(varchar20) |
| `sales_ledger` å­˜é‡æ•°æ® | 3 æ¡åŽ†å²å°è´¦å›žå¡« `ledger_type='卖油'`;`approval_status` ç•™ç©ºï¼ˆåŽ†å²å•æ®ä¸è¡¥å®¡ï¼‰ |
| `approval_template` ç­‰ 3 å¼ è¡¨ | seed å‡º `business_type=19` çš„「销售台账审批」模板(审批人 **郑湘俞**) |
**已核对结果**:模板 `id=65`「销售台账审批」`enabled=1`、`template_type=1`、`business_type=19`;节点 `id=95` `level_no=1` `approve_type=AND`;审批人 `323 éƒ‘湘俞`。
> **DDL æœªæ‰§è¡Œå‰**:台账的新增/修改会直接报 `Unknown column 'ledger_type'`。
---
## 3. é”€å”®å°è´¦ï¼šç±»åž‹åŒºåˆ†ï¼ˆç¬¬ 6 æ¡ 1)
### 3.1 æ–°å¢žçš„字段
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `ledgerType` | String | **必填**,只能是 `卖油` / `代储` |
| `oilDepotId` | Long | æ²¹åº“ id,**代储必填** |
| `oilDepotName` | String | æ²¹åº“名称,**后端按 id å›žæŸ¥ï¼Œä¸é‡‡ä¿¡å‰ç«¯ä¼ å€¼** |
| `tankId` | Long | å‚¨ç½ id,**代储必填** |
| `tankNo` | String | å‚¨ç½å·ï¼Œ**同上,后端回查** |
| `contractAmount` | BigDecimal | åˆåŒé‡‘额(元)。**代储必填且 > 0**;卖油不用传 |
| `approvalStatus` | String | å®¡æ‰¹çŠ¶æ€ï¼Œ**后端维护,前端不要传** |
> âš ï¸ **`ledgerType` æ˜¯æ–°å­—段,不要和表单里已有的 `type` æ··ç”¨**。`type` æ˜¯**产品明细行**的类型(1销售/2采购/3项目),前端表单里已有的那个 `type` ä¿æŒåŽŸæ ·ä¼ ï¼Œä¸è¦æ”¹æˆ `ledgerType`。
### 3.2 å–油(和改造前基本一致)
```json
POST /sales/ledger/addOrUpdateSalesLedger
{
  "ledgerType": "卖油",
  "customerId": 88,
  "entryDate": "2026-09-20",
  "type": 1,
  "productData": [
    { "productId": 12, "productModelId": 407, "quantity": 10, "taxInclusiveUnitPrice": 5000, "taxInclusiveTotalPrice": 50000, "taxRate": 13, "unit": "吨" }
  ]
}
```
- åˆåŒé‡‘额(`contractAmount`)**由产品明细自动汇总**,前端传了也会被覆盖,**卖油表单不要放「合同金额」输入框**
- åŽç«¯ä¼šæŠŠæ²¹åº“/储罐/审批状态之外的代储字段清空
### 3.3 ä»£å‚¨ï¼ˆå–存储位置)
```json
POST /sales/ledger/addOrUpdateSalesLedger
{
  "ledgerType": "代储",
  "customerId": 88,
  "entryDate": "2026-09-20",
  "oilDepotId": 1,
  "tankId": 3,
  "contractAmount": 100000
}
```
- **不要传 `productData`**(代储没有产品明细;就算传了后端也会忽略,避免明细汇总覆盖手工填的合同金额)
- **合同金额必须前端手工填**,后端不会算
- æ²¹åº“下拉取基础数据的油库列表,储罐下拉取储罐列表(储罐**不挂在油库下**,两个下拉互相独立)
### 3.4 âš ï¸ è¿™æ˜¯ç ´åæ€§å˜æ›´ï¼šå‰ç«¯å¿…须先改
`ledgerType` æ˜¯**必填**。**前端没改之前,现有的销售台账新增/修改会直接失败**:
```json
{ "code": 500, "msg": "销售类型不能为空", "data": null }
```
所以上线顺序:**前端先加「销售类型」单选并提交 `ledgerType` â†’ å†æ”¾é‡**。
(后端已把**模拟数据生成**这类内部调用点补成 `卖油`,不受影响。)
---
## 4. ä¿¡æ‰˜åŸºé‡‘联动(第 5 æ¡ + ç¬¬ 6 æ¡ 3)
### 4.1 å®¢æˆ·æ¡£æ¡ˆï¼šå†™å…¥ä¿¡æ‰˜åŸºé‡‘
`/basic/customer/addCustomer`、`/basic/customer/updateCustomer` æ”¶çš„就是客户对象,**直接多传一个 `trustFund` å³å¯**,不用新接口:
```json
POST /basic/customer/updateCustomer
{ "id": 88, "customerName": "某某化工", "trustFund": 200000 }
```
- å•位:元;**可以为空**,为空则不做任何提醒(功能静默可用)
- å®¢æˆ·åˆ—表导出 Excel æ–°å¢žã€Œä¿¡æ‰˜åŸºé‡‘」列
### 4.2 å®¢æˆ·åˆ—表/详情:返回代储金额与超限标记
`GET /basic/customer/list`(`data.records`)和 `GET /basic/customer/{id}`(`data`)的每行**新增两个只读字段**:
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `depositTotalAmount` | è¯¥å®¢æˆ·**代储金额合计** = è¯¥å®¢æˆ·**所有 `ledger_type='代储'` å°è´¦çš„合同金额之和**(无记录返回 `0`) |
| `exceedsTrustFund` | `depositTotalAmount > trustFund` æ—¶ä¸º `true`;`trustFund` ä¸ºç©ºæ—¶æ’为 `false` |
```json
// GET /basic/customer/88
{ "code": 200, "msg": "操作成功",
  "data": { "id": 88, "customerName": "某某化工", "trustFund": 200000.00,
            "depositTotalAmount": 260000.00, "exceedsTrustFund": true } }
```
> **两个口径不要搞混**:
> - å®¢æˆ·æ¡£æ¡ˆä¸Šçš„ `exceedsTrustFund` æ¯”的是 **代储金额合计**(所有代储单加总)→ å®¢æˆ·æ¡£æ¡ˆé¡µæé†’
> - å°è´¦åˆ—表上的 `exceedsTrustFund` æ¯”的是 **本笔合同金额**(单笔)→ å°è´¦åˆ—表行标红
>
> ä»£å‚¨é‡‘额合计是**跨客户汇总**,后端已显式跳过数据权限,看到的始终是**全量**金额(否则数据范围受限的账号会少算、漏报超限)。
### 4.3 å°è´¦åˆ—表:本笔超限标红
`GET /sales/ledger/listPage`、`GET /sales/ledger/list`、导出 Excel çš„æ¯è¡Œ**新增两个只读字段**:
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `trustFund` | è¯¥å®¢æˆ·çš„信托基金(客户没填则为 `null`) |
| `exceedsTrustFund` | **本笔** `contractAmount > trustFund` æ—¶ä¸º `true` |
**规则**:**单笔超就标红**——只要这一笔的合同金额超过该客户的信托基金,这一行就标红;`trustFund` ä¸º `null` æ—¶**不标红**。
展示建议:`exceedsTrustFund === true` æ—¶æŠŠ**合同金额**(或整行)标红,并加 tooltip:`合同金额已超出该客户信托基金(信托基金:200000)`。
> ç¼–辑表单里的实时预览:选中客户后调 `GET /basic/customer/{id}` æ‹¿ `trustFund`,和输入的合同金额本地比较即可,不需要新接口。
---
## 5. å®¡æ‰¹ï¼šæŽ¥å·²æœ‰ OA å®¡æ‰¹å¼•擎(第 6 æ¡ 2)
### 5.1 å‘起时机
**台账保存接口 `POST /sales/ledger/addOrUpdateSalesLedger` å†…部自动发起**,前端不用调审批接口:
- **新增**:insert å°è´¦ â†’ çŠ¶æ€ç½® `待审批` â†’ å‘起审批实例
- **修改**:把该台账**此前未结束的审批实例作废**(置 `REJECTED`)→ çŠ¶æ€å›ž `待审批` â†’ é‡æ–°å‘èµ·
  - âš ï¸ **注意:改个备注也会重新走一遍审批**(沿用报价单的既有行为)
台账上的 `approvalStatus` å–值:
| å€¼ | å«ä¹‰ |
| --- | --- |
| `待审批` | åˆšæäº¤ï¼Œç­‰å¾…审批人处理 |
| `审核中` | å¤šçº§æµç¨‹æµè½¬ä¸­ï¼ˆæœ¬æ¬¡æ¨¡æ¿åªæœ‰ 1 çº§ï¼Œå®žé™…见到的不多) |
| `通过` | å®¡æ‰¹é€šè¿‡ |
| `拒绝` | è¢«é©³å›ž |
| `null` | åŽ†å²å•æ®ï¼ˆæœ¬æ¬¡æ”¹é€ å‰çš„ 3 æ¡ï¼‰ï¼Œä¸è¡¥å®¡ |
> **审批通过只回写状态**:不锁定单据、不生成下游单据、不动库存。通过后的台账**仍然可以编辑**,编辑会再次触发审批。
### 5.2 è¶…限时的审批提醒字眼
审批实例的**标题**承载提醒:
- æœªè¶…限:`HT20260920001审批`
- å·²è¶…限:`HT20260920001审批(合同金额超出客户信托基金,请重点审核)`
所以审批中心列表里,审批人**一眼就能看出这单金额超了**,前端**不需要额外处理**,直接把 `title` åŽŸæ ·å±•ç¤ºå³å¯ã€‚
### 5.3 å®¡æ‰¹ä¸­å¿ƒï¼ˆå‰ç«¯å·²æœ‰é¡µé¢ï¼Œåªéœ€çŸ¥é“这两点)
- å®¡æ‰¹å®žä¾‹åˆ—表 `GET /approvalInstance/listPage` é‡Œ `businessType=19` çš„记录,**新增返回 `salesContractNo`**(销售合同号),与已有的 `quotationNo`(报价单号)、`purchaseContractNumber`(采购单号)、`shippingNo`(发货单号)并列
- å®¡æ‰¹åŠ¨ä½œèµ°å·²æœ‰çš„ `POST /approvalInstance/approve`:
```json
{ "id": 1234, "approveAction": "APPROVED", "approveComment": "同意" }
```
(`approveAction` åªæ”¯æŒ `APPROVED` / `REJECTED`)
### 5.4 åˆ é™¤å°è´¦
`DELETE /sales/ledger/delLedger`(body ä¸º id æ•°ç»„)**会自动清掉该台账对应的审批实例**(同一台账反复提交可能有多条),前端不用管。
---
## 6. å°è´¦åˆ—表新增字段与筛选
### 6.1 è¿”回结构(注意两个接口的包装不同)
| æŽ¥å£ | è¿”回 |
| --- | --- |
| `GET /sales/ledger/listPage` | **直接返回 `IPage`**:`{ "records": [...], "total": 12, "size": 10, "current": 1, ... }`,**没有 `code`/`msg` å¤–层** |
| `GET /sales/ledger/list` | `TableDataInfo`:`{ "total": 12, "rows": [...], "code": 200, "msg": "查询成功" }` |
分页参数沿用平台约定:`pageNum` / `pageSize`。
### 6.2 æ¯è¡Œæ–°å¢žå­—段
`ledgerType`、`oilDepotId`、`oilDepotName`、`tankId`、`tankNo`、`approvalStatus`、`trustFund`、`exceedsTrustFund`。
导出 Excel(`POST /sales/ledger/export`)新增列:**销售类型、油库、储罐号、审批状态、客户信托基金**。
### 6.3 æ–°å¢žä¸¤ä¸ªæŸ¥è¯¢æ¡ä»¶ï¼ˆç¬¬ 5 æ¡ã€Œåˆ†å¼€ä¸¤ä¸ªé¡µé¢ã€çš„后端支撑)
| å‚æ•° | è¯´æ˜Ž |
| --- | --- |
| `ledgerType` | `卖油` / `代储`,精确匹配 |
| `approvalStatus` | `待审批` / `审核中` / `通过` / `拒绝`,精确匹配 |
**建议做法**:前端拆成「销售台账」和「代储台账」两个页面(或一个页面的两个页签),各自固定带 `ledgerType='卖油'` / `'代储'` è°ƒ**同一个** `/sales/ledger/listPage`,后端不用新增列表接口。两个页面的**新增表单也按类型区分**:卖油页放产品明细表格,代储页放「油库 + å‚¨ç½ + åˆåŒé‡‘额」。
---
## 7. æŠ¥é”™æ–‡æ¡ˆä¸Žå“åº”形态
本次新增的校验:
| åœºæ™¯ | æŠ¥é”™ |
| --- | --- |
| æ²¡ä¼ é”€å”®ç±»åž‹ | `销售类型不能为空` |
| é”€å”®ç±»åž‹ä¸æ˜¯å–æ²¹/代储 | `销售类型不合法:xxx` |
| ä»£å‚¨æ²¡é€‰æ²¹åº“ | `代储必须选择油库` |
| ä»£å‚¨æ²¡é€‰å‚¨ç½ | `代储必须选择储罐` |
| ä»£å‚¨æ²¡å¡«åˆåŒé‡‘额或 â‰¤0 | `代储必须填写合同金额` |
| æ²¹åº“ id æŸ¥ä¸åˆ° | `油库不存在:1` |
| å‚¨ç½ id æŸ¥ä¸åˆ° | `储罐不存在:3` |
| æœªé…ç½®å®¡æ‰¹æ¨¡æ¿ | `请先配置销售台账审批模板` |
| å®¡æ‰¹å¼•擎报错 | `审批失败: <原因>` |
**错误响应形态**(`BaseException` / `RuntimeException` è¢«å…¨å±€å¼‚常处理器兜成 `{code:500,msg:...,data:null}`,注意 **HTTP çŠ¶æ€ç ä»ç„¶æ˜¯ 200**):
```json
{ "code": 500, "msg": "代储必须选择油库", "data": null }
```
所以前端**判失败要看 `code`,不能只看 HTTP çŠ¶æ€ç **,并且要把 `msg` åŽŸæ ·é€å‡ºã€‚
> å°è´¦ä¿å­˜å¤±è´¥æ—¶**整个事务回滚**(台账、明细、审批实例都不会留下半截数据)。
---
## 8. å·²çŸ¥å½±å“ä¸Žæ³¨æ„ç‚¹
1. **`ledgerType` å¿…填是破坏性变更**,前端不先改就会报 `销售类型不能为空`(见 3.4)。这是本次唯一的强制联调项。
2. **历史 3 æ¡å°è´¦**已回填为 `卖油`,`approvalStatus` ä¸ºç©ºï¼ˆä¸è¡¥å®¡ï¼‰ã€‚它们的合同金额、产品明细都不变。
3. **客户没维护 `trustFund` å°±ä¸æé†’**:`trustFund=null` â†’ ä¸æ ‡çº¢ã€å®¡æ‰¹æ ‡é¢˜ä¸å¸¦æé†’字眼,功能静默可用,不会因为基础数据没维护就误报。
4. **审批模板的下拉选项是前端硬编码的**:`/approvalTemplate` çš„æ–°å»ºé¡µé‡Œ `business_type` é€‰é¡¹å†™æ­»åœ¨å‰ç«¯ã€‚本次靠 seed è½äº† `19`,**模板列表里能看到也能改**(审批人、加节点);但**如果要新建一个 19 çš„æ¨¡æ¿**,前端需要先在下拉里补上「销售台账审批(19)」这一项。
5. **审批通过不做任何业务副作用**(不锁单、不动库存、不生成下游单据),通过后仍可编辑并再次触发审批 â€”— è¿™æ˜¯æŒ‰éœ€æ±‚确认的口径。
6. **代储不占储罐容量**:本批**没有**做储罐余量扣减(需求没提),`tank_info` çš„剩余量不因代储台账变化。
7. **代储金额合计是跨客户的全量汇总**(已跳过数据权限),台账列表本身**仍受数据权限约束**(非管理员按部门/本人过滤)——所以「客户档案上的超限」和「某个人在台账列表里看到的条数」口径不同,属预期。
8. **`ledgerType` ç”¨ä¸­æ–‡å­—面量**(`卖油`/`代储`),与出库单的 `outCategory`(`销售`/`客存`/`代储`)风格一致;**不是 1/2 æ•°å­—**。
---
## 9. å‰ç«¯è‡ªæŸ¥æ¸…单
- [ ] DDL å·²æ‰§è¡Œï¼ˆ`customer.trust_fund`、`sales_ledger` çš„ 6 ä¸ªæ–°åˆ—都在)
- [ ] å®¢æˆ·æ¡£æ¡ˆè¡¨å•有「信托基金」输入框,新增/修改能存上、能回显
- [ ] å®¢æˆ·åˆ—表能看到「信托基金」列,导出 Excel ä¹Ÿæœ‰
- [ ] å®¢æˆ·åˆ—表/详情能拿到 `depositTotalAmount`、`exceedsTrustFund`,超限时有提醒(比的是**代储金额合计**)
- [ ] é”€å”®å°è´¦æ–°å¢ž/修改表单加了「销售类型」单选,**提交时带 `ledgerType`**(否则报 `销售类型不能为空`)
- [ ] é€‰ã€Œå–油」→ æ˜¾ç¤ºäº§å“æ˜Žç»†è¡¨æ ¼ï¼Œ**不显示**「合同金额」输入框和油库/储罐
- [ ] é€‰ã€Œä»£å‚¨ã€â†’ æ˜¾ç¤ºã€Œæ²¹åº“ + å‚¨ç½ + åˆåŒé‡‘额」,**不显示**产品明细;合同金额必填
- [ ] ä»£å‚¨æäº¤æ—¶ä¸å¸¦ `productData`
- [ ] å°è´¦åˆ—表拆成卖油/代储两个页面(或页签),各自带 `ledgerType` è°ƒåŒä¸€ä¸ª `/sales/ledger/listPage`
- [ ] å°è´¦åˆ—表行 `exceedsTrustFund === true` æ—¶æ ‡çº¢ï¼ˆæ¯”的是**本笔合同金额**)
- [ ] å°è´¦åˆ—表能看到「审批状态」列,并且能按 `approvalStatus` ç­›é€‰
- [ ] å°è´¦ä¿å­˜åŽåˆ°å®¡æ‰¹ä¸­å¿ƒèƒ½çœ‹åˆ° `businessType=19` çš„记录,标题为 `销售合同号+审批`
- [ ] åˆåŒé‡‘额超信托基金时,审批标题带「(合同金额超出客户信托基金,请重点审核)」
- [ ] å®¡æ‰¹ä¸­å¿ƒåˆ—表能显示台账的 `salesContractNo`(销售合同号)
- [ ] ç”¨å®¡æ‰¹äººè´¦å·æŠŠå°è´¦å®¡æ‰¹é€šè¿‡åŽï¼Œå›žå°è´¦åˆ—表看 `approvalStatus` å˜æˆ `通过`;驳回变 `拒绝`
- [ ] æŽ¥å£æŠ¥é”™åˆ¤å®šç”¨çš„æ˜¯ `code` è€Œä¸æ˜¯ HTTP çŠ¶æ€ç ï¼ˆå¤±è´¥æ—¶ HTTP ä»æ˜¯ 200)
- [ ] `/sales/ledger/listPage` å–数据用的是顶层 `records`(没有 `code`/`msg` å¤–层),`/sales/ledger/list` ç”¨çš„æ˜¯ `rows`
doc/20260920_´¢¹ÞºÅ´øÈë¿â´æÇ°¶ËÁªµ÷Îĵµ.md
@@ -45,9 +45,17 @@
前缀 `/basic/tankInfo`,接口形态与油库完全一致(`list` / `all` / `{id}` / `add` / `update` / `del`)。
字段:`id`、`tankNo`(**储罐号**,如 `01#`)、`category`(大类,如 `成品油`)、`oilProduct`(油品,如 `柴#0`)、`capacity`(罐容)、`remaining`(库存余量 m³)、`tenantInfo`(租客信息)。
字段:`id`、`tankNo`(**储罐号**,如 `01#`)、`category`(大类,如 `成品油`)、`oilProduct`(油品,如 `柴#0`)、`capacity`(罐容)、`remaining`(库存余量 m³)、`isEnabled`(是否启用)、`usageType`(用途)、`tenantInfo`(租客信息)。
**前端**:储罐信息表单的「大类」「油品」建议做成下拉(当前库内值为 å¤§ç±»=`成品油`、油品=`柴#0`/`柴#-35`/`æ±½#92`/`æ±½#95`,可与产品规格口径对齐后再定)。
#### 2.2.1 æ˜¯å¦å¯ç”¨ / ç”¨é€” / ç§Ÿå®¢ä¿¡æ¯ï¼ˆæœ¬æ¬¡æ–°å¢žï¼‰
DDL è§ `doc/20260920_add_usage_to_tank_info.sql`。
**字段、表单联动、归一化规则、接口行为变化、校验文案、注意事项,详见单独文档:[`doc/20260920_储罐启用与租赁信息前端联调文档.md`](./20260920_储罐启用与租赁信息前端联调文档.md)。**
一句话摘要:`isEnabled`(1 å¯ç”¨ / 0 æœªå¯ç”¨ï¼‰+ `usageType`(1 ç§Ÿèµ / 2 è‡ªç”¨ï¼‰+ `tenantInfo`(自由文本);未启用时后两者一律清空,选租赁时 `tenantInfo` å¿…填,选自用自动清空。`/basic/tankInfo/all` **只返回已启用的储罐**(未启用不进入库表单下拉),`/basic/tankInfo/list` ä»è¿”回全部并新增 `isEnabled`/`usageType` è¿‡æ»¤å‚数。
---
@@ -190,6 +198,13 @@
- [ ] èœå• 2353 æ²¹åº“信息、2354 å‚¨ç½ä¿¡æ¯èƒ½æ‰“开并增删改查(此前后端缺失,点开报错)
- [ ] å‚¨ç½ä¿¡æ¯èƒ½ä¿å­˜ `tankNo`、`category`、`oilProduct`、`capacity`、`remaining`
- [ ] å‚¨ç½è¡¨å•:打开「是否启用」才出现用途,选「租赁」才出现租客信息输入框
- [ ] é€‰ã€Œç§Ÿèµã€ä½†ä¸å¡«ç§Ÿå®¢ä¿¡æ¯ï¼Œä¿å­˜æŠ¥ã€Œç§Ÿèµçš„储罐必须填写租客信息」
- [ ] é€‰ã€Œè‡ªç”¨ã€ä¿å­˜åŽé‡æ–°æŸ¥è¯¦æƒ…,`tenantInfo` æ˜¯ç©ºï¼ˆè¯´æ˜Žæ¸…空生效,不是没写)
- [ ] ç”±ã€Œç§Ÿèµã€æ”¹æˆã€Œæœªå¯ç”¨ã€ä¿å­˜ï¼Œ`usageType` å’Œ `tenantInfo` éƒ½å˜ç©º
- [ ] æœªå¯ç”¨çš„储罐**不在** `/basic/tankInfo/all` é‡Œï¼›`/basic/tankInfo/list` é‡Œä»èƒ½çœ‹åˆ°
- [ ] åœç”¨ä¸€ä¸ªå‚¨ç½åŽï¼Œå…¥åº“表单的储罐下拉里没有它;重新启用后又出现
- [ ] åœç”¨å‚¨ç½åŽï¼Œæ­¤å‰å·²å…¥åº“记录里的 `tankNo` ä»åœ¨ï¼ˆåŽ†å²æ•°æ®ä¸å—å½±å“ï¼‰
- [ ] æ–°å¢žå…¥åº“表单:油库/储罐做成下拉(`/basic/oilDepot/all`、`/basic/tankInfo/all`),选储罐后 `tankNo` è‡ªåŠ¨å¸¦å‡º
- [ ] æ–°å¢žå…¥åº“提交后,入库管理列表该行 `tankNo` æœ‰å€¼
- [ ] èµ°çš„æ˜¯ `addStockInRecordOnly`(用 `addstockInventory` æäº¤åˆ™ `tankNo` ä¸ºç©º â€”— ç”¨æ¥åå‘确认 3.1)
doc/20260920_´¢¹ÞÆôÓÃÓë×âÁÞÐÅϢǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,206 @@
# å‚¨ç½ã€Œæ˜¯å¦å¯ç”¨ / ç§Ÿèµè‡ªç”¨ / ç§Ÿå®¢ä¿¡æ¯ã€å‰ç«¯è”调文档
更新时间:2026-09-20
适用版本:本次后端变更后
对应需求:基础数据 - å‚¨ç½ä¿¡æ¯æ–°å¢žã€Œæ˜¯å¦å¯ç”¨ã€ã€Œç”¨é€”(租赁/自用)」「租客信息」三级联动
---
## 1. å˜æ›´èŒƒå›´
| å†…容 | å½±å“é¢ |
| --- | --- |
| å‚¨ç½ä¿¡æ¯æ–°å¢ž **是否启用** | å‚¨ç½ä¿¡æ¯é¡µè¡¨å•、列表 |
| å¯ç”¨åŽå¯é€‰ **用途**(租赁 / è‡ªç”¨ï¼‰ | å‚¨ç½ä¿¡æ¯é¡µè¡¨å• |
| ç”¨é€”选「租赁」后填 **租客信息** | å‚¨ç½ä¿¡æ¯é¡µè¡¨å• |
| **未启用的储罐从下拉隐藏** | æ–°å¢žå…¥åº“表单的储罐下拉(`/basic/tankInfo/all`) |
DDL è§ `doc/20260920_add_usage_to_tank_info.sql`,**需先在目标库执行**。
> å‚¨ç½ä¿¡æ¯é¡µèœå• id `2354`(与油库信息 `2353` å¹¶åˆ—)。储罐号带入库存的其余内容见 `doc/20260920_储罐号带入库存前端联调文档.md`。
---
## 2. è¡¨ç»“构变更
`tank_info` æ–°å¢žä¸¤åˆ—:
| åˆ— | ç±»åž‹ | é»˜è®¤å€¼ | è¯´æ˜Ž |
| --- | --- | --- | --- |
| `is_enabled` | `tinyint(1) NOT NULL` | `1` | æ˜¯å¦å¯ç”¨ `1` å¯ç”¨ / `0` æœªå¯ç”¨ |
| `usage_type` | `tinyint NULL` | `NULL` | ç”¨é€” `1` ç§Ÿèµ / `2` è‡ªç”¨ï¼Œå¯ç”¨åŽæ‰éœ€è¦é€‰ |
> å­˜é‡æ•°æ® `is_enabled` é»˜è®¤ `1`、`usage_type` ä¸ºç©ºï¼Œæ‰€ä»¥**升级后行为与改动前一致**,下拉不会突然少罐。
---
## 3. å‰ç«¯è¡¨å•联动
```
是否启用(开关)
   â””─ æ‰“å¼€ â†’ ç”¨é€”(租赁 / è‡ªç”¨ï¼‰
                â””─ é€‰ã€Œç§Ÿèµã€ â†’ ç§Ÿå®¢ä¿¡æ¯ï¼ˆè¾“入框)
```
**关闭「是否启用」时,用途和租客信息两个控件应隐藏**;选「自用」时租客信息隐藏。
### 3.1 å­—段
| å­—段 | ç±»åž‹ | å–值 | è¡¨å•控件 |
| --- | --- | --- | --- |
| `isEnabled` | Integer | `1` å¯ç”¨ / `0` æœªå¯ç”¨ | å¼€å…³ï¼Œé»˜è®¤æ‰“å¼€ |
| `usageType` | Integer | `1` ç§Ÿèµ / `2` è‡ªç”¨ | å•选/下拉,仅在启用时显示 |
| `tenantInfo` | String | è‡ªç”±æ–‡æœ¬ï¼Œæœ€é•¿ 255 | **输入框**,仅在 `usageType=1` æ—¶æ˜¾ç¤º |
> âš ï¸ **租客信息是自由文本输入框,不是下拉选择**。后端**没有**租客列表接口,也没有租客表——请不要按「下拉选租客」来实现,直接放一个 `input` å³å¯ã€‚
### 3.2 å…¶ä½™å­—段(沿用原有,未变)
`tankNo`(储罐号)、`category`(大类)、`oilProduct`(油品)、`capacity`(罐容)、`remaining`(库存余量 m³)。
---
## 4. æŽ¥å£
前缀 `/basic/tankInfo`,统一返回 `{ code, msg, data }`。
| æŽ¥å£ | æ–¹æ³• | è¯´æ˜Ž |
| --- | --- | --- |
| `/basic/tankInfo/list` | GET | åˆ†é¡µæŸ¥è¯¢ï¼Œå– `data.records` |
| `/basic/tankInfo/all` | GET | ä¸‹æ‹‰ç”¨ï¼Œ**只返回已启用的储罐** |
| `/basic/tankInfo/{id}` | GET | è¯¦æƒ… |
| `/basic/tankInfo/add` | POST | æ–°å¢ž |
| `/basic/tankInfo/update` | POST | ä¿®æ”¹ï¼Œå¿…须带 `id` |
| `/basic/tankInfo/del` | DELETE | æ‰¹é‡åˆ é™¤ï¼Œbody ä¸º id æ•°ç»„ |
### 4.1 `GET /basic/tankInfo/list`(行为变化)
**新增可选过滤参数**:
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `isEnabled` | Integer | æŒ‰å¯ç”¨çŠ¶æ€è¿‡æ»¤ï¼Œä¸ä¼ ä¸ºå…¨éƒ¨ |
| `usageType` | Integer | æŒ‰ç”¨é€”过滤,不传为全部 |
原有 `tankNo`、`category` ä»ä¸ºæ¨¡ç³ŠåŒ¹é…ï¼Œ**未改动**。列表**返回全部储罐(含未启用)**,建议前端加一列「是否启用」并用上面的参数做筛选。
```
GET /basic/tankInfo/list?pageNum=1&pageSize=10&isEnabled=1
```
### 4.2 `GET /basic/tankInfo/all`(**行为变化,重点**)
**只返回 `isEnabled=1` çš„储罐。** æœªå¯ç”¨çš„罐还没投用,不应出现在入库表单的储罐下拉里。
- è¯¥æŽ¥å£**不接受** `isEnabled` å‚数,永远只给启用的;
- å­˜é‡æ•°æ®è¿ç§»åŽ `is_enabled` å…¨ä¸º `1`,所以改造前后下拉内容一致;
- åœç”¨ä¸€ä¸ªå‚¨ç½ â†’ åˆ·æ–°ä¸‹æ‹‰å³æ¶ˆå¤±ï¼›é‡æ–°å¯ç”¨ â†’ åˆå‡ºçŽ°ã€‚
响应示例:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": [
    {
      "id": 3,
      "tankNo": "01#",
      "category": "成品油",
      "oilProduct": "柴#0",
      "capacity": 1000.00,
      "remaining": 320.00,
      "isEnabled": 1,
      "usageType": 1,
      "tenantInfo": "南通某某运输有限公司"
    }
  ]
}
```
### 4.3 `POST /basic/tankInfo/add` / `update`
请求体就是储罐对象,**新增字段直接平铺在顶层**:
```json
{
  "id": 3,
  "tankNo": "01#",
  "category": "成品油",
  "oilProduct": "柴#0",
  "capacity": 1000.00,
  "remaining": 320.00,
  "isEnabled": 1,
  "usageType": 1,
  "tenantInfo": "南通某某运输有限公司"
}
```
`update` å¿…须带 `id`,否则返回「缺少储罐id」。
> `id` / `createTime` / `updateTime` / `createUser` / `updateUser` / `tenantId` / `deptId` ç”±åŽç«¯è‡ªåŠ¨ç»´æŠ¤ï¼Œå‰ç«¯**不要传**。
---
## 5. åŽç«¯å½’一化规则(前端不用自己清字段)
后端会对这三个字段做一次归一化,按下面的口径落库:
| æäº¤çš„值 | åŽç«¯è¡Œä¸º |
| --- | --- |
| `isEnabled=0`(未启用) | `usageType` å’Œ `tenantInfo` **一律清空** â€”— å¯ç”¨ä¹‹åŽæ‰è°ˆç§Ÿèµ/自用 |
| `isEnabled=1` + `usageType=1`(租赁) | `tenantInfo` **必填**,为空报错 |
| `isEnabled=1` + `usageType=2`(自用) | `tenantInfo` **自动清空**,前端传了也会被抹掉 |
| `isEnabled=1` + `usageType` ä¸ä¼  / ä¸º `null` | è§†ä¸º**未选择**:既不校验也不清空 |
### 5.1 æ ¡éªŒé”™è¯¯æ–‡æ¡ˆ
| åœºæ™¯ | `msg` |
| --- | --- |
| `usageType` ä¼ äº† `1`/`2` ä¹‹å¤–的整数 | å‚¨ç½ç”¨é€”只能是租赁或自用 |
| `usageType=1`(租赁)但 `tenantInfo` ä¸ºç©º | ç§Ÿèµçš„储罐必须填写租客信息 |
| `update` æœªå¸¦ `id` | ç¼ºå°‘储罐id |
错误响应形态(业务异常统一走全局处理器):
```json
{ "code": 500, "msg": "租赁的储罐必须填写租客信息" }
```
---
## 6. æ³¨æ„äº‹é¡¹
**① `usageType` å¯ä»¥ä¸ºç©ºï¼Œè¿™æ˜¯æœ‰æ„çš„兼容口子**
启用状态下允许 `usageType` ä¸ºç©ºï¼ˆè¡¨ç¤º"还没选")。因为迁移后存量储罐就是 `usage_type=NULL` + `tenant_info` å¯èƒ½æœ‰å€¼ï¼Œå¦‚果强制要求必选,各租户库里的老储罐**第一次编辑就会被拦**,而且选「自用」还会把原有租客信息抹掉。
所以:**前端表单可以强制要求选**(新数据规范),但后端不会拦。若业务要求后端也强制,需要先给存量数据补 `usage_type`,属于额外一步。
**② åœç”¨å‚¨ç½ä¸å½±å“åŽ†å²ä¸šåŠ¡æ•°æ®**
销售台账、库存、出入库记录里的 `tankId` / `tankNo` éƒ½æ˜¯**落库时抄下来的快照**,储罐后来被停用、改用途、甚至删除,都不会回写这些历史数据。
**③ åˆ é™¤å‚¨ç½æ²¡æœ‰å¼•用校验**
`/basic/tankInfo/del` ç›´æŽ¥åˆ ï¼Œä¸æ ¡éªŒæ˜¯å¦è¢«é”€å”®å°è´¦/库存引用。因为有 â‘¡ çš„快照机制,历史数据显示不受影响,只是下拉里会少一个罐。前端删除前建议二次确认。
**④ å‚¨ç½åˆ—表受数据权限过滤**
`tank_info` ä¸åœ¨æ•°æ®æƒé™å¿½ç•¥è¡¨ç™½åå•里,`/list` ä¼šè¢«è‡ªåŠ¨è¿½åŠ  `create_user` / `dept_id` æ¡ä»¶ï¼Œ**换账号可能看不到别人建的储罐**。这与其它基础数据页现状一致。联调时用同一账号,或用 `dataScope=1` çš„账号,否则容易误判成"保存失败"。
---
## 7. è”调自查清单
- [ ] å‚¨ç½ä¿¡æ¯åˆ—表出现「是否启用」列,可用 `isEnabled` å‚数筛选
- [ ] è¡¨å•:打开「是否启用」才出现用途;关闭时用途与租客信息都隐藏
- [ ] è¡¨å•:用途选「租赁」才出现租客信息输入框;选「自用」时隐藏
- [ ] é€‰ã€Œç§Ÿèµã€ä½†ä¸å¡«ç§Ÿå®¢ä¿¡æ¯ â†’ ä¿å­˜è¿”回「租赁的储罐必须填写租客信息」
- [ ] é€‰ã€Œè‡ªç”¨ã€ä¿å­˜åŽé‡æ–°æŸ¥è¯¦æƒ…,`tenantInfo` ä¸ºç©ºï¼ˆç¡®è®¤æ¸…空生效,而不是没写)
- [ ] ç”±ã€Œç§Ÿèµã€æ”¹æˆã€Œæœªå¯ç”¨ã€ä¿å­˜åŽï¼Œ`usageType` å’Œ `tenantInfo` éƒ½å˜ç©º
- [ ] `usageType` ä¼  `9` â†’ è¿”回「储罐用途只能是租赁或自用」
- [ ] æœªå¯ç”¨çš„储罐**不在** `/basic/tankInfo/all` é‡Œï¼›`/basic/tankInfo/list` é‡Œä»èƒ½çœ‹åˆ°
- [ ] åœç”¨ä¸€ä¸ªå‚¨ç½ â†’ æ–°å¢žå…¥åº“表单的储罐下拉里没有它;重新启用 â†’ åˆå‡ºçް
- [ ] åœç”¨å‚¨ç½åŽï¼Œæ­¤å‰å·²å…¥åº“记录里的 `tankNo` ä»åœ¨ï¼ˆåŽ†å²æ•°æ®ä¸å—å½±å“ï¼‰
- [ ] ä¸æ”¹æ–°å­—段直接保存一个存量储罐,能保存成功且 `tenantInfo` åŽŸå€¼ä¸ä¸¢
doc/20260920_³ö¿â¼´·¢»õÓëÍùÀ´±¸¼þǰ¶ËÁªµ÷Îĵµ.md
@@ -11,7 +11,7 @@
| éœ€æ±‚ | å†…容 | å½±å“é¢ |
| --- | --- | --- |
| 13 | æ²¹å“**销售出库即发货**:出库时自动生成一张「已发货」发货台账,不发起发货审批 | å‡ºåº“表单、出库台账列表 |
| 11 | å®¢æˆ·/供应商往来各**新增一个业务字段**(客户「付款」、供应商「回款」)并可手工维护流水 | å®¢æˆ·å¾€æ¥é¡µã€ä¾›åº”商往来页 |
| 11 | å®¢æˆ·/供应商往来各**新增一个业务字段**(客户「回款」、供应商「付款」)并可手工维护流水 | å®¢æˆ·å¾€æ¥é¡µã€ä¾›åº”商往来页 |
| 15 | å¤‡ä»¶**关联产品规格**,采购入库自动累加备件库存,并新增备件出入库流水 | å¤‡ä»¶ç»´æŠ¤é¡µã€å¤‡ä»¶æµæ°´é¡µ |
| 14 | æ–°å¢žå…¥åº“选采购订单后**精确反显明细**(修复后端 SQL) | æ–°å¢žå…¥åº“页 |
@@ -87,8 +87,14 @@
| å¾€æ¥ | æ–°å¢žå­—段 | å–值来源 |
| --- | --- | --- |
| å®¢æˆ·å¾€æ¥ | `paymentAmount`(**付款**) | `payment_record` ä¸­ `party_type=1 and direction=1` çš„合计 |
| ä¾›åº”商往来 | `receiptAmount`(**回款**) | `payment_record` ä¸­ `party_type=2 and direction=2` çš„合计 |
| å®¢æˆ·å¾€æ¥ | `receiptRecordAmount`(**回款**) | `payment_record` ä¸­ `party_type=1 and direction=1` çš„合计 |
| ä¾›åº”商往来 | `paymentRecordAmount`(**付款**) | `payment_record` ä¸­ `party_type=2 and direction=2` çš„合计 |
> âš ï¸ **口径已修正(2026-09-20)**:最初实现成了「客户付款、供应商回款」,与业务不符。现按「**客户是回款、供应商是付款**」展示。
>
> **只换了列的语义与字段名,`direction` çš„取值一个都没动** â€”— å®¢æˆ·ä¾§ä»æ˜¯ `direction=1`,供应商侧仍是 `direction=2`。这两个值是历史流水已经在用的落库方向,改了就会查不到已有数据。所以「`direction=1` åœ¨å®¢æˆ·å¾€æ¥é‡Œè¯»ä½œå›žæ¬¾ã€åœ¨ä¾›åº”商往来里读作付款」这类**按页取值**的对应关系是刻意的,不要按字段名去反推。
>
> å­—段名同时改掉了:客户侧 `paymentAmount` â†’ `receiptRecordAmount`,供应商侧 `receiptAmount` â†’ `paymentRecordAmount`。**供应商侧的付款列无法直接叫 `paymentAmount`** â€”— è¯¥åå­—已被真实付款单(`account_purchase_payment`)的汇总列占用,重名会让同一 resultMap çš„两个列互相覆盖。
**只有一个方向是手工流水**:客户侧的「收款金额」`receiptPaymentAmount`、供应商侧的「付款金额」`paymentAmount` ä»æ¥è‡ªåŽŸæœ‰çš„é‡å•æ®è¡¨ï¼Œ**计算口径未变**;`receiptableAmount`(应收)与 `payableAmount`(应付)公式**保持不变**,新字段只做展示,不参与余额计算。
@@ -96,12 +102,12 @@
| æŽ¥å£ | æ–°å¢žè¿”回字段 |
| --- | --- |
| `GET /metricStatistics/customewTransactions` | `paymentAmount` |
| `GET /metricStatistics/customewTransactionsDetails` | `paymentAmount` |
| `GET /purchase/report/supplierTransactions` | `receiptAmount` |
| `GET /purchase/report/supplierTransactionsDetails` | `receiptAmount` |
| `GET /metricStatistics/customewTransactions` | `receiptRecordAmount`(回款) |
| `GET /metricStatistics/customewTransactionsDetails` | `receiptRecordAmount`(回款) |
| `GET /purchase/report/supplierTransactions` | `paymentRecordAmount`(付款) |
| `GET /purchase/report/supplierTransactionsDetails` | `paymentRecordAmount`(付款) |
> æ˜Žç»†æŽ¥å£çš„ `paymentAmount` / `receiptAmount` æ˜¯**往来方维度**的合计(该客户/供应商全部流水合计),流水本身不挂到单张合同上,因此明细各行该列值相同。若前端希望只在列表页展示,明细页可隐藏该列。
> æ˜Žç»†æŽ¥å£çš„ `receiptRecordAmount` / `paymentRecordAmount` æ˜¯**往来方维度**的合计(该客户/供应商全部流水合计),流水本身不挂到单张合同上,因此明细各行该列值相同。若前端希望只在列表页展示,明细页可隐藏该列。
### 3.3 æ–°å¢žï¼šä»˜æ¬¾å›žæ¬¾æµæ°´å››æŽ¥å£
@@ -129,13 +135,50 @@
`tenantId` / `deptId` / `createUser` / `createTime` / `updateUser` / `updateTime` ç”±åŽç«¯è‡ªåŠ¨å¡«å……ï¼Œ**前端不要传**。
**查询参数示例**(客户往来页「新增付款」后查看该客户流水):
**查询参数示例**(客户往来页「新增回款」后查看该客户流水):
```
GET /paymentRecord/listPage?pageNum=1&pageSize=10&partyType=1&partyId=449&direction=1
```
> å„往来页新增流水时要传的值(**易错,务必按此传;`direction` ä¸è¦æŒ‰ã€Œ1付款 2回款」的字面去选**):
| é¡µé¢ | è¯¥é¡µçš„列语义 | `partyType` | `direction` |
| --- | --- | --- | --- |
| å®¢æˆ·å¾€æ¥ | å›žæ¬¾ | `1` | `1` |
| ä¾›åº”商往来 | ä»˜æ¬¾ | `2` | `2` |
| åˆä½œå•†å¾€æ¥ | å·²ä»˜æ¬¾ | `3` | `1` |
`listPage` è¿”回结构为 MyBatis-Plus åˆ†é¡µå¯¹è±¡ï¼Œå‰ç«¯å– `data.records`。
### 3.4 æ³¨æ„äº‹é¡¹
**① æ–°å­—段只做展示,不参与余额计算**
`receiptRecordAmount`(客户回款)、`paymentRecordAmount`(供应商付款)是独立的手工流水,**不会**改变应收 `receiptableAmount` / åº”付 `payableAmount`。插入一条 `partyType=1&direction=1` çš„客户回款流水后,「回款」列会变,「应收」列**不变**——这是预期行为,不是 bug。
**② `direction` å¿…须只传 `1` æˆ– `2`**
后端对 `partyType` åšäº†ç™½åå•(`1` å®¢æˆ· / `2` ä¾›åº”商 / `3` åˆä½œå•†ï¼Œä¼ å…¶å®ƒå€¼æŠ¥ã€Œå¾€æ¥æ–¹ç±»åž‹ä¸åˆæ³•」),但 **`direction` æ²¡æœ‰ç™½åå•**,传 `1`/`2` ä¹‹å¤–的整数也会入库。前端下拉固定给 `1` ä»˜æ¬¾ã€`2` å›žæ¬¾ä¸¤ä¸ªé€‰é¡¹ï¼Œä¸è¦æ”¾å¼€ã€‚
**③ `/paymentRecord/delete` æ²¡æœ‰å½’属校验(既有风险)**
该接口不校验流水归属,**任何登录用户都能删任意一条流水**,包括别的客户/供应商、乃至合作商的。本次未改动后端行为,前端请务必:
- åˆ é™¤å‰åŠ äºŒæ¬¡ç¡®è®¤ï¼›
- ä¸è¦åœ¨é¡µé¢ä¸Šæš´éœ²ã€Œæ‰¹é‡å…¨é€‰åˆ é™¤ã€è¿™ç±»å…¥å£ï¼Œé¿å…è¯¯åˆ ä¸€ç‰‡ã€‚
若业务要求收紧,需要后端补「归属校验」或「创建人校验」,属于新增需求。
**④ æµæ°´åˆ—表受数据权限过滤**
`payment_record` ä¸åœ¨æ•°æ®æƒé™å¿½ç•¥è¡¨ç™½åå•里,`/paymentRecord/listPage` ä¼šè¢«è‡ªåŠ¨è¿½åŠ  `create_user` / `dept_id` æ¡ä»¶ã€‚**换一个账号可能看不到别人录入的流水**——这与供应商、采购台账列表的现状一致,不是新问题。联调时请用与录入人同数据范围的账号,或用 `dataScope=1` çš„账号,否则容易误判成「新增没生效」。
> å¾€æ¥æŠ¥è¡¨ï¼ˆ`customewTransactions` / `supplierTransactions`)顶层是聚合子查询,不受该限制,两侧金额仍能对上;但因为流水列表被过滤,可能出现「报表里流水列有数、点进流水看不到记录」的观感差异,属同源问题。
**⑤ å¼€å‘库现有测试流水都能用**
`payment_record` çŽ°å­˜ `id=2`(`party_type=3, direction=1`,合作商)、`id=4`(`party_type=2, direction=2`,供应商)、`id=5`(`party_type=1, direction=1`,客户 449)。`direction` å–值未变,所以这三条分别对应合作商「已付款」、供应商「付款」、客户「回款」,可直接用于联调(客户 449 çš„回款列应为 10.00,供应商 243 çš„付款列为 1.00)。
---
@@ -150,14 +193,27 @@
| `productId` | Long | å…³è”产品大类 id |
| `productModelId` | Long | å…³è”产品**规格型号** id |
**这是打通采购的业务钩子**:采购台账新增明细时,后端用明细的 `productModelId` åŽ»åŒ¹é… `spare_parts.product_model_id`。匹配上则:
**这是打通采购的业务钩子**:采购台账保存明细时,后端用明细的 `productModelId` åŽ»åŒ¹é… `spare_parts.product_model_id`。匹配上则:
1. `spare_parts.quantity` ç´¯åŠ è¯¥æ˜Žç»†æ•°é‡ï¼›
2. å†™ä¸€æ¡å¤‡ä»¶å…¥åº“流水(`direction=1`、`sourceType=1`、`sourceId=采购台账id`、`sourceNo=采购合同号`)。
未维护 `productModelId` çš„备件**不会**被匹配,静默跳过,不影响采购台账保存。
> ä»…在采购台账**新增明细**时回写;编辑采购台账走更新分支,不会重复计数。
> åªå¯¹**采购明细**(`type=2`)生效,销售/项目明细不动备件。
**编辑台账是「先回滚、再重建」**:后端先把该台账上次产生的备件入库流水全部减回并删除,再按前端本次提交的**完整明细列表**重新累加。所以:
- æ˜Žç»†è¡Œè¢«**移除** â†’ å¯¹åº”备件数量自动减回去(不需要前端额外调接口);
- æ˜Žç»†è¡Œ**数量被改** â†’ æŒ‰æ–°æ•°é‡é‡ç®—,不会叠加成双份;
- æ˜Žç»†è¡Œ**新增** â†’ æ­£å¸¸ç´¯åŠ ï¼Œä¸ä¼šé‡å¤è®¡æ•°ã€‚
> âš ï¸ å› æ­¤ç¼–辑台账时**必须提交完整明细列表**,不能只提交改动的那几行。只传部分行会被当成「其余行已删除」而回滚掉。
**删除台账会连带回滚备件**:`POST` æ‰¹é‡åˆ é™¤é‡‡è´­å°è´¦æ—¶ï¼ŒåŽç«¯ä¼šæŠŠè¯¥å°è´¦ç´¯åŠ åˆ°å¤‡ä»¶ä¸Šçš„æ•°é‡å‡å›žåŽ»ï¼Œå¹¶åˆ æŽ‰å¯¹åº”çš„ `spare_parts_record` å…¥åº“流水。
- è‹¥å¤‡ä»¶**已被维修/保养领用**,减回会导致负库存,此时**删除会被拒绝**并提示「备件「xxx」当前库存 N,不足以回滚本次采购入库的 M,请先处理领用记录」。这是有意的保护,不做静默负库存。
- åˆ é™¤ä¸Žå›žæ»šåœ¨åŒä¸€ä¸ªäº‹åŠ¡å†…ï¼Œä»»ä¸€æ­¥å¤±è´¥æ•´ä½“å›žæ»šã€‚
### 4.2 æ–°å¢žï¼šå¤‡ä»¶å‡ºå…¥åº“流水
@@ -178,17 +234,19 @@
| `quantity` | æ•°é‡ |
| `sourceType` | æ¥æºç±»åž‹ï¼Œè§ä¸‹è¡¨ |
| `sourceId` | æ¥æºå•据 id |
| `sourceNo` | æ¥æºå•号 |
| `sourceNo` | æ¥æºå•号,见下表 |
| `remark` | å¤‡æ³¨ |
| `createTime` | å‘生时间 |
`sourceType` æžšä¸¾ï¼š
| å€¼ | å«ä¹‰ | è§¦å‘点 |
| --- | --- | --- |
| `1` | é‡‡è´­å…¥åº“ | é‡‡è´­å°è´¦æ–°å¢žå«è¯¥è§„格的明细 |
| `2` | ç»´ä¿®é¢†ç”¨ | è®¾å¤‡æŠ¥ä¿®å•更新时领用备件 |
| `3` | ä¿å…»é¢†ç”¨ | è®¾å¤‡ä¿å…»å•更新时领用备件 |
| å€¼ | å«ä¹‰ | è§¦å‘点 | `sourceNo` å–值 |
| --- | --- | --- | --- |
| `1` | é‡‡è´­å…¥åº“ | é‡‡è´­å°è´¦ä¿å­˜å«è¯¥è§„格的采购明细 | é‡‡è´­åˆåŒå· |
| `2` | ç»´ä¿®é¢†ç”¨ | è®¾å¤‡æŠ¥ä¿®å•更新时领用备件 | **设备名称** |
| `3` | ä¿å…»é¢†ç”¨ | è®¾å¤‡ä¿å…»å•更新时领用备件 | **设备名称** |
> æŠ¥ä¿®å•、保养单本身**没有单号字段**,所以 `sourceType=2/3` çš„ `sourceNo` æ”¾çš„æ˜¯è®¾å¤‡åç§°ï¼Œä¾¿äºŽåœ¨æµæ°´é¡µçœ‹å‡ºå¤‡ä»¶ç”¨åœ¨å“ªå°è®¾å¤‡ä¸Šï¼›è¦ä¸‹é’»åˆ°å•据用 `sourceId`。这一列前端直接展示即可,不要按「单号」格式做校验。
> ç»´ä¿®/保养**领用**仍沿用原有 `spare_parts_requisition_record`(设备维度,另有页面);本次新增的 `spare_parts_record` æ˜¯**备件维度**的进出流水,两者并存,职责不同。
@@ -227,9 +285,15 @@
- [ ] å‘货台账中能看到该自动生成的单,状态为 `已发货`
- [ ] åŒä¸€äº§å“å†æ¬¡å‡ºåº“,生成**另一张**发货单(不是被拦截)
- [ ] åˆ é™¤å‡ºåº“单后,发货台账、发货明细、库存扣减记录一并消失
- [ ] å®¢æˆ·å¾€æ¥åˆ—表/明细出现「付款」列,插入一条 `direction=1` æµæ°´åŽæ•°å€¼å˜åŒ–,且「应收」不变
- [ ] ä¾›åº”商往来列表/明细出现「回款」列,逻辑同上
- [ ] å®¢æˆ·å¾€æ¥åˆ—表/明细出现「回款流水」列(`receiptRecordAmount`),插入一条 `partyType=1&direction=1` æµæ°´åŽæ•°å€¼å˜åŒ–,且「应收」不变
- [ ] ä¾›åº”商往来列表/明细出现「付款流水」列(`paymentRecordAmount`),插入一条 `partyType=2&direction=2` æµæ°´åŽæ•°å€¼å˜åŒ–,且「应付」不变
- [ ] æµæ°´å››æŽ¥å£éƒ½é€šï¼š`/paymentRecord/listPage`、`add`、`update`、`delete`;`partyType` ä¼  `9` è¿”回「往来方类型不合法」
- [ ] ç”¨å¦ä¸€ä¸ªæ•°æ®èŒƒå›´çš„账号调 `listPage`,确认「看不到别人录的流水」是预期(不是新增失败)
- [ ] å¤‡ä»¶ç»´æŠ¤èƒ½ä¿å­˜äº§å“å¤§ç±» + è§„格型号
- [ ] æ–°å¢žä¸€å¼ å«è¯¥è§„格的采购台账,备件数量增加,备件流水出现 `direction=1` è®°å½•
- [ ] è®¾å¤‡æŠ¥ä¿®/保养领用备件后,备件流水出现 `direction=2` è®°å½•
- [ ] ç¼–辑该台账**删掉**那条明细,备件数量减回去,对应入库流水消失
- [ ] ç¼–辑该台账**改数量**,备件数量按新数量重算(不是两份相加)
- [ ] åˆ é™¤è¯¥é‡‡è´­å°è´¦ï¼Œå¤‡ä»¶æ•°é‡å‡å›žåŽ»ï¼Œå…¥åº“æµæ°´æ¶ˆå¤±
- [ ] å¤‡ä»¶å·²è¢«é¢†ç”¨åŽå†åˆ å°è´¦ï¼Œåˆ é™¤è¢«æ‹’绝并给出「不足以回滚」提示(不是负库存)
- [ ] è®¾å¤‡æŠ¥ä¿®/保养领用备件后,备件流水出现 `direction=2` è®°å½•,且「来源单号」显示设备名称
- [ ] æ–°å¢žå…¥åº“选订单后,明细只显示采购类(type=2)产品
doc/20260920_ίÍâ¹ÜÀíǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,725 @@
# å§”外管理模块前端联调文档
更新时间:2026-09-20
适用版本:本次后端变更后
后端新增包:`com.ruoyi.outsourcing`
## 1. å˜æ›´èŒƒå›´
新增一级菜单「**委外管理**」,下含三个页面:
| é¡µé¢ | è·¯å¾„(菜单) | å‰ç«¯ç»„件路径 | æŽ¥å£å‰ç¼€ |
| --- | --- | --- | --- |
| åˆä½œå•†æ¡£æ¡ˆ | `outsourcingManagement/partner` | `outsourcingManagement/partner/index` | `/outsourcing/partner` |
| å§”外订单 | `outsourcingManagement/order` | `outsourcingManagement/order/index` | `/outsourcing/order` |
| åˆä½œå•†å¾€æ¥ | `outsourcingManagement/transactions` | `outsourcingManagement/transactions/index` | `/outsourcing/report` |
### 1.1 æ•°æ®åº“脚本(需先执行)
| è„šæœ¬ | ä½œç”¨ |
| --- | --- |
| `doc/20260920_create_outsourcing.sql` | å»º `outsourcing_partner`、`outsourcing_order`、`outsourcing_order_product` ä¸‰å¼ è¡¨ |
| `doc/20260920_outsourcing_menu.sql` | å»ºèœå•(`menu_id` 2360~2376),可重复执行 |
| `doc/20260920_create_payment_record.sql` | `party_type` æ³¨é‡Šæ”¹ä¸º `1客户 2供应商 3合作商` |
已建过 `outsourcing_order_product` çš„环境,报废品改合格库存后还需执行列改名(2026-09-20 å·²åœ¨å¼€å‘库执行):
```sql
ALTER TABLE outsourcing_order_product
    CHANGE COLUMN stock_uninventory_id stock_inventory_id bigint null comment '选用的报废品合格库存id(stock_inventory.id,只读引用,不回写库存)';
ALTER TABLE outsourcing_order_product RENAME INDEX idx_stock_uninventory_id TO idx_stock_inventory_id;
```
> çº¯æ”¹åï¼Œåˆ—里存的历史值不动。开发库该表无数据,无风险。
> å·²å»ºè¿‡ `payment_record` çš„环境执行:
> `ALTER TABLE payment_record MODIFY COLUMN party_type tinyint not null comment '往来方类型 1客户 2供应商 3合作商';`
> è¯¥æ“ä½œä¸ºæ³¨é‡Šå˜æ›´ï¼Œæ— æ•°æ®é£Žé™©ã€‚
### 1.2 å¯¹æ—¢æœ‰æ¨¡å—的改动
委外订单提交时会占用报废品库存,走的是**现有出库审批机制**,因此改到了 `stock` æ¨¡å—,前端需一并知晓:
| æ”¹åЍ | è¯´æ˜Ž |
| --- | --- |
| æ–°å¢žå‡ºåº“类型 `record_type=30` | ã€Œå§”外-报废品出库」(**`type=0` åˆæ ¼å‡ºåº“**)。**出库管理页的类型列需要加上这个映射**,否则显示为空 |
| å¤ç”¨ `POST /stockOutRecord/approve` | å§”外出库记录用现有审批接口,**没有新增后端接口** |
| æ–°å¢žå†…部方法 | `StockOutRecordService#listByRecordIds` / `releaseByRecordIds`,只给委外订单内部用,前端不直接调用 |
| æŠ¥åºŸå“æ”¹èµ°åˆæ ¼åº“å­˜ | æŠ¥åºŸå“é€‰æ–™ä»Ž `stock_uninventory` æ”¹ä¸º `stock_inventory`,出库记录 `type` ç”± `1` æ”¹ä¸º `0`;`outsourcing_order_product.stock_uninventory_id` **改名为 `stock_inventory_id`**(需执行 1.1 çš„ ALTER) |
| é€‰æ–™æŽ¥å£æŒªä½ç½® | `GET /stockUninventory/pageScrapStock` **已删除**,改为 `GET /stockInventory/pageScrapStock`;委外订单的 `GET /outsourcing/order/scrapStockPage` è·¯å¾„不变、出参字段不变 |
| å‡ºåº“类型 `30` æŒªåˆ°åˆæ ¼å‡ºåº“枚举 | `OUTSOURCING_SCRAP_OUT("30","委外-报废品出库")` ä»Ž**入库**枚举 `StockInQualifiedRecordTypeEnum` ç§»åˆ°**出库**枚举 `StockOutQualifiedRecordTypeEnum`(出库记录导出/下拉按 `type` å–枚举,`type=0` å–的就是后者,不挪会报空指针) |
| é€‰æ–™èŒƒå›´æ”¶çª„ | å§”外报废品选料**只列产品树「危废处置次生危废」大类(含子节点)**下有合格库存的批次,不再列全库库存 |
| å‡ºåº“审批顺带完成订单 | `POST /stockOutRecord/approve` é€šè¿‡æ—¶ï¼Œè‹¥è¯¥å§”外订单的报废品出库已**全部通过**,自动把订单置为「已完成」`2`(`StockOutRecordServiceImpl#batchApprove` å†…的副作用,不是新接口) |
---
## 2. ä¸šåŠ¡å£å¾„ï¼ˆ**必读**,和前端展示强相关)
### 2.1 å§”外订单状态流转
| å€¼ | å«ä¹‰ | å…è®¸æ“ä½œ |
| --- | --- | --- |
| `0` | è‰ç¨¿ | ç¼–辑、删除、提交、作废 |
| `1` | å·²ç¡®è®¤ | å®Œæˆï¼ˆ**需报废品出库已审批通过**)、作废 |
| `2` | å·²å®Œæˆ | ä»…查看(终态) |
| `3` | å·²ä½œåºŸ | ä»…查看(终态) |
- `0 --submit--> 1 --complete--> 2`
- `0 / 1 --cancel--> 3`
- æ–°å¢žçš„委外订单**强制为草稿**,前端不需要也不允许传 `status`。
- **不走 OA å®¡æ‰¹**,审批流不涉及「审批管理」菜单。
- æäº¤ä¼šå ç”¨æŠ¥åºŸå“åº“存,完成要求出库已审批,作废会释放占用 â€”— è¯¦è§ 2.3。
- âš ï¸ **`1 â†’ 2` çŽ°åœ¨å¤šåŠæ˜¯è‡ªåŠ¨å‘ç”Ÿçš„**:该订单的报废品出库记录**全部审批通过**时,后端会自动把订单推到「已完成」,不需要再点「完成」。`POST /outsourcing/order/complete` ä¿ç•™ï¼Œä½†è®¢å•已是 `2` æ—¶è°ƒç”¨ä¼šè¿”回「仅已确认状态的委外订单允许完成」,**前端建议在状态为 `1` æ—¶ä»æ˜¾ç¤ºã€Œå®Œæˆã€æŒ‰é’®ä½œä¸ºå…œåº•,状态为 `2` æ—¶éšè—**。
### 2.2 åˆä½œå•†å¾€æ¥åº”付口径
```
应付余额 = å§”外订单金额(status in (1,2)) âˆ’ å·²ä»˜æ¬¾ï¼ˆpayment_record: party_type=3 and direction=1)
```
两个必须注意的点:
1. **草稿(0) å’Œ å·²ä½œåºŸ(3) çš„订单不计入应付**。只有已确认和已完成计入。
2. **明细每行的「已付款」是合作商级合计,不是订单级**。`payment_record` è¡¨æ²¡æœ‰è®¢å•维度字段,所以同一合作商名下所有明细行的 `paidAmount` æ˜¯åŒä¸€ä¸ªå€¼ï¼ˆçŽ°æœ‰å®¢æˆ·å¾€æ¥ã€ä¾›åº”å•†å¾€æ¥ä¹Ÿæ˜¯è¿™ä¸ªå£å¾„ï¼‰ã€‚
   â†’ å› æ­¤**明细行只有「订单金额」是逐行准确的**;`paidAmount` / `payableAmount` å»ºè®®åªåœ¨æ±‡æ€»è¡Œï¼ˆåˆ—表页)强调,明细行若要展示需加说明文案。
### 2.3 æŠ¥åºŸå“é€‰æ–™çš„库存联动(**提交即占用、出库审批才实扣**)
委外订单明细可选「报废品」库存批次 â€”— é€‰æ–™å¼¹çª—**只列基础数据产品树里「危废处置次生危废」大类(含其所有子节点)下、且有合格库存的批次**,其它产品大类的库存不出现。选料取的是**合格库存 `stock_inventory`**,联动走的是**现有出库审批那套机制**,时序如下:
| çŽ¯èŠ‚ | åº“存动作 |
| --- | --- |
| é€‰æ–™ï¼ˆå‰ç«¯å¼¹çª—) | åªè¯»æŸ¥è¯¢ï¼Œä¸åŠ¨åº“å­˜ |
| å»ºè‰ç¨¿ / ç¼–辑草稿 / åˆ é™¤è‰ç¨¿ | **不动库存** |
| **提交**(`0 â†’ 1`) | æ¯æ¡é€‰æ–™è¡Œå†™ä¸€æ¡**待审批出库记录**(**`type=0` åˆæ ¼å‡ºåº“**)。记录一落库就通过「待审批出库量」把可用量占住,**但不改 `qualitity`** |
| **出库审批**(出库管理页 `/stockOutRecord/approve`) | å®¡æ‰¹**通过**时才真正扣 `stock_inventory.qualitity`(按规格+批号) |
| **完成**(`1 â†’ 2`) | **自动触发**:该订单的报废品出库记录**全部审批通过**的那一刻,后端自动把订单推到「已完成」。也可以手工调 `/complete`,前置校验同样是「出库全部已审批通过」,否则拒绝 |
| **作废**(`1 â†’ 3`) | é‡Šæ”¾å ç”¨ï¼šå¾…审批/驳回的记录**直接删除**(占用随之释放);已审批通过的记录**按批次归还 `stock_inventory.qualitity`**,再删除 |
关键口径:
- **可用量 = `qualitity` âˆ’ `locked_quantity` âˆ’ å¾…审批出库量**。提交时就是按这个口径校验的,不足会直接报错并提示当前可用量。因为待审批记录也算占用,**同一批次不会被多张委外订单超选**。
- æ‰€ä»¥å‡ºåº“记录里既有委外**自己**占的,也有采购退货、销售发货、生产领料等**其它业务**占的,都是同一套算法,互相可见。
- æ˜Žç»†è¡Œä¸Šçš„ `availableQuantity` ä»æ˜¯**下单那一刻的快照**,仅供参照;真实可用量以提交时的校验结果为准。
- `lockedQuantity`(`stock_inventory.locked_quantity`)是**冻结量**,出库等业务在用,和委外占用不是一回事。
### 2.4 å§”外出库记录长什么样、去哪审批
- å‡ºåº“类型固定为 **`30`「委外-报废品出库」**(`record_type=30`、**`type=0` åˆæ ¼å‡ºåº“**)。该取值已加进**合格出库枚举 `StockOutQualifiedRecordTypeEnum`**,因此 `/basic/enum/stockRecordType` ä¼šè‡ªåŠ¨å¸¦ä¸Šå®ƒï¼ŒåŽç«¯å¯¼å‡º Excel ä¹Ÿèƒ½æ­£ç¡®æ˜¾ç¤ºã€‚**前端若用的是这个接口取类型下拉/映射,无需改动**;只有当类型列是前端硬编码映射表时才需要补 `30`。
- `record_id` = **委外订单明细行 id**(`outsourcing_order_product.id`),不是订单 id。
- å‡ºåº“批次号(`outboundBatches`)形如 `CK20260920001`,提交后可在出库管理页按类型 `30` è¿‡æ»¤ã€‚
- å®¡æ‰¹ç”¨**现有**接口 `POST /stockOutRecord/approve`,body `{ "ids": [出库记录id], "approvalStatus": 1 }`(`1` é€šè¿‡ / `2` é©³å›žï¼‰ã€‚**不需要新增后端接口**。
- **审批通过会顺带完成委外订单**:如果这条出库记录是该订单报废品出库的**最后一条待审批**,审批接口返回后委外订单状态就已经是 `2`「已完成」了。前端审批完刷新就会看到订单变已完成,不需要再调 `/complete`。
  - ä¸€æ¬¡å®¡æ‰¹å¤šæ¡ï¼ˆ`ids` ä¼ å¤šæ¡ã€åˆ†å±žä¸åŒè®¢å•)时,每个订单各自判断,够条件的都会自动完成。
  - **驳回(`approvalStatus=2`)不会回退订单状态**:订单仍是「已确认」,走「反审 â†’ é€šè¿‡ã€æˆ–「作废」两条出路(见 2.3 çš„关键口径)。
  - å·²è‡ªåŠ¨å®Œæˆçš„è®¢å•è‹¥ä¹‹åŽè¢«**反审**(`/stockOutRecord/reAudit`),订单状态**也保持在「已完成」不回退**,需要人工重新审批后再处理。
- âš ï¸ **数据权限影响**:`stock_out_record` ä¸åœ¨æ•°æ®æƒé™ç™½åå•里,**出库管理列表和审批都会被 `create_user`/`dept_id` è¿‡æ»¤**。也就是说出库审批必须由**与提交人同数据范围**的账号,或 `dataScope=1`/admin è´¦å·æ¥åšï¼Œå¦åˆ™ä¼šæŠ¥ã€Œå‡ºåº“记录不存在」。这是既有行为(合格/不合格/销售出库的审批都一样),本次未改。
  - å§”外订单自己的两个动作(完成的出库校验、作废的占用释放)**已显式跳过数据权限**,不会因为换账号而漏校验或释放不掉。
- **出库被驳回怎么办**:驳回后该记录的待审批占用会立即释放,但委外订单仍是「已确认」,此时 `complete` ä¼šè¢«æ‹’。两条出路:到出库管理点「反审」`POST /stockOutRecord/reAudit` é€€å›žå¾…审批再审批通过;或直接 `/cancel` ä½œåºŸè¯¥å§”外订单(驳回的记录会被删掉)。
---
## 3. å…¬å…±å­—段说明(后端自动维护,前端只读)
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `orderNo` | å§”外订单号,新增时后端生成,格式 `WWDD` + æ—¥æœŸ + å½“日流水,如 `WWDD20260920001`。**前端不要传**,传了也会被忽略。 |
| `partnerName` | ä¾æ® `partnerId` åæŸ¥å›žå¡«ï¼Œå‰ç«¯ä¸å¿…传(传了以后端结果为准)。 |
| `contractAmount` | **后端按明细 `taxInclusiveTotalPrice` åˆè®¡è¦†ç›–写入**,前端传了也会被覆盖。请保证明细里含税总价正确。 |
| `applyDate` | ä¸ä¼ æ—¶é»˜è®¤å½“天。 |
| `productCategory` / `specificationModel` | **明细行**中这两个字段由后端根据 `productId` / `productModelId` åæŸ¥å†™å…¥ï¼Œå‰ç«¯åªéœ€ä¼  id。 |
| `status` / `confirmTime` / `cancelReason` | ç”±çŠ¶æ€æµè½¬æŽ¥å£å†™å…¥ï¼Œå‰ç«¯ä¸è¦ç›´æŽ¥æ”¹ã€‚ |
| `tenantId` / `deptId` / `createUser` / `createTime` / `updateTime` | åŽç«¯è‡ªåŠ¨å¡«å……ï¼Œå‰ç«¯ä¸è¦ä¼ ã€‚ |
---
## 4. åˆä½œå•†æ¡£æ¡ˆæŽ¥å£
### 4.1 åˆ†é¡µæŸ¥è¯¢
`GET /outsourcing/partner/listPage`
**请求参数**(均为可选)
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `partnerName` | string | åˆä½œå•†åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `partnerType` | string | åˆä½œå•†ç±»åž‹ï¼Œç²¾ç¡®åŒ¹é… |
| `isWhite` | number | æ˜¯å¦ç™½åå•,`1` æ˜¯ / `0` å¦ |
| `pageNum` / `pageSize` | number | åˆ†é¡µå‚æ•° |
**返回**:`{ total, records, size, current, ... }`(**注意**:此接口返回的是 MyBatis-Plus çš„ `IPage` ç»“构,字段是 `records`,不是 RuoYi çš„ `rows`)。
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "id": 1,
        "partnerName": "南通XX环保科技有限公司",
        "taxpayerIdentificationNum": "91320600MA1XXXXXXX",
        "companyAddress": "南通市XX区XXè·¯1号",
        "companyPhone": "0513-88888888",
        "bankAccountName": "中国银行南通分行",
        "bankAccountNum": "5200000000000000",
        "contactUserName": "李四",
        "contactUserPhone": "13800000000",
        "maintainUserId": 1,
        "maintainUserName": "管理员",
        "maintainTime": "2026-09-20",
        "partnerType": "危废处置",
        "isWhite": 1,
        "remark": "",
        "createTime": "2026-09-20 10:12:33"
      }
    ],
    "total": 1,
    "size": 10,
    "current": 1
  }
}
```
### 4.2 è¯¦æƒ…(含附件回显)
`GET /outsourcing/partner/{id}`
**返回**:`data` ä¸ºåˆä½œå•†å¯¹è±¡ï¼Œå¦å« `storageBlobVOS`(附件数组)。
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "id": 1,
    "partnerName": "南通XX环保科技有限公司",
    "maintainUserName": "管理员",
    "storageBlobVOS": [
      {
        "id": 301,
        "originalFilename": "营业执照.pdf",
        "url": "http://xxx/preview/xxx",
        "downloadURL": "http://xxx/download/xxx",
        "storageAttachmentId": 501
      }
    ]
  }
}
```
### 4.3 æ–°å¢ž
`POST /outsourcing/partner/add`,`Content-Type: application/json`
**请求体**
```json
{
  "partnerName": "南通XX环保科技有限公司",
  "taxpayerIdentificationNum": "91320600MA1XXXXXXX",
  "companyAddress": "南通市XX区XXè·¯1号",
  "companyPhone": "0513-88888888",
  "bankAccountName": "中国银行南通分行",
  "bankAccountNum": "5200000000000000",
  "contactUserName": "李四",
  "contactUserPhone": "13800000000",
  "partnerType": "危废处置",
  "isWhite": 1,
  "remark": "",
  "storageBlobDTOS": [
    { "id": 301 }
  ]
}
```
**返回**:`data` ä¸ºæ–°å»ºåˆä½œå•† id。
```json
{ "code": 200, "msg": "操作成功", "data": 1 }
```
> é™„件流程:先调 `POST /common/upload`(`multipart/form-data`,字段名 `files`,可多文件)拿到 `List<StorageBlobVO>`,取出每条的 `id` ç»„成 `storageBlobDTOS` ä¸€èµ·æäº¤å³å¯ã€‚**不需要**再单独调 `/storageAttachment/add`。
> æ–°å¢žæ—¶ `storageBlobDTOS` å¯ä¸ä¼ æˆ–传空数组。
### 4.4 ä¿®æ”¹
`POST /outsourcing/partner/update`,`Content-Type: application/json`
请求体同 4.3,**必须带 `id`**。
> **附件是全量覆盖的**:提交时 `storageBlobDTOS` ä¼ ä»€ä¹ˆå°±æ˜¯ä»€ä¹ˆã€‚ä¼  `[]` æˆ– `null` ä¼š**清空**该合作商已绑定的所有附件。前端编辑时请把回显的 `storageBlobVOS` è½¬æ¢æˆ `storageBlobDTOS` ä¸€å¹¶æäº¤ã€‚
### 4.5 åˆ é™¤
`DELETE /outsourcing/partner/del`,`Content-Type: application/json`
**请求体**:id æ•°ç»„,支持批量。
```json
[1, 2]
```
> åˆ é™¤å‰ä¼šæ ¡éªŒå¼•用:该合作商若已存在**委外订单**或**往来收付款记录**,会被拒绝(见 7. å¸¸è§é”™è¯¯ï¼‰ã€‚
### 4.6 å¯¼å‡º
`POST /outsourcing/partner/export`,参数同 4.1 æŸ¥è¯¢æ¡ä»¶ï¼ˆform å½¢å¼ï¼‰ï¼Œè¿”回 Excel æ–‡ä»¶æµï¼Œç›´æŽ¥ `responseType: 'blob'` ä¸‹è½½ã€‚
### 4.7 ä¸‹è½½å¯¼å…¥æ¨¡æ¿
`POST /outsourcing/partner/downloadTemplate`,无参数,返回 Excel æ–‡ä»¶æµã€‚
### 4.8 å¯¼å…¥
`POST /outsourcing/partner/import`,`multipart/form-data`,字段名 `file`。
```json
{ "code": 200, "msg": "导入成功" }
```
### 4.9 ä¸‹æ‹‰é€‰é¡¹
`GET /outsourcing/partner/getOptions`
**返回**:`data` ä¸ºåˆä½œå•†æ•°ç»„,仅含 `id`、`partnerName`,按 id å€’序。
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": [ { "id": 1, "partnerName": "南通XX环保科技有限公司" } ]
}
```
---
## 5. å§”外订单接口
### 5.1 åˆ†é¡µæŸ¥è¯¢
`GET /outsourcing/order/list`
**请求参数**
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `orderNo` | string | è®¢å•号,模糊匹配 |
| `partnerId` | number | åˆä½œå•†id,精确匹配 |
| `partnerName` | string | åˆä½œå•†åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `projectName` | string | é¡¹ç›®åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `status` | number | çŠ¶æ€ï¼Œè§ 2.1 |
| `startDate` | string | ç”³è¯·æ—¥æœŸèµ·ï¼Œ`yyyy-MM-dd` |
| `endDate` | string | ç”³è¯·æ—¥æœŸæ­¢ï¼Œ`yyyy-MM-dd` |
| `pageNum` / `pageSize` | number | åˆ†é¡µå‚æ•° |
**返回**:RuoYi æ ‡å‡†åˆ†é¡µç»“æž„ `{ total, rows, code, msg }`(**注意此处是 `rows`**)。
```json
{
  "total": 1,
  "rows": [
    {
      "id": 7,
      "orderNo": "WWDD20260920001",
      "partnerId": 1,
      "partnerName": "南通XX环保科技有限公司",
      "applyDate": "2026-09-20",
      "expectedDeliveryDate": "2026-09-30",
      "projectName": "危废处置",
      "contractAmount": 5000.00,
      "paymentMethod": "电汇",
      "remarks": "",
      "status": 1,
      "confirmTime": "2026-09-20 11:00:00",
      "cancelReason": null,
      "createTime": "2026-09-20 10:12:33"
    }
  ],
  "code": 200,
  "msg": "查询成功"
}
```
> åˆ—表**不返回** `productData` æ˜Žç»†ã€‚
### 5.2 è¯¦æƒ…
`GET /outsourcing/order/getById?id={id}`
**返回**:`data` ä¸ºè®¢å•对象(字段同 5.1,另含 `productData` æ˜Žç»†æ•°ç»„)。
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "id": 7,
    "orderNo": "WWDD20260920001",
    "partnerId": 1,
    "partnerName": "南通XX环保科技有限公司",
    "status": 1,
    "contractAmount": 5000.00,
    "productData": [
      {
        "id": 31,
        "outsourcingOrderId": 7,
        "productId": 491,
        "productModelId": 1001,
        "productCategory": "粘油废物",
        "specificationModel": "HW08-1",
        "unit": "吨",
        "quantity": 5,
        "taxRate": 13,
        "taxInclusiveUnitPrice": 1000.00,
        "taxInclusiveTotalPrice": 5000.00,
        "isScrap": true,
        "stockInventoryId": 9001,
        "batchNo": "P20260901",
        "availableQuantity": 100.00,
        "remark": ""
      }
    ]
  }
}
```
### 5.3 æ–°å¢žï¼ˆè‰ç¨¿ï¼‰
`POST /outsourcing/order/add`,`Content-Type: application/json`
**请求体**:同 5.2 çš„ `data` ç»“构,`id`、`orderNo`、`status`、`confirmTime` ä¸éœ€è¦ä¼ ã€‚
```json
{
  "partnerId": 1,
  "projectName": "危废处置",
  "applyDate": "2026-09-20",
  "expectedDeliveryDate": "2026-09-30",
  "paymentMethod": "电汇",
  "remarks": "",
  "productData": [
    {
      "productId": 491,
      "productModelId": 1001,
      "unit": "吨",
      "quantity": 5,
      "taxRate": 13,
      "taxInclusiveUnitPrice": 1000.00,
      "taxInclusiveTotalPrice": 5000.00,
      "isScrap": true,
      "stockInventoryId": 9001,
      "batchNo": "P20260901",
      "availableQuantity": 100.00
    }
  ]
}
```
**返回**:`data` ä¸ºæ–°å»ºè®¢å• id。
### 5.4 ä¿®æ”¹
`POST /outsourcing/order/edit`,`Content-Type: application/json`
请求体同 5.3,**必须带 `id`**,明细需**全量提交**(后端为「先删后插」)。**仅草稿状态允许**。
### 5.5 åˆ é™¤
`DELETE /outsourcing/order/delete`,`Content-Type: application/json`
**请求体**:id æ•°ç»„。**仅草稿状态允许**。
```json
[7, 8]
```
### 5.6 æäº¤
`POST /outsourcing/order/submit?id={id}`
> `id` æ˜¯ **query å‚æ•°**,不是请求体。
草稿 â†’ å·²ç¡®è®¤ã€‚前置校验:合作商存在、明细非空、金额 > 0、**报废品选料行的可用量足够**。
**会占用报废品库存**(写待审批出库记录),详见 2.3。
返回 `{ "code": 200, "msg": "操作成功" }`。
### 5.7 å®Œæˆ
`POST /outsourcing/order/complete?id={id}`
已确认 â†’ å·²å®Œæˆã€‚**前置校验:报废品选料的出库记录必须全部审批通过**(见 2.3 / 2.4),
未通过时返回「报废品出库未审批通过,请先到出库管理审批出库批次:CKxxxx」。
> âš ï¸ è¿™ä¸€æ­¥**通常不需要前端调用**了:出库记录全部审批通过时后端会自动完成订单(见 2.3 / 2.4)。
> æœ¬æŽ¥å£ä¿ç•™ä½œå…œåº•,订单已是 `2` æ—¶è°ƒç”¨è¿”回「仅已确认状态的委外订单允许完成」。
### 5.8 ä½œåºŸ
`POST /outsourcing/order/cancel?id={id}&cancelReason={原因}`
草稿或已确认 â†’ å·²ä½œåºŸã€‚`cancelReason` å¯é€‰ã€‚
**已确认的单作废会释放报废品占用**:未审批的出库记录直接删掉,已审批通过的把库存按批次归还(见 2.3)。
> âš ï¸ å› ä¸ºå‡ºåº“全部通过会自动完成订单(状态变 `2`),**已自动完成的单不能再作废**,会返回「该委外订单已完成或已作废,不允许重复作废」。
> ã€Œä½œåºŸå½’还已审批库存」这条路径现在只对**部分出库已通过**的单(仍处于 `1`)有效。
### 5.9 å¯¼å‡º
`POST /outsourcing/order/export`,参数同 5.1,返回 Excel æ–‡ä»¶æµã€‚
### 5.10 æŠ¥åºŸå“é€‰æ–™åˆ†é¡µï¼ˆçº¯æŸ¥è¯¢ï¼Œä¸å åº“存)
`GET /outsourcing/order/scrapStockPage`
> **结果已收窄到「危废处置次生危废」**:只返回产品树里「危废处置次生危废」大类(含其所有子节点)下有合格库存的批次。其它大类的库存**不会出现**,前端不需要额外传大类参数,也不需要自己过滤。
> è‹¥è¯¥å¤§ç±»åœ¨äº§å“æ ‘里不存在(或名字被改过),接口返回空列表 â€”— è¿™æ˜¯å”¯ä¸€ä¼šè®©å¼¹çª—空掉的原因,排查时先确认产品树里的大类名仍是「危废处置次生危废」。
**请求参数**
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `productModelId` | number | è§„æ ¼id,精确匹配;不传则返回该大类下全部批次 |
| `batchNo` | string | æ‰¹å·ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `productName` | string | äº§å“åç§°ï¼Œæ¨¡ç³ŠåŒ¹é…ï¼ˆåªåœ¨è¯¥å¤§ç±»çš„范围内过滤) |
| `pageNum` / `pageSize` | number | åˆ†é¡µå‚æ•° |
**返回**:`data` ä¸º `IPage` ç»“构(字段是 `records`)。
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "id": 9001,
        "productModelId": 1001,
        "batchNo": "P20260901",
        "qualitity": 100.00,
        "lockedQuantity": 0,
        "unLockedQuantity": 100.00,
        "model": "HW08-1",
        "unit": "吨",
        "productName": "粘油废物",
        "remark": "",
        "version": 0
      }
    ],
    "total": 1
  }
}
```
> è¿™é‡ŒæŸ¥çš„æ˜¯**合格库存 `stock_inventory`**(与 `/stockInventory/pageScrapStock` åŒæºï¼‰ã€‚库存管理页的 `/stockInventory/pagestockInventory` æœªåšä»»ä½•改动(它不返回 `batchNo`),选料请用本接口。
> âš ï¸ æœ¬æŽ¥å£æ˜¯çº¯æŸ¥è¯¢ï¼Œ**不会**占用库存。真正的占用发生在委外订单**提交**时(见 2.3)。另外这里返回的 `unLockedQuantity` = `qualitity âˆ’ lockedQuantity`,**没有减去待审批出库量**,所以它是「账面可用量」,不是提交时校验用的真实可用量。
---
## 6. åˆä½œå•†å¾€æ¥æŽ¥å£
### 6.1 å¾€æ¥æ±‡æ€»ï¼ˆåˆ—表)
`GET /outsourcing/report/partnerTransactions`
**请求参数**
| å‚æ•° | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `partnerName` | string | åˆä½œå•†åç§°ï¼Œæ¨¡ç³ŠåŒ¹é… |
| `pageNum` / `pageSize` | number | åˆ†é¡µå‚æ•° |
**返回**:`data` ä¸º `IPage` ç»“构(字段是 `records`)。
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "partnerId": 1,
        "partnerName": "南通XX环保科技有限公司",
        "contractAmounts": 5000.00,
        "paidAmount": 2000.00,
        "receiptAmount": 0.00,
        "payableAmount": 3000.00
      }
    ],
    "total": 1
  }
}
```
### 6.2 å¾€æ¥æ˜Žç»†ï¼ˆé€è®¢å•)
`GET /outsourcing/report/partnerTransactionsDetails?partnerId={id}`
**返回**:`data` ä¸º `IPage` ç»“构(字段是 `records`)。
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "records": [
      {
        "outsourcingOrderId": 7,
        "orderNo": "WWDD20260920001",
        "partnerId": 1,
        "partnerName": "南通XX环保科技有限公司",
        "applyDate": "2026-09-20",
        "contractAmount": 5000.00,
        "status": 1,
        "paidAmount": 2000.00,
        "payableAmount": 3000.00
      }
    ],
    "total": 1
  }
}
```
> âš ï¸ æ¯è¡Œçš„ `paidAmount` æ˜¯**合作商级合计**(同一合作商所有明细行都一样),见 2.2。
### 6.3 æ˜Žç»†è¡Œã€Œæ–°å¢žä»˜æ¬¾ã€
明细**每一条**都可以加「新增付款」按钮,调用**现有的**往来流水接口(无需新增后端接口):
`POST /paymentRecord/add`,`Content-Type: application/json`
```json
{
  "partyType": 3,
  "partyId": 1,
  "direction": 1,
  "amount": 1000.00,
  "recordDate": "2026-09-20",
  "paymentMethod": "电汇",
  "remark": "第一笔委外汇款"
}
```
| å‚æ•° | å–值 |
| --- | --- |
| `partyType` | å›ºå®š `3`(合作商) |
| `partyId` | æ˜Žç»†è¡Œçš„ `partnerId`(**不是** `outsourcingOrderId`) |
| `direction` | `1` ä»˜æ¬¾ï¼ˆå‡å°‘应付) / `2` å›žæ¬¾ |
| `amount` | é‡‘额 |
**注意**:
- åŽç«¯æ–°å¢žäº† `partyType` ç™½åå•校验,只允许 `1` / `2` / `3`,传其它值返回「往来方类型不合法」。
- æ–°å¢žåŽ**合作商往来列表的 `paidAmount` ä¼šå¢žåŠ ã€`payableAmount` ä¼šå‡å°‘**;由于 `payment_record` æ²¡æœ‰è®¢å•维度,明细里**所有行**的 `paidAmount` éƒ½ä¼šå˜ã€‚
- âš ï¸ çŽ°æœ‰ `/paymentRecord/delete` æŽ¥å£**没有归属校验**,任何登录人都能删任意流水(含合作商付款)。这是既有风险,本次未改,前端如要做删除按钮请加二次确认。
- æŸ¥è¯¢/修改/删除流水沿用现有接口:`GET /paymentRecord/listPage`、`POST /paymentRecord/update`、`DELETE /paymentRecord/delete`。
---
## 7. å¸¸è§é”™è¯¯è¿”回
统一为 `{ "code": 500, "msg": "<错误信息>" }`,前端直接 toast `msg`。
| msg | è§¦å‘场景 |
| --- | --- |
| `请填写合作商名称` | æ–°å¢žåˆä½œå•†æ—¶ `partnerName` ä¸ºç©º |
| `合作商已存在` | åŒç§Ÿæˆ·ä¸‹ `partnerName` é‡å¤ï¼ˆæ–°å¢ž/修改) |
| `合作商id不能为空` | ä¿®æ”¹æœªä¼  `id` |
| `合作商不存在` | è¯¦æƒ…/修改传入的 id ä¸å­˜åœ¨ |
| `合作商不存在,请重新选择` | æ–°å¢ž/修改委外订单时 `partnerId` æŸ¥ä¸åˆ° |
| `合作商已存在委外订单,不允许删除` | åˆ é™¤åˆä½œå•†æ—¶å…¶åä¸‹è¿˜æœ‰å§”外订单 |
| `合作商已存在往来收付款记录,不允许删除` | åˆ é™¤åˆä½œå•†æ—¶å…¶åä¸‹è¿˜æœ‰ä»˜æ¬¾æµæ°´ |
| `请选择至少一条数据` | åˆ é™¤åˆä½œå•†æ—¶è¯·æ±‚体为空 |
| `委外订单不存在` | è¯¦æƒ…/编辑/状态流转传入的 id ä¸å­˜åœ¨ |
| `委外订单id不能为空` | ä¿®æ”¹/状态流转未传 id |
| `仅草稿状态的委外订单允许修改` | å¯¹éžè‰ç¨¿å•调用编辑 |
| `仅草稿状态的委外订单允许删除:<单号>` | å¯¹éžè‰ç¨¿å•调用删除,会回显具体单号 |
| `仅草稿状态的委外订单允许提交` | å¯¹éžè‰ç¨¿å•调用提交 |
| `请先选择有效合作商再提交` | æäº¤æ—¶ `partnerId` ä¸ºç©ºæˆ–查不到 |
| `委外订单没有产品明细,无法提交` | æäº¤æ—¶æ— æ˜Žç»†è¡Œ |
| `委外订单金额必须大于 0 æ‰èƒ½æäº¤` | æäº¤æ—¶é‡‘额为 0 |
| `该委外订单已提交,请勿重复操作` | å¹¶å‘/重复点击提交 |
| `报废品选料行缺少产品规格或批号,无法提交` | é€‰æ–™è¡Œçš„ `productModelId` æˆ– `batchNo` ä¸ºç©º |
| `报废品选料行数量必须大于 0` | é€‰æ–™è¡Œæ•°é‡ä¸º 0 æˆ–è´Ÿæ•° |
| `报废品「X」批号「Y」申请数量超过可用库存,当前可用库存为:Z` | æäº¤æ—¶è¯¥æ‰¹æ¬¡å¯ç”¨é‡ä¸å¤Ÿï¼ˆå®žæ—¶å£å¾„,含其它业务的待审批占用) |
| `仅已确认状态的委外订单允许完成` | å¯¹éžå·²ç¡®è®¤å•调用完成 |
| `报废品出库未审批通过,请先到出库管理审批出库批次:CKxxxx` | å®Œæˆæ—¶æŠ¥åºŸå“å‡ºåº“还没审批通过 |
| `报废品选料缺少出库记录,请先作废后重新提交` | å·²ç¡®è®¤å•的出库记录不全(数据异常) |
| `该委外订单已完成或已作废,不允许重复作废` | å¯¹ç»ˆæ€å•调用作废 |
| `库存记录中没有对应的产品,无法归还,出库批次:CKxxxx` | ä½œåºŸå½’还时库存行已不存在(数据异常) |
| `出库记录类型异常,无法归还库存,出库批次:CKxxxx` | ä½œåºŸå½’还时出库记录的 `type` æ—¢ä¸æ˜¯ `0` ä¹Ÿä¸æ˜¯ `1`(数据异常) |
| `往来方类型不合法` | `/paymentRecord/add`、`/update` çš„ `partyType` ä¸åœ¨ 1/2/3 å†… |
| `请选择要删除的委外订单` | åˆ é™¤æŽ¥å£è¯·æ±‚体为空数组 |
---
## 8. è”调检查清单
**环境前提**
1. ä¸‰ä¸ª SQL è„šæœ¬å·²æ‰§è¡Œï¼›`payment_record` çš„ `party_type` æ³¨é‡Šå·²æ”¹ä¸ºä¸‰å€¼ï¼›**并已执行 1.1 èŠ‚çš„ `outsourcing_order_product` åˆ—改名 ALTER**。库存联动本身复用现有 `stock_out_record` / `stock_inventory`,不需要新增表。
2. å·²å‡†å¤‡æŠ¥åºŸå“åŸºç¡€æ•°æ®ï¼š`product` åœ¨ã€Œå±åºŸå¤„置次生危废」下有一个产品,`product_model` æœ‰å…¶è§„格;**`stock_inventory`(合格库存)有对应 `product_model_id` çš„æ‰¹æ¬¡**。
3. âš ï¸ **数据权限提醒**:`outsourcing_partner`、`outsourcing_order` æ˜¯åˆ—表查询的顶层主表,**会受数据权限过滤**(`create_user` / `dept_id`),和 `supplier_manage`、`purchase_ledger` çŽ°çŠ¶ä¸€è‡´ã€‚
   â†’ è”调用 `dataScope=1` çš„账号或 admin éªŒè¯ï¼Œå¦åˆ™å®¹æ˜“误判「功能坏了」(表现为:别人建的数据自己看不到)。
   â†’ åˆä½œå•†å¾€æ¥åˆ—表与明细**两侧口径已对齐**(不施加行级数据权限),不会出现「列表有应付、点进明细 0 è¡Œã€ã€‚
4. âš ï¸ **出库审批的账号要求**:`stock_out_record` ä»åœ¨æ•°æ®æƒé™ç®¡æŽ§å†…,**出库管理页的列表和审批必须用与提交人同数据范围的账号,或 `dataScope=1`/admin**,否则报「出库记录不存在」。详见 2.4。
**功能检查**
5. èœå•出现「委外管理 â†’ åˆä½œå•†æ¡£æ¡ˆ / å§”外订单 / åˆä½œå•†å¾€æ¥ã€ï¼Œç‚¹å‡»æ—  404。
6. `/outsourcing/partner/add` åªä¼  `partnerName`:能新增成功,返回 id,`/listPage` èƒ½çœ‹åˆ°ã€‚
7. åˆä½œå•†é‡åæ–°å¢žï¼šè¿”回「合作商已存在」。
8. é™„件:上传文件 â†’ æŠŠè¿”回的 blob `id` æ”¾è¿› `storageBlobDTOS` æäº¤ â†’ `/outsourcing/partner/{id}` çš„ `storageBlobVOS` èƒ½å›žæ˜¾ï¼›å†ç¼–辑提交空数组 â†’ é™„件被清空(符合 4.4 çš„全量覆盖语义)。
   æ•°æ®åº“核对:`select a.*, b.original_filename from storage_attachment a join storage_blob b on a.storage_blob_id = b.id where a.record_type = 'outsourcing_partner' and a.record_id = ?`
9. åˆä½œå•†å¯¼å‡ºã€ä¸‹è½½æ¨¡æ¿ã€å¯¼å…¥ä¸‰ä¸ªæŽ¥å£èƒ½æ­£å¸¸èµ°é€šã€‚
10. `/outsourcing/partner/getOptions` è¿”回 `id` + `partnerName`。
11. åˆ é™¤æœ‰è®¢å•的合作商:返回「合作商已存在委外订单,不允许删除」。
12. `scrapStockPage?pageNum=1&pageSize=10`:**只返回「危废处置次生危废」大类下的批次**,带 `batchNo`、`unLockedQuantity`。核对:
    `select si.id, si.product_model_id, si.batch_no, si.qualitity from stock_inventory si join product_model pm on si.product_model_id = pm.id` â€”— è¿”回的行数应等于「该大类及其子节点下的规格」对应的库存行数,比全表少(开发库 10 è¡Œåº”只剩 1 è¡Œï¼‰ã€‚
    **回归**:`/stockInventory/pagestockInventory` è¿”回值与改动前一致(不带 `batchNo`);`/stockUninventory/pagestockUninventory`(不合格库存)完全不受影响。
13. `/outsourcing/order/add` å»ºè‰ç¨¿ï¼ˆå«æŠ¥åºŸå“é€‰æ–™è¡Œï¼‰ï¼šæˆåŠŸï¼Œ`orderNo` å½¢å¦‚ `WWDD20260920001`,`contractAmount` = æ˜Žç»†å«ç¨Žæ€»ä»·åˆè®¡ã€‚
14. è‰ç¨¿å• `/edit`:明细全量替换生效(改数量、增删行都正确)。
15. è‰ç¨¿å• `/submit`:状态变 `1`,`confirmTime` æœ‰å€¼ï¼›**再点一次**返回「该委外订单已提交,请勿重复操作」。
16. éžè‰ç¨¿å• `/edit`、`/delete`:被拒绝并给出对应提示。
17. `/outsourcing/report/partnerTransactions`:`contractAmounts` = è¯¥åˆä½œå•† `status in (1,2)` è®¢å•金额合计。
    **重点**:建一张草稿、一张作废单,确认它们的金额**没有**被算进 `contractAmounts`。
18. `/paymentRecord/add` æ’两笔 `{partyType:3, partyId, direction:1, amount:1000}`:往来列表 `paidAmount` å˜ 2000、`payableAmount` ç›¸åº”减少。
19. `/paymentRecord/add` ä¼  `partyType=9`:返回「往来方类型不合法」。
20. `/outsourcing/report/partnerTransactionsDetails?partnerId=xxx`:明细行数与订单数一致,`paidAmount` æ¯è¡Œç›¸åŒï¼ˆç¬¦åˆ 2.2 å£å¾„)。
**报废品库存联动(重点)**
21. **建草稿不占库存**:建一张带报废品选料行的草稿,比对 `stock_inventory` çš„ `qualitity` **和** `stock_out_record` è®°å½•æ•° â€”— éƒ½ä¸åº”有变化。
22. **提交即占用**:`/submit` åŽåº”出现一条 `record_type='30' and type='0' and approval_status=0` çš„出库记录,`record_id` = è¯¥é€‰æ–™æ˜Žç»†è¡Œ id,`stock_out_num` = æ˜Žç»†æ•°é‡ï¼Œ`batch_no` = é€‰ä¸­çš„æ‰¹å·ã€‚
    æ­¤æ—¶ `qualitity` **仍未变**,但 `scrapStockPage` è¿”回的可用量(`unLockedQuantity`)**不包含**待审批出库量,请改用以下 SQL æ ¸å¯¹çœŸå®žå¯ç”¨é‡ï¼š
    `select si.qualitity - ifnull(si.locked_quantity,0) - (select ifnull(sum(sor.stock_out_num),0) from stock_out_record sor where sor.product_model_id=si.product_model_id and sor.batch_no<=>si.batch_no and sor.type='0' and sor.approval_status=0) as available from stock_inventory si where si.id=?`
23. **防超选**:对同一批次再建一张单、数量填「超过真实可用量」,提交应返回「报废品「X」批号「Y」申请数量超过可用库存,当前可用库存为:Z」。
24. **完成前必须出库审批**:对未审批出库的已确认单调 `/complete`,返回「报废品出库未审批通过,请先到出库管理审批出库批次:CKxxxx」。
25. **出库审批才实扣 + è‡ªåŠ¨å®Œæˆ**:到出库管理页按类型「委外-报废品出库」找到该记录 â†’ `POST /stockOutRecord/approve` `{"ids":[id],"approvalStatus":1}` â†’ æ­¤æ—¶ `stock_inventory.qualitity` **才减少**(减少量 = å‡ºåº“数量),且**该委外订单状态应已自动变成 `2`「已完成」**(查 `select id, status from outsourcing_order where id=?`)。
26. **手工完成仍可用(兜底)**:订单已是 `2` æ—¶å†è°ƒ `/complete`,返回「仅已确认状态的委外订单允许完成」。
27. **多行订单要等最后一条**:一张订单挂 2 æ¡æŠ¥åºŸå“é€‰æ–™è¡Œï¼Œå‡ºåº“记录 2 æ¡ï¼Œå…ˆå®¡æ‰¹ 1 æ¡ â†’ è®¢å•仍是 `1`;审批第 2 æ¡ â†’ è®¢å•变 `2`。
28. **驳回不回退**:另一张已确认单的出库记录审批驳回(`approvalStatus=2`)→ å§”外订单仍为 `1`,此时 `/complete` ä¼šè¢«æ‹’(出库未全部通过)。
29. **作废释放(未审批)**:另一张已确认单(出库仍待审批)调 `/cancel` â†’ å‡ºåº“记录被删除、`qualitity` ä¸å˜ã€å ç”¨é‡Šæ”¾ï¼ˆçœŸå®žå¯ç”¨é‡æ¢å¤ï¼‰ã€‚
30. **作废归还(部分已审批)**:订单挂 2 æ¡é€‰æ–™è¡Œï¼Œå‡ºåº“记录审批通过其中 1 æ¡ï¼ˆæ­¤æ—¶è®¢å•还是 `1`,因为未全部通过)→ è°ƒ `/cancel` â†’ å·²é€šè¿‡çš„那条**按批次归还 `qualitity`**,出库记录被删除。
31. **已自动完成的单不能再作废**:出库**全部**通过后订单已是 `2`,`/cancel` è¿”回「该委外订单已完成或已作废,不允许重复作废」。要撤销只能先到出库管理「反审」并重新处理 â€”— **订单状态不会自动回退到「已确认」**。这是自动完成带来的行为变化,前端不要给状态 `2` çš„单显示「作废」按钮。
32. **回归**:`locked_quantity` å…¨ç¨‹ä¸å˜ï¼›`/stockInventory/pagestockInventory`、`/stockUninventory/pagestockUninventory` è¿”回值与改动前一致;不合格库存 `stock_uninventory` å…¨ç¨‹ä¸å—委外影响;**其它业务(销售发货、采购退货、生产领料)的出库审批不受影响**(自动完成只在 `record_type=30` æ—¶è§¦å‘)。
29. **回归**:`locked_quantity` å…¨ç¨‹ä¸å˜ï¼›`/stockInventory/pagestockInventory`、`/stockUninventory/pagestockUninventory` è¿”回值与改动前一致;不合格库存 `stock_uninventory` å…¨ç¨‹ä¸å—委外影响。
---
## 9. å‰ç«¯æ”¹é€ å»ºè®®
1. æ–°å»ºä¸‰ä¸ªé¡µé¢ç»„件(路径见第 1 èŠ‚è¡¨æ ¼ï¼‰ï¼Œåˆä½œå•†æ¡£æ¡ˆé¡µé¢å¯ç›´æŽ¥å‚ç…§ã€Œä¾›åº”å•†æ¡£æ¡ˆã€é¡µé¢ç»“æž„ï¼Œ**多一个附件上传区域**。
2. å§”外订单新增/编辑表单建议参照「采购订单」页面,明细行增加「选报废品」按钮,弹窗调用 `scrapStockPage`,选中后把 `stockInventoryId`、`batchNo`、`availableQuantity`、`productModelId`、`unit` å›žå¡«åˆ°æ˜Žç»†è¡Œã€‚
3. æ³¨æ„è¿”回结构不统一:合作商 `listPage` / é€‰æ–™ / å¾€æ¥ä¸‰ä¸ªæŽ¥å£æ˜¯ `data.records`,委外订单 `list` æ˜¯é¡¶å±‚ `rows`(RuoYi `TableDataInfo`)。
4. çŠ¶æ€åˆ—ç”¨ tag åŒºåˆ†é¢œè‰²ï¼Œ`0` è‰ç¨¿ï¼ˆç°ï¼‰/ `1` å·²ç¡®è®¤ï¼ˆè“ï¼‰/ `2` å·²å®Œæˆï¼ˆç»¿ï¼‰/ `3` å·²ä½œåºŸï¼ˆçº¢ï¼‰ã€‚
5. æŒ‰é’®æ˜¾éšæŒ‰ 2.1 çš„状态表控制,`submit` / `complete` / `cancel` å»ºè®®åŠ äºŒæ¬¡ç¡®è®¤ã€‚
   âš ï¸ **出库审批通过会自动把订单推到「已完成」**,所以审批完刷新列表时状态可能已经变了:
   - ã€Œå®Œæˆã€æŒ‰é’®ï¼šåªåœ¨çŠ¶æ€ `1` æ—¶æ˜¾ç¤ºï¼ˆä½œä¸ºå…œåº•),状态 `2` æ—¶éšè—ã€‚
   - ã€Œä½œåºŸã€æŒ‰é’®ï¼šçŠ¶æ€ `2` æ—¶ä¸èƒ½æ˜¾ç¤ºï¼ˆåŽç«¯ä¼šæ‹’绝)。
6. åˆä½œå•†å¾€æ¥çš„æ˜Žç»†å¼¹çª—里,每行放「新增付款」按钮,弹表单提交到 `/paymentRecord/add`,`partyType` å›ºå®š `3`、`partyId` å–列表行的 `partnerId`(**不是**订单 id);提交成功后刷新往来汇总和明细。
7. ç¼–辑合作商时,务必把详情接口返回的 `storageBlobVOS` è½¬æˆ `{ id }` å­˜è¿› `storageBlobDTOS` å†æäº¤ï¼Œå¦åˆ™ä¼šæ¸…空附件。
8. **出库管理页的类型映射**:`record_type=30`「委外-报废品出库」已加进后端合格出库枚举,`/basic/enum/stockRecordType` ä¼šå¸¦å‡ºæ¥ã€‚若出库管理页的类型列/下拉是**前端硬编码映射表**,需要补 `30 â†’ å§”外-报废品出库`;否则委外产生的出库记录会显示成空或错值。
9. å§”外订单的「状态」与「出库是否已审批」是两件事:已确认的单点「完成」可能因为出库没审批而被拒。建议在订单详情或列表上把关联出库记录的状态一并展示(`GET /stockOutRecord/listPage?recordType=30` å¯æŒ‰ç±»åž‹è¿‡æ»¤ï¼‰ï¼Œå¦åˆ™ç”¨æˆ·ä¼šä»¥ä¸ºæ˜¯ç³»ç»ŸæŠ¥é”™ã€‚
10. æäº¤å§”外订单前建议先提示「提交后将占用所选报废品批次的可用量,需到出库管理审批通过后才能完成订单」,避免用户不理解为什么提交后库存可用量变了。
doc/20260920_ÓÍÆ·³ö¿âÁª¶¯·¢»ǫ̃ÕËǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,159 @@
# æ²¹å“å‡ºåº“联动发货台账 å‰ç«¯è”调文档
更新时间:2026-09-20
适用版本:本次后端变更后
需求来源:联调反馈「出库了一条(出库类别=销售),但 `/shippingInfo/listPage` å‘货台账没有数据」
---
## 1. èƒŒæ™¯ï¼šä¸ºä»€ä¹ˆä¹‹å‰æ˜¯ç©ºçš„
**不是接口坏了,是「出库台账」和「发货台账」本来是两套东西。**
- å‘货台账(菜单 2230)读的是 `shipping_info` è¡¨ï¼Œè¯¥è¡¨åœ¨æ”¹å‰æ˜¯ **0 è¡Œ**。
- `shipping_info` åªæœ‰ä¸¤ä¸ªå†™å…¥æ–¹ï¼š`POST /shippingInfo/add`(走发货审批)、以及 `POST /stockmanagement/stockout` ä¸” `type=2`(销售出库)。
- æ²¹å“å‡ºåº“表单走的是 `POST /stockInventory/addOilOutRecordOnly`,它的语义是**只落一条 `stock_out_record`,不动库存、不建发货单**——所以发货台账永远看不到它。
经与需求方确认,采用两个口径,本次已实现:
| å£å¾„ | å®žçް |
| --- | --- |
| å‘货台账**直接读** `stock_out_record` | `/shippingInfo/listPage` æ”¹ä¸º `shipping_info UNION ALL stock_out_record` |
| é”€å”®ç±»å‡ºåº“**要扣库存** | å‡ºåº“审批通过时按「产品规格 + æ‰¹æ¬¡ã€æ‰£å‡ `stock_inventory` |
**本次没有任何 DDL**:用到的 `stock_out_record.batch_no`、`out_category`、`tank_no`、`truck_plate_no` ç­‰åˆ—都已存在。
---
## 2. å‘货台账列表契约(**必看 `source`**)
`GET /shippingInfo/listPage` è¿”回 `R.data.records`,**现在包含两类来源的行**,靠新增字段 `source` åŒºåˆ†ï¼š
| `source` | æ¥æºè¡¨ | è¯´æ˜Ž |
| --- | --- | --- |
| `shipping` | `shipping_info` | åŽŸæœ‰çš„å‘è´§å•ï¼ˆé”€å”®å°è´¦å‘è´§ / é‡‡è´­è®°å½•出库自动生成) |
| `oilOut` | `stock_out_record` | æœ¬æ¬¡æ–°å¢žï¼š`out_category='销售'` ä¸” `approval_status=1` çš„æ²¹å“å‡ºåº“ |
### 2.1 âš ï¸ `id` ä¼šæ’žå·ï¼Œç»ä¸èƒ½æŒ‰ `id` ç›´æŽ¥æ“ä½œ
两类表的 `id` æ˜¯**各自的序列,会撞号**。「发货 / åˆ é™¤ / è¯¦æƒ… / é€€è´§ã€è¿™äº›æŽ¥å£èƒŒåŽéƒ½æ˜¯ `shipping_info`:
- `POST /shippingInfo/deductStock`
- `DELETE /shippingInfo/delete`
- `POST /shippingInfo/update`
- é€€è´§ç›¸å…³æŽ¥å£
**对 `source='oilOut'` çš„行调这些接口会操作到另一条完全无关的发货单。** å‰ç«¯å¿…须按 `source` åˆ†æ”¯ï¼š
- `source='shipping'` â†’ æŒ‰é’®ç…§æ—§
- `source='oilOut'` â†’ **隐藏**「发货 / åˆ é™¤ / é€€è´§ã€æŒ‰é’®ï¼›è¦æ“ä½œè¯·è·³ã€Œå‡ºåº“台账」页,用新增字段 `stockOutRecordId` å®šä½
### 2.2 æ²¹å“å‡ºåº“行的字段映射
| å‘货台账列 | `oilOut` è¡Œå–值 |
| --- | --- |
| `stockOutRecordId` | `stock_out_record.id`(**用这个,不要用 `id`**) |
| `shippingNo` | `outbound_batches`(即 CK å‡ºåº“单号) |
| `status` | å›ºå®š `已发货` |
| `shippingDate` | `out_date` |
| `shippingCarNumber` | `truck_plate_no` |
| `customerName` | `customer_name` |
| `specificationModel` / `unit` / `productName` | ç”± `product_model_id` å…³è” `product_model` â†’ `product` |
| `totalQuantity` | `stock_out_num` |
| `outboundBatches` | `outbound_batches`(CK å‡ºåº“单号,**不是批号**) |
| `outBatchNo` | `batch_no`(批号;`source='shipping'` è¡Œæ’为 `null`) |
| `batchNoList` | ç”±æœ¬è¡Œå­—段拼成的单元素数组,供详情弹框「批号」明细表用(仅 `oilOut` è¡Œï¼‰ |
| `outCategory` / `tankNo` / `oilProduct` / `oilDepotName` / `driverName` / `contactPerson` / `contactPhone` | åŒåå‡ºåº“侧列(新增出参,可用作展示) |
| `salesContractNo` / `expressNumber` / `expressCompany` / `salesLedgerId` | **恒为空**(油品出库没有合同号/快递单) |
### 2.2.1 æ‰¹å·æ€Žä¹ˆæ˜¾ç¤ºï¼ˆè¯¦æƒ…弹框)
油品出库的**批号**(形如 `20260917-CY001-001`)此前后端一个字段都没吐,本次补上了:
- æ–°å¢žå‡ºå‚ `outBatchNo` = `stock_out_record.batch_no`。**别和 `outboundBatches` æžæ··**,后者是 CK å‡ºåº“单号。
- æ–°å¢žå‡ºå‚ `batchNoList`,**仅 `source='oilOut'` è¡Œæœ‰å€¼**(一条出库对应一个批号),结构:
```json
"batchNoList": [
  {
    "batchNo": "20260917-CY001-001",
    "productName": "柴油",
    "specificationModel": "02#柴油",
    "deliveryQuantity": 9.0000
  }
]
```
前端 `deliveryLedger/index.vue` çš„详情弹框 `getDeliveryProductInfoList()` æœ¬æ¥å°±ä¼šè¯» `row.batchNoList`(它是兜底链 `batchNoDetailList â†’ batchNoList â†’ shippingBatchList â†’ â€¦` çš„第二项),**所以该页不改代码也能显示出批号**。
> âš ï¸ **详情弹框另有一个必须修的坑**:`openDetail()` ä¼šç”¨ `getDeliveryDetail(row.id)` å†è°ƒä¸€æ¬¡ `/shippingInfo/getDateil/{id}`,而该接口查的是 `shipping_info` + `shipping_product_detail`。对 `source='oilOut'` çš„行传进去的是**出库记录 id**,一旦撞上同号的 `shipping_info`,就会**把另一条发货单的明细显示出来**——因为 `detailProductList` çš„优先级高于 `row.batchNoList`。
> **建议**:`openDetail` é‡ŒæŒ‰ `source` åˆ†æ”¯ï¼Œ`oilOut` è¡Œ**不要**再调 `getDateil`,直接用 `row.batchNoList`。
另外,**主列表当前没有「批号」列**(现有列:销售订单 / å‘货订单号 / é¡¹ç›®åç§° / å®¢æˆ·åç§° / äº§å“åç§° / è§„格型号 / å‘è´§æ—¶é—´ / å‘货数量 / å‘货车牌号 / å¿«é€’公司 / å¿«é€’单号 / å®¡æ ¸çŠ¶æ€ / å‡ºåº“单号 / é€€è´§å• / æ“ä½œï¼‰ã€‚若要在主列表也展示,加一列绑定 `outBatchNo` å³å¯ã€‚
### 2.3 ç­›é€‰è¡Œä¸º
| æŸ¥è¯¢å‚æ•° | `shipping` è¡Œ | `oilOut` è¡Œ |
| --- | --- | --- |
| `shippingNo` | `shipping_info.shipping_no` | `stock_out_record.outbound_batches` |
| `shippingCarNumber` | `shipping_info.shipping_car_number` | `stock_out_record.truck_plate_no` |
| `salesContractNo` | `sales_ledger.sales_contract_no` | **恒不匹配**(按合同号筛选时不会返回油品行,这是刻意的) |
| `expressNumber` | `shipping_info.express_number` | **恒不匹配** |
---
## 3. æ²¹å“å‡ºåº“表单:**「出库批次」必填**
新增校验:`out_category='销售'` æ—¶ `batchNo` ä¸èƒ½ä¸ºç©ºï¼Œå¦åˆ™æ–°å¢žæŽ¥å£ç›´æŽ¥è¿”回错误 **「销售类出库必须选择出库批次」**。
> æ³¨æ„ï¼šè¿™ä¸ªæ ¡éªŒåœ¨**新增时**就生效,不是等审批。前端请把「出库批次」在出库类别=销售时做成必填,避免用户提交后才发现。
>
> å‡ºåº“类别是「代储」「客存」时**不需要**批次,也不扣库存。
批次候选建议与该规格的入库批次一致:`GET /stockInventory/getBatchNoQty?productModelId=xxx`。
---
## 4. æ‰£åº“存的时机
| åŠ¨ä½œ | `stock_inventory` å˜åЍ |
| --- | --- |
| æ–°å¢žé”€å”®ç±»æ²¹å“å‡ºåº“ | ä¸å˜ï¼ˆåªåœ¨å¾…审批) |
| `POST /stockOutRecord/approve` å®¡æ‰¹**通过** | æŒ‰ã€Œ`product_model_id` + `batch_no`」扣减 `stock_out_num` |
| å®¡æ‰¹**驳回** | ä¸å˜ï¼ˆæœ¬æ¥å°±æ²¡æ‰£ï¼‰ |
| åˆ é™¤**已通过**的销售类出库记录 | æŒ‰åŒæ‰¹æ¬¡**归还** |
| åˆ é™¤**待审批**的销售类出库记录 | ä¸å˜ |
失败情形:库存不足时审批会报 **「库存不足无法出库,出库批次:CK…」**,且整条审批事务回滚(`approval_status` ä¸ä¼šå˜æˆ 1)。
出库数量必须 > 0,否则审批报「销售类出库数量必须大于0」。
---
## 5. å·²çŸ¥å½±å“ï¼ˆè”调前需知情)
1. **历史数据不会补扣**。改前那条 `CK20260920001`(`batch_no` ä¸ºç©ºã€å·²å®¡æ‰¹é€šè¿‡ï¼‰ä»Žæœªæ‰£è¿‡åº“存,现在也不会补扣。它会出现在发货台账(显示「已发货」),但**账实不符**。要么让业务补录批次后重开一条,要么接受这条历史数据不扣。
2. **不要再给销售类油品出库手工建发货单**。否则发货台账会同时出现 `shipping_info` è¡Œå’Œ `stock_out_record` è¡Œï¼ŒåŒä¸€æ‰¹è´§æ˜¾ç¤ºä¸¤æ¬¡ã€‚
3. **发货台账的「删除」「退货」对 `oilOut` è¡Œä¸å¯ç”¨**。退货流程(`ReturnManagementServiceImpl`)依赖 `shipping_info` + `record_type='13'`,油品出库不满足。销售类出库的退货请走「出库台账」删除(会归还库存)。
4. **出库类别为「代储」「客存」的出库**既不扣库存、也不进发货台账。
5. **审批通过后再用 `PUT /stockOutRecord/{id}` æ”¹æ•°é‡/批次,不会同步调整库存**(既有实现未覆盖,本次未改)。要改数量请先删除(会归还)再重开。
6. **扣减只校验 `qualitity >= å‡ºåº“数量`,未考虑 `locked_quantity`(冻结量)**。这与现有「合格出库」分支的行为一致;若业务要求冻结量不可动用,需另行确认。
---
## 6. è”调自查清单
- [ ] å‘货台账列表出现 `CK20260920001` é‚£ä¸€è¡Œï¼Œ`source='oilOut'`,客户名/规格/储罐号/对接人都有值
- [ ] `CK20260920002` è¡Œï¼ˆ`batch_no=20260917-CY001-001`)的 `outBatchNo` = `20260917-CY001-001`,且 `batchNoList` æœ‰ä¸”仅有 1 ä¸ªå…ƒç´ 
- [ ] æ‰“开该行详情弹框,「批号」明细表显示 `20260917-CY001-001` / æŸ´æ²¹ / 02#柴油 / 9(**不是 `--`**)
- [ ] æ‰“开该行详情弹框时**没有**发 `/shippingInfo/getDateil/{出库记录id}` è¯·æ±‚(否则可能显示别的发货单明细)
- [ ] è¯¥è¡Œã€Œå‘è´§ / åˆ é™¤ / é€€è´§ã€æŒ‰é’®**不出现**(或点了走的是出库台账,不是 `shipping_info`)
- [ ] åˆ†é¡µ `total` ä¸Žå®žé™…行数一致(本次改了 UNION,重点回归项)
- [ ] æŒ‰ã€Œå‘货单号」筛选:`shipping` å’Œ `oilOut` ä¸¤ç±»è¡Œéƒ½èƒ½è¢«ç­›åˆ°
- [ ] æŒ‰ã€Œé”€å”®åˆåŒå·ã€ç­›é€‰ï¼šåªå‰© `shipping` è¡Œï¼Œæ²¹å“è¡Œä¸å‡ºçް
- [ ] æ²¹å“å‡ºåº“表单:出库类别=销售时不填批次,提交报「销售类出库必须选择出库批次」
- [ ] æ²¹å“å‡ºåº“表单:出库类别=代储/客存时不填批次能正常提交
- [ ] æ–°å»ºé”€å”®ç±»å‡ºåº“(批次 `CG20260815005-187`、数量 10)→ å®¡æ‰¹é€šè¿‡ â†’ `stock_inventory.qualitity` ç”± 1000 å˜ 990
- [ ] å†åˆ é™¤è¯¥å‡ºåº“记录 â†’ åº“存回到 1000
- [ ] æ•°é‡å¡« 99999 â†’ å®¡æ‰¹æŠ¥ã€Œåº“存不足无法出库」,且该记录 `approval_status` ä»ä¸º 0
- [ ] åŽŸæœ‰å‘è´§å•ï¼ˆ`source='shipping'`)的发货/删除功能不受影响
doc/20260920_³µÁ¾¹ÜÀíÓë²ÐÓÍÁª¶¯Ç°¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,304 @@
# è½¦è¾†ç®¡ç†ä¸Žå‡ºåº“残油联动 å‰ç«¯è”调文档
更新时间:2026-09-20
适用版本:本次后端变更后
对应需求:需求清单第 7 æ¡ã€Œå‘货:给车辆做唯一标识,做出库编码,加一个车辆管理页面,记录每次最后送完货后剩余的油量,下次发货还是要继续使用,下次发货为上次剩余量+库存量」
**一句话口径**:出库数量 = **本次装车总量**(车上剩油 + æ–°è£…油);真正从库存里扣减的只有 **出库数量 âˆ’ ä¸Šæ¬¡å‰©ä½™æ²¹é‡**。
---
## 1. å˜æ›´èŒƒå›´
| å†…容 | å½±å“é¢ |
| --- | --- |
| æ–°å»ºè½¦è¾†ä¸»æ•°æ®æ¨¡å— `/basic/vehicle` | è½¦è¾†ç®¡ç†é¡µé¢ï¼ˆå‰ç«¯æ–°å»ºï¼ŒåŽç«¯æŽ¥å£å·²å°±ç»ªï¼‰ |
| å‡ºåº“表单「车辆」由手填车牌号改为下拉 | æ²¹å“å‡ºåº“新增表单 |
| æ–°å¢žå¯ç”¨é‡æŸ¥è¯¢æŽ¥å£ | æ²¹å“å‡ºåº“表单的「上次剩余 / å½“前库存 / å¯ç”¨é‡ã€æç¤º |
| æ–°å¢žæ®‹æ²¹å½•入接口 | å‡ºåº“台账行操作(送完货后回填剩余油量) |
| å‡ºåº“台账列表新增 3 ä¸ªå­—段 | å‡ºåº“管理列表、导出 Excel |
> **本仓库没有「油品出库」表单页**:20 ä¸ªå‰ç«¯åˆ†æ”¯å…¨æŸ¥è¿‡ï¼Œ`油品出库`、`客存`、`代储`、`outTonnage`、`addOilOutRecordOnly` å‡é›¶å‘½ä¸­ã€‚所以本次**只交付后端能力**,下拉、提示、残油录入入口需要前端接。
>
> å‰ç«¯å¦æœ‰ä¸€ä¸ªæ‚¬ç©ºçš„ `inventoryManagement/vehicleManagement/index.vue`(绑 `/vehicle/info/*`,车辆信息+年检+保险+费用+加油卡),那**是别的租户遗留的模块**,后端没有对应接口,与残油毫无关系,**不要复用**。
---
## 2. å‰ç½®ï¼šå…ˆå»ºè¡¨ã€å†å½•车辆
### 2.1 DDL å¿…须先执行
文件:`doc/20260920_vehicle_and_residual_oil.sql`,纯新增、不改动任何既有列:
- æ–°å»º `vehicle` è¡¨ï¼ˆå«å”¯ä¸€ç´¢å¼• `uk_plate_number`)
- `stock_out_record` åŠ  3 åˆ—:`vehicle_id`、`residual_oil`、`deduct_quantity`
**该 DDL å·²äºŽ 2026-09-20 åœ¨å¼€å‘库 `product-inventory-management-rbhb` æ‰§è¡Œå®Œæ¯•**(`vehicle` è¡¨ 13 åˆ— + `plate_number` å”¯ä¸€ç´¢å¼•、`stock_out_record` ä¸‰åˆ—均已核对)。其他环境上线时按同一个文件执行即可。
文件第 3 æ®µçš„菜单 SQL(`车辆信息`,`parent_id=2000`,`component=basicData/vehicle/index`)**已于 2026-09-20 åœ¨å¼€å‘库执行**(`menu_id=2377`,`order_num=4`,并已授权给「技术质量」角色 `role_id=2`,与同级的油库信息/储罐信息一致)。
> âš ï¸ **菜单已就位,但前端页面还没建**:融邦前端分支 `dev_新疆_融邦环保` ä¸‹æš‚æ—¶**没有** `src/views/basicData/vehicle/index.vue`,也没有 `src/api/basicData/vehicle.js`。所以现在**菜单里能看到「车辆信息」,点进去是 404**——这是预期的占位状态,页面按 `views/basicData/vehicle/index.vue` å»ºå¥½åŽè‡ªåŠ¨å¯ç”¨ï¼Œ**不需要再改菜单**。
>
> é¡µé¢è¦è°ƒçš„后端接口见本文档第 2.2 èŠ‚ï¼ˆ`/basic/vehicle/*`)。同级的油库信息 `basicData/oilDepot/index`、储罐信息都在 `parent_id=2000` ä¸‹ï¼Œç…§å®ƒä»¬çš„形态建即可。
> **DDL æœªæ‰§è¡Œå‰**:出库记录的新增/修改、车辆管理接口都会直接报错(`Unknown column` / `Table doesn't exist`)。
### 2.2 è½¦è¾†ç®¡ç†æŽ¥å£ï¼ˆå‰ç¼€ `/basic/vehicle`,照 `oilDepot` å½¢æ€ï¼‰
| æŽ¥å£ | æ–¹æ³• | è¯´æ˜Ž |
| --- | --- | --- |
| `/basic/vehicle/list` | GET | åˆ†é¡µæŸ¥è¯¢ï¼Œè¿”回 `R.data.records`;支持 `plateNumber`、`driverName` æ¨¡ç³ŠæŸ¥ã€`status` ç²¾ç¡®æŸ¥ |
| `/basic/vehicle/all` | GET | **供出库表单下拉**,只返回未停用的车辆,按 id å‡åº |
| `/basic/vehicle/{id}` | GET | è¯¦æƒ… |
| `/basic/vehicle/add` | POST | æ–°å¢žï¼Œè½¦ç‰Œå·å¿…填且唯一 |
| `/basic/vehicle/update` | POST | ä¿®æ”¹ï¼Œå¿…须带 `id` |
| `/basic/vehicle/del` | DELETE | æ‰¹é‡åˆ é™¤ï¼Œbody ä¸º id æ•°ç»„ |
车辆字段:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
| --- | --- | --- |
| `id` | Long | ä¸»é”® |
| `plateNumber` | String | **车牌号,车辆唯一标识**,必填、不可重复 |
| `driverName` | String | é»˜è®¤å¸æœºï¼ˆå¯ç©ºï¼Œå‡ºåº“时可覆盖) |
| `driverPhone` | String | å¸æœºç”µè¯ |
| `loadCapacity` | BigDecimal | æ ¸å®šè½½é‡(吨) |
| `status` | String | `启用` / `停用`,默认 `启用`;**停用后不再出现在下拉里,历史记录不受影响** |
| `remark` | String | å¤‡æ³¨ |
> è½¦è¾†æ˜¯å…¬å¸çº§ä¸»æ•°æ®ï¼š`vehicle` å·²åŠ å…¥ `IgnoreTableConfig`,**不做部门/创建人过滤**,出库表单的下拉能看到全部车辆。
> å»ºè®®ç”¨ã€Œåœç”¨ã€è€Œä¸æ˜¯ã€Œåˆ é™¤ã€æ¥ä¸‹çº¿ä¸€è¾†è½¦â€”—删除不会校验是否已被出库记录引用。
请求 / å“åº”示例(**注意这个模块返回的是 `R`,与出库模块的 `AjaxResult` ç»“构一样都是 `{code, msg, data}`**):
```json
POST /basic/vehicle/add
{
  "plateNumber": "苏A12345",
  "driverName": "张三",
  "driverPhone": "13800000000",
  "loadCapacity": 30,
  "status": "启用",
  "remark": ""
}
```
```json
// æˆåŠŸ
{ "code": 200, "msg": "操作成功", "data": true }
// è½¦ç‰Œå·é‡å¤
{ "code": 500, "msg": "车牌号已存在:苏A12345", "data": null }
// è½¦ç‰Œå·ä¸ºç©º
{ "code": 500, "msg": "车牌号不能为空", "data": null }
```
```json
GET /basic/vehicle/all
{ "code": 200, "msg": "操作成功",
  "data": [ { "id": 12, "plateNumber": "苏A12345", "driverName": "张三", "status": "启用" } ] }
```
```json
DELETE /basic/vehicle/del
[12, 13]
```
`GET /basic/vehicle/list` çš„分页结果在 `data.records`(MyBatis-Plus çš„ `IPage`),前端按现有基础数据页面同样的方式取即可。
### 2.3 âš ï¸ è½¦è¾†å¿…须先登记
销售类出库会按车牌号/车辆id å®šä½ã€Œä¸Šæ¬¡å‰©ä½™æ²¹é‡ã€ã€‚**没在车辆管理里登记过的车牌号,新增出库时会直接报错**:
```
车牌号 è‹A12345 ä¸åœ¨è½¦è¾†ç®¡ç†ä¸­ï¼Œè¯·å…ˆåˆ°è½¦è¾†ç®¡ç†æ·»åŠ 
```
所以上线顺序是:**执行 DDL â†’ å½•入车辆 â†’ æ‰èƒ½èµ°å‡ºåº“**。
---
## 3. æ–°å¢žå‡ºåº“:先搞清走哪个接口(**必看**)
油品出库表单要调的是 `POST /stockInventory/addOilOutRecordOnly`。它在进业务校验前会**强制改写 4 ä¸ªå­—段**,前端传什么都会被覆盖:
| å­—段 | åŽç«¯å†™æˆ | è¯´æ˜Ž |
| --- | --- | --- |
| `stockOutNum` | = å‰ç«¯ä¼ çš„ `outTonnage` | **前端填的是 `outTonnage`(本次装车总量)**,不用传 `stockOutNum`;可用量校验比的也是它 |
| `approvalStatus` | `0`(待审批) | è¦†ç›–前端传值 |
| `recordType` | `'1'`(合格自定义出库) | è¦†ç›–前端传值;出库台账按 `recordType=1` ç­›ã€Œåˆæ ¼è‡ªå®šä¹‰å‡ºåº“」 |
| `recordId` | `0` | å‰ç«¯ä¸ç”¨ç®¡ |
另外三条:
- **不动库存**:这个接口只建 `stock_out_record` è®°å½•,库存扣减发生在**审批通过时**(见 4.3)
- **`outCategory` å¿…须是 `销售`**:车辆校验、可用量校验、残油口径**全部只对 `outCategory = '销售'` ç”Ÿæ•ˆ**。传「客存」「代储」不校验、审批时也不扣库存
- å‡ºåº“编码 `outboundBatches`(`CK` å¼€å¤´ï¼‰ç”±åŽç«¯è‡ªåŠ¨ç”Ÿæˆï¼Œå‰ç«¯ä¸ç”¨ä¼ 
请求示例:
```json
POST /stockInventory/addOilOutRecordOnly
{
  "outCategory": "销售",
  "vehicleId": 12,
  "truckPlateNo": "苏A12345",
  "productModelId": 407,
  "batchNo": "20260917-CY001-001",
  "outTonnage": 15,
  "outDate": "2026-09-20",
  "oilDepotId": 1,
  "tankId": 3,
  "customerId": 88,
  "customerName": "某某化工",
  "driverName": "张三",
  "driverPhone": "13800000000",
  "remark": ""
}
```
> å¦ä¸€ä¸ªå…¥å£ `POST /stockOutRecord`(出库管理-新增出库)**不做** `outTonnage â†’ stockOutNum` çš„æ¬è¿ï¼Œèµ°å®ƒçš„前端要自己传 `stockOutNum`。两个入口都会执行本次新增的车辆校验与可用量校验。
---
## 4. å‡ºåº“表单改造(销售类油品出库)
### 4.1 è½¦è¾†æ”¹ä¸‹æ‹‰
- æ•°æ®æºï¼š`GET /basic/vehicle/all`
- æäº¤æ—¶å¸¦ `vehicleId`;也可以只带 `truckPlateNo`(后端会按车牌号反查回填 `vehicleId`)
- ä¸¤è€…都为空 â†’ æŠ¥é”™ `销售类出库必须选择车辆`
> å…¼å®¹é€»è¾‘的存在是为了前端没跟上时不至于直接报错,**建议前端直接传 `vehicleId`**。
### 4.2 å¯ç”¨é‡æç¤º
```
GET /stockOutRecord/oilOutAvailableQty?vehicleId=&productModelId=&batchNo=
```
- `vehicleId`、`productModelId` å¿…å¡«ï¼›`batchNo` å¯é€‰ï¼Œä¸ä¼ åˆ™åº“存按该规格**全部批次合计**
- è¿”回:
```json
{
  "code": 200,
  "data": {
    "lastResidualOil": 5.0000,
    "stockQuantity": 10.0000,
    "availableQty": 15.0000
  }
}
```
- å±•示建议:`上次剩余(吨) 5 / å½“前库存(吨) 10 / å¯ç”¨é‡(吨) 15`,**并把这句口径显示出来**:可用量 = ä¸Šæ¬¡å‰©ä½™ + å½“前库存
「上次剩余油量」的取数规则(前端不用管,出问题时按这个排查):
`stock_out_record` ä¸­åŒ `vehicle_id` + `product_model_id`、`residual_oil` éžç©ºã€`approval_status=1` çš„**最近一条**。**没填残油的记录会被跳过**,继续往前找。
### 4.3 å®¡æ‰¹ï¼ˆåº“存就是在这扣的)
```
POST /stockOutRecord/approve
{ "ids": [255], "approvalStatus": 1 }
```
- `approvalStatus`:`1` = é€šè¿‡ã€`2` = é©³å›ž
- æ‰¹é‡æŽ¥å£ï¼Œ**一条不过则全批回滚**
- åªæœ‰ `approvalStatus = 0`(待审批)的记录能审批;`addOilOutRecordOnly` å»ºå‡ºæ¥çš„记录正好是 0
- æ‰£å‡é‡ `deduct_quantity` åœ¨é€šè¿‡è¿™ä¸€æ­¥è½åº“
### 4.4 æ ¡éªŒä¸ŽæŠ¥é”™æ–‡æ¡ˆ
销售类出库在**新增**和**审批通过**两步都校验。
| åœºæ™¯ | æŠ¥é”™ |
| --- | --- |
| æ²¡é€‰è½¦è¾†ã€ä¹Ÿæ²¡å¡«è½¦ç‰Œå· | `销售类出库必须选择车辆` |
| è½¦ç‰Œå·ä¸åœ¨è½¦è¾†ç®¡ç†é‡Œ | `车牌号 X ä¸åœ¨è½¦è¾†ç®¡ç†ä¸­ï¼Œè¯·å…ˆåˆ°è½¦è¾†ç®¡ç†æ·»åŠ ` |
| æ²¡é€‰å‡ºåº“批次 | `销售类出库必须选择出库批次` |
| æ•°é‡ä¸ºç©ºæˆ– â‰¤ 0 | `销售类出库数量必须大于0` |
| æ•°é‡ < ä¸Šæ¬¡å‰©ä½™æ²¹é‡ | `出库数量不能小于该车上次剩余油量5` |
| æ•°é‡ > ä¸Šæ¬¡å‰©ä½™ + åº“å­˜ | `出库数量超出可用量(上次剩余5,库存10,可用15)` |
| å®¡æ‰¹æ—¶åº“存被别处扣掉了 | `库存不足无法出库` |
**错误响应形态**(`BaseException` è¢«å…¨å±€å¼‚常处理器兜成 `AjaxResult`,注意 **HTTP çŠ¶æ€ç æ˜¯ 200**):
```json
{ "code": 500, "msg": "销售类出库必须选择车辆", "data": null }
```
所以前端**判失败要看 `code`,不能只看 HTTP çŠ¶æ€ç **,并且要把 `msg` åŽŸæ ·é€å‡ºâ€”â€”è¿™äº›æ–‡æ¡ˆé‡Œå¸¦äº†å…·ä½“æ•°å­—ï¼Œæ˜¯çŽ°åœºæŽ’æŸ¥çš„ä¸»è¦çº¿ç´¢ã€‚
---
## 5. æ®‹æ²¹æ€Žä¹ˆå½•(送完货后回填)
```
PUT /stockOutRecord/{id}/residualOil
Content-Type: application/json
{ "residualOil": 4.5 }
```
- `residualOil` å•位吨,**只更新这一列**,其他列不动
- æ ¡éªŒï¼šä¸èƒ½ä¸ºç©ºã€ä¸èƒ½å°äºŽ 0、不能大于该记录的出库数量
- å»ºè®®åœ¨**出库审批通过后**录入(这时货已经送到了,才知道剩多少)
> âš ï¸ **不要用 `PUT /stockOutRecord/{id}` æ¥å½•残油**。那个接口走 `BeanUtils.copyProperties`,会把没传的字段**全部置空**——只传 `{id, residualOil}` ä¼šæŠŠè¿™æ¡å‡ºåº“记录的客户、批次、数量等全部清掉。所以本次专门开了 `/residualOil` è¿™ä¸ªå•字段接口。
---
## 6. æ‰£åº“存的实际口径(最关键的一条)
审批通过时:
```
deduct_quantity(真正扣库存的量) = å‡ºåº“数量 âˆ’ ä¸Šæ¬¡å‰©ä½™æ²¹é‡
```
- ä¸Šæ¬¡å‰©ä½™ 5、本次装车 15(车上 5 + æ–°è£… 10)→ åªä»Žåº“存扣 **10**,`deduct_quantity` è½åº“为 10
- è½¦ä¸Šçš„剩油刚好够(出库数量 = ä¸Šæ¬¡å‰©ä½™ï¼‰â†’ **不碰库存**,`deduct_quantity` = 0
- å‡ºåº“数量 < ä¸Šæ¬¡å‰©ä½™æ²¹é‡ â†’ æŠ¥é”™ï¼ˆæ²¹ä¸å¯èƒ½å‡­ç©ºå˜å¤šï¼‰
- `deduct_quantity` è½åº“的作用:**删除这条出库记录时按它归还库存**,所以只能归还当初真正扣掉的量
---
## 7. å‡ºåº“台账列表新增字段
`GET /stockOutRecord/listPage`(以及导出 Excel、列表详情)新增返回:
| å­—段 | è¯´æ˜Ž |
| --- | --- |
| `vehicleId` | è½¦è¾†id(车辆主数据) |
| `residualOil` | æœ¬æ¬¡é€å®Œè´§åŽå‰©ä½™æ²¹é‡(吨) |
| `deductQuantity` | å®¡æ‰¹é€šè¿‡æ—¶å®žé™…从库存扣减的数量 |
列表 XML ç”¨çš„æ˜¯ `select sor.*`,所以这三个字段是自动带出的,无需额外处理。
---
## 8. å·²çŸ¥å½±å“ä¸Žæ³¨æ„ç‚¹
1. **销售类出库多了两个前置条件**:必须有车辆、必须选批次。历史数据不受影响——`vehicleId` ä¸ºç©ºçš„老记录,其「上次剩余油量」按 0 å¤„理,扣减量退化为「出库数量」,与改造前行为一致。
2. **历史已审批记录不补算**:例如 `CK20260920002`(已审批、当初扣了全额)`deduct_quantity` ä¸º null,删除时按全额归还,与它当初的扣减一致;它没有车辆,不参与残油链。
3. **一辆车的残油按油品规格分开算**:同一辆车拉不同油品互不干扰。
4. **审批通过后再改 `residualOil` ä¸ä¼šé‡ç®— `deduct_quantity`**(与平台既有的「改数量不同步库存」是同一类问题,本次不同步)。
5. **库存守卫只看 `qualitity`,不考虑 `locked_quantity`(冻结量)**,与既有 `type='0'/'1'` åˆ†æ”¯è¡Œä¸ºä¸€è‡´ã€‚
6. **可用量查询的库存合计会受数据权限影响**:`stock_inventory` æœªåŠ å…¥å¿½ç•¥è¡¨ï¼Œéžç®¡ç†å‘˜ä¸”æ•°æ®èŒƒå›´ä¸ºã€Œæœ¬éƒ¨é—¨/本部门及以下/仅本人」的账号,查询会被追加 `dept_id`/`create_user` æ¡ä»¶ï¼Œ**算出来的「当前库存」可能只覆盖自己部门的那部分**,从而偏小而误报「超出可用量」。这是平台既有行为(`type='0'/'1'` åˆ†æ”¯åŒæ ·å—影响)。若实际使用中遇到误报,需要单独确认 `stock_inventory` æ˜¯å¦åº”加入忽略表。
---
## 9. å‰ç«¯è‡ªæŸ¥æ¸…单
- [ ] DDL å·²æ‰§è¡Œï¼ˆ`vehicle` è¡¨å­˜åœ¨ã€`stock_out_record` æœ‰ `vehicle_id`/`residual_oil`/`deduct_quantity` ä¸‰åˆ—)
- [ ] è½¦è¾†ç®¡ç†é¡µé¢å·²å»ºå¥½ï¼ˆ`src/views/basicData/vehicle/index.vue`)——**菜单已配好,页面是唯一缺的部分,建好后菜单点进去就不再 404**
- [ ] è½¦è¾†ç®¡ç†èƒ½æ–°å¢žï¼Œé‡å¤è½¦ç‰Œå·ä¼šæŠ¥ `车牌号已存在:X`
- [ ] è‡³å°‘录入了一辆测试车
- [ ] å‡ºåº“表单的「车辆」是下拉,数据来自 `/basic/vehicle/all`,提交带 `vehicleId`
- [ ] å‡ºåº“表单提交的是 `outTonnage`(装车总量),且 `outCategory` ä¼ çš„æ˜¯ `销售`
- [ ] é€‰å®Œ è½¦è¾† + è§„æ ¼ + æ‰¹æ¬¡ åŽè°ƒ `/stockOutRecord/oilOutAvailableQty`,展示「上次剩余 / å½“前库存 / å¯ç”¨é‡ã€
- [ ] é€å‡ºåº“数量超出可用量时,页面能显示后端返回的 `出库数量超出可用量(...)` æ–‡æ¡ˆ
- [ ] æŽ¥å£æŠ¥é”™åˆ¤å®šç”¨çš„æ˜¯ `code` è€Œä¸æ˜¯ HTTP çŠ¶æ€ç ï¼ˆå¤±è´¥æ—¶ HTTP ä»æ˜¯ 200)
- [ ] å®¡æ‰¹èµ° `POST /stockOutRecord/approve`(`approvalStatus` 1/2),审批后才扣库存
- [ ] é€å®Œè´§åŽæœ‰åœ°æ–¹å½•残油,走的是 `PUT /stockOutRecord/{id}/residualOil`(不是 `PUT /stockOutRecord/{id}`)
- [ ] å‡ºåº“台账列表能看到 `residualOil` / `deductQuantity`
src/main/java/com/ruoyi/account/controller/PaymentRecordController.java
@@ -2,6 +2,7 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.account.enums.PartyTypeEnum;
import com.ruoyi.account.pojo.PaymentRecord;
import com.ruoyi.account.service.PaymentRecordService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
@@ -72,6 +73,9 @@
        if (paymentRecord.getPartyType() == null || paymentRecord.getPartyId() == null) {
            throw new RuntimeException("请选择往来方");
        }
        if (PartyTypeEnum.getByCode(paymentRecord.getPartyType()) == null) {
            throw new RuntimeException("往来方类型不合法");
        }
        if (paymentRecord.getDirection() == null) {
            throw new RuntimeException("请选择方向(付款/回款)");
        }
src/main/java/com/ruoyi/account/enums/PartyTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package com.ruoyi.account.enums;
import com.ruoyi.common.enums.BaseEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * å¾€æ¥æ–¹ç±»åž‹æžšä¸¾ï¼Œå¯¹åº” payment_record.party_type
 */
@Getter
@AllArgsConstructor
public enum PartyTypeEnum implements BaseEnum<Integer> {
    CUSTOMER(1, "客户"),
    SUPPLIER(2, "供应商"),
    OUTSOURCING_PARTNER(3, "合作商");
    private final Integer value;
    private final String label;
    @Override
    public Integer getCode() {
        return value;
    }
    @Override
    public String getValue() {
        return label;
    }
    public static PartyTypeEnum getByCode(Integer code) {
        if (code == null) {
            return null;
        }
        for (PartyTypeEnum partyType : values()) {
            if (partyType.getCode().equals(code)) {
                return partyType;
            }
        }
        return null;
    }
    public static String getLabelByCode(Integer code) {
        PartyTypeEnum partyType = getByCode(code);
        return partyType == null ? "未知类型" : partyType.getValue();
    }
}
src/main/java/com/ruoyi/account/pojo/PaymentRecord.java
@@ -31,10 +31,10 @@
    @Schema(description = "主键id")
    private Long id;
    @Schema(description = "往来方类型 1客户 2供应商")
    @Schema(description = "往来方类型 1客户 2供应商 3合作商")
    private Integer partyType;
    @Schema(description = "客户id或供应商id")
    @Schema(description = "客户id或供应商id或合作商id")
    private Long partyId;
    @Schema(description = "方向 1付款 2回款")
src/main/java/com/ruoyi/approve/bean/vo/ApprovalInstanceVo.java
@@ -33,5 +33,8 @@
    @Schema(description = "发货单号")
    private String shippingNo;
    @Schema(description = "销售合同号")
    private String salesContractNo;
    private List<StorageBlobVO> storageBlobVOList;
}
src/main/java/com/ruoyi/approve/service/impl/ApprovalInstanceServiceImpl.java
@@ -40,9 +40,11 @@
import com.ruoyi.purchase.mapper.PurchaseLedgerMapper;
import com.ruoyi.purchase.pojo.PurchaseLedger;
import com.ruoyi.quality.utils.QualityInspectHelper;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
import com.ruoyi.sales.mapper.SalesQuotationMapper;
import com.ruoyi.sales.mapper.ShippingInfoMapper;
import com.ruoyi.sales.pojo.SalesLedger;
import com.ruoyi.sales.pojo.SalesLedgerProduct;
import com.ruoyi.sales.pojo.SalesQuotation;
import com.ruoyi.sales.pojo.ShippingInfo;
@@ -84,6 +86,7 @@
    private final SalesLedgerProductMapper salesLedgerProductMapper;
    private final StockUtils stockUtils;
    private final SalesQuotationMapper salesQuotationMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final ShippingInfoMapper shippingInfoMapper;
    private final QualityInspectHelper qualityInspectHelper;
    private final EnterpriseNewsScopeUserMapper enterpriseNewsScopeUserMapper;
@@ -125,6 +128,12 @@
                    ShippingInfo shippingInfo = shippingInfoMapper.selectById(vo.getBusinessId());
                    if (shippingInfo != null) {
                        vo.setShippingNo(shippingInfo.getShippingNo());
                    }
                } else if (TypeEnums.SALES_LEDGER_APPROVAL.getCode().equals(vo.getBusinessType())) {
                    // é”€å”®å°è´¦å®¡æ‰¹ - æŸ¥è¯¢é”€å”®åˆåŒå·
                    SalesLedger salesLedger = salesLedgerMapper.selectById(vo.getBusinessId());
                    if (salesLedger != null) {
                        vo.setSalesContractNo(salesLedger.getSalesContractNo());
                    }
                }
            }
@@ -536,6 +545,10 @@
            handleShippingApprovalFinished(instance, status);
            return;
        }
        if (TypeEnums.SALES_LEDGER_APPROVAL.getCode().equals(businessType)) {
            handleSalesLedgerApprovalFinished(instance, status);
            return;
        }
        if (TypeEnums.TRAVEL_REIMBURSEMENT_APPROVAL.getCode().equals(businessType)
                || TypeEnums.EXPENSE_APPROVAL.getCode().equals(businessType)) {
            handleReimbursementApprovalFinished(instance, status);
@@ -666,6 +679,25 @@
        salesQuotationMapper.updateById(salesQuote);
    }
    /**
     * é”€å”®å°è´¦å®¡æ‰¹ç»“束:只回写台账的审批状态,不锁定单据、不自动生成下游单据
     */
    private void handleSalesLedgerApprovalFinished(ApprovalInstance instance, String status) {
        SalesLedger salesLedger = salesLedgerMapper.selectById(instance.getBusinessId());
        if (salesLedger == null) {
            return;
        }
        if ("APPROVED".equals(status)) {
            salesLedger.setApprovalStatus(SalesQuotationStatusEnum.APPROVED.getCode());
        } else if ("REJECTED".equals(status)) {
            salesLedger.setApprovalStatus(SalesQuotationStatusEnum.REJECTED.getCode());
        } else if ("PENDING".equals(status)) {
            salesLedger.setApprovalStatus(SalesQuotationStatusEnum.IN_PROGRESS.getCode());
        }
        salesLedgerMapper.updateById(salesLedger);
    }
    private void handleShippingApprovalFinished(ApprovalInstance instance, String status) {
        ShippingInfo shippingInfo = shippingInfoMapper.selectOne(
                new LambdaQueryWrapper<ShippingInfo>()
src/main/java/com/ruoyi/basic/controller/VehicleController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
package com.ruoyi.basic.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.basic.pojo.Vehicle;
import com.ruoyi.basic.service.IVehicleService;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.domain.R;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
 * è½¦è¾†ä¿¡æ¯Controller
 *
 * @author ruoyi
 * @date 2026-09-20
 */
@RestController
@RequestMapping("/basic/vehicle")
@AllArgsConstructor
public class VehicleController {
    private IVehicleService vehicleService;
    /**
     * åˆ†é¡µæŸ¥è¯¢è½¦è¾†ä¿¡æ¯åˆ—表
     */
    @GetMapping("/list")
    public R list(Page<Vehicle> page, Vehicle vehicle) {
        IPage<Vehicle> result = vehicleService.selectVehicleList(page, vehicle);
        return R.ok(result);
    }
    /**
     * æŸ¥è¯¢å…¨éƒ¨å¯ç”¨ä¸­çš„车辆(供出库表单下拉选择)
     */
    @GetMapping("/all")
    public R all() {
        return R.ok(vehicleService.selectAll());
    }
    /**
     * èŽ·å–è½¦è¾†ä¿¡æ¯è¯¦ç»†ä¿¡æ¯
     */
    @GetMapping(value = "/{id}")
    public R getInfo(@PathVariable("id") Long id) {
        return R.ok(vehicleService.selectVehicleById(id));
    }
    /**
     * æ–°å¢žè½¦è¾†ä¿¡æ¯
     */
    @Log(title = "车辆信息", businessType = BusinessType.INSERT)
    @PostMapping("/add")
    public R add(@RequestBody Vehicle vehicle) {
        return R.ok(vehicleService.insertVehicle(vehicle));
    }
    /**
     * ä¿®æ”¹è½¦è¾†ä¿¡æ¯
     */
    @Log(title = "车辆信息", businessType = BusinessType.UPDATE)
    @PostMapping("/update")
    public R edit(@RequestBody Vehicle vehicle) {
        return R.ok(vehicleService.updateVehicle(vehicle));
    }
    /**
     * åˆ é™¤è½¦è¾†ä¿¡æ¯
     */
    @Log(title = "车辆信息", businessType = BusinessType.DELETE)
    @DeleteMapping("/del")
    public R remove(@RequestBody Long[] ids) {
        if (ids == null || ids.length == 0) {
            return R.fail("请传入要删除的ID");
        }
        return R.ok(vehicleService.deleteVehicleByIds(ids));
    }
}
src/main/java/com/ruoyi/basic/enums/RecordTypeEnum.java
@@ -202,7 +202,9 @@
    APPROVAL_INSTANCE("approval_instance"),
    ACCOUNT_INVOICE_APPLICATION("account_invoice_application"),
    ACCOUNT_PURCHASE_INVOICE("account_purchase_invoice"),
    APPROVAL_TEMPLATE("approval_template");
    APPROVAL_TEMPLATE("approval_template"),
    // Outsourcing
    OUTSOURCING_PARTNER("outsourcing_partner");
    private final String type;
    RecordTypeEnum(String type) { this.type = type; }
src/main/java/com/ruoyi/basic/enums/TankUsageTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package com.ruoyi.basic.enums;
import com.ruoyi.common.enums.BaseEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * å‚¨ç½ç”¨é€”枚举,对应 tank_info.usage_type
 */
@Getter
@AllArgsConstructor
public enum TankUsageTypeEnum implements BaseEnum<Integer> {
    LEASE(1, "租赁"),
    SELF_USE(2, "自用");
    private final Integer value;
    private final String label;
    @Override
    public Integer getCode() {
        return value;
    }
    @Override
    public String getValue() {
        return label;
    }
}
src/main/java/com/ruoyi/basic/mapper/VehicleMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package com.ruoyi.basic.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.basic.pojo.Vehicle;
import org.apache.ibatis.annotations.Mapper;
/**
 * è½¦è¾†ä¿¡æ¯Mapper接口
 *
 * @author ruoyi
 * @date 2026-09-20
 */
@Mapper
public interface VehicleMapper extends BaseMapper<Vehicle> {
}
src/main/java/com/ruoyi/basic/pojo/Customer.java
@@ -1,6 +1,7 @@
package com.ruoyi.basic.pojo;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
@@ -134,4 +135,8 @@
    @Schema(description = "是否被分配:0-未分配,1-已分配")
    private Integer isAssigned;
    @Schema(description = "信托基金(元)")
    @Excel(name = "信托基金")
    private BigDecimal trustFund;
}
src/main/java/com/ruoyi/basic/pojo/TankInfo.java
@@ -54,7 +54,17 @@
    private BigDecimal remaining;
    /**
     * ç§Ÿå®¢ä¿¡æ¯
     * æ˜¯å¦å¯ç”¨ 1启用 0未启用
     */
    private Integer isEnabled;
    /**
     * ç”¨é€” 1租赁 2自用,启用后才需要选
     */
    private Integer usageType;
    /**
     * ç§Ÿå®¢ä¿¡æ¯ï¼Œä»…用途=租赁时填写
     */
    private String tenantInfo;
src/main/java/com/ruoyi/basic/pojo/Vehicle.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
package com.ruoyi.basic.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * è½¦è¾†ä¿¡æ¯å¯¹è±¡ vehicle
 *
 * @author ruoyi
 * @date 2026-09-20
 */
@TableName(value = "vehicle")
@Data
public class Vehicle implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * è½¦è¾†id
     */
    @TableId(type = IdType.AUTO)
    private Long id;
    /**
     * è½¦ç‰Œå·ï¼ˆè½¦è¾†å”¯ä¸€æ ‡è¯†ï¼‰
     */
    private String plateNumber;
    /**
     * é»˜è®¤å¸æœº
     */
    private String driverName;
    /**
     * å¸æœºç”µè¯
     */
    private String driverPhone;
    /**
     * æ ¸å®šè½½é‡(吨)
     */
    private BigDecimal loadCapacity;
    /**
     * çŠ¶æ€ï¼šå¯ç”¨/停用
     */
    private String status;
    /**
     * å¤‡æ³¨
     */
    private String remark;
    /**
     * åˆ›å»ºæ—¶é—´
     */
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    /**
     * åˆ›å»ºäºº
     */
    @TableField(fill = FieldFill.INSERT)
    private Long createUser;
    /**
     * æ›´æ–°æ—¶é—´
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    /**
     * æ›´æ–°äºº
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
    /**
     * ç§Ÿæˆ·id
     */
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    /**
     * éƒ¨é—¨id
     */
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
src/main/java/com/ruoyi/basic/service/IVehicleService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package com.ruoyi.basic.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.basic.pojo.Vehicle;
import java.util.List;
/**
 * è½¦è¾†ä¿¡æ¯Service接口
 *
 * @author ruoyi
 * @date 2026-09-20
 */
public interface IVehicleService extends IService<Vehicle> {
    /**
     * åˆ†é¡µæŸ¥è¯¢è½¦è¾†ä¿¡æ¯åˆ—表
     *
     * @param page    åˆ†é¡µå‚æ•°
     * @param vehicle æŸ¥è¯¢æ¡ä»¶
     * @return è½¦è¾†ä¿¡æ¯é›†åˆ
     */
    IPage<Vehicle> selectVehicleList(Page<Vehicle> page, Vehicle vehicle);
    /**
     * æŸ¥è¯¢å…¨éƒ¨å¯ç”¨ä¸­çš„车辆(供出库表单下拉选择)
     *
     * @return è½¦è¾†ä¿¡æ¯é›†åˆ
     */
    List<Vehicle> selectAll();
    /**
     * æŸ¥è¯¢è½¦è¾†ä¿¡æ¯
     *
     * @param id è½¦è¾†ä¿¡æ¯ä¸»é”®
     * @return è½¦è¾†ä¿¡æ¯
     */
    Vehicle selectVehicleById(Long id);
    /**
     * æ–°å¢žè½¦è¾†ä¿¡æ¯
     *
     * @param vehicle è½¦è¾†ä¿¡æ¯
     * @return ç»“æžœ
     */
    boolean insertVehicle(Vehicle vehicle);
    /**
     * ä¿®æ”¹è½¦è¾†ä¿¡æ¯
     *
     * @param vehicle è½¦è¾†ä¿¡æ¯
     * @return ç»“æžœ
     */
    boolean updateVehicle(Vehicle vehicle);
    /**
     * æ‰¹é‡åˆ é™¤è½¦è¾†ä¿¡æ¯
     *
     * @param ids éœ€è¦åˆ é™¤çš„车辆信息主键集合
     * @return ç»“æžœ
     */
    boolean deleteVehicleByIds(Long[] ids);
}
src/main/java/com/ruoyi/basic/service/impl/CustomerServiceImpl.java
@@ -23,6 +23,7 @@
import com.ruoyi.framework.web.domain.R;
import com.ruoyi.procurementrecord.mapper.ReturnManagementMapper;
import com.ruoyi.procurementrecord.pojo.ReturnManagement;
import com.ruoyi.sales.dto.CustomerDepositAmountDto;
import com.ruoyi.sales.mapper.SalesLedgerMapper;
import com.ruoyi.sales.mapper.SalesQuotationMapper;
import com.ruoyi.sales.pojo.SalesLedger;
@@ -38,6 +39,7 @@
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
@@ -117,7 +119,37 @@
            customerVo.setFollowUpList(followUpDtoList);
        }
        fillDepositAmount(Collections.singletonList(customerVo));
        return customerVo;
    }
    /**
     * å¡«ä»£å‚¨é‡‘额(该客户全部「类型=代储」的销售台账合同金额累计)并判断是否已超信托基金
     */
    private void fillDepositAmount(List<CustomerVo> records) {
        if (CollectionUtils.isEmpty(records)) {
            return;
        }
        List<Long> customerIds = records.stream()
                .map(CustomerVo::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        Map<Long, BigDecimal> depositAmountMap = customerIds.isEmpty()
                ? Collections.emptyMap()
                : salesLedgerMapper.selectDepositAmountByCustomerIds(customerIds).stream()
                .filter(item -> item.getCustomerId() != null)
                .collect(Collectors.toMap(
                        CustomerDepositAmountDto::getCustomerId,
                        item -> item.getDepositTotalAmount() == null ? BigDecimal.ZERO : item.getDepositTotalAmount(),
                        (existing, replacement) -> existing));
        records.forEach(customer -> {
            BigDecimal depositTotalAmount = depositAmountMap.getOrDefault(customer.getId(), BigDecimal.ZERO);
            customer.setDepositTotalAmount(depositTotalAmount);
            customer.setExceedsTrustFund(customer.getTrustFund() != null
                    && depositTotalAmount.compareTo(customer.getTrustFund()) > 0);
        });
    }
    /**
@@ -168,6 +200,8 @@
                    c.setUserIds(userIds);
                }
            });
            fillDepositAmount(records);
        }
        return customerPage;
src/main/java/com/ruoyi/basic/service/impl/TankInfoServiceImpl.java
@@ -2,11 +2,14 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.enums.TankUsageTypeEnum;
import com.ruoyi.basic.mapper.TankInfoMapper;
import com.ruoyi.basic.pojo.TankInfo;
import com.ruoyi.basic.service.ITankInfoService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.stereotype.Service;
@@ -21,19 +24,30 @@
@Service
public class TankInfoServiceImpl extends ServiceImpl<TankInfoMapper, TankInfo> implements ITankInfoService {
    /**
     * æ˜¯å¦å¯ç”¨ï¼šå¯ç”¨ã€‚与 tank_info.is_enabled çš„默认值一致
     */
    private static final Integer TANK_ENABLED = 1;
    @Override
    public IPage<TankInfo> selectTankInfoList(Page<TankInfo> page, TankInfo tankInfo) {
        LambdaQueryWrapper<TankInfo> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.like(StringUtils.isNotBlank(tankInfo.getTankNo()), TankInfo::getTankNo, tankInfo.getTankNo())
                .like(StringUtils.isNotBlank(tankInfo.getCategory()), TankInfo::getCategory, tankInfo.getCategory())
                .eq(tankInfo.getIsEnabled() != null, TankInfo::getIsEnabled, tankInfo.getIsEnabled())
                .eq(tankInfo.getUsageType() != null, TankInfo::getUsageType, tankInfo.getUsageType())
                .orderByAsc(TankInfo::getId);
        return this.page(page, queryWrapper);
    }
    /**
     * ä¸‹æ‹‰ç”¨ï¼šåªè¿”回已启用的储罐。未启用表示还没投用,不应被入库等业务选中
     */
    @Override
    public List<TankInfo> selectAll() {
        LambdaQueryWrapper<TankInfo> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.orderByAsc(TankInfo::getId);
        queryWrapper.eq(TankInfo::getIsEnabled, TANK_ENABLED)
                .orderByAsc(TankInfo::getId);
        return this.list(queryWrapper);
    }
@@ -44,12 +58,62 @@
    @Override
    public boolean insertTankInfo(TankInfo tankInfo) {
        normalizeUsage(tankInfo);
        return this.save(tankInfo);
    }
    @Override
    public boolean updateTankInfo(TankInfo tankInfo) {
        return this.updateById(tankInfo);
        if (tankInfo.getId() == null) {
            throw new ServiceException("缺少储罐id");
        }
        normalizeUsage(tankInfo);
        boolean updated = this.updateById(tankInfo);
        if (updated) {
            // usageType / tenantInfo æ˜¯å…è®¸è¢«æ¸…空的,而 updateById é»˜è®¤è·³è¿‡ null å­—段(FieldStrategy.NOT_NULL),
            // æ¸…空动作根本写不进库,所以这两列单独补一条更新
            updated = this.update(Wrappers.<TankInfo>lambdaUpdate()
                    .eq(TankInfo::getId, tankInfo.getId())
                    .set(TankInfo::getUsageType, tankInfo.getUsageType())
                    .set(TankInfo::getTenantInfo, tankInfo.getTenantInfo()));
        }
        return updated;
    }
    /**
     * å½’一化「是否启用 / ç”¨é€” / ç§Ÿå®¢ä¿¡æ¯ã€ä¸‰è€…的关系:
     * <ul>
     *     <li>未启用:用途与租客信息一律清空 â€”— å¯ç”¨ä¹‹åŽæ‰è°ˆç§Ÿèµ/自用</li>
     *     <li>启用 + ç§Ÿèµï¼šç§Ÿå®¢ä¿¡æ¯å¿…å¡«</li>
     *     <li>启用 + è‡ªç”¨ï¼šç§Ÿå®¢ä¿¡æ¯æ¸…空</li>
     *     <li>启用 + ç”¨é€”为空:视为未选择,既不校验也不清空。迁移后的存量储罐就是这个状态,
     *         è¿™æ ·è€å‰ç«¯ä¸æ”¹ä¹Ÿä¸ä¼šè¢«æ‹¦ï¼Œæ›´ä¸ä¼šæŠŠå·²æœ‰çš„租客信息抹掉</li>
     * </ul>
     */
    private void normalizeUsage(TankInfo tankInfo) {
        if (tankInfo.getIsEnabled() == null) {
            tankInfo.setIsEnabled(TANK_ENABLED);
        }
        if (!TANK_ENABLED.equals(tankInfo.getIsEnabled())) {
            tankInfo.setUsageType(null);
            tankInfo.setTenantInfo(null);
            return;
        }
        Integer usageType = tankInfo.getUsageType();
        if (usageType == null) {
            return;
        }
        if (TankUsageTypeEnum.LEASE.getCode().equals(usageType)) {
            if (StringUtils.isBlank(tankInfo.getTenantInfo())) {
                throw new ServiceException("租赁的储罐必须填写租客信息");
            }
            return;
        }
        if (TankUsageTypeEnum.SELF_USE.getCode().equals(usageType)) {
            tankInfo.setTenantInfo(null);
            return;
        }
        throw new ServiceException("储罐用途只能是租赁或自用");
    }
    @Override
src/main/java/com/ruoyi/basic/service/impl/VehicleServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
package com.ruoyi.basic.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.mapper.VehicleMapper;
import com.ruoyi.basic.pojo.Vehicle;
import com.ruoyi.basic.service.IVehicleService;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.stereotype.Service;
import java.util.List;
/**
 * è½¦è¾†ä¿¡æ¯Service业务层处理
 *
 * @author ruoyi
 * @date 2026-09-20
 */
@Service
public class VehicleServiceImpl extends ServiceImpl<VehicleMapper, Vehicle> implements IVehicleService {
    /**
     * è½¦è¾†çŠ¶æ€ï¼šåœç”¨ã€‚åœç”¨çš„è½¦è¾†ä¸å†å‡ºçŽ°åœ¨å‡ºåº“è¡¨å•çš„ä¸‹æ‹‰é‡Œ
     */
    private static final String STATUS_DISABLED = "停用";
    @Override
    public IPage<Vehicle> selectVehicleList(Page<Vehicle> page, Vehicle vehicle) {
        LambdaQueryWrapper<Vehicle> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.like(StringUtils.isNotBlank(vehicle.getPlateNumber()), Vehicle::getPlateNumber, vehicle.getPlateNumber())
                .like(StringUtils.isNotBlank(vehicle.getDriverName()), Vehicle::getDriverName, vehicle.getDriverName())
                .eq(StringUtils.isNotBlank(vehicle.getStatus()), Vehicle::getStatus, vehicle.getStatus())
                .orderByAsc(Vehicle::getId);
        return this.page(page, queryWrapper);
    }
    @Override
    public List<Vehicle> selectAll() {
        LambdaQueryWrapper<Vehicle> queryWrapper = new LambdaQueryWrapper<>();
        // status å…è®¸ä¸ºç©ºï¼Œè§†ä¸ºå¯ç”¨
        queryWrapper.and(w -> w.isNull(Vehicle::getStatus).or().ne(Vehicle::getStatus, STATUS_DISABLED))
                .orderByAsc(Vehicle::getId);
        return this.list(queryWrapper);
    }
    @Override
    public Vehicle selectVehicleById(Long id) {
        return this.getById(id);
    }
    @Override
    public boolean insertVehicle(Vehicle vehicle) {
        if (StringUtils.isBlank(vehicle.getPlateNumber())) {
            throw new BaseException("车牌号不能为空");
        }
        checkPlateNumberUnique(vehicle);
        return this.save(vehicle);
    }
    @Override
    public boolean updateVehicle(Vehicle vehicle) {
        if (StringUtils.isNotBlank(vehicle.getPlateNumber())) {
            checkPlateNumberUnique(vehicle);
        }
        return this.updateById(vehicle);
    }
    @Override
    public boolean deleteVehicleByIds(Long[] ids) {
        return this.removeByIds(List.of(ids));
    }
    /**
     * è½¦ç‰Œå·æ˜¯è½¦è¾†çš„唯一标识,不能重复
     */
    private void checkPlateNumberUnique(Vehicle vehicle) {
        LambdaQueryWrapper<Vehicle> queryWrapper = new LambdaQueryWrapper<Vehicle>()
                .eq(Vehicle::getPlateNumber, vehicle.getPlateNumber());
        if (vehicle.getId() != null) {
            queryWrapper.ne(Vehicle::getId, vehicle.getId());
        }
        if (this.count(queryWrapper) > 0) {
            throw new BaseException("车牌号已存在:" + vehicle.getPlateNumber());
        }
    }
}
src/main/java/com/ruoyi/basic/vo/CustomerVo.java
@@ -5,6 +5,7 @@
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
@@ -30,4 +31,10 @@
     * æ˜¯å¦è¢«é”€å”®æ¨¡å—引用:0-否,1-是
     */
    private Integer salesReferenceFlag;
    @ApiModelProperty(value = "该客户代储金额合计(所有类型=代储的销售台账合同金额累计)")
    private BigDecimal depositTotalAmount;
    @ApiModelProperty(value = "代储金额是否已超出信托基金")
    private Boolean exceedsTrustFund;
}
src/main/java/com/ruoyi/common/config/IgnoreTableConfig.java
@@ -39,6 +39,8 @@
        IGNORE_TABLES.add("product_model");
        IGNORE_TABLES.add("ai_chat_session");
        IGNORE_TABLES.add("product");
        // è½¦è¾†æ˜¯å…¬å¸çº§ä¸»æ•°æ®ï¼Œå‡ºåº“表单的下拉必须能看到全部车辆,不做部门/创建人过滤
        IGNORE_TABLES.add("vehicle");
    }
}
src/main/java/com/ruoyi/common/enums/StockOutQualifiedRecordTypeEnum.java
@@ -11,7 +11,8 @@
    CUSTOMIZATION_UNSTOCK_OUT("10", "不合格自定义出库"),
    SALE_SHIP_STOCK_OUT("13", "销售-发货出库"),
    PICK_STOCK_OUT("14", "生产领料出库"),
    FEED_STOCK_OUT("15", "生产补料出库");
    FEED_STOCK_OUT("15", "生产补料出库"),
    OUTSOURCING_SCRAP_OUT("30", "委外-报废品出库");
    private final String code;
    private final String value;
src/main/java/com/ruoyi/common/enums/TypeEnums.java
@@ -24,7 +24,8 @@
    OVERTIME_APPROVAL(15L, "加班审批"),
    TRAVEL_REIMBURSEMENT_APPROVAL(16L, "出差报销审批"),
    EXPENSE_APPROVAL(17L, "费用审批"),
    ENTERPRISE_NEWS_APPROVAL(18L, "企业新闻审批");
    ENTERPRISE_NEWS_APPROVAL(18L, "企业新闻审批"),
    SALES_LEDGER_APPROVAL(19L, "销售台账审批");
src/main/java/com/ruoyi/device/service/impl/DeviceMaintenanceServiceImpl.java
@@ -95,6 +95,8 @@
                        outRecord.setQuantity(new BigDecimal(sparePartUse.getQuantity()));
                        outRecord.setSourceType(3);
                        outRecord.setSourceId(deviceMaintenance.getId());
                        // ä¿å…»å•没有单号,来源单号用设备名称,流水页才看得出备件用在哪台设备上
                        outRecord.setSourceNo(oldDeviceMaintenance.getDeviceName());
                        sparePartsRecordService.save(outRecord);
                    } else {
                        return AjaxResult.error("备件 " + spareParts.getName() + " æ•°é‡ä¸è¶³");
src/main/java/com/ruoyi/device/service/impl/DeviceRepairServiceImpl.java
@@ -125,6 +125,8 @@
                        outRecord.setQuantity(new BigDecimal(sparePartUse.getQuantity()));
                        outRecord.setSourceType(2);
                        outRecord.setSourceId(deviceRepairDto.getId());
                        // æŠ¥ä¿®å•没有单号,来源单号用设备名称,流水页才看得出备件用在哪台设备上
                        outRecord.setSourceNo(oldDeviceRepair.getDeviceName());
                        sparePartsRecordService.save(outRecord);
                    } else {
                        return AjaxResult.error("备件 " + spareParts.getName() + " æ•°é‡ä¸è¶³");
src/main/java/com/ruoyi/mock/service/impl/DataGenerateServiceImpl.java
@@ -557,6 +557,8 @@
                dto.setSalesman(item.getString("salesman"));
                dto.setPaymentMethod(item.getString("paymentMethod"));
                dto.setType(item.getInteger("type"));
                // æ¨¡æ‹Ÿæ•°æ®ç”Ÿæˆçš„台账都带产品明细,按卖油处理(台账新增已要求必填销售类型)
                dto.setLedgerType("卖油");
                // å½•入人:优先用AI返回的,否则从"销售"角色/部门随机选
                String entryPerson = item.getString("entryPerson");
src/main/java/com/ruoyi/outsourcing/controller/OutsourcingOrderController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
package com.ruoyi.outsourcing.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.framework.web.page.TableDataInfo;
import com.ruoyi.outsourcing.dto.OutsourcingOrderDto;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.service.IOutsourcingOrderService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.service.StockInventoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
 * å§”外订单 Controller
 */
@RestController
@RequestMapping("/outsourcing/order")
@AllArgsConstructor
@Tag(name = "委外订单")
public class OutsourcingOrderController extends BaseController {
    private final IOutsourcingOrderService outsourcingOrderService;
    private final StockInventoryService stockInventoryService;
    /**
     * å§”外订单列表
     */
    @Operation(summary = "委外订单列表")
    @GetMapping("/list")
    public TableDataInfo list(OutsourcingOrderDto dto) {
        startPage();
        List<OutsourcingOrder> list = outsourcingOrderService.selectOrderList(dto);
        return getDataTable(list);
    }
    /**
     * å§”外订单详情(含产品明细)
     */
    @Operation(summary = "委外订单详情")
    @GetMapping("/getById")
    public AjaxResult getById(@RequestParam Long id) {
        return AjaxResult.success(outsourcingOrderService.getDetail(id));
    }
    /**
     * æ–°å¢žå§”外订单(草稿)
     */
    @Operation(summary = "新增委外订单")
    @Log(title = "委外订单", businessType = BusinessType.INSERT)
    @PostMapping("/add")
    public AjaxResult add(@RequestBody OutsourcingOrderDto dto) {
        return AjaxResult.success(outsourcingOrderService.saveOrder(dto));
    }
    /**
     * ä¿®æ”¹å§”外订单,仅草稿状态允许
     */
    @Operation(summary = "修改委外订单")
    @Log(title = "委外订单", businessType = BusinessType.UPDATE)
    @PostMapping("/edit")
    public AjaxResult edit(@RequestBody OutsourcingOrderDto dto) {
        return toAjax(outsourcingOrderService.updateOrder(dto));
    }
    /**
     * åˆ é™¤å§”外订单,仅草稿状态允许
     */
    @Operation(summary = "删除委外订单")
    @Log(title = "委外订单", businessType = BusinessType.DELETE)
    @DeleteMapping("/delete")
    public AjaxResult delete(@RequestBody Long[] ids) {
        return toAjax(outsourcingOrderService.deleteOrders(ids == null ? List.of() : List.of(ids)));
    }
    /**
     * æäº¤å§”外订单:草稿 -> å·²ç¡®è®¤
     */
    @Operation(summary = "提交委外订单")
    @Log(title = "委外订单提交", businessType = BusinessType.OTHER)
    @PostMapping("/submit")
    public AjaxResult submit(@RequestParam Long id) {
        return toAjax(outsourcingOrderService.submit(id));
    }
    /**
     * å®Œæˆå§”外订单:已确认 -> å·²å®Œæˆ
     */
    @Operation(summary = "完成委外订单")
    @Log(title = "委外订单完成", businessType = BusinessType.OTHER)
    @PostMapping("/complete")
    public AjaxResult complete(@RequestParam Long id) {
        return toAjax(outsourcingOrderService.complete(id));
    }
    /**
     * ä½œåºŸå§”外订单:草稿/已确认 -> å·²ä½œåºŸ
     */
    @Operation(summary = "作废委外订单")
    @Log(title = "委外订单作废", businessType = BusinessType.OTHER)
    @PostMapping("/cancel")
    public AjaxResult cancel(@RequestParam Long id,
                             @RequestParam(required = false) String cancelReason) {
        return toAjax(outsourcingOrderService.cancel(id, cancelReason));
    }
    /**
     * å§”外订单导出
     */
    @Operation(summary = "委外订单导出")
    @Log(title = "委外订单", businessType = BusinessType.EXPORT)
    @PostMapping("/export")
    public void export(HttpServletResponse response, OutsourcingOrderDto dto) {
        outsourcingOrderService.exportOrders(response, dto);
    }
    /**
     * æŠ¥åºŸå“é€‰æ–™åˆ†é¡µï¼ˆåˆæ ¼åº“存,只读,不占用、不回写库存)
     */
    @Operation(summary = "报废品选料分页")
    @GetMapping("/scrapStockPage")
    public AjaxResult scrapStockPage(Page page, StockInventoryDto dto) {
        IPage<StockInventoryDto> result = stockInventoryService.pageScrapStock(page, dto);
        return AjaxResult.success(result);
    }
}
src/main/java/com/ruoyi/outsourcing/controller/OutsourcingPartnerController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
package com.ruoyi.outsourcing.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.framework.aspectj.lang.annotation.Log;
import com.ruoyi.framework.aspectj.lang.enums.BusinessType;
import com.ruoyi.framework.web.controller.BaseController;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.outsourcing.dto.OutsourcingPartnerDto;
import com.ruoyi.outsourcing.excel.OutsourcingPartnerExcelDto;
import com.ruoyi.outsourcing.service.IOutsourcingPartnerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
 * å§”外合作商档案 Controller
 */
@RestController
@RequestMapping("/outsourcing/partner")
@AllArgsConstructor
@Tag(name = "委外合作商档案")
public class OutsourcingPartnerController extends BaseController {
    private final IOutsourcingPartnerService outsourcingPartnerService;
    /**
     * æ–°å¢žåˆä½œå•†ï¼ˆå«é™„件)
     */
    @Operation(summary = "新增合作商")
    @Log(title = "合作商档案", businessType = BusinessType.INSERT)
    @PostMapping("/add")
    public AjaxResult add(@RequestBody OutsourcingPartnerDto dto) {
        return AjaxResult.success(outsourcingPartnerService.savePartner(dto));
    }
    /**
     * ä¿®æ”¹åˆä½œå•†ï¼ˆå«é™„件)
     */
    @Operation(summary = "修改合作商")
    @Log(title = "合作商档案", businessType = BusinessType.UPDATE)
    @PostMapping("/update")
    public AjaxResult update(@RequestBody OutsourcingPartnerDto dto) {
        return toAjax(outsourcingPartnerService.updatePartner(dto));
    }
    /**
     * åˆ é™¤åˆä½œå•†
     */
    @Operation(summary = "删除合作商")
    @Log(title = "合作商档案", businessType = BusinessType.DELETE)
    @DeleteMapping("/del")
    public AjaxResult del(@RequestBody List<Long> ids) {
        if (CollectionUtils.isEmpty(ids)) {
            return AjaxResult.error("请选择至少一条数据");
        }
        return toAjax(outsourcingPartnerService.delPartner(ids));
    }
    /**
     * åˆä½œå•†è¯¦æƒ…(含附件回显)
     */
    @Operation(summary = "合作商详情")
    @GetMapping("/{id}")
    public AjaxResult detail(@PathVariable("id") Long id) {
        return AjaxResult.success(outsourcingPartnerService.partnerDetail(id));
    }
    /**
     * åˆä½œå•†åˆ†é¡µæŸ¥è¯¢
     */
    @Operation(summary = "合作商分页查询")
    @GetMapping("/listPage")
    public AjaxResult listPage(Page page, OutsourcingPartnerDto dto) {
        IPage<OutsourcingPartnerDto> result = outsourcingPartnerService.partnerListPage(page, dto);
        return AjaxResult.success(result);
    }
    /**
     * åˆä½œå•†å¯¼å‡º
     */
    @Operation(summary = "合作商导出")
    @Log(title = "合作商档案", businessType = BusinessType.EXPORT)
    @PostMapping("/export")
    public void export(HttpServletResponse response, OutsourcingPartnerDto dto) {
        outsourcingPartnerService.partnerExport(response, dto);
    }
    /**
     * ä¸‹è½½å¯¼å…¥æ¨¡æ¿
     */
    @Operation(summary = "下载合作商导入模板")
    @Log(title = "合作商档案-下载模板", businessType = BusinessType.EXPORT)
    @PostMapping("/downloadTemplate")
    public void downloadTemplate(HttpServletResponse response) {
        ExcelUtil<OutsourcingPartnerExcelDto> util = new ExcelUtil<>(OutsourcingPartnerExcelDto.class);
        util.importTemplateExcel(response, "合作商档案模板");
    }
    /**
     * åˆä½œå•†å¯¼å…¥
     */
    @Operation(summary = "合作商导入")
    @Log(title = "合作商档案导入", businessType = BusinessType.IMPORT)
    @PostMapping("/import")
    public AjaxResult importData(MultipartFile file) {
        return outsourcingPartnerService.importData(file)
                ? AjaxResult.success("导入成功")
                : AjaxResult.error("导入失败");
    }
    /**
     * åˆä½œå•†ä¸‹æ‹‰é€‰é¡¹
     */
    @Operation(summary = "合作商下拉选项")
    @GetMapping("/getOptions")
    public AjaxResult getOptions() {
        return AjaxResult.success(outsourcingPartnerService.options());
    }
}
src/main/java/com/ruoyi/outsourcing/controller/OutsourcingPartnerReportController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
package com.ruoyi.outsourcing.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.framework.web.domain.AjaxResult;
import com.ruoyi.outsourcing.service.IOutsourcingPartnerService;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsDetailsVo;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
 * åˆä½œå•†å¾€æ¥æŠ¥è¡¨ Controller
 *
 * <p>应付口径:委外订单金额(status in (1,2))− å·²ä»˜æ¬¾ï¼ˆpayment_record party_type=3 and direction=1)。
 * è‰ç¨¿ä¸Žå·²ä½œåºŸè®¢å•不计入应付。</p>
 */
@RestController
@RequestMapping("/outsourcing/report")
@AllArgsConstructor
@Tag(name = "合作商往来")
public class OutsourcingPartnerReportController {
    private final IOutsourcingPartnerService outsourcingPartnerService;
    /**
     * åˆä½œå•†å¾€æ¥æ±‡æ€»åˆ—表
     */
    @Operation(summary = "合作商往来汇总")
    @GetMapping("/partnerTransactions")
    public AjaxResult partnerTransactions(Page page,
                                         @RequestParam(required = false) String partnerName) {
        IPage<OutsourcingPartnerTransactionsVo> result =
                outsourcingPartnerService.partnerTransactions(page, partnerName);
        return AjaxResult.success(result);
    }
    /**
     * åˆä½œå•†å¾€æ¥æ˜Žç»†ï¼ˆé€å§”外订单)
     */
    @Operation(summary = "合作商往来明细")
    @GetMapping("/partnerTransactionsDetails")
    public AjaxResult partnerTransactionsDetails(Page page, @RequestParam Long partnerId) {
        IPage<OutsourcingPartnerTransactionsDetailsVo> result =
                outsourcingPartnerService.partnerTransactionsDetails(page, partnerId);
        return AjaxResult.success(result);
    }
}
src/main/java/com/ruoyi/outsourcing/dto/OutsourcingOrderDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package com.ruoyi.outsourcing.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
 * å§”外订单 DTO
 */
@Data
public class OutsourcingOrderDto {
    @Schema(description = "订单主键")
    private Long id;
    @Schema(description = "委外订单号")
    private String orderNo;
    @Schema(description = "合作商id")
    private Long partnerId;
    @Schema(description = "合作商名称")
    private String partnerName;
    @Schema(description = "申请日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate applyDate;
    @Schema(description = "期望交付日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate expectedDeliveryDate;
    @Schema(description = "项目名称")
    private String projectName;
    @Schema(description = "委外金额(含税总价)")
    private BigDecimal contractAmount;
    @Schema(description = "付款方式")
    private String paymentMethod;
    @Schema(description = "备注")
    private String remarks;
    @Schema(description = "状态 0草稿 1已确认 2已完成 3已作废")
    private Integer status;
    @Schema(description = "作废原因")
    private String cancelReason;
    @Schema(description = "申请日期查询开始(yyyy-MM-dd)")
    private String startDate;
    @Schema(description = "申请日期查询结束(yyyy-MM-dd)")
    private String endDate;
    @Schema(description = "委外产品明细")
    private List<OutsourcingOrderProduct> productData;
}
src/main/java/com/ruoyi/outsourcing/dto/OutsourcingPartnerDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package com.ruoyi.outsourcing.dto;
import com.baomidou.mybatisplus.annotation.TableField;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.outsourcing.pojo.OutsourcingPartner;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
 * å§”外合作商档案 DTO(查询条件 + æ–°å¢ž/修改入参)
 */
@Data
public class OutsourcingPartnerDto extends OutsourcingPartner {
    @Schema(description = "维护人员名称")
    private String maintainUserName;
    @TableField(exist = false)
    @Schema(description = "附件列表")
    private List<StorageBlobDTO> storageBlobDTOS;
}
src/main/java/com/ruoyi/outsourcing/enums/OutsourcingOrderStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package com.ruoyi.outsourcing.enums;
import com.ruoyi.common.enums.BaseEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * å§”外订单状态枚举
 */
@Getter
@AllArgsConstructor
public enum OutsourcingOrderStatusEnum implements BaseEnum<Integer> {
    DRAFT(0, "草稿"),
    CONFIRMED(1, "已确认"),
    COMPLETED(2, "已完成"),
    CANCELLED(3, "已作废");
    private final Integer value;
    private final String label;
    @Override
    public Integer getCode() {
        return value;
    }
    @Override
    public String getValue() {
        return label;
    }
    public static String getLabelByValue(Integer value) {
        if (value == null) {
            return "未知状态";
        }
        for (OutsourcingOrderStatusEnum status : values()) {
            if (status.getCode().equals(value)) {
                return status.getValue();
            }
        }
        return "未知状态";
    }
}
src/main/java/com/ruoyi/outsourcing/excel/OutsourcingOrderExcelDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package com.ruoyi.outsourcing.excel;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
 * å§”外订单导出对象
 */
@Data
public class OutsourcingOrderExcelDto {
    @Excel(name = "委外订单号")
    private String orderNo;
    @Excel(name = "合作商名称")
    private String partnerName;
    @Excel(name = "申请日期", dateFormat = "yyyy-MM-dd")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate applyDate;
    @Excel(name = "期望交付日期", dateFormat = "yyyy-MM-dd")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate expectedDeliveryDate;
    @Excel(name = "项目名称")
    private String projectName;
    @Excel(name = "委外金额")
    private BigDecimal contractAmount;
    @Excel(name = "付款方式")
    private String paymentMethod;
    @Excel(name = "状态")
    private String statusLabel;
    @Excel(name = "备注")
    private String remarks;
}
src/main/java/com/ruoyi/outsourcing/excel/OutsourcingPartnerExcelDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package com.ruoyi.outsourcing.excel;
import com.ruoyi.framework.aspectj.lang.annotation.Excel;
import lombok.Data;
/**
 * å§”外合作商档案导入导出对象
 */
@Data
public class OutsourcingPartnerExcelDto {
    @Excel(name = "合作商名称")
    private String partnerName;
    @Excel(name = "纳税人识别号")
    private String taxpayerIdentificationNum;
    @Excel(name = "公司地址")
    private String companyAddress;
    @Excel(name = "公司电话")
    private String companyPhone;
    @Excel(name = "开户行")
    private String bankAccountName;
    @Excel(name = "账号")
    private String bankAccountNum;
    @Excel(name = "联系人")
    private String contactUserName;
    @Excel(name = "联系电话")
    private String contactUserPhone;
    @Excel(name = "合作商类型")
    private String partnerType;
    @Excel(name = "维护人")
    private String maintainUserName;
}
src/main/java/com/ruoyi/outsourcing/mapper/OutsourcingOrderMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.ruoyi.outsourcing.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import org.apache.ibatis.annotations.Mapper;
/**
 * å§”外订单 Mapper
 */
@Mapper
public interface OutsourcingOrderMapper extends BaseMapper<OutsourcingOrder> {
}
src/main/java/com/ruoyi/outsourcing/mapper/OutsourcingOrderProductMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package com.ruoyi.outsourcing.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import org.apache.ibatis.annotations.Mapper;
/**
 * å§”外订单产品明细 Mapper
 */
@Mapper
public interface OutsourcingOrderProductMapper extends BaseMapper<OutsourcingOrderProduct> {
}
src/main/java/com/ruoyi/outsourcing/mapper/OutsourcingPartnerMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package com.ruoyi.outsourcing.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.outsourcing.dto.OutsourcingPartnerDto;
import com.ruoyi.outsourcing.excel.OutsourcingPartnerExcelDto;
import com.ruoyi.outsourcing.pojo.OutsourcingPartner;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsDetailsVo;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
 * å§”外合作商档案 Mapper
 */
@Mapper
public interface OutsourcingPartnerMapper extends BaseMapper<OutsourcingPartner> {
    IPage<OutsourcingPartnerDto> partnerListPage(Page page, @Param("dto") OutsourcingPartnerDto dto);
    List<OutsourcingPartnerExcelDto> partnerExportList(@Param("dto") OutsourcingPartnerDto dto);
    IPage<OutsourcingPartnerTransactionsVo> partnerTransactions(Page page, @Param("partnerName") String partnerName);
    IPage<OutsourcingPartnerTransactionsDetailsVo> partnerTransactionsDetails(Page page, @Param("partnerId") Long partnerId);
}
src/main/java/com/ruoyi/outsourcing/pojo/OutsourcingOrder.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
package com.ruoyi.outsourcing.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
 * å§”外订单对象 outsourcing_order
 */
@Getter
@Setter
@TableName("outsourcing_order")
@Schema(name = "OutsourcingOrder", description = "委外订单")
public class OutsourcingOrder implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    @Schema(description = "主键id")
    private Long id;
    @Schema(description = "委外订单号")
    private String orderNo;
    @Schema(description = "合作商id")
    private Long partnerId;
    @Schema(description = "合作商名称")
    private String partnerName;
    @Schema(description = "申请日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate applyDate;
    @Schema(description = "期望交付日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate expectedDeliveryDate;
    @Schema(description = "项目名称")
    private String projectName;
    @Schema(description = "委外金额(含税总价)")
    private BigDecimal contractAmount;
    @Schema(description = "付款方式")
    private String paymentMethod;
    @Schema(description = "备注")
    private String remarks;
    @Schema(description = "状态 0草稿 1已确认 2已完成 3已作废")
    private Integer status;
    @Schema(description = "确认时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime confirmTime;
    @Schema(description = "作废原因")
    private String cancelReason;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
    @TableField(exist = false)
    @Schema(description = "委外产品明细")
    private List<OutsourcingOrderProduct> productData;
}
src/main/java/com/ruoyi/outsourcing/pojo/OutsourcingOrderProduct.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
package com.ruoyi.outsourcing.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å§”外订单产品明细对象 outsourcing_order_product
 */
@Getter
@Setter
@TableName("outsourcing_order_product")
@Schema(name = "OutsourcingOrderProduct", description = "委外订单产品明细")
public class OutsourcingOrderProduct implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    @Schema(description = "主键id")
    private Long id;
    @Schema(description = "委外订单id")
    private Long outsourcingOrderId;
    @Schema(description = "产品id")
    private Long productId;
    @Schema(description = "产品规格id")
    private Long productModelId;
    @Schema(description = "产品大类")
    private String productCategory;
    @Schema(description = "规格型号")
    private String specificationModel;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "数量")
    private BigDecimal quantity;
    @Schema(description = "税率")
    private BigDecimal taxRate;
    @Schema(description = "含税单价")
    private BigDecimal taxInclusiveUnitPrice;
    @Schema(description = "含税总价")
    private BigDecimal taxInclusiveTotalPrice;
    @Schema(description = "不含税总价")
    private BigDecimal taxExclusiveTotalPrice;
    @Schema(description = "发票类型")
    private String invoiceType;
    @Schema(description = "是否报废品选料 0否 1是")
    private Boolean isScrap;
    @Schema(description = "选用的报废品合格库存id(stock_inventory.id,只读引用,不回写库存)")
    private Long stockInventoryId;
    @Schema(description = "报废品批号")
    private String batchNo;
    @Schema(description = "下单时该批次可用量快照")
    private BigDecimal availableQuantity;
    @Schema(description = "备注")
    private String remark;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
}
src/main/java/com/ruoyi/outsourcing/pojo/OutsourcingPartner.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
package com.ruoyi.outsourcing.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * å§”外合作商档案对象 outsourcing_partner
 */
@Data
@TableName("outsourcing_partner")
@Schema(name = "OutsourcingPartner", description = "委外合作商档案")
public class OutsourcingPartner implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    @Schema(description = "主键id")
    private Long id;
    @Schema(description = "合作商名称")
    private String partnerName;
    @Schema(description = "纳税人识别号")
    private String taxpayerIdentificationNum;
    @Schema(description = "公司地址")
    private String companyAddress;
    @Schema(description = "公司电话")
    private String companyPhone;
    @Schema(description = "开户行名称")
    private String bankAccountName;
    @Schema(description = "银行账号")
    private String bankAccountNum;
    @Schema(description = "联系人姓名")
    private String contactUserName;
    @Schema(description = "联系人电话")
    private String contactUserPhone;
    @Schema(description = "维护人id")
    private Long maintainUserId;
    @Schema(description = "维护时间")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate maintainTime;
    @Schema(description = "合作商类型")
    private String partnerType;
    @Schema(description = "是否白名单 1是 0否")
    private Integer isWhite;
    @Schema(description = "备注")
    private String remark;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
}
src/main/java/com/ruoyi/outsourcing/service/IOutsourcingOrderService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
package com.ruoyi.outsourcing.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.outsourcing.dto.OutsourcingOrderDto;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
/**
 * å§”外订单 Service
 */
public interface IOutsourcingOrderService extends IService<OutsourcingOrder> {
    /**
     * å§”外订单列表(配合 startPage åˆ†é¡µï¼‰
     */
    List<OutsourcingOrder> selectOrderList(OutsourcingOrderDto dto);
    /**
     * å§”外订单详情(含产品明细)
     */
    OutsourcingOrderDto getDetail(Long id);
    /**
     * æ–°å¢žå§”外订单(草稿)
     */
    Long saveOrder(OutsourcingOrderDto dto);
    /**
     * ä¿®æ”¹å§”外订单,仅草稿状态允许
     */
    boolean updateOrder(OutsourcingOrderDto dto);
    /**
     * åˆ é™¤å§”外订单,仅草稿状态允许
     */
    boolean deleteOrders(List<Long> ids);
    /**
     * æäº¤ï¼šè‰ç¨¿ -> å·²ç¡®è®¤
     */
    boolean submit(Long id);
    /**
     * å®Œæˆï¼šå·²ç¡®è®¤ -> å·²å®Œæˆ
     */
    boolean complete(Long id);
    /**
     * ä½œåºŸï¼šè‰ç¨¿/已确认 -> å·²ä½œåºŸ
     */
    boolean cancel(Long id, String cancelReason);
    /**
     * å§”外订单导出
     */
    void exportOrders(HttpServletResponse response, OutsourcingOrderDto dto);
}
src/main/java/com/ruoyi/outsourcing/service/IOutsourcingPartnerService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
package com.ruoyi.outsourcing.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.outsourcing.dto.OutsourcingPartnerDto;
import com.ruoyi.outsourcing.pojo.OutsourcingPartner;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsDetailsVo;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsVo;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerVo;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
 * å§”外合作商档案 Service
 */
public interface IOutsourcingPartnerService extends IService<OutsourcingPartner> {
    /**
     * æ–°å¢žåˆä½œå•†ï¼ˆå«é™„件绑定)
     */
    Long savePartner(OutsourcingPartnerDto dto);
    /**
     * ä¿®æ”¹åˆä½œå•†ï¼ˆå«é™„件绑定)
     */
    boolean updatePartner(OutsourcingPartnerDto dto);
    /**
     * åˆ é™¤åˆä½œå•†ï¼ˆå…ˆæ ¡éªŒå¼•用,再清理附件)
     */
    boolean delPartner(List<Long> ids);
    /**
     * åˆä½œå•†è¯¦æƒ…(含附件回显)
     */
    OutsourcingPartnerVo partnerDetail(Long id);
    /**
     * åˆä½œå•†åˆ†é¡µæŸ¥è¯¢
     */
    IPage<OutsourcingPartnerDto> partnerListPage(Page page, OutsourcingPartnerDto dto);
    /**
     * åˆä½œå•†å¯¼å‡º
     */
    void partnerExport(HttpServletResponse response, OutsourcingPartnerDto dto);
    /**
     * åˆä½œå•†å¯¼å…¥
     */
    Boolean importData(MultipartFile file);
    /**
     * åˆä½œå•†ä¸‹æ‹‰é€‰é¡¹
     */
    List<OutsourcingPartner> options();
    /**
     * åˆä½œå•†å¾€æ¥æ±‡æ€»
     */
    IPage<OutsourcingPartnerTransactionsVo> partnerTransactions(Page page, String partnerName);
    /**
     * åˆä½œå•†å¾€æ¥æ˜Žç»†
     */
    IPage<OutsourcingPartnerTransactionsDetailsVo> partnerTransactionsDetails(Page page, Long partnerId);
}
src/main/java/com/ruoyi/outsourcing/service/impl/OutsourcingOrderServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,427 @@
package com.ruoyi.outsourcing.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.pojo.Product;
import com.ruoyi.basic.pojo.ProductModel;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.OrderUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.outsourcing.dto.OutsourcingOrderDto;
import com.ruoyi.outsourcing.enums.OutsourcingOrderStatusEnum;
import com.ruoyi.outsourcing.excel.OutsourcingOrderExcelDto;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderMapper;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderProductMapper;
import com.ruoyi.outsourcing.mapper.OutsourcingPartnerMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import com.ruoyi.outsourcing.pojo.OutsourcingPartner;
import com.ruoyi.outsourcing.service.IOutsourcingOrderService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.pojo.StockOutRecord;
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockOutRecordService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
 * å§”外订单 Service å®žçް
 */
@Service
@RequiredArgsConstructor
public class OutsourcingOrderServiceImpl extends ServiceImpl<OutsourcingOrderMapper, OutsourcingOrder>
        implements IOutsourcingOrderService {
    private static final String ORDER_NO_PREFIX = "WWDD";
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    private final OutsourcingOrderProductMapper outsourcingOrderProductMapper;
    private final OutsourcingPartnerMapper outsourcingPartnerMapper;
    private final ProductMapper productMapper;
    private final ProductModelMapper productModelMapper;
    private final StockInventoryService stockInventoryService;
    private final StockOutRecordService stockOutRecordService;
    @Override
    public List<OutsourcingOrder> selectOrderList(OutsourcingOrderDto dto) {
        LambdaQueryWrapper<OutsourcingOrder> wrapper = new LambdaQueryWrapper<>();
        wrapper.like(StringUtils.isNotBlank(dto.getOrderNo()), OutsourcingOrder::getOrderNo, dto.getOrderNo())
                .eq(dto.getPartnerId() != null, OutsourcingOrder::getPartnerId, dto.getPartnerId())
                .like(StringUtils.isNotBlank(dto.getPartnerName()), OutsourcingOrder::getPartnerName, dto.getPartnerName())
                .eq(dto.getStatus() != null, OutsourcingOrder::getStatus, dto.getStatus())
                .like(StringUtils.isNotBlank(dto.getProjectName()), OutsourcingOrder::getProjectName, dto.getProjectName())
                .ge(StringUtils.isNotBlank(dto.getStartDate()), OutsourcingOrder::getApplyDate, dto.getStartDate())
                .le(StringUtils.isNotBlank(dto.getEndDate()), OutsourcingOrder::getApplyDate, dto.getEndDate())
                .orderByDesc(OutsourcingOrder::getId);
        return this.list(wrapper);
    }
    @Override
    public OutsourcingOrderDto getDetail(Long id) {
        OutsourcingOrder order = outsourcingOrderMapper.selectById(id);
        if (order == null) {
            throw new ServiceException("委外订单不存在");
        }
        OutsourcingOrderDto dto = new OutsourcingOrderDto();
        BeanUtils.copyProperties(order, dto);
        dto.setProductData(listProducts(id));
        return dto;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long saveOrder(OutsourcingOrderDto dto) {
        OutsourcingOrder order = new OutsourcingOrder();
        BeanUtils.copyProperties(dto, order);
        order.setId(null);
        order.setStatus(OutsourcingOrderStatusEnum.DRAFT.getCode());
        order.setOrderNo(OrderUtils.countTodayByCreateTime(
                outsourcingOrderMapper, ORDER_NO_PREFIX, "order_no", LocalDateTime.now()));
        order.setConfirmTime(null);
        order.setCancelReason(null);
        fillPartnerName(order);
        if (order.getApplyDate() == null) {
            order.setApplyDate(LocalDate.now());
        }
        order.setContractAmount(sumProducts(dto.getProductData()));
        this.save(order);
        saveProducts(order.getId(), dto.getProductData());
        return order.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean updateOrder(OutsourcingOrderDto dto) {
        if (dto.getId() == null) {
            throw new ServiceException("委外订单id不能为空");
        }
        OutsourcingOrder existing = outsourcingOrderMapper.selectById(dto.getId());
        if (existing == null) {
            throw new ServiceException("委外订单不存在");
        }
        if (!OutsourcingOrderStatusEnum.DRAFT.getCode().equals(existing.getStatus())) {
            throw new ServiceException("仅草稿状态的委外订单允许修改");
        }
        OutsourcingOrder order = new OutsourcingOrder();
        BeanUtils.copyProperties(dto, order);
        // å•号、状态、确认信息由系统维护,置空后 MyBatis-Plus ä¸ä¼šæ›´æ–°è¿™äº›åˆ—
        order.setOrderNo(null);
        order.setStatus(null);
        order.setConfirmTime(null);
        order.setCancelReason(null);
        order.setTenantId(null);
        order.setDeptId(null);
        order.setCreateUser(null);
        order.setCreateTime(null);
        order.setUpdateTime(null);
        fillPartnerName(order);
        order.setContractAmount(sumProducts(dto.getProductData()));
        boolean updated = this.updateById(order);
        if (updated) {
            outsourcingOrderProductMapper.delete(Wrappers.<OutsourcingOrderProduct>lambdaQuery()
                    .eq(OutsourcingOrderProduct::getOutsourcingOrderId, dto.getId()));
            saveProducts(dto.getId(), dto.getProductData());
        }
        return updated;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean deleteOrders(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("请选择要删除的委外订单");
        }
        List<String> notDraftNos = this.listByIds(ids).stream()
                .filter(order -> !OutsourcingOrderStatusEnum.DRAFT.getCode().equals(order.getStatus()))
                .map(OutsourcingOrder::getOrderNo)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if (!notDraftNos.isEmpty()) {
            throw new ServiceException("仅草稿状态的委外订单允许删除:" + String.join("、", notDraftNos));
        }
        outsourcingOrderProductMapper.delete(Wrappers.<OutsourcingOrderProduct>lambdaQuery()
                .in(OutsourcingOrderProduct::getOutsourcingOrderId, ids));
        return this.removeByIds(ids);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean submit(Long id) {
        OutsourcingOrder order = requireOrder(id);
        if (!OutsourcingOrderStatusEnum.DRAFT.getCode().equals(order.getStatus())) {
            throw new ServiceException("仅草稿状态的委外订单允许提交");
        }
        if (order.getPartnerId() == null
                || outsourcingPartnerMapper.selectById(order.getPartnerId()) == null) {
            throw new ServiceException("请先选择有效合作商再提交");
        }
        List<OutsourcingOrderProduct> products = listProducts(id);
        if (products.isEmpty()) {
            throw new ServiceException("委外订单没有产品明细,无法提交");
        }
        if (order.getContractAmount() == null
                || order.getContractAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("委外订单金额必须大于 0 æ‰èƒ½æäº¤");
        }
        // æ¡ä»¶æ›´æ–°æŠ¢å è‰ç¨¿çŠ¶æ€ï¼Œé¿å…å¹¶å‘ä¸‹é‡å¤æäº¤
        boolean submitted = this.update(Wrappers.<OutsourcingOrder>lambdaUpdate()
                .eq(OutsourcingOrder::getId, id)
                .eq(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.DRAFT.getCode())
                .set(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.CONFIRMED.getCode())
                .set(OutsourcingOrder::getConfirmTime, LocalDateTime.now()));
        if (!submitted) {
            throw new ServiceException("该委外订单已提交,请勿重复操作");
        }
        occupyScrapStock(products);
        return true;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean complete(Long id) {
        OutsourcingOrder order = requireOrder(id);
        if (!OutsourcingOrderStatusEnum.CONFIRMED.getCode().equals(order.getStatus())) {
            throw new ServiceException("仅已确认状态的委外订单允许完成");
        }
        requireScrapStockOutApproved(order.getId());
        boolean completed = this.update(Wrappers.<OutsourcingOrder>lambdaUpdate()
                .eq(OutsourcingOrder::getId, id)
                .eq(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.CONFIRMED.getCode())
                .set(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.COMPLETED.getCode()));
        if (!completed) {
            throw new ServiceException("仅已确认状态的委外订单允许完成");
        }
        return true;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean cancel(Long id, String cancelReason) {
        OutsourcingOrder order = requireOrder(id);
        Integer status = order.getStatus();
        if (!OutsourcingOrderStatusEnum.DRAFT.getCode().equals(status)
                && !OutsourcingOrderStatusEnum.CONFIRMED.getCode().equals(status)) {
            throw new ServiceException("该委外订单已完成或已作废,不允许重复作废");
        }
        boolean cancelled = this.update(Wrappers.<OutsourcingOrder>lambdaUpdate()
                .eq(OutsourcingOrder::getId, id)
                .in(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.DRAFT.getCode(),
                        OutsourcingOrderStatusEnum.CONFIRMED.getCode())
                .set(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.CANCELLED.getCode())
                .set(OutsourcingOrder::getCancelReason, cancelReason));
        if (!cancelled) {
            throw new ServiceException("该委外订单已完成或已作废,不允许重复作废");
        }
        if (OutsourcingOrderStatusEnum.CONFIRMED.getCode().equals(status)) {
            // æäº¤æ—¶å ç”¨çš„æŠ¥åºŸå“åº“存要释放:未审批的记录删掉即释放,已审批通过的按批次归还库存
            stockOutRecordService.releaseByRecordIds(scrapLineIds(id),
                    StockOutQualifiedRecordTypeEnum.OUTSOURCING_SCRAP_OUT.getCode());
        }
        return true;
    }
    @Override
    public void exportOrders(HttpServletResponse response, OutsourcingOrderDto dto) {
        List<OutsourcingOrder> orders = selectOrderList(dto);
        List<OutsourcingOrderExcelDto> excelList = orders.stream().map(order -> {
            OutsourcingOrderExcelDto excel = new OutsourcingOrderExcelDto();
            BeanUtils.copyProperties(order, excel);
            excel.setStatusLabel(OutsourcingOrderStatusEnum.getLabelByValue(order.getStatus()));
            return excel;
        }).collect(Collectors.toList());
        ExcelUtil<OutsourcingOrderExcelDto> util = new ExcelUtil<>(OutsourcingOrderExcelDto.class);
        util.exportExcel(response, excelList, "委外订单");
    }
    private OutsourcingOrder requireOrder(Long id) {
        if (id == null) {
            throw new ServiceException("委外订单id不能为空");
        }
        OutsourcingOrder order = outsourcingOrderMapper.selectById(id);
        if (order == null) {
            throw new ServiceException("委外订单不存在");
        }
        return order;
    }
    /**
     * æŠ¥åºŸå“é€‰æ–™è¡Œçš„ id,作为出库记录的 record_id。
     * å‡ºåº“记录一条对应一行明细(一个规格+批号),与采购退货出库的记法一致
     */
    private List<Long> scrapLineIds(Long orderId) {
        return listProducts(orderId).stream()
                .filter(product -> Boolean.TRUE.equals(product.getIsScrap()))
                .map(OutsourcingOrderProduct::getId)
                .collect(Collectors.toList());
    }
    /**
     * æŠ¥åºŸå“é€‰æ–™å ç”¨åº“存:每条选料行写一条待审批出库记录。
     *
     * <p>报废品取的是<b>合格库存</b>(stock_inventory,出库记录 type=0)。
     * è®°å½•一落库就通过 stock_out_record çš„待审批量把可用量占住
     * ï¼ˆå¯ç”¨é‡ = qualitity âˆ’ locked_quantity âˆ’ å¾…审批出库量),因此同一批次不会被多张委外订单超选,
     * ä½† qualitity æœ¬èº«è¦ç­‰å‡ºåº“管理审批通过才真正扣减 â€”— ä¹Ÿå°±æ˜¯ã€Œæäº¤å³å ç”¨ã€å®¡æ‰¹æ‰å®žæ‰£ã€ã€‚</p>
     */
    private void occupyScrapStock(List<OutsourcingOrderProduct> products) {
        for (OutsourcingOrderProduct product : products) {
            if (!Boolean.TRUE.equals(product.getIsScrap())) {
                continue;
            }
            if (product.getProductModelId() == null || StringUtils.isEmpty(product.getBatchNo())) {
                throw new ServiceException("报废品选料行缺少产品规格或批号,无法提交");
            }
            BigDecimal quantity = product.getQuantity();
            if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
                throw new ServiceException("报废品选料行数量必须大于 0");
            }
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setRecordId(product.getId());
            stockInventoryDto.setRecordType(StockOutQualifiedRecordTypeEnum.OUTSOURCING_SCRAP_OUT.getCode());
            stockInventoryDto.setProductModelId(product.getProductModelId());
            stockInventoryDto.setBatchNo(product.getBatchNo());
            stockInventoryDto.setQualitity(quantity);
            try {
                stockInventoryService.addStockOutRecordOnly(stockInventoryDto);
            } catch (RuntimeException e) {
                // åˆæ ¼åº“存不足抛的是 ServiceException,不合格那种 BaseException ä¹Ÿä¸€å¹¶å…œä½
                throw new ServiceException("报废品「" + product.getSpecificationModel() + "」批号「"
                        + product.getBatchNo() + "」" + e.getMessage());
            }
        }
    }
    /**
     * æŠ¥åºŸå“é€‰æ–™çš„出库记录必须全部审批通过才允许完成订单,
     * å¦åˆ™ä¼šå‡ºçŽ°ã€Œå§”å¤–å·²å®Œæˆã€åº“å­˜æ²¡æ‰£ã€çš„è´¦å®žä¸ç¬¦
     */
    private void requireScrapStockOutApproved(Long orderId) {
        List<Long> lineIds = scrapLineIds(orderId);
        if (lineIds.isEmpty()) {
            return;
        }
        List<StockOutRecord> records = stockOutRecordService.listByRecordIds(
                lineIds, StockOutQualifiedRecordTypeEnum.OUTSOURCING_SCRAP_OUT.getCode());
        if (records.size() < lineIds.size()) {
            throw new ServiceException("报废品选料缺少出库记录,请先作废后重新提交");
        }
        List<String> pendingBatches = records.stream()
                .filter(record -> !ReviewStatusEnum.APPROVED.getCode().equals(record.getApprovalStatus()))
                .map(StockOutRecord::getOutboundBatches)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        if (!pendingBatches.isEmpty()) {
            throw new ServiceException("报废品出库未审批通过,请先到出库管理审批出库批次:"
                    + String.join("、", pendingBatches));
        }
    }
    private List<OutsourcingOrderProduct> listProducts(Long orderId) {
        return outsourcingOrderProductMapper.selectList(Wrappers.<OutsourcingOrderProduct>lambdaQuery()
                .eq(OutsourcingOrderProduct::getOutsourcingOrderId, orderId)
                .orderByAsc(OutsourcingOrderProduct::getId));
    }
    private void saveProducts(Long orderId, List<OutsourcingOrderProduct> products) {
        if (products == null || products.isEmpty()) {
            return;
        }
        fillProductNames(products);
        for (OutsourcingOrderProduct product : products) {
            product.setId(null);
            product.setOutsourcingOrderId(orderId);
            product.setTenantId(null);
            product.setDeptId(null);
            product.setCreateUser(null);
            product.setCreateTime(null);
            product.setUpdateTime(null);
            outsourcingOrderProductMapper.insert(product);
        }
    }
    /**
     * å§”外金额以明细含税总价合计为准,避免前端传入金额与明细对不上导致往来应付错乱
     */
    private BigDecimal sumProducts(List<OutsourcingOrderProduct> products) {
        if (products == null || products.isEmpty()) {
            return BigDecimal.ZERO;
        }
        return products.stream()
                .map(OutsourcingOrderProduct::getTaxInclusiveTotalPrice)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    /**
     * è¡¥å…¨äº§å“å¤§ç±»ä¸Žè§„格型号,便于订单直接展示产品名称
     */
    private void fillProductNames(List<OutsourcingOrderProduct> products) {
        Set<Long> productIds = products.stream()
                .map(OutsourcingOrderProduct::getProductId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Set<Long> modelIds = products.stream()
                .map(OutsourcingOrderProduct::getProductModelId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, String> productMap = new HashMap<>();
        if (!productIds.isEmpty()) {
            for (Product product : productMapper.selectBatchIds(productIds)) {
                productMap.put(product.getId(), product.getProductName());
            }
        }
        Map<Long, String> modelMap = new HashMap<>();
        if (!modelIds.isEmpty()) {
            for (ProductModel model : productModelMapper.selectBatchIds(modelIds)) {
                modelMap.put(model.getId(), model.getModel());
            }
        }
        for (OutsourcingOrderProduct product : products) {
            String productName = productMap.get(product.getProductId());
            if (productName != null) {
                product.setProductCategory(productName);
            }
            String model = modelMap.get(product.getProductModelId());
            if (model != null) {
                product.setSpecificationModel(model);
            }
        }
    }
    private void fillPartnerName(OutsourcingOrder order) {
        if (order.getPartnerId() == null) {
            return;
        }
        OutsourcingPartner partner = outsourcingPartnerMapper.selectById(order.getPartnerId());
        if (partner == null) {
            throw new ServiceException("合作商不存在,请重新选择");
        }
        order.setPartnerName(partner.getPartnerName());
    }
}
src/main/java/com/ruoyi/outsourcing/service/impl/OutsourcingPartnerServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,211 @@
package com.ruoyi.outsourcing.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.mapper.PaymentRecordMapper;
import com.ruoyi.account.pojo.PaymentRecord;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.outsourcing.dto.OutsourcingPartnerDto;
import com.ruoyi.outsourcing.excel.OutsourcingPartnerExcelDto;
import com.ruoyi.outsourcing.mapper.OutsourcingPartnerMapper;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingPartner;
import com.ruoyi.outsourcing.service.IOutsourcingPartnerService;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsDetailsVo;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsVo;
import com.ruoyi.outsourcing.vo.OutsourcingPartnerVo;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
/**
 * å§”外合作商档案 Service å®žçް
 */
@Service
@RequiredArgsConstructor
public class OutsourcingPartnerServiceImpl extends ServiceImpl<OutsourcingPartnerMapper, OutsourcingPartner>
        implements IOutsourcingPartnerService {
    private final OutsourcingPartnerMapper partnerMapper;
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    private final PaymentRecordMapper paymentRecordMapper;
    private final FileUtil fileUtil;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long savePartner(OutsourcingPartnerDto dto) {
        if (dto.getPartnerName() == null || dto.getPartnerName().trim().isEmpty()) {
            throw new ServiceException("请填写合作商名称");
        }
        checkPartnerNameDuplicate(dto.getPartnerName(), null);
        OutsourcingPartner partner = new OutsourcingPartner();
        BeanUtils.copyProperties(dto, partner);
        partner.setId(null);
        partner.setTenantId(null);
        partner.setDeptId(null);
        partner.setCreateUser(null);
        partner.setCreateTime(null);
        partner.setUpdateTime(null);
        if (partner.getMaintainTime() == null) {
            partner.setMaintainTime(LocalDate.now());
        }
        if (partner.getMaintainUserId() == null) {
            partner.setMaintainUserId(SecurityUtils.getUserId());
        }
        partnerMapper.insert(partner);
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.OUTSOURCING_PARTNER,
                partner.getId(), dto.getStorageBlobDTOS());
        return partner.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean updatePartner(OutsourcingPartnerDto dto) {
        if (dto.getId() == null) {
            throw new ServiceException("合作商id不能为空");
        }
        if (partnerMapper.selectById(dto.getId()) == null) {
            throw new ServiceException("合作商不存在");
        }
        checkPartnerNameDuplicate(dto.getPartnerName(), dto.getId());
        OutsourcingPartner partner = new OutsourcingPartner();
        BeanUtils.copyProperties(dto, partner);
        // ç§Ÿæˆ·ã€åˆ›å»ºä¿¡æ¯ç”±ç³»ç»Ÿç»´æŠ¤ï¼Œç½®ç©ºåŽ MyBatis-Plus ä¸ä¼šæ›´æ–°è¿™äº›åˆ—
        partner.setTenantId(null);
        partner.setDeptId(null);
        partner.setCreateUser(null);
        partner.setCreateTime(null);
        partner.setUpdateTime(null);
        boolean updated = partnerMapper.updateById(partner) > 0;
        if (updated) {
            fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.OUTSOURCING_PARTNER,
                    dto.getId(), dto.getStorageBlobDTOS());
        }
        return updated;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean delPartner(List<Long> ids) {
        if (CollectionUtils.isEmpty(ids)) {
            throw new ServiceException("请选择至少一条数据");
        }
        Long orderCount = outsourcingOrderMapper.selectCount(Wrappers.<OutsourcingOrder>lambdaQuery()
                .in(OutsourcingOrder::getPartnerId, ids));
        if (orderCount != null && orderCount > 0) {
            throw new ServiceException("合作商已存在委外订单,不允许删除");
        }
        Long paymentCount = paymentRecordMapper.selectCount(Wrappers.<PaymentRecord>lambdaQuery()
                .eq(PaymentRecord::getPartyType, 3)
                .in(PaymentRecord::getPartyId, ids));
        if (paymentCount != null && paymentCount > 0) {
            throw new ServiceException("合作商已存在往来收付款记录,不允许删除");
        }
        fileUtil.deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordIds(
                ApplicationTypeEnum.FILE, RecordTypeEnum.OUTSOURCING_PARTNER, ids);
        return this.removeByIds(ids);
    }
    @Override
    public OutsourcingPartnerVo partnerDetail(Long id) {
        OutsourcingPartner partner = partnerMapper.selectById(id);
        if (partner == null) {
            throw new ServiceException("合作商不存在");
        }
        OutsourcingPartnerVo vo = new OutsourcingPartnerVo();
        BeanUtils.copyProperties(partner, vo);
        vo.setStorageBlobVOS(fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(
                ApplicationTypeEnum.FILE, RecordTypeEnum.OUTSOURCING_PARTNER, id));
        return vo;
    }
    @Override
    public IPage<OutsourcingPartnerDto> partnerListPage(Page page, OutsourcingPartnerDto dto) {
        return partnerMapper.partnerListPage(page, dto);
    }
    @Override
    public void partnerExport(HttpServletResponse response, OutsourcingPartnerDto dto) {
        List<OutsourcingPartnerExcelDto> list = partnerMapper.partnerExportList(dto);
        ExcelUtil<OutsourcingPartnerExcelDto> util = new ExcelUtil<>(OutsourcingPartnerExcelDto.class);
        util.exportExcel(response, list, "合作商档案");
    }
    @Override
    public Boolean importData(MultipartFile file) {
        try {
            ExcelUtil<OutsourcingPartnerExcelDto> util = new ExcelUtil<>(OutsourcingPartnerExcelDto.class);
            List<OutsourcingPartnerExcelDto> list = util.importExcel(file.getInputStream());
            if (CollectionUtils.isEmpty(list)) {
                return false;
            }
            List<OutsourcingPartner> partners = list.stream().map(excel -> {
                OutsourcingPartner partner = new OutsourcingPartner();
                BeanUtils.copyProperties(excel, partner);
                partner.setMaintainUserId(SecurityUtils.getUserId());
                partner.setMaintainTime(LocalDate.now());
                return partner;
            }).collect(Collectors.toList());
            return this.saveOrUpdateBatch(partners);
        } catch (Exception e) {
            throw new ServiceException("合作商导入失败:" + e.getMessage());
        }
    }
    @Override
    public List<OutsourcingPartner> options() {
        return partnerMapper.selectList(new LambdaQueryWrapper<OutsourcingPartner>()
                .select(OutsourcingPartner::getId, OutsourcingPartner::getPartnerName)
                .orderByDesc(OutsourcingPartner::getId));
    }
    @Override
    public IPage<OutsourcingPartnerTransactionsVo> partnerTransactions(Page page, String partnerName) {
        return partnerMapper.partnerTransactions(page, partnerName);
    }
    @Override
    public IPage<OutsourcingPartnerTransactionsDetailsVo> partnerTransactionsDetails(Page page, Long partnerId) {
        return partnerMapper.partnerTransactionsDetails(page, partnerId);
    }
    /**
     * åˆä½œå•†åç§°åœ¨ç§Ÿæˆ·å†…唯一
     */
    private void checkPartnerNameDuplicate(String partnerName, Long excludeId) {
        if (partnerName == null || partnerName.trim().isEmpty()) {
            return;
        }
        Long tenantId = SecurityUtils.getLoginUser().getTenantId();
        LambdaQueryWrapper<OutsourcingPartner> wrapper = Wrappers.<OutsourcingPartner>lambdaQuery()
                .eq(tenantId != null, OutsourcingPartner::getTenantId, tenantId)
                .isNull(tenantId == null, OutsourcingPartner::getTenantId)
                .eq(OutsourcingPartner::getPartnerName, partnerName)
                .ne(excludeId != null, OutsourcingPartner::getId, excludeId);
        if (partnerMapper.selectCount(wrapper) > 0) {
            throw new ServiceException("合作商已存在");
        }
    }
}
src/main/java/com/ruoyi/outsourcing/vo/OutsourcingPartnerTransactionsDetailsVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package com.ruoyi.outsourcing.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
 * åˆä½œå•†å¾€æ¥æ˜Žç»†ï¼ˆæŒ‰å§”外订单逐行)
 */
@Data
@Schema(name = "OutsourcingPartnerTransactionsDetailsVo", description = "合作商往来明细")
public class OutsourcingPartnerTransactionsDetailsVo {
    @Schema(description = "委外订单id")
    private Long outsourcingOrderId;
    @Schema(description = "委外订单号")
    private String orderNo;
    @Schema(description = "合作商id")
    private Long partnerId;
    @Schema(description = "合作商名称")
    private String partnerName;
    @Schema(description = "申请日期")
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate applyDate;
    @Schema(description = "订单金额(含税总价)")
    private BigDecimal contractAmount;
    @Schema(description = "订单状态 0草稿 1已确认 2已完成 3已作废")
    private Integer status;
    @Schema(description = "已付款合计(合作商级合计,非订单级)")
    private BigDecimal paidAmount;
    @Schema(description = "应付余额 = è®¢å•金额 - å·²ä»˜æ¬¾")
    private BigDecimal payableAmount;
}
src/main/java/com/ruoyi/outsourcing/vo/OutsourcingPartnerTransactionsVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package com.ruoyi.outsourcing.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * åˆä½œå•†å¾€æ¥åˆ—表(按合作商汇总)
 */
@Data
@Schema(name = "OutsourcingPartnerTransactionsVo", description = "合作商往来汇总")
public class OutsourcingPartnerTransactionsVo {
    @Schema(description = "合作商id")
    private Long partnerId;
    @Schema(description = "合作商名称")
    private String partnerName;
    @Schema(description = "委外金额合计(已确认+已完成)")
    private BigDecimal contractAmounts;
    @Schema(description = "已付款合计")
    private BigDecimal paidAmount;
    @Schema(description = "回款合计")
    private BigDecimal receiptAmount;
    @Schema(description = "应付余额 = å§”外金额 - å·²ä»˜æ¬¾")
    private BigDecimal payableAmount;
}
src/main/java/com/ruoyi/outsourcing/vo/OutsourcingPartnerVo.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package com.ruoyi.outsourcing.vo;
import com.baomidou.mybatisplus.annotation.TableField;
import com.ruoyi.basic.dto.StorageBlobVO;
import com.ruoyi.outsourcing.pojo.OutsourcingPartner;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
 * å§”外合作商档案出参
 */
@Data
public class OutsourcingPartnerVo extends OutsourcingPartner {
    @Schema(description = "维护人员名称")
    private String maintainUserName;
    @TableField(exist = false)
    @Schema(description = "附件列表")
    private List<StorageBlobVO> storageBlobVOS;
}
src/main/java/com/ruoyi/purchase/service/impl/PurchaseLedgerServiceImpl.java
@@ -105,6 +105,11 @@
     */
    private static final Integer LEDGER_TYPE_PURCHASE = 2;
    /**
     * å¤‡ä»¶æµæ°´æ¥æºï¼šé‡‡è´­å…¥åº“。见 spare_parts_record.source_type
     */
    private static final Integer SPARE_PARTS_SOURCE_PURCHASE = 1;
    private final PurchaseLedgerMapper purchaseLedgerMapper;
    private final SalesLedgerMapper salesLedgerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
@@ -719,10 +724,13 @@
                salesLedgerProduct.setRegisterDate(localDateTime);
                salesLedgerProductMapper.insert(salesLedgerProduct);
            }
            // ä»…新增明细回写备件,编辑走 update åˆ†æ”¯å¤©ç„¶ä¸é‡å¤è®¡æ•°
            if (LEDGER_TYPE_PURCHASE.equals(ledgerType)) {
                recordSparePartsInbound(insertList, purchaseLedger);
            }
        // é‡‡è´­æ˜Žç»†å›žå†™å¤‡ä»¶ï¼šå…ˆæŒ‰å°è´¦å›žæ»šä¸Šä¸€æ¬¡çš„æµæ°´ï¼Œå†æŒ‰å½“前明细整体重建。
        // ç¼–辑时明细行可能被移除,只认新增行会漏掉移除的那些;整体重建天然与当前明细对齐。
        // æ³¨æ„è¿™æ®µå¿…须在 insertList ä¹‹å¤–,否则「只删明细、不新增」的编辑不会触发回滚
        if (LEDGER_TYPE_PURCHASE.equals(ledgerType)) {
            rollbackSparePartsInbound(salesLedgerId);
            recordSparePartsInbound(products, purchaseLedger);
        }
        // è®¡ç®—总含税金额
@@ -739,11 +747,54 @@
    }
    /**
     * é‡‡è´­å°è´¦æ–°å¢žæ˜Žç»†æŒ‰è§„格匹配备件:累加备件库存并记一条入库流水。
     * æœªç»´æŠ¤è§„格的备件不参与,静默跳过。
     * æŒ‰é‡‡è´­å°è´¦å›žæ»šå¤‡ä»¶ï¼šæŠŠè¯¥å°è´¦äº§ç”Ÿçš„入库流水逐备件减回库存,然后删除这些流水。
     *
     * <p>备件先由采购入账、后续被维修/保养领用出账,若入账量已经被领用掉,减回会出现负库存,
     * æ­¤æ—¶ç›´æŽ¥æ‹¦ä½ä¸è®©åˆ ï¼Œé¿å…æŠŠå¤‡ä»¶è´¦åšèŠ±ã€‚åˆ é™¤å°è´¦ä¸Žç¼–è¾‘é‡å»ºéƒ½èµ°è¿™é‡Œï¼Œå£å¾„ç»Ÿä¸€ã€‚</p>
     */
    private void recordSparePartsInbound(List<SalesLedgerProduct> insertList, PurchaseLedger purchaseLedger) {
        List<Long> modelIds = insertList.stream()
    private void rollbackSparePartsInbound(Long ledgerId) {
        List<SparePartsRecord> records = sparePartsRecordService.list(new LambdaQueryWrapper<SparePartsRecord>()
                .eq(SparePartsRecord::getSourceType, SPARE_PARTS_SOURCE_PURCHASE)
                .eq(SparePartsRecord::getSourceId, ledgerId));
        if (CollectionUtils.isEmpty(records)) {
            return;
        }
        Map<Long, BigDecimal> rollbackBySparePartsId = new HashMap<>();
        for (SparePartsRecord record : records) {
            BigDecimal quantity = record.getQuantity() == null ? BigDecimal.ZERO : record.getQuantity();
            rollbackBySparePartsId.merge(record.getSparePartsId(), quantity, BigDecimal::add);
        }
        for (Map.Entry<Long, BigDecimal> entry : rollbackBySparePartsId.entrySet()) {
            if (entry.getKey() == null) {
                continue;
            }
            SpareParts spareParts = sparePartsMapper.selectById(entry.getKey());
            if (spareParts == null) {
                continue;
            }
            BigDecimal current = spareParts.getQuantity() == null ? BigDecimal.ZERO : spareParts.getQuantity();
            if (current.compareTo(entry.getValue()) < 0) {
                throw new BaseException("备件「" + spareParts.getName() + "」当前库存 " + current
                        + ",不足以回滚本次采购入库的 " + entry.getValue() + ",请先处理领用记录");
            }
            spareParts.setQuantity(current.subtract(entry.getValue()));
            sparePartsMapper.updateById(spareParts);
        }
        sparePartsRecordService.removeByIds(records.stream()
                .map(SparePartsRecord::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList()));
    }
    /**
     * é‡‡è´­å°è´¦æ˜Žç»†æŒ‰è§„格匹配备件:累加备件库存并记一条入库流水。
     * æœªç»´æŠ¤è§„格的备件不参与,静默跳过。
     *
     * <p>调用前必须先 {@link #rollbackSparePartsInbound(Long)},两者成对出现:
     * å›žæ»šæ—§æµæ°´ + æŒ‰å½“前明细重建,才能保证编辑台账后备件数量与明细一致。</p>
     */
    private void recordSparePartsInbound(List<SalesLedgerProduct> products, PurchaseLedger purchaseLedger) {
        List<Long> modelIds = products.stream()
                .map(SalesLedgerProduct::getProductModelId)
                .filter(Objects::nonNull)
                .distinct()
@@ -758,7 +809,7 @@
        }
        Map<Long, SpareParts> sparePartsByModelId = sparePartsList.stream()
                .collect(Collectors.toMap(SpareParts::getProductModelId, s -> s, (a, b) -> a));
        for (SalesLedgerProduct product : insertList) {
        for (SalesLedgerProduct product : products) {
            SpareParts spareParts = sparePartsByModelId.get(product.getProductModelId());
            if (spareParts == null || product.getQuantity() == null
                    || product.getQuantity().compareTo(BigDecimal.ZERO) <= 0) {
@@ -790,6 +841,9 @@
            if (purchaseLedger.getApprovalStatus().equals(3)) {
                throw new BaseException(purchaseLedger.getPurchaseContractNumber()+"已经审批通过,不允许删除");
            }
            // å°è´¦åˆ é™¤è¦æŠŠè¯¥å°è´¦é‡‡è´­å…¥åº“时累加到备件上的数量减回去,并删掉对应流水,
            // å¦åˆ™å¤‡ä»¶åº“存虚高、流水残留。备件已领用导致减回为负时会在这里拦住
            rollbackSparePartsInbound(id);
        }
        // æ‰¹é‡åˆ é™¤å…³è”的采购入库记录
        LambdaQueryWrapper<SalesLedgerProduct> salesLedgerProductQueryWrapper = new LambdaQueryWrapper<>();
src/main/java/com/ruoyi/purchase/vo/SupplierTransactionsDetailsVo.java
@@ -27,8 +27,8 @@
    @Schema(description = "付款金额")
    private BigDecimal paymentAmount;
    @Schema(description = "回款金额")
    private BigDecimal receiptAmount;
    @Schema(description = "付款金额(往来流水)")
    private BigDecimal paymentRecordAmount;
    @Schema(description = "应付金额")
    private BigDecimal payableAmount;
src/main/java/com/ruoyi/purchase/vo/SupplierTransactionsVo.java
@@ -23,9 +23,9 @@
    //该供应商采购付款累计金额
    private BigDecimal paymentAmount;
    @Schema(description = "回款金额")
    //该供应商往来回款累计金额(payment_record æ‰‹å·¥æµæ°´ï¼‰
    private BigDecimal receiptAmount;
    @Schema(description = "付款金额(往来流水)")
    //该供应商往来付款累计金额(payment_record æ‰‹å·¥æµæ°´ï¼Œparty_type=2 direction=2)
    private BigDecimal paymentRecordAmount;
    @Schema(description = "应付金额")
    //该供应商采购应付累计金额=初始金额-付款-退款
src/main/java/com/ruoyi/sales/dto/CustomerDepositAmountDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.sales.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * å®¢æˆ·ä»£å‚¨é‡‘额汇总(用于客户档案的信托基金联动提醒)
 *
 * @author ruoyi
 */
@Data
public class CustomerDepositAmountDto {
    @Schema(description = "客户id")
    private Long customerId;
    @Schema(description = "代储金额合计(该客户全部类型=代储的销售台账合同金额之和)")
    private BigDecimal depositTotalAmount;
}
src/main/java/com/ruoyi/sales/dto/SalesLedgerDto.java
@@ -9,6 +9,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
import java.util.List;
@@ -63,4 +64,32 @@
    private List<StorageBlobDTO> storageBlobDTOs;
    private List<StorageBlobVO> StorageBlobVOs;
    /**
     * é”€å”®ç±»åž‹ï¼šå–æ²¹ / ä»£å‚¨
     * æ³¨æ„ï¼šä¸è¦å¤ç”¨ä¸Šé¢çš„ type,那个是子表行的 SaleEnum(销售/采购/项目),落 sales_ledger_product.type
     */
    @Schema(description = "销售类型:卖油/代储")
    private String ledgerType;
    @Schema(description = "油库id(类型=代储时使用)")
    private Long oilDepotId;
    @Schema(description = "油库名称")
    private String oilDepotName;
    @Schema(description = "储罐id(类型=代储时使用)")
    private Long tankId;
    @Schema(description = "储罐号")
    private String tankNo;
    @Schema(description = "审批状态:待审批/审核中/通过/拒绝")
    private String approvalStatus;
    /**
     * åˆåŒé‡‘额。卖油的金额由产品明细汇总产生;代储没有明细,由前端手工填写
     */
    @Schema(description = "合同金额")
    private BigDecimal contractAmount;
}
src/main/java/com/ruoyi/sales/dto/ShippingInfoDto.java
@@ -51,5 +51,38 @@
    private String templateName;
    /**
     * è¡Œæ¥æºï¼šshipping=发货单,oilOut=销售类油品出库。
     * å‰ç«¯å¿…须按此字段判别,oilOut è¡Œçš„ id ä¸Ž shipping_info.id åŒåŸŸä¼šæ’žå·ï¼Œä¸å¯ç”¨äºŽå‘è´§/删除/退货接口
     */
    private String source;
    /**
     * æ²¹å“å‡ºåº“记录id(仅 source=oilOut æœ‰å€¼ï¼‰
     */
    private Long stockOutRecordId;
    /**
     * å‡ºåº“侧展示字段(仅 source=oilOut æœ‰å€¼ï¼‰
     */
    private String outCategory;
    private String tankNo;
    private String oilProduct;
    private String oilDepotName;
    private String driverName;
    private String contactPerson;
    private String contactPhone;
    /**
     * æ²¹å“å‡ºåº“的批号(仅 source=oilOut æœ‰å€¼ï¼Œå–自 stock_out_record.batch_no)。
     * æ³¨æ„ä¸Ž outboundBatches åŒºåˆ†ï¼šåŽè€…是 CK å‡ºåº“单号,此字段是批次号
     */
    private String outBatchNo;
    /**
     * æ‰¹å·æ˜Žç»†ï¼ˆä»… source=oilOut æœ‰å€¼ï¼‰ï¼šä¸€æ¡æ²¹å“å‡ºåº“对应一个批号,
     * ä¾›å‘货台账详情弹框的「批号」明细表展示
     */
    private List<ShippingProductDetailDto> batchNoList;
}
src/main/java/com/ruoyi/sales/mapper/SalesLedgerMapper.java
@@ -7,6 +7,7 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.home.dto.IncomeExpenseAnalysisDto;
import com.ruoyi.purchase.vo.PurchaseReportVo;
import com.ruoyi.sales.dto.CustomerDepositAmountDto;
import com.ruoyi.sales.dto.SalesLedgerDto;
import com.ruoyi.sales.dto.SalesTrendDto;
import com.ruoyi.sales.dto.StatisticsTableDto;
@@ -90,4 +91,12 @@
    IPage<PurchaseReportVo> selectPurchaseReportVoPage(Page page, @Param("customerName") String customerName);
    /**
     * æŒ‰å®¢æˆ·æ‰¹é‡æ±‡æ€»ä»£å‚¨é‡‘额(该客户全部「类型=代储」的销售台账合同金额之和)
     *
     * @param customerIds å®¢æˆ·id集合
     * @return å®¢æˆ·id + ä»£å‚¨é‡‘额合计,无代储记录的客户不会出现在结果里
     */
    List<CustomerDepositAmountDto> selectDepositAmountByCustomerIds(@Param("customerIds") List<Long> customerIds);
}
src/main/java/com/ruoyi/sales/pojo/SalesLedger.java
@@ -143,5 +143,46 @@
    @TableField(exist = false)
    private Boolean hasProductionRecord;
    /**
     * é”€å”®ç±»åž‹ï¼šå–æ²¹ / ä»£å‚¨ï¼ˆå–存储位置)
     * æ³¨æ„ï¼šä¸è¦ä¸Žå­è¡¨è¡Œçš„ SaleEnum(type) æ··ç”¨ï¼Œé‚£æ˜¯é”€å”®/采购/项目
     */
    @Schema(description = "销售类型:卖油/代储")
    @Excel(name = "销售类型")
    private String ledgerType;
    @Schema(description = "油库id(类型=代储时使用)")
    private Long oilDepotId;
    @Schema(description = "油库名称")
    @Excel(name = "油库")
    private String oilDepotName;
    @Schema(description = "储罐id(类型=代储时使用)")
    private Long tankId;
    @Schema(description = "储罐号")
    @Excel(name = "储罐号")
    private String tankNo;
    @Schema(description = "审批状态:待审批/审核中/通过/拒绝")
    @Excel(name = "审批状态")
    private String approvalStatus;
    /**
     * å®¢æˆ·ä¿¡æ‰˜åŸºé‡‘(查询时由服务层按 customerId å›žå¡«ï¼Œä¸æ˜¯æœ¬è¡¨å­—段)
     */
    @Schema(description = "客户信托基金")
    @TableField(exist = false)
    @Excel(name = "客户信托基金")
    private BigDecimal trustFund;
    /**
     * æœ¬ç¬”合同金额是否超出客户信托基金(超出则前端标红,不是本表字段)
     */
    @Schema(description = "本笔合同金额是否超出客户信托基金")
    @TableField(exist = false)
    private Boolean exceedsTrustFund;
}
src/main/java/com/ruoyi/sales/pojo/ShippingInfo.java
@@ -91,4 +91,10 @@
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    /**
     * æ•°æ®æƒé™-部门集合,由 DataScopeAop æ³¨å…¥ï¼Œä»…ä¾› listPage çš„æŸ¥è¯¢æ¡ä»¶ä½¿ç”¨
     */
    @TableField(exist = false)
    private Long[] deptIds;
}
src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -12,15 +12,26 @@
import com.ruoyi.account.mapper.sales.AccountSalesCollectionMapper;
import com.ruoyi.account.pojo.sales.AccountInvoiceApplication;
import com.ruoyi.account.pojo.sales.AccountSalesCollection;
import com.ruoyi.approve.bean.dto.ApprovalInstanceDto;
import com.ruoyi.approve.mapper.ApprovalTemplateMapper;
import com.ruoyi.approve.pojo.ApprovalInstance;
import com.ruoyi.approve.pojo.ApprovalTemplate;
import com.ruoyi.approve.service.ApprovalInstanceService;
import com.ruoyi.basic.enums.ApplicationTypeEnum;
import com.ruoyi.basic.enums.RecordTypeEnum;
import com.ruoyi.basic.mapper.CustomerMapper;
import com.ruoyi.basic.mapper.ProductMapper;
import com.ruoyi.basic.mapper.ProductModelMapper;
import com.ruoyi.basic.mapper.OilDepotMapper;
import com.ruoyi.basic.mapper.TankInfoMapper;
import com.ruoyi.basic.pojo.Customer;
import com.ruoyi.basic.pojo.OilDepot;
import com.ruoyi.basic.pojo.TankInfo;
import com.ruoyi.basic.utils.FileUtil;
import com.ruoyi.common.enums.FileNameType;
import com.ruoyi.common.enums.SaleEnum;
import com.ruoyi.common.enums.SalesQuotationStatusEnum;
import com.ruoyi.common.enums.TypeEnums;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.EnumUtil;
@@ -89,6 +100,10 @@
    private static final String LOCK_PREFIX = "contract_no_lock:";
    private static final long LOCK_WAIT_TIMEOUT = 10; // é”ç­‰å¾…超时时间(秒)
    private static final long LOCK_EXPIRE_TIME = 30;  // é”è‡ªåŠ¨è¿‡æœŸæ—¶é—´ï¼ˆç§’ï¼‰
    /** é”€å”®ç±»åž‹ï¼šå–油(走产品明细) */
    private static final String LEDGER_TYPE_OIL = "卖油";
    /** é”€å”®ç±»åž‹ï¼šä»£å‚¨ï¼ˆå–存储位置,走油库+储罐+手工合同金额) */
    private static final String LEDGER_TYPE_DEPOSIT = "代储";
    private final SalesLedgerMapper salesLedgerMapper;
    private final CustomerMapper customerMapper;
    private final SalesLedgerProductMapper salesLedgerProductMapper;
@@ -106,6 +121,10 @@
    private final FileUtil fileUtil;
    private final AccountInvoiceApplicationMapper accountInvoiceApplicationMapper;
    private final AccountSalesCollectionMapper accountSalesCollectionMapper;
    private final ApprovalTemplateMapper approvalTemplateMapper;
    private final ApprovalInstanceService approvalInstanceService;
    private final OilDepotMapper oilDepotMapper;
    private final TankInfoMapper tankInfoMapper;
    @Autowired
    private SysDeptMapper sysDeptMapper;
@@ -123,7 +142,9 @@
    @Override
    public List<SalesLedger> selectSalesLedgerList(SalesLedgerDto salesLedgerDto) {
        return salesLedgerMapper.selectSalesLedgerList(salesLedgerDto);
        List<SalesLedger> list = salesLedgerMapper.selectSalesLedgerList(salesLedgerDto);
        fillTrustFund(list);
        return list;
    }
    public List<SalesLedgerProduct> getSalesLedgerProductListByRelateId(Long relateId, SaleEnum type) {
@@ -303,7 +324,37 @@
    @Override
    public IPage<SalesLedgerVo> selectSalesLedgerListPage(Page page, SalesLedgerDto salesLedgerDto) {
        return salesLedgerMapper.selectSalesLedgerListPage(page, salesLedgerDto);
        IPage<SalesLedgerVo> result = salesLedgerMapper.selectSalesLedgerListPage(page, salesLedgerDto);
        fillTrustFund(result.getRecords());
        return result;
    }
    /**
     * å›žå¡«å®¢æˆ·çš„信托基金,并判断本笔合同金额是否已超出。
     * è¶…出与否由后端算好返回,前端只负责标红
     */
    private void fillTrustFund(List<? extends SalesLedger> records) {
        if (CollectionUtils.isEmpty(records)) {
            return;
        }
        List<Long> customerIds = records.stream()
                .map(SalesLedger::getCustomerId)
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
        if (customerIds.isEmpty()) {
            return;
        }
        Map<Long, BigDecimal> trustFundMap = customerMapper.selectBatchIds(customerIds).stream()
                .filter(item -> item.getTrustFund() != null)
                .collect(Collectors.toMap(Customer::getId, Customer::getTrustFund, (existing, replacement) -> existing));
        records.forEach(ledger -> {
            BigDecimal trustFund = trustFundMap.get(ledger.getCustomerId());
            ledger.setTrustFund(trustFund);
            ledger.setExceedsTrustFund(trustFund != null && ledger.getContractAmount() != null
                    && ledger.getContractAmount().compareTo(trustFund) > 0);
        });
    }
    @Override
@@ -536,6 +587,19 @@
        // åˆ é™¤é™„件表
        commonFileService.deleteByBusinessIds(idList, FileNameType.SALE.getValue());
        // åˆ é™¤å°è´¦å¯¹åº”的审批实例(同一台账可能反复提交/重审,会有多条)
        List<ApprovalInstance> approvalInstances = approvalInstanceService.list(
                new LambdaQueryWrapper<ApprovalInstance>()
                        .in(ApprovalInstance::getBusinessId, idList)
                        .eq(ApprovalInstance::getBusinessType, TypeEnums.SALES_LEDGER_APPROVAL.getCode())
                        .eq(ApprovalInstance::getDeleted, 0));
        if (CollectionUtils.isNotEmpty(approvalInstances)) {
            approvalInstanceService.delete(approvalInstances.stream()
                    .map(ApprovalInstance::getId)
                    .filter(Objects::nonNull)
                    .collect(Collectors.toList()));
        }
        // åˆ é™¤ç”Ÿäº§ç®¡æŽ§æ•°æ®
        //查询生产报工id
        ArrayList<Long> mainIdList = productionProductMainService.listMain(idList);
@@ -561,10 +625,13 @@
        SalesLedger salesLedger = convertToEntity(salesLedgerDto);
        salesLedger.setCustomerName(customer.getCustomerName());
        salesLedger.setTenantId(customer.getTenantId());
        applyLedgerType(salesLedger, salesLedgerDto);
        // å½•入人:如果未传则使用当前登录用户
        if (StringUtils.isEmpty(salesLedger.getEntryPerson())) {
            salesLedger.setEntryPerson(String.valueOf(SecurityUtils.getUserId()));
        }
        // æ–°å¢žå’Œä¿®æ”¹éƒ½è¦é‡æ–°èµ°å®¡æ‰¹
        salesLedger.setApprovalStatus(SalesQuotationStatusEnum.PENDING.getCode());
        // 3. æ–°å¢žæˆ–更新主表
        if (salesLedger.getId() == null) {
            String contractNo = salesLedger.getSalesContractNo();
@@ -574,12 +641,19 @@
            salesLedger.setSalesContractNo(contractNo);
            salesLedgerMapper.insert(salesLedger);
        } else {
            // æ”¹å•会重新走一遍审批:先作废此前未结束的审批实例,事务提交前还会重新发起
            approvalInstanceService.lambdaUpdate()
                    .set(ApprovalInstance::getStatus, "REJECTED")
                    .eq(ApprovalInstance::getBusinessId, salesLedger.getId())
                    .eq(ApprovalInstance::getBusinessType, TypeEnums.SALES_LEDGER_APPROVAL.getCode())
                    .update();
            salesLedgerMapper.updateById(salesLedger);
        }
        // 4. å¤„理子表数据
        List<SalesLedgerProduct> productList = salesLedgerDto.getProductData();
        // 4. å¤„理子表数据。代储没有产品明细,合同金额以手工填写的为准,不能被子表汇总覆盖
        List<SalesLedgerProduct> productList = LEDGER_TYPE_OIL.equals(salesLedger.getLedgerType())
                ? salesLedgerDto.getProductData() : null;
        if (productList != null && !productList.isEmpty()) {
            handleSalesLedgerProducts(salesLedger.getId(), productList, EnumUtil.fromCode(SaleEnum.class, salesLedgerDto.getType()));
            updateMainContractAmount(
@@ -605,9 +679,100 @@
        // 6. ä¿å­˜é”€å”®å°è´¦é™„ä»¶
        fileUtil.saveStorageAttachment(ApplicationTypeEnum.FILE, RecordTypeEnum.SALES_LEDGER, salesLedger.getId(), salesLedgerDto.getStorageBlobDTOs());
        // 7. å‘起审批。合同金额要取落库后的值(卖油的金额由第 4 æ­¥çš„产品明细汇总写回)
        SalesLedger savedLedger = salesLedgerMapper.selectById(salesLedger.getId());
        startSalesLedgerApproval(savedLedger, customer.getTrustFund());
        return 1;
    }
    /**
     * æ ¡éªŒé”€å”®ç±»åž‹å¹¶è¡¥é½ä»£å‚¨çš„æ²¹åº“/储罐信息。
     * å–油:合同金额只能由产品明细汇总产生;代储:没有明细,必须选油库、储罐并手工填合同金额
     */
    private void applyLedgerType(SalesLedger salesLedger, SalesLedgerDto salesLedgerDto) {
        String ledgerType = salesLedgerDto.getLedgerType();
        if (StringUtils.isEmpty(ledgerType)) {
            throw new BaseException("销售类型不能为空");
        }
        if (LEDGER_TYPE_OIL.equals(ledgerType)) {
            salesLedger.setOilDepotId(null);
            salesLedger.setOilDepotName(null);
            salesLedger.setTankId(null);
            salesLedger.setTankNo(null);
            // updateById ä¼šè·³è¿‡ null,对修改是空操作;新增时与改造前一致(本就为 null)
            salesLedger.setContractAmount(null);
            return;
        }
        if (!LEDGER_TYPE_DEPOSIT.equals(ledgerType)) {
            throw new BaseException("销售类型不合法:" + ledgerType);
        }
        if (salesLedgerDto.getOilDepotId() == null) {
            throw new BaseException("代储必须选择油库");
        }
        if (salesLedgerDto.getTankId() == null) {
            throw new BaseException("代储必须选择储罐");
        }
        if (salesLedgerDto.getContractAmount() == null
                || salesLedgerDto.getContractAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("代储必须填写合同金额");
        }
        OilDepot oilDepot = oilDepotMapper.selectById(salesLedgerDto.getOilDepotId());
        if (oilDepot == null) {
            throw new BaseException("油库不存在:" + salesLedgerDto.getOilDepotId());
        }
        TankInfo tankInfo = tankInfoMapper.selectById(salesLedgerDto.getTankId());
        if (tankInfo == null) {
            throw new BaseException("储罐不存在:" + salesLedgerDto.getTankId());
        }
        // åç§°ä»¥åŸºç¡€æ•°æ®ä¸ºå‡†ï¼Œä¸é‡‡ä¿¡å‰ç«¯ä¼ å€¼
        salesLedger.setOilDepotName(oilDepot.getDepotName());
        salesLedger.setTankNo(tankInfo.getTankNo());
    }
    /**
     * å‘起销售台账审批,走系统已有的 OA å®¡æ‰¹æµç¨‹ã€‚
     * åˆåŒé‡‘额超出客户信托基金时,审批标题带上提醒字眼
     */
    private void startSalesLedgerApproval(SalesLedger ledger, BigDecimal trustFund) {
        if (ledger == null) {
            return;
        }
        ApprovalTemplate approvalTemplate = approvalTemplateMapper.selectOne(
                new LambdaQueryWrapper<ApprovalTemplate>()
                        .eq(ApprovalTemplate::getBusinessType, TypeEnums.SALES_LEDGER_APPROVAL.getCode())
                        .eq(ApprovalTemplate::getDeleted, 0)
                        .orderByDesc(ApprovalTemplate::getId)
                        .last("LIMIT 1")
        );
        if (approvalTemplate == null) {
            throw new RuntimeException("请先配置销售台账审批模板");
        }
        boolean exceedsTrustFund = trustFund != null && ledger.getContractAmount() != null
                && ledger.getContractAmount().compareTo(trustFund) > 0;
        String title = ledger.getSalesContractNo() + "审批";
        if (exceedsTrustFund) {
            title += "(合同金额超出客户信托基金,请重点审核)";
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        ApprovalInstanceDto approvalInstance = new ApprovalInstanceDto();
        approvalInstance.setTemplateId(approvalTemplate.getId());
        approvalInstance.setTemplateName(approvalTemplate.getTemplateName());
        approvalInstance.setBusinessId(ledger.getId());
        approvalInstance.setBusinessType(TypeEnums.SALES_LEDGER_APPROVAL.getCode());
        approvalInstance.setCurrentLevel(1);
        approvalInstance.setTitle(title);
        approvalInstance.setApplicantId(loginUser.getUserId());
        approvalInstance.setApplicantName(loginUser.getNickName());
        approvalInstance.setApplyTime(LocalDateTime.now());
        try {
            approvalInstanceService.add(approvalInstance);
        } catch (Exception e) {
            log.error("SalesLedgerServiceImpl approve error for salesContractNo: {}", ledger.getSalesContractNo(), e);
            throw new RuntimeException("审批失败: " + e.getMessage(), e);
        }
    }
    @Override
    public void handleSalesLedgerProducts(Long salesLedgerId, List<SalesLedgerProduct> products, SaleEnum type) {
        // æŒ‰ID分组,区分新增和更新的记录
src/main/java/com/ruoyi/sales/service/impl/ShippingInfoServiceImpl.java
@@ -36,6 +36,7 @@
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@@ -48,6 +49,10 @@
@RequiredArgsConstructor
public class ShippingInfoServiceImpl extends ServiceImpl<ShippingInfoMapper, ShippingInfo> implements ShippingInfoService {
    /**
     * listPage çš„行来源标记:shipping=发货单,oilOut=销售类油品出库
     */
    private static final String SOURCE_OIL_OUT = "oilOut";
    private final ShippingInfoMapper shippingInfoMapper;
@@ -69,10 +74,26 @@
        IPage<ShippingInfoDto> listPage = shippingInfoMapper.listPage(page, req);
        listPage.getRecords().forEach(item -> {
            item.setStorageBlobVOs(fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum.IMAGE, RecordTypeEnum.SHIPPING_INFO, item.getId()));
            // æ²¹å“å‡ºåº“行没有 shipping_product_detail,批号明细只能由出库记录自身字段构造
            if (SOURCE_OIL_OUT.equals(item.getSource())) {
                item.setBatchNoList(buildOilOutBatchNoList(item));
            }
        });
        return listPage;
    }
    /**
     * æ²¹å“å‡ºåº“行的批号明细,供发货台账详情弹框的「批号」明细表展示
     */
    private List<ShippingProductDetailDto> buildOilOutBatchNoList(ShippingInfoDto item) {
        ShippingProductDetailDto detail = new ShippingProductDetailDto();
        detail.setBatchNo(item.getOutBatchNo());
        detail.setProductName(item.getProductName());
        detail.setSpecificationModel(item.getSpecificationModel());
        detail.setDeliveryQuantity(item.getTotalQuantity());
        return Collections.singletonList(detail);
    }
    @Override
    public boolean deductStock(ShippingInfoDto req) {
        ShippingInfo byId = this.getById(req.getId());
src/main/java/com/ruoyi/sales/vo/CustomerTransactionsDetailsVo.java
@@ -27,8 +27,8 @@
    @Schema(description = "收款金额")
    private BigDecimal receiptPaymentAmount;
    @Schema(description = "付款金额")
    private BigDecimal paymentAmount;
    @Schema(description = "回款金额(往来流水)")
    private BigDecimal receiptRecordAmount;
    @Schema(description = "应收金额")
    private BigDecimal receiptableAmount;
src/main/java/com/ruoyi/sales/vo/CustomerTransactionsVo.java
@@ -23,9 +23,9 @@
    //该客户销售收款累计金额
    private BigDecimal receiptPaymentAmount;
    @Schema(description = "付款金额")
    //该客户往来付款累计金额(payment_record æ‰‹å·¥æµæ°´ï¼‰
    private BigDecimal paymentAmount;
    @Schema(description = "回款金额(往来流水)")
    //该客户往来回款累计金额(payment_record æ‰‹å·¥æµæ°´ï¼Œparty_type=1 direction=1)
    private BigDecimal receiptRecordAmount;
    @Schema(description = "应收金额")
    //该客户销售应收累计金额=初始金额-回款-退款
src/main/java/com/ruoyi/stock/controller/StockInventoryController.java
@@ -43,6 +43,13 @@
        return R.ok(stockInventoryDtoIPage);
    }
    @GetMapping("/pageScrapStock")
    @Operation(summary = "报废品选料分页(只读,带批号)")
    public R pageScrapStock(Page page, StockInventoryDto stockInventoryDto) {
        IPage<StockInventoryDto> result = stockInventoryService.pageScrapStock(page, stockInventoryDto);
        return R.ok(result);
    }
    @GetMapping("/pageListCombinedStockInventory")
    @Operation(summary = "分页查询联合库存列表")
    public R pageListCombinedStockInventory(Page page, StockInventoryDto stockInventoryDto) {
src/main/java/com/ruoyi/stock/controller/StockOutRecordController.java
@@ -98,4 +98,17 @@
        return AjaxResult.success();
    }
    @GetMapping("/oilOutAvailableQty")
    @Operation(summary = "油品出库可用量(上次剩余油量+当前库存)")
    public AjaxResult oilOutAvailableQty(StockOutRecordDto stockOutRecordDto) {
        return AjaxResult.success(stockOutRecordService.getOilOutAvailableQty(stockOutRecordDto));
    }
    @PutMapping("/{id}/residualOil")
    @Log(title = "出库管理-录入剩余油量", businessType = BusinessType.UPDATE)
    @Operation(summary = "录入本次送完货后的剩余油量(只更新该字段)")
    public AjaxResult updateResidualOil(@PathVariable("id") Long id, @RequestBody StockOutRecordDto stockOutRecordDto) {
        return AjaxResult.success(stockOutRecordService.updateResidualOil(id, stockOutRecordDto.getResidualOil()));
    }
}
src/main/java/com/ruoyi/stock/dto/OilOutAvailableDto.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package com.ruoyi.stock.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
 * æ²¹å“å‡ºåº“可用量:下次发货 = ä¸Šæ¬¡å‰©ä½™æ²¹é‡ + åº“存量
 *
 * @author èŠ¯å¯¼è½¯ä»¶ï¼ˆæ±Ÿè‹ï¼‰æœ‰é™å…¬å¸
 * @since 2026-09-20
 */
@Data
public class OilOutAvailableDto {
    @Schema(description = "该车该规格上次送完货后剩余油量(吨)")
    private BigDecimal lastResidualOil;
    @Schema(description = "当前库存量(吨)")
    private BigDecimal stockQuantity;
    @Schema(description = "可用量(吨) = ä¸Šæ¬¡å‰©ä½™æ²¹é‡ + å½“前库存量")
    private BigDecimal availableQty;
}
src/main/java/com/ruoyi/stock/mapper/StockInventoryMapper.java
@@ -29,6 +29,11 @@
    IPage<StockInventoryDto> pagestockInventory(Page page, @Param("ew") StockInventoryDto stockInventoryDto);
    /**
     * æŠ¥åºŸå“é€‰æ–™åˆ†é¡µï¼ˆåªè¯»ï¼Œå¸¦æ‰¹å·ï¼›ä¸Ž pagestockInventory ç›¸äº’独立)
     */
    IPage<StockInventoryDto> selectScrapStockPage(Page page, @Param("ew") StockInventoryDto stockInventoryDto);
    IPage<StockInventoryDto> pageListCombinedStockInventory(Page page, @Param("ew") StockInventoryDto stockInventoryDto);
    int updateAddStockInventory(@Param("ew") StockInventoryDto stockInventoryDto);
src/main/java/com/ruoyi/stock/mapper/StockOutRecordMapper.java
@@ -28,4 +28,14 @@
    List<StockOutRecordExportData> listStockOutRecordExportData(@Param("params") StockOutRecordDto stockOutRecordDto);
    IPage<SalesOutboundVo> listPageAccountSales(Page page, @Param("req") SalesOutboundDto salesOutboundDto);
    /**
     * æŒ‰æ¥æºæ˜Žç»†id批量查询出库记录。
     *
     * <p>stock_out_record ä¸åœ¨ {@code IgnoreTableConfig.IGNORE_TABLES} ç™½åå•里,普通查询会被
     * DataScopeSqlInterceptor è¿½åŠ  create_user/dept_id æ¡ä»¶ã€‚委外订单要靠这批记录做「完成前的出库审批校验」
     * å’Œã€Œä½œåºŸæ—¶çš„占用释放」,换个账号查不到就会静默漏校验、占用释放不掉,所以这里显式跳过数据权限。</p>
     */
    List<StockOutRecord> selectByRecordIds(@Param("recordIds") List<Long> recordIds,
                                           @Param("recordType") String recordType);
}
src/main/java/com/ruoyi/stock/pojo/StockOutRecord.java
@@ -131,6 +131,15 @@
    @Schema(description = "车挂牌号")
    private String trailerPlateNo;
    @Schema(description = "车辆id(车辆主数据)")
    private Long vehicleId;
    @Schema(description = "本次送完货后剩余油量(吨)")
    private BigDecimal residualOil;
    @Schema(description = "审批通过时实际从库存扣减的数量(出库数量-上次剩余油量)")
    private BigDecimal deductQuantity;
    @Schema(description = "产品规格id")
    private Long productModelId;
src/main/java/com/ruoyi/stock/service/StockInventoryService.java
@@ -25,6 +25,11 @@
    IPage<StockInventoryDto> pagestockInventory(Page page, StockInventoryDto stockInventoryDto);
    /**
     * æŠ¥åºŸå“é€‰æ–™åˆ†é¡µï¼ˆåªè¯»ï¼Œå¸¦æ‰¹å·ï¼‰
     */
    IPage<StockInventoryDto> pageScrapStock(Page page, StockInventoryDto stockInventoryDto);
    IPage<StockInventoryDto> pageListCombinedStockInventory(Page page, StockInventoryDto stockInventoryDto);
    Boolean addstockInventory(StockInventoryDto stockInventoryDto);
src/main/java/com/ruoyi/stock/service/StockOutRecordService.java
@@ -3,10 +3,12 @@
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.stock.dto.OilOutAvailableDto;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.pojo.StockOutRecord;
import jakarta.servlet.http.HttpServletResponse;
import java.math.BigDecimal;
import java.util.List;
/**
@@ -33,4 +35,38 @@
    int batchApprove(List<Long> ids, Integer approvalStatus);
    int batchReAudit(List<Long> ids);
    /**
     * å½•入「本次送完货后剩余油量」。
     * åªæ›´æ–° residual_oil,不动其他列;走 {@link #update} ä¼šæŠŠæœªä¼ çš„字段全部置空,故单独开一个接口
     *
     * @param id          å‡ºåº“记录id
     * @param residualOil å‰©ä½™æ²¹é‡(吨),不能为空、不能小于0、不能大于出库数量
     */
    int updateResidualOil(Long id, BigDecimal residualOil);
    /**
     * æ²¹å“å‡ºåº“可用量:下次发货 = ä¸Šæ¬¡å‰©ä½™æ²¹é‡ + å½“前库存量
     *
     * @param query éœ€å¸¦ vehicleId、productModelId,batchNo å¯é€‰
     * @return ä¸Šæ¬¡å‰©ä½™æ²¹é‡ / å½“前库存 / å¯ç”¨é‡
     */
    OilOutAvailableDto getOilOutAvailableQty(StockOutRecordDto query);
    /**
     * æŒ‰æ¥æºæ˜Žç»†id批量查询出库记录,不受数据权限约束。
     * ä¾›ä¸šåŠ¡å•æ®åšã€Œå‡ºåº“æ˜¯å¦å·²å®¡æ‰¹ã€è¿™ç±»è‡ªèº«çŠ¶æ€æœºæ ¡éªŒï¼Œæ¢è´¦å·æ‰§è¡Œä¹Ÿå¿…é¡»çœ‹åˆ°åŒä¸€æ‰¹è®°å½•
     *
     * @param recordIds  æ¥æºæ˜Žç»†id,为空返回空集合
     * @param recordType å‡ºåº“类型,见 StockInQualifiedRecordTypeEnum
     */
    List<StockOutRecord> listByRecordIds(List<Long> recordIds, String recordType);
    /**
     * é‡Šæ”¾æ¥æºæ˜Žç»†å…³è”的出库记录:审批通过过的按批次归还库存量,然后删除记录。
     * å¾…审批/驳回的记录没扣过库存,删掉即释放占用
     *
     * @return åˆ é™¤çš„记录数
     */
    int releaseByRecordIds(List<Long> recordIds, String recordType);
}
src/main/java/com/ruoyi/stock/service/impl/StockInventoryServiceImpl.java
@@ -80,6 +80,11 @@
    }
    @Override
    public IPage<StockInventoryDto> pageScrapStock(Page page, StockInventoryDto stockInventoryDto) {
        return stockInventoryMapper.selectScrapStockPage(page, stockInventoryDto);
    }
    @Override
    public IPage<StockInventoryDto> pageListCombinedStockInventory(Page page, StockInventoryDto stockInventoryDto) {
        return stockInventoryMapper.pageListCombinedStockInventory(page, stockInventoryDto);
    }
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java
@@ -2,10 +2,13 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.mapper.sales.AccountInvoiceApplicationMapper;
import com.ruoyi.account.mapper.sales.AccountSalesCollectionMapper;
import com.ruoyi.basic.pojo.Vehicle;
import com.ruoyi.basic.service.IVehicleService;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
@@ -15,8 +18,14 @@
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.bean.BeanUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.outsourcing.enums.OutsourcingOrderStatusEnum;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderMapper;
import com.ruoyi.outsourcing.mapper.OutsourcingOrderProductMapper;
import com.ruoyi.outsourcing.pojo.OutsourcingOrder;
import com.ruoyi.outsourcing.pojo.OutsourcingOrderProduct;
import java.time.LocalDateTime;
import com.ruoyi.stock.dto.OilOutAvailableDto;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.dto.StockOutRecordDto;
import com.ruoyi.stock.dto.StockUninventoryDto;
@@ -34,7 +43,10 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
 * <p>
@@ -47,11 +59,19 @@
@Service
@RequiredArgsConstructor
public class StockOutRecordServiceImpl extends ServiceImpl<StockOutRecordMapper, StockOutRecord> implements StockOutRecordService {
    /**
     * å‡ºåº“类别:销售。销售类油品出库在审批通过时按批次扣减库存
     */
    private static final String OUT_CATEGORY_SALE = "销售";
    private final StockOutRecordMapper stockOutRecordMapper;
    private final IVehicleService vehicleService;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockUninventoryMapper stockUninventoryMapper;
    private final AccountSalesCollectionMapper accountSalesCollectionMapper;
    private final AccountInvoiceApplicationMapper accountInvoiceApplicationMapper;
    private final OutsourcingOrderProductMapper outsourcingOrderProductMapper;
    private final OutsourcingOrderMapper outsourcingOrderMapper;
    @Override
    public IPage<StockOutRecordDto> listPage(Page page, StockOutRecordDto stockOutRecordDto) {
@@ -60,6 +80,14 @@
    @Override
    public int add(StockOutRecordDto stockOutRecordDto) {
        if (OUT_CATEGORY_SALE.equals(stockOutRecordDto.getOutCategory())) {
            // é”€å”®ç±»æ²¹å“å‡ºåº“审批通过时要按批次扣减库存,批次不能为空
            if (StringUtils.isEmpty(stockOutRecordDto.getBatchNo())) {
                throw new BaseException("销售类出库必须选择出库批次");
            }
            resolveVehicle(stockOutRecordDto);
            checkSaleOutAvailable(stockOutRecordDto);
        }
        LocalDateTime createTime = stockOutRecordDto.getCreateTime();
        if (createTime == null) {
            createTime = LocalDateTime.now();
@@ -87,6 +115,7 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchDelete(List<Long> ids) {
        //todo å¦‚果出库与开票收款有关联则无法删除
        if (accountSalesCollectionMapper.existsByStockOutRecordId(ids) ||
@@ -133,8 +162,35 @@
                    stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto);
                }
            }
            else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                // é”€å”®ç±»æ²¹å“å‡ºåº“只有审批通过时才扣过库存,删除已通过的记录需按同批次归还
                returnSaleOilOutStock(stockOutRecord);
            }
        }
        return stockOutRecordMapper.deleteBatchIds(ids);
    }
    /**
     * é”€å”®ç±»æ²¹å“å‡ºåº“归还库存,仅对审批通过(即扣过库存)的记录生效。
     * å½’还量取 deduct_quantity(实际扣减量);改造前审批通过的记录没有该值,按全额归还
     */
    private void returnSaleOilOutStock(StockOutRecord stockOutRecord) {
        if (!ReviewStatusEnum.APPROVED.getCode().equals(stockOutRecord.getApprovalStatus())
                || StringUtils.isEmpty(stockOutRecord.getBatchNo())
                || stockOutRecord.getStockOutNum() == null) {
            return;
        }
        BigDecimal returnQuantity = stockOutRecord.getDeductQuantity() != null
                ? stockOutRecord.getDeductQuantity()
                : stockOutRecord.getStockOutNum();
        if (returnQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        StockInventoryDto stockInventoryDto = new StockInventoryDto();
        stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
        stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
        stockInventoryDto.setQualitity(returnQuantity);
        stockInventoryMapper.updateAddStockInventory(stockInventoryDto);
    }
    @Override
@@ -183,9 +239,7 @@
            if (stockOutRecord.getApprovalStatus() != null && !ReviewStatusEnum.PENDING_REVIEW.getCode().equals(stockOutRecord.getApprovalStatus())) {
                throw new BaseException("只有待审批状态的记录才能审批,出库批次:" + stockOutRecord.getOutboundBatches());
            }
            stockOutRecord.setApprovalStatus(approvalStatus);
            stockOutRecordMapper.updateById(stockOutRecord);
            // å®¡æ‰¹é€šè¿‡æ—¶ï¼Œæ‰£å‡åº“å­˜
            // å®¡æ‰¹é€šè¿‡æ—¶å…ˆæ‰£å‡åº“存,扣减量(deduct_quantity)要随审批状态一起落库,所以放在更新之前
            if (ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus)) {
                if ("0".equals(stockOutRecord.getType())) {
                    // åˆæ ¼å‡ºåº“ -> å…ˆæŸ¥åº“存是否存在,存在才扣减
@@ -209,10 +263,94 @@
                    stockUninventoryDto.setBatchNo(stockOutRecord.getBatchNo());
                    stockUninventoryDto.setQualitity(stockOutRecord.getStockOutNum());
                    stockUninventoryMapper.updateSubtractStockUnInventory(stockUninventoryDto);
                } else if (OUT_CATEGORY_SALE.equals(stockOutRecord.getOutCategory())) {
                    // é”€å”®ç±»æ²¹å“å‡ºåº“(type ä¸ºç©ºï¼‰å®¡æ‰¹é€šè¿‡æ—¶æŒ‰æ‰¹æ¬¡æ‰£å‡åº“å­˜
                    deductSaleOilOutStock(stockOutRecord);
                }
            }
            stockOutRecord.setApprovalStatus(approvalStatus);
            stockOutRecordMapper.updateById(stockOutRecord);
            if (ReviewStatusEnum.APPROVED.getCode().equals(approvalStatus)
                    && StockOutQualifiedRecordTypeEnum.OUTSOURCING_SCRAP_OUT.getCode()
                            .equals(stockOutRecord.getRecordType())) {
                tryCompleteOutsourcingOrder(stockOutRecord);
            }
        }
        return ids.size();
    }
    /**
     * å§”外报废品出库审批通过后,若该委外订单的报废品选料行已<b>全部</b>出库审批通过,
     * è‡ªåŠ¨æŠŠè®¢å•ä»Žã€Œå·²ç¡®è®¤ã€æŽ¨åˆ°ã€Œå·²å®Œæˆã€ï¼ŒçœæŽ‰äººå·¥å†ç‚¹ä¸€æ¬¡å®Œæˆã€‚
     *
     * <p>直接用 mapper è€Œä¸æ˜¯ {@code IOutsourcingOrderService#complete}:后者依赖本服务,
     * ä¸¤è¾¹éƒ½èµ°æž„造器注入会形成循环依赖。驳回/反审不回退订单状态(处理错误审批走人工重新审批)。</p>
     */
    private void tryCompleteOutsourcingOrder(StockOutRecord record) {
        if (record.getRecordId() == null) {
            return;
        }
        OutsourcingOrderProduct line = outsourcingOrderProductMapper.selectById(record.getRecordId());
        if (line == null || line.getOutsourcingOrderId() == null) {
            return;
        }
        Long orderId = line.getOutsourcingOrderId();
        // isScrap åœ¨ Java ä¾§è¿‡æ»¤ï¼Œä¸Žå§”外模块自己的 scrapLineIds ä¸€è‡´ï¼Œä¸ä¾èµ– Boolean å­—段的列名推断
        List<Long> lineIds = outsourcingOrderProductMapper.selectList(
                        Wrappers.<OutsourcingOrderProduct>lambdaQuery()
                                .eq(OutsourcingOrderProduct::getOutsourcingOrderId, orderId))
                .stream()
                .filter(item -> Boolean.TRUE.equals(item.getIsScrap()))
                .map(OutsourcingOrderProduct::getId)
                .toList();
        if (lineIds.isEmpty()) {
            return;
        }
        // å‡ºåº“记录一条对应一行明细;缺记录(被反审删除等)就不完成,与人工完成的校验口径一致
        long approvedCount = listByRecordIds(lineIds, record.getRecordType()).stream()
                .filter(item -> ReviewStatusEnum.APPROVED.getCode().equals(item.getApprovalStatus()))
                .count();
        if (approvedCount < lineIds.size()) {
            return;
        }
        // æ¡ä»¶æ›´æ–°ï¼Œåªæœ‰ã€Œå·²ç¡®è®¤ã€èƒ½æŽ¨è¿›ï¼›å·²ä½œåºŸ/已完成的订单不动
        outsourcingOrderMapper.update(null, Wrappers.<OutsourcingOrder>lambdaUpdate()
                .eq(OutsourcingOrder::getId, orderId)
                .eq(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.CONFIRMED.getCode())
                .set(OutsourcingOrder::getStatus, OutsourcingOrderStatusEnum.COMPLETED.getCode()));
    }
    /**
     * é”€å”®ç±»æ²¹å“å‡ºåº“扣减库存。出库数量是本次装车总量,车上上次剩的油本次继续用,
     * æ‰€ä»¥åªä»Žåº“存补足「出库数量 âˆ’ ä¸Šæ¬¡å‰©ä½™æ²¹é‡ã€çš„差额。
     * updateSubtractStockInventory è‡ªå¸¦ qualitity &gt;= æ•°é‡ çš„守卫,影响行数为 0 å³åº“存不足,
     * æŠ›é”™ç”±å¤–层事务回滚
     */
    private void deductSaleOilOutStock(StockOutRecord stockOutRecord) {
        String suffix = ",出库批次:" + stockOutRecord.getOutboundBatches();
        if (StringUtils.isEmpty(stockOutRecord.getBatchNo())) {
            throw new BaseException("销售类出库必须选择出库批次" + suffix);
        }
        BigDecimal quantity = stockOutRecord.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecord.getVehicleId(), stockOutRecord.getProductModelId(), stockOutRecord.getId());
        if (lastResidual.compareTo(quantity) > 0) {
            throw new BaseException("出库数量不能小于该车上次剩余油量" + toPlain(lastResidual) + suffix);
        }
        BigDecimal deduct = quantity.subtract(lastResidual);
        if (deduct.compareTo(BigDecimal.ZERO) > 0) {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(stockOutRecord.getProductModelId());
            stockInventoryDto.setBatchNo(stockOutRecord.getBatchNo());
            stockInventoryDto.setQualitity(deduct);
            if (stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto) == 0) {
                throw new BaseException("库存不足无法出库" + suffix);
            }
        }
        // è½¦ä¸Šå‰©æ²¹åˆšå¥½å¤Ÿï¼ˆdeduct=0)时不涉及库存更新,同样落库便于删除时按实际扣减量归还
        stockOutRecord.setDeductQuantity(deduct);
    }
    @Override
@@ -235,6 +373,24 @@
        return ids.size();
    }
    @Override
    public int updateResidualOil(Long id, BigDecimal residualOil) {
        StockOutRecord stockOutRecord = stockOutRecordMapper.selectById(id);
        if (stockOutRecord == null) {
            throw new BaseException("该出库记录不存在,无法更新!!!");
        }
        if (residualOil == null || residualOil.compareTo(BigDecimal.ZERO) < 0) {
            throw new BaseException("剩余油量不能为空且不能小于0");
        }
        if (stockOutRecord.getStockOutNum() != null && residualOil.compareTo(stockOutRecord.getStockOutNum()) > 0) {
            throw new BaseException("剩余油量不能大于出库数量" + toPlain(stockOutRecord.getStockOutNum()));
        }
        StockOutRecord update = new StockOutRecord();
        update.setId(id);
        update.setResidualOil(residualOil);
        return stockOutRecordMapper.updateById(update);
    }
    private StockInventory getStockInventory(Long productModelId, String batchNo) {
        LambdaQueryWrapper<StockInventory> eq = new LambdaQueryWrapper<>();
        eq.eq(StockInventory::getProductModelId, productModelId);
@@ -256,4 +412,145 @@
        }
        return stockUninventoryMapper.selectOne(eq);
    }
    /**
     * è½¦è¾†å›žå¡«ï¼šå‰ç«¯å¯ä»¥ç›´æŽ¥ä¼  vehicleId,也可以只传车牌号,
     * åŽè€…按车辆主数据反查回填。两者都拿不到就无法定位「上次剩余油量」,直接报错
     */
    private void resolveVehicle(StockOutRecordDto stockOutRecordDto) {
        if (stockOutRecordDto.getVehicleId() != null) {
            return;
        }
        if (StringUtils.isEmpty(stockOutRecordDto.getTruckPlateNo())) {
            throw new BaseException("销售类出库必须选择车辆");
        }
        LambdaQueryWrapper<Vehicle> wrapper = new LambdaQueryWrapper<Vehicle>()
                .eq(Vehicle::getPlateNumber, stockOutRecordDto.getTruckPlateNo());
        Vehicle vehicle = vehicleService.getOne(wrapper, false);
        if (vehicle == null) {
            throw new BaseException("车牌号 " + stockOutRecordDto.getTruckPlateNo() + " ä¸åœ¨è½¦è¾†ç®¡ç†ä¸­ï¼Œè¯·å…ˆåˆ°è½¦è¾†ç®¡ç†æ·»åŠ ");
        }
        stockOutRecordDto.setVehicleId(vehicle.getId());
    }
    /**
     * å‡ºåº“数量是本次装车总量,必须落在「上次剩余油量 ~ ä¸Šæ¬¡å‰©ä½™æ²¹é‡ + åº“存」区间内
     */
    private void checkSaleOutAvailable(StockOutRecordDto stockOutRecordDto) {
        String suffix = ",出库批次:" + stockOutRecordDto.getOutboundBatches();
        BigDecimal quantity = stockOutRecordDto.getStockOutNum();
        if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BaseException("销售类出库数量必须大于0" + suffix);
        }
        BigDecimal lastResidual = getLastResidualOil(stockOutRecordDto.getVehicleId(), stockOutRecordDto.getProductModelId(), null);
        if (lastResidual.compareTo(quantity) > 0) {
            throw new BaseException("出库数量不能小于该车上次剩余油量" + toPlain(lastResidual) + suffix);
        }
        BigDecimal stockQuantity = getStockQuantity(stockOutRecordDto.getProductModelId(), stockOutRecordDto.getBatchNo());
        BigDecimal availableQty = lastResidual.add(stockQuantity);
        if (quantity.compareTo(availableQty) > 0) {
            throw new BaseException("出库数量超出可用量(上次剩余" + toPlain(lastResidual)
                    + ",库存" + toPlain(stockQuantity) + ",可用" + toPlain(availableQty) + ")" + suffix);
        }
    }
    /**
     * è¯¥è½¦è¯¥è§„格上次送完货后剩余的油量,取最近一条填过残油且已审批的记录。
     * excludeId ç”¨äºŽæŽ’除「本次要审批的这条自己」——它自己的 residual_oil æ˜¯é€å®Œä¹‹åŽçš„值,不能用来算本次扣减
     */
    private BigDecimal getLastResidualOil(Long vehicleId, Long productModelId, Long excludeId) {
        if (vehicleId == null || productModelId == null) {
            return BigDecimal.ZERO;
        }
        LambdaQueryWrapper<StockOutRecord> wrapper = new LambdaQueryWrapper<StockOutRecord>()
                .eq(StockOutRecord::getVehicleId, vehicleId)
                .eq(StockOutRecord::getProductModelId, productModelId)
                .isNotNull(StockOutRecord::getResidualOil)
                .eq(StockOutRecord::getApprovalStatus, ReviewStatusEnum.APPROVED.getCode());
        if (excludeId != null) {
            wrapper.lt(StockOutRecord::getId, excludeId);
        }
        wrapper.orderByDesc(StockOutRecord::getId).last("limit 1");
        StockOutRecord last = stockOutRecordMapper.selectOne(wrapper);
        return last == null || last.getResidualOil() == null ? BigDecimal.ZERO : last.getResidualOil();
    }
    /**
     * å½“前库存量,按规格合计;传了批次号再叠加批次条件
     */
    private BigDecimal getStockQuantity(Long productModelId, String batchNo) {
        if (productModelId == null) {
            return BigDecimal.ZERO;
        }
        LambdaQueryWrapper<StockInventory> wrapper = new LambdaQueryWrapper<StockInventory>()
                .eq(StockInventory::getProductModelId, productModelId);
        if (!StringUtils.isEmpty(batchNo)) {
            wrapper.eq(StockInventory::getBatchNo, batchNo);
        }
        return stockInventoryMapper.selectList(wrapper).stream()
                .map(StockInventory::getQualitity)
                .filter(Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    @Override
    public OilOutAvailableDto getOilOutAvailableQty(StockOutRecordDto query) {
        if (query == null || query.getVehicleId() == null || query.getProductModelId() == null) {
            throw new BaseException("请先选择车辆和产品规格");
        }
        BigDecimal lastResidualOil = getLastResidualOil(query.getVehicleId(), query.getProductModelId(), query.getId());
        BigDecimal stockQuantity = getStockQuantity(query.getProductModelId(), query.getBatchNo());
        OilOutAvailableDto result = new OilOutAvailableDto();
        result.setLastResidualOil(lastResidualOil);
        result.setStockQuantity(stockQuantity);
        result.setAvailableQty(lastResidualOil.add(stockQuantity));
        return result;
    }
    private String toPlain(BigDecimal value) {
        return value == null ? "0" : value.stripTrailingZeros().toPlainString();
    }
    @Override
    public List<StockOutRecord> listByRecordIds(List<Long> recordIds, String recordType) {
        if (CollectionUtils.isEmpty(recordIds) || StringUtils.isEmpty(recordType)) {
            return Collections.emptyList();
        }
        return stockOutRecordMapper.selectByRecordIds(recordIds, recordType);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int releaseByRecordIds(List<Long> recordIds, String recordType) {
        List<StockOutRecord> records = listByRecordIds(recordIds, recordType);
        if (records.isEmpty()) {
            return 0;
        }
        for (StockOutRecord record : records) {
            if (!ReviewStatusEnum.APPROVED.getCode().equals(record.getApprovalStatus())) {
                continue;
            }
            // type=0 å½’还合格库存(stock_inventory),type=1 å½’还不合格库存(stock_uninventory)
            if ("0".equals(record.getType())) {
                StockInventoryDto stockInventoryDto = new StockInventoryDto();
                stockInventoryDto.setProductModelId(record.getProductModelId());
                stockInventoryDto.setBatchNo(record.getBatchNo());
                stockInventoryDto.setQualitity(record.getStockOutNum());
                if (stockInventoryMapper.updateAddStockInventory(stockInventoryDto) == 0) {
                    throw new BaseException("库存记录中没有对应的产品,无法归还,出库批次:" + record.getOutboundBatches());
                }
            } else if ("1".equals(record.getType())) {
                StockUninventoryDto stockUninventoryDto = new StockUninventoryDto();
                stockUninventoryDto.setProductModelId(record.getProductModelId());
                stockUninventoryDto.setBatchNo(record.getBatchNo());
                stockUninventoryDto.setQualitity(record.getStockOutNum());
                if (stockUninventoryMapper.updateAddStockUnInventory(stockUninventoryDto) == 0) {
                    throw new BaseException("库存记录中没有对应的产品,无法归还,出库批次:" + record.getOutboundBatches());
                }
            } else {
                throw new BaseException("出库记录类型异常,无法归还库存,出库批次:" + record.getOutboundBatches());
            }
        }
        return stockOutRecordMapper.deleteBatchIds(records.stream().map(StockOutRecord::getId).toList());
    }
}
src/main/resources/mapper/basic/CustomerMapper.xml
@@ -128,12 +128,12 @@
               c.customer_name,
               T1.contractAmounts AS contractAmounts,
               IFNULL(T2.receiptPaymentAmount, 0) AS receiptPaymentAmount,
               IFNULL(T5.paymentAmount, 0) AS paymentAmount,
               IFNULL(T5.receiptRecordAmount, 0) AS receiptRecordAmount,
               T1.contractAmounts - IFNULL(T2.receiptPaymentAmount, 0) - IFNULL(T4.returnAmount, 0) AS receiptableAmount,
               IFNULL(T4.returnAmount, 0) AS returnAmount
        from (select customer_id, sum(contract_amount) as contractAmounts from sales_ledger group by customer_id) T1
        left join (select customer_id, sum(collection_amount) as receiptPaymentAmount from account_sales_collection group by customer_id) T2 on T1.customer_id = T2.customer_id
        left join (select party_id, sum(amount) as paymentAmount from payment_record where party_type = 1 and direction = 1 group by party_id) T5 on T5.party_id = T1.customer_id
        left join (select party_id, sum(amount) as receiptRecordAmount from payment_record where party_type = 1 and direction = 1 group by party_id) T5 on T5.party_id = T1.customer_id
        left join (
            select
                sl.customer_id,
@@ -159,7 +159,7 @@
               sl.execution_date,
               sl.contract_amount AS contract_amount,
               IFNULL(T1.receiptPaymentAmount, 0) AS receiptPaymentAmount,
               IFNULL(T5.paymentAmount, 0) AS paymentAmount,
               IFNULL(T5.receiptRecordAmount, 0) AS receiptRecordAmount,
               sl.contract_amount - IFNULL(T1.receiptPaymentAmount, 0) - IFNULL(T3.returnAmount, 0) AS receiptableAmount,
               IFNULL(T3.returnAmount, 0) AS returnAmount
        from sales_ledger sl
@@ -185,7 +185,7 @@
            group by sl.id
        )T3 on T3.id = sl.id
        left join (
            select party_id, sum(amount) as paymentAmount
            select party_id, sum(amount) as receiptRecordAmount
            from payment_record
            where party_type = 1 and direction = 1
            group by party_id
src/main/resources/mapper/basic/SupplierManageMapper.xml
@@ -74,12 +74,12 @@
               sm.supplier_name,
               T1.contractAmounts,
               IFNULL(T2.paymentAmount, 0) AS paymentAmount,
               IFNULL(T5.receiptAmount, 0) AS receiptAmount,
               IFNULL(T5.paymentRecordAmount, 0) AS paymentRecordAmount,
               T1.contractAmounts - IFNULL(T2.paymentAmount, 0) - IFNULL(T4.returnAmount, 0) AS payableAmount,
               IFNULL(T4.returnAmount, 0) AS returnAmount
        from (select supplier_id, sum(contract_amount) as contractAmounts from purchase_ledger group by supplier_id) T1
        left join (select supplier_id, sum(payment_amount) as paymentAmount from account_purchase_payment group by supplier_id) T2 on T1.supplier_id = T2.supplier_id
        left join (select party_id, sum(amount) as receiptAmount from payment_record where party_type = 2 and direction = 2 group by party_id) T5 on T5.party_id = T1.supplier_id
        left join (select party_id, sum(amount) as paymentRecordAmount from payment_record where party_type = 2 and direction = 2 group by party_id) T5 on T5.party_id = T1.supplier_id
        left join (
            select
                supplier_id,
@@ -102,7 +102,7 @@
              pl.execution_date,
              pl.contract_amount AS contract_amount,
              IFNULL(T1.paymentAmount, 0) AS paymentAmount,
              IFNULL(T5.receiptAmount, 0) AS receiptAmount,
              IFNULL(T5.paymentRecordAmount, 0) AS paymentRecordAmount,
              pl.contract_amount - IFNULL(T1.paymentAmount, 0) - IFNULL(T3.returnAmount, 0) AS payableAmount,
              IFNULL(T3.returnAmount, 0) AS returnAmount
       from purchase_ledger pl
@@ -130,7 +130,7 @@
           group by pl.id
       )T3 on T3.id = pl.id
       left join (
           select party_id, sum(amount) as receiptAmount
           select party_id, sum(amount) as paymentRecordAmount
           from payment_record
           where party_type = 2 and direction = 2
           group by party_id
src/main/resources/mapper/outsourcing/OutsourcingPartnerMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.outsourcing.mapper.OutsourcingPartnerMapper">
    <select id="partnerListPage" resultType="com.ruoyi.outsourcing.dto.OutsourcingPartnerDto">
        SELECT
        T1.id,
        T1.partner_name,
        T1.taxpayer_identification_num,
        T1.company_address,
        T1.company_phone,
        T1.bank_account_name,
        T1.bank_account_num,
        T1.contact_user_name,
        T1.contact_user_phone,
        T1.maintain_user_id,
        T1.maintain_time,
        T1.partner_type,
        T1.is_white,
        T1.remark,
        T1.create_time,
        T1.create_user,
        T1.update_time,
        T1.tenant_id,
        T2.nick_name AS maintainUserName
        FROM outsourcing_partner T1
        LEFT JOIN sys_user T2 ON T1.maintain_user_id = T2.user_id
        <where>
            <if test="dto.partnerName != null and dto.partnerName != ''">
                AND T1.partner_name LIKE CONCAT('%',#{dto.partnerName},'%')
            </if>
            <if test="dto.partnerType != null and dto.partnerType != ''">
                AND T1.partner_type = #{dto.partnerType}
            </if>
            <if test="dto.isWhite != null">
                AND T1.is_white = #{dto.isWhite}
            </if>
        </where>
        order by T1.id desc
    </select>
    <select id="partnerExportList" resultType="com.ruoyi.outsourcing.excel.OutsourcingPartnerExcelDto">
        SELECT
        T1.partner_name,
        T1.taxpayer_identification_num,
        T1.company_address,
        T1.company_phone,
        T1.bank_account_name,
        T1.bank_account_num,
        T1.contact_user_name,
        T1.contact_user_phone,
        T1.partner_type,
        T2.nick_name AS maintainUserName
        FROM outsourcing_partner T1
        LEFT JOIN sys_user T2 ON T1.maintain_user_id = T2.user_id
        <where>
            <if test="dto.partnerName != null and dto.partnerName != ''">
                AND T1.partner_name LIKE CONCAT('%',#{dto.partnerName},'%')
            </if>
            <if test="dto.partnerType != null and dto.partnerType != ''">
                AND T1.partner_type = #{dto.partnerType}
            </if>
            <if test="dto.isWhite != null">
                AND T1.is_white = #{dto.isWhite}
            </if>
        </where>
        order by T1.id desc
    </select>
    <!-- å¾€æ¥åˆ—表:顶层 from æ˜¯å­æŸ¥è¯¢ï¼Œæ•°æ®æƒé™æ‹¦æˆªå™¨å¤©ç„¶è·³è¿‡ï¼›ä¸¤ä¾§å£å¾„必须一致,明细侧显式标记跳过 -->
    <select id="partnerTransactions" resultType="com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsVo">
        select T1.partner_id,
               op.partner_name,
               T1.contractAmounts,
               IFNULL(T2.paidAmount, 0) AS paidAmount,
               IFNULL(T3.receiptAmount, 0) AS receiptAmount,
               T1.contractAmounts - IFNULL(T2.paidAmount, 0) AS payableAmount
        from (select partner_id, sum(contract_amount) as contractAmounts
              from outsourcing_order
              where status in (1, 2)
              group by partner_id) T1
        left join (select party_id, sum(amount) as paidAmount
                   from payment_record
                   where party_type = 3 and direction = 1
                   group by party_id) T2 on T2.party_id = T1.partner_id
        left join (select party_id, sum(amount) as receiptAmount
                   from payment_record
                   where party_type = 3 and direction = 2
                   group by party_id) T3 on T3.party_id = T1.partner_id
        inner join outsourcing_partner op on T1.partner_id = op.id
        <where>
            <if test="partnerName != null and partnerName != ''">
                AND op.partner_name LIKE CONCAT('%',#{partnerName},'%')
            </if>
        </where>
        order by T1.partner_id desc
    </select>
    <!-- å¾€æ¥æ˜Žç»†ï¼šé¡¶å±‚ from æ˜¯ outsourcing_order,会被拦截器追加 create_user/dept_id æ¡ä»¶ï¼Œ
         ä¸Žåˆ—表口径不一致会导致「列表有应付、明细 0 è¡Œã€ï¼Œæ•…用 /*data_scope*/ æ˜¾å¼è·³è¿‡ -->
    <select id="partnerTransactionsDetails"
            resultType="com.ruoyi.outsourcing.vo.OutsourcingPartnerTransactionsDetailsVo">
        select /*data_scope*/ oo.id as outsourcingOrderId,
               oo.order_no,
               oo.partner_id,
               oo.partner_name,
               oo.apply_date,
               oo.contract_amount,
               oo.status,
               IFNULL(T1.paidAmount, 0) AS paidAmount,
               oo.contract_amount - IFNULL(T1.paidAmount, 0) AS payableAmount
        from outsourcing_order oo
        left join (select party_id, sum(amount) as paidAmount
                   from payment_record
                   where party_type = 3 and direction = 1
                   group by party_id) T1 on T1.party_id = oo.partner_id
        where oo.partner_id = #{partnerId}
          and oo.status in (1, 2)
        order by oo.id desc
    </select>
</mapper>
src/main/resources/mapper/sales/SalesLedgerMapper.xml
@@ -13,6 +13,22 @@
    </select>
    <!-- ä»£å‚¨é‡‘额:该客户全部「类型=代储」台账合同金额合计。跨客户汇总,加 /*data_scope*/ è·³è¿‡æ•°æ®æƒé™ï¼Œ
         å¦åˆ™éžç®¡ç†å‘˜çœ‹åˆ°çš„æ˜¯è¢« dept_id è¿‡æ»¤è¿‡çš„部分金额,会把「已超信托基金」误判成未超 -->
    <select id="selectDepositAmountByCustomerIds" resultType="com.ruoyi.sales.dto.CustomerDepositAmountDto">
        select /*data_scope*/ customer_id as customerId,
        IFNULL(sum(contract_amount), 0) as depositTotalAmount
        from sales_ledger
        where ledger_type = '代储'
        <if test="customerIds != null and customerIds.size() > 0">
            and customer_id in
            <foreach collection="customerIds" item="cid" open="(" separator="," close=")">
                #{cid}
            </foreach>
        </if>
        group by customer_id
    </select>
    <select id="selectSalesLedgerList" resultType="com.ruoyi.sales.pojo.SalesLedger">
        SELECT
        T1.id,
@@ -23,6 +39,12 @@
        T1.salesman,
        T1.customer_id,
        T1.customer_name,
        T1.ledger_type,
        T1.oil_depot_id,
        T1.oil_depot_name,
        T1.tank_id,
        T1.tank_no,
        T1.approval_status,
        T1.entry_person,
        T1.remarks,
        T1.attachment_materials,
@@ -39,6 +61,12 @@
            <if test="salesLedgerDto.customerName != null and salesLedgerDto.customerName != '' ">
                T1.customer_name LIKE CONCAT('%',#{salesLedgerDto.customerName},'%')
            </if>
            <if test="salesLedgerDto.ledgerType != null and salesLedgerDto.ledgerType != '' ">
                AND T1.ledger_type = #{salesLedgerDto.ledgerType}
            </if>
            <if test="salesLedgerDto.approvalStatus != null and salesLedgerDto.approvalStatus != '' ">
                AND T1.approval_status = #{salesLedgerDto.approvalStatus}
            </if>
        </where>
    </select>
@@ -51,6 +79,12 @@
        T1.salesman,
        T1.customer_id,
        T1.customer_name,
        T1.ledger_type,
        T1.oil_depot_id,
        T1.oil_depot_name,
        T1.tank_id,
        T1.tank_no,
        T1.approval_status,
        T1.entry_person,
        T1.remarks,
        T1.attachment_materials,
@@ -95,6 +129,12 @@
            <if test="salesLedgerDto.entryDateEnd != null and salesLedgerDto.entryDateEnd != '' ">
                AND T1.entry_date &lt;= DATE_FORMAT(#{salesLedgerDto.entryDateEnd},'%Y-%m-%d')
            </if>
            <if test="salesLedgerDto.ledgerType != null and salesLedgerDto.ledgerType != '' ">
                AND T1.ledger_type = #{salesLedgerDto.ledgerType}
            </if>
            <if test="salesLedgerDto.approvalStatus != null and salesLedgerDto.approvalStatus != '' ">
                AND T1.approval_status = #{salesLedgerDto.approvalStatus}
            </if>
        </where>
        order by T1.entry_date desc
    </select>
src/main/resources/mapper/sales/ShippingInfoMapper.xml
@@ -2,6 +2,11 @@
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.sales.mapper.ShippingInfoMapper">
    <!--
      å‘货台账列表 = å‘货单(shipping_info) UNION ALL é”€å”®ç±»æ²¹å“å‡ºåº“(stock_out_record)。
      ä¸¤åˆ†æ”¯åˆ—顺序必须严格一致(共 31 åˆ—),列名以分支 A ä¸ºå‡†ã€‚
      æ³¨æ„ï¼šæ•°æ®æƒé™æ‹¦æˆªå™¨åªç»™ç¬¬ä¸€ä¸ª from çš„表加条件,分支 B éœ€åœ¨æœ¬ XML é‡Œè‡ªè¡Œè¡¥åŒç­‰è¿‡æ»¤ã€‚
    -->
    <select id="listPage" resultType="com.ruoyi.sales.dto.ShippingInfoDto">
        SELECT
        s.id,
@@ -24,7 +29,17 @@
        p.product_name,
        sl.customer_name,
        spd.totalQuantity,
        sor.outboundBatches
        sor.outboundBatches,
        'shipping' as source,
        NULL as stockOutRecordId,
        NULL as outCategory,
        NULL as tankNo,
        NULL as oilProduct,
        NULL as oilDepotName,
        NULL as driverName,
        NULL as contactPerson,
        NULL as contactPhone,
        NULL as outBatchNo
        FROM shipping_info s
        LEFT JOIN (select shipping_info_id,sum(quantity) totalQuantity from shipping_product_detail GROUP BY shipping_info_id) spd ON spd.shipping_info_id = s.id
        LEFT JOIN sales_ledger sl ON s.sales_ledger_id = sl.id
@@ -48,6 +63,68 @@
        <if test="req.expressNumber != null and req.expressNumber != ''">
            AND s.express_number LIKE CONCAT('%',#{req.expressNumber},'%')
        </if>
        UNION ALL
        SELECT
        o.id,
        NULL as sales_ledger_id,
        o.out_date as shipping_date,
        o.truck_plate_no as shipping_car_number,
        NULL as express_number,
        NULL as express_company,
        o.outbound_batches as shipping_no,
        NULL as type,
        '已发货' as status,
        o.create_time,
        o.update_time,
        o.create_user,
        o.update_user,
        NULL as tenant_id,
        NULL as sales_contract_no,
        pm2.model as specification_model,
        pm2.unit,
        p2.product_name,
        o.customer_name,
        o.stock_out_num as totalQuantity,
        o.outbound_batches as outboundBatches,
        'oilOut' as source,
        o.id as stockOutRecordId,
        o.out_category as outCategory,
        o.tank_no as tankNo,
        o.oil_product as oilProduct,
        o.oil_depot_name as oilDepotName,
        o.driver_name as driverName,
        o.contact_person as contactPerson,
        o.contact_phone as contactPhone,
        o.batch_no as outBatchNo
        FROM stock_out_record o
        left join product_model pm2 on pm2.id = o.product_model_id
        left join product p2 on p2.id = pm2.product_id
        WHERE o.out_category = '销售' AND o.approval_status = 1
        <!-- æ²¹å“å‡ºåº“没有合同号/快递单,按这两项筛选时不应返回任何油品行 -->
        <if test="req.salesContractNo != null and req.salesContractNo != ''">
            AND 1=0
        </if>
        <if test="req.expressNumber != null and req.expressNumber != ''">
            AND 1=0
        </if>
        <if test="req.shippingCarNumber != null and req.shippingCarNumber != ''">
            AND o.truck_plate_no LIKE CONCAT('%',#{req.shippingCarNumber},'%')
        </if>
        <if test="req.shippingNo != null and req.shippingNo != ''">
            AND o.outbound_batches LIKE CONCAT('%',#{req.shippingNo},'%')
        </if>
        <if test="req.createUser != null">
            AND o.create_user = #{req.createUser}
        </if>
        <if test="req.deptId != null">
            AND o.dept_id = #{req.deptId}
        </if>
        <if test="req.deptIds != null and req.deptIds.length > 0">
            AND o.dept_id IN
            <foreach collection="req.deptIds" item="deptId" open="(" separator="," close=")">
                #{deptId}
            </foreach>
        </if>
        order by create_time DESC
    </select>
    <select id="listAll" resultType="com.ruoyi.sales.pojo.ShippingInfo">
src/main/resources/mapper/stock/StockInventoryMapper.xml
@@ -86,6 +86,48 @@
        </if>
    </select>
    <!-- æŠ¥åºŸå“é€‰æ–™ï¼šåªæŸ¥äº§å“æ ‘「危废处置次生危废」大类(含其所有子节点)下的合格库存,带批号。
         å¤§ç±»åæŒ‰ä¸šåŠ¡å£å¾„å†™æ­»åœ¨è¿™é‡Œï¼Œæ”¹å£å¾„åªéœ€æ”¹è¿™ä¸€å¤„ã€‚
         è¯¥æŸ¥è¯¢ä»¥ WITH å¼€å¤´ï¼Œè€Œæ•°æ®æƒé™æ‹¦æˆªå™¨åªå¤„理 select å¼€å¤´çš„ SQL,因此不会追加 dept_id/create_user,
         ä¸Žã€Œåº“存是共用基础数据、选料不应受建单人限制」的口径一致;保留 /*data_scope*/ æ ‡è®°è®©è¿™ä¸ªæ„å›¾æ˜¾å¼å¯è¯»ã€‚
         ä¸å½±å“åŽŸ pagestockInventory -->
    <select id="selectScrapStockPage" resultType="com.ruoyi.stock.dto.StockInventoryDto">
        WITH RECURSIVE scrap_product_tree AS (
            SELECT id
            FROM product
            WHERE product_name = '危废处置次生危废'
            UNION ALL
            SELECT p.id
            FROM product p
            INNER JOIN scrap_product_tree t ON p.parent_id = t.id
        )
        select /*data_scope*/ si.id,
        si.product_model_id,
        si.batch_no,
        si.qualitity,
        COALESCE(si.locked_quantity, 0) as locked_quantity,
        (si.qualitity - COALESCE(si.locked_quantity, 0)) as un_locked_quantity,
        si.remark,
        si.version,
        pm.model,
        pm.unit,
        p.product_name
        from stock_inventory si
        left join product_model pm on si.product_model_id = pm.id
        left join product p on pm.product_id = p.id
        where pm.product_id in (select id from scrap_product_tree)
        <if test="ew.productModelId != null">
            and si.product_model_id = #{ew.productModelId}
        </if>
        <if test="ew.batchNo != null and ew.batchNo != ''">
            and si.batch_no like concat('%',#{ew.batchNo},'%')
        </if>
        <if test="ew.productName != null and ew.productName != ''">
            and p.product_name like concat('%',#{ew.productName},'%')
        </if>
        order by si.update_time desc
    </select>
    <select id="pageListCombinedStockInventory" resultType="com.ruoyi.stock.dto.StockInventoryDto">
        WITH RECURSIVE product_tree AS (
        SELECT id
src/main/resources/mapper/stock/StockOutRecordMapper.xml
@@ -64,6 +64,18 @@
        </where>
        order by sor.id desc
    </select>
    <!-- æŒ‰æ¥æºæ˜Žç»†æŸ¥å‡ºåº“记录:加 /*data_scope*/ è·³è¿‡æ•°æ®æƒé™ï¼Œå¦åˆ™æ¢è´¦å·ä¼šæŸ¥ä¸åˆ°è®°å½•,
         å¯¼è‡´å§”外的「完成校验」被静默绕过、「作废释放占用」失效 -->
    <select id="selectByRecordIds" resultType="com.ruoyi.stock.pojo.StockOutRecord">
        select /*data_scope*/ *
        from stock_out_record
        where record_type = #{recordType}
        and record_id in
        <foreach collection="recordIds" item="recordId" open="(" separator="," close=")">
            #{recordId}
        </foreach>
    </select>
    <select id="listStockOutRecordExportData" resultType="com.ruoyi.stock.execl.StockOutRecordExportData">
        SELECT
        sor.*,