feat(bpm): 接入询价单与项目用印审批流程
- 新增询价单审核状态枚举及流程实例字段
- 实现询价单提交审核接口与审批状态回调监听
- 调整询价单编辑与状态流转的校验逻辑
- 新增项目用印管理及申请相关的数据库表与菜单
- 实现印章上传、用印申请及审批状态监听功能
- 改造跟踪表导出逻辑以生成快照并嵌入审批印章
- 添加用印审批流程定义文件及数据库变更脚本
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <?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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | -- ç¨å°ç®¡çï¼å¾çåææ¡£å¿«ç
§åæ°æ®åºï¼ä¸ä½¿ç¨å
¬å¼éä»¶å°åã |
| | | 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'); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | -- æ§è¡å确认å½åæ°æ®åºå
å« srm_inquiryï¼ä¸å°æªæ·»å æ¤å段ã |
| | | ALTER TABLE srm_inquiry ADD COLUMN process_instance_id varchar(64) DEFAULT NULL COMMENT 'BPMæµç¨å®ä¾ç¼å·'; |
| | | -- å¨ BPM æµç¨ç®¡çä¸å建并å叿µç¨æ è¯ srm_inquiry_approveï¼é
置审æ¹èç¹å审æ¹äººã |
| | |
| | | @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); |
| | |
| | | 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) { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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);} |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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(); |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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); } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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());} |
| | | } |
| | | } |
| | |
| | | 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) |
| | |
| | | |
| | | @Schema(description = "ç¶æï¼0 è稿ã10 询价ä¸ã20 已转订åã30 已忶") |
| | | private Integer status; |
| | | private String processInstanceId; |
| | | |
| | | @Schema(description = "è¯¢ä»·æ¥æ") |
| | | private LocalDate inquiryDate; |
| | |
| | | */ |
| | | private Long purchaserUserId; |
| | | private String remark; |
| | | private String processInstanceId; |
| | | |
| | | } |
| | |
| | | .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); |
| | | } |
| | |
| | | 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); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | * åå¸è¯¢ä»·åï¼è稿 -> è¯¢ä»·ä¸ |
| | | */ |
| | | void publishInquiry(Long id); |
| | | void submitApproval(Long id, Long userId); |
| | | void updateApprovalStatus(Long id, String processInstanceId, Integer status); |
| | | |
| | | /** |
| | | * åæ¶è¯¢ä»·åï¼è稿/è¯¢ä»·ä¸ -> 已忶 |
| | |
| | | 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; |
| | |
| | | |
| | | @Resource |
| | | private SrmInquiryMapper inquiryMapper; |
| | | @Resource |
| | | private BpmProcessInstanceApi processInstanceApi; |
| | | @Resource |
| | | private SrmInquiryItemMapper inquiryItemMapper; |
| | | @Resource |
| | |
| | | @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()) |
| | | + "ãï¼ä»
èç¨¿ç¶æå¯ç¼è¾ãå¦éè°æ´äº§åè¡æä¾åºåï¼è¯·åæ¶åéæ°å建询价åã"); |
| | |
| | | @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()) |
| | |
| | | |
| | | @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()) |
| | |
| | | @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()) |
| | | + "ãï¼ä»
è稿æè¯¢ä»·ä¸ç¶æå¯åæ¶ã"); |
| | |
| | | @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()) |
| | | + "ãï¼ä»
è稿æè¯¢ä»·ä¸ç¶æå¯å½å
¥æ¥ä»·ã"); |
| | |
| | | @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)) { |
| | |
| | | |
| | | // ========== æ ¡éª ========== |
| | | |
| | | 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) { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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()))); |
| | | } |
| | | } |