云
4 天以前 ccdebc5369c108e6616550bfbe1cd47ec1e05813
feat(bpm): 接入询价单与项目用印审批流程

- 新增询价单审核状态枚举及流程实例字段
- 实现询价单提交审核接口与审批状态回调监听
- 调整询价单编辑与状态流转的校验逻辑
- 新增项目用印管理及申请相关的数据库表与菜单
- 实现印章上传、用印申请及审批状态监听功能
- 改造跟踪表导出逻辑以生成快照并嵌入审批印章
- 添加用印审批流程定义文件及数据库变更脚本
已添加9个文件
已修改8个文件
430 ■■■■■ 文件已修改
sql/bpm/hrm-seal-use-approval.bpmn 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/20261109_pd_seal.sql 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/srm_inquiry_approval_20261109.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pd/project/MesPdProjectController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pd/seal/PdSealController.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/seal/PdSealService.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/seal/PdSealStatusListener.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/seal/PdSealServiceTest.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/inquiry/SrmInquiryController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/inquiry/vo/SrmInquiryRespVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/dal/dataobject/SrmInquiryDO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/dal/mysql/SrmInquiryMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/enums/InquiryStatusEnum.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryApprovalListener.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryServiceImpl.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryApprovalTest.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/bpm/hrm-seal-use-approval.bpmn
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" targetNamespace="http://flowable.org/processdef">
  <process id="hrm-seal-use-approval" name="项目跟踪表用印审批" isExecutable="true">
    <startEvent id="StartEvent" name="提交申请" />
    <userTask id="SealApproval" name="用印审批">
      <extensionElements>
        <flowable:candidateStrategy>30</flowable:candidateStrategy>
        <flowable:candidateParam>1</flowable:candidateParam>
        <flowable:approveType>1</flowable:approveType>
        <flowable:approveMethod>1</flowable:approveMethod>
        <flowable:assignStartUserHandlerType>1</flowable:assignStartUserHandlerType>
        <flowable:assignEmptyHandlerType>2</flowable:assignEmptyHandlerType>
      </extensionElements>
    </userTask>
    <endEvent id="EndEvent" name="结束" />
    <sequenceFlow id="flow1" sourceRef="StartEvent" targetRef="SealApproval" />
    <sequenceFlow id="flow2" sourceRef="SealApproval" targetRef="EndEvent" />
  </process>
  <bpmndi:BPMNDiagram id="Diagram"><bpmndi:BPMNPlane id="Plane" bpmnElement="hrm-seal-use-approval">
    <bpmndi:BPMNShape id="StartShape" bpmnElement="StartEvent"><dc:Bounds x="160" y="132" width="36" height="36" /></bpmndi:BPMNShape>
    <bpmndi:BPMNShape id="ApprovalShape" bpmnElement="SealApproval"><dc:Bounds x="260" y="110" width="120" height="80" /></bpmndi:BPMNShape>
    <bpmndi:BPMNShape id="EndShape" bpmnElement="EndEvent"><dc:Bounds x="450" y="132" width="36" height="36" /></bpmndi:BPMNShape>
    <bpmndi:BPMNEdge id="Flow1Edge" bpmnElement="flow1"><di:waypoint x="196" y="150" /><di:waypoint x="260" y="150" /></bpmndi:BPMNEdge>
    <bpmndi:BPMNEdge id="Flow2Edge" bpmnElement="flow2"><di:waypoint x="380" y="150" /><di:waypoint x="450" y="150" /></bpmndi:BPMNEdge>
  </bpmndi:BPMNPlane></bpmndi:BPMNDiagram>
</definitions>
sql/mysql/20261109_pd_seal.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
-- ç”¨å°ç®¡ç†ï¼šå›¾ç‰‡å’Œæ–‡æ¡£å¿«ç…§å­˜æ•°æ®åº“,不使用公开附件地址。
CREATE TABLE IF NOT EXISTS pd_seal (
 id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT NOT NULL DEFAULT 0, name VARCHAR(100) NOT NULL,
 image_data MEDIUMBLOB NOT NULL, image_hash CHAR(64) NOT NULL, enabled BIT NOT NULL DEFAULT 1,
 created_by BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 deleted BIT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pd_seal_application (
 id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT NOT NULL DEFAULT 0, project_id BIGINT NOT NULL, project_code VARCHAR(128),
 seal_id BIGINT NOT NULL, seal_name VARCHAR(100) NOT NULL, seal_image MEDIUMBLOB NOT NULL,
 document_snapshot MEDIUMBLOB NOT NULL, reason VARCHAR(500) NOT NULL, applicant_id BIGINT NOT NULL,
 status INT NOT NULL DEFAULT 1, process_instance_id VARCHAR(128),
 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME,
 INDEX idx_seal_app_user_project (applicant_id,project_id), INDEX idx_seal_app_process (process_instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS pd_seal_download_log (
 id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT NOT NULL DEFAULT 0, application_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_seal_download_app (application_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- æ ¹æ®çŽ°æœ‰çš„äººåŠ›èµ„æºç›®å½•å®šä½ï¼›å¦‚æ²¡æœ‰è¯¥ç›®å½•ï¼Œéœ€æ‰‹å·¥åœ¨èœå•ç®¡ç†ä¸­åˆ›å»ºã€‚
SET @hrm_parent = (SELECT id FROM system_menu WHERE name='人力资源' AND type=1 AND deleted=b'0' ORDER BY id LIMIT 1);
INSERT INTO system_menu(name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater,deleted)
SELECT '用印管理','',2,90,@hrm_parent,'seal','ep:stamp','hrm/seal/index','HrmSeal',0,b'1',b'0',b'1','1','1',b'0'
WHERE @hrm_parent IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component='hrm/seal/index' AND deleted=b'0');
SET @seal_menu = (SELECT id FROM system_menu WHERE component='hrm/seal/index' AND deleted=b'0' ORDER BY id LIMIT 1);
INSERT INTO system_menu(name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater,deleted)
SELECT '印章维护','mes:pd-seal:manage',3,1,@seal_menu,'','','','',0,b'1',b'0',b'1','1','1',b'0'
WHERE @seal_menu IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='mes:pd-seal:manage' AND deleted=b'0');
INSERT INTO system_role_menu(role_id,menu_id,creator,updater,deleted)
SELECT r.id,m.id,'1','1',b'0' FROM system_role r JOIN system_menu m ON m.deleted=b'0' AND (m.id=@seal_menu OR m.parent_id=@seal_menu)
WHERE r.code='super_admin' AND r.deleted=b'0' AND NOT EXISTS (SELECT 1 FROM system_role_menu rm WHERE rm.role_id=r.id AND rm.menu_id=m.id AND rm.deleted=b'0');
sql/mysql/srm_inquiry_approval_20261109.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
-- æ‰§è¡Œå‰ç¡®è®¤å½“前数据库包含 srm_inquiry,且尚未添加此字段。
ALTER TABLE srm_inquiry ADD COLUMN process_instance_id varchar(64) DEFAULT NULL COMMENT 'BPM流程实例编号';
-- åœ¨ BPM æµç¨‹ç®¡ç†ä¸­åˆ›å»ºå¹¶å‘布流程标识 srm_inquiry_approve,配置审批节点和审批人。
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pd/project/MesPdProjectController.java
@@ -122,6 +122,11 @@
    @ApiAccessLog(operateType = EXPORT)
    public void exportProjectTrackingWord(@RequestParam("id") Long id,
                                          HttpServletResponse response) throws IOException {
        throw new cn.iocoder.yudao.framework.common.exception.ServiceException(1_040_990_001, "跟踪表用印须先提交审批,请使用用印申请");
    }
    /** ä»…供用印申请生成不可变快照。 */
    public byte[] createTrackingSnapshot(Long id) throws IOException {
        MesPdProjectDO project = projectService.getProject(id);
        if (project == null) {
            throw exception(PD_PROJECT_NOT_EXISTS);
@@ -141,13 +146,8 @@
        fields.put("remark", project.getRemark());
        var companyConfig = companyConfigService.getCompanyConfig();
        byte[] content = MesWmDocumentRenderer.renderProjectTracking(fields,
        return MesWmDocumentRenderer.renderProjectTracking(fields,
                companyConfig != null ? companyConfig.getCompanyName() : null);
        String filename = "项目跟踪表-" + project.getTaskCode() + ".docx";
        String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
        response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + encoded);
        response.getOutputStream().write(content);
    }
    private String resolveStatusName(Integer status) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pd/seal/PdSealController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.mes.controller.admin.pd.seal;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.controller.admin.pd.project.MesPdProjectController;
import cn.iocoder.yudao.module.mes.service.pd.project.MesPdProjectService;
import cn.iocoder.yudao.module.mes.service.pd.seal.PdSealService;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@RestController
@RequestMapping("/mes/pd/seal")
public class PdSealController {
    @Resource private PdSealService service;
    @Resource private MesPdProjectService projectService;
    @Resource private MesPdProjectController projectController;
    @GetMapping("/list") @PreAuthorize("@ss.hasPermission('mes:pd-seal:manage')")
    public CommonResult<List<Map<String,Object>>> list(){ return success(service.seals(false)); }
    @GetMapping("/available") @PreAuthorize("@ss.hasPermission('mes:pd-project:export')")
    public CommonResult<List<Map<String,Object>>> available(){ return success(service.seals(true)); }
    @PostMapping("/upload") @PreAuthorize("@ss.hasPermission('mes:pd-seal:manage')")
    public CommonResult<Long> upload(@RequestParam String name,@RequestParam MultipartFile file) throws Exception { return success(service.upload(name,file,getLoginUserId())); }
    @PutMapping("/enabled") @PreAuthorize("@ss.hasPermission('mes:pd-seal:manage')")
    public CommonResult<Boolean> enabled(@RequestParam long id,@RequestParam boolean enabled){service.setEnabled(id,enabled);return success(true);}
    public record ApplyReq(Long projectId, Long sealId, String reason) {}
    @PostMapping("/apply") @PreAuthorize("@ss.hasPermission('mes:pd-project:export')")
    public CommonResult<Long> apply(@RequestBody ApplyReq req) throws Exception {
        if(req.projectId()==null || req.sealId()==null) throw new cn.iocoder.yudao.framework.common.exception.ServiceException(1_040_990_001,"请选择项目和印章");
        var project=projectService.getProject(req.projectId());
        if(project==null) throw new cn.iocoder.yudao.framework.common.exception.ServiceException(1_040_990_001,"项目不存在");
        return success(service.apply(req.projectId(),project.getTaskCode(),req.sealId(),req.reason(),getLoginUserId(),projectController.createTrackingSnapshot(req.projectId())));
    }
    @GetMapping("/applications") @PreAuthorize("@ss.hasPermission('mes:pd-project:export')")
    public CommonResult<List<Map<String,Object>>> applications(@RequestParam(required=false) Long projectId){ return success(service.applications(getLoginUserId(),projectId)); }
    @GetMapping("/download") @PreAuthorize("@ss.hasPermission('mes:pd-project:export')")
    public void download(@RequestParam long id,HttpServletResponse response) throws Exception {byte[] bytes=service.download(id,getLoginUserId()); response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); response.setHeader("Content-Disposition","attachment; filename=tracking.docx"); response.getOutputStream().write(bytes);}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/seal/PdSealService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
package cn.iocoder.yudao.module.mes.service.pd.seal;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.io.*;
import java.sql.Statement;
import java.security.MessageDigest;
import java.util.*;
import java.util.zip.*;
@Service
public class PdSealService {
    public static final String PROCESS_KEY = "hrm-seal-use-approval";
    @Resource private JdbcTemplate jdbc;
    @Resource private BpmProcessInstanceApi bpm;
    @Resource private cn.iocoder.yudao.module.bpm.service.definition.BpmProcessAutoApproveService autoApprove;
    @Resource private cn.iocoder.yudao.module.bpm.service.task.BpmTaskService taskService;
    private long tenant() { Long id = cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder.getTenantId(); return id == null ? 0L : id; }
    private ServiceException fail(String text) { return new ServiceException(1_040_990_001, text); }
    public List<Map<String,Object>> seals(boolean activeOnly) {
        return jdbc.queryForList("SELECT id,name,enabled,create_time FROM pd_seal WHERE tenant_id=? AND deleted=0" + (activeOnly ? " AND enabled=1" : "") + " ORDER BY id DESC", tenant());
    }
    public long upload(String name, MultipartFile file, Long userId) throws Exception {
        if (name == null || name.isBlank() || name.length() > 100) throw fail("印章名称不能为空且不能超过100字");
        if (file == null || file.isEmpty() || file.getSize() > 2 * 1024 * 1024) throw fail("请上传不超过2MB的PNG印章图片");
        byte[] data = file.getBytes();
        byte[] sig = {(byte)137,80,78,71,13,10,26,10};
        if (data.length < 8 || !Arrays.equals(sig, Arrays.copyOf(data, 8))) throw fail("仅支持PNG图片");
        try (var input = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) {
            var readers = ImageIO.getImageReaders(input);
            if (!readers.hasNext()) throw fail("图片无法读取");
            var reader = readers.next();
            try { reader.setInput(input); if (reader.getWidth(0) > 2048 || reader.getHeight(0) > 2048) throw fail("图片尺寸不能超过2048×2048"); reader.read(0); }
            finally { reader.dispose(); }
        }
        String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data));
        var keys = new GeneratedKeyHolder();
        jdbc.update(c -> { var ps=c.prepareStatement("INSERT INTO pd_seal(name,image_data,image_hash,enabled,created_by,tenant_id) VALUES(?,?,?,1,?,?)", Statement.RETURN_GENERATED_KEYS); ps.setString(1,name.trim()); ps.setBytes(2,data); ps.setString(3,hash); ps.setLong(4,userId); ps.setLong(5,tenant()); return ps; }, keys);
        return Objects.requireNonNull(keys.getKey()).longValue();
    }
    public void setEnabled(long id, boolean enabled) {
        if (jdbc.update("UPDATE pd_seal SET enabled=? WHERE id=? AND tenant_id=? AND deleted=0", enabled, id, tenant()) != 1) throw fail("印章不存在");
    }
    @Transactional(rollbackFor=Exception.class)
    public long apply(long projectId, String projectCode, long sealId, String reason, Long userId, byte[] snapshot) {
        if (reason == null || reason.isBlank() || reason.length() > 500) throw fail("请填写不超过500字的用印事由");
        if (autoApprove.isAutoApprove(PROCESS_KEY)) throw fail("用印流程开启了免审批,请先关闭后再提交");
        List<Map<String,Object>> seals=jdbc.queryForList("SELECT name,image_data FROM pd_seal WHERE id=? AND tenant_id=? AND enabled=1 AND deleted=0 FOR UPDATE",sealId,tenant());
        if (seals.isEmpty()) throw fail("印章不存在或已停用");
        var seal=seals.get(0); var keys=new GeneratedKeyHolder();
        jdbc.update(c -> { var ps=c.prepareStatement("INSERT INTO pd_seal_application(project_id,project_code,seal_id,seal_name,seal_image,document_snapshot,reason,applicant_id,status,tenant_id) VALUES(?,?,?,?,?,?,?,?,1,?)",Statement.RETURN_GENERATED_KEYS); ps.setLong(1,projectId); ps.setString(2,projectCode); ps.setLong(3,sealId); ps.setString(4,(String)seal.get("name")); ps.setBytes(5,(byte[])seal.get("image_data")); ps.setBytes(6,snapshot); ps.setString(7,reason.trim()); ps.setLong(8,userId); ps.setLong(9,tenant()); return ps; },keys);
        long id=Objects.requireNonNull(keys.getKey()).longValue();
        var variables=new HashMap<String,Object>(); variables.put("projectCode", projectCode); variables.put("sealName",seal.get("name")); variables.put("reason",reason.trim());
        String instance=bpm.createProcessInstance(userId,new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(PROCESS_KEY).setBusinessKey(String.valueOf(id)).setVariables(variables));
        jdbc.update("UPDATE pd_seal_application SET process_instance_id=? WHERE id=? AND tenant_id=? AND (process_instance_id IS NULL OR process_instance_id=?)",instance,id,tenant(),instance);
        return id;
    }
    public void status(BpmProcessInstanceStatusEvent event) {
        if (!PROCESS_KEY.equals(event.getProcessDefinitionKey()) || event.getStatus() == null || event.getStatus() < 2 || event.getStatus() > 4) return;
        try { long id=Long.parseLong(event.getBusinessKey()); jdbc.update("UPDATE pd_seal_application SET status=?,process_instance_id=?,finish_time=NOW() WHERE id=? AND tenant_id=? AND status=1 AND (process_instance_id IS NULL OR process_instance_id=?)",event.getStatus(),event.getId(),id,tenant(),event.getId()); } catch (NumberFormatException ignored) { }
    }
    public List<Map<String,Object>> applications(long userId, Long projectId) {
        return jdbc.queryForList("SELECT id,project_id,project_code,seal_name,reason,status,process_instance_id,create_time,finish_time FROM pd_seal_application WHERE tenant_id=? AND applicant_id=?"+(projectId == null ? "" : " AND project_id="+projectId)+" ORDER BY id DESC LIMIT 200",tenant(),userId);
    }
    @Transactional(rollbackFor=Exception.class)
    public byte[] download(long id,long userId) throws IOException {
        var rows=jdbc.queryForList("SELECT document_snapshot,seal_image,process_instance_id FROM pd_seal_application WHERE id=? AND tenant_id=? AND applicant_id=? AND status=2",id,tenant(),userId);
        if(rows.isEmpty()) throw fail("申请不存在、无权下载或审批尚未通过");
        String instance=(String) rows.get(0).get("process_instance_id");
        if (instance == null || instance.isBlank()) throw fail("审批记录不完整,无法下载");
        boolean humanApproval = taskService.getTaskListByProcessInstanceId(instance, true).stream().anyMatch(task -> {
            var vars = task.getTaskLocalVariables();
            if (vars == null || !Integer.valueOf(2).equals(vars.get("TASK_STATUS")) || task.getAssignee() == null || task.getAssignee().equals(String.valueOf(userId))) return false;
            Object reason = vars.get("TASK_REASON");
            return Arrays.stream(cn.iocoder.yudao.module.bpm.enums.task.BpmReasonEnum.values()).noneMatch(item -> Objects.equals(item.getReason(), reason));
        });
        if (!humanApproval) throw fail("未找到其他审批人的人工通过记录,无法下载");
        byte[] snapshot=(byte[])rows.get(0).get("document_snapshot"), image=(byte[])rows.get(0).get("seal_image");
        var out=new ByteArrayOutputStream(); boolean found=false;
        try(var zin=new ZipInputStream(new ByteArrayInputStream(snapshot)); var zout=new ZipOutputStream(out)) { ZipEntry entry; while((entry=zin.getNextEntry())!=null){byte[] bytes=zin.readAllBytes(); if(entry.getName().equals("word/media/seal.png")){bytes=image;found=true;} zout.putNextEntry(new ZipEntry(entry.getName())); zout.write(bytes); zout.closeEntry();} }
        if(!found) throw fail("跟踪表模板缺少印章位置");
        jdbc.update("INSERT INTO pd_seal_download_log(application_id,user_id,tenant_id) VALUES(?,?,?)",id,userId,tenant());
        return out.toByteArray();
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/seal/PdSealStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
package cn.iocoder.yudao.module.mes.service.pd.seal;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
@Component
public class PdSealStatusListener extends BpmProcessInstanceStatusEventListener {
    @Resource private PdSealService service;
    @Override protected String getProcessDefinitionKey() { return PdSealService.PROCESS_KEY; }
    @Override protected void onEvent(BpmProcessInstanceStatusEvent event) { service.status(event); }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pd/seal/PdSealServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.mes.service.pd.seal;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.bpm.service.task.BpmTaskService;
import org.flowable.task.api.history.HistoricTaskInstance;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import java.io.*;
import java.util.*;
import java.util.zip.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PdSealServiceTest {
    @InjectMocks PdSealService service;
    @Mock JdbcTemplate jdbc;
    @Mock BpmTaskService tasks;
    @Test void refusesDownloadWithoutApprovedApplication() {
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
        assertThrows(ServiceException.class, () -> service.download(1, 7));
        verifyNoInteractions(tasks);
    }
    @Test void refusesAutomaticApproval() throws Exception {
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of(Map.of("process_instance_id", "p", "document_snapshot", new byte[]{1}, "seal_image", new byte[]{2})));
        HistoricTaskInstance task=mock(HistoricTaskInstance.class);
        when(task.getAssignee()).thenReturn("8");
        when(task.getTaskLocalVariables()).thenReturn(Map.of("TASK_STATUS",2,"TASK_REASON","流程已配置免审批,提交后自动通过"));
        when(tasks.getTaskListByProcessInstanceId("p",true)).thenReturn(List.of(task));
        assertThrows(ServiceException.class, () -> service.download(1,7));
    }
    @Test void approvedDownloadEmbedsSelectedImage() throws Exception {
        var buffer=new ByteArrayOutputStream();
        try(var zip=new ZipOutputStream(buffer)){zip.putNextEntry(new ZipEntry("word/media/seal.png"));zip.write(new byte[]{1});zip.closeEntry();}
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of(Map.of("process_instance_id","p","document_snapshot",buffer.toByteArray(),"seal_image",new byte[]{2,3})));
        HistoricTaskInstance task=mock(HistoricTaskInstance.class);
        when(task.getAssignee()).thenReturn("8");
        when(task.getTaskLocalVariables()).thenReturn(Map.of("TASK_STATUS",2,"TASK_REASON","同意"));
        when(tasks.getTaskListByProcessInstanceId("p",true)).thenReturn(List.of(task));
        byte[] result=service.download(1,7);
        try(var zip=new ZipInputStream(new ByteArrayInputStream(result))){assertEquals("word/media/seal.png",zip.getNextEntry().getName());assertArrayEquals(new byte[]{2,3},zip.readAllBytes());}
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/inquiry/SrmInquiryController.java
@@ -73,6 +73,14 @@
        return success(BeanUtils.toBean(list, SrmInquiryRespVO.class));
    }
    @PutMapping("/submit-approval")
    @Operation(summary = "提交询价审核")
    @PreAuthorize("@ss.hasPermission('srm:inquiry:confirm')")
    public CommonResult<Boolean> submitApproval(@RequestParam("id") Long id) {
        inquiryService.submitApproval(id, cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId());
        return success(true);
    }
    @PutMapping("/publish")
    @Operation(summary = "发布询价单:草稿 -> è¯¢ä»·ä¸­")
    @Parameter(name = "id", description = "编号", required = true)
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/inquiry/vo/SrmInquiryRespVO.java
@@ -23,6 +23,7 @@
    @Schema(description = "状态:0 è‰ç¨¿ã€10 è¯¢ä»·ä¸­ã€20 å·²è½¬è®¢å•、30 å·²å–消")
    private Integer status;
    private String processInstanceId;
    @Schema(description = "询价日期")
    private LocalDate inquiryDate;
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/dal/dataobject/SrmInquiryDO.java
@@ -49,5 +49,6 @@
     */
    private Long purchaserUserId;
    private String remark;
    private String processInstanceId;
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/dal/mysql/SrmInquiryMapper.java
@@ -22,6 +22,10 @@
                .orderByDesc(SrmInquiryDO::getId));
    }
    default SrmInquiryDO selectByIdForUpdate(Long id) {
        return selectOne(new LambdaQueryWrapperX<SrmInquiryDO>().eq(SrmInquiryDO::getId, id).last("FOR UPDATE"));
    }
    default SrmInquiryDO selectByNo(String no) {
        return selectOne(SrmInquiryDO::getNo, no);
    }
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/enums/InquiryStatusEnum.java
@@ -16,7 +16,10 @@
    DRAFT(0, "草稿"),
    QUOTING(10, "询价中"),
    CONFIRMED(20, "已转订单"),
    CANCELLED(30, "已取消");
    CANCELLED(30, "已取消"),
    APPROVING(40, "审核中"),
    APPROVED(50, "审核通过"),
    REJECTED(60, "审核未通过");
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(InquiryStatusEnum::getStatus).toArray(Integer[]::new);
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryApprovalListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.srm.service.inquiry;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
@Component
public class SrmInquiryApprovalListener extends BpmProcessInstanceStatusEventListener {
    @Resource
    private SrmInquiryService inquiryService;
    @Override
    protected String getProcessDefinitionKey() { return "srm_inquiry_approve"; }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        inquiryService.updateApprovalStatus(Long.valueOf(event.getBusinessKey()), event.getId(), event.getStatus());
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryService.java
@@ -36,6 +36,8 @@
     * å‘布询价单:草稿 -> è¯¢ä»·ä¸­
     */
    void publishInquiry(Long id);
    void submitApproval(Long id, Long userId);
    void updateApprovalStatus(Long id, String processInstanceId, Integer status);
    /**
     * å–消询价单:草稿/询价中 -> å·²å–消
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryServiceImpl.java
@@ -7,6 +7,9 @@
import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.erp.api.purchase.ErpPurchaseOrderApi;
import cn.iocoder.yudao.module.erp.api.purchase.dto.ErpPurchaseOrderCreateReqDTO;
import cn.iocoder.yudao.module.erp.api.purchase.dto.ErpPurchaseOrderRespDTO;
@@ -48,6 +51,8 @@
    @Resource
    private SrmInquiryMapper inquiryMapper;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    @Resource
    private SrmInquiryItemMapper inquiryItemMapper;
    @Resource
@@ -91,8 +96,9 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateInquiry(SrmInquirySaveReqVO updateReqVO) {
        SrmInquiryDO inquiry = validateInquiryExists(updateReqVO.getId());
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())) {
        SrmInquiryDO inquiry = validateInquiryForUpdate(updateReqVO.getId());
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.REJECTED.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_EDITABLE.getCode(),
                    "询价单【" + inquiry.getNo() + "】当前状态为【" + statusName(inquiry.getStatus())
                            + "】,仅草稿状态可编辑。如需调整产品行或供应商,请取消后重新创建询价单。");
@@ -123,7 +129,7 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteInquiry(Long id) {
        SrmInquiryDO inquiry = validateInquiryExists(id);
        SrmInquiryDO inquiry = validateInquiryForUpdate(id);
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_EDITABLE.getCode(),
                    "询价单【" + inquiry.getNo() + "】当前状态为【" + statusName(inquiry.getStatus())
@@ -161,8 +167,52 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitApproval(Long id, Long userId) {
        SrmInquiryDO inquiry = validateInquiryForUpdate(id);
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.QUOTING.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.REJECTED.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_CONFIRMABLE.getCode(), "当前状态不可提交审核");
        }
        List<SrmInquiryItemDO> items = inquiryItemMapper.selectListByInquiryId(id);
        if (CollUtil.isEmpty(items)) throw ServiceExceptionUtil.exception(ErrorCodeConstants.INQUIRY_ITEM_EMPTY);
        Map<Long, SrmInquiryQuoteDO> quotes = inquiryQuoteMapper.selectListByInquiryId(id).stream()
                .collect(Collectors.toMap(SrmInquiryQuoteDO::getId, Function.identity()));
        Set<Long> suppliers = inquirySupplierMapper.selectListByInquiryId(id).stream()
                .map(SrmInquirySupplierDO::getSupplierId).collect(Collectors.toSet());
        for (SrmInquiryItemDO item : items) {
            SrmInquiryQuoteDO quote = quotes.get(item.getSelectedQuoteId());
            if (quote == null || !item.getId().equals(quote.getInquiryItemId())
                    || !suppliers.contains(quote.getSupplierId()) || quote.getPrice() == null
                    || quote.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
                throw new ServiceException(ErrorCodeConstants.INQUIRY_WINNER_REQUIRED.getCode(), "请先为每个产品选择有效的中标报价");
            }
            inquiryItemMapper.updateById(new SrmInquiryItemDO().setId(item.getId())
                    .setSelectedSupplierId(quote.getSupplierId()).setSelectedPrice(quote.getPrice()));
        }
        inquiryMapper.updateById(new SrmInquiryDO().setId(id).setStatus(InquiryStatusEnum.APPROVING.getStatus()).setProcessInstanceId(""));
        String processId = processInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey("srm_inquiry_approve").setBusinessKey(String.valueOf(id))
                .setVariables(Map.of("inquiryNo", inquiry.getNo(), "inquiryTitle", inquiry.getTitle())));
        inquiryMapper.updateById(new SrmInquiryDO().setId(id).setProcessInstanceId(processId));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateApprovalStatus(Long id, String processInstanceId, Integer status) {
        SrmInquiryDO inquiry = validateInquiryForUpdate(id);
        if (!InquiryStatusEnum.APPROVING.getStatus().equals(inquiry.getStatus())
                || (StrUtil.isNotEmpty(inquiry.getProcessInstanceId()) && !Objects.equals(inquiry.getProcessInstanceId(), processInstanceId))) return;
        Integer next = null;
        if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(status)) next = InquiryStatusEnum.APPROVED.getStatus();
        if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(status) || BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(status)) next = InquiryStatusEnum.REJECTED.getStatus();
        if (next != null) inquiryMapper.updateById(new SrmInquiryDO().setId(id).setStatus(next));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void publishInquiry(Long id) {
        SrmInquiryDO inquiry = validateInquiryExists(id);
        SrmInquiryDO inquiry = validateInquiryForUpdate(id);
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_QUOTING.getCode(),
                    "询价单【" + inquiry.getNo() + "】当前状态为【" + statusName(inquiry.getStatus())
@@ -175,9 +225,10 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelInquiry(Long id) {
        SrmInquiryDO inquiry = validateInquiryExists(id);
        SrmInquiryDO inquiry = validateInquiryForUpdate(id);
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.QUOTING.getStatus().equals(inquiry.getStatus())) {
                && !InquiryStatusEnum.QUOTING.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.REJECTED.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_CANCELLABLE.getCode(),
                    "询价单【" + inquiry.getNo() + "】当前状态为【" + statusName(inquiry.getStatus())
                            + "】,仅草稿或询价中状态可取消。");
@@ -189,9 +240,10 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void saveQuotes(SrmInquiryQuoteSaveReqVO saveReqVO) {
        SrmInquiryDO inquiry = validateInquiryExists(saveReqVO.getInquiryId());
        SrmInquiryDO inquiry = validateInquiryForUpdate(saveReqVO.getInquiryId());
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.QUOTING.getStatus().equals(inquiry.getStatus())) {
                && !InquiryStatusEnum.QUOTING.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.REJECTED.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_QUOTING.getCode(),
                    "询价单【" + inquiry.getNo() + "】当前状态为【" + statusName(inquiry.getStatus())
                            + "】,仅草稿或询价中状态可录入报价。");
@@ -269,12 +321,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SrmInquiryConfirmRespVO confirmInquiry(SrmInquiryConfirmReqVO confirmReqVO) {
        SrmInquiryDO inquiry = validateInquiryExists(confirmReqVO.getId());
        if (!InquiryStatusEnum.DRAFT.getStatus().equals(inquiry.getStatus())
                && !InquiryStatusEnum.QUOTING.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_CONFIRMABLE.getCode(),
                    "询价单【" + inquiry.getNo() + "】当前状态为【" + statusName(inquiry.getStatus())
                            + "】,仅草稿或询价中状态可确认转订单。");
        SrmInquiryDO inquiry = validateInquiryForUpdate(confirmReqVO.getId());
        if (!InquiryStatusEnum.APPROVED.getStatus().equals(inquiry.getStatus())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_CONFIRMABLE.getCode(), "询价单审核通过后才能转订单");
        }
        if (CollUtil.isNotEmpty(confirmReqVO.getSelections())) {
            throw new ServiceException(ErrorCodeConstants.INQUIRY_STATUS_NOT_CONFIRMABLE.getCode(), "审核通过后不能更改中标结果");
        }
        List<SrmInquiryItemDO> items = inquiryItemMapper.selectListByInquiryId(inquiry.getId());
        if (CollUtil.isEmpty(items)) {
@@ -647,6 +699,12 @@
    // ========== æ ¡éªŒ ==========
    private SrmInquiryDO validateInquiryForUpdate(Long id) {
        SrmInquiryDO inquiry = inquiryMapper.selectByIdForUpdate(id);
        if (inquiry == null) throw ServiceExceptionUtil.exception(ErrorCodeConstants.INQUIRY_NOT_EXISTS);
        return inquiry;
    }
    private SrmInquiryDO validateInquiryExists(Long id) {
        SrmInquiryDO inquiry = inquiryMapper.selectById(id);
        if (inquiry == null) {
yudao-module-srm/src/test/java/cn/iocoder/yudao/module/srm/service/inquiry/SrmInquiryApprovalTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.srm.service.inquiry;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
import cn.iocoder.yudao.module.srm.controller.admin.inquiry.vo.SrmInquiryConfirmReqVO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmInquiryDO;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmInquiryMapper;
import cn.iocoder.yudao.module.srm.enums.InquiryStatusEnum;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class SrmInquiryApprovalTest extends BaseMockitoUnitTest {
    @InjectMocks private SrmInquiryServiceImpl service;
    @Mock private SrmInquiryMapper mapper;
    @Test void cannotConvertBeforeApproval() {
        when(mapper.selectByIdForUpdate(1L)).thenReturn(new SrmInquiryDO().setId(1L).setStatus(InquiryStatusEnum.DRAFT.getStatus()));
        SrmInquiryConfirmReqVO req = new SrmInquiryConfirmReqVO().setId(1L);
        assertThrows(ServiceException.class, () -> service.confirmInquiry(req));
    }
    @Test void ignoresOldProcessAndAcceptsCurrentApproval() {
        when(mapper.selectByIdForUpdate(1L)).thenReturn(new SrmInquiryDO().setId(1L)
                .setStatus(InquiryStatusEnum.APPROVING.getStatus()).setProcessInstanceId("current"));
        service.updateApprovalStatus(1L, "old", BpmProcessInstanceStatusEnum.APPROVE.getStatus());
        verify(mapper, never()).updateById(any(SrmInquiryDO.class));
        service.updateApprovalStatus(1L, "current", BpmProcessInstanceStatusEnum.APPROVE.getStatus());
        verify(mapper).updateById(argThat((SrmInquiryDO update) -> InquiryStatusEnum.APPROVED.getStatus().equals(update.getStatus())));
    }
}