diff --git a/.gitignore b/.gitignore index 502c0b8..dedab56 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,51 @@ +# Maven / Java /.project *.class .classpath .settings .factorypath .project -.logs -.log pom.properties **/target/** mvnw mvnw.cmd maven-wrapper.properties -**/logs/ +# Logs +.logs +.log +*.log +**/logs/ /logs/ # IDE .idea/ *.iml .vscode/ +*.iws +*.ipr +out/ +.cursor/ -# 本地文档/工具(默认不进仓库;需要单独提交时再强制 add) +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# Node(文档脚本等本地依赖) +**/node_modules/ +package-lock.json +yarn.lock + +# 本地文档/工具产物(默认不进仓库) docs/ + +# 临时/导出文件 +*.tmp +*.bak +*.swp +*.orig +*.xlsx +!**/src/**/*.xlsx +*.docx +!**/src/**/*.docx diff --git a/pom.xml b/pom.xml index 9258f69..26eeb0c 100644 --- a/pom.xml +++ b/pom.xml @@ -36,7 +36,8 @@ 2.14.2 2.4.0 1.18.30 - 1.72 + + 1.74 2.7.0 @@ -265,10 +266,15 @@ ${snakeyaml.version} - + org.bouncycastle - bcprov-jdk15to18 + bcprov-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcpkix-jdk18on ${bouncycastle.version} diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 1b44847..f92bf68 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -103,12 +103,36 @@ com.aliyun dingtalk 2.0.15 + + + org.bouncycastle + bcprov-jdk15on + + + org.bouncycastle + bcpkix-jdk15on + + + org.bouncycastle + bcutil-jdk15on + + com.aliyun alibaba-dingtalk-service-sdk 2.0.0 + + + org.bouncycastle + bcprov-jdk15on + + + org.bouncycastle + bcpkix-jdk15on + + @@ -189,6 +213,10 @@ true true + + --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + diff --git a/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java b/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java index a69305a..3668210 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java @@ -1,5 +1,6 @@ package com.ruoyi; +import cn.hutool.crypto.SecureUtil; import org.apache.poi.openxml4j.util.ZipSecureFile; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; @@ -25,6 +26,8 @@ public class RuoYiApplication { // 全局放宽,启动时执行一次即可 ZipSecureFile.setMaxFileCount(100000); ZipSecureFile.setMinInflateRatio(0.005); + // Oracle JDK17 + Spring Boot fat jar 下 BC JCE 提供者会触发 zip file closed,JWT 用 JDK 自带 HMAC 即可 + SecureUtil.disableBouncyCastle(); } public static void main(String[] args) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/dingtalk/CallbackController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/dingtalk/CallbackController.java index 2e43bce..9561f16 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/dingtalk/CallbackController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/dingtalk/CallbackController.java @@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaIgnore; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.ruoyi.common.config.DingTalkProperties; import com.ruoyi.common.dingding.DingCallbackCrypto; import com.ruoyi.common.dingding.robot.RobotGroupService; @@ -285,15 +286,26 @@ public class CallbackController { */ private void updateProcessOrderProStatus(String outTrackId, Long routeStatus, Long bomStatus) { try { - // 根据 notificationCardId 查询 + // 根据审核卡片 Id 查询 ProcessOrderPro order = processOrderProMapper.selectOne(new LambdaQueryWrapper().eq(ProcessOrderPro::getAuditCardId, outTrackId)); if (order != null) { - ProcessOrderPro update = new ProcessOrderPro(); - update.setId(order.getId()); - update.setRouteStatus(routeStatus); - update.setBomStatus(bomStatus); - update.setUpdateBy("DingDing"); - processOrderProMapper.updateById(update); + // 幂等:已是目标审核状态则不再更新/通知(防钉钉回调重试连发) + if (routeStatus != null && routeStatus.equals(order.getRouteStatus())) { + bizLogger.info("工艺审核状态已是目标值,跳过重复处理: id={}, routeStatus={}", order.getId(), routeStatus); + return; + } + // 条件更新:仅当 route_status 仍非目标值时更新,避免并发双回调都发通知 + LambdaUpdateWrapper uw = new LambdaUpdateWrapper<>(); + uw.eq(ProcessOrderPro::getId, order.getId()) + .and(w -> w.isNull(ProcessOrderPro::getRouteStatus).or().ne(ProcessOrderPro::getRouteStatus, routeStatus)) + .set(ProcessOrderPro::getRouteStatus, routeStatus) + .set(ProcessOrderPro::getBomStatus, bomStatus) + .set(ProcessOrderPro::getUpdateBy, "DingDing"); + int rows = processOrderProMapper.update(null, uw); + if (rows <= 0) { + bizLogger.info("工艺审核状态已被并发更新,跳过通知: id={}, routeStatus={}", order.getId(), routeStatus); + return; + } bizLogger.info("更新 ProcessOrderPro 状态成功: id={}, routeStatus={}, bomStatus={}", order.getId(), routeStatus, bomStatus); @@ -409,8 +421,8 @@ public class CallbackController { // 其他事件(包括机器人消息) if (eventJson.containsKey("text")) { String content = extractRobotText(eventJson); - bizLogger.info("机器人收到文本消息: content={} conversationTitle={} robotCode={}", - content, eventJson.getString("conversationTitle"), eventJson.getString("robotCode")); + bizLogger.info("机器人收到文本消息: content={} conversationTitle={} conversationId={} robotCode={}", + content, eventJson.getString("conversationTitle"), eventJson.getString("conversationId"), eventJson.getString("robotCode")); boolean isHandled = false; @@ -598,6 +610,24 @@ public class CallbackController { }).start(); } + if (textContains(content, "群id") || textContains(content, "群ID") + || textContains(content, "会话id") || textContains(content, "会话ID") + || textContains(content, "群会话")) { + isHandled = true; + String sessionWebhook = eventJson.getString("sessionWebhook"); + String senderId = eventJson.getString("senderStaffId"); + String conversationId = eventJson.getString("conversationId"); + String title = eventJson.getString("conversationTitle"); + String reply = "群名称:" + (title == null ? "" : title) + + "\n群会话ID:\n" + (conversationId == null ? "未获取到" : conversationId); + if (sessionWebhook != null && !sessionWebhook.isEmpty()) { + webhookClient.sendText(sessionWebhook, reply, Collections.singletonList(senderId), null); + bizLogger.info("回复群会话ID conversationId={} senderId={}", conversationId, senderId); + } else { + bizLogger.warn("未获取到 sessionWebhook,无法回复群会话ID"); + } + } + // 默认回复 if (!isHandled) { String sessionWebhook = eventJson.getString("sessionWebhook"); @@ -605,7 +635,8 @@ public class CallbackController { String conversationId = eventJson.getString("conversationId"); if (conversationId != null && !conversationId.isEmpty()) { - String helpContent = "您好,我是数据提醒机器人。收到您的消息,但不知道你在讲什么?。\n" + "目前支持以下业务指令,请直接回复关键词:\n" + "1. 安全库存\n" + "2. 延期\n" + "3. 预警\n" + "4. 采购 / 采购申请\n" + "5. 工序汇报未质检 / 质检未入库\n" + "6. 未及时转移\n" + "7. 已转入未转出 / 转入但部分转出 / 部分转出\n" + "8. 开工未转入 / 开工未转"; + String helpContent = "您好,我是数据提醒机器人。收到您的消息,但不知道你在讲什么?。\n" + "目前支持以下业务指令,请直接回复关键词:\n" + "1. 安全库存\n" + "2. 延期\n" + "3. 预警\n" + "4. 采购 / 采购申请\n" + "5. 工序汇报未质检 / 质检未入库\n" + "6. 未及时转移\n" + "7. 已转入未转出 / 转入但部分转出 / 部分转出\n" + "8. 开工未转入 / 开工未转\n" + + "\n本群会话ID(填到群预警推送页面):\n" + conversationId; // 使用回调中的 sessionWebhook 直接回复 (这是最标准的回调回复方式) if (sessionWebhook != null && !sessionWebhook.isEmpty()) { diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index d9a1d19..1059401 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -36,7 +36,7 @@ server: # 以下的配置会影响buffer,这些buffer会用于服务器连接的IO操作,有点类似netty的池化内存管理 # 每块buffer的空间大小,越小的空间被利用越充分 buffer-size: 512 - # 是否分配的直接内存 + # 是否分配的直接内存\/system/kingdee/materialDocHub/board direct-buffers: true threads: # 设置IO线程数, 它主要执行非阻塞的任务,它们会负责多个连接, 默认设置每个CPU核心一个线程 @@ -162,6 +162,10 @@ security: - /system/route/push/** - /DataBoard/ProduceKanBan/** - /system/workCenterData/produceDataKanban + # MES 图纸查询 / 下载(方法上另有 @SaIgnore + appKey 鉴权) + - /open/mes/** + # 北通道实验台设备状态看板 + - /open/devstat/** # MyBatisPlus配置 # https://baomidou.com/config/ @@ -320,21 +324,21 @@ dingtalk: info-send-app-key: ding9bhxctsmq5jsf8pk info-send-app-secret: OBhuwZzf31kcVqqVwODtu59Aqmql21gc301V06Y8URdjZy_YmBJ47-AZ4nnLZVVm info-send-robot-code: ding9bhxctsmq5jsf8pk - info-send-user-ids: "665416234832653417,283633411029629036,0754423610937470" + info-send-user-ids: "665416234832653417,283633411029629036,0754423610937470,01692556241068902" #项目卡片模板ID project-card-template-id: 2b7bd7ab-0a20-43f2-902c-71198a8dd6a1.schema #工艺审核互动卡片模板ID route-card-template-id: 58244f90-b92d-4ab9-b619-8e83cef67d0c.schema #工艺审核互动卡片回调路由Key callback-route-key: update_route_status2 - # 出图完成通知(钉钉 userId/staffId,多人逗号分隔;以 0 开头的须加引号) - notify-user-id: "173769650335444471,0108392663561185496,283633411029629036" + # 出图完成 / PLM图纸到达通知(钉钉 userId/staffId,多人逗号分隔;以 0 开头的须加引号) + notify-user-id: "293047444820124967,0108392663561185496,03610242692523410847" # 工艺审核互动卡片 - route-approval-id: "01201038164023402380" - # 工艺计划推送用户 - plan-user-id: "313514580637707245,02256761642526333139,192530665123549546,01201038164023402380" + route-approval-id: "0108392663561185496" + # 工艺计划推送用户;无需做工艺且出图完成后、PLM 总装BOM与工艺都到齐后通知生管 + plan-user-id: "313514580637707245,02256761642526333139,192530665123549546,0108392663561185496,03361938481539892633,283633411029629036" # 同步K3通知用户 - sync-k3-notify-user-ids: "313514580637707245,02256761642526333139,192530665123549546" + sync-k3-notify-user-ids: "313514580637707245,02256761642526333139,192530665123549546,03361938481539892633" #技术方案申请流程编码 scheme-application-process-code: PROC-81A79B4D-F0EA-4353-9A91-68FD7E3BCEBB #技术方案申请流程同意后是否推送PLM @@ -345,12 +349,41 @@ dingtalk: scheme-bpms-plm-push-task-show-names: 总工程师 #技术方案申请流程同意后推送PLM是否显示总工程师意见 scheme-bpms-plm-push-on-chief-opinion-filled: true + # 钉钉待办中转只放连接信息。哪个方法发、创建人、接收人、截止天数在表 ding_todo_scene(页面「钉钉待办」) + # 测试 http://192.168.5.232:8089 ,正式 http://192.168.5.121:8089 + todo-base-url: http://192.168.5.121:8089 + # adapter 登录账号,三项都填上才会真正调用创建待办 + todo-username: mes + todo-password: 123456 + todo-uuid: mes + # 页面未选类型时的默认:code=考勤工号,name=姓名 + todo-recipient-type: name + # 登录返回的 token 放到这个请求头里再调 /ding/send + todo-token-header: Authorization + # 需要 Bearer 时写成 "Bearer ",对方直接收 token 就留空 + todo-token-prefix: + +# VMI 补货推送金蝶采购申请 +vmi: + # 仓储部VMI负责人钉钉userId(逗号分隔);物料未单独配置 dingUserId 时用此默认 + ding-user-ids: "696700331229897052,285404475726261431,283633411029629036" + kingdee: + # 申请人:取登录用户 sys_user.employee_number(金蝶员工工号),不再使用固定 applicant-number + # 申请部门:仓储部 + application-dept: "009" + # 申请组织 + application-org: "100" # 图纸档案 FTP:同机 14.218,下载 21 / 上传 8022(8022 登录根目录=电子档案/1.标准图纸存放文件夹) app: drawing: # DWG 上传在应用服务器上的暂存目录(须可写);实际上传目标走 FTP 8022 upload-staging-dir: D:/file/dwg-upload + # 图纸/模型文件服务。前端只调本系统,由后端转发下载 + file-query: + base-url: http://192.168.5.20:28000 + download-path: /api/FileQuery/download + read-timeout-ms: 300000 # 工艺计划表落盘目录(手动上传 / PLM 导出共用;下按生产令号分子目录) plan-excel: base-dir: D:/file/plan-excel @@ -401,3 +434,9 @@ plm: # 库内 DDAttachment 常为空时,推送前再从钉钉 getProcessInstance 拉附件 refresh-attachments-from-ding-on-push: true log-payload-max-chars: 8000 + +# MES 图纸开放(/open/mes/drawing,appKey/appSecret 鉴权) +mes: + auth: + app-key: mes + app-secret: change-me-mes-secret diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index dbc6f07..3b7de34 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -174,10 +174,10 @@ lock4j-redisson-spring-boot-starter - + org.bouncycastle - bcprov-jdk15to18 + bcprov-jdk18on com.hierynomus @@ -256,7 +256,13 @@ eu.agno3.jcifs jcifs-ng - 2.1.9 + 2.1.10 + + + org.bouncycastle + bcprov-jdk15on + + com.github.xiaoymin @@ -283,12 +289,36 @@ alibaba-dingtalk-service-sdk 2.0.0 compile + + + org.bouncycastle + bcprov-jdk15on + + + org.bouncycastle + bcpkix-jdk15on + + com.aliyun dingtalk 2.0.15 + + + org.bouncycastle + bcprov-jdk15on + + + org.bouncycastle + bcpkix-jdk15on + + + org.bouncycastle + bcutil-jdk15on + + @@ -303,4 +333,17 @@ + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + + + + diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/config/DingTalkProperties.java b/ruoyi-common/src/main/java/com/ruoyi/common/config/DingTalkProperties.java index 3f4ba92..21527a2 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/config/DingTalkProperties.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/config/DingTalkProperties.java @@ -132,8 +132,8 @@ public class DingTalkProperties { * 工艺卡片模版id */ private String routeCardTemplateId; - /** - * 工艺计划接收人 + /** + * 工艺计划接收人;无需做工艺且出图完成后、PLM 总装BOM与工艺到齐后也通知这批人(逗号分隔) */ private String planUserId; @@ -158,6 +158,41 @@ public class DingTalkProperties { */ private String messageRelayBaseUrl; + /** + * 待办中转根地址。测试 http://192.168.5.232:8089 ,正式 http://192.168.5.121:8089 + */ + private String todoBaseUrl; + + /** + * 待办中转登录账号 + */ + private String todoUsername; + + /** + * 待办中转登录密码 + */ + private String todoPassword; + + /** + * 待办中转登录 uuid(adapter 分配的权限标识) + */ + private String todoUuid; + + /** + * 接收人类型:code 考勤工号,name 姓名。页面每一行可以单独选。 + */ + private String todoRecipientType = "name"; + + /** + * 登录成功后调用 /ding/send 时携带 token 的请求头 + */ + private String todoTokenHeader = "Authorization"; + + /** + * token 前缀,例如 {@code Bearer };为空则只传 token 本身 + */ + private String todoTokenPrefix = ""; + /** * 技术方案申请表 OA 模板 processCode(如 PROC-xxx);为空则不处理 BPMS 审批事件 */ diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java index 18aeabc..fcd4dc3 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java @@ -75,6 +75,12 @@ public class SysUser extends BaseEntity { @Sensitive(strategy = SensitiveStrategy.PHONE) private String phonenumber; + /** + * 员工编号(金蝶员工工号,如 00094) + */ + @Size(min = 0, max = 64, message = "员工编号长度不能超过{max}个字符") + private String employeeNumber; + /** * 用户性别 */ diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/poi/ExcelTemplateProc.java b/ruoyi-common/src/main/java/com/ruoyi/common/poi/ExcelTemplateProc.java index 393fdfa..b9e9ce4 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/poi/ExcelTemplateProc.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/poi/ExcelTemplateProc.java @@ -8,12 +8,11 @@ import org.apache.poi.xssf.usermodel.XSSFCell; import org.apache.poi.xssf.usermodel.XSSFRow; import org.apache.poi.xssf.usermodel.XSSFSheet; import org.apache.poi.xssf.usermodel.XSSFWorkbook; -import org.springframework.core.io.ClassPathResource; -import org.springframework.util.ResourceUtils; - import java.io.*; import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -35,20 +34,10 @@ public class ExcelTemplateProc { String exportFilePathAndName, Map staticDataMap, List dynamicDataMappingList) throws IOException { - - InputStream inputStream; - - if (templateFileName.startsWith("/") || templateFileName.contains(":")) { - // 绝对路径 (Windows: D:/... Linux: /opt/...) - inputStream = Files.newInputStream(new File(templateFileName).toPath()); - } else { - // classpath 路径 - ClassPathResource resource = new ClassPathResource(templateFileName); - inputStream = resource.getInputStream(); + try (InputStream inputStream = openTemplateInputStream(templateFileName)) { + Workbook workbook = dealAllSheetsByTemplate(inputStream, staticDataMap, dynamicDataMappingList); + saveExportFile(workbook, exportFilePathAndName); } - - Workbook workbook = dealAllSheetsByTemplate(inputStream, staticDataMap, dynamicDataMappingList); - saveExportFile(workbook, exportFilePathAndName); } /** @@ -60,12 +49,9 @@ public class ExcelTemplateProc { if (sheetConfigs == null || sheetConfigs.isEmpty()) { throw new IllegalArgumentException("sheetConfigs 不能为空"); } - InputStream inputStream = openTemplateInputStream(templateFileName); XSSFWorkbook workbook; - try { + try (InputStream inputStream = openTemplateInputStream(templateFileName)) { workbook = new XSSFWorkbook(inputStream); - } finally { - inputStream.close(); } while (workbook.getNumberOfSheets() > 1) { workbook.removeSheetAt(workbook.getNumberOfSheets() - 1); @@ -82,12 +68,105 @@ public class ExcelTemplateProc { saveExportFile(workbook, exportFilePathAndName); } + /** + * 打开 Excel 模板流。classpath 资源必须先完整读入内存再交给 POI: + * Spring Boot fat jar 中 ClassPathResource 返回的是嵌套 JAR 的 ZipInflaterInputStream, + * 直接 new XSSFWorkbook(stream) 会 ZipException: invalid block type。 + */ private static InputStream openTemplateInputStream(String templateFileName) throws IOException { + byte[] bytes = readTemplateBytes(templateFileName); + return new ByteArrayInputStream(bytes); + } + + private static byte[] readTemplateBytes(String templateFileName) throws IOException { if (templateFileName.startsWith("/") || templateFileName.contains(":")) { - return Files.newInputStream(new File(templateFileName).toPath()); + File file = new File(templateFileName); + if (!file.exists() || !file.isFile()) { + throw new FileNotFoundException("Excel模板不存在: " + templateFileName); + } + return validateXlsxBytes(Files.readAllBytes(file.toPath()), templateFileName); } - ClassPathResource resource = new ClassPathResource(templateFileName); - return resource.getInputStream(); + + List candidates = buildTemplateCandidates(templateFileName); + List loaders = new ArrayList<>(); + // Spring Boot / 异步线程:优先用上下文 ClassLoader,再回退到本类加载器 + ClassLoader ctx = Thread.currentThread().getContextClassLoader(); + if (ctx != null) { + loaders.add(ctx); + } + ClassLoader self = ExcelTemplateProc.class.getClassLoader(); + if (self != null && self != ctx) { + loaders.add(self); + } + loaders.add(ClassLoader.getSystemClassLoader()); + + List tried = new ArrayList<>(); + for (String candidate : candidates) { + for (ClassLoader loader : loaders) { + if (loader == null) { + continue; + } + String mark = candidate + " @ " + loader.getClass().getName(); + tried.add(mark); + byte[] bytes = readClasspathBytes(loader, candidate); + if (bytes != null) { + return validateXlsxBytes(bytes, candidate); + } + } + // 开发态:相对工作目录兜底 + File local = new File(candidate); + if (local.isFile()) { + return validateXlsxBytes(Files.readAllBytes(local.toPath()), local.getAbsolutePath()); + } + tried.add(candidate + " @ file:" + local.getAbsolutePath()); + } + throw new FileNotFoundException("Excel模板不存在: " + templateFileName + ";已尝试: " + tried); + } + + private static List buildTemplateCandidates(String templateFileName) { + LinkedHashSet set = new LinkedHashSet<>(); + set.add(templateFileName); + // 模版 / 模板 常见混用 + if (templateFileName.contains("模版")) { + set.add(templateFileName.replace("模版", "模板")); + } + if (templateFileName.contains("模板")) { + set.add(templateFileName.replace("模板", "模版")); + } + if (templateFileName.startsWith("jpg/")) { + String name = templateFileName.substring("jpg/".length()); + set.add("EXCEL模板/" + name); + if (name.contains("模版")) { + set.add("EXCEL模板/" + name.replace("模版", "模板")); + } + } + return new ArrayList<>(set); + } + + private static byte[] readClasspathBytes(ClassLoader loader, String resourcePath) { + // 不用 ClassPathResource.exists():部分环境下对依赖 jar / 中文路径会误判不存在 + try (InputStream in = loader.getResourceAsStream(resourcePath)) { + if (in == null) { + return null; + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } catch (Exception e) { + return null; + } + } + + private static byte[] validateXlsxBytes(byte[] bytes, String templateFileName) throws IOException { + if (bytes == null || bytes.length < 4 || bytes[0] != 0x50 || bytes[1] != 0x4B) { + throw new IOException("Excel模板不是有效的xlsx文件: " + templateFileName + + ", size=" + (bytes == null ? -1 : bytes.length)); + } + return bytes; } private static String sanitizeSheetName(String name) { diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java index 4133e21..e38de44 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java @@ -1,25 +1,21 @@ package com.ruoyi.common.utils; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.lang.SimpleCache; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ReflectUtil; -import cn.hutool.core.util.StrUtil; import lombok.AccessLevel; import lombok.NoArgsConstructor; -import org.springframework.cglib.beans.BeanCopier; -import org.springframework.cglib.beans.BeanMap; -import org.springframework.cglib.core.Converter; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** - * bean深拷贝工具(基于 cglib 性能优异) + * bean深拷贝工具 *

- * 重点 cglib 不支持 拷贝到链式对象 + * 重点 不支持 拷贝到链式对象 * 例如: 源对象 拷贝到 目标(链式对象) * 请区分好`浅拷贝`和`深拷贝`再做使用 * @@ -60,8 +56,7 @@ public class BeanCopyUtils { if (ObjectUtil.isNull(desc)) { return null; } - BeanCopier beanCopier = BeanCopierCache.INSTANCE.get(source.getClass(), desc.getClass(), null); - beanCopier.copy(source, desc, null); + BeanUtil.copyProperties(source, desc); return desc; } @@ -92,12 +87,11 @@ public class BeanCopyUtils { * @param bean 数据来源实体 * @return map对象 */ - @SuppressWarnings("unchecked") public static Map copyToMap(T bean) { if (ObjectUtil.isNull(bean)) { return null; } - return BeanMap.create(bean); + return BeanUtil.beanToMap(bean); } /** @@ -132,7 +126,7 @@ public class BeanCopyUtils { if (ObjectUtil.isNull(bean)) { return null; } - BeanMap.create(bean).putAll(map); + BeanUtil.fillBeanWithMap(map, bean, false); return bean; } @@ -155,50 +149,4 @@ public class BeanCopyUtils { return copyMap; } - /** - * BeanCopier属性缓存
- * 缓存用于防止多次反射造成的性能问题 - * - * @author Looly - * @since 5.4.1 - */ - public enum BeanCopierCache { - /** - * BeanCopier属性缓存单例 - */ - INSTANCE; - - private final SimpleCache cache = new SimpleCache<>(); - - /** - * 获得类与转换器生成的key在{@link BeanCopier}的Map中对应的元素 - * - * @param srcClass 源Bean的类 - * @param targetClass 目标Bean的类 - * @param converter 转换器 - * @return Map中对应的BeanCopier - */ - public BeanCopier get(Class srcClass, Class targetClass, Converter converter) { - final String key = genKey(srcClass, targetClass, converter); - return cache.get(key, () -> BeanCopier.create(srcClass, targetClass, converter != null)); - } - - /** - * 获得类与转换器生成的key - * - * @param srcClass 源Bean的类 - * @param targetClass 目标Bean的类 - * @param converter 转换器 - * @return 属性名和Map映射的key - */ - private String genKey(Class srcClass, Class targetClass, Converter converter) { - final StringBuilder key = StrUtil.builder() - .append(srcClass.getName()).append('#').append(targetClass.getName()); - if (null != converter) { - key.append('#').append(converter.getClass().getName()); - } - return key.toString(); - } - } - } diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java index fc68914..0bcb5ce 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java @@ -16,6 +16,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; @Slf4j public final class FtpUtil { @@ -138,6 +141,86 @@ public final class FtpUtil { remoteDir, localDir, fileNameFilter); } + /** + * 在远程目录(含全部子目录)中查找并下载第一个匹配的文件。 + * 目录不存在时返回失败(与整目录递归下载「视为空」不同)。 + */ + public static R downloadMatchingFileRecursive(DrawingFtpProperties.Endpoint ftp, String remoteDir, + String localDir, Predicate fileNameMatcher) { + if (fileNameMatcher == null) { + return R.fail("文件匹配规则不能为空"); + } + FTPClient ftpClient = null; + try { + ftpClient = getFTPClient(ftp.getHost(), ftp.getUsername(), ftp.getPassword(), ftp.getPort()); + if (ftpClient == null || !ftpClient.isConnected()) { + return R.fail("FTP连接失败 " + ftp.getHost() + ":" + ftp.getPort()); + } + String dir = normalizeRemotePath(remoteDir); + log.info("FTP 递归匹配下载 {}:{} dir={}", ftp.getHost(), ftp.getPort(), dir); + if (!changeWorkingDirectoryWithEncoding(ftpClient, dir)) { + return R.fail("远程目录不存在: " + dir + " (reply=" + ftpClient.getReplyString().trim() + ")"); + } + File localDirectory = new File(localDir); + if (!localDirectory.exists() && !localDirectory.mkdirs()) { + return R.fail("无法创建本地目录: " + localDir); + } + String saved = findAndDownloadMatching(ftpClient, dir, localDirectory, fileNameMatcher); + if (saved == null) { + return R.fail("远程目录中未找到匹配文件 (dir=" + dir + ")"); + } + return R.ok(saved); + } catch (Exception e) { + log.error("FTP递归匹配下载异常 dir={}", remoteDir, e); + return R.fail("下载失败: " + e.getMessage()); + } finally { + disconnectQuietly(ftpClient); + } + } + + private static String findAndDownloadMatching(FTPClient ftp, String remoteDir, File localDir, + Predicate fileNameMatcher) throws IOException { + if (!changeWorkingDirectoryWithEncoding(ftp, remoteDir)) { + return null; + } + FTPFile[] files = ftp.listFiles(); + if (files == null || files.length == 0) { + return null; + } + List subDirs = new ArrayList<>(); + for (FTPFile file : files) { + String name = file.getName(); + if (name == null || ".".equals(name) || "..".equals(name)) { + continue; + } + if (file.isDirectory()) { + subDirs.add(name); + continue; + } + if (!fileNameMatcher.test(name)) { + continue; + } + File localFile = new File(localDir, name); + try (OutputStream os = Files.newOutputStream(localFile.toPath())) { + if (retrieveFileWithEncoding(ftp, name, os)) { + log.info("FTP匹配下载成功: {}/{} -> {}", remoteDir, name, localFile.getAbsolutePath()); + return name; + } + Files.deleteIfExists(localFile.toPath()); + log.warn("FTP匹配下载失败: {}/{} reply={}", remoteDir, name, ftp.getReplyString()); + return null; + } + } + for (String name : subDirs) { + String childRemote = normalizeRemotePath(remoteDir + "/" + name); + String saved = findAndDownloadMatching(ftp, childRemote, localDir, fileNameMatcher); + if (saved != null) { + return saved; + } + } + return null; + } + /** * 出图 Excel 远程目录:basePath(如 F:) + 年份 + 生产令号,与 SMB 路径 F:\2026\令号 一致。 */ @@ -258,6 +341,9 @@ public final class FtpUtil { FTPFile[] files = ftpClient.listFiles(); if (files == null || files.length == 0) { + if (fileNameFilter != null && !fileNameFilter.isEmpty()) { + return R.fail("远程目录为空,未找到文件: " + fileNameFilter + " (dir=" + remoteDir + ")"); + } return R.ok("远程目录为空,无需下载"); } @@ -270,7 +356,8 @@ public final class FtpUtil { if (fileName.startsWith(".") || file.isDirectory()) { continue; } - if (fileNameFilter != null && !fileNameFilter.isEmpty() && !fileName.contains(fileNameFilter)) { + if (fileNameFilter != null && !fileNameFilter.isEmpty() + && !fileNameMatchesFilter(fileName, fileNameFilter)) { continue; } totalCount++; @@ -312,6 +399,191 @@ public final class FtpUtil { disconnectQuietly(ftpClient); } + /** + * 递归下载远程目录(含全部子目录)到本地。远程目录不存在时返回成功(视为空)。 + */ + public static R downloadFtpDirectoryRecursive(DrawingFtpProperties.Endpoint ftp, String remoteDir, + String localDir) { + FTPClient ftpClient = null; + try { + ftpClient = getFTPClient(ftp.getHost(), ftp.getUsername(), ftp.getPassword(), ftp.getPort()); + if (ftpClient == null || !ftpClient.isConnected()) { + return R.fail("FTP连接失败 " + ftp.getHost() + ":" + ftp.getPort()); + } + return downloadFtpDirectoryRecursive(ftpClient, remoteDir, localDir); + } catch (Exception e) { + log.error("FTP递归下载异常 dir={}", remoteDir, e); + return R.fail("下载失败: " + e.getMessage()); + } finally { + disconnectQuietly(ftpClient); + } + } + + public static R downloadFtpDirectoryRecursive(FTPClient ftpClient, String remoteDir, String localDir) { + if (ftpClient == null || !ftpClient.isConnected()) { + return R.fail("FTP连接不可用"); + } + String dir = normalizeRemotePath(remoteDir); + try { + if (!changeWorkingDirectoryWithEncoding(ftpClient, dir)) { + log.info("FTP 远程目录不存在,跳过下载: {}", dir); + return R.ok("远程目录不存在,无需下载: " + dir); + } + int count = downloadTree(ftpClient, dir, localDir); + return R.ok("递归下载完成,文件数=" + count + " dir=" + dir); + } catch (Exception e) { + log.error("FTP递归下载异常 dir={}", dir, e); + return R.fail("下载失败: " + e.getMessage()); + } + } + + /** + * 删除远程目录内全部文件与子目录,保留该目录本身。目录不存在视为已清理。 + */ + public static R deleteRemoteDirectoryContents(DrawingFtpProperties.Endpoint ftp, String remoteDir) { + FTPClient ftpClient = null; + try { + ftpClient = getFTPClient(ftp.getHost(), ftp.getUsername(), ftp.getPassword(), ftp.getPort()); + if (ftpClient == null || !ftpClient.isConnected()) { + return R.fail("FTP连接失败 " + ftp.getHost() + ":" + ftp.getPort()); + } + return deleteRemoteDirectoryContents(ftpClient, remoteDir); + } catch (Exception e) { + log.error("FTP清理远程目录异常 dir={}", remoteDir, e); + return R.fail("清理远程目录失败: " + e.getMessage()); + } finally { + disconnectQuietly(ftpClient); + } + } + + public static R deleteRemoteDirectoryContents(FTPClient ftpClient, String remoteDir) { + if (ftpClient == null || !ftpClient.isConnected()) { + return R.fail("FTP连接不可用"); + } + String dir = normalizeRemotePath(remoteDir); + try { + if (!changeWorkingDirectoryWithEncoding(ftpClient, dir)) { + log.info("FTP 远程目录不存在,跳过删除: {}", dir); + return R.ok("远程目录不存在,无需删除: " + dir); + } + int count = deleteListedContents(ftpClient, dir); + return R.ok("已清理远程出图目录,删除 " + count + " 项: " + dir); + } catch (Exception e) { + log.error("FTP清理远程目录异常 dir={}", dir, e); + return R.fail("清理远程目录失败: " + e.getMessage()); + } + } + + private static int downloadTree(FTPClient ftp, String remoteDir, String localDir) throws IOException { + if (!changeWorkingDirectoryWithEncoding(ftp, remoteDir)) { + throw new IOException("远程目录不存在: " + remoteDir); + } + File local = new File(localDir); + if (!local.exists() && !local.mkdirs()) { + throw new IOException("无法创建本地目录: " + localDir); + } + FTPFile[] files = ftp.listFiles(); + if (files == null || files.length == 0) { + return 0; + } + int count = 0; + List subDirs = new ArrayList<>(); + for (FTPFile file : files) { + String name = file.getName(); + if (name == null || ".".equals(name) || "..".equals(name)) { + continue; + } + if (file.isDirectory()) { + subDirs.add(name); + continue; + } + File localFile = new File(local, name); + try (OutputStream os = Files.newOutputStream(localFile.toPath())) { + if (retrieveFileWithEncoding(ftp, name, os)) { + count++; + log.info("FTP递归下载成功: {}/{}", remoteDir, name); + } else { + Files.deleteIfExists(localFile.toPath()); + log.warn("FTP递归下载失败: {}/{} reply={}", remoteDir, name, ftp.getReplyString()); + } + } + } + for (String name : subDirs) { + String childRemote = normalizeRemotePath(remoteDir + "/" + name); + String childLocal = new File(local, name).getAbsolutePath(); + count += downloadTree(ftp, childRemote, childLocal); + } + return count; + } + + private static int deleteListedContents(FTPClient ftp, String absDir) throws IOException { + if (!changeWorkingDirectoryWithEncoding(ftp, absDir)) { + return 0; + } + FTPFile[] files = ftp.listFiles(); + if (files == null || files.length == 0) { + return 0; + } + int count = 0; + List subDirs = new ArrayList<>(); + List fileNames = new ArrayList<>(); + for (FTPFile file : files) { + String name = file.getName(); + if (name == null || ".".equals(name) || "..".equals(name)) { + continue; + } + if (file.isDirectory()) { + subDirs.add(name); + } else { + fileNames.add(name); + } + } + for (String name : fileNames) { + if (deleteFileWithEncoding(ftp, name)) { + count++; + log.info("FTP已删除文件: {}/{}", absDir, name); + } else { + log.warn("FTP删除文件失败: {}/{} reply={}", absDir, name, ftp.getReplyString()); + } + } + for (String name : subDirs) { + String child = normalizeRemotePath(absDir + "/" + name); + count += deleteListedContents(ftp, child); + changeWorkingDirectoryWithEncoding(ftp, absDir); + if (removeDirectoryWithEncoding(ftp, name)) { + count++; + log.info("FTP已删除目录: {}", child); + } else { + log.warn("FTP删除目录失败: {} reply={}", child, ftp.getReplyString()); + } + } + return count; + } + + private static boolean deleteFileWithEncoding(FTPClient ftpClient, String fileName) throws IOException { + if (ftpClient.deleteFile(fileName)) { + return true; + } + String gbkIso = new String(fileName.getBytes("GBK"), SERVER_CHARSET); + if (ftpClient.deleteFile(gbkIso)) { + return true; + } + String utf8Iso = new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + return ftpClient.deleteFile(utf8Iso); + } + + private static boolean removeDirectoryWithEncoding(FTPClient ftpClient, String dir) throws IOException { + if (ftpClient.removeDirectory(dir)) { + return true; + } + String gbkIso = new String(dir.getBytes("GBK"), SERVER_CHARSET); + if (ftpClient.removeDirectory(gbkIso)) { + return true; + } + String utf8Iso = new String(dir.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + return ftpClient.removeDirectory(utf8Iso); + } + private static String combineRemotePath(String basePath, String filePath) { String base = basePath == null ? "" : basePath.trim(); String rel = filePath == null ? "" : filePath.trim(); @@ -417,6 +689,16 @@ public final class FtpUtil { return ftpClient.retrieveFile(utf8Iso, os); } + /** + * 目录扫描按文件名精确匹配。不能用 contains,否则「图纸数量统计表.xlsx」会命中其它令号的「令号图纸数量统计表.xlsx」。 + */ + private static boolean fileNameMatchesFilter(String fileName, String fileNameFilter) { + if (fileName == null || fileNameFilter == null) { + return false; + } + return fileName.equalsIgnoreCase(fileNameFilter); + } + private static boolean changeWorkingDirectoryWithEncoding(FTPClient ftpClient, String remoteDir) throws IOException { if (ftpClient.changeWorkingDirectory(remoteDir)) { diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java index 8e0c331..2a2cfee 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java @@ -16,7 +16,7 @@ import java.util.stream.Collectors; * WC000002 机二工段 → 006 机加车间 * WC000004 装一工段 → 007 装配车间 * WC000005 装二工段 → 007 装配车间 - * WC000006 电钳工段 → 007 装配车间 + * WC000006 电钳工段 → 023 电钳车间 * WC000007 下料工段 → 006 机加车间 * WC000008 委外中心 → 006 机加车间 * WC000010 下料工段 → 018 铆焊车间 @@ -31,16 +31,21 @@ public final class KingdeeRouteDepartmentHelper { public static final String DEPT_ASSEMBLY = "007"; /** 铆焊车间 */ public static final String DEPT_RIVET_WELD = "018"; + /** 电钳车间 */ + public static final String DEPT_ELECTRICIAN = "023"; /** 006 机加车间 */ private static final Set MACHINE_WORKSHOP = setOf( "机一工段", "机二工段", "机加委外"); /** 007 装配车间 */ private static final Set ASSEMBLY_WORKSHOP = setOf( - "装一工段", "装二工段", "电钳工段","装配委外"); + "装一工段", "装二工段", "装配委外"); /** 018 铆焊车间 */ private static final Set RIVET_WELD_WORKSHOP = setOf( - "下料工段", "焊接工段","铆焊委外"); + "下料工段", "焊接工段", "铆焊委外"); + /** 023 电钳车间 */ + private static final Set ELECTRICIAN_WORKSHOP = setOf( + "电钳工段"); public static final List ALLOWED_WORK_CENTERS; static { @@ -48,6 +53,7 @@ public final class KingdeeRouteDepartmentHelper { all.addAll(MACHINE_WORKSHOP); all.addAll(ASSEMBLY_WORKSHOP); all.addAll(RIVET_WELD_WORKSHOP); + all.addAll(ELECTRICIAN_WORKSHOP); ALLOWED_WORK_CENTERS = Collections.unmodifiableList(Arrays.asList(all.toArray(new String[0]))); } @@ -72,6 +78,9 @@ public final class KingdeeRouteDepartmentHelper { if (RIVET_WELD_WORKSHOP.contains(wc)) { return DEPT_RIVET_WELD; } + if (ELECTRICIAN_WORKSHOP.contains(wc)) { + return DEPT_ELECTRICIAN; + } throw new ServiceException("不支持的工作中心「" + wc + "」,仅允许:" + String.join("、", ALLOWED_WORK_CENTERS)); } @@ -95,6 +104,9 @@ public final class KingdeeRouteDepartmentHelper { if (DEPT_RIVET_WELD.equals(deptFNumber)) { return "铆焊车间"; } + if (DEPT_ELECTRICIAN.equals(deptFNumber)) { + return "电钳车间"; + } return deptFNumber != null ? deptFNumber : ""; } diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java index ed7e6da..6a99857 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java @@ -8,10 +8,12 @@ import com.itextpdf.text.pdf.PdfStamper; import org.apache.pdfbox.io.MemoryUsageSetting; import org.apache.pdfbox.multipdf.PDFMergerUtility; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.List; import java.util.Map; @@ -132,7 +134,7 @@ public class PDFDocHelper { } /** - * 在 PDF 第 1 页指定位置叠加文字(无图章),经临时文件替换原文件。 + * 在 PDF 第 1 页指定位置叠加文字(无图章),就地覆盖原文件。 */ public static void stampTextFirstPage(String pdfPath, List> reviews, float[] pswX, float[] pswY, int fontSize) throws IOException, DocumentException { @@ -140,23 +142,88 @@ public class PDFDocHelper { } /** - * 在 PDF 第 1 页指定位置叠加文字(无图章),经临时文件替换原文件。 + * 在 PDF 第 1 页指定位置叠加文字(无图章),就地覆盖原文件。 * * @param rotation 逆时针旋转角度(伊特左栏竖排一般为 90) */ public static void stampTextFirstPage(String pdfPath, List> reviews, float[] pswX, float[] pswY, int fontSize, float rotation) throws IOException, DocumentException { - if (reviews == null || reviews.isEmpty()) { + File src = new File(pdfPath); + File result = stampTextFirstPage(src, readAllBytesWithRetry(src.toPath()), reviews, pswX, pswY, fontSize, rotation); + if (isSameFile(result, src)) { return; } + try { + if (!writeBytesWithRetry(src.toPath(), Files.readAllBytes(result.toPath()), 20)) { + throw new IOException("无法覆盖原文件: " + src.getAbsolutePath()); + } + } finally { + Files.deleteIfExists(result.toPath()); + } + } + + /** + * 内存盖章后写回 {@code dest}。若 Windows 锁住原文件,则写入系统临时文件并返回该文件, + * 调用方用返回值继续出图,避免左栏标注被跳过。 + * + * @return 后续流程应使用的 PDF(覆盖成功为 dest,否则为临时文件) + */ + public static File stampTextFirstPage(File dest, byte[] pdfBytes, List> reviews, + float[] pswX, float[] pswY, int fontSize, float rotation) + throws IOException, DocumentException { + if (reviews == null || reviews.isEmpty()) { + return dest; + } if (pswX == null || pswY == null || pswX.length < reviews.size() || pswY.length < reviews.size()) { throw new IllegalArgumentException("reviews 与坐标数组长度不一致"); } - File src = new File(pdfPath); - File temp = new File(src.getParentFile(), "_plm_stamp_" + src.getName()); - PdfReader reader = new PdfReader(pdfPath); - FileOutputStream fout = new FileOutputStream(temp); - PdfStamper stp = new PdfStamper(reader, fout); + byte[] stamped = stampFirstPageToBytes(pdfBytes, reviews, pswX, pswY, fontSize, rotation); + if (writeBytesWithRetry(dest.toPath(), stamped, 20)) { + return dest; + } + File sidecar = Files.createTempFile("plm_stamp_", ".pdf").toFile(); + Files.write(sidecar.toPath(), stamped); + return sidecar; + } + + public static boolean copyOverWithRetry(File from, File to) { + if (from == null || to == null || !from.isFile()) { + return false; + } + try { + return writeBytesWithRetry(to.toPath(), readAllBytesWithRetry(from.toPath()), 10); + } catch (IOException e) { + return false; + } + } + + public static byte[] readAllBytesWithRetry(Path src) throws IOException { + IOException last = null; + for (int i = 1; i <= 20; i++) { + try { + return Files.readAllBytes(src); + } catch (IOException e) { + last = e; + if (i == 20 || Thread.currentThread().isInterrupted()) { + throw e; + } + try { + Thread.sleep(Math.min(1000L, 50L * (1L << Math.min(i - 1, 4)))); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + throw last != null ? last : new IOException("读取文件失败: " + src); + } + + private static byte[] stampFirstPageToBytes(byte[] pdfBytes, List> reviews, + float[] pswX, float[] pswY, int fontSize, float rotation) + throws IOException, DocumentException { + PdfReader reader = new PdfReader(pdfBytes); + ByteArrayOutputStream bout = new ByteArrayOutputStream(Math.max(8192, pdfBytes.length)); + PdfStamper stp = new PdfStamper(reader, bout); try { PdfContentByte content = stp.getOverContent(1); content.beginText(); @@ -168,10 +235,42 @@ public class PDFDocHelper { content.endText(); } finally { stp.close(); - fout.close(); - reader.close(); } - Files.move(temp.toPath(), src.toPath(), StandardCopyOption.REPLACE_EXISTING); + return bout.toByteArray(); + } + + /** + * Windows 上刚复制完的文件常被杀毒/索引短暂锁住,写回时重试。 + */ + private static boolean writeBytesWithRetry(Path dest, byte[] data, int maxAttempts) { + for (int i = 1; i <= maxAttempts; i++) { + try { + Files.write(dest, data); + return true; + } catch (IOException e) { + if (i == maxAttempts || Thread.currentThread().isInterrupted()) { + return false; + } + try { + Thread.sleep(Math.min(1000L, 50L * (1L << Math.min(i - 1, 4)))); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return false; + } + + private static boolean isSameFile(File a, File b) { + if (a == null || b == null) { + return false; + } + try { + return a.getCanonicalFile().equals(b.getCanonicalFile()); + } catch (IOException e) { + return a.getAbsoluteFile().equals(b.getAbsoluteFile()); + } } /** @@ -235,11 +334,12 @@ public class PDFDocHelper { List> reviews, float[] pswX, float[] pswY) throws IOException, DocumentException { PdfReader reader = new PdfReader(inputPath); - // 临时输出文件名,防止冲突 - String tempName = name.replace(".pdf", "_temp.pdf"); - String tempPath = outputPath + "/" + tempName; + // 临时文件放到系统临时目录,避免 _temp.pdf 残留在 zip 输出目录被一起打包 + String baseName = name.toLowerCase().endsWith(".pdf") ? name.substring(0, name.length() - 4) : name; + File tempFile = Files.createTempFile("pdf-wm-" + sanitizeTempToken(baseName) + "-", ".pdf").toFile(); + String tempPath = tempFile.getAbsolutePath(); - FileOutputStream foc = new FileOutputStream(tempPath); + FileOutputStream foc = new FileOutputStream(tempFile); PdfStamper stamper = new PdfStamper(reader, foc); PdfGState gs1 = new PdfGState(); @@ -268,28 +368,50 @@ public class PDFDocHelper { e.printStackTrace(); return false; } finally { - stamper.close(); - foc.close(); - reader.close(); + try { + stamper.close(); + } catch (Exception ignore) { + } + try { + foc.close(); + } catch (Exception ignore) { + } + try { + reader.close(); + } catch (Exception ignore) { + } } - // 第二次处理 —— 添加文字水印 + // 第二次处理 —— 添加文字水印到正式输出文件 try { - signSinglePsw(outputPath, tempName, outputPath, name, reviews, pswX, pswY); + File outDir = new File(outputPath); + if (!outDir.exists() && !outDir.mkdirs()) { + throw new IOException("无法创建输出目录: " + outputPath); + } + signSinglePsw(tempFile.getParent(), tempFile.getName(), outputPath, name, reviews, pswX, pswY); } catch (Exception e) { e.printStackTrace(); return false; - } - - // 删除临时文件 - File tempFile = new File(tempPath); - if (tempFile.exists()) { - tempFile.delete(); + } finally { + // 删除临时文件(失败不阻塞主流程) + try { + Files.deleteIfExists(tempFile.toPath()); + } catch (Exception ignore) { + tempFile.deleteOnExit(); + } } return true; } + private static String sanitizeTempToken(String name) { + if (name == null || name.isEmpty()) { + return "file"; + } + String cleaned = name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); + return cleaned.length() > 40 ? cleaned.substring(0, 40) : cleaned; + } + /** * pdf合并 * @param files diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/handler/PlusDataPermissionHandler.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/handler/PlusDataPermissionHandler.java index 64df589..85c98d7 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/handler/PlusDataPermissionHandler.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/handler/PlusDataPermissionHandler.java @@ -28,6 +28,7 @@ import org.springframework.expression.BeanResolver; import org.springframework.expression.ExpressionParser; import org.springframework.expression.ParserContext; import org.springframework.expression.common.TemplateParserContext; +import org.springframework.util.ClassUtils; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; @@ -161,7 +162,7 @@ public class PlusDataPermissionHandler { int index = sb.lastIndexOf("."); String clazzName = sb.substring(0, index); String methodName = sb.substring(index + 1, sb.length()); - Class clazz = ClassUtil.loadClass(clazzName); + Class clazz = loadMapperClass(clazzName); List methods = Arrays.stream(ClassUtil.getDeclaredMethods(clazz)) .filter(method -> method.getName().equals(methodName)).collect(Collectors.toList()); DataPermission dataPermission; @@ -196,4 +197,15 @@ public class PlusDataPermissionHandler { public boolean isInvalid(String mappedStatementId) { return invalidCacheSet.contains(mappedStatementId); } + + /** + * ForkJoin 等异步线程的 contextClassLoader 可能找不到 Mapper,回退到 Spring 类加载器。 + */ + private Class loadMapperClass(String clazzName) { + try { + return ClassUtils.forName(clazzName, getClass().getClassLoader()); + } catch (ClassNotFoundException e) { + return ClassUtil.loadClass(clazzName); + } + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/AssemblyBomReceiveController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/AssemblyBomReceiveController.java index 0db0f21..0293094 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/AssemblyBomReceiveController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/AssemblyBomReceiveController.java @@ -12,18 +12,21 @@ import com.ruoyi.system.domain.dto.productionorder.AssemblyBomReceiveItemDTO; import com.ruoyi.system.domain.dto.productionorder.AssemblyBomReceiveSummary; import com.ruoyi.system.service.IPlmReceiveLogService; import com.ruoyi.system.service.IProductionOrderService; +import com.ruoyi.system.service.PlmPlanCompleteNotifyService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; 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 javax.validation.Valid; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; /** * PLM 总装配 BOM 推送接收入口(POST JSON 数组)。 @@ -39,9 +42,10 @@ public class AssemblyBomReceiveController extends BaseController { private final IProductionOrderService productionOrderService; private final IPlmReceiveLogService plmReceiveLogService; + private final PlmPlanCompleteNotifyService plmPlanCompleteNotifyService; /** - * 接收总装配 BOM 行列表,按生产令号全量替换写入 production_order,并同步 bom_details(bomType=0)。 + * 接收总装配 BOM 行列表。已审核且主产品图号相同则拒绝;主产品图号不同则追加。 *

* POST /system/plm/assemblyBomReceive
* Content-Type: application/json; charset=UTF-8 @@ -60,6 +64,7 @@ public class AssemblyBomReceiveController extends BaseController { summaryMap.put("lineCount", summary.getLineCount()); summaryMap.put("insertedCount", summary.getInsertedCount()); summaryMap.put("replaced", summary.isReplaced()); + summaryMap.put("appended", summary.isAppended()); plmReceiveLogService.record( IPlmReceiveLogService.BIZ_ASSEMBLY_BOM, API_PATH, @@ -73,6 +78,10 @@ public class AssemblyBomReceiveController extends BaseController { null, clientIp ); + // 工艺可能先到、总装后到:入库后再核实两边都有才发给 plan-user-id + final String productionOrder = summary.getProductionOrderNo(); + CompletableFuture.runAsync(() -> + plmPlanCompleteNotifyService.notifyIfAssemblyAndRouteComplete(productionOrder, null)); return R.ok("接收成功", summary); } catch (Exception e) { plmReceiveLogService.record( @@ -92,6 +101,20 @@ public class AssemblyBomReceiveController extends BaseController { } } + /** + * 总装 BOM 与工艺都已入库后,按令号重发生管通知(工艺计划表 + Markdown)。 + * 两边缺一则只返回原因,不发钉钉。 + *

+ * POST /system/plm/planCompleteNotify?productionOrderNo=令号 + */ + @SaIgnore + @Log(title = "PLM工艺完成通知重发", businessType = BusinessType.OTHER) + @PostMapping("/planCompleteNotify") + public R planCompleteNotify(@RequestParam("productionOrderNo") String productionOrderNo) { + String result = plmPlanCompleteNotifyService.notifyIfAssemblyAndRouteComplete(productionOrderNo, null); + return R.ok(result); + } + private static String resolveOrderNo(List body) { if (body == null) { return null; diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/BomDetailsController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/BomDetailsController.java index 0999649..df900e9 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/BomDetailsController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/BomDetailsController.java @@ -22,6 +22,8 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.core.validate.AddGroup; import com.ruoyi.common.core.validate.EditGroup; import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.JsyyOrderNo; +import com.ruoyi.common.utils.ProjectStatusLock; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.domain.ProcessRoute; import com.ruoyi.system.domain.dto.*; @@ -96,6 +98,9 @@ public class BomDetailsController extends BaseController { private final DingTalkProperties dingTalkProperties; + /** 电气 BOM 导入成功后发「无需做工艺」钉钉,并写入推送日志 */ + private final SkipProcessNotifyService skipProcessNotifyService; + /** * 查询bom明细列表 */ @@ -437,6 +442,156 @@ public class BomDetailsController extends BaseController { || "PLM".equalsIgnoreCase(material.getUpdateBy()); } + /** + * 电气 Excel BOM:bomType=1,且不是 PLM 项目、也不是 PLM 推送的材料 BOM。 + */ + private static boolean isElectricalExcelBom(BomDetails material, ProcessOrderPro project) { + if (material == null || !"1".equals(material.getBomType())) { + return false; + } + if (project != null && ProcessOrderProSource.PLM.equals(project.getSource())) { + return false; + } + return !"PLM".equalsIgnoreCase(material.getCreateBy()) + && !"PLM".equalsIgnoreCase(material.getUpdateBy()); + } + + private static boolean containsElectricalExcelBom(List materials, ProcessOrderPro project) { + if (materials == null) { + return false; + } + for (BomDetails material : materials) { + if (isElectricalExcelBom(material, project)) { + return true; + } + } + return false; + } + + private static String firstNonBlankTrim(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (StringUtils.isNotBlank(value)) { + return value.trim(); + } + } + return null; + } + + /** + * 单台数量:{@code 1/4}、{@code 1/3} 拆成分子和分母;普通数字分母为 1。 + */ + private static void applyElectricalQuantity(BomDetails bomDetails, String rawQuantity) { + bomDetails.setDenominator(1.0); + if (StringUtils.isBlank(rawQuantity)) { + bomDetails.setQuantity(null); + return; + } + String text = rawQuantity.trim().replace('/', '/'); + int slash = text.indexOf('/'); + if (slash > 0 && slash == text.lastIndexOf('/')) { + String numeratorText = text.substring(0, slash).trim(); + String denominatorText = text.substring(slash + 1).trim(); + Double numerator = parsePositiveNumber(numeratorText); + Double denominator = parsePositiveNumber(denominatorText); + if (numerator != null && denominator != null && denominator > 0) { + bomDetails.setQuantity(formatQuantityNumber(numerator)); + bomDetails.setDenominator(denominator); + return; + } + } + Double number = parsePositiveNumber(text); + bomDetails.setQuantity(number == null ? text : formatQuantityNumber(number)); + } + + private static Double parsePositiveNumber(String text) { + if (StringUtils.isBlank(text)) { + return null; + } + try { + double value = Double.parseDouble(text); + if (value < 0 || Double.isInfinite(value) || Double.isNaN(value)) { + return null; + } + return value; + } catch (NumberFormatException e) { + return null; + } + } + + private static String formatQuantityNumber(double value) { + if (value == Math.rint(value) && value <= Long.MAX_VALUE) { + return String.valueOf((long) value); + } + return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); + } + + /** 电气 Excel「型号/规格型号」入库在 material;仅电气建档写入金蝶 FSpecification */ + private static String resolveElectricalSpecification(BomDetails details) { + return details == null ? null : StringUtils.trim(details.getMaterial()); + } + + private static void writeKingdeeElectricalSpecification(JsonObject model, BomDetails details) { + String spec = resolveElectricalSpecification(details); + if (StringUtils.isNotBlank(spec)) { + model.addProperty("FSpecification", spec); + log.info("[电气建档] 写入规格型号 FNumber={} FSpecification={}", + details.getPartNumber(), spec); + } else { + log.warn("[电气建档] 规格型号为空,未写入 FSpecification, FNumber={}", + details == null ? null : details.getPartNumber()); + } + } + + /** 电气单位在 remarks,不能按品牌 wareHouse 去 switch(品牌为空会 NPE,建档失败规格也进不去) */ + private static void fillElectricalKingdeeUnit(JsonObject target, String unitText) { + if (target == null || StringUtils.isBlank(unitText)) { + return; + } + switch (unitText.trim()) { + case "把": + target.addProperty("FNumber", "002"); + break; + case "包": + target.addProperty("FNumber", "012"); + break; + case "个": + target.addProperty("FNumber", "001"); + break; + case "根": + target.addProperty("FNumber", "003"); + break; + case "米": + target.addProperty("FNumber", "005"); + break; + case "卷": + target.addProperty("FNumber", "020"); + break; + case "盒": + target.addProperty("FNumber", "014"); + break; + case "件": + target.addProperty("FNumber", "jian"); + break; + case "盘": + target.addProperty("FNumber", "011"); + break; + case "瓶": + target.addProperty("FNumber", "016"); + break; + case "条": + target.addProperty("FNumber", "017"); + break; + case "套": + target.addProperty("FNumber", "007"); + break; + default: + break; + } + } + /* 触发金蝶物料清单按钮 */ @Log(title = "金蝶物料清单更新") @SaCheckPermission("system:details:updateMaterial") @@ -473,9 +628,27 @@ public class BomDetailsController extends BaseController { if (bomDetailParams == null || bomDetailParams.isEmpty()) { return R.fail("请先选择要上传的物料"); } + for (Map param : bomDetailParams) { + String orderNo = param == null ? null : param.get("totalWeight"); + String jsyyDeny = JsyyOrderNo.denyIfMatches(orderNo); + if (jsyyDeny != null) { + return R.fail(jsyyDeny); + } + if (StringUtils.isNotBlank(orderNo)) { + ProcessOrderPro orderForLock = iProcessOrderProService.selectByProjectNumber(orderNo); + if (orderForLock != null) { + String statusDeny = ProjectStatusLock.denyIfLocked( + orderForLock.getBomStatus(), ProjectStatusLock.Action.UPDATE_MATERIAL); + if (statusDeny != null) { + return R.fail(statusDeny); + } + } + } + } List bomDetailsList = new ArrayList<>(); List logDTOS = new ArrayList<>(); List failedMaterials = new ArrayList<>(); // 用于跟踪处理失败的物料 + Set electricalOrderNos = new LinkedHashSet<>(); String totalWeight = ""; // 遍历前端传来的数据 for (Map param : bomDetailParams) { @@ -505,6 +678,9 @@ public class BomDetailsController extends BaseController { failedMaterials.add(fnumber); continue; } + if (containsElectricalExcelBom(bomDetails, bo)) { + electricalOrderNos.add(bo.getProductionOrderNo()); + } // 第一步:处理所有物料的保存 for (BomDetails material : bomDetails) { // 获取工艺表中的非委外工时 @@ -513,7 +689,7 @@ public class BomDetailsController extends BaseController { if (material.getPartNumber().startsWith("015") || material.getPartNumber().startsWith("009")) { return R.fail("015和009为管控物料,请先申请"); } - if (material.getRemarks() != null && material.getRemarks().contains("甲供")) { + if (isOwnerSuppliedBom(material)) { continue; } String state = determineState(material); @@ -537,41 +713,46 @@ public class BomDetailsController extends BaseController { } } - // 第二步:BOM校验和上传(在循环外部) - ValidateBomResult validateResult = validateBOM(fnumber, bomDetails); - boolean needUpload = !validateResult.isMatch(); - if (needUpload) { - try { - // 物料清单保存方法,判断是电气还是生产 - BOMUploadResult bomUploadResult = FBloadBillOfMaterialsPreservation(bomDetails, bo, groupName); - if (bomUploadResult.isSuccess()) { - KindegeeLogDTO logDTO = new KindegeeLogDTO(); - logDTO.setProjectCode(bo.getProductionOrderNo()); - logDTO.setMaterialCode(fnumber); // 使用物料编码而不是单个物料的编码 - logDTO.setCode("200"); - logDTO.setReason("成功上传" + "版本号:" + bomUploadResult.getNumber()); - logDTOS.add(logDTO); - } else { - KindegeeLogDTO logDTO = new KindegeeLogDTO(); - logDTO.setProjectCode(bo.getProductionOrderNo()); - logDTO.setMaterialCode(fnumber); - logDTO.setCode("300"); - logDTO.setReason(bomUploadResult.getErrorMessage()); - logDTOS.add(logDTO); - } - bomDetailsList.addAll(bomDetails); - } catch (Exception e) { - log.error("保存BOM失败, 物料编码: {}, 错误: {}", fnumber, e.getMessage()); - failedMaterials.add(fnumber); - } + // 第二步:BOM校验和上传(过滤甲供件,不进金蝶 BOM) + List uploadBomDetails = filterOwnerSuppliedFromBom(bomDetails); + if (uploadBomDetails.isEmpty()) { + log.info("过滤甲供件后无子项,跳过金蝶BOM上传, 物料编码: {}", fnumber); } else { - KindegeeLogDTO logDTO = new KindegeeLogDTO(); - logDTO.setProjectCode(bo.getProductionOrderNo()); - logDTO.setMaterialCode(fnumber); - logDTO.setCode("100"); - logDTO.setReason("BOM已存在且一致,版本号:" + validateResult.getVersion()); - logDTOS.add(logDTO); - log.info("BOM已存在且一致,物料编码: {},跳过保存", fnumber); + ValidateBomResult validateResult = validateBOM(fnumber, uploadBomDetails); + boolean needUpload = !validateResult.isMatch(); + if (needUpload) { + try { + // 物料清单保存方法,判断是电气还是生产 + BOMUploadResult bomUploadResult = FBloadBillOfMaterialsPreservation(uploadBomDetails, bo, groupName); + if (bomUploadResult.isSuccess()) { + KindegeeLogDTO logDTO = new KindegeeLogDTO(); + logDTO.setProjectCode(bo.getProductionOrderNo()); + logDTO.setMaterialCode(fnumber); // 使用物料编码而不是单个物料的编码 + logDTO.setCode("200"); + logDTO.setReason("成功上传" + "版本号:" + bomUploadResult.getNumber()); + logDTOS.add(logDTO); + } else { + KindegeeLogDTO logDTO = new KindegeeLogDTO(); + logDTO.setProjectCode(bo.getProductionOrderNo()); + logDTO.setMaterialCode(fnumber); + logDTO.setCode("300"); + logDTO.setReason(bomUploadResult.getErrorMessage()); + logDTOS.add(logDTO); + } + bomDetailsList.addAll(bomDetails); + } catch (Exception e) { + log.error("保存BOM失败, 物料编码: {}, 错误: {}", fnumber, e.getMessage()); + failedMaterials.add(fnumber); + } + } else { + KindegeeLogDTO logDTO = new KindegeeLogDTO(); + logDTO.setProjectCode(bo.getProductionOrderNo()); + logDTO.setMaterialCode(fnumber); + logDTO.setCode("100"); + logDTO.setReason("BOM已存在且一致,版本号:" + validateResult.getVersion()); + logDTOS.add(logDTO); + log.info("BOM已存在且一致,物料编码: {},跳过保存", fnumber); + } } } } @@ -584,7 +765,17 @@ public class BomDetailsController extends BaseController { return R.fail("未找到生产令号对应的项目: " + totalWeight); } processOrderProBo.setDrawingType(JSONUtil.toJsonStr(logDTOS)); - processOrderProBo.setBomStatus(2L); + boolean electricalProject = electricalOrderNos.contains(processOrderProBo.getProductionOrderNo()); + boolean productionDone = processOrderProBo.getBomStatus() != null + && processOrderProBo.getBomStatus() == ProjectStatusLock.PRODUCTION_DONE; + if (electricalProject) { + // 电气项目:物料清单更新成功后进入开始生产;已生产完成不回退 + if (!productionDone) { + processOrderProBo.setBomStatus(ProjectStatusLock.IN_PRODUCTION); + } + } else { + processOrderProBo.setBomStatus(2L); + } processOrderProMapper.updateById(processOrderProBo); // 更新钉钉卡片数据 @@ -633,7 +824,12 @@ public class BomDetailsController extends BaseController { } // 返回处理结果 - return R.ok("成功", bomDetailsList); + String resultMsg = electricalOrderNos.contains(processOrderProBo.getProductionOrderNo()) + && (processOrderProBo.getBomStatus() != null + && processOrderProBo.getBomStatus() == ProjectStatusLock.IN_PRODUCTION) + ? "成功,电气项目状态已改为开始生产" + : "成功"; + return R.ok(resultMsg, bomDetailsList); } @@ -825,6 +1021,10 @@ public class BomDetailsController extends BaseController { model.add("FTreeEntity", fTreeEntity); ArrayList fTreeEntityList = new ArrayList<>(); for (BomDetails details : bomlist) { + if (isOwnerSuppliedBom(details)) { + log.info("跳过甲供件,不写入金蝶BOM: {}", details.getPartNumber()); + continue; + } // 创建FTreeEntity对象,并加入FTreeEntity数组 JsonObject fTreeEntityItem = new JsonObject(); fTreeEntity.add(fTreeEntityItem); @@ -854,6 +1054,10 @@ public class BomDetailsController extends BaseController { fTreeEntityItem.addProperty("FDENOMINATOR", details.getDenominator()); fTreeEntityList.add(fTreeEntityItem); } + if (fTreeEntity.size() == 0) { + log.warn("过滤甲供件后无子项,跳过金蝶BOM保存, 父项: {}", bomDetails.getFNumber()); + return 1; + } String jsonData = json.toString(); log.debug("打印json:" + jsonData); System.out.println(jsonData); @@ -947,6 +1151,10 @@ public class BomDetailsController extends BaseController { if (bomlist.isEmpty()) { return null; } + if (isOwnerSuppliedBom(details)) { + log.info("跳过甲供件,不写入金蝶BOM: {}", details.getPartNumber()); + continue; + } // 创建FTreeEntity对象,并加入FTreeEntity数组 JsonObject fTreeEntityItem = new JsonObject(); fTreeEntity.add(fTreeEntityItem); @@ -1006,6 +1214,10 @@ public class BomDetailsController extends BaseController { } fTreeEntityList.add(fTreeEntityItem); } + if (fTreeEntity.size() == 0) { + log.warn("过滤甲供件后无子项,跳过金蝶BOM保存, 图号={}", bomDetails1.getFNumber()); + return BOMUploadResult.success(null, "SKIP_OWNER_SUPPLY"); + } try { String resultJson = new K3CloudApi().save("ENG_BOM", json.toString()); // 直接用 Jackson 解析 @@ -1049,6 +1261,33 @@ public class BomDetailsController extends BaseController { return StringUtils.isNotBlank(text) && ("伊特".equals(text.trim()) || text.contains("伊特")); } + /** 备注或材质含「甲供」视为甲供件,不进入金蝶 BOM */ + private static boolean isOwnerSuppliedBom(BomDetails details) { + if (details == null) { + return false; + } + return containsOwnerSupply(details.getRemarks()) || containsOwnerSupply(details.getMaterial()); + } + + private static boolean containsOwnerSupply(String text) { + return StringUtils.isNotBlank(text) && text.contains("甲供"); + } + + /** 生成/校验/上传金蝶 BOM 前过滤甲供件 */ + private static List filterOwnerSuppliedFromBom(List bomDetails) { + if (bomDetails == null || bomDetails.isEmpty()) { + return Collections.emptyList(); + } + List filtered = new ArrayList<>(bomDetails.size()); + for (BomDetails detail : bomDetails) { + if (isOwnerSuppliedBom(detail)) { + continue; + } + filtered.add(detail); + } + return filtered; + } + /* * 父级物料保存接口 * 单位 是台 @@ -1955,7 +2194,8 @@ public class BomDetailsController extends BaseController { // 添加Model字段 model.addProperty("FMATERIALID", 0); - model.addProperty("FSpecification", bomDetails1.getMaterial());// 规格 为存入的材质字段 + // 电气规格型号:Excel「型号/规格型号」入库 material;生产建档仍走 loadMaterialPreservation,互不影响 + writeKingdeeElectricalSpecification(model, bomDetails1); model.addProperty("F_HBYT_PP", bomDetails1.getWareHouse());// 品牌 为存入的仓库字段 writeKingdeeChildDanZhong(model, bomDetails1, "电气子项物料建档"); model.addProperty("FNumber", bomDetails1.getPartNumber()); @@ -2006,46 +2246,10 @@ public class BomDetailsController extends BaseController { // 创建FBaseUnitId对象,并加入SubHeadEntity JsonObject fBaseUnitId = new JsonObject(); - switch (bomDetails1.getWareHouse()) {// 单位 - case "把": - fBaseUnitId.addProperty("FNumber", "002"); - break; - case "包": - fBaseUnitId.addProperty("FNumber", "012"); - break; - case "个": - fBaseUnitId.addProperty("FNumber", "001"); - break; - case "根": - fBaseUnitId.addProperty("FNumber", "003"); - break; - case "米": - fBaseUnitId.addProperty("FNumber", "005"); - break; - case "卷": - fBaseUnitId.addProperty("FNumber", "020"); - break; - case "盒": - fBaseUnitId.addProperty("FNumber", "014"); - break; - case "件": - fBaseUnitId.addProperty("FNumber", "jian"); - break; - case "盘": - fBaseUnitId.addProperty("FNumber", "011"); - break; - case "瓶": - fBaseUnitId.addProperty("FNumber", "016"); - break; - case "条": - fBaseUnitId.addProperty("FNumber", "017"); - break; - case "套": - fBaseUnitId.addProperty("FNumber", "007"); - break; - } + String electricalUnit = StringUtils.trim(bomDetails1.getRemarks()); + fillElectricalKingdeeUnit(fBaseUnitId, electricalUnit); subHeadEntity.add("FBaseUnitId", fBaseUnitId); - if (states.equals("1")) { + if ("1".equals(states)) { subHeadEntity.addProperty("FIsPurchase", true); subHeadEntity.addProperty("FIsSale", true); subHeadEntity.addProperty("FIsInventory", true); @@ -2062,44 +2266,7 @@ public class BomDetailsController extends BaseController { JsonObject subHeadEntity1 = new JsonObject(); model.add("SubHeadEntity1", subHeadEntity1); JsonObject fStoreUnitId = new JsonObject(); - switch (bomDetails1.getWareHouse()) { - case "把": - fBaseUnitId.addProperty("FNumber", "002"); - break; - case "包": - fBaseUnitId.addProperty("FNumber", "012"); - break; - case "个": - fBaseUnitId.addProperty("FNumber", "001"); - break; - case "根": - fBaseUnitId.addProperty("FNumber", "003"); - break; - case "米": - fBaseUnitId.addProperty("FNumber", "005"); - break; - case "卷": - fBaseUnitId.addProperty("FNumber", "020"); - break; - case "盒": - fBaseUnitId.addProperty("FNumber", "014"); - break; - case "件": - fBaseUnitId.addProperty("FNumber", "jian"); - break; - case "盘": - fBaseUnitId.addProperty("FNumber", "011"); - break; - case "瓶": - fBaseUnitId.addProperty("FNumber", "016"); - break; - case "条": - fBaseUnitId.addProperty("FNumber", "017"); - break; - case "套": - fBaseUnitId.addProperty("FNumber", "007"); - break; - } + fillElectricalKingdeeUnit(fStoreUnitId, electricalUnit); subHeadEntity1.add("FStoreUnitID", fStoreUnitId); subHeadEntity1.addProperty("FUnitConvertDir", "1"); @@ -2120,44 +2287,7 @@ public class BomDetailsController extends BaseController { subHeadEntity1.addProperty("FBoxStandardQty", 0.0); // 创建FPurchaseUnitId对象,并加入SubHeadEntity1 JsonObject fPurchaseUnitId = new JsonObject(); - switch (bomDetails1.getWareHouse()) { - case "把": - fBaseUnitId.addProperty("FNumber", "002"); - break; - case "包": - fBaseUnitId.addProperty("FNumber", "012"); - break; - case "个": - fBaseUnitId.addProperty("FNumber", "001"); - break; - case "根": - fBaseUnitId.addProperty("FNumber", "003"); - break; - case "米": - fBaseUnitId.addProperty("FNumber", "005"); - break; - case "卷": - fBaseUnitId.addProperty("FNumber", "020"); - break; - case "盒": - fBaseUnitId.addProperty("FNumber", "014"); - break; - case "件": - fBaseUnitId.addProperty("FNumber", "jian"); - break; - case "盘": - fBaseUnitId.addProperty("FNumber", "011"); - break; - case "瓶": - fBaseUnitId.addProperty("FNumber", "016"); - break; - case "条": - fBaseUnitId.addProperty("FNumber", "017"); - break; - case "套": - fBaseUnitId.addProperty("FNumber", "007"); - break; - } + fillElectricalKingdeeUnit(fPurchaseUnitId, electricalUnit); subHeadEntity1.add("FPurchaseUnitId", fPurchaseUnitId); // 创建SubHeadEntity3对象,并加入Model @@ -2166,44 +2296,7 @@ public class BomDetailsController extends BaseController { // 创建FPurchasePriceUnitId对象,并加入SubHeadEntity3 JsonObject fPurchasePriceUnitId = new JsonObject(); - switch (bomDetails1.getWareHouse()) { - case "把": - fBaseUnitId.addProperty("FNumber", "002"); - break; - case "包": - fBaseUnitId.addProperty("FNumber", "012"); - break; - case "个": - fBaseUnitId.addProperty("FNumber", "001"); - break; - case "根": - fBaseUnitId.addProperty("FNumber", "003"); - break; - case "米": - fBaseUnitId.addProperty("FNumber", "005"); - break; - case "卷": - fBaseUnitId.addProperty("FNumber", "020"); - break; - case "盒": - fBaseUnitId.addProperty("FNumber", "014"); - break; - case "件": - fBaseUnitId.addProperty("FNumber", "jian"); - break; - case "盘": - fBaseUnitId.addProperty("FNumber", "011"); - break; - case "瓶": - fBaseUnitId.addProperty("FNumber", "016"); - break; - case "条": - fBaseUnitId.addProperty("FNumber", "017"); - break; - case "套": - fBaseUnitId.addProperty("FNumber", "007"); - break; - } + fillElectricalKingdeeUnit(fPurchasePriceUnitId, electricalUnit); subHeadEntity3.add("FPurchasePriceUnitId", fPurchasePriceUnitId); subHeadEntity3.addProperty("FIsQuota", false); @@ -2212,18 +2305,18 @@ public class BomDetailsController extends BaseController { JsonObject subHeadEntity6 = new JsonObject(); model.add("SubHeadEntity6", subHeadEntity6); // 不同的属性不同的检验方案 - if (states.equals("1")) { + if ("1".equals(states)) { // 外购 subHeadEntity6.addProperty("FCheckIncoming", true); subHeadEntity6.addProperty("FCheckStock", true); } - if (states.equals("2")) { + if ("2".equals(states)) { // 自制 subHeadEntity6.addProperty("FCheckProduct", true); subHeadEntity6.addProperty("FCheckStock", true); subHeadEntity6.addProperty("FCheckReturnMtrl", true); } - if (states.equals("3")) { + if ("3".equals(states)) { // 委外 subHeadEntity6.addProperty("FCheckIncoming", true); subHeadEntity6.addProperty("FCheckProduct", true); @@ -2236,44 +2329,7 @@ public class BomDetailsController extends BaseController { // 创建FProduceUnitId对象,并加入SubHeadEntity5 JsonObject fProduceUnitId = new JsonObject(); - switch (bomDetails1.getWareHouse()) { - case "把": - fBaseUnitId.addProperty("FNumber", "002"); - break; - case "包": - fBaseUnitId.addProperty("FNumber", "012"); - break; - case "个": - fBaseUnitId.addProperty("FNumber", "001"); - break; - case "根": - fBaseUnitId.addProperty("FNumber", "003"); - break; - case "米": - fBaseUnitId.addProperty("FNumber", "005"); - break; - case "卷": - fBaseUnitId.addProperty("FNumber", "020"); - break; - case "盒": - fBaseUnitId.addProperty("FNumber", "014"); - break; - case "件": - fBaseUnitId.addProperty("FNumber", "jian"); - break; - case "盘": - fBaseUnitId.addProperty("FNumber", "011"); - break; - case "瓶": - fBaseUnitId.addProperty("FNumber", "016"); - break; - case "条": - fBaseUnitId.addProperty("FNumber", "017"); - break; - case "套": - fBaseUnitId.addProperty("FNumber", "007"); - break; - } + fillElectricalKingdeeUnit(fProduceUnitId, electricalUnit); subHeadEntity5.add("FProduceUnitId", fProduceUnitId); // 创建FProduceBillType对象,并加入SubHeadEntity5 @@ -2283,46 +2339,9 @@ public class BomDetailsController extends BaseController { // 创建FBOMUnitId对象,并加入SubHeadEntity5 JsonObject fBOMUnitId = new JsonObject(); - switch (bomDetails1.getRemarks()) { - case "把": - fBaseUnitId.addProperty("FNumber", "002"); - break; - case "包": - fBaseUnitId.addProperty("FNumber", "012"); - break; - case "个": - fBaseUnitId.addProperty("FNumber", "001"); - break; - case "根": - fBaseUnitId.addProperty("FNumber", "003"); - break; - case "米": - fBaseUnitId.addProperty("FNumber", "005"); - break; - case "卷": - fBaseUnitId.addProperty("FNumber", "020"); - break; - case "盒": - fBaseUnitId.addProperty("FNumber", "014"); - break; - case "件": - fBaseUnitId.addProperty("FNumber", "jian"); - break; - case "盘": - fBaseUnitId.addProperty("FNumber", "011"); - break; - case "瓶": - fBaseUnitId.addProperty("FNumber", "016"); - break; - case "条": - fBaseUnitId.addProperty("FNumber", "017"); - break; - case "套": - fBaseUnitId.addProperty("FNumber", "007"); - break; - } + fillElectricalKingdeeUnit(fBOMUnitId, electricalUnit); subHeadEntity5.add("FBOMUnitId", fBOMUnitId); - subHeadEntity5.addProperty("FIsMainPrd", !states.equals("1")); + subHeadEntity5.addProperty("FIsMainPrd", !"1".equals(states)); subHeadEntity5.addProperty("FIssueType", "1"); // 创建FPickStockId对象,并加入SubHeadEntity5 @@ -2382,6 +2401,13 @@ public class BomDetailsController extends BaseController { } } + @Log(title = "电气BOM导入模板", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:details:importElectricalBom") + @PostMapping("/importElectricalBomTemplate") + public void importElectricalBomTemplate(HttpServletResponse response) { + ExcelUtil.exportExcel(new ArrayList<>(), "电气BOM", ElectricalMaterialBomVO.class, response); + } + /** * 导入BOM * daorubaon @@ -2392,8 +2418,9 @@ public class BomDetailsController extends BaseController { @Log(title = "导入电气bom", businessType = BusinessType.IMPORT) @SaCheckPermission("system:details:importElectricalBom") @PostMapping(value = "/importElectricalBom", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public R> importElectricalBom(@RequestPart("file") MultipartFile file) { - Map resp = new HashMap<>(); + public R> importElectricalBom( + @RequestPart("file") MultipartFile file, + @RequestParam(value = "productionOrderNo", required = false) String productionOrderNo) { try { if (file == null || file.isEmpty() || StringUtils.isEmpty(file.getOriginalFilename())) { return R.fail("请选择有效的Excel文件"); @@ -2404,12 +2431,37 @@ public class BomDetailsController extends BaseController { return R.fail("仅支持Excel文件(xls/xlsx)"); } - List rows = ExcelUtil.importExcel(file.getInputStream(), ElectricalMaterialBomVO.class); + List rows; + try { + rows = ExcelUtil.importExcel(file.getInputStream(), ElectricalMaterialBomVO.class); + } catch (Exception parseEx) { + log.error("电气BOM Excel解析失败: {}", filename, parseEx); + return R.fail("Excel解析失败,请检查表头是否包含:生产令号、物料编码、物料名称、上级部件、上级部件图号"); + } + int total = rows != null ? rows.size() : 0; + int skippedLingYong = 0; + int skippedEmpty = 0; List list = new ArrayList<>(); + Set orderNos = new LinkedHashSet<>(); + if (StringUtils.isNotBlank(productionOrderNo)) { + orderNos.add(productionOrderNo.trim()); + } if (rows != null) { for (ElectricalMaterialBomVO bomVO : rows) { + if (bomVO == null) { + skippedEmpty++; + continue; + } + if (StringUtils.isNotBlank(bomVO.getProductionOrderNo())) { + orderNos.add(bomVO.getProductionOrderNo().trim()); + } + if (StringUtils.isBlank(bomVO.getDrawingNo()) && StringUtils.isBlank(bomVO.getDrawingName())) { + skippedEmpty++; + continue; + } if (bomVO.getRemark() != null && bomVO.getRemark().contains("领用")) { + skippedLingYong++; continue; } BomDetails bomDetails = new BomDetails(); @@ -2418,7 +2470,8 @@ public class BomDetailsController extends BaseController { bomDetails.setFName(bomVO.getParentPart()); bomDetails.setPartNumber(bomVO.getDrawingNo()); bomDetails.setName(bomVO.getDrawingName()); - bomDetails.setMaterial(bomVO.getModel()); + String specification = firstNonBlankTrim(bomVO.getModel(), bomVO.getSpecificationModel()); + bomDetails.setMaterial(specification); bomDetails.setWareHouse(bomVO.getBrand()); String brand = bomVO.getBrand(); if (brand != null && brand.contains("伊特")) { @@ -2426,10 +2479,9 @@ public class BomDetailsController extends BaseController { } else { bomDetails.setStats("外购"); } - bomDetails.setQuantity(bomVO.getQuantity() == null ? null : bomVO.getQuantity().toString()); + applyElectricalQuantity(bomDetails, bomVO.getQuantity()); bomDetails.setRemarks(bomVO.getUnit()); bomDetails.setUpdateTime(new Date()); - bomDetails.setDenominator(1.0); bomDetails.setBomType("1"); list.add(bomDetails); } @@ -2437,21 +2489,140 @@ public class BomDetailsController extends BaseController { List vos = BeanUtil.copyToList(list, BomDetailsVo.class); List toInsert = saveBomDetails(vos); - boolean saved = false; - if (!toInsert.isEmpty()) { - saved = bomDetailsMapper.insertBatch(toInsert); + int matchedMaterial = 0; + int unmatchedMaterial = 0; + int nameMismatch = 0; + for (BomDetails item : toInsert) { + String flag = item.getUnitWeight(); + if ("是".equals(flag)) { + matchedMaterial++; + } else if ("编码名称不符".equals(flag)) { + nameMismatch++; + } else { + unmatchedMaterial++; + } } + int savedCount = 0; + boolean saved = false; + if (!toInsert.isEmpty()) { + saved = Boolean.TRUE.equals(bomDetailsMapper.insertBatch(toInsert)); + savedCount = saved ? toInsert.size() : 0; + } + + Map resp = new HashMap<>(); resp.put("totalRows", total); resp.put("parsedRows", list.size()); - resp.put("savedRows", saved); - resp.put("message", "导入完成"); - return R.ok(resp); + resp.put("skippedLingYong", skippedLingYong); + resp.put("skippedEmpty", skippedEmpty); + resp.put("savedCount", savedCount); + resp.put("matchedMaterial", matchedMaterial); + resp.put("unmatchedMaterial", unmatchedMaterial); + resp.put("nameMismatch", nameMismatch); + resp.put("bomStatus", ProjectStatusLock.DRAWING_DONE); + resp.put("needProcess", 0); + + if (!saved) { + String failMsg; + if (total == 0) { + failMsg = "导入失败:Excel没有读到数据,请确认是否用对模板、表头是否在第一行"; + } else if (list.isEmpty() && skippedLingYong > 0) { + failMsg = "导入失败:有效物料为0(领用行 " + skippedLingYong + " 条已跳过)"; + } else if (list.isEmpty()) { + failMsg = "导入失败:没有可保存的物料行"; + } else { + failMsg = "导入失败:物料解析了 " + list.size() + " 条,但写入数据库失败"; + } + resp.put("success", false); + resp.put("resultType", "error"); + resp.put("skipProcessOrders", Collections.emptyList()); + resp.put("productionDoneOrders", Collections.emptyList()); + resp.put("unmatchedOrderNos", Collections.emptyList()); + resp.put("message", failMsg); + return R.fail(failMsg, resp); + } + + List electricalOrders = applyElectricalSkipProcess(orderNos, resp); + // 匹配到的令号发「电气BOM已上传」通知(含已生产完成、仅改 needProcess 的) + skipProcessNotifyService.notifyElectricalBomUploaded(electricalOrders, savedCount); + @SuppressWarnings("unchecked") + List skipProcessOrders = (List) resp.get("skipProcessOrders"); + @SuppressWarnings("unchecked") + List unmatchedOrderNos = (List) resp.get("unmatchedOrderNos"); + boolean statusUpdated = skipProcessOrders != null && !skipProcessOrders.isEmpty(); + StringBuilder msg = new StringBuilder("导入成功,已保存 ").append(savedCount).append(" 条物料"); + if (statusUpdated) { + msg.append(";令号 ").append(String.join("、", skipProcessOrders)) + .append(" 已跳过工艺,状态改为设计出图完成"); + } else if (unmatchedOrderNos != null && !unmatchedOrderNos.isEmpty()) { + msg.append(";未匹配到项目令号 ").append(String.join("、", unmatchedOrderNos)) + .append(",状态未改"); + } + String resultType = statusUpdated ? "success" : "warning"; + resp.put("success", true); + resp.put("resultType", resultType); + resp.put("message", msg.toString()); + return R.ok(msg.toString(), resp); } catch (Exception e) { - return R.fail("导入失败: " + e.getMessage()); + log.error("导入电气BOM失败", e); + String detail = e.getMessage(); + if (StringUtils.isBlank(detail)) { + detail = e.getClass().getSimpleName(); + } + return R.fail("导入失败:" + detail); } } + /** + * 电气 BOM 不走工艺审核:对应令号标记不做工艺,状态改为设计出图完成。 + * 开始生产改在更新物料清单时,确认是电气项目后再写。已生产完成的令号不回退状态。 + * + * @return 匹配到的项目,供钉钉通知使用(含未改状态的已完工令号) + */ + private List applyElectricalSkipProcess(Set orderNos, Map resp) { + List updated = new ArrayList<>(); + List productionDone = new ArrayList<>(); + List matched = new ArrayList<>(); + Set found = new HashSet<>(); + if (orderNos != null && !orderNos.isEmpty()) { + List orders = processOrderProMapper.selectList( + new LambdaQueryWrapper().in(ProcessOrderPro::getProductionOrderNo, orderNos)); + for (ProcessOrderPro order : orders) { + if (order == null || order.getId() == null) { + continue; + } + found.add(order.getProductionOrderNo()); + ProcessOrderPro patch = new ProcessOrderPro(); + patch.setId(order.getId()); + patch.setNeedProcess(0); + // 内存对象同步,供后续钉钉通知读最新标记 + order.setNeedProcess(0); + if (order.getBomStatus() != null && order.getBomStatus() == ProjectStatusLock.PRODUCTION_DONE) { + // 已生产完成:只改 needProcess,不回退状态 + productionDone.add(order.getProductionOrderNo()); + } else { + patch.setBomStatus(ProjectStatusLock.DRAWING_DONE); + order.setBomStatus(ProjectStatusLock.DRAWING_DONE); + updated.add(order.getProductionOrderNo()); + } + processOrderProMapper.updateById(patch); + matched.add(order); + } + } + List unmatched = new ArrayList<>(); + if (orderNos != null) { + for (String no : orderNos) { + if (StringUtils.isNotBlank(no) && !found.contains(no)) { + unmatched.add(no); + } + } + } + resp.put("skipProcessOrders", updated); + resp.put("productionDoneOrders", productionDone); + resp.put("unmatchedOrderNos", unmatched); + return matched; + } + // 保存到 BomDetails 表中 //TODO: 使用本地库加速 private List saveBomDetails(List bomDetailsVos) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ImMaterialController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ImMaterialController.java index ed79616..f762f53 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ImMaterialController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ImMaterialController.java @@ -22,8 +22,10 @@ import com.ruoyi.common.core.validate.AddGroup; import com.ruoyi.common.core.validate.EditGroup; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.excel.ExcelResult; +import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.JdUtils; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.file.PlmDrawingFileNames; import com.ruoyi.common.utils.file.PlmPdfArchiveExtractor; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.system.domain.ImMaterial; @@ -39,6 +41,7 @@ import com.ruoyi.system.mapper.ImMaterialMapper; import com.ruoyi.system.service.IImMaterialService; import com.ruoyi.system.service.IPartCostService; import com.ruoyi.system.service.IPlmPdfZipPushService; +import com.ruoyi.system.service.PlmDrawingArriveNotifyService; import com.xxl.job.core.handler.annotation.XxlJob; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -94,6 +97,7 @@ public class ImMaterialController extends BaseController { private final IPartCostService iPartCostService; private final IPlmPdfZipPushService plmPdfZipPushService; + private final PlmDrawingArriveNotifyService plmDrawingArriveNotifyService; @Value("${plm.pdf.base-dir:D:/plm/pdf}") private String plmPdfBaseDir; @@ -222,6 +226,16 @@ public class ImMaterialController extends BaseController { if (cleaned > 0) { resp.put("cleanedParentPdfCount", String.valueOf(cleaned)); } + Map notifyItem = new HashMap<>(8); + notifyItem.put("materialCode", materialCode); + notifyItem.put("materialName", materialName); + notifyItem.put("savedFileName", targetFile.getFileName().toString()); + if (isDrawingChanged != null) { + notifyItem.put("isDrawingChanged", formatPlmBool(isDrawingChanged)); + } + plmDrawingArriveNotifyService.notifyAsync( + productionOrderNo, "/system/material/plm/pdf", "单张", + null, null, Collections.singletonList(notifyItem)); return R.ok(resp); } catch (Exception e) { log.error("接收PLM PDF图纸失败: productionOrderNo={}, materialCode={}", productionOrderNo, materialCode, e); @@ -324,6 +338,14 @@ public class ImMaterialController extends BaseController { return R.fail("以下items声明的文件未上传: " + String.join(", ", missingFiles)); } + Long processOrderProId; + try { + // 落盘前校验:令号必须已存在,不再自动建项目 + processOrderProId = plmPdfZipPushService.ensureProcessOrderProForPlmPdf(productionOrderNo, null); + } catch (ServiceException e) { + return R.fail(e.getMessage()); + } + String safeProductionOrderNo = sanitizePathPart(productionOrderNo); String separator = resolvePlmPdfNameSeparator(); Path dir = Paths.get(plmPdfBaseDir, safeProductionOrderNo); @@ -357,11 +379,13 @@ public class ImMaterialController extends BaseController { } Long pushRecordId = null; - Long processOrderProId = null; try { - processOrderProId = plmPdfZipPushService.ensureProcessOrderProForPlmPdf(productionOrderNo); + // 落盘后按推送明细补默认产品(项目已存在) + processOrderProId = plmPdfZipPushService.ensureProcessOrderProForPlmPdf(productionOrderNo, saved); pushRecordId = plmPdfZipPushService.savePushRecord( itemsJson, "batch", productionOrderNo, dir.toString(), saved); + } catch (ServiceException e) { + return R.fail(e.getMessage()); } catch (Exception e) { log.error("[PLM-PDF] 批量推送记录入库失败 productionOrderNo={}", productionOrderNo, e); } @@ -395,6 +419,9 @@ public class ImMaterialController extends BaseController { if (cleaned > 0) { resp.put("cleanedParentPdfCount", cleaned); } + plmDrawingArriveNotifyService.notifyAsync( + productionOrderNo, "/system/material/plm/pdf/batch", "批量", + processOrderProId, pushRecordId, saved); return R.ok(resp); } catch (Exception e) { log.error("批量接收PLM PDF图纸失败: productionOrderNo={}", productionOrderNo, e); @@ -413,6 +440,15 @@ public class ImMaterialController extends BaseController { if (parsed.failed()) { return R.fail(parsed.message); } + + Long processOrderProId; + try { + // 落盘前校验:令号必须已存在,不再自动建项目 + processOrderProId = plmPdfZipPushService.ensureProcessOrderProForPlmPdf(parsed.productionOrderNo, null); + } catch (ServiceException e) { + return R.fail(e.getMessage()); + } + Path dir = Paths.get(plmPdfBaseDir, sanitizePathPart(parsed.productionOrderNo)); Path dwgDir = dir.resolve(PLM_DWG_SUBDIR); String separator = resolvePlmPdfNameSeparator(); @@ -472,11 +508,13 @@ public class ImMaterialController extends BaseController { } Long pushRecordId = null; - Long processOrderProId = null; try { - processOrderProId = plmPdfZipPushService.ensureProcessOrderProForPlmPdf(parsed.productionOrderNo); + // 落盘后按推送明细补默认产品(项目已存在) + processOrderProId = plmPdfZipPushService.ensureProcessOrderProForPlmPdf(parsed.productionOrderNo, saved); pushRecordId = plmPdfZipPushService.savePushRecord( manifestJson, parsed.archiveName, parsed.productionOrderNo, dir.toString(), saved); + } catch (ServiceException e) { + return R.fail(e.getMessage()); } catch (Exception e) { log.error("[PLM-PDF] 推送记录入库失败 productionOrderNo={}, archive={}", parsed.productionOrderNo, parsed.archiveName, e); @@ -516,6 +554,9 @@ public class ImMaterialController extends BaseController { } log.info("[PLM-PDF] 接收完成 productionOrderNo={}, count={}, archive={}, pushRecordId={}, processOrderProId={}, cleanedParentPdfCount={}", parsed.productionOrderNo, saved.size(), parsed.archiveName, pushRecordId, processOrderProId, cleaned); + plmDrawingArriveNotifyService.notifyAsync( + parsed.productionOrderNo, "/system/material/plm/pdf/zip", "压缩包", + processOrderProId, pushRecordId, saved); return R.ok(resp); } } catch (IllegalArgumentException e) { @@ -719,6 +760,7 @@ public class ImMaterialController extends BaseController { /** * 同令号同图号覆盖:先删除目录内该图号的旧文件(含历史时间戳后缀),再落盘为标准名。 + * 按完整图号匹配,不会把 {@code 图号-MP} 等更长图号当成同一张图。 * * @param extension 不含点,如 pdf / dwg */ @@ -732,7 +774,7 @@ public class ImMaterialController extends BaseController { } /** - * 删除目录中与指定图号匹配的图纸(标准名、时间戳后缀名均清理) + * 删除目录中与指定图号匹配的图纸(标准名、历史时间戳后缀名均清理;不按「图号-」前缀误伤 -MP 等更长图号) */ private static void deleteExistingDrawingsByMaterialCode(Path dir, String materialCode, String separator, String extension) { @@ -741,15 +783,12 @@ public class ImMaterialController extends BaseController { } String code = sanitizeFileNamePart(materialCode); String sep = StringUtils.isNotBlank(separator) ? separator : "_"; - String prefixWithSep = code + sep; String ext = extension.startsWith(".") ? extension.toLowerCase(Locale.ROOT) : ("." + extension.toLowerCase(Locale.ROOT)); try (DirectoryStream stream = Files.newDirectoryStream(dir, "*" + ext)) { for (Path file : stream) { String name = file.getFileName().toString(); - if (name.equalsIgnoreCase(code + ext) - || name.regionMatches(true, 0, prefixWithSep, 0, prefixWithSep.length()) - || name.regionMatches(true, 0, code + "-", 0, code.length() + 1)) { + if (PlmDrawingFileNames.matchesMaterialCode(name, code, sep, extension)) { try { Files.deleteIfExists(file); log.info("[PLM-PDF] 同令号同图号覆盖,已删除旧文件: {}", file); @@ -941,7 +980,7 @@ public class ImMaterialController extends BaseController { } /** - * 查询物料检索列表 + * 查询物料检索列表(实时金蝶物料) */ @SaCheckPermission("system:material:list") @GetMapping("/list") diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/IndexController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/IndexController.java index af5dfdb..f0e2be0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/IndexController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/IndexController.java @@ -7,8 +7,7 @@ import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.page.TableDataInfo; -import com.ruoyi.common.utils.JdUtils; -import com.ruoyi.system.domain.ImMaterial; +import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.domain.bo.ImMaterialBo; import com.ruoyi.system.domain.bo.ImProductionPlanBo; import com.ruoyi.system.domain.dto.*; @@ -17,15 +16,12 @@ import com.ruoyi.system.domain.vo.InventoryInfoVO; import com.ruoyi.system.runner.JdUtil; import com.ruoyi.system.service.IImMaterialService; import com.ruoyi.system.service.IImProductionPlanService; -import io.netty.util.concurrent.CompleteFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.concurrent.*; @RequiredArgsConstructor @@ -33,6 +29,8 @@ import java.util.concurrent.*; @RequestMapping("/index") @Slf4j public class IndexController { + private static final int MAX_MATERIAL_CODES = 50; + private final IImMaterialService iImMaterialService; private final IImProductionPlanService imProductionPlanService; @@ -42,111 +40,123 @@ public class IndexController { } /** - * 金蝶chaxun + * 金蝶库存查询(支持单个或多个物料编码) + *

+ * 前端一次传入多个编码;后端对每个物料按原逻辑精确查询后汇总返回(金蝶侧不支持可靠的多码 IN 拼接)。 + * 参数任选其一: + * - materialCode:单个物料编码(兼容旧调用) + * - materialCodes:多个物料编码,逗号分隔 */ @GetMapping("/inventory") - public R handleData(String materialCode) { - // 创建线程池 + public R> handleData( + @RequestParam(value = "materialCode", required = false) String materialCode, + @RequestParam(value = "materialCodes", required = false) String materialCodes) { + List codes = parseMaterialCodes(materialCode, materialCodes); + if (codes.isEmpty()) { + return R.fail("请指定物料编码"); + } + if (codes.size() > MAX_MATERIAL_CODES) { + return R.fail("单次最多查询 " + MAX_MATERIAL_CODES + " 个物料"); + } + + int poolSize = Math.min(Math.max(codes.size(), 4), 10); ExecutorService executorService = new ThreadPoolExecutor( - 4, // 线程池核心线程数 - 4, // 线程池最大线程数 - 0L, // 线程空闲时间 - TimeUnit.MILLISECONDS, // 时间单位 - new LinkedBlockingQueue<>(10), // 线程队列 - new ThreadPoolExecutor.DiscardPolicy() // 拒绝策略 + poolSize, + poolSize, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(Math.max(codes.size() * 2, 10)), + new ThreadPoolExecutor.CallerRunsPolicy() ); try { - // 并行执行多个查询任务 - CompletableFuture inventoryFuture = CompletableFuture.supplyAsync(() -> { - log.info("开始查询即时库存数量: {}", materialCode); - return Optional.ofNullable(JdUtil.getKuCun(materialCode)) - .map(list -> list.stream() - .mapToDouble(JDInventoryDTO::getFBaseQty) - .sum()) - .orElse(0.0); - }, executorService); - - CompletableFuture noPickedFuture = CompletableFuture.supplyAsync(() -> { - log.info("开始查询生产用料清单子项未领料数量: {}", materialCode); - return Optional.ofNullable(JdUtil.getWeiLingliao(materialCode)) - .map(list -> list.stream() - .mapToDouble(JDProductionDTO::getFNoPickedQty) - .sum()) - .orElse(0.0); - }, executorService); - - CompletableFuture productionFuture = CompletableFuture.supplyAsync(() -> { - log.info("开始查询生产订单未入库数量: {}", materialCode); - return Optional.ofNullable(JdUtil.getProMoList(materialCode)) - .map(list -> list.stream() - .mapToDouble(ProMoDTO::getFQty) - .sum()) - .orElse(0.0); - }, executorService); - - CompletableFuture purchaseFuture = CompletableFuture.supplyAsync(() -> { - log.info("开始查询采购订单未入库数量: {}", materialCode); - return Optional.ofNullable(JdUtil.getPurchaseOrderList(materialCode)) - .map(list -> list.stream() - .mapToDouble(PurchaseOrderDTO::getFRemainStockINQty) - .sum()) - .orElse(0.0); - }, executorService); - CompletableFuture salesOrderFuture = CompletableFuture.supplyAsync(() -> { - log.info("开始查询销售订单未入库数量: {}", materialCode); - return Optional.ofNullable(JdUtil.getSalesOrderList(materialCode)) - .map(list -> list.stream() - .mapToDouble(SalesOrderDTO::getFBaseRemainOutQty) - .sum()) - .orElse(0.0); - }, executorService); - - // 等待所有任务完成并获取结果 - CompletableFuture resultFuture = CompletableFuture.allOf(inventoryFuture, noPickedFuture, productionFuture, purchaseFuture,salesOrderFuture) - .thenApply(v -> { - try { - double inventoryQty = inventoryFuture.get(); - double fNoPickedQty = noPickedFuture.get(); - double productionQty = productionFuture.get(); - double purchaseQty = purchaseFuture.get(); - double salesOrder = salesOrderFuture.get(); - - // 计算预计可用库存 即时库存+生产未入库+(采购申请-采购未入库)-子项未领料-销售实际未出库数量 - double keyong = inventoryQty + productionQty + purchaseQty - fNoPickedQty-salesOrder; - InventoryInfoVO inventoryInfoVO = new InventoryInfoVO(); - inventoryInfoVO.setMaterialCode(materialCode); - inventoryInfoVO.setKucun(String.valueOf(inventoryQty)); - inventoryInfoVO.setQuantity(String.valueOf(fNoPickedQty)); - inventoryInfoVO.setMaterialName(String.valueOf(purchaseQty)); - inventoryInfoVO.setStockName(String.valueOf(productionQty)); - inventoryInfoVO.setStockUnit(String.valueOf(keyong)); - inventoryInfoVO.setSalesOrder(String.valueOf(salesOrder)); - - return inventoryInfoVO; - } catch (Exception e) { - log.error("处理库存数据时发生错误", e); - throw new CompletionException(e); - } - }); - - // 获取最终结果 - return R.ok(resultFuture.get(30, TimeUnit.SECONDS)); + List> futures = new ArrayList<>(codes.size()); + for (String code : codes) { + futures.add(CompletableFuture.supplyAsync(() -> queryOne(code), executorService)); + } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .get(Math.max(30, codes.size() * 10L), TimeUnit.SECONDS); + List result = new ArrayList<>(codes.size()); + for (CompletableFuture future : futures) { + result.add(future.get()); + } + return R.ok(result); } catch (Exception e) { log.error("查询库存信息失败", e); return R.fail("查询库存信息失败:" + e.getMessage()); } finally { - // 关闭线程池 executorService.shutdown(); } } + /** + * 单个物料精确查询(与原先单码接口同一套金蝶条件),供多码并行汇总。 + */ + private InventoryInfoVO queryOne(String materialCode) { + try { + double inventoryQty = Optional.ofNullable(JdUtil.getKuCun(materialCode)) + .map(list -> list.stream().mapToDouble(JDInventoryDTO::getFBaseQty).sum()) + .orElse(0.0); + double fNoPickedQty = Optional.ofNullable(JdUtil.getWeiLingliao(materialCode)) + .map(list -> list.stream().mapToDouble(JDProductionDTO::getFNoPickedQty).sum()) + .orElse(0.0); + double productionQty = Optional.ofNullable(JdUtil.getProMoList(materialCode)) + .map(list -> list.stream().mapToDouble(ProMoDTO::getFQty).sum()) + .orElse(0.0); + double purchaseQty = Optional.ofNullable(JdUtil.getPurchaseOrderList(materialCode)) + .map(list -> list.stream().mapToDouble(PurchaseOrderDTO::getFRemainStockINQty).sum()) + .orElse(0.0); + double salesOrder = Optional.ofNullable(JdUtil.getSalesOrderList(materialCode)) + .map(list -> list.stream().mapToDouble(SalesOrderDTO::getFBaseRemainOutQty).sum()) + .orElse(0.0); + double keyong = inventoryQty + productionQty + purchaseQty - fNoPickedQty - salesOrder; + + InventoryInfoVO vo = new InventoryInfoVO(); + vo.setMaterialCode(materialCode); + vo.setKucun(String.valueOf(inventoryQty)); + vo.setQuantity(String.valueOf(fNoPickedQty)); + vo.setMaterialName(String.valueOf(purchaseQty)); + vo.setStockName(String.valueOf(productionQty)); + vo.setStockUnit(String.valueOf(keyong)); + vo.setSalesOrder(String.valueOf(salesOrder)); + return vo; + } catch (Exception e) { + log.error("查询单个物料库存失败: {}", materialCode, e); + InventoryInfoVO vo = new InventoryInfoVO(); + vo.setMaterialCode(materialCode); + vo.setKucun("0.0"); + vo.setQuantity("0.0"); + vo.setMaterialName("0.0"); + vo.setStockName("0.0"); + vo.setStockUnit("0.0"); + vo.setSalesOrder("0.0"); + return vo; + } + } + + private static List parseMaterialCodes(String materialCode, String materialCodes) { + LinkedHashSet set = new LinkedHashSet<>(); + addCodes(set, materialCodes); + addCodes(set, materialCode); + return new ArrayList<>(set); + } + + private static void addCodes(Set set, String raw) { + if (StringUtils.isBlank(raw)) { + return; + } + for (String part : raw.split(",")) { + if (StringUtils.isNotBlank(part)) { + set.add(part.trim()); + } + } + } + @Log(title = "生产计划") @PostMapping("/JDList") - public void synchronize_lists() { + public void synchronize_lists() { K3CloudApi client = new K3CloudApi(); - //请求参数,要求为json字符串 JsonObject json = new JsonObject(); json.addProperty("FormId", "PRD_MO"); json.addProperty("FieldKeys", "FID,F_UCHN_Text,FBillType.FNUMBER,FMaterialId.FNumber,FMaterialName,FQty,FBillType.FNUMBER,FWorkShopID.FNumber,FDate,FProductType,FPickMtrlStatus"); @@ -168,19 +178,16 @@ public class IndexController { String jsonData = json.toString(); try { - - // 调用接口并获取结果 String input = String.valueOf(client.executeBillQuery(jsonData)); input = input.substring(2, input.length() - 2); String[] split = input.split("]\\, \\["); for (String imProductionn : split) { ImProductionPlanBo imProductionPlan = new ImProductionPlanBo(); - String[] imPro= imProductionn.split(","); + String[] imPro = imProductionn.split(","); imProductionPlan.setFID(Long.valueOf(imPro[0])); imProductionPlan.setProductionCode(imPro[1]); imProductionPlan.setTypeName(imPro[2]); imProductionPlan.setWorkShop(imPro[7]); - // 解析时间 SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); Date parse = simpleDateFormat.parse(imPro[8]); imProductionPlan.setDateF(parse); @@ -188,14 +195,13 @@ public class IndexController { imProductionPlan.setProductTyp(imPro[10]); imProductionPlan.setFigureNumber(imPro[3]); imProductionPlan.setFigureName(imPro[4]); - // imProductionPlan.setOneNumber(imPro[5]); imProductionPlan.setPickmtrlStatus(imPro[10]); imProductionPlan.setUpdateTime(new Date()); imProductionPlan.setSearchValue("1"); imProductionPlan.setUpdateBy("admin"); imProductionPlan.setCreateTime(new Date()); imProductionPlan.setCreateBy("admin"); - imProductionPlanService.insertByBo(imProductionPlan) ; + imProductionPlanService.insertByBo(imProductionPlan); } } catch (Exception e) { e.printStackTrace(); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/PlmProductionOrderController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/PlmProductionOrderController.java index 6730727..22f5fc5 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/PlmProductionOrderController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/PlmProductionOrderController.java @@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaIgnore; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.utils.ProjectStatusLock; import com.ruoyi.system.domain.bo.ProcessOrderProBo; import com.ruoyi.system.domain.vo.ProcessOrderProVo; import com.ruoyi.system.domain.vo.plm.PlmProductionOrderVo; @@ -45,8 +46,7 @@ public class PlmProductionOrderController extends BaseController { public TableDataInfo productionOrders(ProcessOrderProBo bo, PageQuery pageQuery) { TableDataInfo page = processOrderProService.queryPageList(bo, pageQuery); List rows = page.getRows(); - List mapped = rows == null ? Collections.emptyList() - : rows.stream().map(this::toPlmVo).collect(Collectors.toList()); + List mapped = rows == null ? Collections.emptyList() : rows.stream().map(this::toPlmVo).collect(Collectors.toList()); TableDataInfo result = new TableDataInfo<>(mapped, page.getTotal()); result.setCode(page.getCode()); result.setMsg(page.getMsg()); @@ -63,7 +63,7 @@ public class PlmProductionOrderController extends BaseController { vo.setUnit(src.getUnit()); vo.setNeedProcess(src.getNeedProcess()); vo.setBomStatus(src.getBomStatus()); - vo.setBomStatusLabel(resolveBomStatusLabel(src.getBomStatus())); + vo.setBomStatusLabel(src.getBomStatus() == null ? null : ProjectStatusLock.statusLabel(src.getBomStatus())); vo.setRouteStatus(src.getRouteStatus()); vo.setRouteStatusLabel(resolveRouteStatusLabel(src.getRouteStatus())); vo.setPlanStartTime(src.getPlanStartTime()); @@ -72,39 +72,6 @@ public class PlmProductionOrderController extends BaseController { return vo; } - /** - * 项目状态:0方案设计完成 … 2工艺待审核 … 6开始生产 … 8工艺审核完成 … 9工艺审核拒绝 - */ - private static String resolveBomStatusLabel(Long status) { - if (status == null) { - return null; - } - switch (status.intValue()) { - case 0: - return "方案设计完成"; - case 1: - return "设计出图完成"; - case 2: - return "工艺待审核"; - case 3: - return "已推送BOM"; - case 4: - return "已推送工艺"; - case 5: - return "已核算MRP"; - case 6: - return "开始生产"; - case 7: - return "生产完成"; - case 8: - return "工艺审核完成"; - case 9: - return "工艺审核拒绝"; - default: - return String.valueOf(status); - } - } - /** 工艺审核:0未审核 1已审核 2已拒绝 */ private static String resolveRouteStatusLabel(Long status) { if (status == null) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java index 107c2a2..00e5ea1 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java @@ -26,6 +26,7 @@ import com.ruoyi.common.poi.DynamicDataMapping; import com.ruoyi.system.constant.ProcessOrderProSource; import com.ruoyi.system.domain.*; import com.ruoyi.system.domain.bo.KingdeeOrderReceiveBatchBo; +import com.ruoyi.system.domain.bo.OrderProBomStatusBo; import com.ruoyi.system.domain.dto.KingdeePrdMoDTO; import com.ruoyi.system.domain.dto.KingdeePurchaseOrderDTO; import com.ruoyi.system.domain.dto.MaterialUseDTO; @@ -53,6 +54,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.validation.annotation.Validated; import com.ruoyi.common.annotation.RepeatSubmit; @@ -133,6 +135,28 @@ public class ProcessOrderProController extends BaseController { ExcelUtil.exportExcel(exportList, "项目令号", ProcessOrderProExportVo.class, response); } + /** + * 临时导入模板:生产令号 / 出图时间 / 项目完成时间 / 工艺完成时间 / 计划排产时间 / BOM/物料更新 / 设计人 + */ + @Log(title = "预计出图时间导入模板", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:orderPro:importDrawingByTemplate") + @PostMapping("/importDrawingByTemplate") + public void importDrawingByTemplate(HttpServletResponse response) { + ExcelUtil.exportExcel(new ArrayList<>(), "项目时间导入", OrderProDrawingImportVo.class, response); + } + + /** + * 临时导入:按生产令号更新已填写字段,空单元格不更新 + */ + @Log(title = "导入预计出图时间", businessType = BusinessType.IMPORT) + @SaCheckPermission("system:orderPro:importDrawingBy") + @PostMapping(value = "/importDrawingBy", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R> importDrawingBy(@RequestPart("file") MultipartFile file) { + Map result = iProcessOrderProService.importDrawingByExcel(file); + String message = result.get("message") != null ? String.valueOf(result.get("message")) : "导入完成"; + return R.ok(message, result); + } + /** * 获取项目令号详细信息 * @@ -170,6 +194,18 @@ public class ProcessOrderProController extends BaseController { return toAjax(iProcessOrderProService.updateByBo(bo)); } + /** + * 批量设置项目状态。 + * 独立接口,避免走 PUT /system/orderPro 的 EditGroup(设计人必填)导致只改状态失败。 + */ + @SaCheckPermission("system:orderPro:edit") + @Log(title = "设置项目状态", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping("/bomStatus") + public R updateBomStatus(@Validated @RequestBody OrderProBomStatusBo bo) { + return toAjax(iProcessOrderProService.updateBomStatus(bo.getIds(), bo.getBomStatus())); + } + /** * 清空项目关联数据(保留项目) * @@ -258,6 +294,18 @@ public class ProcessOrderProController extends BaseController { return R.ok(); } + /** + * 设置出图模式:normal 正常 / part 零部件 + */ + @SaCheckPermission("system:orderPro:edit") + @Log(title = "设置出图模式", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping("/drawingMode/{id}") + public R updateDrawingMode(@PathVariable Long id, @RequestBody ProcessOrderProBo bo) { + String mode = bo == null ? null : bo.getDrawingMode(); + return toAjax(iProcessOrderProService.updateDrawingMode(id, mode)); + } + @SaCheckPermission("system:orderPro:executDrawing") @Log(title = "执行出图", businessType = BusinessType.INSERT) @RepeatSubmit() @@ -361,6 +409,7 @@ public class ProcessOrderProController extends BaseController { os.flush(); } log.info("下载PDF ZIP 输出完成: id={}, file={}", id, file.getName()); + iProcessOrderProService.cleanupPdfRunDir(filePath); } catch (Exception e) { if (response.isCommitted()) { @@ -430,6 +479,7 @@ public class ProcessOrderProController extends BaseController { } os.flush(); } + iProcessOrderProService.cleanupPdfRunDir(zipFile.getAbsolutePath()); } catch (Exception e) { if (response.isCommitted()) { @@ -1947,7 +1997,8 @@ public class ProcessOrderProController extends BaseController { } /** - * 出图完成后由前端触发:先推送「出图完成」Markdown,再生成工艺计划表 Excel 并发至钉钉 notifyUserId。 + * 出图完成后由前端触发:先推送「出图完成」Markdown,再生成工艺计划表 Excel 并发至钉钉 notifyUserId(工艺)。 + * 项目「是否做工艺=否」时不推送。 */ @SaCheckPermission("system:orderPro:sendProcessRoutePlanExcel") @Log(title = "发送工艺计划表", businessType = BusinessType.OTHER) @@ -1956,6 +2007,16 @@ public class ProcessOrderProController extends BaseController { return iProcessOrderProService.sendProcessRoutePlanExcel(id); } + /** + * 设计人员确认本令号图纸已全部上传,确认后方可下载 PDF / PLM DWG。 + */ + @SaCheckPermission("system:orderPro:issueDrawingQty") + @Log(title = "确认图纸已齐", businessType = BusinessType.UPDATE) + @PostMapping("/issueDrawingQty/{id}") + public R> issueDrawingQty(@NotNull(message = "项目ID不能为空") @PathVariable Long id) { + return iProcessOrderProService.issueDrawingQty(id); + } + /** * 查询工艺计划表钉钉发送成功次数(用于按钮文案) */ @@ -2821,6 +2882,11 @@ public class ProcessOrderProController extends BaseController { writeJsonFail(response, "项目未配置生产令号"); return; } + if (ProcessOrderProSource.PLM.equals(orderPro.getSource()) + && !Integer.valueOf(1).equals(orderPro.getDrawingQtyIssued())) { + writeJsonFail(response, "请先点击「确认图纸已齐」,图纸全部上传完成后再下载图纸"); + return; + } List dwgFiles = plmPdfZipPushService.listDownloadableDwgFiles(orderPro.getProductionOrderNo()); String zipName = orderPro.getProductionOrderNo().trim() + "_PLM_DWG.zip"; String encoded = URLEncoder.encode(zipName, "UTF-8").replaceAll("\\+", "%20"); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java index c75e2ae..e683b26 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java @@ -1,6 +1,7 @@ package com.ruoyi.system.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; import cn.hutool.json.JSONUtil; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.ExcelWriter; @@ -25,9 +26,12 @@ import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.common.utils.FtpUtil; import com.ruoyi.common.utils.KingdeeRouteDepartmentHelper; +import com.ruoyi.common.utils.JsyyOrderNo; +import com.ruoyi.common.utils.ProjectStatusLock; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.file.SmbUtil; import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.system.constant.DingTodoSceneCodes; import com.ruoyi.system.domain.*; import com.ruoyi.system.domain.bo.ProcessRouteBo; import com.ruoyi.system.domain.dto.*; @@ -66,8 +70,6 @@ import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; /** * 工艺路线 @@ -98,8 +100,13 @@ public class ProcessRouteController extends BaseController { private ISysOssService iSysOssService; @Autowired private IProcessOrderProService processOrderProService; - /** 计划表落盘 + 按 plan-user-id 推送(与 PLM 导出共用) */ + /** + * 计划表落盘 + 按 plan-user-id 推送(与 PLM 导出共用) + */ private final ProcessPlanExcelPushService processPlanExcelPushService; + private final IDrawingRouteOperLogService drawingRouteOperLogService; + private final IDingTodoDispatchService dingTodoDispatchService; + private final DrawingFileQueryService drawingFileQueryService; @Autowired private ISysUserService userService; @@ -175,9 +182,9 @@ public class ProcessRouteController extends BaseController { } /** - * 查询工艺路线列表 + * 查询工艺路线列表。项目令号「查看工艺」只分配 processlist 时也要能拉数。 */ - @SaCheckPermission("system:route:list") + @SaCheckPermission(value = {"system:route:list", "system:orderPro:processlist"}, mode = SaMode.OR) @GetMapping("/list") public TableDataInfo list(ProcessRouteBo bo, PageQuery pageQuery) { try { @@ -400,7 +407,8 @@ public class ProcessRouteController extends BaseController { @Log(title = "生产工艺计划表导入", businessType = BusinessType.IMPORT) @SaCheckPermission("system:route:importRoute") @PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public R importData(@RequestParam("file") MultipartFile file) throws Exception { + public R importData(@RequestParam("file") MultipartFile file, + @RequestParam(value = "productionOrderNo", required = false) String expectedOrderNo) throws Exception { String originalFilename = file.getOriginalFilename(); log.info("读取文件名: " + originalFilename); @@ -410,23 +418,57 @@ public class ProcessRouteController extends BaseController { // 读取总装部分sheet ExcelResult result1 = ExcelUtil.importExcelSheet1(file.getInputStream(), ProductionOrderVo.class, true); List list = result1.getList(); - String productionOrderNo = list1.get(0).getRouteDescription(); - List bomDetails = iBomDetailsService.selectByProjectNumber(list1.get(0).getRouteDescription()); - List route = iProcessRouteService.selectByProjectNumber(list1.get(0).getRouteDescription()); - if (list1.isEmpty()) { - return R.fail("项目 " + productionOrderNo + "工艺生产计划表为空,请检查此表或将sheet移动至第七个!"); + long started = System.currentTimeMillis(); + if (list1 == null || list1.isEmpty()) { + recordRouteUpload(null, expectedOrderNo, originalFilename, 0, + IDrawingRouteOperLogService.STATUS_FAIL, "工艺生产计划表为空", started); + return R.fail("工艺生产计划表为空,请检查此表或将sheet移动至第七个!"); } + // 防呆:当前项目令号必须与文件内「生产令号」一致 + String orderNoMismatch = validateImportOrderNo(expectedOrderNo, list1, list); + if (orderNoMismatch != null) { + ProcessOrderPro orderForMismatch = StringUtils.isNotBlank(expectedOrderNo) + ? processOrderProService.selectByProjectNumber(expectedOrderNo.trim()) : null; + recordRouteUpload(orderForMismatch, expectedOrderNo, originalFilename, list1.size(), + IDrawingRouteOperLogService.STATUS_FAIL, orderNoMismatch, started); + return R.fail(orderNoMismatch); + } + String resolvedOrderNo = firstNonBlankRouteDescription(list1); + if (StringUtils.isBlank(resolvedOrderNo) && StringUtils.isNotBlank(expectedOrderNo)) { + resolvedOrderNo = expectedOrderNo.trim(); + } + // 后面异步推送会捕获令号,必须是 effectively final + final String productionOrderNo = resolvedOrderNo; + ProcessOrderPro orderForLock = processOrderProService.selectByProjectNumber(productionOrderNo); + if (orderForLock != null) { + String statusDeny = ProjectStatusLock.denyIfLocked(orderForLock.getBomStatus(), ProjectStatusLock.Action.UPLOAD_ROUTE); + if (statusDeny != null) { + recordRouteUpload(orderForLock, productionOrderNo, originalFilename, list1.size(), + IDrawingRouteOperLogService.STATUS_FAIL, statusDeny, started); + return R.fail(statusDeny); + } + } + List bomDetails = iBomDetailsService.selectByProjectNumber(productionOrderNo); + List route = iProcessRouteService.selectByProjectNumber(productionOrderNo); if (!bomDetails.isEmpty()) { + recordRouteUpload(orderForLock, productionOrderNo, originalFilename, list1.size(), + IDrawingRouteOperLogService.STATUS_FAIL, "已导入过BOM,请先清空再上传", started); return R.fail("项目 " + productionOrderNo + "已导入过BOM,请先清空再上传"); } if (!route.isEmpty()) { + recordRouteUpload(orderForLock, productionOrderNo, originalFilename, list1.size(), + IDrawingRouteOperLogService.STATUS_FAIL, "已导入过工艺,请先清空再上传", started); return R.fail("项目 " + productionOrderNo + "已导入过工艺 ,请先清空再上传"); } String workCenterError = validateImportWorkCenters(list1); if (workCenterError != null) { + recordRouteUpload(orderForLock, productionOrderNo, originalFilename, list1.size(), + IDrawingRouteOperLogService.STATUS_FAIL, "工段校验失败", started); return R.fail("生产工艺计划表工段校验失败:\n" + workCenterError); } if (iProcessRouteService.saveData(list1, list)) { + recordRouteUpload(orderForLock, productionOrderNo, originalFilename, list1.size(), + IDrawingRouteOperLogService.STATUS_SUCCESS, null, started); // 计划表落到服务器固定目录(app.plan-excel.base-dir/令号/),保留不删 File savedPlanFile = null; try { @@ -456,12 +498,38 @@ public class ProcessRouteController extends BaseController { log.warn("获取当前登录用户昵称失败", e); } + String todoProjectName = orderForLock == null ? null : orderForLock.getProductionName(); + Date todoProjectEndTime = orderForLock == null ? null : orderForLock.getProjectEndTime(); + String planUserIds = dingTalkProperties.getPlanUserId(); // 异步:按 plan-user-id 发文件 + 发工艺审核卡片(落盘文件保留) String finalNickName = currentUserNickName; + String operator = drawingRouteOperLogService.currentOperator(); + String clientIp = drawingRouteOperLogService.currentClientIp(); + Long projectId = orderForLock == null ? null : orderForLock.getId(); + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); CompletableFuture.runAsync(() -> { - // 与 PLM 导出推送同一套接收人 - processPlanExcelPushService.sendToPlanUsers(uploadFile, originalFilename); - + Thread.currentThread().setContextClassLoader(contextClassLoader); + long notifyStarted = System.currentTimeMillis(); + ProcessPlanExcelPushService.FilePushResult filePush = + processPlanExcelPushService.sendToPlanUsers(uploadFile, originalFilename); + String todoError = dingTodoDispatchService.sendIfEnabled( + DingTodoSceneCodes.ROUTE_UPLOAD_PLAN, + productionOrderNo, + todoProjectName, + todoProjectEndTime, + uploadFile, + planUserIds); + if (StringUtils.isNotBlank(todoError) && filePush != null) { + filePush.setErrorMsg(StringUtils.isBlank(filePush.getErrorMsg()) + ? "待办: " + todoError + : filePush.getErrorMsg() + "; 待办: " + todoError); + if (filePush.getSuccessCount() > 0) { + filePush.setFailCount(filePush.getFailCount() + 1); + } + } + boolean cardOk = false; + String cardError = null; + String outTrackId = null; try { ProcessOrderPro order = processOrderProService.selectByProjectNumber(productionOrderNo); if (order != null) { @@ -476,7 +544,7 @@ public class ProcessRouteController extends BaseController { cardDataMap.put("lastMessage", "请尽快处理"); cardDataMap.put("status", StringUtils.defaultIfEmpty("待审批", "待审批")); - String outTrackId = "audit-card-" + System.currentTimeMillis(); + outTrackId = "audit-card-" + System.currentTimeMillis(); // 调用发送给工艺负责人的方法 createAndDeliverCardToUser DingUtil.createAndDeliverCardToUser( accessToken, @@ -497,17 +565,172 @@ public class ProcessRouteController extends BaseController { // 更新审核状态为1(已审核/提交审核) update.setRouteStatus(0L); proMapper.updateById(update); + cardOk = true; + } else { + cardError = "项目不存在,未发送审核卡片"; } } catch (Exception e) { + cardError = e.getMessage(); log.error("发送钉钉卡片异常", e); } + recordRouteNotifyAfterUpload(productionOrderNo, projectId, originalFilename, finalNickName, + filePush, cardOk, cardError, outTrackId, operator, clientIp, notifyStarted); }); return R.ok("上传物料成功"); } else { + recordRouteUpload(orderForLock, productionOrderNo, originalFilename, list1.size(), + IDrawingRouteOperLogService.STATUS_FAIL, "导入失败", started); return R.fail("导入失败"); } } + /** + * 防呆:页面所选项目令号须与 Excel「生产令号」一致。 + * 工艺 sheet(routeDescription)与总装 sheet(productionOrderNo)都会检查。 + * + * @return 错误提示;通过则 null + */ + private String validateImportOrderNo(String expectedOrderNo, List routes, + List products) { + LinkedHashSet fileNos = new LinkedHashSet<>(); + if (routes != null) { + for (ProcessRouteVo row : routes) { + if (row != null && StringUtils.isNotBlank(row.getRouteDescription())) { + fileNos.add(row.getRouteDescription().trim()); + } + } + } + if (products != null) { + for (ProductionOrderVo row : products) { + if (row != null && StringUtils.isNotBlank(row.getProductionOrderNo())) { + fileNos.add(row.getProductionOrderNo().trim()); + } + } + } + if (fileNos.isEmpty()) { + return "导入文件未读到生产令号,请检查工艺计划表「生产令号」列"; + } + // 大小写不同仍视为同一令号,避免 Excel 大小写差异误报多个令号 + String fileNo = null; + for (String no : fileNos) { + if (fileNo == null) { + fileNo = no; + } else if (!fileNo.equalsIgnoreCase(no)) { + return "导入文件中存在多个生产令号:" + String.join("、", fileNos) + ",请确认是否导错文件"; + } + } + if (StringUtils.isBlank(expectedOrderNo)) { + return "请先选择要导入的项目令号"; + } + String expected = expectedOrderNo.trim(); + if (!expected.equalsIgnoreCase(fileNo)) { + return "导入令号不一致:当前项目为「" + expected + "」,文件中为「" + fileNo + "」,请确认后重新导入"; + } + return null; + } + + /** + * 取工艺表中第一条非空生产令号。 + */ + private static String firstNonBlankRouteDescription(List routes) { + if (routes == null) { + return null; + } + for (ProcessRouteVo row : routes) { + if (row != null && StringUtils.isNotBlank(row.getRouteDescription())) { + return row.getRouteDescription().trim(); + } + } + return null; + } + + private void recordRouteUpload(ProcessOrderPro order, String productionOrderNo, String fileName, + int lineCount, String status, String errorMsg, long started) { + Map summary = new LinkedHashMap<>(); + summary.put("lineCount", lineCount); + summary.put("fileName", fileName); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_UPLOAD, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_MANUAL); + row.setApiPath("/system/route/importData"); + row.setProjectId(order == null ? null : order.getId()); + row.setProductionOrderNo(productionOrderNo); + row.setFileName(fileName); + row.setStatus(status); + row.setErrorMsg(errorMsg); + row.setSummaryJson(JSONUtil.toJsonStr(summary)); + row.setDurationMs(System.currentTimeMillis() - started); + }); + } + + private void recordRouteNotifyAfterUpload(String productionOrderNo, Long projectId, String fileName, + String nickName, ProcessPlanExcelPushService.FilePushResult filePush, + boolean cardOk, String cardError, String outTrackId, + String operator, String clientIp, long started) { + String fileStatus = filePush == null ? IDrawingRouteOperLogService.STATUS_FAIL : filePush.resolveStatus(); + boolean fileOk = IDrawingRouteOperLogService.STATUS_SUCCESS.equals(fileStatus); + String status; + if (fileOk && cardOk) { + status = IDrawingRouteOperLogService.STATUS_SUCCESS; + } else if (!fileOk && !cardOk) { + status = IDrawingRouteOperLogService.STATUS_FAIL; + } else { + status = IDrawingRouteOperLogService.STATUS_PARTIAL; + } + StringBuilder error = new StringBuilder(); + if (filePush != null && StringUtils.isNotBlank(filePush.getErrorMsg())) { + error.append("文件: ").append(filePush.getErrorMsg()); + } + if (StringUtils.isNotBlank(cardError)) { + if (error.length() > 0) { + error.append("; "); + } + error.append("卡片: ").append(cardError); + } + Map summary = new LinkedHashMap<>(); + summary.put("fileStatus", fileStatus); + summary.put("fileSuccessCount", filePush == null ? 0 : filePush.getSuccessCount()); + summary.put("fileFailCount", filePush == null ? 0 : filePush.getFailCount()); + summary.put("cardOk", cardOk); + summary.put("outTrackId", outTrackId); + summary.put("submitter", nickName); + String notifyUsers = joinNotifyUsers( + filePush == null ? null : filePush.getUserIds(), + dingTalkProperties.getRouteApprovalId()); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_MANUAL); + row.setApiPath("/system/route/importData"); + row.setProjectId(projectId); + row.setProductionOrderNo(productionOrderNo); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_FILE_AND_CARD); + row.setNotifyUserIds(notifyUsers); + row.setMessageTitle(nickName + "提交工艺审核"); + row.setFileName(fileName); + row.setStatus(status); + row.setErrorMsg(error.length() == 0 ? null : error.toString()); + row.setSummaryJson(JSONUtil.toJsonStr(summary)); + row.setOperator(operator); + row.setClientIp(clientIp); + row.setDurationMs(System.currentTimeMillis() - started); + }); + } + + private static String joinNotifyUsers(String... parts) { + StringBuilder sb = new StringBuilder(); + if (parts == null) { + return null; + } + for (String part : parts) { + if (StringUtils.isBlank(part)) { + continue; + } + if (sb.length() > 0) { + sb.append(","); + } + sb.append(part.trim()); + } + return sb.length() == 0 ? null : sb.toString(); + } + /** * 查询组焊件集合 */ @@ -549,9 +772,51 @@ public class ProcessRouteController extends BaseController { @SaCheckPermission("system:route:pushRouteBom") @PostMapping("/pushRouteBom") public R pushRouteBom(@RequestParam String rooteProdet, @RequestParam String groupName) { + String jsyyDeny = JsyyOrderNo.denyIfMatches(rooteProdet); + if (jsyyDeny != null) { + return R.fail(jsyyDeny); + } + ProcessOrderPro orderForLock = processOrderProService.selectByProjectNumber(rooteProdet); + if (orderForLock != null) { + String statusDeny = ProjectStatusLock.denyIfLocked(orderForLock.getBomStatus(), ProjectStatusLock.Action.PUSH_ROUTE); + if (statusDeny != null) { + return R.fail(statusDeny); + } + } return iProcessRouteService.pushRouteBom(rooteProdet, groupName); } + /** + * 按物料推送工艺:只推所选物料,不改项目整单状态。 + */ + @Log(title = "按物料推送工艺工序") + @SaCheckPermission("system:route:pushRouteBom") + @PostMapping("/pushRouteBomByMaterial") + public R pushRouteBomByMaterial(@RequestParam String rooteProdet, + @RequestParam String groupName, + @RequestParam String materialCodes) { + String jsyyDeny = JsyyOrderNo.denyIfMatches(rooteProdet); + if (jsyyDeny != null) { + return R.fail(jsyyDeny); + } + ProcessOrderPro orderForLock = processOrderProService.selectByProjectNumber(rooteProdet); + if (orderForLock != null) { + String statusDeny = ProjectStatusLock.denyIfLocked(orderForLock.getBomStatus(), ProjectStatusLock.Action.PUSH_ROUTE); + if (statusDeny != null) { + return R.fail(statusDeny); + } + } + if (StringUtils.isBlank(materialCodes)) { + return R.fail("请选择要推送的物料"); + } + List codes = Arrays.stream(materialCodes.split("[,,;;\\s]+")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + return iProcessRouteService.pushRouteBomByMaterial(rooteProdet, groupName, codes); + } + @Log(title = "审核通过(发送钉钉通知,不推送金蝶)") @SaCheckPermission("system:route:approveRoute") @PostMapping("/approveRoute") @@ -583,41 +848,54 @@ public class ProcessRouteController extends BaseController { return iProcessRouteService.getSelecPlanRouteList(rooteProdet); } - @Log(title = "生成项目图纸PDF") + @Log(title = "生成工序计划单PDF") @SaCheckPermission("system:route:generatePDFs") @GetMapping("/generatePDFs") - public void generatePDFs(String rooteProdet, HttpServletResponse response) throws IOException { - // 调用服务层方法生成 ZIP 文件并获取其路径 - String zipFilePath = iProcessRouteService.generatePDFs(rooteProdet); - ProcessOrderPro processOrderPro = proMapper.selectByProjectNumber(rooteProdet); - processOrderPro.setBomStatus(5L); - proMapper.updateById(processOrderPro); - // 读取文件为字节数组 - File zipFile = new File(zipFilePath); - if (!zipFile.exists()) { - throw new FileNotFoundException("ZIP 文件未找到: " + zipFilePath); + public void generatePDFs(String rooteProdet, + @RequestParam(value = "materialCode", required = false) String materialCode, + HttpServletResponse response) throws IOException { + if (StringUtils.isBlank(rooteProdet)) { + throw new ServiceException("生产令号不能为空"); } - if (zipFile.length() <= 0) { - throw new IOException("ZIP 文件为空: " + zipFilePath); + // 有物料编码:单份 PDF;无物料编码:令号下全部 PDF 压缩包 + String filePath = iProcessRouteService.generatePDFs(rooteProdet, materialCode); + File outFile = new File(filePath); + if (!outFile.exists()) { + throw new FileNotFoundException("文件未找到: " + filePath); } - try (ZipFile zf = new ZipFile(zipFile)) { - Enumeration entries = zf.entries(); - if (entries == null || !entries.hasMoreElements()) { - throw new IOException("ZIP 文件中没有PDF: " + zipFilePath); + if (outFile.length() <= 0) { + throw new IOException("文件为空: " + filePath); + } + boolean isPdf = filePath.toLowerCase().endsWith(".pdf"); + boolean isZip = filePath.toLowerCase().endsWith(".zip"); + if (!isPdf && !isZip) { + throw new IOException("未生成PDF或压缩包: " + filePath); + } + response.setContentType(isPdf ? "application/pdf" : "application/zip"); + String encoded = URLEncoder.encode(outFile.getName(), "UTF-8").replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded); + try (FileInputStream fis = new FileInputStream(outFile); + OutputStream outputStreams = response.getOutputStream()) { + byte[] buffer = new byte[1024]; + int len; + while ((len = fis.read(buffer)) != -1) { + outputStreams.write(buffer, 0, len); } + outputStreams.flush(); } - FileInputStream fis = new FileInputStream(zipFile); - OutputStream outputStreams = response.getOutputStream(); - response.setContentType("application/zip"); - byte[] buffer = new byte[1024]; - int len; - while ((len = fis.read(buffer)) != -1) { - outputStreams.write(buffer, 0, len); - } - outputStreams.flush(); + } - fis.close(); - outputStreams.close(); + /** + * 下载 PLM 图纸/模型。后端校验项目来源后转发文件服务。 + */ + @Log(title = "下载图纸模型", businessType = BusinessType.EXPORT) + @SaCheckPermission("system:route:downloadDrawingModel") + @GetMapping("/downloadDrawingModel") + public void downloadDrawingModel(@RequestParam String productionOrderNo, + @RequestParam String materialCode, + @RequestParam String fileType, + HttpServletResponse response) { + drawingFileQueryService.download(productionOrderNo, materialCode, fileType, response); } @Log(title = "更新计划时间") @@ -719,7 +997,7 @@ public class ProcessRouteController extends BaseController { log.info("读取文件名: " + originalFilename); ExcelResult result = ExcelUtil.importExcelSheet6(file.getInputStream(), ProcessRouteVo.class, true); List list = result.getList(); - List list1 = iProcessRouteService.importDataTime(list); + iProcessRouteService.importDataTime(list); return R.ok("更新成功"); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRoutePushReceiveController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRoutePushReceiveController.java index 942f868..35cee6e 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRoutePushReceiveController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRoutePushReceiveController.java @@ -13,8 +13,10 @@ import com.ruoyi.system.domain.ProcessOrderPro; import com.ruoyi.system.domain.bo.plm.PlmProcessRoutePushBo; import com.ruoyi.system.domain.bo.plm.PlmProcessRoutePushNormalizer; import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.service.IDrawingRouteOperLogService; import com.ruoyi.system.service.IPlmProcessRoutePushService; import com.ruoyi.system.service.IPlmReceiveLogService; +import com.ruoyi.system.service.PlmPlanCompleteNotifyService; import com.ruoyi.system.service.ProcessPlanExcelPushService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -46,8 +48,11 @@ public class ProcessRoutePushReceiveController extends BaseController { private final IPlmProcessRoutePushService plmProcessRoutePushService; private final IPlmReceiveLogService plmReceiveLogService; private final ProcessOrderProMapper processOrderProMapper; - /** 导出计划表落盘 + 按 plan-user-id 推送(与手动上传一致) */ + /** 导出计划表落盘;总装已到才按 plan-user-id 推送 */ private final ProcessPlanExcelPushService processPlanExcelPushService; + /** 总装 BOM 与工艺都到齐后发给 plan-user-id */ + private final PlmPlanCompleteNotifyService plmPlanCompleteNotifyService; + private final IDrawingRouteOperLogService drawingRouteOperLogService; /** * 接收一个生产令号下的物料、材料 BOM、工艺路线及工步数据。 @@ -81,6 +86,8 @@ public class ProcessRoutePushReceiveController extends BaseController { summaryMap.put("savedBomCount", summary.getSavedBomCount()); summaryMap.put("savedMaterialBomCount", summary.getSavedMaterialBomCount()); summaryMap.put("savedRouteCount", summary.getSavedRouteCount()); + summaryMap.put("appended", summary.isAppended()); + summaryMap.put("replaced", summary.isReplaced()); int saved = summary.getSavedBomCount() + summary.getSavedMaterialBomCount() + summary.getSavedRouteCount(); plmReceiveLogService.record( IPlmReceiveLogService.BIZ_PROCESS_ROUTE, @@ -95,8 +102,17 @@ public class ProcessRoutePushReceiveController extends BaseController { null, clientIp ); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_UPLOAD, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath(API_PATH); + row.setProductionOrderNo(summary.getProductionOrder()); + row.setStatus(IDrawingRouteOperLogService.STATUS_SUCCESS); + row.setSummaryJson(JSON.toJSONString(summaryMap)); + row.setOperator("PLM"); + row.setClientIp(clientIp); + }); - // 入库成功后异步:导出工艺计划表 → 落盘 → 推送给 plan-user-id(与手动上传同一套用户) + // 入库成功后异步:导出落盘;有总装才发给 plan-user-id,没有就等总装到达 final String productionOrder = summary.getProductionOrder(); CompletableFuture.runAsync(() -> exportAndPushPlanExcel(productionOrder)); @@ -119,18 +135,38 @@ public class ProcessRoutePushReceiveController extends BaseController { e.getMessage(), clientIp ); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_UPLOAD, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath(API_PATH); + row.setProductionOrderNo(orderNo); + row.setStatus(IDrawingRouteOperLogService.STATUS_FAIL); + row.setErrorMsg(e.getMessage()); + row.setOperator("PLM"); + row.setClientIp(clientIp); + }); throw e; } } /** - * PLM 工艺入库后:按模板导出计划表,存到 app.plan-excel.base-dir,再按 plan-user-id 推送。 + * PLM 工艺入库后:按模板导出计划表并落盘;总装已到才发给 plan-user-id。 */ private void exportAndPushPlanExcel(String productionOrder) { + long started = System.currentTimeMillis(); try { ProcessOrderPro order = processOrderProMapper.selectByProjectNumber(productionOrder); if (order == null || order.getId() == null) { log.warn("PLM 导出计划表跳过:项目不存在 productionOrder={}", productionOrder); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath(API_PATH); + row.setProductionOrderNo(productionOrder); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_FILE); + row.setStatus(IDrawingRouteOperLogService.STATUS_FAIL); + row.setErrorMsg("项目不存在,跳过导出计划表"); + row.setOperator("PLM"); + row.setDurationMs(System.currentTimeMillis() - started); + }); return; } // 复用现有导出(PLM 来源会走 generateRoute3ExcelFromPlm) @@ -139,12 +175,23 @@ public class ProcessRoutePushReceiveController extends BaseController { File generated = new File(generatedPath); if (!generated.exists() || generated.length() <= 0) { log.warn("PLM 导出计划表失败:文件为空 path={}", generatedPath); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath(API_PATH); + row.setProjectId(order.getId()); + row.setProductionOrderNo(productionOrder); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_FILE); + row.setStatus(IDrawingRouteOperLogService.STATUS_FAIL); + row.setErrorMsg("导出计划表失败:文件为空"); + row.setOperator("PLM"); + row.setDurationMs(System.currentTimeMillis() - started); + }); return; } - // 落到固定目录,再推送;临时生成文件可删 + // 落到固定目录;有总装才钉钉推送,没有就只落盘等总装 File saved = processPlanExcelPushService.savePlanExcel(productionOrder, generated, generated.getName()); File toSend = saved != null ? saved : generated; - processPlanExcelPushService.sendToPlanUsers(toSend, toSend.getName()); + plmPlanCompleteNotifyService.notifyIfAssemblyAndRouteComplete(productionOrder, toSend); if (saved != null && generated.exists() && !generated.getAbsolutePath().equals(saved.getAbsolutePath())) { if (!generated.delete()) { log.warn("删除临时计划表失败: {}", generated.getAbsolutePath()); @@ -152,6 +199,16 @@ public class ProcessRoutePushReceiveController extends BaseController { } } catch (Exception e) { log.error("PLM 工艺推送后导出/推送计划表失败 productionOrder={}", productionOrder, e); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath(API_PATH); + row.setProductionOrderNo(productionOrder); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_FILE); + row.setStatus(IDrawingRouteOperLogService.STATUS_FAIL); + row.setErrorMsg(e.getMessage()); + row.setOperator("PLM"); + row.setDurationMs(System.currentTimeMillis() - started); + }); } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/RatioBuildCapacityController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/RatioBuildCapacityController.java index 1829bc8..3da6982 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/RatioBuildCapacityController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/RatioBuildCapacityController.java @@ -2,14 +2,20 @@ package com.ruoyi.system.controller; import com.ruoyi.common.core.domain.R; import com.ruoyi.system.domain.dto.ratio.RatioBuildCapacityDTO; +import com.ruoyi.system.domain.dto.ratio.RatioImportResultDTO; +import com.ruoyi.system.domain.dto.ratio.RatioModelOptionDTO; +import com.ruoyi.system.service.IRatioBomImportService; import com.ruoyi.system.service.IRatioBuildCapacityService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; -import java.math.BigDecimal; +import java.util.List; @RequiredArgsConstructor @RestController @@ -17,11 +23,32 @@ import java.math.BigDecimal; public class RatioBuildCapacityController { private final IRatioBuildCapacityService ratioBuildCapacityService; + private final IRatioBomImportService ratioBomImportService; + + @GetMapping("/ratios") + public R> ratios(@RequestParam("seriesType") String seriesType) { + return R.ok(ratioBuildCapacityService.listRatios(seriesType)); + } + + @GetMapping("/models") + public R> models(@RequestParam("seriesType") String seriesType, + @RequestParam("ratioValue") String ratioValue) { + return R.ok(ratioBuildCapacityService.listModels(seriesType, ratioValue)); + } @GetMapping("/buildCapacity") - public R buildCapacity(@RequestParam("ratioValue") BigDecimal ratioValue, + public R buildCapacity(@RequestParam("seriesType") String seriesType, + @RequestParam("ratioValue") String ratioValue, + @RequestParam("modelId") Long modelId, @RequestParam("qty") Integer qty) { - return R.ok(ratioBuildCapacityService.queryByRatio(ratioValue, qty)); + return R.ok(ratioBuildCapacityService.queryByRatio(seriesType, ratioValue, modelId, qty)); + } + + @PostMapping("/import") + public R importBom(@RequestPart("file") MultipartFile file, + @RequestParam("seriesType") String seriesType) { + RatioImportResultDTO result = ratioBomImportService.importExcel(file, seriesType); + return R.ok(result.getMessage(), result); } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java index 3e88ba4..8e453ae 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java @@ -37,6 +37,14 @@ public class ProcessOrderPro extends BaseEntity { * 是否可以下载 */ private Integer isDownload; + /** + * 图纸是否已齐:0否 1是。设计人员确认图纸上传完成后置1,方可下载 PDF;本批数量在下载时现算,不落库。 + */ + private Integer drawingQtyIssued; + /** + * 出图模式:{@link com.ruoyi.system.constant.DrawingMode#NORMAL} / {@link com.ruoyi.system.constant.DrawingMode#PART};执行出图前必填 + */ + private String drawingMode; /** * 是否做工艺:0否 1是 */ @@ -89,7 +97,7 @@ public class ProcessOrderPro extends BaseEntity { private String drawingPath; /** - * 项目状态(0方案设计完成,1设计出图完成,2工艺待审核,3已推送BOM,4已推送工艺,5已核算MRP,6开始生产,7生产完成,8工艺审核完成,9工艺审核拒绝) + * 项目状态(0方案设计完成,1设计出图完成,2工艺待审核,3已推送BOM,4已推送工艺,6开始生产,7生产完成,8工艺审核完成,9工艺审核拒绝) */ private Long bomStatus; /** @@ -129,7 +137,7 @@ public class ProcessOrderPro extends BaseEntity { */ private Date drawingTime; /** - * 出图人 + * 设计人(字典 plan_proer,多人逗号分隔) */ private String drawingBy; /** diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioAccessory.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioAccessory.java index 6facc72..6313faf 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioAccessory.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioAccessory.java @@ -7,6 +7,8 @@ import com.ruoyi.common.core.domain.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; +import java.math.BigDecimal; + @Data @EqualsAndHashCode(callSuper = true) @TableName("ratio_accessory") @@ -17,16 +19,24 @@ public class RatioAccessory extends BaseEntity { private Long ratioConfigId; + /** 减速机型号ID,空表示该速比通用件 */ + private Long ratioModelId; + private String materialCode; private String materialName; private String danzhong; + /** 材料 */ private String canzhi; + /** 配件类别:通用件 / 轴套 / 行星架 / 法兰 */ private String partCategory; + /** 单台用量 */ + private BigDecimal bomQty; + private Integer status; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioConfig.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioConfig.java index 1ba19e3..852c6eb 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioConfig.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioConfig.java @@ -17,7 +17,11 @@ public class RatioConfig extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) private Long id; - private BigDecimal ratioValue; + /** 机型:KP08 / KP14 */ + private String seriesType; + + /** 速比值,例如 91B */ + private String ratioValue; private BigDecimal maxTorqueNm; diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningDetail.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningDetail.java index d0392aa..2f0341f 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningDetail.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningDetail.java @@ -86,7 +86,7 @@ public class SfcOperationPlanningDetail extends BaseEntity { */ private String operator; /** - * 状态 + * 工序状态(金蝶 FOperStatus 原始码:0创建 1计划 2计划确认 3下达 4开工 5完工 6关闭) */ private String operStatus; /** diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningMain.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningMain.java index 9365b28..8a56d4e 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningMain.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SfcOperationPlanningMain.java @@ -66,4 +66,19 @@ public class SfcOperationPlanningMain extends BaseEntity { */ private Date planFinishTime; + /** + * 单据状态(来自生产订单 PRD_MO.FDocumentStatus):A创建 B审核中 C已审核 D重新审核 E暂存 + */ + private String documentStatus; + + /** + * 业务状态(来自生产订单 PRD_MO.FStatus):1计划 2计划确认 3下达 4开工 5完工 6结案 7结算 + */ + private String billStatus; + + /** + * 工序计划单据状态(SFC_OperationPlanning.FDocumentStatus) + */ + private String planDocumentStatus; + } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/FigureSaveBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/FigureSaveBo.java index 2896aec..def5fe1 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/FigureSaveBo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/FigureSaveBo.java @@ -81,31 +81,26 @@ public class FigureSaveBo extends BaseEntity { /** * 文件类型 */ - @NotBlank(message = "文件类型不能为空", groups = { AddGroup.class, EditGroup.class }) private String fileType; /** * 出图人 */ - @NotBlank(message = "出图人不能为空", groups = { AddGroup.class, EditGroup.class }) private String drawBy; /** * 是否延期:0否 1是 */ - @NotNull(message = "是否延期:0否 1是不能为空", groups = { AddGroup.class, EditGroup.class }) private Integer whetherDelay; /** * 延期天数 */ - @NotNull(message = "延期天数不能为空", groups = { AddGroup.class, EditGroup.class }) private Long delayDay; /** - * 出图时间 + * 出图时间(项目级维护,产品不再必填) */ - @NotNull(message = "出图时间不能为空", groups = { AddGroup.class, EditGroup.class }) private Date drawTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/ProcessOrderProBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/ProcessOrderProBo.java index 268d072..c256179 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/ProcessOrderProBo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/ProcessOrderProBo.java @@ -1,14 +1,18 @@ package com.ruoyi.system.domain.bo; import com.alibaba.excel.annotation.ExcelProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; import com.ruoyi.common.annotation.Translation; import com.ruoyi.common.constant.TransConstant; import com.ruoyi.common.core.domain.BaseEntity; import com.ruoyi.common.core.validate.AddGroup; import com.ruoyi.common.core.validate.EditGroup; +import com.ruoyi.common.utils.StringUtils; import lombok.Data; import lombok.EqualsAndHashCode; import javax.validation.constraints.*; +import java.util.Collection; import java.util.Date; import java.util.List; @@ -86,6 +90,16 @@ public class ProcessOrderProBo extends BaseEntity { */ private Integer isDownload; + /** + * 图纸是否已齐:0否 1是 + */ + private Integer drawingQtyIssued; + + /** + * 出图模式:normal正常 / part零部件(执行出图前必填) + */ + private String drawingMode; + /** * 是否做工艺:0否 1是 */ @@ -131,14 +145,17 @@ public class ProcessOrderProBo extends BaseEntity { /** * 工艺上传更新时间 */ + @NotNull(message = "工艺上传更新不能为空", groups = { AddGroup.class }) private Date routeUptime; /** * 排产计划更新时间 */ + @NotNull(message = "排产计划更新不能为空", groups = { AddGroup.class }) private Date planUptime; /** * BOM/物料更新时间 */ + @NotNull(message = "BOM/物料更新不能为空", groups = { AddGroup.class }) private Date bomUptime; /** * bom数量 @@ -153,13 +170,64 @@ public class ProcessOrderProBo extends BaseEntity { */ private Long routeCount; /** - * 出图时间 + * 出图时间(项目整体;新增必填) */ + @NotNull(message = "出图时间不能为空", groups = { AddGroup.class }) private Date drawingTime; /** - * 出图人 + * 设计人(字典 plan_proer,多人逗号分隔)。 + * 前端表单绑定的是 drawer 数组,提交时再折成本字段;校验看 drawingBy。 */ + @NotBlank(message = "设计人不能为空", groups = { AddGroup.class, EditGroup.class }) private String drawingBy; + + /** + * JSON 反序列化设计人。 + * {@code Nulls.SKIP}:请求体里 drawingBy 为 null 时不覆盖,避免把 drawer 已解析出的值冲掉。 + */ + @JsonSetter(nulls = Nulls.SKIP) + public void setDrawingBy(String drawingBy) { + if (StringUtils.isNotBlank(drawingBy)) { + this.drawingBy = drawingBy.trim(); + } + } + + /** + * 兼容前端多选字段 drawer:仅当 drawingBy 仍为空时,把数组/字符串折成逗号分隔写入 drawingBy。 + */ + @JsonSetter("drawer") + public void unpackDrawer(Object drawer) { + if (StringUtils.isNotBlank(this.drawingBy) || drawer == null) { + return; + } + String joined = joinDesignerValue(drawer); + if (StringUtils.isNotBlank(joined)) { + this.drawingBy = joined; + } + } + + /** 将设计人数组或单个值规范为逗号分隔字符串;空值忽略。 */ + private static String joinDesignerValue(Object value) { + if (value instanceof Collection) { + StringBuilder sb = new StringBuilder(); + for (Object item : (Collection) value) { + if (item == null) { + continue; + } + String s = String.valueOf(item).trim(); + if (s.isEmpty() || "null".equalsIgnoreCase(s)) { + continue; + } + if (sb.length() > 0) { + sb.append(','); + } + sb.append(s); + } + return sb.length() == 0 ? null : sb.toString(); + } + String s = String.valueOf(value).trim(); + return s.isEmpty() || "null".equalsIgnoreCase(s) ? null : s; + } /** * 审核卡片Id */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/SfcOperationPlanningMainBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/SfcOperationPlanningMainBo.java index 6c58664..beb553b 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/SfcOperationPlanningMainBo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/SfcOperationPlanningMainBo.java @@ -85,5 +85,13 @@ public class SfcOperationPlanningMainBo extends BaseEntity { private String workCenterName; + /** 单据状态(生产订单 PRD_MO.FDocumentStatus) */ + private String documentStatus; + + /** 业务状态(生产订单 PRD_MO.FStatus) */ + private String billStatus; + + /** 工序计划单据状态(SFC_OperationPlanning.FDocumentStatus) */ + private String planDocumentStatus; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmProcessRoutePushNormalizer.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmProcessRoutePushNormalizer.java index 4fcbd4b..613740c 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmProcessRoutePushNormalizer.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmProcessRoutePushNormalizer.java @@ -114,6 +114,10 @@ public final class PlmProcessRoutePushNormalizer { private int savedMaterialBomCount; /** 实际写入 process_route 的行数(已过滤 PLM 占位工序) */ private int savedRouteCount; + /** 同令号已有工艺且本次总图号不同,追加写入 */ + private boolean appended; + /** 同令号已有工艺且本次覆盖(同总图或无法比较总图) */ + private boolean replaced; private boolean received = true; } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanResultDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanResultDTO.java index ae8efe2..22e0949 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanResultDTO.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanResultDTO.java @@ -55,6 +55,36 @@ public class OperationPlanResultDTO { */ private Long fid; + /** + * 单据状态码(生产订单 PRD_MO.FDocumentStatus) + */ + private String documentStatus; + + /** + * 单据状态名称 + */ + private String documentStatusName; + + /** + * 业务状态码(生产订单 PRD_MO.FStatus) + */ + private String billStatus; + + /** + * 业务状态名称 + */ + private String billStatusName; + + /** + * 工序计划单据状态码(SFC_OperationPlanning.FDocumentStatus) + */ + private String planDocumentStatus; + + /** + * 工序计划单据状态名称 + */ + private String planDocumentStatusName; + /** * 原生计划开始时间(内部排序用,不输出到JSON) */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanningDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanningDTO.java index d8ce7ec..5e2ae5a 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanningDTO.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OperationPlanningDTO.java @@ -61,6 +61,12 @@ public class OperationPlanningDTO { @JsonProperty("FBillNo") private String fBillNo; + /** + * 工序计划单据状态:A创建 B审核中 C已审核 D重新审核 E暂存 + */ + @JsonProperty("FDocumentStatus") + private String fDocumentStatus; + /** * 工序号 */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ProcessDetailDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ProcessDetailDTO.java index 5554d0e..b895b3b 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ProcessDetailDTO.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ProcessDetailDTO.java @@ -43,10 +43,18 @@ public class ProcessDetailDTO { private Double ngQty; /** - * 状态 + * 工序状态码 FOperStatus:0创建 1计划 2计划确认 3下达 4开工 5完工 6关闭 + */ + private String operStatus; + + /** + * 业务展示状态(未开工/生产中/质检中/工序完成/已转出/已关闭) */ private String status; + /** + * 金蝶工序状态名称 + */ private String k3Status; /** * 转出数量 diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/productionorder/AssemblyBomReceiveSummary.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/productionorder/AssemblyBomReceiveSummary.java index c573c35..70abb9c 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/productionorder/AssemblyBomReceiveSummary.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/productionorder/AssemblyBomReceiveSummary.java @@ -14,5 +14,9 @@ public class AssemblyBomReceiveSummary { private int insertedCount; + /** 同令号已有数据且本次为全量/同主产品覆盖 */ private boolean replaced; + + /** 同令号已有数据且本次主产品图号不同,追加写入 */ + private boolean appended; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBuildCapacityDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBuildCapacityDTO.java index 90740f4..c2db4b3 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBuildCapacityDTO.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBuildCapacityDTO.java @@ -10,7 +10,13 @@ public class RatioBuildCapacityDTO { private Long ratioConfigId; - private BigDecimal ratioValue; + private String seriesType; + + private String ratioValue; + + private Long modelId; + + private String modelCode; private BigDecimal maxTorqueNm; @@ -18,6 +24,9 @@ public class RatioBuildCapacityDTO { private Long maxBuildQty; + /** 该型号没有轴套、行星架、法兰时,结果只有通用件。 */ + private Boolean onlyCommonParts; + private List items; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioMaterialItemDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioMaterialItemDTO.java index 812f87d..74b20c0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioMaterialItemDTO.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioMaterialItemDTO.java @@ -17,6 +17,9 @@ public class RatioMaterialItemDTO { private String canzhi; + /** Excel 中的单台数量 */ + private Double bomQty; + private Double requiredQty; private Double onHandQty; diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ElectricalMaterialBomVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ElectricalMaterialBomVO.java index 6be6e28..7e10ed5 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ElectricalMaterialBomVO.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ElectricalMaterialBomVO.java @@ -1,13 +1,15 @@ package com.ruoyi.system.domain.vo; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; import com.alibaba.excel.annotation.ExcelProperty; -import com.ruoyi.common.convert.NullableDoubleConverter; +import com.ruoyi.common.convert.NullableQuantityTextConverter; import lombok.Data; /** * 电气物料BOM * 2025-09-25 */ @Data +@ExcelIgnoreUnannotated public class ElectricalMaterialBomVO { /** @@ -33,22 +35,28 @@ public class ElectricalMaterialBomVO { @ExcelProperty(value = "物料名称") private String drawingName; /** - * 型号 + * 型号(部分模板表头为「规格型号」) */ @ExcelProperty(value = "型号") - private String Model; + private String model; + + /** + * 规格型号(与「型号」二选一,导入时都会写入 material) + */ + @ExcelProperty(value = "规格型号") + private String specificationModel; /** * 品牌 */ @ExcelProperty(value = "品牌") - private String Brand; + private String brand; /** - * 单台数量 + * 单台数量。普通数字原样入库;文本分数如 1/4、1/3 在导入时拆成分子和分母。 */ - @ExcelProperty(value = "单台数量", converter = NullableDoubleConverter.class) - private Double quantity; + @ExcelProperty(value = "单台数量", converter = NullableQuantityTextConverter.class) + private String quantity; /** * 批次数量 */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProExportVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProExportVo.java index 3aef819..05f7a8c 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProExportVo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProExportVo.java @@ -3,8 +3,7 @@ package com.ruoyi.system.domain.vo; import cn.hutool.core.date.DateUtil; import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; import com.alibaba.excel.annotation.ExcelProperty; -import com.ruoyi.common.annotation.ExcelDictFormat; -import com.ruoyi.common.convert.ExcelDictConvert; +import com.ruoyi.common.utils.ProjectStatusLock; import com.ruoyi.common.utils.StringUtils; import lombok.Data; @@ -43,8 +42,7 @@ public class ProcessOrderProExportVo { @ExcelProperty(value = "生产令号") private String productionOrderNo; - @ExcelProperty(value = "项目状态", converter = ExcelDictConvert.class) - @ExcelDictFormat(dictType = "process_status") + @ExcelProperty(value = "项目状态") private String bomStatus; @ExcelProperty(value = "项目名称") @@ -101,7 +99,7 @@ public class ProcessOrderProExportVo { return row; } row.setProductionOrderNo(vo.getProductionOrderNo()); - row.setBomStatus(vo.getBomStatus() == null ? null : String.valueOf(vo.getBomStatus())); + row.setBomStatus(vo.getBomStatus() == null ? null : ProjectStatusLock.statusLabel(vo.getBomStatus())); row.setProductionName(vo.getProductionName()); row.setSource(formatSource(vo.getSource())); row.setWhetherDelayLabel(Integer.valueOf(1).equals(vo.getWhetherDelay()) ? "延期" : "正常"); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java index e5804f7..fd428b7 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java @@ -52,6 +52,18 @@ public class ProcessOrderProVo { @ExcelProperty(value = "是否可以下载") private Integer isDownload; + /** + * 图纸是否已齐:0否 1是 + */ + @ExcelProperty(value = "图纸已齐") + private Integer drawingQtyIssued; + + /** + * 出图模式:normal正常 / part零部件(执行出图前必填) + */ + @ExcelProperty(value = "出图模式") + private String drawingMode; + /** * 是否做工艺:0否 1是 */ @@ -151,8 +163,10 @@ public class ProcessOrderProVo { */ private Long routeCount; /** - * 出图人 + * 设计人(字典 plan_proer,多人逗号分隔) */ + @ExcelProperty(value = "设计人", converter = ExcelDictConvert.class) + @ExcelDictFormat(dictType = "plan_proer") private String drawingBy; /** * 出图时间 @@ -210,6 +224,18 @@ public class ProcessOrderProVo { @ExcelIgnore private Boolean productionComplete; + /** + * 设计出图进度(非库字段):进行中 / 延期 / 设计出图完成 + */ + @ExcelIgnore + private String drawingProgress; + + /** + * 等效 A4 合计(非库字段,来自图纸工作量) + */ + @ExcelProperty(value = "等效A4") + private Integer equivalentA4Total; + /** * 列表/导出:金蝶订单状态摘要(非库字段) */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SfcOperationPlanningMainVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SfcOperationPlanningMainVo.java index d1374e5..4b38d45 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SfcOperationPlanningMainVo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SfcOperationPlanningMainVo.java @@ -86,5 +86,22 @@ public class SfcOperationPlanningMainVo { @ExcelProperty(value = "计划结束时间") private Date planFinishTime; + /** + * 单据状态(生产订单 PRD_MO.FDocumentStatus) + */ + @ExcelProperty(value = "单据状态") + private String documentStatus; + + /** + * 业务状态(生产订单 PRD_MO.FStatus) + */ + @ExcelProperty(value = "业务状态") + private String billStatus; + + /** + * 工序计划单据状态(SFC_OperationPlanning.FDocumentStatus) + */ + @ExcelProperty(value = "工序计划单据状态") + private String planDocumentStatus; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/plm/PlmProductionOrderVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/plm/PlmProductionOrderVo.java index 61d32f5..48532b5 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/plm/PlmProductionOrderVo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/plm/PlmProductionOrderVo.java @@ -40,7 +40,6 @@ public class PlmProductionOrderVo { * 2 工艺待审核 * 3 已推送BOM * 4 已推送工艺 - * 5 已核算MRP * 6 开始生产 * 7 生产完成 * 8 工艺审核完成 diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/job/SfcOperationPlanningJob.java b/ruoyi-system/src/main/java/com/ruoyi/system/job/SfcOperationPlanningJob.java index 54187d7..a61d4e8 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/job/SfcOperationPlanningJob.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/job/SfcOperationPlanningJob.java @@ -1,8 +1,16 @@ package com.ruoyi.system.job; +import com.ruoyi.common.constant.UserConstants; import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.enums.UserType; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.JobLoginContext; +import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.domain.bo.SfcOperationPlanningMainBo; import com.ruoyi.system.service.ISfcOperationPlanningMainService; +import com.ruoyi.system.service.ISysUserService; import com.xxl.job.core.context.XxlJobHelper; import com.xxl.job.core.handler.annotation.XxlJob; import lombok.RequiredArgsConstructor; @@ -21,6 +29,7 @@ import org.springframework.stereotype.Component; public class SfcOperationPlanningJob { private final ISfcOperationPlanningMainService iSfcOperationPlanningMainService; + private final ISysUserService sysUserService; /** * 定时同步K3工序计划数据 @@ -30,8 +39,8 @@ public class SfcOperationPlanningJob { public void syncOperationPlanningJobHandler() throws Exception { XxlJobHelper.log("XXL-JOB, 开始同步K3工序计划数据..."); long startTime = System.currentTimeMillis(); - try { + loginAsAdmin(); Boolean result = iSfcOperationPlanningMainService.syncOperationPlanning(); if (result) { XxlJobHelper.log("XXL-JOB, 同步K3工序计划数据成功!耗时: {}ms", (System.currentTimeMillis() - startTime)); @@ -44,9 +53,29 @@ public class SfcOperationPlanningJob { log.error("同步K3工序计划数据异常", e); XxlJobHelper.log("XXL-JOB, 同步K3工序计划数据异常: {}", e.getMessage()); XxlJobHelper.handleFail("同步异常: " + e.getMessage()); + } finally { + JobLoginContext.logout(); } } + /** + * 同步线程没有页面登录态,用管理员账号补上,创建人/更新人才能自动填充。 + */ + private void loginAsAdmin() { + SysUser admin = sysUserService.selectUserById(UserConstants.ADMIN_ID); + if (admin == null || StringUtils.isBlank(admin.getUserName())) { + throw new ServiceException("未找到管理员账号,工序计划同步无法登录"); + } + LoginUser loginUser = new LoginUser(); + loginUser.setUserId(admin.getUserId()); + loginUser.setDeptId(admin.getDeptId()); + loginUser.setUsername(admin.getUserName()); + loginUser.setUserType(StringUtils.isNotBlank(admin.getUserType()) + ? admin.getUserType() : UserType.SYS_USER.getUserType()); + JobLoginContext.login(loginUser); + XxlJobHelper.log("XXL-JOB, 已以用户 {} 登录", loginUser.getUsername()); + } + /** * 项目进度推送:生成进度 Excel,用「信息发送」机器人私发给配置人员。 * JobHandler:pushOperationPlanningProgress diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/listener/FileToZip.java b/ruoyi-system/src/main/java/com/ruoyi/system/listener/FileToZip.java index 48318da..da14514 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/listener/FileToZip.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/listener/FileToZip.java @@ -27,6 +27,10 @@ public class FileToZip { private static void zipFileRecursive(File fileToZip, String fileName, ZipOutputStream zos) throws IOException { if (fileToZip.isHidden()) return; + // 跳过盖章残留的 *_temp.pdf,避免解压后出现双份 + if (fileToZip.isFile() && fileToZip.getName().toLowerCase().endsWith("_temp.pdf")) { + return; + } if (fileToZip.isDirectory()) { if (!fileName.endsWith("/")) fileName += "/"; zos.putNextEntry(new ZipEntry(fileName)); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/runner/CodeConverter.java b/ruoyi-system/src/main/java/com/ruoyi/system/runner/CodeConverter.java index 4930c9d..5304de8 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/runner/CodeConverter.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/runner/CodeConverter.java @@ -14,7 +14,7 @@ public class CodeConverter { //nameToCodeMap.put("焊接工段", "WC000003"); nameToCodeMap.put("装一工段", "WC000004"); // 装配车间 nameToCodeMap.put("装二工段", "WC000005"); - nameToCodeMap.put("电钳工段", "WC000006"); + nameToCodeMap.put("电钳工段", "WC000023"); nameToCodeMap.put("机加委外", "WC000008"); nameToCodeMap.put("下料工段", "WC000010"); // 铆焊车间 nameToCodeMap.put("焊接工段", "WC000011"); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/runner/JdUtil.java b/ruoyi-system/src/main/java/com/ruoyi/system/runner/JdUtil.java index 0dd272e..607a9ef 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/runner/JdUtil.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/runner/JdUtil.java @@ -16,6 +16,7 @@ import com.ruoyi.system.domain.BomDetails; import com.ruoyi.system.domain.PartCost; import com.ruoyi.system.domain.dto.*; import com.ruoyi.system.domain.vo.*; +import com.ruoyi.system.service.support.OperationPlanningResultAssembler; import com.ruoyi.system.jdmain.rouplan.FidEntryIdDTO; import com.ruoyi.system.jdmain.rouplan.Model; import org.aspectj.bridge.MessageUtil; @@ -854,7 +855,7 @@ public class JdUtil { JsonArray filterString = new JsonArray(); JsonObject filterObject = new JsonObject(); filterObject.addProperty("FieldName", "FMaterialId.FNumber"); - filterObject.addProperty("Compare", "="); // 改为等号运算符 + filterObject.addProperty("Compare", "="); filterObject.addProperty("Value", materialCode); filterObject.addProperty("Left", ""); filterObject.addProperty("Right", ""); @@ -1125,20 +1126,21 @@ public class JdUtil { JsonArray filterString = new JsonArray(); JsonObject filterObject = new JsonObject(); filterObject.addProperty("FieldName", "FMaterialId.FNumber"); - filterObject.addProperty("Compare", "="); // 改为等号运算符 + filterObject.addProperty("Compare", "="); filterObject.addProperty("Value", materialCode); filterObject.addProperty("Left", ""); filterObject.addProperty("Right", ""); filterObject.addProperty("Logic", 0);// 从 PlanOrderVo 获取生产令号 filterString.add(filterObject); - JsonObject filterObject1 = new JsonObject(); - filterObject1.addProperty("FieldName", "FMRPCloseStatus"); - filterObject1.addProperty("Compare", "105"); // 改为等号运算符 - filterObject1.addProperty("Value", "A"); - filterObject1.addProperty("Left", ""); - filterObject1.addProperty("Right", ""); - filterObject1.addProperty("Logic", 0);// 从 PlanOrderVo 获取生产令号 - filterString.add(filterObject1); + // 整单未关闭 + JsonObject filterObjectClose = new JsonObject(); + filterObjectClose.addProperty("FieldName", "FCloseStatus"); + filterObjectClose.addProperty("Compare", "105"); + filterObjectClose.addProperty("Value", "A"); + filterObjectClose.addProperty("Left", ""); + filterObjectClose.addProperty("Right", ""); + filterObjectClose.addProperty("Logic", 0); + filterString.add(filterObjectClose); json.add("FilterString", filterString); json.addProperty("OrderString", ""); json.addProperty("TopRowCount", 0); @@ -1194,14 +1196,15 @@ public class JdUtil { filterObject.addProperty("Right", ""); filterObject.addProperty("Logic", 0); filterString.add(filterObject); - JsonObject filterObject1 = new JsonObject(); - filterObject1.addProperty("FieldName", "FMRPCloseStatus"); - filterObject1.addProperty("Compare", "105"); - filterObject1.addProperty("Value", "A"); - filterObject1.addProperty("Left", ""); - filterObject1.addProperty("Right", ""); - filterObject1.addProperty("Logic", 0); - filterString.add(filterObject1); + // 整单未关闭 + JsonObject filterObjectClose = new JsonObject(); + filterObjectClose.addProperty("FieldName", "FCloseStatus"); + filterObjectClose.addProperty("Compare", "105"); + filterObjectClose.addProperty("Value", "A"); + filterObjectClose.addProperty("Left", ""); + filterObjectClose.addProperty("Right", ""); + filterObjectClose.addProperty("Logic", 0); + filterString.add(filterObjectClose); JsonObject filterObject2 = new JsonObject(); filterObject2.addProperty("FieldName", "F_UCHN_Text2"); filterObject2.addProperty("Compare", "83"); @@ -4109,7 +4112,8 @@ public class JdUtil { JsonObject json = new JsonObject(); json.addProperty("FormId", "SFC_OperationPlanning"); json.addProperty("FieldKeys", - "FID,FENTITY_FEntryID,FSubEntity_FDetailID,F_HBYT_SCLH,F_HBYT_XMH,FMONumber,FProductId.FNumber,FProductName,FProSpecification,FBillNo,FOperNumber," + + "FID,FENTITY_FEntryID,FSubEntity_FDetailID,F_HBYT_SCLH,F_HBYT_XMH,FMONumber,FProductId.FNumber,FProductName,FProSpecification,FBillNo," + + "FDocumentStatus,FOperNumber," + "FOperQty,FQualifiedQty,FTransOutQty,FUnqualifiedQty,FWastageQty,FTransInQty,FDepartmentId.FName,FWorkCenterId.FName," + "FProcessId.FName,F_HBYT_ZYRY,FOperStatus,FSeqPlanStartTime,FSeqPlanFinishTime,FOperPlanStartTime,FOperPlanFinishTime," + "FOptCtrlCodeId.FName,FKeyOper,FActivity1Name,FOperDescription,FPlanStartTime,FPlanFinishTime,F_HBYT_SB"); @@ -4237,6 +4241,14 @@ public class JdUtil { .filter(item -> item.getFid() != null) .collect(java.util.stream.Collectors.groupingBy(OperationPlanningDTO::getFid)); + // 生产订单单据状态/业务状态:均来自 PRD_MO(FDocumentStatus / FStatus) + Set moNumbers = rawList.stream() + .map(OperationPlanningDTO::getFMoNumber) + .filter(StringUtils::isNotBlank) + .map(String::trim) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + Map moStatusMap = queryProductionOrderStatusMap(moNumbers); + List resultList = new ArrayList<>(); for (Map.Entry> entry : groupedMap.entrySet()) { @@ -4255,6 +4267,19 @@ public class JdUtil { resultDTO.setPlanStartTime(firstItem.getFPlanStartTime()); resultDTO.setPlanFinishTime(firstItem.getFPlanFinishTime()); resultDTO.setFid(firstItem.getFid()); + String[] moStatus = firstItem.getFMoNumber() == null + ? null + : moStatusMap.get(firstItem.getFMoNumber().trim()); + String moDocumentStatus = moStatus == null ? null : moStatus[0]; + String billStatus = moStatus == null ? null : moStatus[1]; + // 工序计划自身单据状态 + resultDTO.setPlanDocumentStatus(firstItem.getFDocumentStatus()); + resultDTO.setPlanDocumentStatusName(OperationPlanningResultAssembler.mapDocumentStatusName(firstItem.getFDocumentStatus())); + // 生产订单单据状态 / 业务状态 + resultDTO.setDocumentStatus(moDocumentStatus); + resultDTO.setDocumentStatusName(OperationPlanningResultAssembler.mapDocumentStatusName(moDocumentStatus)); + resultDTO.setBillStatus(billStatus); + resultDTO.setBillStatusName(OperationPlanningResultAssembler.mapBillStatusName(billStatus)); List processList = new ArrayList<>(); for (OperationPlanningDTO item : items) { @@ -4284,26 +4309,9 @@ public class JdUtil { detail.setK3DetailId(item.getFSubEntityFDetailId()); detail.setEntityEntryId(item.getFEntityFEntryId()); - // 状态映射:K3里4通常表示已完工 - String statusStr = item.getFOperStatus(); - if ("0".equals(statusStr)) { - detail.setStatus("创建"); - } - else if ("1".equals(statusStr)) { - detail.setStatus("计划"); - } else if ("2".equals(statusStr)) { - detail.setStatus("计划确认"); - } else if ("3".equals(statusStr)) { - detail.setStatus("下达"); - } else if ("4".equals(statusStr)) { - detail.setStatus("开工"); - } else if ("5".equals(statusStr)) { - detail.setStatus("完工"); - } else if ("6".equals(statusStr)) { - detail.setStatus("关闭"); - } else { - detail.setStatus(statusStr); - } + // 入库存原始 FOperStatus 码;展示名由 Assembler 统一映射 + detail.setOperStatus(item.getFOperStatus()); + OperationPlanningResultAssembler.enrichProcessDetailStatus(detail); processList.add(detail); } @@ -4318,6 +4326,69 @@ public class JdUtil { return resultList; } + /** + * 按生产订单编号批量查 PRD_MO 的单据状态/业务状态。 + * + * @return key=FBillNo,value=[FDocumentStatus, FStatus] + */ + private static Map queryProductionOrderStatusMap(Collection moNumbers) { + Map result = new HashMap<>(); + if (moNumbers == null || moNumbers.isEmpty()) { + return result; + } + List list = moNumbers.stream() + .filter(StringUtils::isNotBlank) + .map(String::trim) + .distinct() + .collect(java.util.stream.Collectors.toList()); + K3CloudApi client = new K3CloudApi(); + ObjectMapper objectMapper = new ObjectMapper(); + int batchSize = 200; + for (int i = 0; i < list.size(); i += batchSize) { + List batch = list.subList(i, Math.min(i + batchSize, list.size())); + JsonObject json = new JsonObject(); + json.addProperty("FormId", "PRD_MO"); + json.addProperty("FieldKeys", "FBillNo,FDocumentStatus,FStatus"); + JsonArray filterString = new JsonArray(); + JsonObject filterObject = new JsonObject(); + filterObject.addProperty("FieldName", "FBillNo"); + filterObject.addProperty("Compare", "338"); + filterObject.addProperty("Value", String.join(",", batch)); + filterObject.addProperty("Left", ""); + filterObject.addProperty("Right", ""); + filterObject.addProperty("Logic", 0); + filterString.add(filterObject); + json.add("FilterString", filterString); + json.addProperty("OrderString", ""); + json.addProperty("TopRowCount", 0); + json.addProperty("StartRow", 0); + json.addProperty("Limit", batch.size()); + json.addProperty("SubSystemId", ""); + try { + String resultJson = String.valueOf(client.billQuery(json.toString())); + JsonArray jsonArray = new Gson().fromJson(resultJson, JsonArray.class); + if (jsonArray == null || jsonArray.size() == 0) { + continue; + } + List rows = objectMapper.readValue(jsonArray.toString(), + new TypeReference>() { + }); + if (rows == null) { + continue; + } + for (ProMoDTO row : rows) { + if (row == null || StringUtils.isBlank(row.getBillNo())) { + continue; + } + result.put(row.getBillNo().trim(), new String[]{row.getDocumentStatus(), row.getStatus()}); + } + } catch (Exception e) { + log.warn("批量查询生产订单状态失败, size={}, err={}", batch.size(), e.getMessage()); + } + } + return result; + } + // ===================== ND3 阶段性产品报表 · 金蝶查询 ===================== @@ -4555,7 +4626,7 @@ public class JdUtil { "FBillNo,F_UCHN_Text2,FDate,FDeliveryDate,FQty,FRemainStockINQty,FMaterialId.FNumber,FMaterialName"); JsonArray filterString = new JsonArray(); filterString.add(nd3Filter("FMaterialId.FNumber", "=", materialCode)); - filterString.add(nd3Filter("FMRPCloseStatus", "105", "A")); + filterString.add(nd3Filter("FCloseStatus", "105", "A")); filterString.add(nd3Filter("F_UCHN_Text2", "83", proCode)); json.add("FilterString", filterString); nd3BillQueryDefaults(json); @@ -4573,7 +4644,7 @@ public class JdUtil { "FBillNo,F_UCHN_Text2,FDate,FDeliveryDate,FQty,FRemainStockINQty,FMaterialId.FNumber,FMaterialName"); JsonArray filterString = new JsonArray(); filterString.add(nd3Filter("FMaterialId.FNumber", "=", materialCode)); - filterString.add(nd3Filter("FMRPCloseStatus", "105", "A")); + filterString.add(nd3Filter("FCloseStatus", "105", "A")); json.add("FilterString", filterString); nd3BillQueryDefaults(json); return nd3ParseList(client, json, PurchaseOrderDTO.class, "PUR_PurchaseOrder", materialCode, null); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/runner/PDFGenerator.java b/ruoyi-system/src/main/java/com/ruoyi/system/runner/PDFGenerator.java index e06ef7d..c7f055d 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/runner/PDFGenerator.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/runner/PDFGenerator.java @@ -131,6 +131,14 @@ public class PDFGenerator { } public static String writeToPdf(List combinedVoList, String rooteProdet, List fontPaths) { + return writeToPdf(combinedVoList, rooteProdet, fontPaths, null); + } + + /** + * @param singlePdfFileName 非空时不打 zip,把本批 PDF 合成/直接返回该文件名(一个物料一份) + */ + public static String writeToPdf(List combinedVoList, String rooteProdet, List fontPaths, + String singlePdfFileName) { String[] fontCandidates = (fontPaths == null || fontPaths.isEmpty()) ? new String[] { "C:\\Users\\Administrator\\Desktop\\arial unicode ms.ttf" @@ -380,6 +388,14 @@ public class PDFGenerator { if (generatedPdfFiles.isEmpty()) { throw new RuntimeException("目录中未找到已生成PDF: " + directoryPath); } + // 指定了单物料文件名:一份物料一份 PDF,多张生产订单则合并 + if (singlePdfFileName != null && !singlePdfFileName.trim().isEmpty()) { + try { + return packAsSinglePdf(directoryPath, generatedPdfFiles, singlePdfFileName.trim()); + } catch (IOException e) { + throw new RuntimeException("合并单物料PDF失败: " + e.getMessage(), e); + } + } // 生成ZIP文件 String zipFilePath = directoryPath + "\\" + rooteProdet + "_工序计划单.zip"; try { @@ -395,6 +411,27 @@ public class PDFGenerator { } + /** + * 将已生成的多份 PDF 合成一份(同一物料多张金蝶生产订单时)。 + */ + private static String packAsSinglePdf(String directoryPath, List pdfFiles, String fileName) throws IOException { + File dest = new File(directoryPath, fileName); + if (pdfFiles.size() == 1) { + File src = pdfFiles.get(0); + if (!src.getCanonicalPath().equals(dest.getCanonicalPath())) { + Files.copy(src.toPath(), dest.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return dest.getAbsolutePath(); + } + PDFMergerUtility merger = new PDFMergerUtility(); + for (File pdf : pdfFiles) { + merger.addSource(pdf); + } + merger.setDestinationFileName(dest.getAbsolutePath()); + merger.mergeDocuments(null); + return dest.getAbsolutePath(); + } + /** * 创建ZIP文件 * diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingWorkloadService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingWorkloadService.java index a2f290b..430bae7 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingWorkloadService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingWorkloadService.java @@ -8,10 +8,20 @@ import com.ruoyi.system.domain.vo.DrawingWorkloadVo; public interface IDrawingWorkloadService { /** - * 从 FTP 下载「{令号}图纸数量统计表.xlsx」,解析 Sheet1/Sheet2 后入库(按项目覆盖)。 + * 从该令号对应目录下载「图纸数量统计表」并入库。目录中没有该表时工作量记为 0,不再用其它文件凑数。 */ DrawingWorkloadVo syncFromFtp(Long orderProId); + /** + * 本机对应令号目录已有图纸数量统计表时解析入库;没有文件则返回 null。 + */ + DrawingWorkloadVo syncFromLocalIfExists(Long orderProId); + + /** + * 批量从 FTP 读取图纸工作量。不因单条失败中断。 + */ + java.util.Map syncFromFtpBatch(java.util.Collection orderProIds); + /** * 查询已入库的图纸工作量(含明细)。 */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmPdfZipPushService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmPdfZipPushService.java index eb2dda6..c452c52 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmPdfZipPushService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmPdfZipPushService.java @@ -16,12 +16,18 @@ public interface IPlmPdfZipPushService { String savedDir, List> savedItems); /** - * 图纸 zip/批量推送时:若 process_order_pro 尚无该令号,则自动建项目(source=PLM)。 + * 图纸 zip/批量推送:校验 process_order_pro 已存在该令号,否则抛「生产令号不存在」。 + * 项目下尚无产品时,可补一条 figure_save(型号/名称/数量/类型/出图时间)。 * - * @return 新建返回项目 id;已存在返回已有 id;令号为空返回 null + * @return 已有项目 id */ Long ensureProcessOrderProForPlmPdf(String productionOrderNo); + /** + * 同上;可用本次推送明细推断主产品型号/名称/数量(优先 .00 总装图)。 + */ + Long ensureProcessOrderProForPlmPdf(String productionOrderNo, List> savedItems); + /** * 清理总令号目录中与子令号重复的图纸(保留子令号侧): *

    diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmProcessRoutePushService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmProcessRoutePushService.java index d92c062..1ae83d8 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmProcessRoutePushService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IPlmProcessRoutePushService.java @@ -9,7 +9,8 @@ import com.ruoyi.system.domain.bo.plm.PlmProcessRoutePushNormalizer; public interface IPlmProcessRoutePushService { /** - * 将 PLM 推送的 BOM、材料 BOM、工艺路线写入本地库(同令号先删后插)。 + * 将 PLM 推送的 BOM、材料 BOM、工艺路线写入本地库。 + * 已审核且总图号(物料编码以 .00 结尾)相同则拒绝;总图号不同则追加;未审核且相同则覆盖该总图。 */ PlmProcessRoutePushNormalizer.PlmProcessRoutePushSummary persist(PlmProcessRoutePushBo body); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java index 30e1425..3f5efa7 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java @@ -17,6 +17,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -52,6 +53,11 @@ public interface IProcessOrderProService { */ Boolean updateByBo(ProcessOrderProBo bo); + /** + * 批量设置项目状态(不校验设计人等整单字段) + */ + Boolean updateBomStatus(List ids, Long bomStatus); + /** * 清空项目关联数据(保留项目本身) */ @@ -72,6 +78,11 @@ public interface IProcessOrderProService { void addProduct( RigidChainModelDTO orderPro); + /** + * 设置项目出图模式(normal / part)。零部件模式下若已有产品且图纸路径不一致则拒绝。 + */ + Boolean updateDrawingMode(Long id, String drawingMode); + String executDrawing(ProcessOrderProBo orderPro); /** @@ -81,6 +92,16 @@ public interface IProcessOrderProService { */ R uploadPDF(Long id); + /** + * 下载流结束后清理本次出图临时目录(run_*),不影响其他人正在用的目录。 + */ + void cleanupPdfRunDir(String zipPath); + + /** + * 设计人员确认图纸已齐,允许下载 PDF(本批数量不落库,下载时按图纸推送明细取值)。 + */ + R> issueDrawingQty(Long id); + String uploadContractPDF(Integer id, String originalFilename, MultipartFile filePath); List getOverdueProjects(); @@ -124,4 +145,9 @@ public interface IProcessOrderProService { * 刷新并计算项目延期状态 */ void refreshDelayStatus(); + + /** + * 临时导入:按生产令号更新出图时间、项目完成时间、工艺完成时间、计划排产时间、BOM/物料更新、设计人 + */ + Map importDrawingByExcel(MultipartFile file); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessRouteService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessRouteService.java index 814cbfa..12e92bc 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessRouteService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessRouteService.java @@ -58,16 +58,19 @@ public interface IProcessRouteService { Boolean deleteWithValidByIds(Collection ids, Boolean isValid); Boolean updateOrInsertRoute(List bomDetailsVos); + /** * 保存数据 */ boolean saveData(List routeVoList, List productionOrderVos); + /** * 查询组焊件集合 */ List pushGroupWeldments(ProcessRoute processRoute); + /** * 获取所有的项目令号 */ @@ -82,11 +85,19 @@ public interface IProcessRouteService { * 查询工艺工序集合 */ List getProcessRoute(String rooteProdet); + /** * 推送工艺路线 */ - R pushRouteBom(String rooteProdet,String groupName); + R pushRouteBom(String rooteProdet, String groupName); + + /** + * 按物料推送工艺路线(不改项目整单状态,不影响全量推送)。 + * + * @param materialCodes 本项目下要推送的物料编码,不可空 + */ + R pushRouteBomByMaterial(String rooteProdet, String groupName, Collection materialCodes); /** * 获取所有的项目令号 @@ -94,14 +105,18 @@ public interface IProcessRouteService { List getSelecPlanRouteList(String rooteProdet); /** - * 生成这个项目的pdf + * 生成工序计划单:物料编码为空时按令号打全部 PDF 压缩包;有物料编码时只生成该物料一份 PDF。 + * + * @param materialCode 物料编码,可空 */ - String generatePDFs(String rooteProdet) throws IOException; + String generatePDFs(String rooteProdet, String materialCode) throws IOException; + /** * 更新计划时间 */ List updateProcessPlan(String rooteProdet) throws Exception; + /** * 获取所有项目 */ @@ -111,7 +126,7 @@ public interface IProcessRouteService { R addRoute(List list); - List getProcessMaterialList(String materialCode,String materialName, String productionOrderNo); + List getProcessMaterialList(String materialCode, String materialName, String productionOrderNo); List deleteRoute(ProcessRoute processRoute); @@ -122,18 +137,22 @@ public interface IProcessRouteService { List getSelectProcessRoute(String materilCode); List getSelectStandProcessRoute(String materilCode); + //根据项目令号删除 材料bom 总装bom 工艺路线 R delByProjectCode(String productionOrderNo); - List getProcessRouteList(String materialCode, String materialName, String productionOrderNo); + List getProcessRouteList(String materialCode, String materialName, String productionOrderNo); + //获取物料的非委外的工作时长 Double getFbWorkTime(BomDetails material); Double getFaWorkTime(BomDetails material); + //根据令号和物料编码 查询工艺路线 List getProcessRoutesByOrder(String productionOrderNo, String materialCode); + //根据物料编码查询工艺路线 - List getSingleWeght( String materialCode); + List getSingleWeght(String materialCode); List updateProcesTime(String rooteProdet) throws Exception; @@ -141,18 +160,19 @@ public interface IProcessRouteService { List getProcessMaterialListAll(String routeDescription); - List getProcessRouteGD(List list,String rooteProdet); + List getProcessRouteGD(List list, String rooteProdet); + //获取物料首个工序的工作中心 - String getRouteCode(String materialCode,String code); + String getRouteCode(String materialCode, String code); List selectByProjectNumber(String productionOrderNo); R viewGetBomUploadStatus(String rooteProdet); //根据令号和物料编码 查询工艺路线 - ProcessRoute getProcessRoutesXuTime(String productionOrderNo, String materialCode,String xu); + ProcessRoute getProcessRoutesXuTime(String productionOrderNo, String materialCode, String xu); - List updateProductionOrders(String rooteProdet,String cangKuNum) throws Exception; + List updateProductionOrders(String rooteProdet, String cangKuNum) throws Exception; List updateCgOrders(String rooteProdet, String cangKuNum) throws Exception; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProductionOrderService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProductionOrderService.java index 8f607c6..52e1258 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProductionOrderService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProductionOrderService.java @@ -99,7 +99,8 @@ public interface IProductionOrderService { Map resolveSingleWeightByMaterialCodes(Collection materialCodes); /** - * 接收 PLM 总装配 BOM JSON,按生产令号全量替换入库。 + * 接收 PLM 总装配 BOM JSON。 + * 已审核且主产品图号相同则拒绝;主产品图号不同则追加;未审核且相同则覆盖该主产品。 */ AssemblyBomReceiveSummary receiveAssemblyBom(List items); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBuildCapacityService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBuildCapacityService.java index e5f90d0..d0d0fb2 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBuildCapacityService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBuildCapacityService.java @@ -1,10 +1,16 @@ package com.ruoyi.system.service; import com.ruoyi.system.domain.dto.ratio.RatioBuildCapacityDTO; +import com.ruoyi.system.domain.dto.ratio.RatioModelOptionDTO; -import java.math.BigDecimal; +import java.util.List; public interface IRatioBuildCapacityService { - RatioBuildCapacityDTO queryByRatio(BigDecimal ratioValue, Integer qty); + + List listRatios(String seriesType); + + List listModels(String seriesType, String ratioValue); + + RatioBuildCapacityDTO queryByRatio(String seriesType, String ratioValue, Long modelId, Integer qty); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/KingdeeOrderReceiveService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/KingdeeOrderReceiveService.java index 1b36a48..a1255b5 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/KingdeeOrderReceiveService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/KingdeeOrderReceiveService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.ruoyi.common.config.DingTalkProperties; import com.ruoyi.common.dingding.robot.RobotGroupService; +import com.ruoyi.common.utils.ProjectStatusLock; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.domain.ProcessOrderPro; import com.ruoyi.system.domain.KingdeePrdMo; @@ -202,6 +203,7 @@ public class KingdeeOrderReceiveService { /** * 金蝶推送入库后,回写项目令号:有生产/采购明细则对应标志置 1(已建)。 + * 只有本次含生产订单才把项目状态改为开始生产;仅采购订单不改状态。已生产完成的不回退。 */ private void markProjectKingdeeOrderFlags(String productionOrderNo, int moCount, int poCount) { if (moCount <= 0 && poCount <= 0) { @@ -209,7 +211,7 @@ public class KingdeeOrderReceiveService { } ProcessOrderPro project = resolveProjectByProductionOrderNo(productionOrderNo); if (project == null) { - log.warn("金蝶订单接收:未找到生产令号对应项目,跳过标志更新。推送令号={},请确认 process_order_pro.production_order_no 与金蝶 F_HBYT_SCLH 一致或为父令号", + log.warn("金蝶订单接收:未找到生产令号对应项目,跳过标志与状态更新。推送令号={},请确认 process_order_pro.production_order_no 与金蝶令号完全一致", productionOrderNo); return; } @@ -221,42 +223,40 @@ public class KingdeeOrderReceiveService { if (poCount > 0) { uw.set(ProcessOrderPro::getNeedKingdeePurchaseOrder, 1); } + boolean alreadyDone = project.getBomStatus() != null + && project.getBomStatus() == ProjectStatusLock.PRODUCTION_DONE; + boolean startProduction = moCount > 0 && !alreadyDone; + if (startProduction) { + uw.set(ProcessOrderPro::getBomStatus, ProjectStatusLock.IN_PRODUCTION); + } int rows = processOrderProMapper.update(null, uw); if (rows <= 0) { log.warn("金蝶订单接收:更新项目标志影响行数为 0,项目 id={},令号={}", project.getId(), project.getProductionOrderNo()); return; } - if (!productionOrderNo.equals(project.getProductionOrderNo())) { - log.info("金蝶订单接收:推送令号 {} 已回写至父项目 {}(id={})", - productionOrderNo, project.getProductionOrderNo(), project.getId()); + log.info("金蝶订单接收:已更新项目 {}(id={})金蝶订单标志,生产 {} 条,采购 {} 条", + productionOrderNo, project.getId(), moCount, poCount); + if (alreadyDone) { + log.info("金蝶订单接收:项目 {} 已是生产完成,不回退开始生产", project.getProductionOrderNo()); + } else if (startProduction) { + log.info("金蝶订单接收:项目 {} 含生产订单,状态已更新为开始生产", project.getProductionOrderNo()); } else { - log.info("金蝶订单接收:已更新项目 {} 金蝶订单标志,生产 {} 条,采购 {} 条", - productionOrderNo, moCount, poCount); + log.info("金蝶订单接收:项目 {} 仅采购订单,不更新为开始生产", project.getProductionOrderNo()); } } /** - * 先精确匹配;若无则取「项目令号为金蝶令号前缀」的最长匹配(如 FB-26-038-FW-01 → FB-26-038-FW)。 + * 仅按生产令号精确匹配项目,找不到则不更新。 */ private ProcessOrderPro resolveProjectByProductionOrderNo(String productionOrderNo) { if (StringUtils.isBlank(productionOrderNo)) { return null; } - String key = productionOrderNo.trim(); - ProcessOrderPro exact = processOrderProMapper.selectOne( + return processOrderProMapper.selectOne( Wrappers.lambdaQuery() - .eq(ProcessOrderPro::getProductionOrderNo, key) + .eq(ProcessOrderPro::getProductionOrderNo, productionOrderNo.trim()) .last("LIMIT 1")); - if (exact != null) { - return exact; - } - List prefixMatches = processOrderProMapper.selectList( - Wrappers.lambdaQuery() - .apply("{0} LIKE CONCAT(production_order_no, '%')", key) - .ne(ProcessOrderPro::getProductionOrderNo, "") - .last("ORDER BY LENGTH(production_order_no) DESC LIMIT 1")); - return prefixMatches.isEmpty() ? null : prefixMatches.get(0); } private void notifyDingTalkGroup(List summaries, Date receiveTime) { @@ -275,13 +275,26 @@ public class KingdeeOrderReceiveService { markdown.append("> **接收时间**:").append(sdf.format(receiveTime)).append("\n\n"); markdown.append("> **项目数量**:").append(summaries.size()).append(" 个\n\n"); markdown.append("---\n\n"); + boolean hasProductionOrder = false; + boolean hasPurchaseOnly = false; for (OrderReceiveSummary summary : summaries) { markdown.append("> **生产令号**:").append(summary.productionOrderNo).append("\n\n"); markdown.append("> 生产订单 **").append(summary.moCount).append("** 条 · "); markdown.append("采购订单 **").append(summary.poCount).append("** 条\n\n"); + if (summary.moCount > 0) { + hasProductionOrder = true; + } else { + hasPurchaseOnly = true; + } } markdown.append("---\n\n"); - markdown.append("金蝶生产、采购订单已审核并下达,请相关人员关注。"); + if (hasProductionOrder && hasPurchaseOnly) { + markdown.append("含生产订单的项目状态已更新为开始生产;仅采购订单的项目状态不变。"); + } else if (hasProductionOrder) { + markdown.append("金蝶生产订单已审核并下达,项目状态已更新为开始生产;请相关人员关注。"); + } else { + markdown.append("金蝶采购订单已审核并下达;本次无生产订单,项目状态未改为开始生产。"); + } robotGroupService.sendMarkdown(conversationId, "金蝶订单接收通知", markdown.toString()); } catch (Exception e) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/MssqlQueryService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/MssqlQueryService.java index bdefbc5..c1309f4 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/MssqlQueryService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/MssqlQueryService.java @@ -7,10 +7,13 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.ImMaterial; +import com.ruoyi.system.domain.bo.ImMaterialBo; import com.ruoyi.system.domain.bo.KingdeeOrderTraceQueryBo; import com.ruoyi.system.domain.bo.KingdeeWorkCenterDataBo; import com.ruoyi.system.domain.dto.JjSubcontractTransferDetailDTO; import com.ruoyi.system.domain.dto.SubcontractOperationTransferRowDTO; +import com.ruoyi.system.domain.vo.ImMaterialVo; import java.math.BigDecimal; import java.math.RoundingMode; @@ -1222,5 +1225,238 @@ public class MssqlQueryService { } return result; } + + private static final String KINGDEE_MATERIAL_FROM = "" + + " FROM T_BD_MATERIAL m " + + " LEFT JOIN T_BD_MATERIAL_L ml ON m.FMATERIALID = ml.FMATERIALID AND ml.FLOCALEID = 2052 " + + " LEFT JOIN T_BD_MATERIALBASE b ON m.FMATERIALID = b.FMATERIALID " + + " LEFT JOIN T_BD_UNIT_L ul ON b.FBASEUNITID = ul.FUNITID AND ul.FLOCALEID = 2052 " + + " LEFT JOIN T_BD_MATERIALCATEGORY c ON b.FCATEGORYID = c.FCATEGORYID " + + " LEFT JOIN T_BD_MATERIALPURCHASE p ON m.FMATERIALID = p.FMATERIALID " + + " LEFT JOIN T_BAS_ASSISTANTDATAENTRY ass ON m.F_SVRI_ASSISTANT = ass.FENTRYID " + + " LEFT JOIN T_BAS_ASSISTANTDATAENTRY_L assl ON ass.FENTRYID = assl.FENTRYID AND assl.FLOCALEID = 2052 "; + + private static final String KINGDEE_MATERIAL_SELECT = "" + + "SELECT m.FMATERIALID AS materialId, m.FNUMBER AS materialCode, ml.FNAME AS materialName, " + + "COALESCE(NULLIF(LTRIM(RTRIM(ml.FSPECIFICATION)), ''), assl.FDATAVALUE) AS materialQuality, " + + "m.F_HBYT_DZ AS singleWeight, ul.FNAME AS unit, " + + "c.FNUMBER AS imCategory, b.FERPCLSID AS classificationNumber, " + + "COALESCE(p.FISVMIBUSINESS, b.FISVMIBUSINESS) AS isVmi, " + + "m.FMODIFYDATE AS modifyDate, m.FFORBIDSTATUS AS forbidStatus "; + + /** + * 分页查询金蝶物料(T_BD_MATERIAL),仅未禁用。 + */ + @DS("sqlserver") + public KingdeeMaterialPage queryMaterialPage(ImMaterialBo bo, int pageNum, int pageSize, boolean onlyEnabled) { + List args = new ArrayList<>(); + String where = buildKingdeeMaterialWhere(bo, onlyEnabled, args); + Long total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) " + KINGDEE_MATERIAL_FROM + where, args.toArray(), Long.class); + int offset = Math.max(pageNum - 1, 0) * pageSize; + List pageArgs = new ArrayList<>(args); + pageArgs.add(offset); + pageArgs.add(pageSize); + List> rows = jdbcTemplate.queryForList( + KINGDEE_MATERIAL_SELECT + KINGDEE_MATERIAL_FROM + where + + " ORDER BY m.FMODIFYDATE DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY", + pageArgs.toArray()); + KingdeeMaterialPage page = new KingdeeMaterialPage(); + page.setTotal(total != null ? total : 0L); + page.setRecords(mapKingdeeMaterialVos(rows)); + return page; + } + + @DS("sqlserver") + public List queryMaterialList(ImMaterialBo bo, int limit, boolean onlyEnabled) { + List args = new ArrayList<>(); + String where = buildKingdeeMaterialWhere(bo, onlyEnabled, args); + args.add(Math.max(limit, 1)); + List> rows = jdbcTemplate.queryForList( + KINGDEE_MATERIAL_SELECT + KINGDEE_MATERIAL_FROM + where + + " ORDER BY m.FMODIFYDATE DESC OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY", + args.toArray()); + return mapKingdeeMaterialVos(rows); + } + + @DS("sqlserver") + public KingdeeMaterialPage queryMaterialByKeyword(String keyword, int pageNum, int pageSize) { + List args = new ArrayList<>(); + StringBuilder where = new StringBuilder(" WHERE m.FFORBIDSTATUS = 'A' "); + if (StringUtils.isNotBlank(keyword)) { + where.append(" AND (m.FNUMBER LIKE ? OR ml.FNAME LIKE ?) "); + String like = "%" + keyword.trim() + "%"; + args.add(like); + args.add(like); + } + Long total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) " + KINGDEE_MATERIAL_FROM + where, args.toArray(), Long.class); + int offset = Math.max(pageNum - 1, 0) * pageSize; + List pageArgs = new ArrayList<>(args); + pageArgs.add(offset); + pageArgs.add(pageSize); + List> rows = jdbcTemplate.queryForList( + KINGDEE_MATERIAL_SELECT + KINGDEE_MATERIAL_FROM + where + + " ORDER BY m.FMODIFYDATE DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY", + pageArgs.toArray()); + KingdeeMaterialPage page = new KingdeeMaterialPage(); + page.setTotal(total != null ? total : 0L); + page.setRecords(mapKingdeeMaterialVos(rows)); + return page; + } + + @DS("sqlserver") + public ImMaterial findMaterialById(Long materialId) { + if (materialId == null) { + return null; + } + List> rows = jdbcTemplate.queryForList( + KINGDEE_MATERIAL_SELECT + KINGDEE_MATERIAL_FROM + " WHERE m.FMATERIALID = ?", materialId); + return rows.isEmpty() ? null : mapKingdeeMaterial(rows.get(0)); + } + + @DS("sqlserver") + public ImMaterial findMaterialByCode(String materialCode) { + if (StringUtils.isBlank(materialCode)) { + return null; + } + List> rows = jdbcTemplate.queryForList( + KINGDEE_MATERIAL_SELECT + KINGDEE_MATERIAL_FROM + + " WHERE m.FNUMBER = ? ORDER BY CASE WHEN m.FFORBIDSTATUS = 'A' THEN 0 ELSE 1 END, m.FMODIFYDATE DESC", + materialCode.trim()); + return rows.isEmpty() ? null : mapKingdeeMaterial(rows.get(0)); + } + + @DS("sqlserver") + public ImMaterial findMaterialByCodeAndName(String materialCode, String materialName) { + if (StringUtils.isBlank(materialCode) || StringUtils.isBlank(materialName)) { + return null; + } + List> rows = jdbcTemplate.queryForList( + KINGDEE_MATERIAL_SELECT + KINGDEE_MATERIAL_FROM + + " WHERE m.FNUMBER = ? AND ml.FNAME = ?" + + " ORDER BY CASE WHEN m.FFORBIDSTATUS = 'A' THEN 0 ELSE 1 END, m.FMODIFYDATE DESC", + materialCode.trim(), materialName.trim()); + return rows.isEmpty() ? null : mapKingdeeMaterial(rows.get(0)); + } + + private String buildKingdeeMaterialWhere(ImMaterialBo bo, boolean onlyEnabled, List args) { + StringBuilder where = new StringBuilder(" WHERE 1=1 "); + if (onlyEnabled) { + where.append(" AND m.FFORBIDSTATUS = 'A' "); + } + if (bo == null) { + return where.toString(); + } + appendLike(where, args, "m.FNUMBER", bo.getMaterialCode()); + appendLike(where, args, "ml.FNAME", bo.getMaterialName()); + if (StringUtils.isNotBlank(bo.getMaterialQuality())) { + where.append(" AND (ml.FSPECIFICATION LIKE ? OR assl.FDATAVALUE LIKE ?) "); + String qualityLike = "%" + bo.getMaterialQuality().trim() + "%"; + args.add(qualityLike); + args.add(qualityLike); + } + appendLike(where, args, "ul.FNAME", bo.getUnit()); + appendLike(where, args, "c.FNUMBER", bo.getImCategory()); + if (bo.getSingleWeight() != null) { + where.append(" AND m.F_HBYT_DZ = ? "); + args.add(bo.getSingleWeight()); + } + if (StringUtils.isNotBlank(bo.getClassificationName())) { + String vmi = bo.getClassificationName().trim(); + if ("true".equalsIgnoreCase(vmi) || "1".equals(vmi)) { + where.append(" AND COALESCE(p.FISVMIBUSINESS, b.FISVMIBUSINESS) IN (1, '1', 'true') "); + } else if ("false".equalsIgnoreCase(vmi) || "0".equals(vmi)) { + where.append(" AND ISNULL(COALESCE(p.FISVMIBUSINESS, b.FISVMIBUSINESS), 0) IN (0, '0', 'false') "); + } + } + return where.toString(); + } + + private static void appendLike(StringBuilder where, List args, String column, String value) { + if (StringUtils.isBlank(value)) { + return; + } + where.append(" AND ").append(column).append(" LIKE ? "); + args.add("%" + value.trim() + "%"); + } + + private List mapKingdeeMaterialVos(List> rows) { + List list = new ArrayList<>(); + if (rows == null) { + return list; + } + for (Map row : rows) { + ImMaterial entity = mapKingdeeMaterial(row); + ImMaterialVo vo = new ImMaterialVo(); + vo.setId(entity.getId()); + vo.setMaterialCode(entity.getMaterialCode()); + vo.setMaterialName(entity.getMaterialName()); + vo.setMaterialQuality(entity.getMaterialQuality()); + vo.setSingleWeight(entity.getSingleWeight()); + vo.setUnit(entity.getUnit()); + vo.setImCategory(entity.getImCategory()); + vo.setClassificationName(entity.getClassificationName()); + vo.setClassificationNumber(entity.getClassificationNumber()); + list.add(vo); + } + return list; + } + + private ImMaterial mapKingdeeMaterial(Map row) { + ImMaterial entity = new ImMaterial(); + String materialId = emptyToNull(getString(row, "materialId")); + if (materialId != null) { + entity.setMaterialId(materialId); + try { + entity.setId(Long.valueOf(materialId)); + } catch (NumberFormatException ignored) { + // 金蝶内码无法作为本地主键时忽略 + } + } + entity.setMaterialCode(emptyToNull(getString(row, "materialCode"))); + entity.setMaterialName(emptyToNull(getString(row, "materialName"))); + entity.setMaterialQuality(emptyToNull(getString(row, "materialQuality"))); + entity.setSingleWeight(getBigDecimalNullable(row, "singleWeight")); + entity.setUnit(emptyToNull(getString(row, "unit"))); + entity.setImCategory(emptyToNull(getString(row, "imCategory"))); + entity.setClassificationNumber(emptyToNull(getString(row, "classificationNumber"))); + entity.setClassificationName(toVmiFlag(getCell(row, "isVmi"))); + Object modifyDate = getCell(row, "modifyDate"); + if (modifyDate != null) { + entity.setModifyDate(modifyDate.toString()); + } + entity.setForbidStatus(emptyToNull(getString(row, "forbidStatus"))); + return entity; + } + + private BigDecimal getBigDecimalNullable(Map row, String key) { + Object val = getCell(row, key); + if (val == null) { + return null; + } + try { + return new BigDecimal(val.toString()); + } catch (Exception e) { + return null; + } + } + + private static String toVmiFlag(Object val) { + if (val == null) { + return "false"; + } + String s = val.toString().trim(); + if ("1".equals(s) || "true".equalsIgnoreCase(s)) { + return "true"; + } + return "false"; + } + + @lombok.Data + public static class KingdeeMaterialPage { + private long total; + private List records; + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ProcessPlanExcelPushService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ProcessPlanExcelPushService.java index 45dc9b6..41e7648 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ProcessPlanExcelPushService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ProcessPlanExcelPushService.java @@ -4,6 +4,7 @@ import com.ruoyi.common.config.DingTalkProperties; import com.ruoyi.common.dingding.media.MediaService; import com.ruoyi.common.dingding.robot.RobotGroupService; import com.ruoyi.common.utils.StringUtils; +import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -19,7 +20,7 @@ import java.nio.file.StandardCopyOption; /** * 工艺计划表:落盘到服务器固定目录,并按 dingtalk.plan-user-id 推送钉钉文件。 *

    - * 手动上传计划表、PLM 推送工艺后导出,共用同一套目录与接收人。 + * 手动上传计划表、PLM 总装与工艺到齐后导出,共用同一套目录与接收人。 */ @Slf4j @Service @@ -61,19 +62,29 @@ public class ProcessPlanExcelPushService { /** * 按配置 dingtalk.plan-user-id(逗号分隔)私发文件,与手动上传计划表同一接收人逻辑。 */ - public void sendToPlanUsers(File file, String displayFileName) { + public FilePushResult sendToPlanUsers(File file, String displayFileName) { + FilePushResult result = new FilePushResult(); + result.setUserIds(dingTalkProperties.getPlanUserId()); if (file == null || !file.exists() || file.length() <= 0) { log.warn("推送计划表跳过:文件不存在或为空 file={}", file); - return; + result.setSkipped(true); + result.setSkipReason("文件不存在或为空"); + result.setErrorMsg(result.getSkipReason()); + return result; } String planUserIds = dingTalkProperties.getPlanUserId(); if (StringUtils.isBlank(planUserIds)) { log.warn("未配置钉钉工艺计划接收人(plan-user-id),跳过发送"); - return; + result.setSkipped(true); + result.setSkipReason("未配置 plan-user-id"); + result.setErrorMsg(result.getSkipReason()); + return result; } String name = StringUtils.isNotBlank(displayFileName) ? displayFileName : file.getName(); + result.setFileName(name); try { String mediaId = mediaService.uploadMedia("file", file.getAbsolutePath()); + result.setMediaId(mediaId); for (String userId : planUserIds.split(",")) { String cleanUserId = userId == null ? "" : userId.trim(); if (StringUtils.isBlank(cleanUserId)) { @@ -81,14 +92,92 @@ public class ProcessPlanExcelPushService { } try { robotGroupService.sendFileToUser(cleanUserId, mediaId, name); + result.setSuccessCount(result.getSuccessCount() + 1); log.info("工艺计划表已发送给用户: {} file={}", cleanUserId, name); } catch (Exception e) { + result.setFailCount(result.getFailCount() + 1); + result.setErrorMsg(appendError(result.getErrorMsg(), cleanUserId + ": " + e.getMessage())); log.error("发送工艺计划表给用户 {} 失败", cleanUserId, e); } } } catch (Exception e) { + result.setFailCount(result.getFailCount() + 1); + result.setErrorMsg(appendError(result.getErrorMsg(), e.getMessage())); log.error("上传/推送工艺计划表失败 file={}", file.getAbsolutePath(), e); } + return result; + } + + /** + * 按配置 dingtalk.notify-user-id(逗号分隔)私发文件,与出图完成发送计划表同一接收人。 + */ + public FilePushResult sendToNotifyUsers(File file, String displayFileName) { + FilePushResult result = new FilePushResult(); + result.setUserIds(dingTalkProperties.getNotifyUserId()); + if (file == null || !file.exists() || file.length() <= 0) { + log.warn("推送计划表给 notify-user-id 跳过:文件不存在或为空 file={}", file); + result.setSkipped(true); + result.setSkipReason("文件不存在或为空"); + result.setErrorMsg(result.getSkipReason()); + return result; + } + String notifyUserIds = dingTalkProperties.getNotifyUserId(); + if (StringUtils.isBlank(notifyUserIds)) { + log.warn("未配置钉钉出图完成接收人(notify-user-id),跳过发送"); + result.setSkipped(true); + result.setSkipReason("未配置 notify-user-id"); + result.setErrorMsg(result.getSkipReason()); + return result; + } + String name = StringUtils.isNotBlank(displayFileName) ? displayFileName : file.getName(); + result.setFileName(name); + try { + String mediaId = mediaService.uploadMedia("file", file.getAbsolutePath()); + result.setMediaId(mediaId); + robotGroupService.sendFileToUser(notifyUserIds, mediaId, name); + result.setSuccessCount(1); + log.info("工艺计划表已发送给 notify-user-id file={}", name); + } catch (Exception e) { + result.setFailCount(1); + result.setErrorMsg(e.getMessage()); + log.error("上传/推送工艺计划表给 notify-user-id 失败 file={}", file.getAbsolutePath(), e); + } + return result; + } + + private static String appendError(String existing, String next) { + if (StringUtils.isBlank(next)) { + return existing; + } + if (StringUtils.isBlank(existing)) { + return next; + } + return existing + "; " + next; + } + + @Data + public static class FilePushResult { + private boolean skipped; + private String skipReason; + private String fileName; + private String mediaId; + private String userIds; + private int successCount; + private int failCount; + private String errorMsg; + + public String resolveStatus() { + if (skipped) { + return "FAIL"; + } + if (failCount > 0 && successCount > 0) { + return "PARTIAL"; + } + if (failCount > 0 || successCount <= 0) { + return "FAIL"; + } + return "SUCCESS"; + } } public String getPlanExcelBaseDir() { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/WorkAlertPushScheduleService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/WorkAlertPushScheduleService.java index 30e2399..3f9bc04 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/WorkAlertPushScheduleService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/WorkAlertPushScheduleService.java @@ -3,9 +3,12 @@ package com.ruoyi.system.service; import com.ruoyi.common.config.DingTalkProperties; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.dingding.util.DingUtil; +import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.controller.KingdeeWorkCenterDataController; +import com.ruoyi.system.domain.WorkAlertGroupPushConfig; import com.ruoyi.system.domain.WorkAlertPushConfig; +import com.ruoyi.system.mapper.WorkAlertGroupPushConfigMapper; import com.ruoyi.system.mapper.WorkAlertPushConfigMapper; import com.ruoyi.system.service.support.WorkAlertPushDeliveryHolder; import com.ruoyi.system.service.support.WorkAlertPushRecipientRulesHelper; @@ -37,6 +40,7 @@ public class WorkAlertPushScheduleService { private static final DateTimeFormatter HH_MM = DateTimeFormatter.ofPattern("HH:mm"); private final WorkAlertPushConfigMapper configMapper; + private final WorkAlertGroupPushConfigMapper groupConfigMapper; private final KingdeeWorkCenterDataController kingdeeWorkCenterDataController; private final DingTalkProperties dingTalkProperties; @@ -48,11 +52,34 @@ public class WorkAlertPushScheduleService { LocalDate today = LocalDate.now(); List configs = configMapper.selectList(null); + if (configs != null) { + for (WorkAlertPushConfig config : configs) { + if (!isEnabled(config)) { + continue; + } + if (!nowTime.equals(normalizePushTime(config.getPushTime()))) { + continue; + } + if (alreadyPushedToday(config.getLastPushDate(), today)) { + continue; + } + try { + executePush(config, false); + } catch (Exception e) { + log.error("定时推送失败 pushCode={} id={}", config.getPushCode(), config.getId(), e); + } + } + } + runDueGroupPushes(nowTime, today); + } + + private void runDueGroupPushes(String nowTime, LocalDate today) { + List configs = groupConfigMapper.selectList(null); if (configs == null || configs.isEmpty()) { return; } - for (WorkAlertPushConfig config : configs) { - if (!isEnabled(config)) { + for (WorkAlertGroupPushConfig config : configs) { + if (!isEnabled(config.getEnabled())) { continue; } if (!nowTime.equals(normalizePushTime(config.getPushTime()))) { @@ -62,13 +89,61 @@ public class WorkAlertPushScheduleService { continue; } try { - executePush(config, false); + executeGroupSchedule(config, false); } catch (Exception e) { - log.error("定时推送失败 pushCode={} id={}", config.getPushCode(), config.getId(), e); + log.error("指定群定时推送失败 id={} conversationId={}", config.getId(), config.getOpenConversationId(), e); } } } + /** + * 按保存的群定时配置推送(可多任务)。不走私发。 + */ + public void executeGroupSchedule(WorkAlertGroupPushConfig config, boolean manual) { + if (config == null) { + throw new ServiceException("配置为空"); + } + if (!manual && !isEnabled(config.getEnabled())) { + throw new ServiceException("配置未启用"); + } + List codes = parsePushCodes(config.getPushCodes()); + if (codes.isEmpty()) { + throw new ServiceException("未配置推送任务"); + } + log.info("开始指定群定时推送 id={} manual={} time={} conversationId={} codes={}", + config.getId(), manual, config.getPushTime(), config.getOpenConversationId(), codes.size()); + int ok = 0; + int fail = 0; + StringBuilder failMsg = new StringBuilder(); + for (String code : codes) { + try { + executeGroupPush(code, config.getOpenConversationId(), config.getRobotCode()); + ok++; + } catch (Exception e) { + fail++; + log.error("指定群定时推送单项失败 id={} pushCode={}", config.getId(), code, e); + if (failMsg.length() > 0) { + failMsg.append(";"); + } + failMsg.append(code).append(": ").append(e.getMessage()); + } + } + if (ok > 0 || fail == 0) { + WorkAlertGroupPushConfig update = new WorkAlertGroupPushConfig(); + update.setId(config.getId()); + update.setLastPushDate(Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant())); + groupConfigMapper.updateById(update); + } + log.info("指定群定时推送结束 id={} ok={} fail={}", config.getId(), ok, fail); + if (fail > 0) { + String msg = "推送完成:成功 " + ok + ",失败 " + fail + "。" + failMsg; + if (manual) { + throw new ServiceException(msg); + } + log.warn("指定群定时推送部分失败 id={} {}", config.getId(), msg); + } + } + public void executePush(WorkAlertPushConfig config, boolean manual) { if (config == null) { throw new IllegalArgumentException("配置为空"); @@ -114,6 +189,39 @@ public class WorkAlertPushScheduleService { log.info("预警推送完成 pushCode={} id={}", config.getPushCode(), config.getId()); } + /** + * 将与预警推送配置相同的数据发到指定钉钉群。不走私发、不影响配置表上次推送日期。 + */ + public void executeGroupPush(String pushCode, String conversationId, String robotCode) { + if (!WorkAlertPushTypeRegistry.isSupported(pushCode)) { + throw new ServiceException("不支持的推送编码:" + pushCode); + } + if (!WorkAlertPushTypeRegistry.supportsGroupConversation(pushCode)) { + throw new ServiceException("该任务不支持指定群会话ID:" + pushCode); + } + if (StringUtils.isBlank(conversationId)) { + throw new ServiceException("群会话ID不能为空"); + } + String resolvedRobot = StringUtils.isNotBlank(robotCode) + ? robotCode.trim() + : dingTalkProperties.getDefaultAlertPushRobotCode(); + String cid = conversationId.trim(); + log.info("指定群预警推送 pushCode={} conversationId={} robotCode={}", pushCode, cid, resolvedRobot); + try { + R result = invokePush(pushCode, cid, null, resolvedRobot); + if (result == null || !R.isSuccess(result)) { + String msg = result == null ? "无返回" : result.getMsg(); + throw new ServiceException("推送执行失败:" + msg); + } + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("指定群预警推送失败 pushCode={}", pushCode, e); + throw new ServiceException("推送执行失败:" + e.getMessage()); + } + log.info("指定群预警推送完成 pushCode={}", pushCode); + } + private R invokePush(String pushCode, String conversationId, String primaryUserId, String robotCode) { switch (pushCode) { case "getKuCun": @@ -222,8 +330,23 @@ public class WorkAlertPushScheduleService { .collect(Collectors.toList()); } + static List parsePushCodes(String raw) { + if (StringUtils.isBlank(raw)) { + return new ArrayList<>(); + } + return Arrays.stream(raw.split("[,,;;\\s]+")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + } + private static boolean isEnabled(WorkAlertPushConfig config) { - return config != null && "1".equals(StringUtils.trim(config.getEnabled())); + return config != null && isEnabled(config.getEnabled()); + } + + private static boolean isEnabled(String enabled) { + return "1".equals(StringUtils.trim(enabled)); } private static String normalizePushTime(String pushTime) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingWorkloadServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingWorkloadServiceImpl.java index e2b086b..3581d08 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingWorkloadServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingWorkloadServiceImpl.java @@ -71,12 +71,74 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { } String productionCode = orderPro.getProductionOrderNo().trim(); String localFile = downloadDrawingCountExcel(productionCode); + if (StringUtils.isBlank(localFile)) { + log.info("令号 {} 对应目录未找到图纸数量统计表,工作量记为0", productionCode); + return saveEmptyWorkload(orderPro); + } ParsedExcel parsed = parseExcel(localFile); DrawingWorkload saved = saveWorkload(orderPro, parsed); return toVo(saved, true); } + @Override + @Transactional(rollbackFor = Exception.class) + public DrawingWorkloadVo syncFromLocalIfExists(Long orderProId) { + ProcessOrderPro orderPro = processOrderProMapper.selectById(orderProId); + if (orderPro == null || StringUtils.isBlank(orderPro.getProductionOrderNo())) { + return null; + } + File localFile = findLocalDrawingCountExcel(orderPro.getProductionOrderNo().trim()); + if (localFile == null) { + return null; + } + ParsedExcel parsed = parseExcel(localFile.getAbsolutePath()); + DrawingWorkload saved = saveWorkload(orderPro, parsed); + return toVo(saved, true); + } + + @Override + public Map syncFromFtpBatch(java.util.Collection orderProIds) { + Map result = new HashMap<>(); + int success = 0; + int fail = 0; + int skip = 0; + List failDetails = new ArrayList<>(); + if (orderProIds == null || orderProIds.isEmpty()) { + result.put("success", 0); + result.put("fail", 0); + result.put("skip", 0); + result.put("failDetails", failDetails); + result.put("message", "没有可读取的项目"); + return result; + } + for (Long id : orderProIds) { + if (id == null) { + skip++; + continue; + } + try { + DrawingWorkloadVo local = syncFromLocalIfExists(id); + if (local != null && (local.getA4Count() != null || local.getEquivalentA4Total() != null)) { + success++; + continue; + } + syncFromFtp(id); + success++; + } catch (Exception e) { + fail++; + failDetails.add("项目ID " + id + ":" + e.getMessage()); + log.warn("批量读取图纸工作量失败 orderProId={}", id, e); + } + } + result.put("success", success); + result.put("fail", fail); + result.put("skip", skip); + result.put("failDetails", failDetails); + result.put("message", "读取完成:成功" + success + "条,失败" + fail + "条"); + return result; + } + @Override public DrawingWorkloadVo getByOrderProId(Long orderProId) { DrawingWorkload row = workloadMapper.selectOne(Wrappers.lambdaQuery() @@ -92,39 +154,63 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { String year = ProductionOrderNoUtils.extractYear(productionCode); String preferredName = productionCode + "图纸数量统计表.xlsx"; String fallbackName = "图纸数量统计表.xlsx"; - String localPreferred = LOCAL_PATH + preferredName; + File preferred = new File(LOCAL_PATH + preferredName); + File fallback = new File(LOCAL_PATH + fallbackName); String ftpPath = FtpUtil.buildDrawingExcelRemoteDir(drawingBasePath, year, productionCode); log.info("图纸数量统计表 FTP 下载路径: {} (basePath={})", ftpPath, drawingBasePath); - R result = tryDownloadExcel(ftpPath, preferredName, preferredName); - if (R.isError(result)) { - result = tryDownloadExcel(ftpPath, fallbackName, preferredName); + StringBuilder lastMsg = new StringBuilder(); + if (downloadOwnedExcel(ftpPath, preferredName, fallbackName, preferred, fallback, lastMsg)) { + return preferred.getAbsolutePath(); } - if (R.isError(result)) { - String noDrivePath = FtpUtil.buildDrawingExcelRemoteDir("", year, productionCode); - if (!noDrivePath.equals(ftpPath)) { - log.info("尝试无盘符 FTP 路径: {}", noDrivePath); - result = tryDownloadExcel(noDrivePath, preferredName, preferredName); - if (R.isError(result)) { - result = tryDownloadExcel(noDrivePath, fallbackName, preferredName); - } - if (R.isSuccess(result)) { - ftpPath = noDrivePath; - } + String noDrivePath = FtpUtil.buildDrawingExcelRemoteDir("", year, productionCode); + if (!noDrivePath.equals(ftpPath)) { + log.info("尝试无盘符 FTP 路径: {}", noDrivePath); + if (downloadOwnedExcel(noDrivePath, preferredName, fallbackName, preferred, fallback, lastMsg)) { + return preferred.getAbsolutePath(); } + ftpPath = noDrivePath; + } + log.info("项目 {} 对应目录未找到图纸数量统计表,FTP路径={},期望文件名={} 或 {},{}", + productionCode, ftpPath, preferredName, fallbackName, lastMsg); + return null; + } + + /** + * 只接受本次从该令号目录真正下载到的文件,禁止把 D:\file 里其它令号的统计表改名冒充当前项目。 + */ + private boolean downloadOwnedExcel(String ftpPath, String preferredName, String fallbackName, + File preferred, File fallback, StringBuilder lastMsg) { + Snapshot preferredSnap = snapshot(preferred); + R result = tryDownloadExcel(ftpPath, preferredName, preferredName); + rememberMsg(lastMsg, result); + if (touchedSince(preferred, preferredSnap)) { + return true; } - // 目录扫描可能按远程原名落盘,统一落到 preferred 本地名 - ensureLocalPreferredName(preferredName, fallbackName); - - File file = new File(localPreferred); - if (!file.exists() || file.length() == 0) { - throw new ServiceException("项目 " + productionCode + " 图纸数量统计表下载失败或不存在,FTP路径=" - + ftpPath + ",期望文件名=" + preferredName + " 或 " + fallbackName + "," - + (result == null ? "" : result.getMsg())); + Snapshot fallbackSnap = snapshot(fallback); + result = tryDownloadExcel(ftpPath, fallbackName, preferredName); + rememberMsg(lastMsg, result); + if (touchedSince(preferred, preferredSnap)) { + return true; } - return file.getAbsolutePath(); + if (touchedSince(fallback, fallbackSnap)) { + renameToPreferred(fallback, preferred); + return true; + } + if (R.isSuccess(result)) { + log.warn("FTP 返回成功但未拿到当前令号图纸数量统计表 path={} msg={}", ftpPath, result.getMsg()); + } + return false; + } + + private static void rememberMsg(StringBuilder lastMsg, R result) { + if (lastMsg == null || result == null || StringUtils.isBlank(result.getMsg())) { + return; + } + lastMsg.setLength(0); + lastMsg.append(result.getMsg()); } private R tryDownloadExcel(String ftpPath, String remoteName, String localName) { @@ -138,40 +224,66 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { return result; } - private void ensureLocalPreferredName(String preferredName, String fallbackName) { - File preferred = new File(LOCAL_PATH + preferredName); - if (preferred.exists() && preferred.length() > 0) { - return; + private void renameToPreferred(File source, File preferred) { + if (preferred.exists() && !preferred.delete()) { + throw new ServiceException("图纸数量统计表本地文件覆盖失败"); } - File fallback = new File(LOCAL_PATH + fallbackName); - if (fallback.exists() && fallback.length() > 0) { - if (preferred.exists()) { - //noinspection ResultOfMethodCallIgnored - preferred.delete(); - } - if (!fallback.renameTo(preferred)) { - throw new ServiceException("图纸数量统计表本地文件重命名失败"); - } - return; + if (!source.renameTo(preferred)) { + throw new ServiceException("图纸数量统计表本地文件重命名失败: " + source.getName()); } - File dir = new File(LOCAL_PATH); - File[] matches = dir.listFiles((d, name) -> - name != null && name.contains("图纸数量统计表") && name.toLowerCase().endsWith(".xlsx")); - if (matches == null || matches.length == 0) { - return; + } + + /** + * 只认该令号自己的目录里的表。D:\file 是所有项目共用缓存,不能当“共享盘上有这份表”的证据。 + */ + private File findLocalDrawingCountExcel(String productionCode) { + String preferredName = productionCode + "图纸数量统计表.xlsx"; + String fallbackName = "图纸数量统计表.xlsx"; + List candidates = new ArrayList<>(); + try { + String year = ProductionOrderNoUtils.extractYear(productionCode); + File shareDir = new File(drawingBasePath, year + File.separator + productionCode); + candidates.add(new File(shareDir, preferredName)); + candidates.add(new File(shareDir, fallbackName)); + File dDriveDir = new File("D:" + File.separator + year + File.separator + productionCode); + candidates.add(new File(dDriveDir, preferredName)); + candidates.add(new File(dDriveDir, fallbackName)); + } catch (Exception e) { + log.debug("令号 {} 无法解析年份,跳过本机图纸数量统计表查找", productionCode); } - File best = matches[0]; - for (File f : matches) { - if (f.lastModified() > best.lastModified()) { - best = f; + for (File file : candidates) { + if (isUsable(file)) { + return file; } } - if (preferred.exists()) { - //noinspection ResultOfMethodCallIgnored - preferred.delete(); + return null; + } + + private static boolean isUsable(File file) { + return file != null && file.isFile() && file.length() > 0; + } + + private static Snapshot snapshot(File file) { + if (!isUsable(file)) { + return new Snapshot(-1L, -1L); } - if (!best.getName().equals(preferredName) && !best.renameTo(preferred)) { - throw new ServiceException("图纸数量统计表本地文件重命名失败: " + best.getName()); + return new Snapshot(file.lastModified(), file.length()); + } + + private static boolean touchedSince(File file, Snapshot before) { + if (!isUsable(file)) { + return false; + } + return file.lastModified() > before.modified || file.length() != before.length; + } + + private static final class Snapshot { + private final long modified; + private final long length; + + private Snapshot(long modified, long length) { + this.modified = modified; + this.length = length; } } @@ -224,17 +336,17 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { } Map col = mapHeaderColumns(header); DrawingWorkload row = new DrawingWorkload(); - row.setA4Count(readInt(data, col, "A4图纸数量")); - row.setA3Count(readInt(data, col, "A3图纸数量")); - row.setA3HalfCount(readInt(data, col, "A3+1/2图纸数量")); - row.setA3Plus1Count(readInt(data, col, "A3+1图纸数量")); - row.setA2Count(readInt(data, col, "A2图纸数量")); - row.setA2HalfCount(readInt(data, col, "A2+1/2图纸数量")); - row.setA1Count(readInt(data, col, "A1图纸数量")); - row.setA1HalfCount(readInt(data, col, "A1+1/2图纸数量")); - row.setA0Count(readInt(data, col, "A0图纸数量")); - row.setA0HalfCount(readInt(data, col, "A0+1/2图纸数量")); - row.setEquivalentA4Total(readInt(data, col, "等效A4图纸总数量")); + row.setA4Count(readInt(data, col, "A4图纸数量", "A4图纸数", "A4数量")); + row.setA3Count(readInt(data, col, "A3图纸数量", "A3图纸数", "A3数量")); + row.setA3HalfCount(readInt(data, col, "A3+1/2图纸数量", "A3+1/2图纸数")); + row.setA3Plus1Count(readInt(data, col, "A3+1图纸数量", "A3+1图纸数")); + row.setA2Count(readInt(data, col, "A2图纸数量", "A2图纸数")); + row.setA2HalfCount(readInt(data, col, "A2+1/2图纸数量", "A2+1/2图纸数")); + row.setA1Count(readInt(data, col, "A1图纸数量", "A1图纸数")); + row.setA1HalfCount(readInt(data, col, "A1+1/2图纸数量", "A1+1/2图纸数")); + row.setA0Count(readInt(data, col, "A0图纸数量", "A0图纸数")); + row.setA0HalfCount(readInt(data, col, "A0+1/2图纸数量", "A0+1/2图纸数")); + row.setEquivalentA4Total(readInt(data, col, "等效A4图纸总数量", "等效A4图纸总量", "等效A4合计", "等效A4")); return row; } @@ -277,6 +389,7 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { private int findHeaderRow(Sheet sheet, String... keywords) { int last = Math.min(sheet.getLastRowNum(), 20); + int fallback = -1; for (int r = 0; r <= last; r++) { Row row = sheet.getRow(r); if (row == null) { @@ -287,10 +400,10 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { for (int c = 0; c < lastCell; c++) { sb.append(cellText(row.getCell(c))).append('|'); } - String line = sb.toString(); + String line = sb.toString().replace(" ", ""); boolean allMatch = true; for (String kw : keywords) { - if (!line.contains(kw)) { + if (!line.contains(kw.replace(" ", ""))) { allMatch = false; break; } @@ -298,8 +411,11 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { if (allMatch) { return r; } + if (fallback < 0 && (line.contains("等效A4") || line.contains("A4图纸数量") || line.contains("A4图纸数"))) { + fallback = r; + } } - return -1; + return fallback; } private Map mapHeaderColumns(Row header) { @@ -308,20 +424,44 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { for (int c = 0; c < last; c++) { String text = cellText(header.getCell(c)); if (StringUtils.isNotBlank(text)) { - map.put(text.trim(), c); + String trimmed = text.trim(); + map.put(trimmed, c); + map.put(trimmed.replace(" ", ""), c); } } return map; } - private Integer readInt(Row row, Map col, String header) { - Integer idx = col.get(header); + private Integer readInt(Row row, Map col, String... headers) { + Integer idx = findColumnIndex(col, headers); if (idx == null) { return null; } return parseInteger(row.getCell(idx)); } + private Integer findColumnIndex(Map col, String... headers) { + for (String header : headers) { + if (col.containsKey(header)) { + return col.get(header); + } + String compact = header.replace(" ", ""); + if (col.containsKey(compact)) { + return col.get(compact); + } + } + for (Map.Entry entry : col.entrySet()) { + String key = entry.getKey() == null ? "" : entry.getKey().replace(" ", ""); + for (String header : headers) { + String compact = header.replace(" ", ""); + if (key.contains(compact) || compact.contains(key)) { + return entry.getValue(); + } + } + } + return null; + } + private String readString(Row row, Map col, String header) { Integer idx = col.get(header); if (idx == null) { @@ -360,6 +500,29 @@ public class DrawingWorkloadServiceImpl implements IDrawingWorkloadService { } } + private DrawingWorkloadVo saveEmptyWorkload(ProcessOrderPro orderPro) { + ParsedExcel parsed = new ParsedExcel(); + parsed.summary = emptySummary(); + parsed.items = new ArrayList<>(); + return toVo(saveWorkload(orderPro, parsed), true); + } + + private static DrawingWorkload emptySummary() { + DrawingWorkload row = new DrawingWorkload(); + row.setA4Count(0); + row.setA3Count(0); + row.setA3HalfCount(0); + row.setA3Plus1Count(0); + row.setA2Count(0); + row.setA2HalfCount(0); + row.setA1Count(0); + row.setA1HalfCount(0); + row.setA0Count(0); + row.setA0HalfCount(0); + row.setEquivalentA4Total(0); + return row; + } + private DrawingWorkload saveWorkload(ProcessOrderPro orderPro, ParsedExcel parsed) { Date now = new Date(); DrawingWorkload existing = workloadMapper.selectOne(Wrappers.lambdaQuery() diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FigureSaveServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FigureSaveServiceImpl.java index cd47f54..e3ae9a2 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FigureSaveServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/FigureSaveServiceImpl.java @@ -7,9 +7,9 @@ import com.ruoyi.common.core.domain.PageQuery; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.system.constant.DrawingMode; import com.ruoyi.system.domain.ProcessOrderPro; import com.ruoyi.system.mapper.ProcessOrderProMapper; -import com.ruoyi.system.service.IProcessOrderProService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import com.ruoyi.system.domain.bo.FigureSaveBo; @@ -17,6 +17,7 @@ import com.ruoyi.system.domain.vo.FigureSaveVo; import com.ruoyi.system.domain.FigureSave; import com.ruoyi.system.mapper.FigureSaveMapper; import com.ruoyi.system.service.IFigureSaveService; +import com.ruoyi.system.service.IOrderProDrawingDelayService; import java.util.Collections; import java.util.List; @@ -35,6 +36,7 @@ public class FigureSaveServiceImpl implements IFigureSaveService { private final FigureSaveMapper baseMapper; private final ProcessOrderProMapper processOrderProMapper; + private final IOrderProDrawingDelayService orderProDrawingDelayService; /** * 查询外购件临时 */ @@ -87,9 +89,12 @@ public class FigureSaveServiceImpl implements IFigureSaveService { public Boolean insertByBo(FigureSaveBo bo) { FigureSave add = BeanUtil.toBean(bo, FigureSave.class); validEntityBeforeSave(add); + assertPartModeSameDrawPath(add.getPid(), add.getDrawPath(), null); + fillUploadTime(add); boolean flag = baseMapper.insert(add) > 0; if (flag) { bo.setId(add.getId()); + orderProDrawingDelayService.refreshByProjectId(add.getPid()); } return flag; } @@ -101,7 +106,21 @@ public class FigureSaveServiceImpl implements IFigureSaveService { public Boolean updateByBo(FigureSaveBo bo) { FigureSave update = BeanUtil.toBean(bo, FigureSave.class); validEntityBeforeSave(update); - return baseMapper.updateById(update) > 0; + Long pid = update.getPid(); + if (pid == null && update.getId() != null) { + FigureSave existing = baseMapper.selectById(update.getId()); + pid = existing != null ? existing.getPid() : null; + if (update.getPid() == null) { + update.setPid(pid); + } + } + assertPartModeSameDrawPath(pid, update.getDrawPath(), update.getId()); + fillUploadTime(update); + boolean ok = baseMapper.updateById(update) > 0; + if (ok) { + orderProDrawingDelayService.refreshByProjectId(pid); + } + return ok; } /** @@ -111,6 +130,48 @@ public class FigureSaveServiceImpl implements IFigureSaveService { //TODO 做一些数据校验,如唯一约束 } + private void fillUploadTime(FigureSave entity) { + if (entity != null && StringUtils.isNotBlank(entity.getDrawPath()) && entity.getUploadTime() == null) { + entity.setUploadTime(new java.util.Date()); + } + } + + /** + * 零部件模式下:必须先选出图模式;图纸路径不能为空且须与已有产品一致。 + */ + private void assertPartModeSameDrawPath(Long pid, String drawPath, Long excludeFigureId) { + if (pid == null) { + return; + } + ProcessOrderPro order = processOrderProMapper.selectById(pid); + if (order == null) { + throw new IllegalArgumentException("项目不存在"); + } + DrawingMode.requireConfigured(order); + if (!DrawingMode.PART.equals(DrawingMode.normalize(order.getDrawingMode()))) { + return; + } + String normalized = DrawingMode.normalizeDrawPath(drawPath); + if (StringUtils.isBlank(normalized)) { + throw new IllegalArgumentException("零部件出图模式下图纸路径不能为空"); + } + List existing = baseMapper.selectList( + Wrappers.lambdaQuery().eq(FigureSave::getPid, pid)); + if (existing == null) { + return; + } + for (FigureSave fs : existing) { + if (excludeFigureId != null && excludeFigureId.equals(fs.getId())) { + continue; + } + String existingPath = DrawingMode.normalizeDrawPath(fs.getDrawPath()); + if (StringUtils.isNotBlank(existingPath) && !existingPath.equals(normalized)) { + throw new IllegalArgumentException( + "零部件出图模式下所有产品必须使用同一图纸路径,已有路径:" + fs.getDrawPath()); + } + } + } + /** * 批量删除外购件临时 */ @@ -119,7 +180,18 @@ public class FigureSaveServiceImpl implements IFigureSaveService { if(isValid){ //TODO 做一些业务上的校验,判断是否需要校验 } - return baseMapper.deleteBatchIds(ids) > 0; + List existing = ids == null || ids.isEmpty() + ? Collections.emptyList() + : baseMapper.selectBatchIds(ids); + boolean ok = baseMapper.deleteBatchIds(ids) > 0; + if (ok && existing != null) { + existing.stream() + .map(FigureSave::getPid) + .filter(pid -> pid != null) + .distinct() + .forEach(orderProDrawingDelayService::refreshByProjectId); + } + return ok; } /** @@ -158,10 +230,14 @@ public class FigureSaveServiceImpl implements IFigureSaveService { StringBuilder failureMsg = new StringBuilder(); for (FigureSaveVo vo : dataList) { try { - // 验证是否存在这个用户 FigureSave figureSave = BeanUtil.toBean(vo, FigureSave.class); ProcessOrderPro processOrderPro = processOrderProMapper.selectByProjectNumber(figureSave.getProductionCode()); + if (processOrderPro == null) { + throw new IllegalArgumentException("未找到生产令号:" + figureSave.getProductionCode()); + } figureSave.setPid(processOrderPro.getId()); + assertPartModeSameDrawPath(figureSave.getPid(), figureSave.getDrawPath(), null); + fillUploadTime(figureSave); baseMapper.insert(figureSave); successNum++; successMsg.append("
    ").append(successNum).append("、数据 ").append(vo.getFigureName()).append(" 导入成功"); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ImMaterialServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ImMaterialServiceImpl.java index 07cfd3f..a6ec642 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ImMaterialServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ImMaterialServiceImpl.java @@ -39,6 +39,7 @@ import com.ruoyi.system.runner.JdUtil; import com.ruoyi.system.runner.MaterialProperties; import com.ruoyi.system.service.IImMaterialService; import com.ruoyi.system.service.IProductionOrderService; +import com.ruoyi.system.service.MssqlQueryService; import lombok.RequiredArgsConstructor; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; @@ -70,6 +71,7 @@ public class ImMaterialServiceImpl implements IImMaterialService { private final ImMaterialMapper baseMapper; private final IProductionOrderService productionOrderService; + private final MssqlQueryService mssqlQueryService; private static final Logger logger = LoggerFactory.getLogger(ImMaterialController.class); @Autowired @@ -181,26 +183,31 @@ public class ImMaterialServiceImpl implements IImMaterialService { /** - * 查询物料检索列表 + * 查询物料检索列表(实时查金蝶 T_BD_MATERIAL) */ @Override public TableDataInfo queryPageList(ImMaterialBo bo, PageQuery pageQuery) { try { - - LambdaQueryWrapper lqw = buildQueryWrapper(bo); - Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); - if (CollectionUtils.isEmpty(result.getRecords())) { - return TableDataInfo.build(result); // 如果结果为空,直接返回 + int pageNum = pageQuery.getPageNum() == null || pageQuery.getPageNum() < 1 ? 1 : pageQuery.getPageNum(); + Integer rawSize = pageQuery.getPageSize(); + int pageSize; + if (rawSize == null || rawSize <= 0 || rawSize == Integer.MAX_VALUE) { + pageSize = 10; + } else { + pageSize = Math.min(rawSize, 200); } - // 调用库存查询方法,优化为批量查询而非逐个查询 - Map jsonMap = queryAllKuCun(result.getRecords()); - - // 更新库存信息 - updateRecordsWithKuCunInfo(jsonMap, result.getRecords()); - // 更新数量,更新实体类 - List records = result.getRecords(); - // 根据库存 in_inventory 降序排序 + MssqlQueryService.KingdeeMaterialPage kdPage = + mssqlQueryService.queryMaterialPage(bo, pageNum, pageSize, true); + Page result = new Page<>(pageNum, pageSize); + result.setTotal(kdPage.getTotal()); + List records = kdPage.getRecords() != null ? kdPage.getRecords() : Collections.emptyList(); + result.setRecords(records); + if (CollectionUtils.isEmpty(records)) { + return TableDataInfo.build(result); + } + Map jsonMap = queryAllKuCun(records); + updateRecordsWithKuCunInfo(jsonMap, records); records.sort(Comparator.comparing( r -> { BigDecimal value = BigDecimal.ZERO; @@ -208,21 +215,18 @@ public class ImMaterialServiceImpl implements IImMaterialService { try { value = new BigDecimal(r.getInInventory()); } catch (NumberFormatException e) { - // 如果 in_inventory 不是数字,默认为 0 value = BigDecimal.ZERO; } } - // 保留三位小数,四舍五入 return value.setScale(3, RoundingMode.HALF_UP); }, Comparator.reverseOrder() )); - result.setRecords(records); // 更新结果集*/ + result.setRecords(records); return TableDataInfo.build(result); } catch (Exception e) { - // 异常处理逻辑,可根据实际情况调整 log.error("查询页面列表异常", e); - return new TableDataInfo<>(); // 返回空结果或自定义错误结果 + return new TableDataInfo<>(); } } @@ -331,22 +335,21 @@ public class ImMaterialServiceImpl implements IImMaterialService { record.setInInventory("0.0"); } - // 使用keYong方法计算可用库存 - ImMaterial material = BeanUtil.toBean(record, ImMaterial.class); - /* double availableStock = keYong(material); - record.setSingleWeight(BigDecimal.valueOf(availableStock));*/ - - // 更新记录 - ImMaterial bean = BeanUtil.toBean(record, ImMaterial.class); - baseMapper.updateById(bean); } } /** - * 查询物料检索 + * 查询物料检索(优先金蝶内码,本地表兜底) */ @Override public ImMaterialVo queryById(Long id) { + try { + ImMaterial kingdee = mssqlQueryService.findMaterialById(id); + if (kingdee != null) { + return BeanUtil.toBean(kingdee, ImMaterialVo.class); + } + } catch (Exception e) { + } return baseMapper.selectVoById(id); } /** @@ -392,12 +395,11 @@ public class ImMaterialServiceImpl implements IImMaterialService { /** - * 查询物料检索列表 + * 查询物料检索列表(实时查金蝶,导出上限 10000) */ @Override public List queryList(ImMaterialBo bo) { - LambdaQueryWrapper lqw = buildQueryWrapper(bo); - return baseMapper.selectVoList(lqw); + return mssqlQueryService.queryMaterialList(bo, 10000, true); } private LambdaQueryWrapper buildQueryWrapper(ImMaterialBo bo) { @@ -525,6 +527,13 @@ public class ImMaterialServiceImpl implements IImMaterialService { @Override public ImMaterial selectByCodeAndName(String materialCode, String materialName) { + try { + ImMaterial kingdee = mssqlQueryService.findMaterialByCodeAndName(materialCode, materialName); + if (kingdee != null) { + return kingdee; + } + } catch (Exception e) { + } LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(ImMaterial::getMaterialCode, materialCode) .eq(ImMaterial::getMaterialName, materialName); @@ -639,8 +648,14 @@ public class ImMaterialServiceImpl implements IImMaterialService { public ImMaterial getMaterialByCode(String materialCode) { + try { + ImMaterial kingdee = mssqlQueryService.findMaterialByCode(materialCode); + if (kingdee != null) { + return kingdee; + } + } catch (Exception e) { + } QueryWrapper wrapper = new QueryWrapper<>(); - // 从数据库中获取ImMaterial,假设返回的是一个Optional wrapper.eq("material_code", materialCode); return baseMapper.selectOne(wrapper); } @@ -706,12 +721,16 @@ public class ImMaterialServiceImpl implements IImMaterialService { } /** - * @param query - * @return + * 按编码或名称模糊搜索金蝶物料 */ @Override public TableDataInfo search(String query) { - return null; + MssqlQueryService.KingdeeMaterialPage kdPage = + mssqlQueryService.queryMaterialByKeyword(query, 1, 50); + Page page = new Page<>(1, 50); + page.setTotal(kdPage.getTotal()); + page.setRecords(kdPage.getRecords() != null ? kdPage.getRecords() : Collections.emptyList()); + return TableDataInfo.build(page); } /** diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MrpResultCheckServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MrpResultCheckServiceImpl.java index b6093a9..e837317 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MrpResultCheckServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MrpResultCheckServiceImpl.java @@ -279,8 +279,6 @@ public class MrpResultCheckServiceImpl implements IMrpResultCheckService { baseMapper.insert(vo); } - processOrderPro.setBomStatus(4L); - processOrderProMapper.updateById(processOrderPro); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmPdfZipPushServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmPdfZipPushServiceImpl.java index 152c34d..68a20c9 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmPdfZipPushServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmPdfZipPushServiceImpl.java @@ -8,18 +8,23 @@ import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.system.constant.ProcessOrderProSource; +import com.ruoyi.common.utils.file.PlmDrawingFileNames; +import com.ruoyi.system.domain.FigureSave; import com.ruoyi.system.domain.PlmPdfZipPush; import com.ruoyi.system.domain.PlmPdfZipPushItem; import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.domain.ProductionOrder; import com.ruoyi.system.domain.bo.PlmPdfZipPushBo; import com.ruoyi.system.domain.vo.PlmPdfZipPushDetailVo; import com.ruoyi.system.domain.vo.PlmPdfZipPushItemVo; import com.ruoyi.system.domain.vo.PlmPdfZipPushVo; +import com.ruoyi.system.mapper.FigureSaveMapper; import com.ruoyi.system.mapper.PlmPdfZipPushItemMapper; import com.ruoyi.system.mapper.PlmPdfZipPushMapper; import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.mapper.ProductionOrderMapper; import com.ruoyi.system.service.IPlmPdfZipPushService; +import com.ruoyi.system.utils.ProductionOrderNoUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -55,6 +60,8 @@ public class PlmPdfZipPushServiceImpl implements IPlmPdfZipPushService { private final PlmPdfZipPushMapper pushMapper; private final PlmPdfZipPushItemMapper pushItemMapper; private final ProcessOrderProMapper processOrderProMapper; + private final FigureSaveMapper figureSaveMapper; + private final ProductionOrderMapper productionOrderMapper; private static final String PLM_DWG_SUBDIR = "dwg"; @@ -110,47 +117,185 @@ public class PlmPdfZipPushServiceImpl implements IPlmPdfZipPushService { @Override @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public Long ensureProcessOrderProForPlmPdf(String productionOrderNo) { - String orderNo = StringUtils.trim(productionOrderNo); + return ensureProcessOrderProForPlmPdf(productionOrderNo, null); + } + + /** + * 图纸推送前校验:令号必须已在 process_order_pro 中存在,不再自动建项目。 + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public Long ensureProcessOrderProForPlmPdf(String productionOrderNo, List> savedItems) { + String orderNo = ProductionOrderNoUtils.stripSpaces(productionOrderNo); if (StringUtils.isBlank(orderNo)) { - return null; + throw new ServiceException("生产令号不能为空"); } ProcessOrderPro existing = processOrderProMapper.selectByProjectNumber(orderNo); - if (existing != null) { - return existing.getId(); + if (existing == null) { + throw new ServiceException("生产令号 " + orderNo + " 不存在"); } + ensureDefaultFigureProduct(existing, savedItems); + return existing.getId(); + } + + /** + * 项目下尚无产品时自动建一条 figure_save,满足 PDF 下载前置校验。 + * 型号/名称/数量:优先 production_order 主产品,其次推送明细(.00 总装),否则用令号。 + * 类型:项目类型(非标/企标等);文件类型:二维;出图时间:项目出图时间或当前时间。 + */ + private void ensureDefaultFigureProduct(ProcessOrderPro order, List> savedItems) { + if (order == null || order.getId() == null) { + return; + } + Long count = figureSaveMapper.selectCount( + Wrappers.lambdaQuery().eq(FigureSave::getPid, order.getId())); + if (count != null && count > 0) { + return; + } + ProductHint hint = resolveProductHint(order.getProductionOrderNo(), savedItems); + Date drawTime = order.getDrawingTime() != null ? order.getDrawingTime() : new Date(); Date now = new Date(); - ProcessOrderPro add = new ProcessOrderPro(); - add.setProductionOrderNo(orderNo); - add.setProductionName(orderNo); - add.setSource(ProcessOrderProSource.PLM); - add.setUnit("台"); - add.setQuantity(0L); // 不保留零件图(前端 quantity:1是/0否) - add.setBomStatus(1L); // 设计出图完成(图纸已推送) - add.setRouteStatus(0L); - add.setNeedProcess(1); - add.setIsDownload(1); - add.setNeedKingdeePrdMo(0); - add.setNeedKingdeePurchaseOrder(0); - add.setWhetherDelay(0); - add.setIsEnterpriseStandard(resolveProjectTypeByOrderNo(orderNo)); - add.setDrawingTime(now); - add.setCreateBy("system-PLM"); - add.setUpdateBy("system-PLM"); - add.setCreateTime(now); - add.setUpdateTime(now); - try { - processOrderProMapper.insert(add); - log.info("[PLM-PDF] 自动新建项目: productionOrderNo={}, id={}, source=PLM", orderNo, add.getId()); - return add.getId(); - } catch (Exception e) { - // 并发首次推送:另一请求可能已插入,再查一次 - ProcessOrderPro again = processOrderProMapper.selectByProjectNumber(orderNo); - if (again != null) { - log.info("[PLM-PDF] 自动建项目遇并发,已存在: productionOrderNo={}, id={}", orderNo, again.getId()); - return again.getId(); + FigureSave product = new FigureSave(); + product.setPid(order.getId()); + product.setProductionCode(order.getProductionOrderNo()); + product.setFigureNumber(hint.figureNumber); + product.setFigureName(hint.figureName); + product.setFigureNum(hint.figureNum); + product.setProductType(StringUtils.isNotBlank(order.getIsEnterpriseStandard()) + ? order.getIsEnterpriseStandard() : resolveProjectTypeByOrderNo(order.getProductionOrderNo())); + product.setFileType("二维"); + product.setDrawTime(drawTime); + product.setWhetherDelay(0); + product.setCreateBy("system-PLM"); + product.setUpdateBy("system-PLM"); + product.setCreateTime(now); + product.setUpdateTime(now); + figureSaveMapper.insert(product); + log.info("[PLM-PDF] 自动新建产品: orderNo={}, pid={}, figureNumber={}, figureName={}, figureNum={}, productType={}, drawTime={}", + order.getProductionOrderNo(), order.getId(), product.getFigureNumber(), product.getFigureName(), + product.getFigureNum(), product.getProductType(), product.getDrawTime()); + } + + private ProductHint resolveProductHint(String orderNo, List> savedItems) { + ProductHint hint = new ProductHint(); + hint.figureNumber = orderNo; + hint.figureName = orderNo; + hint.figureNum = 1L; + + List assemblyRows = productionOrderMapper.selectList( + Wrappers.lambdaQuery() + .eq(ProductionOrder::getProductionOrderNo, orderNo) + .orderByAsc(ProductionOrder::getId)); + if (!CollectionUtils.isEmpty(assemblyRows)) { + ProductionOrder preferred = null; + for (ProductionOrder row : assemblyRows) { + if (row == null) { + continue; + } + if (StringUtils.isNotBlank(row.getMainPart()) || StringUtils.isNotBlank(row.getMainName())) { + preferred = row; + break; + } + } + if (preferred == null) { + preferred = assemblyRows.get(0); + } + if (StringUtils.isNotBlank(preferred.getMainPart())) { + hint.figureNumber = preferred.getMainPart().trim(); + } else if (StringUtils.isNotBlank(preferred.getDrawingNo())) { + hint.figureNumber = preferred.getDrawingNo().trim(); + } + if (StringUtils.isNotBlank(preferred.getMainName())) { + hint.figureName = preferred.getMainName().trim(); + } else if (StringUtils.isNotBlank(preferred.getDrawingName())) { + hint.figureName = preferred.getDrawingName().trim(); + } + Long batch = parsePositiveLong(preferred.getBatchQuantity()); + if (batch != null) { + hint.figureNum = batch; } - throw e; } + + Map item = pickMainPushItem(savedItems); + if (item != null) { + if (StringUtils.isBlank(hint.figureNumber) || Objects.equals(hint.figureNumber, orderNo)) { + String code = StringUtils.trim(item.get("materialCode")); + if (StringUtils.isNotBlank(code)) { + hint.figureNumber = code; + } + } + if (StringUtils.isBlank(hint.figureName) || Objects.equals(hint.figureName, orderNo)) { + String name = StringUtils.trim(item.get("materialName")); + if (StringUtils.isNotBlank(name)) { + hint.figureName = name; + } + } + if (hint.figureNum == null || hint.figureNum <= 1L) { + Long batchQty = parsePositiveLong(item.get("batchQty")); + if (batchQty == null) { + batchQty = parsePositiveLong(item.get("unitQty")); + } + if (batchQty != null) { + hint.figureNum = batchQty; + } + } + } + + if (StringUtils.isBlank(hint.figureNumber)) { + hint.figureNumber = orderNo; + } + if (StringUtils.isBlank(hint.figureName)) { + hint.figureName = orderNo; + } + if (hint.figureNum == null || hint.figureNum < 1L) { + hint.figureNum = 1L; + } + return hint; + } + + /** 优先选图号含 .00 的总装图纸,否则取第一条有物料编码的明细 */ + private static Map pickMainPushItem(List> savedItems) { + if (CollectionUtils.isEmpty(savedItems)) { + return null; + } + Map first = null; + for (Map item : savedItems) { + if (item == null) { + continue; + } + String code = StringUtils.trim(item.get("materialCode")); + if (StringUtils.isBlank(code)) { + continue; + } + if (first == null) { + first = item; + } + if (code.contains(".00")) { + return item; + } + } + return first; + } + + private static Long parsePositiveLong(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + try { + double v = Double.parseDouble(raw.trim()); + if (v < 1D) { + return null; + } + return (long) Math.round(v); + } catch (Exception e) { + return null; + } + } + + private static final class ProductHint { + private String figureNumber; + private String figureName; + private Long figureNum; } /** 与手工建项一致:按令号前缀推断项目类型(FB/非标、QB/企标等) */ @@ -395,7 +540,6 @@ public class PlmPdfZipPushServiceImpl implements IPlmPdfZipPushService { } String sep = StringUtils.isNotBlank(plmPdfSeparator) ? plmPdfSeparator : "_"; String code = StringUtils.trimToEmpty(materialCode); - String prefixWithSep = code + sep; String ext = "." + extension.toLowerCase(Locale.ROOT); int count = 0; int scanned = 0; @@ -407,10 +551,7 @@ public class PlmPdfZipPushServiceImpl implements IPlmPdfZipPushService { if (!lower.endsWith(ext)) { continue; } - boolean matched = name.equalsIgnoreCase(code + ext) - || name.regionMatches(true, 0, prefixWithSep, 0, prefixWithSep.length()) - || name.regionMatches(true, 0, code + "-", 0, code.length() + 1); - if (matched) { + if (PlmDrawingFileNames.matchesMaterialCode(name, code, sep, extension)) { log.info("[PLM-PDF][清理重复图] 文件名匹配: dir={}, materialCode={}, file={}", dir, materialCode, name); count += deletePlmDrawingFile(file.toString(), deleted); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmProcessRoutePushServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmProcessRoutePushServiceImpl.java index 0fbcc6e..888496e 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmProcessRoutePushServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PlmProcessRoutePushServiceImpl.java @@ -11,6 +11,7 @@ import com.ruoyi.system.domain.MaterialBom; import com.ruoyi.system.domain.ProcessOrderPro; import com.ruoyi.system.domain.ProcessRoute; import com.ruoyi.system.domain.ProductionOrder; +import com.ruoyi.system.domain.bo.plm.PlmGeneralDrawingUtils; import com.ruoyi.system.domain.bo.plm.PlmMaterialBomBo; import com.ruoyi.system.domain.bo.plm.PlmOrderMaterialMatchUtils; import com.ruoyi.system.domain.bo.plm.PlmProcessMaterialBo; @@ -38,8 +39,10 @@ import java.math.RoundingMode; import java.util.ArrayList; import java.util.Comparator; import java.util.Date; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -75,26 +78,74 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi if (StringUtils.isBlank(productionOrder)) { throw new ServiceException("productionOrder 不能为空"); } - requireProductionOrderExists(productionOrder); + ProcessOrderPro project = requireProductionOrderExists(productionOrder); log.info("PLM 工艺推送 body JSON:\n{}", JSON.toJSONString(body, SerializerFeature.PrettyFormat)); PlmProcessRoutePushValidator.validate(body); // 总令号推送:入库前剔除子令号已有且图号归属匹配的物料(变更令 BG 跳过) filterParentMaterialsDuplicatedInChild(body, productionOrder); - // bom_details = 总装行(bomType=0,来自 production_order) + 材料BOM(bomType=1,来自本次推送) - List assemblyBomRows = buildAssemblyBomDetailsFromProductionOrder(productionOrder); + Set incomingCodes = collectPushMaterialCodes(body); + Set incomingGeneral = PlmGeneralDrawingUtils.collectGeneralDrawingNos(incomingCodes); + Set existingCodes = collectExistingProcessMaterialCodes(productionOrder); + Set existingGeneral = PlmGeneralDrawingUtils.collectGeneralDrawingNos(existingCodes); + Set overlap = PlmGeneralDrawingUtils.intersection(incomingGeneral, existingGeneral); + boolean hasExisting = !existingCodes.isEmpty(); + boolean canCompare = !existingGeneral.isEmpty() && !incomingGeneral.isEmpty(); + boolean append = hasExisting && canCompare && overlap.isEmpty(); + boolean replaced = false; + + if (hasExisting && !append) { + if (PlmGeneralDrawingUtils.isRouteApproved(project.getRouteStatus())) { + throw new ServiceException("生产令号 " + productionOrder + " 工艺状态已审核,不允许再推送"); + } + replaced = true; + if (canCompare && !overlap.isEmpty()) { + Set allGenerals = new LinkedHashSet<>(existingGeneral); + allGenerals.addAll(incomingGeneral); + Set belonging = collectCodesBelongingToGenerals(productionOrder, overlap, allGenerals); + for (String code : incomingCodes) { + String owner = PlmGeneralDrawingUtils.resolveOwnerGeneralDrawing(code, allGenerals); + if (overlap.contains(code) || (owner != null && overlap.contains(owner))) { + belonging.add(code); + } + } + deleteProcessAndMaterialByCodes(productionOrder, belonging); + log.info("[PLM-工艺] 未审核同总图覆盖: order={}, general={}, codes={}", + productionOrder, overlap, belonging.size()); + } else { + deleteAllProcessAndMaterialKeepAssembly(productionOrder); + log.info("[PLM-工艺] 未审核全量覆盖: order={}, existingGeneral={}, incomingGeneral={}", + productionOrder, existingGeneral, incomingGeneral); + } + } else if (append) { + log.info("[PLM-工艺] 总图号不同,追加写入: order={}, existing={}, incoming={}", + productionOrder, existingGeneral, incomingGeneral); + Set mergedCodes = mergeSharedProcessBatchQuantities(body, productionOrder); + if (!mergedCodes.isEmpty()) { + List kept = new ArrayList<>(); + for (PlmProcessMaterialBo material : body.getMaterialList()) { + if (material == null) { + continue; + } + String code = StringUtils.trimToEmpty(material.getMaterialCode()); + if (mergedCodes.contains(code)) { + continue; + } + kept.add(material); + } + body.setMaterialList(kept); + log.info("[PLM-工艺] 共用件已合并本批数量,不再重复入库: order={}, merged={}, remain={}", + productionOrder, mergedCodes.size(), kept.size()); + } + } + + int assemblyCount = refreshAssemblyBomDetailsFromProductionOrder(productionOrder); List materialBomDetailRows = buildBomDetails(body, productionOrder); - List bomRows = new ArrayList<>(assemblyBomRows.size() + materialBomDetailRows.size()); - bomRows.addAll(assemblyBomRows); - bomRows.addAll(materialBomDetailRows); List materialBomRows = buildMaterialBoms(body, productionOrder); List routeRows = buildProcessRoutes(body, productionOrder); - bomDetailsMapper.deleteWithProCode(productionOrder); - materialBomMapper.deleteWithProCode(productionOrder); - processRouteMapper.deleteByProCode(productionOrder); - if (!bomRows.isEmpty()) { - bomDetailsMapper.insertBatch(bomRows); + if (!materialBomDetailRows.isEmpty()) { + bomDetailsMapper.insertBatch(materialBomDetailRows); } if (!materialBomRows.isEmpty()) { materialBomMapper.insertBatch(materialBomRows); @@ -104,29 +155,30 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi } // 子令号推送:按本次归属匹配的图号,清理总令号侧重复工艺/BOM - cleanupParentProcessDuplicatedInChild(productionOrder, collectPushMaterialCodes(body)); + cleanupParentProcessDuplicatedInChild(productionOrder, incomingCodes); PlmProcessRoutePushNormalizer.PlmProcessRoutePushSummary summary = PlmProcessRoutePushNormalizer.summarize(body); - summary.setSavedBomCount(bomRows.size()); + summary.setSavedBomCount(assemblyCount + materialBomDetailRows.size()); summary.setSavedMaterialBomCount(materialBomRows.size()); summary.setSavedRouteCount(routeRows.size()); + summary.setAppended(append); + summary.setReplaced(replaced); markProjectAfterPlmProcessRoute(productionOrder, summary); - log.info("PLM 工艺推送入库完成 productionOrder={} bom={}(assembly={} materialDetail={}) materialBom={} route={}", - productionOrder, bomRows.size(), assemblyBomRows.size(), materialBomDetailRows.size(), - materialBomRows.size(), routeRows.size()); + log.info("PLM 工艺推送入库完成 productionOrder={} bom={}(assembly={} materialDetail={}) materialBom={} route={} appended={} replaced={}", + productionOrder, assemblyCount + materialBomDetailRows.size(), assemblyCount, materialBomDetailRows.size(), + materialBomRows.size(), routeRows.size(), append, replaced); return summary; } /** - * 总令号入库前:去掉已在子令号出现、且图号前缀归属本令号的物料。 + * 总令号入库前:去掉子令号 process_route 中已存在的相同物料编码,并清理总令库中对应行。 */ private void filterParentMaterialsDuplicatedInChild(PlmProcessRoutePushBo body, String productionOrder) { if (body == null || CollectionUtils.isEmpty(body.getMaterialList())) { return; } if (PlmOrderMaterialMatchUtils.isChangeOrder(productionOrder) - || PlmOrderMaterialMatchUtils.isChildProductionOrderNo(productionOrder) - || PlmOrderMaterialMatchUtils.resolveMaterialCodePrefix(productionOrder) == null) { + || PlmOrderMaterialMatchUtils.isChildProductionOrderNo(productionOrder)) { return; } Set childCodes = collectChildOrderMaterialCodes(productionOrder); @@ -148,26 +200,26 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi kept.add(material); } body.setMaterialList(kept); - log.info("[PLM-工艺去重] 总令号过滤完成: parent={}, before={}, after={}, childCodes={}", - productionOrder, before, kept.size(), childCodes.size()); + int deleted = deleteProcessDataByMaterialCodes(productionOrder, childCodes); + log.info("[PLM-工艺去重] 总令号过滤完成: parent={}, before={}, after={}, childCodes={}, deletedRows={}", + productionOrder, before, kept.size(), childCodes.size(), deleted); } /** - * 子令号入库后:按本次图号删除总令号侧重复工艺/材料BOM/bom_details。 + * 子令号入库后:按本次推送的物料编码,删除总令号侧相同工艺/材料BOM/bom_details。 */ private void cleanupParentProcessDuplicatedInChild(String productionOrder, Set inputCodes) { if (!PlmOrderMaterialMatchUtils.isChildProductionOrderNo(productionOrder)) { return; } String parent = PlmOrderMaterialMatchUtils.resolveParentProductionOrderNo(productionOrder); - if (StringUtils.isBlank(parent) || PlmOrderMaterialMatchUtils.isChangeOrder(parent) - || PlmOrderMaterialMatchUtils.resolveMaterialCodePrefix(parent) == null) { + if (StringUtils.isBlank(parent) || PlmOrderMaterialMatchUtils.isChangeOrder(parent)) { log.info("[PLM-工艺去重] 子令号跳过清理总令: child={}, parent={}", productionOrder, parent); return; } - Set codes = filterCodesBelongingToOrder(inputCodes, parent); + Set codes = normalizeMaterialCodes(inputCodes); if (codes.isEmpty()) { - log.info("[PLM-工艺去重] 子令号本次无归属匹配图号,不清理: child={}, parent={}", productionOrder, parent); + log.info("[PLM-工艺去重] 子令号本次无有效物料编码,不清理: child={}, parent={}", productionOrder, parent); return; } int deleted = deleteProcessDataByMaterialCodes(parent, codes); @@ -175,7 +227,7 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi productionOrder, parent, codes, deleted); } - /** 汇总已接收子令号(末段纯数字、不含 BG)在 process_route 中的物料编码,且须归属总令图号前缀。 */ + /** 汇总已接收子令号在 process_route 中的物料编码(物料编码完全相同即视为重复,不做图号前缀过滤)。 */ private Set collectChildOrderMaterialCodes(String parentOrderNo) { Set childOrderNos = findChildProductionOrderNos(parentOrderNo); if (childOrderNos.isEmpty()) { @@ -192,7 +244,7 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi continue; } String code = StringUtils.trimToEmpty(route.getMaterialCode()); - if (PlmOrderMaterialMatchUtils.materialBelongsToOrder(code, parentOrderNo)) { + if (StringUtils.isNotBlank(code)) { codes.add(code); } } @@ -235,17 +287,18 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi .collect(Collectors.toCollection(LinkedHashSet::new)); } - private static Set filterCodesBelongingToOrder(Set codes, String productionOrderNo) { - Set matched = new LinkedHashSet<>(); + private static Set normalizeMaterialCodes(Set codes) { + Set normalized = new LinkedHashSet<>(); if (codes == null) { - return matched; + return normalized; } for (String code : codes) { - if (PlmOrderMaterialMatchUtils.materialBelongsToOrder(code, productionOrderNo)) { - matched.add(StringUtils.trimToEmpty(code)); + String trimmed = StringUtils.trimToEmpty(code); + if (StringUtils.isNotBlank(trimmed)) { + normalized.add(trimmed); } } - return matched; + return normalized; } /** 按令号 + 物料编码精确删除工艺及相关 BOM(只动该令号)。 */ @@ -268,20 +321,263 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi return n; } - /** 从已入库的 production_order 回写总装行到 bom_details,避免工艺推送全删后总装丢失。 */ - private List buildAssemblyBomDetailsFromProductionOrder(String productionOrder) { + /** 从已入库的 production_order 回写总装行到 bom_details,避免工艺推送后总装丢失。 */ + private int refreshAssemblyBomDetailsFromProductionOrder(String productionOrder) { + bomDetailsMapper.delete(Wrappers.lambdaQuery() + .eq(BomDetails::getTotalWeight, productionOrder) + .eq(BomDetails::getBomType, AssemblyBomToBomDetailsConverter.BOM_TYPE_ASSEMBLY)); List orders = productionOrderMapper.selectList( Wrappers.lambdaQuery() .eq(ProductionOrder::getProductionOrderNo, productionOrder)); - return AssemblyBomToBomDetailsConverter.toBomDetailsList(orders); + List assemblyBomRows = AssemblyBomToBomDetailsConverter.toBomDetailsList(orders); + if (assemblyBomRows.isEmpty()) { + return 0; + } + bomDetailsMapper.insertBatch(assemblyBomRows); + return assemblyBomRows.size(); + } + + private Set collectExistingProcessMaterialCodes(String productionOrder) { + Set codes = new LinkedHashSet<>(); + List routes = processRouteMapper.selectList( + Wrappers.lambdaQuery() + .eq(ProcessRoute::getRouteDescription, productionOrder) + .select(ProcessRoute::getMaterialCode)); + if (routes != null) { + for (ProcessRoute route : routes) { + if (route == null) { + continue; + } + String code = StringUtils.trimToEmpty(route.getMaterialCode()); + if (StringUtils.isNotBlank(code)) { + codes.add(code); + } + } + } + return codes; + } + + /** + * 收集归属 overlap 总图号的已有物料编码;用 allGenerals 做最长匹配,避免误删另一总图下的件。 + */ + private Set collectCodesBelongingToGenerals(String productionOrder, + Set overlapGenerals, + Set allGenerals) { + Set belonging = new LinkedHashSet<>(); + if (StringUtils.isBlank(productionOrder) || CollectionUtils.isEmpty(overlapGenerals)) { + return belonging; + } + List routes = processRouteMapper.selectList( + Wrappers.lambdaQuery() + .eq(ProcessRoute::getRouteDescription, productionOrder) + .select(ProcessRoute::getMaterialCode)); + if (routes != null) { + for (ProcessRoute route : routes) { + if (route == null) { + continue; + } + addIfBelongingToOverlap(belonging, route.getMaterialCode(), overlapGenerals, allGenerals); + } + } + List boms = materialBomMapper.selectList( + Wrappers.lambdaQuery() + .eq(MaterialBom::getProjectNumber, productionOrder) + .select(MaterialBom::getParentMaterialCode, MaterialBom::getMaterialCode)); + if (boms != null) { + for (MaterialBom bom : boms) { + if (bom == null) { + continue; + } + String parent = StringUtils.trimToEmpty(bom.getParentMaterialCode()); + String child = StringUtils.trimToEmpty(bom.getMaterialCode()); + String parentOwner = PlmGeneralDrawingUtils.resolveOwnerGeneralDrawing(parent, allGenerals); + String childOwner = PlmGeneralDrawingUtils.resolveOwnerGeneralDrawing(child, allGenerals); + if ((parentOwner != null && overlapGenerals.contains(parentOwner)) + || (childOwner != null && overlapGenerals.contains(childOwner))) { + if (StringUtils.isNotBlank(parent)) { + belonging.add(parent); + } + if (StringUtils.isNotBlank(child)) { + belonging.add(child); + } + } + } + } + return belonging; + } + + private static void addIfBelongingToOverlap(Set belonging, String materialCode, + Set overlapGenerals, Set allGenerals) { + String code = StringUtils.trimToEmpty(materialCode); + if (StringUtils.isBlank(code)) { + return; + } + String owner = PlmGeneralDrawingUtils.resolveOwnerGeneralDrawing(code, allGenerals); + if (owner != null && overlapGenerals.contains(owner)) { + belonging.add(code); + } + } + + /** 覆盖同总图时删除工艺/材料 BOM,保留总装行(bomType=0)。 */ + private void deleteProcessAndMaterialByCodes(String orderNo, Set materialCodes) { + if (StringUtils.isBlank(orderNo) || CollectionUtils.isEmpty(materialCodes)) { + return; + } + processRouteMapper.delete(Wrappers.lambdaQuery() + .eq(ProcessRoute::getRouteDescription, orderNo) + .in(ProcessRoute::getMaterialCode, materialCodes)); + materialBomMapper.delete(Wrappers.lambdaQuery() + .eq(MaterialBom::getProjectNumber, orderNo) + .and(w -> w.in(MaterialBom::getParentMaterialCode, materialCodes) + .or() + .in(MaterialBom::getMaterialCode, materialCodes))); + bomDetailsMapper.delete(Wrappers.lambdaQuery() + .eq(BomDetails::getTotalWeight, orderNo) + .eq(BomDetails::getBomType, "1") + .and(w -> w.in(BomDetails::getFNumber, materialCodes) + .or() + .in(BomDetails::getPartNumber, materialCodes))); + } + + /** 无法比较总图时全量覆盖工艺/材料 BOM,保留总装行。 */ + private void deleteAllProcessAndMaterialKeepAssembly(String productionOrder) { + processRouteMapper.deleteByProCode(productionOrder); + materialBomMapper.deleteWithProCode(productionOrder); + bomDetailsMapper.delete(Wrappers.lambdaQuery() + .eq(BomDetails::getTotalWeight, productionOrder) + .eq(BomDetails::getBomType, "1")); + } + + /** + * 追加写入时:先比物料编码;再比有效工序条数;再逐道比工序号/名称/工作中心/描述。 + * 全部相同才合并本批数量,返回已合并的物料编码。 + */ + private Set mergeSharedProcessBatchQuantities(PlmProcessRoutePushBo body, String productionOrder) { + Set merged = new LinkedHashSet<>(); + if (body == null || CollectionUtils.isEmpty(body.getMaterialList())) { + return merged; + } + List existing = processRouteMapper.selectList( + Wrappers.lambdaQuery() + .eq(ProcessRoute::getRouteDescription, productionOrder) + .select(ProcessRoute::getId, ProcessRoute::getMaterialCode, + ProcessRoute::getProcessNo, ProcessRoute::getProcessName, + ProcessRoute::getWorkCenter, ProcessRoute::getProcessDescription, + ProcessRoute::getBatchQuantity)); + if (CollectionUtils.isEmpty(existing)) { + return merged; + } + Map> byCode = new LinkedHashMap<>(); + for (ProcessRoute row : existing) { + if (row == null || StringUtils.isBlank(row.getMaterialCode())) { + continue; + } + byCode.computeIfAbsent(row.getMaterialCode().trim(), k -> new ArrayList<>()).add(row); + } + Date now = new Date(); + for (PlmProcessMaterialBo material : body.getMaterialList()) { + if (material == null || StringUtils.isBlank(material.getMaterialCode())) { + continue; + } + String code = material.getMaterialCode().trim(); + List existRows = byCode.get(code); + if (existRows == null || existRows.isEmpty()) { + continue; + } + if (!isSameProcessRoute(existRows, material, productionOrder, code)) { + continue; + } + long addQty = material.getBatchQuantity() == null ? 0L : material.getBatchQuantity().longValue(); + Long oldQty = null; + for (ProcessRoute row : existRows) { + if (row.getBatchQuantity() != null) { + oldQty = row.getBatchQuantity(); + break; + } + } + long baseQty = oldQty == null ? 0L : oldQty; + long newQty = baseQty + addQty; + processRouteMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessRoute::getRouteDescription, productionOrder) + .eq(ProcessRoute::getMaterialCode, code) + .set(ProcessRoute::getBatchQuantity, newQty) + .set(ProcessRoute::getUpdateBy, PLM_OPERATOR) + .set(ProcessRoute::getUpdateTime, now)); + merged.add(code); + log.info("[PLM-工艺] 共用件合并本批数量: order={}, materialCode={}, {}+{}={}", + productionOrder, code, baseQty, addQty, newQty); + } + return merged; + } + + /** + * 比较顺序:物料编码已相同 → 有效工序条数 → 逐道工序号/名称/工作中心/描述。 + * 全部相同才允许合并本批数量。 + */ + private static boolean isSameProcessRoute(List existing, PlmProcessMaterialBo incoming, + String productionOrder, String materialCode) { + List existSteps = collectExistingProcessSteps(existing); + List incomingSteps = incoming == null || incoming.getProcessRoute() == null + ? new ArrayList<>() + : collectValidRoutes(incoming.getProcessRoute()); + if (existSteps.size() != incomingSteps.size()) { + log.info("[PLM-工艺] 物料编码相同但工序条数不同,不合并: order={}, materialCode={}, exist={}, incoming={}", + productionOrder, materialCode, existSteps.size(), incomingSteps.size()); + return false; + } + for (int i = 0; i < existSteps.size(); i++) { + ProcessRoute exist = existSteps.get(i); + PlmProcessRouteItemBo in = incomingSteps.get(i); + Long existNo = exist.getProcessNo(); + Long inNo = parseProcessNo(in.getProcessNo()); + if (!Objects.equals(existNo, inNo) + || !equalsTrim(exist.getProcessName(), in.getProcessName()) + || !equalsTrim(exist.getWorkCenter(), in.getWorkCenter()) + || !equalsProcessDesc(exist.getProcessDescription(), buildProcessDescription(in))) { + log.info("[PLM-工艺] 物料编码相同但第{}道工序不同,不合并: order={}, materialCode={}, existNo={}, incomingNo={}", + i + 1, productionOrder, materialCode, existNo, inNo); + return false; + } + } + return true; + } + + private static List collectExistingProcessSteps(List rows) { + List processes = new ArrayList<>(); + if (rows != null) { + for (ProcessRoute row : rows) { + if (row == null || row.getProcessNo() == null || StringUtils.isBlank(row.getProcessName())) { + continue; + } + processes.add(row); + } + } + processes.sort(Comparator.comparing(ProcessRoute::getProcessNo)); + return processes; + } + + private static boolean equalsTrim(String left, String right) { + return StringUtils.trimToEmpty(left).equals(StringUtils.trimToEmpty(right)); + } + + private static boolean equalsProcessDesc(String stored, String incoming) { + return normalizeProcessDesc(stored).equals(normalizeProcessDesc(incoming)); + } + + private static String normalizeProcessDesc(String desc) { + if (desc == null) { + return ""; + } + return desc.replace("\r\n", "\n").replace('\r', '\n').trim(); } /** 接收前校验:process_order_pro.production_order_no 必须已存在,否则拒绝接收。 */ - private void requireProductionOrderExists(String productionOrder) { + private ProcessOrderPro requireProductionOrderExists(String productionOrder) { ProcessOrderPro order = processOrderProMapper.selectByProjectNumber(productionOrder.trim()); if (order == null) { throw new ServiceException("生产令号 " + productionOrder.trim() + " 不存在"); } + return order; } /** @@ -299,6 +595,23 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi throw new ServiceException("生产令号 " + productionOrder.trim() + " 不存在"); } Date now = new Date(); + long bomCount = bomDetailsMapper.selectCount( + Wrappers.lambdaQuery().eq(BomDetails::getTotalWeight, productionOrder)); + long routeCount = processRouteMapper.selectCount( + Wrappers.lambdaQuery().eq(ProcessRoute::getRouteDescription, productionOrder)); + List materialRows = processRouteMapper.selectList( + Wrappers.lambdaQuery() + .eq(ProcessRoute::getRouteDescription, productionOrder) + .select(ProcessRoute::getMaterialCode)); + long materialCount = 0L; + if (materialRows != null) { + materialCount = materialRows.stream() + .map(ProcessRoute::getMaterialCode) + .map(StringUtils::trimToEmpty) + .filter(StringUtils::isNotBlank) + .distinct() + .count(); + } ProcessOrderPro update = new ProcessOrderPro(); update.setId(order.getId()); update.setBomStatus(8L); @@ -306,16 +619,16 @@ public class PlmProcessRoutePushServiceImpl implements IPlmProcessRoutePushServi update.setRouteUptime(now); update.setPlanUptime(now); update.setBomUptime(now); - update.setBomCount((long) summary.getSavedBomCount()); - update.setMaterialCount((long) summary.getMaterialCount()); - update.setRouteCount((long) summary.getSavedRouteCount()); + update.setBomCount(bomCount); + update.setMaterialCount(materialCount); + update.setRouteCount(routeCount); update.setSource(ProcessOrderProSource.PLM); update.setUpdateBy("PLM"); update.setUpdateTime(now); processOrderProMapper.updateById(update); - log.info("PLM工艺计划已回写项目状态: 令号={}, id={}, bomStatus=8, routeStatus=1, bom={}, material={}, route={}", - productionOrder, order.getId(), - summary.getSavedBomCount(), summary.getMaterialCount(), summary.getSavedRouteCount()); + log.info("PLM工艺计划已回写项目状态: 令号={}, id={}, bomStatus=8, routeStatus=1, bom={}, material={}, route={}, appended={}, replaced={}", + productionOrder, order.getId(), bomCount, materialCount, routeCount, + summary != null && summary.isAppended(), summary != null && summary.isReplaced()); } private List buildBomDetails(PlmProcessRoutePushBo body, String productionOrder) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java index 495ac9c..d4a0fbc 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java @@ -17,16 +17,22 @@ import com.ruoyi.common.config.DrawingFtpProperties; import com.ruoyi.common.constant.Constants; import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.domain.entity.SysDictData; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.dingding.robot.RobotGroupService; import com.ruoyi.common.dingding.token.AccessTokenManager; +import com.ruoyi.common.dingding.util.DingUtil; import com.ruoyi.common.dingding.webhook.WebhookClient; import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.exception.ServiceException; +import cn.hutool.core.date.DateUtil; +import com.ruoyi.common.utils.DrawingDelayCalculator; import com.ruoyi.common.utils.FtpUtil; import com.ruoyi.common.utils.HttpUtils; +import com.ruoyi.common.utils.ProjectStatusLock; import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.common.utils.file.DeleteFile; +import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.utils.file.PDFDocHelper; import com.ruoyi.common.utils.PlmPdfMarkPlacement; import com.ruoyi.common.utils.PlmPdfMarkPlacementResolver; @@ -34,14 +40,18 @@ import com.ruoyi.common.utils.spring.SpringUtils; import com.ruoyi.oss.factory.OssFactory; import com.ruoyi.system.controller.ProcessOrderProController; import com.ruoyi.system.domain.*; +import com.ruoyi.system.constant.DrawingMode; +import com.ruoyi.system.constant.DingTodoSceneCodes; import com.ruoyi.system.constant.ProcessOrderProSource; import com.ruoyi.system.domain.bo.ProcessOrderProBo; +import com.ruoyi.system.domain.bo.plm.PlmMaterialCodeNormalizer; import com.ruoyi.system.domain.dto.ProcessRouteExcelDTO; import com.ruoyi.system.domain.dto.excuteDrawing.DataInfo; import com.ruoyi.system.domain.dto.excuteDrawing.ProductInfo; import com.ruoyi.system.domain.dto.excuteDrawing.PwProductionBill; import com.ruoyi.system.domain.dto.excuteDrawing.RigidChainModelDTO; import com.ruoyi.system.domain.dto.excuteDrawing.Subparts; +import com.ruoyi.system.domain.vo.OrderProDrawingImportVo; import com.ruoyi.system.domain.vo.OverdueProjectVo; import com.ruoyi.system.domain.vo.ProcessOrderProVo; import com.ruoyi.system.domain.vo.ProductionOrderVo; @@ -61,6 +71,7 @@ import org.springframework.beans.BeanWrapperImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -75,6 +86,7 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -98,6 +110,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { private final FigureSaveMapper figureSaveMapper; private final IMaterialBomService materialBomService; private final DingTalkProperties dingTalkProperties; + private final IDingTodoDispatchService dingTodoDispatchService; private final ISysOssService iSysOssService; private final ProcessOrderProMapper baseMapper; private final SfcOperationPlanningMainMapper sfcOperationPlanningMainMapper; @@ -110,11 +123,17 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { private final DeviceSpec60rMapper deviceSpec60rMapper; private final DeviceSpec80rMapper deviceSpec80rMapper; private final DeviceSpec100rMapper deviceSpec100rMapper; + private final DeviceSpec125rMapper deviceSpec125rMapper; private final IFigureSaveService iFigureSaveService; + private final IOrderProDrawingDelayService orderProDrawingDelayService; + private final SkipProcessNotifyService skipProcessNotifyService; + private final DrawingWorkloadMapper drawingWorkloadMapper; private final SafetyStockMapper safetyStockMapper; private final IProcessRouteService iProcessRouteService; private final IProductionOrderService iProductionOrderService; private final ISysUserService userService; + private final ISysDictTypeService dictTypeService; + private final IDesignTaskService designTaskService; private final ProductionOrderMapper productionOrderMapper; private final ImMaterialMapper mapper; @Autowired @@ -131,6 +150,9 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { @Autowired private ProcessRouteMapper processRouteMapper; + @Autowired + private IDrawingRouteOperLogService drawingRouteOperLogService; + /** PLM 推送的 PDF 根目录,下按生产令号分子目录 */ @Value("${plm.pdf.base-dir:D:/plm/pdf}") private String plmPdfBaseDir; @@ -144,6 +166,8 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { /** 与 {@link com.ruoyi.system.controller.ImMaterialController} 接收 PLM PDF 时落盘目录一致 */ private static final String PLM_PDF_WORK_SUBDIR = "PLM-PDF"; + /** 同一令号同时只打一次包,其他人复用已生成的 ZIP */ + private final ConcurrentHashMap uploadPdfLocks = new ConcurrentHashMap<>(); @Autowired private IPcRigidChainService pcRigidChainService; @Value("${app.drawing.base-path:F:}") @@ -163,14 +187,6 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { private final DrawingFtpProperties drawingFtpProperties; - private R downloadDrawingFtpDirectory(String remoteDir, String localDir) { - return FtpUtil.downloadFtpDirectoryFiles(drawingFtpProperties, remoteDir, localDir); - } - - private R downloadDrawingFtpDirectory(String remoteDir, String localDir, String fileNameFilter) { - return FtpUtil.downloadFtpDirectoryFiles(drawingFtpProperties, remoteDir, localDir, fileNameFilter); - } - @Autowired private IFigureSaveService iFigureSaveSservice; private Map specStrategies; @@ -205,62 +221,70 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { private void initStrategies() { if (specStrategies != null) return; specStrategies = new HashMap<>(); + // itemType 必须用策略标准型号(如 40S),不能用产品别名(如 40SX),否则 device_spec_* 查不到会走空变量 fallback specStrategies.put("35R", figureSave -> { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(DeviceSpec35r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec35r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec35r::getItemType, figureSave.getProductType()); + wrapper.eq(DeviceSpec35r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec35r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec35r::getItemType, "35R"); DeviceSpec35r device = deviceSpec35rMapper.selectOne(wrapper); if (device == null) return null; return buildProductInfo(figureSave, device); }); specStrategies.put("30S", figureSave -> { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(DeviceSpec30s::getBoxType, figureSave.getBoxType()).eq(DeviceSpec30s::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec30s::getItemType, figureSave.getProductType()); + wrapper.eq(DeviceSpec30s::getBoxType, figureSave.getBoxType()).eq(DeviceSpec30s::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec30s::getItemType, "30S"); DeviceSpec30s device = deviceSpec30sMapper.selectOne(wrapper); if (device == null) return null; return buildProductInfo30S(figureSave, device); }); specStrategies.put("30D", figureSave -> { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); - w.eq(DeviceSpec30d::getBoxType, figureSave.getBoxType()).eq(DeviceSpec30d::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec30d::getItemType, figureSave.getProductType()); + w.eq(DeviceSpec30d::getBoxType, figureSave.getBoxType()).eq(DeviceSpec30d::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec30d::getItemType, "30D"); DeviceSpec30d d = deviceSpec30dMapper.selectOne(w); if (d == null) return null; return buildProductInfoGeneric(figureSave, d); }); specStrategies.put("40R", figureSave -> { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); - w.eq(DeviceSpec40r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec40r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec40r::getItemType, figureSave.getProductType()); + w.eq(DeviceSpec40r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec40r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec40r::getItemType, "40R"); DeviceSpec40r d = deviceSpec40rMapper.selectOne(w); if (d == null) return null; return buildProductInfoGeneric(figureSave, d); }); specStrategies.put("40S", figureSave -> { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); - w.eq(DeviceSpec40s::getBoxType, figureSave.getBoxType()).eq(DeviceSpec40s::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec40s::getItemType, figureSave.getProductType()); + w.eq(DeviceSpec40s::getBoxType, figureSave.getBoxType()).eq(DeviceSpec40s::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec40s::getItemType, "40S"); DeviceSpec40s d = deviceSpec40sMapper.selectOne(w); if (d == null) return null; return buildProductInfoGeneric(figureSave, d); }); specStrategies.put("60R", figureSave -> { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); - w.eq(DeviceSpec60r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec60r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec60r::getItemType, figureSave.getProductType()); + w.eq(DeviceSpec60r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec60r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec60r::getItemType, "60R"); DeviceSpec60r d = deviceSpec60rMapper.selectOne(w); if (d == null) return null; return buildProductInfoGeneric(figureSave, d); }); specStrategies.put("80R", figureSave -> { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); - w.eq(DeviceSpec80r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec80r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec80r::getItemType, figureSave.getProductType()); + w.eq(DeviceSpec80r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec80r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec80r::getItemType, "80R"); DeviceSpec80r d = deviceSpec80rMapper.selectOne(w); if (d == null) return null; return buildProductInfoGeneric(figureSave, d); }); specStrategies.put("100R", figureSave -> { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); - w.eq(DeviceSpec100r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec100r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec100r::getItemType, figureSave.getProductType()); + w.eq(DeviceSpec100r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec100r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec100r::getItemType, "100R"); DeviceSpec100r d = deviceSpec100rMapper.selectOne(w); if (d == null) return null; return buildProductInfoGeneric(figureSave, d); }); + specStrategies.put("125R", figureSave -> { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(DeviceSpec125r::getBoxType, figureSave.getBoxType()).eq(DeviceSpec125r::getTravelLength, Objects.toString(figureSave.getJdInventory(), "")).eq(DeviceSpec125r::getItemType, "125R"); + DeviceSpec125r d = deviceSpec125rMapper.selectOne(w); + if (d == null) return null; + return buildProductInfoGeneric(figureSave, d); + }); } @@ -274,6 +298,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { alias.put("60R-FR", "60R"); alias.put("60RX", "60R"); alias.put("40P", "40S"); + alias.put("40SX", "40S"); alias.put("40RX", "40R"); alias.put("40RT", "40R"); alias.put("40R-FR", "40R"); @@ -307,60 +332,11 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } /** - * 刷新并计算项目延期状态 + * 刷新并计算项目延期状态(仅未出图完成的项目;完成后冻结超期标识与天数) */ @Override public void refreshDelayStatus() { - // 1. 查询所有未完成的项目(状态为 0) - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.in(ProcessOrderPro::getBomStatus, 0L); - // 确保计划结束时间不为空 - wrapper.isNotNull(ProcessOrderPro::getDrawingTime); - - List list = baseMapper.selectList(wrapper); - if (list == null || list.isEmpty()) { - return; - } - - Date now = new Date(); - List updates = new ArrayList<>(); - - for (ProcessOrderPro vo : list) { - Integer oldWhetherDelay = vo.getWhetherDelay(); - Long oldDelayDay = vo.getDelayDay(); - - // 检查子产品是否有图纸 - boolean hasProductDrawing = false; - List figureSaves = iFigureSaveService.selectByFid(vo.getId()); - if (figureSaves != null && !figureSaves.isEmpty()) { - hasProductDrawing = figureSaves.stream().anyMatch(f -> StringUtils.isNotBlank(f.getDrawPath())); - } - - // 如果项目或产品已经上传了图纸,则认为没有延期 - if (hasProductDrawing) { - vo.setWhetherDelay(0); - vo.setDelayDay(0L); - } else { - // 如果都没有上传图纸,则计算是否超过出图时间 - long diff = now.getTime() - vo.getDrawingTime().getTime(); - if (diff > 0) { - vo.setWhetherDelay(1); - vo.setDelayDay(diff / (1000 * 60 * 60 * 24)); - } else { - vo.setWhetherDelay(0); - vo.setDelayDay(0L); - } - } - // 检查是否发生变化 - if (!Objects.equals(oldWhetherDelay, vo.getWhetherDelay()) || !Objects.equals(oldDelayDay, vo.getDelayDay())) { - updates.add(vo); - } - } - // 批量更新 - if (!updates.isEmpty()) { - baseMapper.updateBatchById(updates); - log.info("已刷新 {} 条项目的延期状态", updates.size()); - } + orderProDrawingDelayService.refreshAll(); } /** @@ -372,6 +348,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { LambdaQueryWrapper lqw = buildQueryWrapper(bo); Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); fillProductionComplete(result.getRecords()); + fillDrawingMeta(result.getRecords()); return TableDataInfo.build(result); } @@ -450,6 +427,46 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return "完工".equals(operStatus) || "关闭".equals(operStatus); } + private void fillDrawingMeta(List rows) { + if (rows == null || rows.isEmpty()) { + return; + } + for (ProcessOrderProVo row : rows) { + row.setDrawingProgress(resolveDrawingProgress(row.getBomStatus(), row.getWhetherDelay())); + } + List ids = rows.stream() + .map(ProcessOrderProVo::getId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (ids.isEmpty()) { + return; + } + List workloads = drawingWorkloadMapper.selectList( + Wrappers.lambdaQuery() + .in(DrawingWorkload::getOrderProId, ids) + .select(DrawingWorkload::getOrderProId, DrawingWorkload::getEquivalentA4Total)); + if (workloads == null || workloads.isEmpty()) { + return; + } + Map a4ByProject = workloads.stream() + .filter(w -> w.getOrderProId() != null) + .collect(Collectors.toMap(DrawingWorkload::getOrderProId, DrawingWorkload::getEquivalentA4Total, (a, b) -> a)); + for (ProcessOrderProVo row : rows) { + row.setEquivalentA4Total(a4ByProject.get(row.getId())); + } + } + + private static String resolveDrawingProgress(Long bomStatus, Integer whetherDelay) { + if (bomStatus != null && bomStatus != DrawingDelayCalculator.BOM_STATUS_SCHEME_DONE) { + return "设计出图完成"; + } + if (Integer.valueOf(1).equals(whetherDelay)) { + return "延期"; + } + return "进行中"; + } + /** * 查询项目令号列表 */ @@ -509,6 +526,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } } lqw.eq(bo.getNeedProcess() != null, ProcessOrderPro::getNeedProcess, bo.getNeedProcess()); + lqw.like(StringUtils.isNotBlank(bo.getDrawingBy()), ProcessOrderPro::getDrawingBy, bo.getDrawingBy()); lqw.eq(bo.getNeedKingdeePrdMo() != null, ProcessOrderPro::getNeedKingdeePrdMo, bo.getNeedKingdeePrdMo()); lqw.eq(bo.getNeedKingdeePurchaseOrder() != null, ProcessOrderPro::getNeedKingdeePurchaseOrder, bo.getNeedKingdeePurchaseOrder()); //按照创建时间排序,最新的在顶端 @@ -535,9 +553,12 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { */ @Override public Boolean insertByBo(ProcessOrderProBo bo) { + // 防呆:生产令号去掉所有空白后再入库 + String productionOrderNo = ProductionOrderNoUtils.stripSpaces(bo.getProductionOrderNo()); + bo.setProductionOrderNo(productionOrderNo); // 下载excel文件 - log.info("下载excel文件.." + bo.getProductionOrderNo()); - SmbUtils.downloadExcelFiles(bo.getProductionOrderNo()); + log.info("下载excel文件.." + productionOrderNo); + SmbUtils.downloadExcelFiles(productionOrderNo); ProcessOrderPro add = BeanUtil.toBean(bo, ProcessOrderPro.class); //创建人换成昵称 @@ -550,7 +571,6 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } // 对项目令号的判断,根据前缀设置DrawingType - String productionOrderNo = bo.getProductionOrderNo(); if (StringUtils.isNotBlank(productionOrderNo)) { String isEnterpriseStandard = null; if (productionOrderNo.toUpperCase().contains("BG")) { @@ -578,6 +598,8 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { boolean flag = baseMapper.insert(add) > 0; if (flag) { bo.setId(add.getId()); + orderProDrawingDelayService.refreshByProjectId(add.getId()); + designTaskService.syncOrder(add, false); } return flag; } @@ -642,39 +664,58 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { cardDataMap.put("img_id", imgMediaId); String outTrackId = "project-card-" + System.currentTimeMillis(); String outTrackId1 = cardService.createAndDeliverCard(dingTalkProperties.getProjectCardTemplateId(), dingTalkProperties.getOpenConversationId(), outTrackId, cardDataMap, dingTalkProperties.getCallbackRouteKey()); - //同时发送给设计人员 - List designers = StringUtils.splitList(bo.getDrawingBy()); - // 收集手机号用于@ - List receiverMobiles = new ArrayList<>(); - //拿到用户id,这里将用户id存入email字段 + // 同时私发给设计人员:优先项目表设计人,否则用产品出图人汇总 + String designerSource = StringUtils.isNotBlank(bo.getDrawingBy()) ? bo.getDrawingBy() : drawingBy; + List designers = StringUtils.splitList(designerSource); + // email 字段存的是钉钉 userId(非手机号) + List receiverUserIds = new ArrayList<>(); for (String designer : designers) { - SysUser sysUser = userService.selectUserByUserName(designer); + if (StringUtils.isBlank(designer)) { + continue; + } + SysUser sysUser = userService.selectUserByUserName(designer.trim()); if (sysUser != null && StringUtils.isNotEmpty(sysUser.getEmail())) { - receiverMobiles.add(sysUser.getEmail()); - log.info("添加发送消息用户 {}: {}", sysUser.getNickName(), sysUser.getEmail()); + receiverUserIds.add(sysUser.getEmail().trim()); + log.info("添加私发卡片用户 {}: {}", sysUser.getNickName(), sysUser.getEmail()); + } else { + log.warn("出图人未找到钉钉userId(email),跳过私发: {}", designer); } } - //获取最晚的出图时间 - FigureSave latest = figureSaves.stream().filter(e -> e.getDrawTime() != null).max(Comparator.comparing(FigureSave::getDrawTime)).orElse(null); - - - if (!receiverMobiles.isEmpty()) { - // 去重 - receiverMobiles = receiverMobiles.stream().distinct().collect(Collectors.toList()); + if (!receiverUserIds.isEmpty()) { + receiverUserIds = receiverUserIds.stream().distinct().collect(Collectors.toList()); try { - log.info("发送群消息并@用户(手机号): {}", receiverMobiles); - // 使用 WebhookClient 发送消息 - webhookClient.sendText("https://oapi.dingtalk.com/robot/send?access_token=61525f1eccca9dde871df334918b1aac47f08e4ef71b8c689b46a091b3d45450", bo.getProductionOrderNo() + " 项目已创建", null, receiverMobiles); + log.info("私发项目卡片给设计人员: {}", receiverUserIds); + DingUtil.createAndDeliverCardToUser( + accessToken, + dingTalkProperties.getProjectCardTemplateId(), + receiverUserIds, + outTrackId, + cardDataMap, + dingTalkProperties.getCallbackRouteKey() + ); } catch (Exception e) { - log.error("发送@消息失败", e); + log.error("私发项目卡片给设计人员失败", e); } + try { + log.info("发送群消息并@设计人员(userId): {}", receiverUserIds); + webhookClient.sendText( + "https://oapi.dingtalk.com/robot/send?access_token=61525f1eccca9dde871df334918b1aac47f08e4ef71b8c689b46a091b3d45450", + bo.getProductionOrderNo() + " 项目已创建", + receiverUserIds, + null + ); + } catch (Exception e) { + log.error("发送群@消息失败", e); + } + } else { + log.warn("无可用设计人员钉钉userId,跳过私发与群@,令号: {}", bo.getProductionOrderNo()); } - // 更新数据库中的 auditCardId + // 更新数据库中的 auditCardId(不回写出图时间,出图时间由项目整体维护) ProcessOrderPro update = new ProcessOrderPro(); update.setId(processOrderPro.getId()); - update.setDrawingBy(drawingBy); - if (latest != null) { - update.setDrawingTime(latest.getDrawTime()); + // 项目设计人由表单维护,不覆盖已填写值 + if (StringUtils.isBlank(processOrderPro.getDrawingBy())) { + update.setDrawingBy(drawingBy); } update.setUpdateBy("Dingding"); update.setNotificationCardId(outTrackId1); @@ -689,11 +730,16 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } /** - * 修改项目令号 + * 修改项目令号。 */ @Override public Boolean updateByBo(ProcessOrderProBo bo) { ProcessOrderPro update = BeanUtil.toBean(bo, ProcessOrderPro.class); + ProcessOrderPro previous = update.getId() == null ? null : baseMapper.selectById(update.getId()); + // 编辑时同样去掉令号空白,避免改项目时又带进空格 + if (update.getProductionOrderNo() != null) { + update.setProductionOrderNo(ProductionOrderNoUtils.stripSpaces(update.getProductionOrderNo())); + } if (StringUtils.isNotBlank(update.getSource())) { update.setSource(resolveSource(update.getSource())); } @@ -703,7 +749,60 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { if (update.getNeedKingdeePurchaseOrder() != null) { update.setNeedKingdeePurchaseOrder(update.getNeedKingdeePurchaseOrder() == 1 ? 1 : 0); } - return baseMapper.updateById(update) > 0; + // 延期/齐套/审计字段由各自流程维护,编辑表单整包回写会拉长行锁并覆盖定时刷新结果 + update.setWhetherDelay(null); + update.setDelayDay(null); + update.setDrawingQtyIssued(null); + update.setRoutePlanSendCount(null); + update.setCreateBy(null); + update.setCreateTime(null); + // 空设计人不回写,避免把库里已有值更新成空串 + if (StringUtils.isBlank(update.getDrawingBy())) { + update.setDrawingBy(null); + } + boolean ok = baseMapper.updateById(update) > 0; + if (ok) { + orderProDrawingDelayService.refreshByProjectId(update.getId()); + syncDesignTasksAfterEdit(previous, update); + } + return ok; + } + + /** + * 编辑保存后同步设计任务。空设计人不派工;设计人有值时按当前名单对齐。 + */ + private void syncDesignTasksAfterEdit(ProcessOrderPro previous, ProcessOrderPro update) { + if (previous == null || update.getId() == null) { + return; + } + if (StringUtils.isNotBlank(update.getDrawingBy())) { + ProcessOrderPro snap = new ProcessOrderPro(); + snap.setId(update.getId()); + snap.setProductionOrderNo(StringUtils.isNotBlank(update.getProductionOrderNo()) + ? update.getProductionOrderNo() : previous.getProductionOrderNo()); + snap.setDrawingBy(update.getDrawingBy()); + snap.setBomStatus(previous.getBomStatus()); + snap.setDrawingTime(previous.getDrawingTime()); + designTaskService.syncOrder(snap, false); + return; + } + if (StringUtils.isNotBlank(update.getProductionOrderNo()) + && !update.getProductionOrderNo().equals(previous.getProductionOrderNo())) { + designTaskService.updateOrderNo(update.getId(), update.getProductionOrderNo()); + } + } + + /** + * 批量只改项目状态,不触碰设计人、工艺标记等其它字段。 + */ + @Override + public Boolean updateBomStatus(List ids, Long bomStatus) { + if (ids == null || ids.isEmpty() || bomStatus == null) { + throw new IllegalArgumentException("项目或状态不能为空"); + } + return baseMapper.update(null, Wrappers.lambdaUpdate() + .set(ProcessOrderPro::getBomStatus, bomStatus) + .in(ProcessOrderPro::getId, ids)) > 0; } /** @@ -720,6 +819,12 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return trimmed; } + private static void requireDrawingTime(Date drawingTime) { + if (drawingTime == null) { + throw new IllegalArgumentException("出图时间不能为空"); + } + } + /** * 保存前的数据校验 */ @@ -728,6 +833,16 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { if (StringUtils.isBlank(entity.getProductionOrderNo())) { throw new IllegalArgumentException("生产令号不能为空"); } + requireDrawingTime(entity.getDrawingTime()); + if (entity.getRouteUptime() == null) { + throw new IllegalArgumentException("工艺上传更新不能为空"); + } + if (entity.getPlanUptime() == null) { + throw new IllegalArgumentException("排产计划更新不能为空"); + } + if (entity.getBomUptime() == null) { + throw new IllegalArgumentException("BOM/物料更新不能为空"); + } LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(ProcessOrderPro::getProductionOrderNo, entity.getProductionOrderNo()); if (baseMapper.selectCount(queryWrapper) > 0) { @@ -749,6 +864,10 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { productionOrderMapper.deleteWithProCode(productionOrderNo); materialBomMapper.deleteWithProCode(productionOrderNo); bomDetailsMapper.deleteWithProCode(productionOrderNo); + ProcessOrderPro resetIssue = new ProcessOrderPro(); + resetIssue.setId(id); + resetIssue.setDrawingQtyIssued(0); + baseMapper.updateById(resetIssue); } return R.ok(); } @@ -777,6 +896,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { // 执行批量删除操作 boolean deleteResult = baseMapper.deleteBatchIds(ids) > 0; if (deleteResult) { + designTaskService.withdrawOpenByOrderIds(ids); log.info("成功删除 ID 列表: {}", ids); } else { log.error("删除 ID 列表失败: {}", ids); @@ -906,15 +1026,101 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { figureSave.setFileType(orderPro.getFileType()); figureSave.setDrawBy(orderPro.getDrawBy()); figureSave.setDelayDay(orderPro.getDelayDay()); - figureSave.setDrawTime(orderPro.getDrawTime()); + if (StringUtils.isNotBlank(orderPro.getDrawPath())) { + figureSave.setUploadTime(new Date()); + } try { figureSave.setJdInventory(Long.valueOf(journey)); } catch (Exception ignore) { } + assertPartModeSameDrawPath(orderPro.getPid(), orderPro.getDrawPath(), null); + //更新产品类型 figureSaveMapper.insert(figureSave); + orderProDrawingDelayService.refreshByProjectId(figureSave.getPid()); + } + + @Override + public Boolean updateDrawingMode(Long id, String drawingMode) { + if (id == null) { + throw new IllegalArgumentException("项目ID不能为空"); + } + String mode = DrawingMode.normalize(drawingMode); + if (!DrawingMode.isValid(mode)) { + throw new IllegalArgumentException("出图模式仅支持:normal(正常)、part(零部件)"); + } + ProcessOrderPro order = baseMapper.selectById(id); + if (order == null) { + throw new IllegalArgumentException("项目不存在"); + } + if (DrawingMode.PART.equals(mode)) { + List existing = figureSaveMapper.selectList( + new LambdaQueryWrapper().eq(FigureSave::getPid, id)); + assertAllSameDrawPath(existing, null, "切换为零部件模式前"); + } + ProcessOrderPro update = new ProcessOrderPro(); + update.setId(id); + update.setDrawingMode(mode); + return baseMapper.updateById(update) > 0; + } + + /** + * 零部件模式下校验新增/修改的图纸路径必须与已有产品一致。 + */ + private void assertPartModeSameDrawPath(Long pid, String drawPath, Long excludeFigureId) { + if (pid == null) { + return; + } + ProcessOrderPro order = baseMapper.selectById(pid); + if (order == null) { + throw new IllegalArgumentException("项目不存在"); + } + DrawingMode.requireConfigured(order); + if (!DrawingMode.PART.equals(DrawingMode.normalize(order.getDrawingMode()))) { + return; + } + String normalized = DrawingMode.normalizeDrawPath(drawPath); + if (StringUtils.isBlank(normalized)) { + throw new IllegalArgumentException("零部件出图模式下图纸路径不能为空"); + } + List existing = figureSaveMapper.selectList( + new LambdaQueryWrapper().eq(FigureSave::getPid, pid)); + assertAllSameDrawPath(existing, excludeFigureId, null); + for (FigureSave fs : existing) { + if (excludeFigureId != null && excludeFigureId.equals(fs.getId())) { + continue; + } + String existingPath = DrawingMode.normalizeDrawPath(fs.getDrawPath()); + if (StringUtils.isNotBlank(existingPath) && !existingPath.equals(normalized)) { + throw new IllegalArgumentException( + "零部件出图模式下所有产品必须使用同一图纸路径,已有路径:" + fs.getDrawPath()); + } + } + } + + private void assertAllSameDrawPath(List existing, Long excludeFigureId, String actionHint) { + if (existing == null || existing.isEmpty()) { + return; + } + String firstPath = null; + for (FigureSave fs : existing) { + if (excludeFigureId != null && excludeFigureId.equals(fs.getId())) { + continue; + } + String p = DrawingMode.normalizeDrawPath(fs.getDrawPath()); + if (StringUtils.isBlank(p)) { + continue; + } + if (firstPath == null) { + firstPath = p; + } else if (!firstPath.equals(p)) { + String prefix = StringUtils.isBlank(actionHint) ? "" : actionHint; + throw new IllegalArgumentException( + prefix + "产品图纸路径不一致,零部件模式要求同一路径。冲突:" + firstPath + " 与 " + p); + } + } } /** @@ -923,46 +1129,70 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { */ @Override public String executDrawing(ProcessOrderProBo orderPro) { - // 1. 参数验证 - validateOrderPro(orderPro); - - // 2. 查询生产令号(日志) - checkProductionOrderExists(orderPro.getProductionOrderNo()); - - // 2.1 检查出图服务是否繁忙 + long started = System.currentTimeMillis(); + ProcessOrderPro existing = null; + String drawingMode = null; + int productCount = 0; try { - String preStatusResp = HttpUtils.sendGet(statusUrl, "UTF-8"); - JSONObject preJson = JSONObject.parseObject(preStatusResp); - String preStatus = preJson.getString("status"); - if ("busy".equals(preStatus)) { - throw new RuntimeException("出图服务忙,有其他人在出图中,请稍后再试"); + // 1. 参数验证 + validateOrderPro(orderPro); + // 2. 查询生产令号(日志) + existing = checkProductionOrderExists(orderPro.getProductionOrderNo()); + if (existing == null && orderPro.getId() != null) { + existing = baseMapper.selectById(orderPro.getId()); } - } catch (Exception e) { - if (e.getMessage().contains("出图服务忙")) { - throw (RuntimeException) e; + drawingMode = existing != null ? existing.getDrawingMode() : orderPro.getDrawingMode(); + // 出图模式不能为空,必须先选正常/零部件(以库表为准) + DrawingMode.requireConfigured(drawingMode); + if (existing != null) { + String statusDeny = ProjectStatusLock.denyIfLocked(existing.getBomStatus(), ProjectStatusLock.Action.DRAWING); + if (statusDeny != null) { + throw new IllegalArgumentException(statusDeny); + } + } + // 2.1 检查出图服务是否繁忙 + try { + String preStatusResp = HttpUtils.sendGet(statusUrl, "UTF-8"); + JSONObject preJson = JSONObject.parseObject(preStatusResp); + String preStatus = preJson.getString("status"); + if ("busy".equals(preStatus)) { + throw new RuntimeException("出图服务忙,有其他人在出图中,请稍后再试"); + } + } catch (Exception e) { + if (e instanceof IllegalArgumentException) { + throw (IllegalArgumentException) e; + } + if (e.getMessage() != null && e.getMessage().contains("出图服务忙")) { + throw (RuntimeException) e; + } + log.warn("检查出图服务状态失败,继续执行: {}", e.getMessage()); } - log.warn("检查出图服务状态失败,继续执行: {}", e.getMessage()); - } - // 3. 构建路径 - String dirPath = buildDirectoryPath(orderPro.getProductionOrderNo()); + // 2.2 出图前:清本机 zip/文件,FTP 清 218 旧出图文件 + prepareFreshDrawingWorkspace(orderPro.getProductionOrderNo()); - // 4. 构建生产单据 - PwProductionBill pwProductionBill = buildProductionBill(orderPro, dirPath); + // 3. 构建路径 + String dirPath = buildDirectoryPath(orderPro.getProductionOrderNo()); - // 5. 获取产品信息 - List productInfos = buildProductInfos(orderPro.getId()); - pwProductionBill.setProduct(productInfos); + // 4. 构建生产单据 + PwProductionBill pwProductionBill = buildProductionBill(orderPro, dirPath); - // 6. 调用API - String apiResult = null; - try { - apiResult = callDrawingApi(pwProductionBill, orderPro); + // 5. 获取产品信息 + List productInfos = buildProductInfos(orderPro.getId()); + productCount = productInfos == null ? 0 : productInfos.size(); + pwProductionBill.setProduct(productInfos); + + // 6. 调用API + String apiResult = callDrawingApi(pwProductionBill, orderPro); + refreshLocalDrawingFilesFromFtp(orderPro.getProductionOrderNo()); // 出图成功后不再自动发钉钉;由前端调用 sendProcessRoutePlanExcel 一并发送 Markdown 通知与工艺计划表 Excel - + recordDrawingExec(existing, orderPro, drawingMode, productCount, IDrawingRouteOperLogService.STATUS_SUCCESS, + null, apiResult, started); return apiResult; } catch (Exception e) { log.error("调用出图API失败", e); + recordDrawingExec(existing, orderPro, drawingMode, productCount, IDrawingRouteOperLogService.STATUS_FAIL, + e.getMessage(), null, started); throw e; } } @@ -1001,18 +1231,28 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { if (order == null) { return R.fail("项目不存在:" + productionOrderNo); } - ProcessOrderPro update = new ProcessOrderPro(); - update.setId(order.getId()); - update.setRouteStatus(1L); - update.setBomStatus(8L); - try { - update.setUpdateBy(LoginHelper.getUsername()); - } catch (Exception e) { - update.setUpdateBy("ManualApprove"); + // 幂等:已审核通过则不再发钉钉(防网页重复点 / 与钉钉回调并发) + if (Long.valueOf(1L).equals(order.getRouteStatus())) { + return R.ok("已审核通过,无需重复操作"); + } + String updateBy; + try { + updateBy = LoginHelper.getUsername(); + } catch (Exception e) { + updateBy = "ManualApprove"; + } + LambdaUpdateWrapper uw = Wrappers.lambdaUpdate(); + uw.eq(ProcessOrderPro::getId, order.getId()) + .and(w -> w.isNull(ProcessOrderPro::getRouteStatus).or().ne(ProcessOrderPro::getRouteStatus, 1L)) + .set(ProcessOrderPro::getRouteStatus, 1L) + .set(ProcessOrderPro::getBomStatus, 8L) + .set(ProcessOrderPro::getUpdateBy, updateBy); + int rows = baseMapper.update(null, uw); + if (rows <= 0) { + log.info("工艺审核已通过(并发/重复),跳过通知 productionOrder={}", order.getProductionOrderNo()); + return R.ok("已审核通过,无需重复操作"); } - baseMapper.updateById(update); log.info("工艺审核通过(手动)productionOrder={}", order.getProductionOrderNo()); - sendRouteAuditCompleteNotification(order); return R.ok("审核通过,已发送通知"); } @@ -1059,15 +1299,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { markdownText.append("> **是否企标**: ").append(Objects.equals(po.getIsEnterpriseStandard(), "1") ? "是" : "否").append("\n\n"); } - String bomStatus = null; - if (po.getBomStatus() != null) { - if (po.getBomStatus() == 0) bomStatus = "未完成"; - else if (po.getBomStatus() == 1) bomStatus = "完成"; - else if (po.getBomStatus() == 2) bomStatus = "已上传"; - else if (po.getBomStatus() == 8) bomStatus = "工艺审核完成"; - else if (po.getBomStatus() == 9) bomStatus = "工艺审核拒绝"; - else bomStatus = String.valueOf(po.getBomStatus()); - } + String bomStatus = po.getBomStatus() == null ? null : ProjectStatusLock.statusLabel(po.getBomStatus()); String routeStatus = null; if (po.getRouteStatus() != null) { if (po.getRouteStatus() == 0) routeStatus = "未审核"; @@ -1094,12 +1326,22 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { * 发送「出图完成」Markdown 通知,并生成工艺计划表 Excel(generateRoute3Excel(id, true))上传钉钉媒体发给 notifyUserId。 */ private R sendProcessRoutePlanExcelInternal(ProcessOrderPro orderPro) { + long started = System.currentTimeMillis(); Long id = orderPro.getId(); String productionOrderNo = orderPro.getProductionOrderNo(); + if (SkipProcessNotifyService.isSkipProcess(orderPro.getNeedProcess())) { + log.info("项目无需做工艺,跳过出图后推送给工艺,令号: {}", productionOrderNo); + recordDrawingNotifySkipped(orderPro, started); + return R.ok("项目无需做工艺,已跳过推送给工艺"); + } log.info("开始发送出图完成通知并下发工艺计划表Excel,令号: {}", productionOrderNo); + String notifyUserIds = dingTalkProperties.getNotifyUserId(); + String fileName = null; - if (StringUtils.isBlank(dingTalkProperties.getNotifyUserId())) { + if (StringUtils.isBlank(notifyUserIds)) { log.warn("未配置钉钉通知个人ID(notifyUserId),无法发送工艺计划表文件"); + recordDrawingNotify(orderPro, notifyUserIds, null, IDrawingRouteOperLogService.STATUS_FAIL, + "未配置钉钉通知用户(notifyUserId),无法发送文件", started); return R.fail("未配置钉钉通知用户(notifyUserId),无法发送文件"); } @@ -1107,6 +1349,8 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { sendDrawingCompleteMarkdownNotify(orderPro); } catch (Exception e) { log.error("发送出图完成钉钉Markdown通知失败, id={}", id, e); + recordDrawingNotify(orderPro, notifyUserIds, null, IDrawingRouteOperLogService.STATUS_FAIL, + "发送钉钉通知失败: " + e.getMessage(), started); return R.fail("发送钉钉通知失败: " + e.getMessage()); } @@ -1116,30 +1360,53 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { filePath = controller.generateRoute3Excel(id, true); } catch (Exception e) { log.error("生成工艺计划表Excel失败, id={}", id, e); + recordDrawingNotify(orderPro, notifyUserIds, null, IDrawingRouteOperLogService.STATUS_PARTIAL, + "Markdown已发送,生成Excel失败: " + e.getMessage(), started); return R.fail("生成Excel失败: " + e.getMessage()); } if (StringUtils.isBlank(filePath)) { + recordDrawingNotify(orderPro, notifyUserIds, null, IDrawingRouteOperLogService.STATUS_PARTIAL, + "Markdown已发送,生成Excel失败:未返回文件路径", started); return R.fail("生成Excel失败:未返回文件路径"); } File excelFile = new File(filePath); + fileName = excelFile.getName(); if (!excelFile.exists() || excelFile.length() <= 0) { + recordDrawingNotify(orderPro, notifyUserIds, fileName, IDrawingRouteOperLogService.STATUS_PARTIAL, + "Markdown已发送,Excel文件不存在或为空: " + filePath, started); return R.fail("Excel文件不存在或为空: " + filePath); } try { String mediaId = mediaService.uploadMedia("file", filePath); - robotGroupService.sendFileToUser(dingTalkProperties.getNotifyUserId(), mediaId, excelFile.getName()); + robotGroupService.sendFileToUser(notifyUserIds, mediaId, excelFile.getName()); log.info("工艺计划表Excel已发送至钉钉用户,令号: {}", productionOrderNo); } catch (Exception e) { log.error("钉钉发送工艺计划表文件失败, id={}", id, e); if (excelFile.exists() && !excelFile.delete()) { log.warn("临时Excel删除失败: {}", excelFile.getAbsolutePath()); } + recordDrawingNotify(orderPro, notifyUserIds, fileName, IDrawingRouteOperLogService.STATUS_PARTIAL, + "Markdown已发送,钉钉发送失败: " + e.getMessage(), started); return R.fail("钉钉发送失败: " + e.getMessage()); } + String todoError = null; + try { + todoError = dingTodoDispatchService.sendIfEnabled( + DingTodoSceneCodes.DRAWING_PLAN_EXCEL, + productionOrderNo, + orderPro.getProductionName(), + orderPro.getProjectEndTime(), + excelFile, + notifyUserIds); + } catch (Exception e) { + todoError = e.getMessage(); + log.error("工艺计划表待办创建失败, 令号={}", productionOrderNo, e); + } + if (excelFile.exists() && !excelFile.delete()) { log.warn("临时Excel删除失败: {}", excelFile.getAbsolutePath()); } @@ -1156,9 +1423,86 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { log.error("记录工艺计划表发送次数失败, id={}", id, ex); } + if (todoError != null) { + recordDrawingNotify(orderPro, notifyUserIds, fileName, IDrawingRouteOperLogService.STATUS_PARTIAL, + "文件已发送,待办创建失败: " + todoError, started); + return R.ok("文件已发送,待办创建失败: " + todoError); + } + recordDrawingNotify(orderPro, notifyUserIds, fileName, IDrawingRouteOperLogService.STATUS_SUCCESS, null, started); return R.ok("发送成功"); } + private void recordDrawingExec(ProcessOrderPro existing, ProcessOrderProBo orderPro, String drawingMode, + int productCount, String status, String errorMsg, String apiResult, long started) { + recordDrawingExec(existing, orderPro, drawingMode, productCount, status, errorMsg, apiResult, started, + "/system/orderPro/executDrawing", null, null); + } + + private void recordDrawingExec(ProcessOrderPro existing, ProcessOrderProBo orderPro, String drawingMode, + int productCount, String status, String errorMsg, String apiResult, long started, + String apiPath, String operator, String clientIp) { + String orderNo = existing != null ? existing.getProductionOrderNo() + : (orderPro == null ? null : orderPro.getProductionOrderNo()); + Long projectId = existing != null ? existing.getId() : (orderPro == null ? null : orderPro.getId()); + Map summary = new LinkedHashMap<>(); + summary.put("productCount", productCount); + if (StringUtils.isNotBlank(apiResult)) { + summary.put("apiResult", apiResult.length() > 400 ? apiResult.substring(0, 400) : apiResult); + } + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_DRAWING_EXEC, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_MANUAL); + row.setApiPath(apiPath); + row.setProjectId(projectId); + row.setProductionOrderNo(orderNo); + row.setDrawingMode(drawingMode); + row.setStatus(status); + row.setErrorMsg(errorMsg); + row.setSummaryJson(JSONObject.toJSONString(summary)); + row.setDurationMs(System.currentTimeMillis() - started); + if (StringUtils.isNotBlank(operator)) { + row.setOperator(operator); + } + if (StringUtils.isNotBlank(clientIp)) { + row.setClientIp(clientIp); + } + }); + } + + /** 无需做工艺:不生成工艺表、不发给工艺接收人,只留一条跳过记录。 */ + private void recordDrawingNotifySkipped(ProcessOrderPro orderPro, long started) { + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_DRAWING_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_MANUAL); + row.setApiPath("/system/orderPro/sendProcessRoutePlanExcel"); + row.setProjectId(orderPro == null ? null : orderPro.getId()); + row.setProductionOrderNo(orderPro == null ? null : orderPro.getProductionOrderNo()); + row.setDrawingMode(orderPro == null ? null : orderPro.getDrawingMode()); + row.setMessageTitle("出图后推送已跳过"); + row.setMessageContent("项目无需做工艺,未推送给工艺"); + row.setStatus(IDrawingRouteOperLogService.STATUS_SKIP); + row.setErrorMsg("无需做工艺,未推送给工艺"); + row.setDurationMs(System.currentTimeMillis() - started); + }); + } + + private void recordDrawingNotify(ProcessOrderPro orderPro, String notifyUserIds, String fileName, + String status, String errorMsg, long started) { + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_DRAWING_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_MANUAL); + row.setApiPath("/system/orderPro/sendProcessRoutePlanExcel"); + row.setProjectId(orderPro == null ? null : orderPro.getId()); + row.setProductionOrderNo(orderPro == null ? null : orderPro.getProductionOrderNo()); + row.setDrawingMode(orderPro == null ? null : orderPro.getDrawingMode()); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_MARKDOWN_AND_FILE); + row.setNotifyUserIds(notifyUserIds); + row.setMessageTitle("生产项目创建通知"); + row.setMessageContent("项目创建出图完成,请及时填写工艺"); + row.setFileName(fileName); + row.setStatus(status); + row.setErrorMsg(errorMsg); + row.setDurationMs(System.currentTimeMillis() - started); + }); + } + private void validateOrderPro(ProcessOrderProBo orderPro) { if (orderPro == null || StringUtils.isBlank(orderPro.getProductionOrderNo())) { throw new IllegalArgumentException("生产令号不能为空"); @@ -1177,6 +1521,66 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return processOrderPro; } + /** + * 出图工作目录:本机 {@code D:/{年}/{令号}/},FTP 218 为 {@code /{年}/{令号}/}。 + */ + private File localDrawingOrderDir(String productionOrderNo) { + return new File(buildWorkDirectory(productionOrderNo), productionOrderNo); + } + + private String drawingFtpOrderPath(String productionOrderNo) { + String year = ProductionOrderNoUtils.extractYear(productionOrderNo); + return ("/" + year + "/" + productionOrderNo).replace("\\", "/").replaceAll("/+", "/"); + } + + /** + * 出图前:删除本机该令号下已生成的 zip 及全部文件,再通过 FTP 清空 218 上同令号旧出图目录。 + */ + private void prepareFreshDrawingWorkspace(String productionOrderNo) { + File orderDir = localDrawingOrderDir(productionOrderNo); + log.info("出图前清理本机工作目录: {}", orderDir.getAbsolutePath()); + try { + if (orderDir.exists()) { + deleteDirectory(orderDir); + } + if (!orderDir.mkdirs() && !orderDir.isDirectory()) { + throw new IOException("无法重建本机令号目录: " + orderDir.getAbsolutePath()); + } + } catch (IOException e) { + throw new RuntimeException("清理本机出图文件失败: " + e.getMessage(), e); + } + String ftpPath = drawingFtpOrderPath(productionOrderNo); + log.info("出图前清理 218 FTP 旧出图文件: {}:{}{}", + drawingFtpProperties.getDownload().getHost(), + drawingFtpProperties.getDownload().getPort(), + ftpPath); + R deleted = FtpUtil.deleteRemoteDirectoryContents(drawingFtpProperties.getDownload(), ftpPath); + if (deleted.getCode() != 200) { + throw new RuntimeException("清理218出图文件失败: " + deleted.getMsg()); + } + log.info("218 FTP 清理完成: {}", deleted.getMsg()); + } + + /** + * 出图完成后:从 218 FTP 将该令号目录整棵拉回本机。下载失败只记日志,不回滚已完成的出图。 + */ + private void refreshLocalDrawingFilesFromFtp(String productionOrderNo) { + File orderDir = localDrawingOrderDir(productionOrderNo); + String ftpPath = drawingFtpOrderPath(productionOrderNo); + log.info("出图完成,从218 FTP重新下载: {} -> {}", ftpPath, orderDir.getAbsolutePath()); + try { + R downloaded = FtpUtil.downloadFtpDirectoryRecursive( + drawingFtpProperties.getDownload(), ftpPath, orderDir.getAbsolutePath()); + if (downloaded.getCode() != 200) { + log.error("出图完成但从218下载失败: {}", downloaded.getMsg()); + return; + } + log.info("出图完成后本机已从218刷新: {}", downloaded.getMsg()); + } catch (Exception e) { + log.error("出图完成后从218下载异常 令号={}", productionOrderNo, e); + } + } + private String buildDirectoryPath(String productionOrderNo) { String yearPart = extractYearPart(productionOrderNo); String dirPath = Paths.get(basePath, yearPrefix + yearPart).toString(); @@ -1222,25 +1626,23 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { if (StringUtils.isBlank(productionCode) && !figureSaveList.isEmpty()) { productionCode = figureSaveList.get(0).getProductionCode(); } - String normalizedProductionCode = productionCode == null ? "" : productionCode.trim().toUpperCase(Locale.ROOT); - //判断是企标 - boolean isQbProject = normalizedProductionCode.startsWith("QB"); - if (isQbProject) { + // 零部件模式:按图纸路径合并为 ProductInfo;正常模式:每产品一行 + if (DrawingMode.isPartMode(processOrderPro)) { if (figureSaveList.isEmpty()) { return Collections.emptyList(); } - // 同一图纸路径下可有多个子件;不同路径各自一个 ProductInfo,sourcefile 与子件与路径一致 + // 同一图纸路径下可有多个子件;不同路径各自一个 ProductInfo(兼容历史多路径数据) Map> byDrawPath = new LinkedHashMap<>(); for (FigureSave fs : figureSaveList) { - byDrawPath.computeIfAbsent(qbDrawPathGroupKey(fs), k -> new ArrayList<>()).add(fs); + byDrawPath.computeIfAbsent(partDrawPathGroupKey(fs), k -> new ArrayList<>()).add(fs); } - List qbProducts = new ArrayList<>(byDrawPath.size()); - int qbSeq = 1; + List partProducts = new ArrayList<>(byDrawPath.size()); + int partSeq = 1; for (List pathGroup : byDrawPath.values()) { - qbProducts.add(buildQbProductInfoForPathGroup(pathGroup, preservePartDrawing, qbSeq, productionCode)); - qbSeq++; + partProducts.add(buildPartProductInfoForPathGroup(pathGroup, preservePartDrawing, partSeq, productionCode)); + partSeq++; } - return qbProducts; + return partProducts; } return figureSaveList.stream().map(fs -> { SpecStrategy st = getStrategyForType(fs.getProductType()); @@ -1251,32 +1653,30 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { }).collect(Collectors.toList()); } - /** 企标按图纸路径分组:空路径按行拆开,避免误合并。 */ - private String qbDrawPathGroupKey(FigureSave fs) { + /** 零部件按图纸路径分组:空路径按行拆开,避免误合并。 */ + private String partDrawPathGroupKey(FigureSave fs) { String raw = fs.getDrawPath(); if (StringUtils.isBlank(raw)) { return "__row__" + (fs.getId() != null ? fs.getId() : System.identityHashCode(fs)); } - return raw.trim().replace('\\', '/'); + return DrawingMode.normalizeDrawPath(raw); } - private boolean isQbProductionOrderNo(String orderProductionNo) { - if (StringUtils.isBlank(orderProductionNo)) { - return false; - } - return orderProductionNo.trim().toUpperCase(Locale.ROOT).startsWith("QB"); + /** 是否零部件出图(以 drawing_mode=part 为准,必须先选出图模式) */ + private boolean isPartDrawingMode(ProcessOrderPro order) { + return DrawingMode.isPartMode(order); } /** - * 与 {@link #buildProductInfos} 企标分组、assembledrawing(生产令号-序号)规则一致,得到 FTP/本地 PDF 目录名(如 QBxx-1-PDF)。 + * 与 {@link #buildProductInfos} 零部件分组、assembledrawing(生产令号-序号)规则一致,得到 FTP/本地 PDF 目录名(如 QBxx-1-PDF)。 */ - private List qbPdfDirectoryNamesOrdered(String orderProductionNo, List figureSaves) { + private List partPdfDirectoryNamesOrdered(String orderProductionNo, List figureSaves) { if (figureSaves == null || figureSaves.isEmpty()) { return Collections.emptyList(); } Map> byDrawPath = new LinkedHashMap<>(); for (FigureSave fs : figureSaves) { - byDrawPath.computeIfAbsent(qbDrawPathGroupKey(fs), k -> new ArrayList<>()).add(fs); + byDrawPath.computeIfAbsent(partDrawPathGroupKey(fs), k -> new ArrayList<>()).add(fs); } String base = orderProductionNo == null ? "" : orderProductionNo.trim(); if (StringUtils.isBlank(base)) { @@ -1294,7 +1694,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return names; } - private ProductInfo buildQbProductInfoForPathGroup(List pathGroup, boolean preservePartDrawing, int qbAssemblySeq, String productionCodeBase) { + private ProductInfo buildPartProductInfoForPathGroup(List pathGroup, boolean preservePartDrawing, int assemblySeq, String productionCodeBase) { FigureSave first = pathGroup.get(0); List subparts = pathGroup.stream().map(fs -> { Subparts sp = new Subparts(); @@ -1313,7 +1713,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { String fromRow = first.getProductionCode(); base = fromRow == null ? "" : fromRow.trim(); } - resolved.setAssembledrawing(base + "-" + qbAssemblySeq); + resolved.setAssembledrawing(base + "-" + assemblySeq); return resolved; } @@ -1662,6 +2062,10 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { processOrderPro.setBomStatus(1L); baseMapper.updateById(processOrderPro); log.info("图纸处理完成!"); + ProcessOrderPro latest = processOrderPro.getId() != null + ? baseMapper.selectById(processOrderPro.getId()) : processOrderPro; + skipProcessNotifyService.notifyDrawingCompleteIfSkipProcess( + latest != null ? latest : processOrderPro); return response; } long sleepMs = tick < 60 ? 1000L : (tick < 240 ? 2000L : 5000L); @@ -1680,18 +2084,25 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { @Override public SseEmitter startDrawingSse(Long id) { SseEmitter emitter = new SseEmitter(0L); + String operator = drawingRouteOperLogService.currentOperator(); + String clientIp = drawingRouteOperLogService.currentClientIp(); new Thread(() -> { + long started = System.currentTimeMillis(); + ProcessOrderPro order = null; + int productCount = 0; try { - ProcessOrderPro order = baseMapper.selectById(id); + order = baseMapper.selectById(id); + DrawingMode.requireConfigured(order); + prepareFreshDrawingWorkspace(order.getProductionOrderNo()); ProcessOrderProBo bo = new ProcessOrderProBo(); BeanUtils.copyProperties(order, bo); String dirPath = buildDirectoryPath(order.getProductionOrderNo()); PwProductionBill bill = buildProductionBill(bo, dirPath); List infos = buildProductInfos(order.getId()); + productCount = infos == null ? 0 : infos.size(); bill.setProduct(infos); String startResp = HttpUtils.sendPost(apiUrl, JSONObject.toJSONString(bill)); emitter.send(SseEmitter.event().name("start").data(startResp)); - boolean finished = false; int maxRetry = 360; int count = 0; while (count < maxRetry) { @@ -1705,17 +2116,29 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { BeanUtils.copyProperties(order, update); update.setBomStatus(1L); baseMapper.updateById(update); + refreshLocalDrawingFilesFromFtp(order.getProductionOrderNo()); + ProcessOrderPro latest = baseMapper.selectById(id); + skipProcessNotifyService.notifyDrawingCompleteIfSkipProcess( + latest != null ? latest : update); emitter.send(SseEmitter.event().name("completed").data(startResp)); - finished = true; + recordDrawingExec(order, bo, order.getDrawingMode(), productCount, + IDrawingRouteOperLogService.STATUS_SUCCESS, null, startResp, started, + "/system/orderPro/executDrawing/progress", operator, clientIp); emitter.complete(); return; } Thread.sleep(1000); count++; } + recordDrawingExec(order, null, order == null ? null : order.getDrawingMode(), productCount, + IDrawingRouteOperLogService.STATUS_FAIL, "timeout", null, started, + "/system/orderPro/executDrawing/progress", operator, clientIp); emitter.send(SseEmitter.event().name("error").data("timeout")); emitter.complete(); } catch (Exception ex) { + recordDrawingExec(order, null, order == null ? null : order.getDrawingMode(), productCount, + IDrawingRouteOperLogService.STATUS_FAIL, ex.getMessage(), null, started, + "/system/orderPro/executDrawing/progress", operator, clientIp); try { emitter.send(SseEmitter.event().name("error").data(Objects.toString(ex.getMessage(), "error"))); } catch (Exception ignore) { @@ -1745,22 +2168,17 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return R.fail("未找到对应的产品信息"); } - // 假设这些文件属于同一个项目(生产令号) - String productionCode = figureSave.getProductionCode(); + String productionCode = resolveDrawingOrderNo(figureSave); if (StringUtils.isBlank(productionCode)) { return R.fail("产品信息中生产令号为空"); } - String year = ProductionOrderNoUtils.extractYear(productionCode); - - // 生成到本地的路径: 根据项目号生成文件夹 String localBaseDir = buildWorkDirectory(productionCode); String targetDir = localBaseDir + productionCode + "/dwg"; File targetDirectory = new File(targetDir); if (!targetDirectory.exists()) { targetDirectory.mkdirs(); } else { - // 清空之前的旧文件 File[] files = targetDirectory.listFiles(); if (files != null) { for (File f : files) { @@ -1774,37 +2192,25 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return R.fail("图号为空,无法下载DWG文件"); } - // 替换图号中的 / 为 - - String safeFigureNumber = figureNumber.replace("/", "-"); - String dwgFileName = safeFigureNumber + ".dwg"; + String remoteDir = drawingFtpOrderPath(productionCode); + log.info("下载DWG: 令号={}, 图号={}, 远程目录={}", productionCode, figureNumber, remoteDir); - - String rootRemoteDir = "/" + year + "/" + productionCode; - - log.info("尝试下载DWG文件, 远程目录: {}, 本地目录: {}, 文件名: {}", rootRemoteDir, targetDir, dwgFileName); - - // 参考单元测试,调用 FtpUtil 下载文件 - R downloadResult = downloadDrawingFtpDirectory(rootRemoteDir, targetDir, dwgFileName); - - if (downloadResult.getCode() != 200) { - // 尝试加一层子目录去下载(防止它藏在图号同名文件夹下) - String subRemoteDir = "/" + year + "/" + productionCode + "/" + safeFigureNumber; - log.warn("根目录 {} 下载失败,尝试从子目录 {} 下载", rootRemoteDir, subRemoteDir); - downloadResult = downloadDrawingFtpDirectory(subRemoteDir, targetDir, dwgFileName); + R downloadResult = FtpUtil.downloadMatchingFileRecursive( + drawingFtpProperties.getDownload(), remoteDir, targetDir, + fileName -> matchesDrawingDwgFileName(fileName, figureNumber)); + if (downloadResult.getCode() != 200 && downloadResult.getMsg() != null + && downloadResult.getMsg().contains("远程目录不存在")) { + String destPathDir = "F:" + remoteDir; + log.warn("FTP路径 {} 不存在,尝试出图盘符路径 {}", remoteDir, destPathDir); + downloadResult = FtpUtil.downloadMatchingFileRecursive( + drawingFtpProperties.getDownload(), destPathDir, targetDir, + fileName -> matchesDrawingDwgFileName(fileName, figureNumber)); } if (downloadResult.getCode() != 200) { - // 有的旧数据可能在 /2026/KC-26-026-FS/dwg 下 - String dwgRemoteDir = "/" + year + "/" + productionCode + "/dwg"; - log.warn("子目录下载也失败,尝试从dwg子目录 {} 下载", dwgRemoteDir); - downloadResult = downloadDrawingFtpDirectory(dwgRemoteDir, targetDir, dwgFileName); + return R.fail("DWG文件下载失败: " + figureNumber + ".dwg - " + downloadResult.getMsg()); } - if (downloadResult.getCode() != 200) { - return R.fail("DWG文件下载失败: " + dwgFileName + " - " + downloadResult.getMsg()); - } - - // 直接返回目标文件夹路径 return R.ok(targetDir); } catch (Exception e) { @@ -1813,6 +2219,36 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } } + /** 出图目录以项目令号为准(与 CAD outputpath F:\{年}\{令号}\ 一致)。 */ + private String resolveDrawingOrderNo(FigureSave figureSave) { + if (figureSave.getPid() != null) { + ProcessOrderPro order = baseMapper.selectById(figureSave.getPid()); + if (order != null && StringUtils.isNotBlank(order.getProductionOrderNo())) { + return order.getProductionOrderNo().trim(); + } + } + return figureSave.getProductionCode(); + } + + /** + * 出图 DWG 文件名匹配:精确图号,或去掉 CAD 序号前缀后再比对。 + */ + static boolean matchesDrawingDwgFileName(String fileName, String figureNumber) { + if (fileName == null || figureNumber == null) { + return false; + } + String lower = fileName.toLowerCase(Locale.ROOT); + if (!lower.endsWith(".dwg")) { + return false; + } + String base = fileName.substring(0, fileName.length() - 4).trim(); + String fig = figureNumber.trim(); + if (base.equalsIgnoreCase(fig) || base.equalsIgnoreCase(fig.replace("/", "-"))) { + return true; + } + return fig.equalsIgnoreCase(figureFilenameBaseToDrawingNo(base)); + } + /** * DWG 文件名主体 → 库表图号:去掉开头「数字序号-」;{@code --} 表示 {@code /};其余单个 {@code -} 转为 {@code /}。 * 例:{@code 5-80R.13.0-1440--D} → {@code 80R.13.0/1440/D} @@ -1990,6 +2426,68 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } } + /** + * 设计人员确认图纸已齐。本批数量不落库,下载 PDF 时按图纸推送明细(plm_pdf_zip_push_item)取值。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public R> issueDrawingQty(Long id) { + if (id == null) { + return R.fail("项目ID不能为空"); + } + ProcessOrderPro order = baseMapper.selectById(id); + if (order == null) { + return R.fail("项目不存在"); + } + if (!ProcessOrderProSource.PLM.equals(order.getSource())) { + return R.fail("仅 PLM 来源项目需确认图纸已齐,其它项目不受此限制"); + } + String productionOrderNo = StringUtils.trimToEmpty(order.getProductionOrderNo()); + if (StringUtils.isBlank(productionOrderNo)) { + return R.fail("项目未配置生产令号"); + } + List rows = productionOrderMapper.selectList( + Wrappers.lambdaQuery().eq(ProductionOrder::getProductionOrderNo, productionOrderNo)); + Map rowCountByDrawingNo = new LinkedHashMap<>(); + if (rows != null) { + for (ProductionOrder row : rows) { + String drawingNo = StringUtils.trimToEmpty(row.getDrawingNo()); + if (StringUtils.isBlank(drawingNo)) { + continue; + } + rowCountByDrawingNo.merge(drawingNo, 1, Integer::sum); + } + } + int sharedPartCount = 0; + for (Integer count : rowCountByDrawingNo.values()) { + if (count != null && count > 1) { + sharedPartCount++; + } + } + if (rowCountByDrawingNo.isEmpty()) { + log.info("确认图纸已齐:总装无物料,直接允许下载 令号={}", productionOrderNo); + } + + ProcessOrderPro update = new ProcessOrderPro(); + update.setId(order.getId()); + update.setDrawingQtyIssued(1); + update.setUpdateTime(new Date()); + try { + update.setUpdateBy(LoginHelper.getUsername()); + } catch (Exception ignored) { + update.setUpdateBy("system"); + } + baseMapper.updateById(update); + + Map data = new LinkedHashMap<>(); + data.put("productionOrderNo", productionOrderNo); + data.put("partCount", rowCountByDrawingNo.size()); + data.put("sharedPartCount", sharedPartCount); + log.info("确认图纸已齐: 令号={}, 图号数={}, 共用件数={}", + productionOrderNo, rowCountByDrawingNo.size(), sharedPartCount); + return R.ok("图纸已确认齐套,下载 PDF 时单台/本批数量以图纸推送明细为准", data); + } + /** * @param id */ @@ -2012,102 +2510,99 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { String code = processOrderPro.getProductionOrderNo(); String source = processOrderPro.getSource(); + if (ProcessOrderProSource.PLM.equals(source) + && !Integer.valueOf(1).equals(processOrderPro.getDrawingQtyIssued())) { + log.warn("uploadPDF 失败: id={}, 令号={}, 原因=图纸尚未确认齐套", id, code); + return R.fail("请先点击「确认图纸已齐」,图纸全部上传完成后再下载 PDF"); + } // 复用首次查询结果,避免二次 selectByFid(结果与原先一致) List fiseList = figureSaves; log.info("uploadPDF 项目信息: id={}, 令号={}, source={}, 产品数={}", id, code, source, fiseList.size()); - // 4. 构建本地根目录(后续复用,不再二次 buildWorkDirectory) - // 修正:不再硬编码为 D:/2025/,而是根据 code 动态构建(与后续处理逻辑保持一致) - String arr = buildWorkDirectory(code); - // 5. 清空本地目标文件夹 - String targetDir = arr + code; - File targetDirectory = new File(targetDir); - if (targetDirectory.exists()) { - log.info("清空目标文件夹: {}", targetDir); - try { - deleteDirectory(targetDirectory); - log.info("目标文件夹清空完成: {}", targetDir); - } catch (Exception e) { - log.error("清空目标文件夹失败: {}", targetDir, e); - return R.fail("清空目标文件夹失败: " + e.getMessage()); - } - } - - // 6. 获取 PDF:PLM 来源走本地目录,手动来源走 FTP boolean fromPlm = ProcessOrderProSource.PLM.equals(processOrderPro.getSource()); - // PLM 标注上下文只加载一次,取图改名与盖章共用(逻辑不变,少一次 DB/磁盘扫描) PlmPdfMarkContext plmMarkContext = fromPlm ? loadPlmPdfMarkContext(code) : null; - if (fromPlm) { - log.info("项目来源=PLM,从本地目录加载PDF(跳过FTP): {}/{}", plmPdfBaseDir, code); - R plmStage = copyPlmPdfFromLocalDirectory(code, arr, plmMarkContext); - if (plmStage.getCode() != 200) { - log.warn("uploadPDF PLM本地PDF加载失败: id={}, 令号={}, msg={}", id, code, plmStage.getMsg()); - return R.fail(plmStage.getMsg()); + String yearRoot = buildWorkDirectory(code); + File orderDir = new File(yearRoot, code); + File stableZip = new File(orderDir, code + ".zip"); + + Object uploadLock = uploadPdfLocks.computeIfAbsent(code, k -> new Object()); + synchronized (uploadLock) { + if (isStablePdfZipReusable(stableZip, code, fromPlm, plmMarkContext)) { + log.info("uploadPDF 复用已生成ZIP: id={}, 令号={}, path={}, size={}", + id, code, stableZip.getAbsolutePath(), stableZip.length()); + return R.ok(stableZip.getAbsolutePath()); } - log.info("uploadPDF PLM本地PDF加载成功: id={}, 令号={}, workDir={}", id, code, plmStage.getMsg()); - } else { - log.info("项目来源=手动,开始从FTP下载文件..."); - R ftpStage = downloadPdfFromFtp(code, arr, fiseList); - if (ftpStage.getCode() != 200) { - log.warn("uploadPDF FTP下载失败: id={}, 令号={}, msg={}", id, code, ftpStage.getMsg()); - return R.fail(ftpStage.getMsg()); + + cleanupStalePdfRuns(orderDir); + String arr = yearRoot + code + "/run_" + System.currentTimeMillis() + "_" + Thread.currentThread().getId() + "/"; + log.info("uploadPDF 本次工作目录: {}", arr); + + if (fromPlm) { + log.info("项目来源=PLM,从本地目录加载PDF(跳过FTP): {}/{}", plmPdfBaseDir, code); + R plmStage = copyPlmPdfFromLocalDirectory(code, arr, plmMarkContext); + if (plmStage.getCode() != 200) { + log.warn("uploadPDF PLM本地PDF加载失败: id={}, 令号={}, msg={}", id, code, plmStage.getMsg()); + return R.fail(plmStage.getMsg()); + } + log.info("uploadPDF PLM本地PDF加载成功: id={}, 令号={}, workDir={}", id, code, plmStage.getMsg()); + } else { + log.info("项目来源=手动,开始从FTP下载文件..."); + R ftpStage = downloadPdfFromFtp(code, arr, fiseList); + if (ftpStage.getCode() != 200) { + log.warn("uploadPDF FTP下载失败: id={}, 令号={}, msg={}", id, code, ftpStage.getMsg()); + return R.fail(ftpStage.getMsg()); + } + log.info("uploadPDF FTP下载成功: id={}, 令号={}", id, code); } - log.info("uploadPDF FTP下载成功: id={}, 令号={}", id, code); - } - // 7. 清理并创建工作目录 - if (!createWorkDirectories(arr, code)) { - return R.fail("创建工作目录失败"); - } + if (!createWorkDirectories(arr, code)) { + return R.fail("创建工作目录失败"); + } - // 8. 处理PDF文件(盖章顺序与失败策略不变;bomThree 仍盖) - log.info("uploadPDF 开始盖章/汇总: id={}, 令号={}, fromPlm={}", id, code, fromPlm); - if (!processPDFFiles(arr, code, fiseList, fromPlm, plmMarkContext)) { - log.warn("uploadPDF 失败: id={}, 令号={}, 原因=处理PDF文件失败", id, code); - return R.fail("处理PDF文件失败"); - } + log.info("uploadPDF 开始盖章/汇总: id={}, 令号={}, fromPlm={}", id, code, fromPlm); + if (!processPDFFiles(arr, code, fiseList, fromPlm, plmMarkContext)) { + log.warn("uploadPDF 失败: id={}, 令号={}, 原因=处理PDF文件失败", id, code); + return R.fail("处理PDF文件失败"); + } - // 9. 分类到 zip/工段/,并同步写入 zip/allpdf(等价于原「分类 copy + 删根目录 + 再拷 allpdf」) - log.info("uploadPDF 开始按工作中心分类: id={}, 令号={}", id, code); - String watermarkedPdfDir = arr + code + "/zip"; - String productionOrderNo = processOrderPro.getProductionOrderNo(); - - classifyPdfByWorkCenterToTarget(productionOrderNo, watermarkedPdfDir, code, arr + code + "/zip", fromPlm); - - // 9.1 兜底:删除 zip/ 根目录残留 PDF(move 成功则通常已空;失败回退 copy 时与原先一致需清掉) - File[] zipPdfFiles = new File(watermarkedPdfDir).listFiles((dir, name) -> name.toLowerCase().endsWith(".pdf")); - if (zipPdfFiles != null) { - for (File pdf : zipPdfFiles) { - if (pdf.isFile()) { - boolean deleted = pdf.delete(); - if (deleted) { - log.info("已删除未分类PDF文件: {}", pdf.getName()); - } else { - log.warn("删除未分类PDF失败: {}", pdf.getName()); + log.info("uploadPDF 跳过工段分类,PDF 平铺打包: id={}, 令号={}", id, code); + String watermarkedPdfDir = arr + code + "/zip"; + File[] zipTempFiles = new File(watermarkedPdfDir).listFiles((dir, name) -> + name != null && name.toLowerCase().endsWith("_temp.pdf")); + if (zipTempFiles != null) { + for (File pdf : zipTempFiles) { + if (pdf.isFile() && !pdf.delete()) { + log.warn("删除临时PDF失败: {}", pdf.getName()); } } } - } - // 10. 生成ZIP包 - String zipPath = arr + code + "/" + code + ".zip"; - log.info("uploadPDF 开始生成ZIP: id={}, 令号={}, zipDir={}", id, code, arr + code + "/zip"); - boolean zipSuccess = FileToZip.fileToZip(arr + code + "/zip", arr + code, code); - if (!zipSuccess) { - log.warn("uploadPDF 失败: id={}, 令号={}, 原因=生成ZIP包失败", id, code); - return R.fail("生成ZIP包失败"); - } + String generatedZipPath = arr + code + "/" + code + ".zip"; + log.info("uploadPDF 开始生成ZIP: id={}, 令号={}, zipDir={}", id, code, watermarkedPdfDir); + boolean zipSuccess = FileToZip.fileToZip(arr + code + "/zip", arr + code, code); + if (!zipSuccess) { + log.warn("uploadPDF 失败: id={}, 令号={}, 原因=生成ZIP包失败", id, code); + return R.fail("生成ZIP包失败"); + } + File generatedZip = new File(generatedZipPath); + if (!generatedZip.exists()) { + log.warn("uploadPDF 失败: id={}, 令号={}, 原因=ZIP文件不存在, path={}", id, code, generatedZipPath); + return R.fail("ZIP文件生成失败,文件不存在"); + } - File zipFile = new File(zipPath); - if (!zipFile.exists()) { - log.warn("uploadPDF 失败: id={}, 令号={}, 原因=ZIP文件不存在, path={}", id, code, zipPath); - return R.fail("ZIP文件生成失败,文件不存在"); + if (!orderDir.exists() && !orderDir.mkdirs()) { + return R.fail("无法创建令号目录: " + orderDir.getAbsolutePath()); + } + if (!PDFDocHelper.copyOverWithRetry(generatedZip, stableZip)) { + log.warn("uploadPDF 正式ZIP写回失败,回退本次生成文件: {}", generatedZipPath); + return R.ok(generatedZipPath); + } + cleanupPdfRunDir(generatedZipPath); + log.info("uploadPDF 完成: id={}, 令号={}, source={}, zipPath={}, size={}", + id, code, source, stableZip.getAbsolutePath(), stableZip.length()); + return R.ok(stableZip.getAbsolutePath()); } - log.info("uploadPDF 完成: id={}, 令号={}, source={}, zipPath={}, size={}", - id, code, source, zipPath, zipFile.length()); - return R.ok(zipPath); - } catch (Exception e) { log.error("uploadPDF 异常: id={}", id, e); return R.fail("处理失败: " + e.getMessage()); @@ -2127,9 +2622,10 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { log.info("FTP 单连接复用下载开始: {}:{}", downloadEp.getHost(), downloadEp.getPort()); try { Set downloadedDirectories = new HashSet<>(); - if (isQbProductionOrderNo(code)) { + ProcessOrderPro orderForMode = selectByProjectNumber(code); + if (isPartDrawingMode(orderForMode)) { String yearForFtp = ProductionOrderNoUtils.extractYear(code); - for (String pdfDirectoryName : qbPdfDirectoryNamesOrdered(code, fiseList)) { + for (String pdfDirectoryName : partPdfDirectoryNamesOrdered(code, fiseList)) { if (!downloadedDirectories.add(pdfDirectoryName)) { log.info("目录已下载,跳过重复下载: {}", pdfDirectoryName); continue; @@ -2141,7 +2637,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { ftpPath = "/" + ftpPath; } String localPath = localBaseDir + code + "/" + pdfDirectoryName; - log.info("下载路径(企标按路径组与出图 assembledrawing 一致): {} -> {}", ftpPath, localPath); + log.info("下载路径(零部件按路径组与出图 assembledrawing 一致): {} -> {}", ftpPath, localPath); File localDir = new File(localPath); if (!localDir.exists()) { if (!localDir.mkdirs()) { @@ -2159,11 +2655,10 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { if (pdfCount == 0) { log.warn("FTP下载完成但目录中未发现PDF文件: {}, msg: {}", localPath, downloadResult.getMsg()); } else { - log.info("FTP下载成功,PDF数量: {}, msg: {}", pdfCount, downloadResult.getMsg()); - renamePdfFilesInDirectoryRecursive(localDownloadedDir, code); + log.info("FTP下载成功,PDF数量: {}, msg: {}(不再按物料名称重命名)", pdfCount, downloadResult.getMsg()); } } catch (Exception e) { - log.error("下载企标目录失败: {}", pdfDirectoryName, e); + log.error("下载零部件目录失败: {}", pdfDirectoryName, e); return R.fail("下载产品文件失败: " + pdfDirectoryName + " - " + e.getMessage()); } } @@ -2215,8 +2710,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { if (pdfCount == 0) { log.warn("FTP下载完成但目录中未发现PDF文件: {}, msg: {}", localPath, downloadResult.getMsg()); } else { - log.info("FTP下载成功,PDF数量: {}, msg: {}", pdfCount, downloadResult.getMsg()); - renamePdfFilesInDirectoryRecursive(localDownloadedDir, code); + log.info("FTP下载成功,PDF数量: {}, msg: {}(不再按物料名称重命名)", pdfCount, downloadResult.getMsg()); } } catch (Exception e) { @@ -2234,7 +2728,8 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { /** * PLM 来源:从 {@code plm.pdf.base-dir}/{生产令号} 复制 PDF 到工作目录 {@code {year}/{令号}/PLM-PDF}。 - * @param markCtx 已加载的标注上下文(可为 null,此时内部再加载一次,行为与原先一致) + * 保留原文件名,不再按物料名称重命名。 + * @param markCtx 保留参数兼容调用方(本方法内不再用于重命名) */ private R copyPlmPdfFromLocalDirectory(String productionOrderNo, String localBaseDir, PlmPdfMarkContext markCtx) { @@ -2259,21 +2754,15 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { if (pdfFiles == null || pdfFiles.length == 0) { return R.fail("PLM图纸目录下未找到PDF文件: " + plmDir.getAbsolutePath()); } - if (markCtx == null) { - markCtx = loadPlmPdfMarkContext(productionOrderNo); - } int copied = 0; for (File pdf : pdfFiles) { if (!pdf.isFile()) { continue; } - String standardName = resolvePlmStandardPdfFileName(pdf.getName(), markCtx); - File dest = resolveAvailablePlmPdfPath(localDir, standardName); + // 变更:保留原文件名,不再按物料编码/名称规范重命名 + File dest = resolveAvailablePlmPdfPath(localDir, pdf.getName()); FileUtils.copyFile(pdf, dest); copied++; - if (!standardName.equals(pdf.getName())) { - tryRenamePlmSourcePdfToStandard(pdf, standardName); - } } log.info("PLM本地PDF已复制: {} -> {}, 数量={}", plmDir.getAbsolutePath(), localPath, copied); return R.ok(localPath); @@ -2295,7 +2784,10 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return; } - File[] pdfFiles = sourceDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".pdf")); + File[] pdfFiles = sourceDir.listFiles((dir, name) -> + name != null + && name.toLowerCase().endsWith(".pdf") + && !name.toLowerCase().endsWith("_temp.pdf")); if (pdfFiles == null || pdfFiles.length == 0) { return; } @@ -2384,44 +2876,26 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { */ private boolean createWorkDirectories(String arr, String code) { try { - // 清理并创建zip目录 - DeleteFile.deleteDirectory(arr + code + "/allpdf"); - File filesZip1 = new File(arr + code + "/allpdf"); - if (!filesZip1.exists() && !filesZip1.mkdirs()) { - log.error("无法创建zip目录: {}", filesZip1.getAbsolutePath()); - return false; - } - // 清理并创建zip目录 - DeleteFile.deleteDirectory(arr + code + "/zip"); - File filesZip = new File(arr + code + "/zip"); - if (!filesZip.exists() && !filesZip.mkdirs()) { - log.error("无法创建zip目录: {}", filesZip.getAbsolutePath()); - return false; - } - - // 清理并创建bom目录 - DeleteFile.deleteDirectory(arr + code + "/bom"); - File bom = new File(arr + code + "/bom"); - if (!bom.exists() && !bom.mkdirs()) { - log.error("无法创建bom目录: {}, bom.getAbsolutePath()", bom.getAbsolutePath()); - return false; - } - - // 清理并创建bomThree目录 - DeleteFile.deleteDirectory(arr + code + "/bomThree"); - File bomThree = new File(arr + code + "/bomThree"); - if (!bomThree.exists() && !bomThree.mkdirs()) { - log.error("无法创建bomThree目录: {}", bomThree.getAbsolutePath()); - return false; - } - + ensureCleanDir(new File(arr + code + "/allpdf")); + ensureCleanDir(new File(arr + code + "/zip")); + ensureCleanDir(new File(arr + code + "/bom")); + ensureCleanDir(new File(arr + code + "/bomThree")); return true; } catch (Exception e) { - log.error("创建工作目录失败, e", e); + log.error("创建工作目录失败", e); return false; } } + private void ensureCleanDir(File dir) throws IOException { + if (dir.exists()) { + deleteDirectory(dir); + } + if (!dir.mkdirs() && !dir.isDirectory()) { + throw new IOException("无法创建目录: " + dir.getAbsolutePath()); + } + } + /** * 将某一源目录下 PDF 汇总复制到 allpdf。 */ @@ -2484,7 +2958,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } } - // 遍历所有产品,将PDF文件复制到allpdf文件夹(企标目录名与 FTP 下载、出图 assembledrawing 一致) + // 遍历所有产品,将PDF文件复制到allpdf文件夹(零部件目录名与 FTP 下载、出图 assembledrawing 一致) Set processedSourceDirs = new HashSet<>(); if (fromPlm) { String plmSourceDir = arr + code + "/" + PLM_PDF_WORK_SUBDIR; @@ -2493,8 +2967,8 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { log.warn("processPDFFiles PLM模式复制失败: 令号={}, 源目录={}", code, plmSourceDir); return false; } - } else if (isQbProductionOrderNo(code)) { - for (String filePDF : qbPdfDirectoryNamesOrdered(code, fiseList)) { + } else if (isPartDrawingMode(selectByProjectNumber(code))) { + for (String filePDF : partPdfDirectoryNamesOrdered(code, fiseList)) { String sourceDir = arr + code + "/" + filePDF; if (!processedSourceDirs.add(sourceDir)) { log.info("源PDF目录已处理,跳过重复处理: {}", sourceDir); @@ -2576,8 +3050,11 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } } - // 检查allpdf文件夹是否有文件 - File[] allPdfFiles = allPdfFolder.listFiles((dir, name) -> name.toLowerCase().endsWith(".pdf")); + // 检查allpdf文件夹是否有文件(排除历史残留的盖章临时文件) + File[] allPdfFiles = allPdfFolder.listFiles((dir, name) -> { + String lower = name.toLowerCase(); + return lower.endsWith(".pdf") && !name.startsWith("_plm_stamp_") && !name.startsWith("plm_stamp_"); + }); if (allPdfFiles == null || allPdfFiles.length == 0) { log.warn("allpdf文件夹为空,没有PDF文件需要处理"); return true; @@ -2587,6 +3064,15 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { plmMarkContext = loadPlmPdfMarkContext(code); } + // 刚复制完的文件可能被 Windows 杀毒/索引短暂占用,稍候再盖章 + if (fromPlm) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + // 盖章并行:每文件逻辑不变,仅用线程池加速 return watermarkPdfFilesParallel(allPdfFiles, arr, code, fromPlm, plmMarkContext); } catch (Exception e) { @@ -2643,17 +3129,30 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { PlmPdfMarkContext plmMarkContext) { String name = fs.getName(); PdfReader reader = null; + File working = fs; + File sidecar = null; try { - // PLM:在统一图纸章/zip 输出前,对 allpdf 内文件做专属标注(就地改写,供后续流程复用) - if (fromPlm && !applyPlmPdfPreWatermark(fs, code, plmMarkContext)) { - log.warn("PLM PDF 左栏标注未成功,继续出图: 令号={}, file={}", code, name); + // PLM:在统一图纸章/zip 输出前做左栏标注。原文件被锁时用临时文件继续,保证 zip 仍带标注。 + if (fromPlm) { + File marked = applyPlmPdfPreWatermark(fs, code, plmMarkContext); + if (marked != null && marked.isFile()) { + working = marked; + if (!samePdfFile(marked, fs)) { + sidecar = marked; + log.warn("原 PDF 被占用,左栏标注已改写临时文件后继续出图: 令号={}, file={}, temp={}", + code, name, marked.getAbsolutePath()); + } + } } - // 读取PDF尺寸 - reader = new PdfReader(fs.getAbsolutePath()); + // 读取PDF尺寸后立刻关闭,避免后续写回 allpdf 时句柄仍占用 + byte[] workingBytes = PDFDocHelper.readAllBytesWithRetry(working.toPath()); + reader = new PdfReader(workingBytes); Rectangle pageSize = reader.getPageSize(1); float height = pageSize.getHeight(); float width = pageSize.getWidth(); + reader.close(); + reader = null; float[] pawX = {width - 100}; float[] pawY = {height - 20}; @@ -2667,7 +3166,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { // 处理PDF文件 - 输出到zip目录 String zipDir = arr + code + "/zip"; log.info("处理PDF文件=====>输出到zip目录: {}", name); - PDFDocHelper.imageWatermark(fs.getAbsolutePath(), zipDir, name, "D:/java/tuzhizhang.png", reviews, pawX, pawY); + PDFDocHelper.imageWatermark(working.getAbsolutePath(), zipDir, name, "D:/java/tuzhizhang.png", reviews, pawX, pawY); // 生成三维BOM版本(若已按「原名@@图号@@名称」重命名,则仍按原文件名主体解析) String threeDName; @@ -2679,14 +3178,18 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } else { threeDName = name; } - PDFDocHelper.imageWatermark(fs.getAbsolutePath(), arr + code + "/bomThree/" + threeDName, "D:/java/tuzhizhang.png"); + PDFDocHelper.imageWatermark(working.getAbsolutePath(), arr + code + "/bomThree/" + threeDName, "D:/java/tuzhizhang.png"); + + if (sidecar != null && !PDFDocHelper.copyOverWithRetry(sidecar, fs)) { + log.warn("左栏标注临时文件未能写回 allpdf(zip 已带标注): {}", name); + } log.info("PDF处理成功: {}", name); return true; } catch (Exception e) { log.error("处理PDF文件失败: {}", name, e); if (fromPlm) { - copyPlmPdfToZipFallback(fs, arr + code + "/zip"); + copyPlmPdfToZipFallback(working, arr + code + "/zip", name); return true; } return false; @@ -2698,6 +3201,24 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { log.error("关闭PDF读取器失败", e); } } + if (sidecar != null) { + try { + Files.deleteIfExists(sidecar.toPath()); + } catch (Exception ignore) { + sidecar.deleteOnExit(); + } + } + } + } + + private static boolean samePdfFile(File a, File b) { + if (a == null || b == null) { + return false; + } + try { + return a.getCanonicalFile().equals(b.getCanonicalFile()); + } catch (IOException e) { + return a.getAbsoluteFile().equals(b.getAbsoluteFile()); } } @@ -2707,24 +3228,21 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { * 调用时机:{@link #processPDFFiles} 已将 PLM-PDF 汇总到 {@code allpdf} 之后, * 且对每个 PDF 执行 {@link PDFDocHelper#imageWatermark} 之前;仅 {@code fromPlm=true} 时触发。 *

    - * 建议在本方法内就地改写 {@code pdfFile}(仍在 allpdf 目录), - * 后续 zip 分类、bomThree、打包 ZIP 会自动带上 PLM 附加内容。 - *

    - * 可扩展项示例:令号/PLM 来源文字、图号角标、与 {@code tuzhizhang.png} 不同的 PLM 专用图章等。 + * 优先就地改写 {@code pdfFile};若 Windows 锁住原文件,则返回系统临时文件供后续 zip/bomThree 使用。 * * @param pdfFile allpdf 中的单个 PDF(可读写) * @param productionOrderNo 生产令号 * @param plmMarkContext 本令号 PLM 推送明细(文件名 → 物料编码),可为空 - * @return 处理成功返回 true;失败则中断整批出图 + * @return 后续盖章应使用的文件;失败时回退为原文件 */ - private boolean applyPlmPdfPreWatermark(File pdfFile, String productionOrderNo, PlmPdfMarkContext plmMarkContext) { + private File applyPlmPdfPreWatermark(File pdfFile, String productionOrderNo, PlmPdfMarkContext plmMarkContext) { if (pdfFile == null || !pdfFile.isFile()) { log.warn("PLM PDF 预备处理跳过:文件无效, 令号={}", productionOrderNo); - return true; + return pdfFile; } try { PlmPdfZipPushItem pushItem = plmMarkContext != null - ? resolvePlmPushItemForPdf(plmMarkContext, pdfFile.getName()) : null; + ? resolvePlmPushItemForPdf(plmMarkContext, pdfFile) : null; PlmPdfQtyMark qtyMark = resolvePlmPdfUnitAndBatchQty(productionOrderNo, pdfFile.getName(), pushItem); String materialCode = qtyMark.materialCode; String unitQty = qtyMark.unitQtyText; @@ -2737,10 +3255,14 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { log.warn("PLM PDF 左栏标注内容全为空: 令号={}, file={}, material={}", productionOrderNo, pdfFile.getName(), materialCode); } - // --- 左栏位置:按 plm-pdf-mark.yml 图幅配置匹配 --- - PdfReader reader = new PdfReader(pdfFile.getAbsolutePath()); - Rectangle pageSize = reader.getPageSizeWithRotation(1); - reader.close(); + byte[] pdfBytes = PDFDocHelper.readAllBytesWithRetry(pdfFile.toPath()); + PdfReader reader = new PdfReader(pdfBytes); + Rectangle pageSize; + try { + pageSize = reader.getPageSizeWithRotation(1); + } finally { + reader.close(); + } float width = pageSize.getWidth(); float height = pageSize.getHeight(); PlmPdfMarkPlacement placement = plmPdfMarkPlacementResolver.resolve(width, height); @@ -2755,17 +3277,17 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { addPlmTextReview(reviews, issueTime); addPlmTextReview(reviews, orderNoText); - PDFDocHelper.stampTextFirstPage(pdfFile.getAbsolutePath(), reviews, markXArr, markY, + File stamped = PDFDocHelper.stampTextFirstPage(pdfFile, pdfBytes, reviews, markXArr, markY, placement.getFontSize(), markRotation); log.info("PLM PDF 左栏标注完成: 令号={}, file={}, 图幅={}, pushItem={}, material={}, 单台={}, 本批={}, 发图={}, rotation={}, page={}x{}", productionOrderNo, pdfFile.getName(), placement.getFormatId(), pushItem != null ? pushItem.getSavedFileName() : "-", StringUtils.isNotBlank(materialCode) ? materialCode : "-", unitQty, batchQty, issueTime, markRotation, width, height); - return true; + return stamped != null ? stamped : pdfFile; } catch (Exception e) { log.error("PLM PDF 左栏标注失败(继续出图): 令号={}, file={}", productionOrderNo, pdfFile.getName(), e); - return true; + return pdfFile; } } @@ -2822,26 +3344,28 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } /** - * PLM 左栏「单台数量」「本批数量」:取自 {@code plm_pdf_zip_push_item}(PLM 推送 manifest 落库)。 - * 图号优先从 PDF 文件名解析,否则用 push_item.material_code。 + * 下载时现算单台/本批:严格取该 PDF 对应图纸推送明细的 unit_qty / batch_qty,不做跨物料、跨批次累加。 */ private PlmPdfQtyMark resolvePlmPdfUnitAndBatchQty(String productionOrderNo, String pdfFileName, PlmPdfZipPushItem pushItem) { - String materialCode = extractMaterialCodeFromPlmPdfFileName(pdfFileName); - if (StringUtils.isBlank(materialCode) && pushItem != null - && StringUtils.isNotBlank(pushItem.getMaterialCode())) { + String materialCode = ""; + if (pushItem != null && StringUtils.isNotBlank(pushItem.getMaterialCode())) { materialCode = pushItem.getMaterialCode().trim(); } + if (StringUtils.isBlank(materialCode)) { + log.warn("PLM 明细无物料编码,跳过本批数量: 令号={}, file={}, itemId={}", + productionOrderNo, pdfFileName, pushItem != null ? pushItem.getId() : null); + } String unitText = pushItem != null ? formatPlmMarkQty(pushItem.getUnitQty()) : ""; String batchText = pushItem != null ? formatPlmMarkQty(pushItem.getBatchQty()) : ""; - if (pushItem == null) { - log.warn("PLM 数量未命中(plm_pdf_zip_push_item 无匹配): 令号={}, file={}, 图号={}", + if (StringUtils.isNotBlank(materialCode) && StringUtils.isBlank(batchText)) { + log.warn("PLM 本批数量未命中: 令号={}, file={}, materialCode={}", productionOrderNo, pdfFileName, materialCode); - } else if (StringUtils.isBlank(unitText) || StringUtils.isBlank(batchText)) { - log.warn("PLM 数量不完整(plm_pdf_zip_push_item): 令号={}, file={}, itemId={}, 单台={}, 本批={}", - productionOrderNo, pdfFileName, pushItem.getId(), unitText, batchText); + } + if (pushItem == null) { + log.warn("PLM PDF 未命中 plm_pdf_zip_push_item: 令号={}, file={}", productionOrderNo, pdfFileName); } return new PlmPdfQtyMark(materialCode, unitText, batchText); @@ -3045,14 +3569,40 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } /** - * 按 PDF 文件名匹配 {@code plm_pdf_zip_push_item}(优先 saved_file_name,其次 material_code 前缀)。 + * 用当前 PDF 对上 {@code plm_pdf_zip_push_item}:优先 saved_path / pdf_path,其次 saved_file_name。 */ + private PlmPdfZipPushItem resolvePlmPushItemForPdf(PlmPdfMarkContext ctx, File pdfFile) { + if (pdfFile == null) { + return null; + } + return resolvePlmPushItemForPdf(ctx, pdfFile.getName(), pdfFile.getAbsolutePath()); + } + private PlmPdfZipPushItem resolvePlmPushItemForPdf(PlmPdfMarkContext ctx, String pdfFileName) { + return resolvePlmPushItemForPdf(ctx, pdfFileName, null); + } + + private PlmPdfZipPushItem resolvePlmPushItemForPdf(PlmPdfMarkContext ctx, String pdfFileName, String pdfAbsolutePath) { if (ctx == null || ctx.items.isEmpty() || StringUtils.isBlank(pdfFileName)) { return null; } String name = pdfFileName.trim(); + String abs = normalizePathForMatch(pdfAbsolutePath); String base = normalizePlmPdfMatchBase(extractPdfOriginalBaseName(name)); + + if (StringUtils.isNotBlank(abs)) { + for (PlmPdfZipPushItem item : ctx.items) { + String savedPath = normalizePathForMatch(item.getSavedPath()); + String pdfPath = normalizePathForMatch(item.getPdfPath()); + if (abs.equalsIgnoreCase(savedPath) || abs.equalsIgnoreCase(pdfPath)) { + return item; + } + if (StringUtils.isNotBlank(savedPath) && (abs.endsWith(savedPath) || savedPath.endsWith(abs))) { + return item; + } + } + } + for (PlmPdfZipPushItem item : ctx.items) { String saved = StringUtils.trimToEmpty(item.getSavedFileName()); String savedBase = normalizePlmPdfMatchBase( @@ -3062,35 +3612,17 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { || savedBase.equalsIgnoreCase(base)) { return item; } - String fileMaterial = extractMaterialCodeFromPlmPdfFileName(name); - if (StringUtils.isNotBlank(fileMaterial) - && fileMaterial.equalsIgnoreCase(StringUtils.trimToEmpty(item.getMaterialCode()))) { - return item; - } } - PlmPdfZipPushItem best = null; - int bestLen = 0; - for (PlmPdfZipPushItem item : ctx.items) { - String mc = StringUtils.trimToEmpty(item.getMaterialCode()); - if (StringUtils.isBlank(mc)) { - continue; - } - String safe = sanitizeForFileNamePart(mc); - if ((base.startsWith(safe) || name.startsWith(safe) || base.startsWith(mc) || name.startsWith(mc)) - && Math.max(safe.length(), mc.length()) > bestLen) { - best = item; - bestLen = Math.max(safe.length(), mc.length()); - } - String drawingKey = figureFilenameBaseToDrawingNo(base); - if (StringUtils.isNotBlank(drawingKey) && drawingKey.equals(mc) && mc.length() > bestLen) { - best = item; - bestLen = mc.length(); - } + + log.warn("plm_pdf_zip_push_item 未匹配到 PDF: file={}, path={}", pdfFileName, pdfAbsolutePath); + return null; + } + + private static String normalizePathForMatch(String path) { + if (StringUtils.isBlank(path)) { + return ""; } - if (best == null) { - log.warn("plm_pdf_zip_push_item 未匹配到 PDF: file={}", pdfFileName); - } - return best; + return path.trim().replace('\\', '/'); } private long resolvePlmIssueTimeEpoch(String productionOrderNo, File currentPdfFile, String pdfFileName, @@ -3171,20 +3703,25 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { * PLM 单文件盖章失败时,原样复制到 zip 根目录,避免整批丢文件。 */ private void copyPlmPdfToZipFallback(File sourcePdf, String zipDir) { + copyPlmPdfToZipFallback(sourcePdf, zipDir, sourcePdf != null ? sourcePdf.getName() : null); + } + + private void copyPlmPdfToZipFallback(File sourcePdf, String zipDir, String targetName) { if (sourcePdf == null || !sourcePdf.isFile()) { return; } + String outName = StringUtils.isNotBlank(targetName) ? targetName : sourcePdf.getName(); try { File zipFolder = new File(zipDir); if (!zipFolder.exists() && !zipFolder.mkdirs()) { log.warn("无法创建 zip 目录,PLM 原样复制跳过: {}", zipDir); return; } - File target = new File(zipFolder, sourcePdf.getName()); + File target = new File(zipFolder, outName); FileUtils.copyFile(sourcePdf, target); - log.warn("PLM PDF 盖章失败,已原样复制到 zip: {}", sourcePdf.getName()); + log.warn("PLM PDF 盖章失败,已原样复制到 zip: {}", outName); } catch (IOException e) { - log.error("PLM PDF 原样复制到 zip 失败: {}", sourcePdf.getName(), e); + log.error("PLM PDF 原样复制到 zip 失败: {}", outName, e); } } @@ -3375,27 +3912,145 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } /** - * 递归删除目录及其内容 - * - * @param directory 要删除的目录 - * @throws IOException 删除失败时抛出异常 + * 递归删除目录及其内容。Windows 上文件可能被杀毒/上一轮盖章短暂占用,删除失败会重试。 */ private void deleteDirectory(File directory) throws IOException { - if (directory.exists()) { - File[] files = directory.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - deleteDirectory(file); - } else { - if (!file.delete()) { - throw new IOException("无法删除文件: " + file.getAbsolutePath()); - } - } + if (directory == null || !directory.exists()) { + return; + } + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectory(file); + } else { + deletePathWithRetry(file); } } - if (!directory.delete()) { - throw new IOException("无法删除目录: " + directory.getAbsolutePath()); + } + deletePathWithRetry(directory); + } + + private void deletePathWithRetry(File file) throws IOException { + IOException last = null; + for (int i = 1; i <= 20; i++) { + if (!file.exists()) { + return; + } + try { + file.setWritable(true); + Files.delete(file.toPath()); + return; + } catch (IOException e) { + last = e; + if (file.delete() || !file.exists()) { + return; + } + if (i == 20 || Thread.currentThread().isInterrupted()) { + break; + } + if (i >= 8) { + System.gc(); + } + try { + Thread.sleep(Math.min(1000L, 50L * (1L << Math.min(i - 1, 4)))); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + throw new IOException("无法删除文件: " + file.getAbsolutePath() + + "(文件被占用,请关闭已打开的 PDF,且不要在出图过程中重复点击下载)", last); + } + + @Override + public void cleanupPdfRunDir(String zipPath) { + if (StringUtils.isBlank(zipPath)) { + return; + } + File zipFile = new File(zipPath); + File orderWork = zipFile.getParentFile(); + if (orderWork == null) { + return; + } + File runDir = orderWork.getParentFile(); + if (runDir == null || !runDir.getName().startsWith("run_")) { + return; + } + try { + deleteDirectory(runDir); + log.info("已清理出图临时目录: {}", runDir.getAbsolutePath()); + } catch (Exception e) { + log.warn("清理出图临时目录失败(忽略): {}", runDir.getAbsolutePath()); + } + } + + /** + * 已有正式 ZIP 且源图纸未更新则直接复用,避免第二人重新盖章打包。 + * PLM:源 PDF 或推送接收时间新于 ZIP 时重新打包,避免下到旧图。 + */ + private boolean isStablePdfZipReusable(File stableZip, String code, boolean fromPlm, PlmPdfMarkContext ctx) { + if (stableZip == null || !stableZip.isFile() || stableZip.length() < 22) { + return false; + } + if (!fromPlm) { + return true; + } + long zipTime = stableZip.lastModified(); + File plmDir = resolvePlmSourceDir(code); + if (plmDir.isDirectory()) { + long newest = plmDir.lastModified(); + File[] pdfs = plmDir.listFiles((dir, name) -> name != null && name.toLowerCase().endsWith(".pdf")); + if (pdfs != null) { + for (File pdf : pdfs) { + newest = Math.max(newest, pdf.lastModified()); + } + } + if (newest > zipTime) { + log.info("源PDF已更新,不复用旧ZIP: 令号={}, zipTime={}, sourceTime={}", code, zipTime, newest); + return false; + } + } + if (ctx != null && ctx.pushById != null) { + for (PlmPdfZipPush push : ctx.pushById.values()) { + if (push.getReceiveTime() != null && push.getReceiveTime().getTime() > zipTime) { + log.info("PLM推送新于ZIP,不复用旧ZIP: 令号={}, zipTime={}, receiveTime={}", + code, zipTime, push.getReceiveTime().getTime()); + return false; + } + } + } + return true; + } + + private File resolvePlmSourceDir(String productionOrderNo) { + String plmDirPath = plmPdfBaseDir.replace("\\", "/"); + if (plmDirPath.endsWith("/")) { + plmDirPath = plmDirPath.substring(0, plmDirPath.length() - 1); + } + return new File(plmDirPath, productionOrderNo); + } + + /** 清理超过 2 小时的历史 run_* 目录,不碰其他人正在用的本次目录 */ + private void cleanupStalePdfRuns(File orderDir) { + if (orderDir == null || !orderDir.isDirectory()) { + return; + } + File[] runs = orderDir.listFiles(f -> f.isDirectory() && f.getName().startsWith("run_")); + if (runs == null || runs.length == 0) { + return; + } + long expireBefore = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2); + for (File run : runs) { + if (run.lastModified() > expireBefore) { + continue; + } + try { + deleteDirectory(run); + log.info("已清理过期出图目录: {}", run.getAbsolutePath()); + } catch (Exception e) { + log.warn("清理过期出图目录失败(忽略): {}", run.getAbsolutePath()); } } } @@ -3536,6 +4191,243 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { return R.ok(jsonStr == null ? "" : jsonStr); } + @Override + public Map importDrawingByExcel(MultipartFile file) { + if (file == null || file.isEmpty() || StringUtils.isEmpty(file.getOriginalFilename())) { + throw new ServiceException("请选择有效的Excel文件"); + } + String filename = file.getOriginalFilename(); + String suffix = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + if (!("xls".equals(suffix) || "xlsx".equals(suffix))) { + throw new ServiceException("仅支持Excel文件(xls/xlsx)"); + } + + List rows; + try { + rows = ExcelUtil.importExcelSheet1(file.getInputStream(), OrderProDrawingImportVo.class, false).getList(); + } catch (Exception e) { + log.error("项目时间导入解析失败: {}", filename, e); + throw new ServiceException("Excel解析失败,表头须为:生产令号、出图时间、项目完成时间、工艺完成时间、计划排产时间、BOM/物料更新、设计人"); + } + if (rows == null) { + rows = Collections.emptyList(); + } + + Map designerDict = buildDesignerDict(); + int success = 0; + int skippedEmpty = 0; + List failDetails = new ArrayList<>(); + + for (int i = 0; i < rows.size(); i++) { + int excelRow = i + 2; + OrderProDrawingImportVo row = rows.get(i); + if (row == null) { + skippedEmpty++; + continue; + } + String orderNo = StringUtils.trim(row.getProductionOrderNo()); + String drawingTimeRaw = StringUtils.trim(row.getDrawingTime()); + String projectEndTimeRaw = StringUtils.trim(row.getProjectEndTime()); + String routeUptimeRaw = StringUtils.trim(row.getRouteUptime()); + String planUptimeRaw = StringUtils.trim(row.getPlanUptime()); + String bomUptimeRaw = StringUtils.trim(row.getBomUptime()); + String designerRaw = StringUtils.trim(row.getDrawingBy()); + boolean allFieldsBlank = StringUtils.isBlank(drawingTimeRaw) + && StringUtils.isBlank(projectEndTimeRaw) + && StringUtils.isBlank(routeUptimeRaw) + && StringUtils.isBlank(planUptimeRaw) + && StringUtils.isBlank(bomUptimeRaw) + && StringUtils.isBlank(designerRaw); + if (StringUtils.isBlank(orderNo) && allFieldsBlank) { + skippedEmpty++; + continue; + } + if (StringUtils.isBlank(orderNo)) { + failDetails.add("第" + excelRow + "行:生产令号不能为空"); + continue; + } + if (allFieldsBlank) { + failDetails.add("第" + excelRow + "行 令号" + orderNo + ":没有可更新字段"); + continue; + } + + Date drawingTime = null; + Date projectEndTime = null; + Date routeUptime = null; + Date planUptime = null; + Date bomUptime = null; + try { + drawingTime = parseOptionalImportDate(drawingTimeRaw, "出图时间"); + projectEndTime = parseOptionalImportDate(projectEndTimeRaw, "项目完成时间"); + routeUptime = parseOptionalImportDate(routeUptimeRaw, "工艺完成时间"); + planUptime = parseOptionalImportDate(planUptimeRaw, "计划排产时间"); + bomUptime = parseOptionalImportDate(bomUptimeRaw, "BOM/物料更新"); + } catch (IllegalArgumentException ex) { + failDetails.add("第" + excelRow + "行 令号" + orderNo + ":" + ex.getMessage()); + continue; + } + + String drawingBy = null; + if (StringUtils.isNotBlank(designerRaw)) { + try { + drawingBy = resolveImportDesigners(designerRaw, designerDict); + } catch (IllegalArgumentException ex) { + failDetails.add("第" + excelRow + "行 令号" + orderNo + ":" + ex.getMessage()); + continue; + } + } + + ProcessOrderPro existing = selectByProjectNumber(orderNo); + if (existing == null) { + failDetails.add("第" + excelRow + "行:未找到生产令号 " + orderNo); + continue; + } + + LambdaUpdateWrapper uw = Wrappers.lambdaUpdate(); + uw.eq(ProcessOrderPro::getId, existing.getId()); + boolean hasUpdate = false; + if (drawingTime != null) { + uw.set(ProcessOrderPro::getDrawingTime, drawingTime); + hasUpdate = true; + } + if (projectEndTime != null) { + uw.set(ProcessOrderPro::getProjectEndTime, projectEndTime); + hasUpdate = true; + } + if (routeUptime != null) { + uw.set(ProcessOrderPro::getRouteUptime, routeUptime); + hasUpdate = true; + } + if (planUptime != null) { + uw.set(ProcessOrderPro::getPlanUptime, planUptime); + hasUpdate = true; + } + if (bomUptime != null) { + uw.set(ProcessOrderPro::getBomUptime, bomUptime); + hasUpdate = true; + } + if (drawingBy != null) { + uw.set(ProcessOrderPro::getDrawingBy, drawingBy); + hasUpdate = true; + } + if (!hasUpdate) { + failDetails.add("第" + excelRow + "行 令号" + orderNo + ":没有可更新字段"); + continue; + } + int updated = baseMapper.update(null, uw); + if (updated > 0) { + success++; + if (drawingTime != null) { + orderProDrawingDelayService.refreshByProjectId(existing.getId()); + } + if (drawingBy != null) { + ProcessOrderPro snap = new ProcessOrderPro(); + snap.setId(existing.getId()); + snap.setProductionOrderNo(orderNo); + snap.setDrawingBy(drawingBy); + snap.setBomStatus(existing.getBomStatus()); + snap.setDrawingTime(drawingTime != null ? drawingTime : existing.getDrawingTime()); + designTaskService.syncOrder(snap, false); + } + } else { + failDetails.add("第" + excelRow + "行 令号" + orderNo + ":更新失败"); + } + } + + Map result = new LinkedHashMap<>(); + result.put("total", rows.size()); + result.put("success", success); + result.put("fail", failDetails.size()); + result.put("skippedEmpty", skippedEmpty); + result.put("failDetails", failDetails); + result.put("message", "导入完成:成功" + success + "条,失败" + failDetails.size() + "条"); + return result; + } + + private Map buildDesignerDict() { + List dicts = dictTypeService.selectDictDataByType("plan_proer"); + Map map = new HashMap<>(); + if (dicts == null) { + return map; + } + for (SysDictData d : dicts) { + if (d == null) { + continue; + } + String value = StringUtils.trim(d.getDictValue()); + String label = StringUtils.trim(d.getDictLabel()); + if (StringUtils.isNotBlank(value)) { + map.put(value, value); + } + if (StringUtils.isNotBlank(label) && StringUtils.isNotBlank(value)) { + map.put(label, value); + } + } + return map; + } + + private String resolveImportDesigners(String raw, Map designerDict) { + String[] parts = raw.split("[,,、;;/\\\\]+"); + List values = new ArrayList<>(); + for (String part : parts) { + String token = StringUtils.trim(part); + if (StringUtils.isBlank(token)) { + continue; + } + String value = designerDict.get(token); + if (value == null) { + for (Map.Entry e : designerDict.entrySet()) { + if (e.getKey() != null && e.getKey().equalsIgnoreCase(token)) { + value = e.getValue(); + break; + } + } + } + if (StringUtils.isBlank(value)) { + throw new IllegalArgumentException("设计人「" + token + "」不在字典中"); + } + if (!values.contains(value)) { + values.add(value); + } + } + if (values.isEmpty()) { + throw new IllegalArgumentException("设计人不能为空"); + } + return String.join(",", values); + } + + private Date parseOptionalImportDate(String raw, String fieldName) { + if (StringUtils.isBlank(raw)) { + return null; + } + Date parsed = parseImportDrawingTime(raw); + if (parsed == null) { + throw new IllegalArgumentException(fieldName + "格式无法识别(" + raw + ")"); + } + return parsed; + } + + private Date parseImportDrawingTime(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String text = raw.trim(); + try { + return DateUtil.parse(text); + } catch (Exception ignored) { + // continue + } + try { + double serial = Double.parseDouble(text); + if (serial > 20000 && serial < 80000) { + return org.apache.poi.ss.usermodel.DateUtil.getJavaDate(serial); + } + } catch (Exception ignored) { + // continue + } + return null; + } + private interface SpecStrategy { ProductInfo build(FigureSave figureSave); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessRouteServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessRouteServiceImpl.java index d9d521e..d38c041 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessRouteServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessRouteServiceImpl.java @@ -22,6 +22,8 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.KingdeeMaterialCategoryHelper; import com.ruoyi.common.utils.KingdeeRouteDepartmentHelper; +import com.ruoyi.common.utils.JsyyOrderNo; +import com.ruoyi.common.utils.ProjectStatusLock; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.controller.ProcessRouteController; import com.ruoyi.system.domain.*; @@ -907,7 +909,7 @@ public class ProcessRouteServiceImpl implements IProcessRouteService { return bomDetails; } - + //图号以 009、AA、AB(、AC(、015、BC(、DIN、DNS、SF、两个空格开头,或图号含 (M),或备注含「外购件」 private boolean isOutsourced(String code) { return code.startsWith("009") || code.startsWith("AA") || code.startsWith("AB(") || code.startsWith("AC(") || code.startsWith("015") || code.contains("(M)") || code.startsWith("BC("); } @@ -1133,10 +1135,39 @@ public class ProcessRouteServiceImpl implements IProcessRouteService { @Override public R pushRouteBom(String rooteProdet, String groupName) { + return pushRouteBomInternal(rooteProdet, groupName, null, true); + } + + @Override + public R pushRouteBomByMaterial(String rooteProdet, String groupName, Collection materialCodes) { + if (CollUtil.isEmpty(materialCodes)) { + return R.fail("请选择要推送的物料"); + } + return pushRouteBomInternal(rooteProdet, groupName, materialCodes, false); + } + + /** + * @param materialCodes 为空则推送项目下全部物料;有值则只推这些物料 + * @param updateProjectStatus 仅整单推送时回写项目状态与 routeLog + */ + private R pushRouteBomInternal(String rooteProdet, String groupName, + Collection materialCodes, + boolean updateProjectStatus) { + String jsyyDeny = JsyyOrderNo.denyIfMatches(rooteProdet); + if (jsyyDeny != null) { + return R.fail(jsyyDeny); + } //先查询此项目的工艺状态是否审核 LambdaQueryWrapper wq = new LambdaQueryWrapper<>(); wq.eq(ProcessOrderPro::getProductionOrderNo, rooteProdet); ProcessOrderPro processOrderPro1 = processOrderProMapper.selectOne(wq); + if (processOrderPro1 != null) { + String statusDeny = ProjectStatusLock.denyIfLocked( + processOrderPro1.getBomStatus(), ProjectStatusLock.Action.PUSH_ROUTE); + if (statusDeny != null) { + return R.fail(statusDeny); + } + } if (processOrderPro1 != null && processOrderPro1.getRouteStatus() == 0) { return R.fail("项目:" + processOrderPro1.getProductionOrderNo() + "工艺未审核,无法推送,请先审核"); } @@ -1144,6 +1175,29 @@ public class ProcessRouteServiceImpl implements IProcessRouteService { return R.fail("项目:" + processOrderPro1.getProductionOrderNo() + "工艺未通过,无法推送,请先审核"); } List rawBomList = getProcessRoute(rooteProdet); + if (CollUtil.isNotEmpty(materialCodes)) { + Set wanted = materialCodes.stream() + .filter(StringUtils::isNotBlank) + .map(s -> s.trim().toUpperCase(Locale.ROOT)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (wanted.isEmpty()) { + return R.fail("请选择要推送的物料"); + } + rawBomList = rawBomList.stream() + .filter(r -> r != null && StringUtils.isNotBlank(r.getMaterialCode()) + && wanted.contains(r.getMaterialCode().trim().toUpperCase(Locale.ROOT))) + .collect(Collectors.toList()); + if (rawBomList.isEmpty()) { + return R.fail("项目 " + rooteProdet + " 下未找到所选物料的可推送工艺"); + } + Set found = rawBomList.stream() + .map(r -> r.getMaterialCode().trim().toUpperCase(Locale.ROOT)) + .collect(Collectors.toSet()); + List missing = wanted.stream().filter(c -> !found.contains(c)).collect(Collectors.toList()); + if (!missing.isEmpty()) { + return R.fail("项目 " + rooteProdet + " 下未找到物料 " + String.join("、", missing) + " 的可推送工艺"); + } + } List successfulRoutes = new ArrayList<>(); List failedRoutes = new ArrayList<>(); List duplicateRoutes = new ArrayList<>(); @@ -1181,13 +1235,15 @@ public class ProcessRouteServiceImpl implements IProcessRouteService { resultDTO.setSuccessfulRoutes(successfulRoutes); resultDTO.setFailedRoutes(failedRoutes); resultDTO.setDuplicateRoutes(duplicateRoutes); - String jsonStr = JSONUtil.toJsonStr(resultDTO); - ProcessOrderPro processOrderPro = processOrderProMapper.selectByProjectNumber(rooteProdet); - //更新项目状态 推送工艺 - processOrderPro.setBomStatus(3L); - processOrderPro.setRouteLog(jsonStr); - log.info(": " + jsonStr); - processOrderProMapper.updateById(processOrderPro); + if (updateProjectStatus) { + String jsonStr = JSONUtil.toJsonStr(resultDTO); + ProcessOrderPro processOrderPro = processOrderProMapper.selectByProjectNumber(rooteProdet); + //更新项目状态 推送工艺 + processOrderPro.setBomStatus(3L); + processOrderPro.setRouteLog(jsonStr); + log.info(": " + jsonStr); + processOrderProMapper.updateById(processOrderPro); + } return R.ok(resultDTO); } @@ -1260,95 +1316,116 @@ public class ProcessRouteServiceImpl implements IProcessRouteService { } /** - * 查询工艺路线单据体 - * - * @param rooteProdet - * @return + * 查询工艺路线单据体:按生产令号查金蝶 PRD_MO,再拉工序计划与用料。 */ @Override public List getSelecPlanRouteList(String rooteProdet) { - // 根据生产令号查询生产订单编码,获取计划单据编号和工艺路线 - List planOrderList = getSelectProceOrder1(rooteProdet); + return getSelecPlanRouteList(rooteProdet, null); + } - log.info("获取计划单号集合: planOrderList=====>" + planOrderList); + /** + * 用令号 + 物料编码查金蝶 PRD_MO,再组装工序/用料。 + */ + public List getSelecPlanRouteList(String rooteProdet, Collection materialCodes) { + List planOrderList = getSelectProceOrder1(rooteProdet, materialCodes); + if (planOrderList == null || planOrderList.isEmpty()) { + return new ArrayList<>(); + } + // 金蝶过滤后再按物料编码兜底筛一次,避免大小写/空格差异 + if (CollUtil.isNotEmpty(materialCodes)) { + Set wanted = materialCodes.stream() + .filter(StringUtils::isNotBlank) + .map(s -> s.trim().toUpperCase()) + .collect(Collectors.toSet()); + if (!wanted.isEmpty()) { + planOrderList = planOrderList.stream() + .filter(o -> o != null && StringUtils.isNotBlank(o.getFmaterialidFnumber()) + && wanted.contains(o.getFmaterialidFnumber().trim().toUpperCase())) + .collect(Collectors.toList()); + } + } + log.info("获取计划单号集合(令号={}, 物料={}): planOrderList=====>{}", rooteProdet, materialCodes, planOrderList); + return buildCombinedFromPlanOrders(planOrderList); + } - // 初始化空列表 - List plannedProcesses = new ArrayList<>(); + /** 按金蝶生产订单逐张拉取工序计划、用料清单并组装 CombinedDTO。 */ + private List buildCombinedFromPlanOrders(List planOrderList) { List combinedVoList = new ArrayList<>(); if (planOrderList == null || planOrderList.isEmpty()) { - return combinedVoList; // 返回空列表避免空指针异常 + return combinedVoList; } - - K3CloudApi client = new K3CloudApi(); for (PlanOrderVo planOrder : planOrderList) { - // 请求参数,要求为json字符串 JsonObject json = new JsonObject(); json.addProperty("FormId", "SFC_OperationPlanning"); json.addProperty("FieldKeys", "FBillNo,FOperNumber,FProcessId.FName,FOptCtrlCodeId.FName,FActivity1BaseQty,FOperPlanStartTime,FOperPlanFinishTime,FOperQty,FDepartmentId.FName,FWorkCenterId.FName,FOperDescription,FPlanStartTime,FPlanFinishTime,FMOQty,FOperUnitId.FName"); - // 创建过滤条件 JsonArray filterString = new JsonArray(); JsonObject filterObject = new JsonObject(); filterObject.addProperty("FieldName", "FMONumber"); - filterObject.addProperty("Compare", "="); // 改为等号运算符 + filterObject.addProperty("Compare", "="); filterObject.addProperty("Value", planOrder.getFBillNo()); filterObject.addProperty("Left", ""); filterObject.addProperty("Right", ""); - filterObject.addProperty("Logic", 0);// 从 PlanOrderVo 获取生产令号 + filterObject.addProperty("Logic", 0); filterString.add(filterObject); - json.add("FilterString", filterString); json.addProperty("OrderString", ""); json.addProperty("TopRowCount", 0); json.addProperty("StartRow", 0); json.addProperty("Limit", 2000); json.addProperty("SubSystemId", ""); - - String jsonData = json.toString(); try { - // 调用接口 - String resultJson = String.valueOf(client.billQuery(jsonData)); + K3CloudApi client = new K3CloudApi(); + String resultJson = String.valueOf(client.billQuery(json.toString())); JsonArray jsonArray = new Gson().fromJson(resultJson, JsonArray.class); - // 使用 ObjectMapper 将 JsonArray 转换为 List ObjectMapper objectMapper = new ObjectMapper(); - List plannedProcessList = objectMapper.readValue(jsonArray.toString(), new TypeReference>() { - }); - - // 检查 plannedProcessList 是否为 null,避免空指针异常 - if (plannedProcessList != null && !plannedProcessList.isEmpty()) { - plannedProcesses.addAll(plannedProcessList); // 将非空列表添加到最终列表中 - } - // 获取用料清单列表 + List plannedProcessList = objectMapper.readValue(jsonArray.toString(), + new TypeReference>() { + }); List materialUseList = getSelectMaterialUseList(planOrder.getFBillNo()); - // 4. 将 PlanOrderVo 和对应的 PlannedProcessVo 合并成一个对象 CombinedDTO combined = new CombinedDTO(); - combined.setOrderNumber(planOrder.getFBillNo()); // 生产订单号 - combined.setMaterialCode(planOrder.getFmaterialidFnumber()); // 物料编码 - combined.setMaterialName(planOrder.getFMaterialName()); // 物料名称 + combined.setOrderNumber(planOrder.getFBillNo()); + combined.setMaterialCode(planOrder.getFmaterialidFnumber()); + combined.setMaterialName(planOrder.getFMaterialName()); combined.setProcesses(plannedProcessList); combined.setMaterial(planOrder.getBasePropertyqtr()); combined.setMaterialUsageDTOList(materialUseList); - // 5. 添加到最终合并的列表中 combinedVoList.add(combined); - } catch (Exception e) { - e.printStackTrace(); // 输出异常日志 + log.error("组装工序计划单失败 订单号={}", planOrder.getFBillNo(), e); } } - - return combinedVoList; // 返回结果 + return combinedVoList; } + /** + * 物料编码为空:令号下全部工序计划单打 zip;有物料编码:该物料一份 PDF。 + */ @Override - public String generatePDFs(String rooteProdet) { + public String generatePDFs(String rooteProdet, String materialCode) { + if (StringUtils.isNotBlank(materialCode)) { + String code = materialCode.trim(); + List selecPlanRouteList = getSelecPlanRouteList(rooteProdet, Collections.singletonList(code)); + if (selecPlanRouteList == null || selecPlanRouteList.isEmpty()) { + throw new ServiceException("未查询到令号「" + rooteProdet + "」物料「" + code + "」的金蝶生产订单"); + } + String singlePdfName = rooteProdet + "_" + code.replaceAll("[\\\\/:*?\"<>|]", "_") + "_工序计划单.pdf"; + String path = PDFGenerator.writeToPdf(selecPlanRouteList, rooteProdet, + Collections.singletonList(pdfFontPath), singlePdfName); + if (StringUtils.isBlank(path)) { + throw new ServiceException("PDF生成失败"); + } + return path; + } List selecPlanRouteList = getSelecPlanRouteList(rooteProdet); if (selecPlanRouteList == null || selecPlanRouteList.isEmpty()) { - throw new ServiceException("未查询到可生成PDF的工艺数据"); + throw new ServiceException("未查询到令号「" + rooteProdet + "」的金蝶生产订单"); } - String pathZIP = PDFGenerator.writeToPdf(selecPlanRouteList, rooteProdet, Collections.singletonList(pdfFontPath)); - if (StringUtils.isBlank(pathZIP)) { - throw new ServiceException("PDF压缩包生成失败"); + String path = PDFGenerator.writeToPdf(selecPlanRouteList, rooteProdet, + Collections.singletonList(pdfFontPath)); + if (StringUtils.isBlank(path)) { + throw new ServiceException("PDF生成失败"); } - return pathZIP; + return path; } public List getSelectProceOrder(String rooteProdet) { @@ -1395,26 +1472,49 @@ public class ProcessRouteServiceImpl implements IProcessRouteService { } public List getSelectProceOrder1(String rooteProdet) { + return getSelectProceOrder1(rooteProdet, null); + } + + /** + * 查金蝶生产订单 PRD_MO:令号(项目号或生产令号)+ 可选物料编码。 + */ + public List getSelectProceOrder1(String rooteProdet, Collection materialCodes) { K3CloudApi client = new K3CloudApi(); - // 第一次查询:使用 F_HBYT_SCLH - List result = queryWithFieldName(client, "F_HBYT_XMH", rooteProdet); - return result; + return queryWithFieldName(client, "F_HBYT_XMH", rooteProdet, materialCodes); } /* 根据生产令号查询 返回订单编号 物料编码名称 */ private List queryWithFieldName(K3CloudApi client, String fieldName, String rooteProdet) { + return queryWithFieldName(client, fieldName, rooteProdet, null); + } + + /** + * 金蝶 PRD_MO:条件为 (项目号 或 生产令号) ,有物料编码时再 AND 物料。 + */ + private List queryWithFieldName(K3CloudApi client, String fieldName, String rooteProdet, + Collection materialCodes) { JsonObject json = new JsonObject(); json.addProperty("FormId", "PRD_MO"); json.addProperty("FieldKeys", "F_HBYT_XMH,F_HBYT_SCLH,FBillNo ,FMaterialId.FNumber,FMaterialName, F_UCHN_BaseProperty_qtr"); + List codes = new ArrayList<>(); + if (CollUtil.isNotEmpty(materialCodes)) { + for (String code : materialCodes) { + if (StringUtils.isNotBlank(code)) { + codes.add(code.trim()); + } + } + } + boolean hasMaterial = !codes.isEmpty(); + JsonArray filterString = new JsonArray(); JsonObject filterObject = new JsonObject(); filterObject.addProperty("FieldName", fieldName); // 使用传入的 fieldName filterObject.addProperty("Compare", "67"); filterObject.addProperty("Value", rooteProdet); - filterObject.addProperty("Left", ""); + filterObject.addProperty("Left", hasMaterial ? "(" : ""); filterObject.addProperty("Right", ""); filterObject.addProperty("Logic", 1); filterString.add(filterObject); @@ -1424,10 +1524,28 @@ public class ProcessRouteServiceImpl implements IProcessRouteService { filterObject1.addProperty("Compare", "67"); filterObject1.addProperty("Value", rooteProdet); filterObject1.addProperty("Left", ""); - filterObject1.addProperty("Right", ""); - filterObject1.addProperty("Logic", 1); + filterObject1.addProperty("Right", hasMaterial ? ")" : ""); + // 有物料时括号内 OR 结束,再 AND 物料;无物料时保持原来的令号 OR + filterObject1.addProperty("Logic", hasMaterial ? 0 : 1); filterString.add(filterObject1); + // 令号命中后再按物料编码收窄,一个物料对应金蝶里该物料的生产订单 + if (hasMaterial) { + JsonObject matFilter = new JsonObject(); + matFilter.addProperty("FieldName", "FMaterialId.FNumber"); + if (codes.size() == 1) { + matFilter.addProperty("Compare", "="); + matFilter.addProperty("Value", codes.get(0)); + } else { + matFilter.addProperty("Compare", "in"); + matFilter.addProperty("Value", String.join(",", codes)); + } + matFilter.addProperty("Left", ""); + matFilter.addProperty("Right", ""); + matFilter.addProperty("Logic", 0); + filterString.add(matFilter); + } + json.add("FilterString", filterString); json.addProperty("OrderString", ""); json.addProperty("TopRowCount", 0); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProductionOrderServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProductionOrderServiceImpl.java index c8ef86f..8e10095 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProductionOrderServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProductionOrderServiceImpl.java @@ -29,6 +29,7 @@ import com.ruoyi.system.domain.dto.productionorder.AssemblyBomReceiveSummary; import com.ruoyi.system.domain.dto.productionorder.AssemblyBomToBomDetailsConverter; import com.ruoyi.system.domain.dto.productionorder.ProductionOrderWeightExportDTO; import com.ruoyi.system.domain.dto.productionorder.ProductionOrderWeightImportDTO; +import com.ruoyi.system.domain.bo.plm.PlmGeneralDrawingUtils; import com.ruoyi.system.domain.bo.plm.PlmOrderMaterialMatchUtils; import com.ruoyi.system.domain.vo.ProcessOrderProVo; import com.ruoyi.system.mapper.FigureSaveMapper; @@ -503,7 +504,7 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { throw new ServiceException("总装配 BOM 数据不能为空"); } String productionOrderNo = AssemblyBomReceiveConverter.resolveProductionOrderNo(items); - requireProductionOrderExists(productionOrderNo); + ProcessOrderPro project = requireProductionOrderExists(productionOrderNo); // 外购件尾部 (2D)/(2D)去掉,避免上传金蝶一物两码 AssemblyBomReceiveConverter.normalizeDrawingCodes(items); for (int i = 0; i < items.size(); i++) { @@ -528,11 +529,34 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { throw new ServiceException("总装配 BOM 无有效行数据"); } - LambdaQueryWrapper existsQuery = Wrappers.lambdaQuery(); - existsQuery.eq(ProductionOrder::getProductionOrderNo, productionOrderNo); - boolean replaced = baseMapper.selectCount(existsQuery) > 0; - if (replaced) { - baseMapper.deleteWithProCode(productionOrderNo); + List existingRows = baseMapper.selectList( + Wrappers.lambdaQuery() + .eq(ProductionOrder::getProductionOrderNo, productionOrderNo) + .select(ProductionOrder::getId, ProductionOrder::getMainPart, ProductionOrder::getDrawingNo)); + boolean hasExisting = existingRows != null && !existingRows.isEmpty(); + Set existingKeys = collectAssemblyProductKeysFromOrders(existingRows); + Set incomingKeys = collectAssemblyProductKeysFromItems(effectiveItems); + Set overlap = PlmGeneralDrawingUtils.intersection(incomingKeys, existingKeys); + boolean canCompare = !existingKeys.isEmpty() && !incomingKeys.isEmpty(); + boolean append = hasExisting && canCompare && overlap.isEmpty(); + boolean replaced = false; + + if (hasExisting && !append) { + if (PlmGeneralDrawingUtils.isRouteApproved(project.getRouteStatus())) { + throw new ServiceException("生产令号 " + productionOrderNo + " 工艺状态已审核,不允许再推送"); + } + replaced = true; + if (canCompare && !overlap.isEmpty()) { + deleteAssemblyByProductKeys(productionOrderNo, overlap); + log.info("[PLM-总装] 未审核同主产品覆盖: order={}, mainPart={}", productionOrderNo, overlap); + } else { + baseMapper.deleteWithProCode(productionOrderNo); + log.info("[PLM-总装] 未审核全量覆盖: order={}, existingKeys={}, incomingKeys={}", + productionOrderNo, existingKeys, incomingKeys); + } + } else if (append) { + log.info("[PLM-总装] 主产品图号不同,追加写入: order={}, existing={}, incoming={}", + productionOrderNo, existingKeys, incomingKeys); } int inserted = 0; @@ -546,7 +570,9 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { throw new ServiceException("总装配 BOM 入库失败"); } - int bomDetailsCount = syncAssemblyBomDetails(productionOrderNo, entities); + List allRows = baseMapper.selectList( + Wrappers.lambdaQuery().eq(ProductionOrder::getProductionOrderNo, productionOrderNo)); + int bomDetailsCount = syncAssemblyBomDetails(productionOrderNo, allRows); // 子令号:按本次图号清理总令号侧重复总装行 Set pushDrawingNos = effectiveItems.stream() @@ -562,14 +588,82 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { summary.setLineCount(effectiveItems.size()); summary.setInsertedCount(inserted); summary.setReplaced(replaced); - markProjectAfterPlmAssemblyBom(productionOrderNo, inserted); - log.info("总装配 BOM 接收完成 productionOrderNo={} lineCount={} inserted={} replaced={} bomDetails={}", - productionOrderNo, effectiveItems.size(), inserted, replaced, bomDetailsCount); + summary.setAppended(append); + int totalLines = allRows == null ? inserted : allRows.size(); + markProjectAfterPlmAssemblyBom(productionOrderNo, totalLines); + log.info("总装配 BOM 接收完成 productionOrderNo={} lineCount={} inserted={} replaced={} appended={} bomDetails={}", + productionOrderNo, effectiveItems.size(), inserted, replaced, append, bomDetailsCount); return summary; } + private static Set collectAssemblyProductKeysFromOrders(List rows) { + Set keys = new LinkedHashSet<>(); + if (rows == null) { + return keys; + } + for (ProductionOrder row : rows) { + String key = resolveAssemblyProductKey(row); + if (StringUtils.isNotBlank(key)) { + keys.add(key); + } + } + return keys; + } + + private static Set collectAssemblyProductKeysFromItems(List items) { + Set keys = new LinkedHashSet<>(); + if (items == null) { + return keys; + } + for (AssemblyBomReceiveItemDTO item : items) { + String key = resolveAssemblyProductKey(item); + if (StringUtils.isNotBlank(key)) { + keys.add(key); + } + } + return keys; + } + + /** 主产品图号优先;为空时回退图号结尾 .00 的总图号。 */ + private static String resolveAssemblyProductKey(ProductionOrder row) { + if (row == null) { + return null; + } + if (StringUtils.isNotBlank(row.getMainPart())) { + return row.getMainPart().trim(); + } + if (PlmGeneralDrawingUtils.isGeneralDrawingNo(row.getDrawingNo())) { + return row.getDrawingNo().trim(); + } + return null; + } + + private static String resolveAssemblyProductKey(AssemblyBomReceiveItemDTO item) { + if (item == null) { + return null; + } + if (StringUtils.isNotBlank(item.getProductDrawingNo())) { + return item.getProductDrawingNo().trim(); + } + if (PlmGeneralDrawingUtils.isGeneralDrawingNo(item.getDrawingNo())) { + return item.getDrawingNo().trim(); + } + return null; + } + + private void deleteAssemblyByProductKeys(String productionOrderNo, Set productKeys) { + if (StringUtils.isBlank(productionOrderNo) || productKeys == null || productKeys.isEmpty()) { + return; + } + baseMapper.delete(Wrappers.lambdaQuery() + .eq(ProductionOrder::getProductionOrderNo, productionOrderNo) + .and(w -> w.in(ProductionOrder::getMainPart, productKeys) + .or() + .in(ProductionOrder::getDrawingNo, productKeys))); + } + /** - * 总令号总装入库前:去掉子令号已有、且图号前缀归属本令号的行。 + * 总令号总装入库前:去掉子令号已有相同图号的行,并清理总令库中对应行。 */ private List filterParentAssemblyDuplicatedInChild( List items, String productionOrderNo) { @@ -577,8 +671,7 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { return items; } if (PlmOrderMaterialMatchUtils.isChangeOrder(productionOrderNo) - || PlmOrderMaterialMatchUtils.isChildProductionOrderNo(productionOrderNo) - || PlmOrderMaterialMatchUtils.resolveMaterialCodePrefix(productionOrderNo) == null) { + || PlmOrderMaterialMatchUtils.isChildProductionOrderNo(productionOrderNo)) { return items; } Set childCodes = collectChildAssemblyDrawingNos(productionOrderNo); @@ -597,8 +690,15 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { } kept.add(item); } - log.info("[PLM-总装去重] 总令号过滤完成: parent={}, before={}, after={}", - productionOrderNo, items.size(), kept.size()); + int deletedPo = baseMapper.delete(Wrappers.lambdaQuery() + .eq(ProductionOrder::getProductionOrderNo, productionOrderNo) + .in(ProductionOrder::getDrawingNo, childCodes)); + int deletedBom = bomDetailsMapper.delete(Wrappers.lambdaQuery() + .eq(BomDetails::getTotalWeight, productionOrderNo) + .eq(BomDetails::getBomType, AssemblyBomToBomDetailsConverter.BOM_TYPE_ASSEMBLY) + .in(BomDetails::getPartNumber, childCodes)); + log.info("[PLM-总装去重] 总令号过滤完成: parent={}, before={}, after={}, childCodes={}, deletedPo={}, deletedBom={}", + productionOrderNo, items.size(), kept.size(), childCodes.size(), deletedPo, deletedBom); return kept; } @@ -608,15 +708,15 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { return; } String parent = PlmOrderMaterialMatchUtils.resolveParentProductionOrderNo(productionOrderNo); - if (StringUtils.isBlank(parent) || PlmOrderMaterialMatchUtils.isChangeOrder(parent) - || PlmOrderMaterialMatchUtils.resolveMaterialCodePrefix(parent) == null) { + if (StringUtils.isBlank(parent) || PlmOrderMaterialMatchUtils.isChangeOrder(parent)) { return; } Set codes = new LinkedHashSet<>(); if (drawingNos != null) { for (String code : drawingNos) { - if (PlmOrderMaterialMatchUtils.materialBelongsToOrder(code, parent)) { - codes.add(StringUtils.trimToEmpty(code)); + String trimmed = StringUtils.trimToEmpty(code); + if (StringUtils.isNotBlank(trimmed)) { + codes.add(trimmed); } } } @@ -666,7 +766,7 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { continue; } String drawingNo = StringUtils.trimToEmpty(row.getDrawingNo()); - if (PlmOrderMaterialMatchUtils.materialBelongsToOrder(drawingNo, parentOrderNo)) { + if (StringUtils.isNotBlank(drawingNo)) { codes.add(drawingNo); } } @@ -710,7 +810,7 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { } /** 接收前校验:process_order_pro.production_order_no 必须已存在,否则拒绝接收。 */ - private void requireProductionOrderExists(String productionOrderNo) { + private ProcessOrderPro requireProductionOrderExists(String productionOrderNo) { if (StringUtils.isBlank(productionOrderNo)) { throw new ServiceException("生产令号不能为空"); } @@ -719,6 +819,7 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { if (order == null) { throw new ServiceException("生产令号 " + orderNo + " 不存在"); } + return order; } /** @@ -742,6 +843,7 @@ public class ProductionOrderServiceImpl implements IProductionOrderService { update.setBomUptime(now); update.setMaterialCount((long) lineCount); update.setSource(ProcessOrderProSource.PLM); + update.setDrawingQtyIssued(0); update.setUpdateBy("PLM"); update.setUpdateTime(now); processOrderProMapper.updateById(update); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBuildCapacityServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBuildCapacityServiceImpl.java index d8f6446..671ecec 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBuildCapacityServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBuildCapacityServiceImpl.java @@ -2,22 +2,25 @@ package com.ruoyi.system.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.domain.RatioAccessory; import com.ruoyi.system.domain.RatioConfig; +import com.ruoyi.system.domain.RatioModel; import com.ruoyi.system.domain.dto.ProMoDTO; import com.ruoyi.system.domain.dto.PurchaseOrderDTO; import com.ruoyi.system.domain.dto.ratio.RatioBuildCapacityDTO; import com.ruoyi.system.domain.dto.ratio.RatioMaterialItemDTO; +import com.ruoyi.system.domain.dto.ratio.RatioModelOptionDTO; import com.ruoyi.system.domain.dto.ratio.RatioTransitMoDTO; import com.ruoyi.system.domain.dto.ratio.RatioTransitPoDTO; import com.ruoyi.system.mapper.RatioAccessoryMapper; import com.ruoyi.system.mapper.RatioConfigMapper; +import com.ruoyi.system.mapper.RatioModelMapper; import com.ruoyi.system.runner.JdUtil; import com.ruoyi.system.service.IRatioBuildCapacityService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.Comparator; import java.util.Collections; @@ -31,33 +34,90 @@ import java.util.stream.Collectors; public class RatioBuildCapacityServiceImpl implements IRatioBuildCapacityService { private final RatioConfigMapper ratioConfigMapper; + private final RatioModelMapper ratioModelMapper; private final RatioAccessoryMapper ratioAccessoryMapper; @Override - public RatioBuildCapacityDTO queryByRatio(BigDecimal ratioValue, Integer qty) { - // ratioValue: ratio_config.ratio_value(速比),qty: 页面输入的目标数量(本实现口径:BOM每个物料需求数量=qty) - if (ratioValue == null) { - throw new ServiceException("速比不能为空"); + public List listRatios(String seriesType) { + seriesType = normalizeSeries(seriesType); + List configs = ratioConfigMapper.selectList(new LambdaQueryWrapper() + .eq(RatioConfig::getSeriesType, seriesType) + .eq(RatioConfig::getStatus, 0)); + List ratios = new ArrayList<>(); + if (configs == null) { + return ratios; + } + for (RatioConfig config : configs) { + if (config != null && StringUtils.isNotBlank(config.getRatioValue())) { + ratios.add(config.getRatioValue().trim()); + } + } + ratios.sort(this::compareRatio); + return ratios; + } + + @Override + public List listModels(String seriesType, String ratioValue) { + RatioConfig config = findConfig(seriesType, ratioValue); + List models = ratioModelMapper.selectList(new LambdaQueryWrapper() + .eq(RatioModel::getRatioConfigId, config.getId()) + .eq(RatioModel::getStatus, 0) + .orderByAsc(RatioModel::getModelCode)); + List options = new ArrayList<>(); + if (models == null) { + return options; + } + for (RatioModel model : models) { + RatioModelOptionDTO option = new RatioModelOptionDTO(); + option.setId(model.getId()); + option.setModelCode(model.getModelCode()); + options.add(option); + } + return options; + } + + @Override + public RatioBuildCapacityDTO queryByRatio(String seriesType, String ratioValue, Long modelId, Integer qty) { + // seriesType: KP08 / KP14;ratioValue: 速比,如 91B;qty: 套数。需求数量 = Excel 单台数量 × 套数。 + if (modelId == null) { + throw new ServiceException("请选择减速机型号"); } int inputQty = qty == null ? 0 : qty; if (inputQty <= 0) { throw new ServiceException("数量必须大于0"); } - // 1) 查速比主表 ratio_config,按速比值+启用状态获取一条配置(包含 maxTorqueNm 等扩展信息) - RatioConfig config = ratioConfigMapper.selectOne(new LambdaQueryWrapper() - .eq(RatioConfig::getRatioValue, ratioValue) - .eq(RatioConfig::getStatus, 0) - .last("limit 1")); - if (config == null) { - throw new ServiceException("未找到对应速比配置"); + RatioConfig config = findConfig(seriesType, ratioValue); + RatioModel model = ratioModelMapper.selectById(modelId); + if (model == null || !config.getId().equals(model.getRatioConfigId())) { + throw new ServiceException("减速机型号不属于该速比"); } - // 2) 查速比对应的BOM明细 ratio_accessory:每条代表一个需要齐套的物料(物料编码/名称/类别/单重/材质等) - List accessories = ratioAccessoryMapper.selectList(new LambdaQueryWrapper() + // 通用件挂在速比上。轴套、行星架、法兰只挂在所选型号上,没有的类别不补空行。 + List scoped = ratioAccessoryMapper.selectList(new LambdaQueryWrapper() .eq(RatioAccessory::getRatioConfigId, config.getId()) - .eq(RatioAccessory::getStatus, 0)); - if (accessories == null || accessories.isEmpty()) { + .eq(RatioAccessory::getStatus, 0) + .and(w -> w.isNull(RatioAccessory::getRatioModelId) + .or() + .eq(RatioAccessory::getRatioModelId, model.getId()))); + List accessories = new ArrayList<>(); + boolean hasModelPart = false; + if (scoped != null) { + for (RatioAccessory accessory : scoped) { + if (accessory == null) { + continue; + } + if (accessory.getRatioModelId() == null || "通用件".equals(accessory.getPartCategory())) { + accessories.add(accessory); + continue; + } + if (modelPartKind(accessory.getPartCategory()) != null) { + accessories.add(accessory); + hasModelPart = true; + } + } + } + if (accessories.isEmpty()) { throw new ServiceException("该速比未配置BOM明细"); } @@ -69,8 +129,11 @@ public class RatioBuildCapacityServiceImpl implements IRatioBuildCapacityService } String materialCode = a.getMaterialCode(); - // 3) requiredQty:该物料需求数量(当前按你的要求取“页面输入的数量”,没有乘BOM用量系数) - double requiredQty = inputQty; + double bomQty = a.getBomQty() == null ? 1D : a.getBomQty().doubleValue(); + if (bomQty <= 0) { + bomQty = 1D; + } + double requiredQty = bomQty * inputQty; // 4) 即时库存(onHandQty):金蝶 STK_Inventory 的基本单位库存汇总(JdUtil.getKuCun -> FBaseQty) double onHandQty = Optional.ofNullable(JdUtil.getKuCun(materialCode)) @@ -99,8 +162,8 @@ public class RatioBuildCapacityServiceImpl implements IRatioBuildCapacityService double availableQty = Math.max(0.0, onHandQty - moNotPickQty); // 9) 最终可用量 = 可用量 + 采购在途 + 生产在途 double finalAvailableQty = availableQty + poNotInQty + moNotInQty; - // 10) 可支持数量:按最终可用量取整向下(当前实现等价于“能做多少套”,因为 requiredQty=输入数量) - long canSupportQty = (long) Math.floor(finalAvailableQty); + // 10) 可支持套数:最终可用 ÷ Excel 单台数量,向下取整 + long canSupportQty = (long) Math.floor(finalAvailableQty / bomQty); maxBuildQty = Math.min(maxBuildQty, canSupportQty); // 11) 补差数量:需求 - 最终可用(>0 表示缺多少) double shortageQty = Math.max(0.0, requiredQty - finalAvailableQty); @@ -111,6 +174,7 @@ public class RatioBuildCapacityServiceImpl implements IRatioBuildCapacityService item.setMaterialName(a.getMaterialName()); item.setDanzhong(a.getDanzhong()); item.setCanzhi(a.getCanzhi()); + item.setBomQty(bomQty); item.setRequiredQty(requiredQty); item.setOnHandQty(onHandQty); item.setMoNotPickQty(moNotPickQty); @@ -126,19 +190,125 @@ public class RatioBuildCapacityServiceImpl implements IRatioBuildCapacityService } items = items.stream() - .sorted(Comparator.comparing(RatioMaterialItemDTO::getMaterialCode, Comparator.nullsLast(String::compareTo))) + .sorted(Comparator + .comparingInt((RatioMaterialItemDTO item) -> categoryOrder(item.getPartCategory())) + .thenComparing(RatioMaterialItemDTO::getMaterialCode, Comparator.nullsLast(String::compareTo))) .collect(Collectors.toList()); RatioBuildCapacityDTO dto = new RatioBuildCapacityDTO(); dto.setRatioConfigId(config.getId()); + dto.setSeriesType(config.getSeriesType()); dto.setRatioValue(config.getRatioValue()); + dto.setModelId(model.getId()); + dto.setModelCode(model.getModelCode()); dto.setMaxTorqueNm(config.getMaxTorqueNm()); dto.setInputQty(inputQty); dto.setMaxBuildQty(maxBuildQty == Long.MAX_VALUE ? 0L : maxBuildQty); + dto.setOnlyCommonParts(!hasModelPart); dto.setItems(items); return dto; } + private String normalizeSeries(String seriesType) { + if (seriesType == null) { + throw new ServiceException("请选择型号 KP08 或 KP14"); + } + seriesType = seriesType.trim().toUpperCase(); + if (!"KP08".equals(seriesType) && !"KP14".equals(seriesType)) { + throw new ServiceException("型号只能是 KP08 或 KP14"); + } + return seriesType; + } + + /** 同一字母按数字从大到小,例如 160A、147A,再排到 B、C、D。 */ + private int compareRatio(String left, String right) { + int letter = ratioLetter(left).compareTo(ratioLetter(right)); + if (letter != 0) { + return letter; + } + return Integer.compare(ratioNumber(right), ratioNumber(left)); + } + + private String ratioLetter(String ratio) { + if (ratio == null) { + return ""; + } + int i = 0; + while (i < ratio.length() && Character.isDigit(ratio.charAt(i))) { + i++; + } + return ratio.substring(i); + } + + private int ratioNumber(String ratio) { + if (ratio == null) { + return -1; + } + int i = 0; + while (i < ratio.length() && Character.isDigit(ratio.charAt(i))) { + i++; + } + if (i == 0) { + return -1; + } + try { + return Integer.parseInt(ratio.substring(0, i)); + } catch (NumberFormatException e) { + return -1; + } + } + + private RatioConfig findConfig(String seriesType, String ratioValue) { + seriesType = normalizeSeries(seriesType); + if (StringUtils.isBlank(ratioValue)) { + throw new ServiceException("减速比不能为空"); + } + ratioValue = ratioValue.trim(); + RatioConfig config = ratioConfigMapper.selectOne(new LambdaQueryWrapper() + .eq(RatioConfig::getSeriesType, seriesType) + .eq(RatioConfig::getRatioValue, ratioValue) + .eq(RatioConfig::getStatus, 0) + .last("limit 1")); + if (config == null) { + throw new ServiceException("未找到 " + seriesType + " 速比 " + ratioValue + " 的配置"); + } + return config; + } + + /** 轴套、行星架(含行程架)、法兰。对不上的专用件不展示。 */ + private String modelPartKind(String category) { + if (category == null) { + return null; + } + if (category.contains("轴套")) { + return "轴套"; + } + if (category.contains("行星架") || category.contains("行程架")) { + return "行星架"; + } + if (category.contains("法兰")) { + return "法兰"; + } + return null; + } + + private int categoryOrder(String category) { + String kind = modelPartKind(category); + if ("轴套".equals(kind)) { + return 0; + } + if ("行星架".equals(kind)) { + return 1; + } + if ("法兰".equals(kind)) { + return 2; + } + if ("通用件".equals(category)) { + return 3; + } + return 9; + } + private static List toPoTransit(List poList) { // 采购在途明细:从采购订单列表中过滤剩余未入库数量>0,按交期排序,最多返回50条(展示单号/交期/未入库数) if (poList == null || poList.isEmpty()) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SfcOperationPlanningMainServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SfcOperationPlanningMainServiceImpl.java index 588be3c..af3fd66 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SfcOperationPlanningMainServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SfcOperationPlanningMainServiceImpl.java @@ -78,7 +78,7 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin private final DingTalkProperties dingTalkProperties; /** - * 同步K3工序计划数据(全量新增与更新) + structuredList = {ArrayList@20034} size = 11284 * 同步K3工序计划数据(全量新增与更新) */ @Override @Transactional(rollbackFor = Exception.class) @@ -109,6 +109,9 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin mainEntity.setProductCode(resultDTO.getProductCode()); mainEntity.setProductName(resultDTO.getProductName()); mainEntity.setProSpecification(resultDTO.getProSpecification()); + mainEntity.setDocumentStatus(resultDTO.getDocumentStatus()); + mainEntity.setBillStatus(resultDTO.getBillStatus()); + mainEntity.setPlanDocumentStatus(resultDTO.getPlanDocumentStatus()); if (StringUtils.isNotBlank(resultDTO.getPlanStartTime())) { mainEntity.setPlanStartTime(DateUtil.parse(resultDTO.getPlanStartTime())); } @@ -152,7 +155,10 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin detailEntity.setWorkCenterName(detailDTO.getWorkCenter()); detailEntity.setProcessName(detailDTO.getProcessName()); detailEntity.setOperator(detailDTO.getOperator()); - detailEntity.setOperStatus(detailDTO.getStatus()); + // 存 FOperStatus 原始码(0~6),展示名由 Assembler 映射 + detailEntity.setOperStatus(StringUtils.isNotBlank(detailDTO.getOperStatus()) + ? detailDTO.getOperStatus().trim() + : detailDTO.getStatus()); if (StringUtils.isNotBlank(detailDTO.getSeqPlanStartTime())) { detailEntity.setSeqPlanStartTime(DateUtil.parse(detailDTO.getSeqPlanStartTime())); @@ -530,7 +536,6 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin resultDTO.setWorkCenter(mainWorkCenter); resultDTO.setOverallPlanQty(totalPlanQty); resultDTO.setOverallOkQty(totalOkQty); - OperationPlanningResultAssembler.applyOverallMetrics(resultDTO, resultDTO.getPlanFinishTime(), relatedDetails); } } @@ -657,6 +662,12 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin /** * 构建工序进度多 Sheet Excel 临时文件。 + *

    推送/导出过滤: + *

      + *
    • 生产订单单据状态、工序计划单据状态:仅已审核(C)
    • + *
    • 生产订单业务状态:仅下达(3)、开工(4)
    • + *
    • 工序 FOperStatus:仅下达(3)、开工(4)
    • + *
    */ private ProgressExcelFile buildCascadeProgressExcel(SfcOperationPlanningMainBo bo) throws Exception { if (bo == null) { @@ -664,14 +675,22 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin } LambdaQueryWrapper lqw = buildQueryWrapper(bo); lqw.notLike(SfcOperationPlanningMain::getHbytSclh, "暂停"); + // 单据状态:生产订单 + 工序计划 均只要已审核;业务状态只要下达/开工 + lqw.and(w -> w.eq(SfcOperationPlanningMain::getDocumentStatus, "C") + .or().eq(SfcOperationPlanningMain::getDocumentStatus, "已审核")); + lqw.and(w -> w.eq(SfcOperationPlanningMain::getPlanDocumentStatus, "C") + .or().eq(SfcOperationPlanningMain::getPlanDocumentStatus, "已审核")); + lqw.and(w -> w.in(SfcOperationPlanningMain::getBillStatus, "3", "4", "下达", "开工")); List mainList = baseMapper.selectList(lqw); if (mainList == null || mainList.isEmpty()) { - throw new ServiceException("没有可导出的工序计划数据"); + throw new ServiceException("没有符合状态条件的工序计划(订单/计划需已审核,业务状态需下达或开工)"); } List mainIds = mainList.stream().map(SfcOperationPlanningMain::getId).collect(Collectors.toList()); var detailQuery = Wrappers.lambdaQuery() - .in(SfcOperationPlanningDetail::getMainId, mainIds); + .in(SfcOperationPlanningDetail::getMainId, mainIds) + // 工序只要下达、开工(兼容历史中文) + .and(w -> w.in(SfcOperationPlanningDetail::getOperStatus, "3", "4", "下达", "开工")); if (StringUtils.isNotBlank(bo.getWorkCenterName())) { detailQuery.eq(SfcOperationPlanningDetail::getWorkCenterName, bo.getWorkCenterName().trim()); } @@ -687,10 +706,13 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin if (plan == null || plan.getProcessList() == null || plan.getProcessList().isEmpty()) { continue; } + if (!isProgressPushMainAllowed(plan)) { + continue; + } String sheetKey = resolveSheetKey(plan); List rows = sheetMap.computeIfAbsent(sheetKey, k -> new ArrayList<>()); for (ProcessDetailDTO process : plan.getProcessList()) { - if (process == null || isFinishedOrClosed(process)) { + if (process == null || !isProgressPushOperAllowed(process)) { continue; } rows.add(toProgressExportRow(plan, process)); @@ -698,7 +720,7 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin } sheetMap.entrySet().removeIf(e -> e.getValue() == null || e.getValue().isEmpty()); if (sheetMap.isEmpty()) { - throw new ServiceException("过滤完工/关闭后没有可导出的工序"); + throw new ServiceException("过滤后没有可导出的工序(需下达/开工且单据已审核)"); } String generatedAt = DateUtil.format(new Date(), "yyyy年MM月dd日 HH:mm:ss"); @@ -795,6 +817,43 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin return "未命名令号"; } + /** + * 推送/导出主表状态:生产订单与工序计划单据状态均需已审核;业务状态需下达或开工。 + */ + private static boolean isProgressPushMainAllowed(OperationPlanResultDTO plan) { + return isApprovedDocumentStatus(plan.getDocumentStatus()) + && isApprovedDocumentStatus(plan.getPlanDocumentStatus()) + && isReleasedOrStartedBillStatus(plan.getBillStatus()); + } + + /** + * 推送/导出工序:FOperStatus 只要下达(3)、开工(4)。 + */ + private static boolean isProgressPushOperAllowed(ProcessDetailDTO process) { + String oper = OperationPlanningResultAssembler.normalizeOperStatusCode(process.getOperStatus()); + if ("3".equals(oper) || "4".equals(oper)) { + return true; + } + String k3 = process.getK3Status() == null ? "" : process.getK3Status().trim(); + return "下达".equals(k3) || "开工".equals(k3) || "计划下达".equals(k3); + } + + private static boolean isApprovedDocumentStatus(String status) { + if (StringUtils.isBlank(status)) { + return false; + } + String v = status.trim(); + return "C".equalsIgnoreCase(v) || "已审核".equals(v); + } + + private static boolean isReleasedOrStartedBillStatus(String status) { + if (StringUtils.isBlank(status)) { + return false; + } + String v = status.trim(); + return "3".equals(v) || "4".equals(v) || "下达".equals(v) || "开工".equals(v); + } + /** * 去除已完工 / 已关闭(含结案、结算、已转出、工序完成)。 */ @@ -804,8 +863,10 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin } String status = process.getStatus() == null ? "" : process.getStatus().trim(); String k3 = process.getK3Status() == null ? "" : process.getK3Status().trim(); + String oper = process.getOperStatus() == null ? "" : process.getOperStatus().trim(); return status.contains("完工") || status.contains("关闭") - || "完工".equals(k3) || "关闭".equals(k3); + || "完工".equals(k3) || "关闭".equals(k3) + || "5".equals(oper) || "6".equals(oper); } private static OperationPlanningProgressExportVo toProgressExportRow( @@ -818,8 +879,10 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin row.setProductName(plan.getProductName()); row.setProSpecification(plan.getProSpecification()); row.setBillNo(plan.getBillNo()); - row.setOverallStatus(plan.getOverallStatus()); - row.setOverallProgress(scale2(plan.getOverallProgress())); + // 模板 {{ProgressData.overallStatus}} / overallProgress:工序状态、工序进度 + Double operProgress = resolveExportOperProgress(process); + row.setOverallProgress(scale2(operProgress)); + row.setOverallStatus(resolveStatusByProgress(operProgress)); row.setOperNo(process.getOperNo()); row.setProcessName(process.getProcessName()); row.setWorkCenter(process.getWorkCenter()); @@ -827,8 +890,8 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin row.setInQty(process.getInQty()); row.setOkQty(process.getOkQty()); row.setTransOutQty(process.getTransOutQty()); - row.setProgress(scale2(process.getProgress())); - row.setStatus(process.getStatus()); + row.setProgress(scale2(operProgress)); + row.setStatus(resolveStatusByProgress(operProgress)); row.setOperPlanStartTime(process.getOperPlanStartTime()); row.setOperPlanFinishTime(process.getOperPlanFinishTime()); row.setOperator(process.getOperator()); @@ -836,6 +899,36 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin return row; } + /** + * 工序状态按进度:0 未开工;>0 生产中;100 已完工 + */ + private static String resolveStatusByProgress(Double progress) { + double p = progress == null ? 0D : progress; + if (p <= 0D) { + return "未开工"; + } + if (p >= 100D) { + return "已完工"; + } + return "生产中"; + } + + /** 导出用工序进度:合格数量 / 工序数量 * 100 */ + private static Double resolveExportOperProgress(ProcessDetailDTO process) { + if (process == null) { + return 0D; + } + Double planQty = process.getPlanQty(); + if (planQty == null || planQty <= 0D) { + return 0D; + } + double ok = process.getOkQty() == null ? 0D : process.getOkQty(); + return BigDecimal.valueOf(ok) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(planQty), 2, RoundingMode.HALF_UP) + .doubleValue(); + } + private static Double scale2(Double value) { if (value == null) { return null; @@ -881,6 +974,9 @@ public class SfcOperationPlanningMainServiceImpl implements ISfcOperationPlannin lqw.eq(StringUtils.isNotBlank(bo.getProductCode()), SfcOperationPlanningMain::getProductCode, bo.getProductCode()); lqw.like(StringUtils.isNotBlank(bo.getProductName()), SfcOperationPlanningMain::getProductName, bo.getProductName()); lqw.eq(StringUtils.isNotBlank(bo.getProSpecification()), SfcOperationPlanningMain::getProSpecification, bo.getProSpecification()); + lqw.eq(StringUtils.isNotBlank(bo.getDocumentStatus()), SfcOperationPlanningMain::getDocumentStatus, bo.getDocumentStatus()); + lqw.eq(StringUtils.isNotBlank(bo.getBillStatus()), SfcOperationPlanningMain::getBillStatus, bo.getBillStatus()); + lqw.eq(StringUtils.isNotBlank(bo.getPlanDocumentStatus()), SfcOperationPlanningMain::getPlanDocumentStatus, bo.getPlanDocumentStatus()); lqw.ge(bo.getPlanStartTime() != null, SfcOperationPlanningMain::getPlanStartTime, bo.getPlanStartTime()); lqw.le(bo.getPlanFinishTime() != null, SfcOperationPlanningMain::getPlanFinishTime, bo.getPlanFinishTime()); if (StringUtils.isNotBlank(bo.getWorkCenterName())) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/support/OperationPlanningResultAssembler.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/OperationPlanningResultAssembler.java index 3af2bc7..27beba2 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/support/OperationPlanningResultAssembler.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/OperationPlanningResultAssembler.java @@ -39,7 +39,6 @@ public final class OperationPlanningResultAssembler { if (mainList == null || mainList.isEmpty()) { return resultList; } - Date now = new Date(); for (SfcOperationPlanningMain main : mainList) { OperationPlanResultDTO resultDTO = new OperationPlanResultDTO(); resultDTO.setMoNumber(main.getMoNumber()); @@ -52,6 +51,12 @@ public final class OperationPlanningResultAssembler { resultDTO.setPlanStartTime(main.getPlanStartTime() != null ? DateUtil.formatDateTime(main.getPlanStartTime()) : null); resultDTO.setPlanFinishTime(main.getPlanFinishTime() != null ? DateUtil.formatDateTime(main.getPlanFinishTime()) : null); resultDTO.setFid(main.getFid()); + resultDTO.setDocumentStatus(main.getDocumentStatus()); + resultDTO.setDocumentStatusName(mapDocumentStatusName(main.getDocumentStatus())); + resultDTO.setBillStatus(main.getBillStatus()); + resultDTO.setBillStatusName(mapBillStatusName(main.getBillStatus())); + resultDTO.setPlanDocumentStatus(main.getPlanDocumentStatus()); + resultDTO.setPlanDocumentStatusName(mapDocumentStatusName(main.getPlanDocumentStatus())); resultDTO.setRawPlanStartTime(main.getPlanStartTime()); List processList = new ArrayList<>(); @@ -78,11 +83,8 @@ public final class OperationPlanningResultAssembler { resultDTO.setWorkCenter(mainWorkCenter); resultDTO.setOverallPlanQty(totalPlanQty); resultDTO.setOverallOkQty(totalOkQty); - applyOverallMetrics(resultDTO, main.getPlanFinishTime(), processList, now); - } else { - resultDTO.setOverallProgress(0.0); - resultDTO.setOverallStatus(isOverdue(main.getPlanFinishTime(), now) ? STATUS_DELAYED : STATUS_NOT_STARTED); } + // 整体进度/整体状态不再计算 resultDTO.setProcessList(processList); resultList.add(resultDTO); } @@ -162,8 +164,10 @@ public final class OperationPlanningResultAssembler { detailDTO.setWastageQty(detail.getWastageQty() != null ? detail.getWastageQty().doubleValue() : null); detailDTO.setDepartment(detail.getDepartmentName()); detailDTO.setOperator(detail.getOperator()); - if (detailDTO.getPlanQty() != null && detailDTO.getInQty() != null && detailDTO.getPlanQty() > 0) { - BigDecimal progress = BigDecimal.valueOf(detailDTO.getInQty()) + // 工序进度 = 合格数量 / 工序数量 * 100 + if (detailDTO.getPlanQty() != null && detailDTO.getPlanQty() > 0) { + double ok = detailDTO.getOkQty() == null ? 0D : detailDTO.getOkQty(); + BigDecimal progress = BigDecimal.valueOf(ok) .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(detailDTO.getPlanQty()), 1, RoundingMode.HALF_UP); if (progress.compareTo(BigDecimal.ZERO) < 0) { @@ -174,40 +178,9 @@ public final class OperationPlanningResultAssembler { detailDTO.setProgress(progress.doubleValue()); } - String statusStr = detail.getOperStatus(); - if ("1".equals(statusStr)) { - detailDTO.setK3Status("计划"); - } else if ("2".equals(statusStr)) { - detailDTO.setK3Status("计划下达"); - } else if ("3".equals(statusStr)) { - detailDTO.setK3Status("开工"); - } else if ("4".equals(statusStr)) { - detailDTO.setK3Status("已完工"); - } else if ("5".equals(statusStr)) { - detailDTO.setK3Status("结案"); - } else if ("6".equals(statusStr)) { - detailDTO.setK3Status("结算"); - } else { - detailDTO.setK3Status(statusStr); - } - - double inQty = detailDTO.getInQty() == null ? 0D : detailDTO.getInQty(); - double okQty = detailDTO.getOkQty() == null ? 0D : detailDTO.getOkQty(); - double ngQty = detailDTO.getNgQty() == null ? 0D : detailDTO.getNgQty(); - double transOutQty = detailDTO.getTransOutQty() == null ? 0D : detailDTO.getTransOutQty(); - if ("5".equals(statusStr) || "6".equals(statusStr)) { - detailDTO.setStatus("已关闭"); - } else if (inQty == 0D) { - detailDTO.setStatus("未开工"); - } else if (transOutQty > 0D) { - detailDTO.setStatus("已转出"); - } else if (okQty + ngQty >= inQty) { - detailDTO.setStatus("工序完成"); - } else if (okQty + ngQty > 0D) { - detailDTO.setStatus("质检中"); - } else { - detailDTO.setStatus("生产中"); - } + // 兼容历史中文状态:优先按 FOperStatus 码解析 + detailDTO.setOperStatus(normalizeOperStatusCode(detail.getOperStatus())); + enrichProcessDetailStatus(detailDTO); detailDTO.setSeqPlanStartTime(detail.getSeqPlanStartTime() != null ? DateUtil.formatDateTime(detail.getSeqPlanStartTime()) : null); detailDTO.setSeqPlanFinishTime(detail.getSeqPlanFinishTime() != null ? DateUtil.formatDateTime(detail.getSeqPlanFinishTime()) : null); @@ -223,16 +196,184 @@ public final class OperationPlanningResultAssembler { return detailDTO; } + /** + * 根据 FOperStatus 原始码填充 k3Status / 业务 status。 + * FOperStatus:0创建 1计划 2计划确认 3下达 4开工 5完工 6关闭 + *

    数量口径:工序数(planQty)=转入=合格=转出 时视为已转出完工。 + */ + public static void enrichProcessDetailStatus(ProcessDetailDTO detailDTO) { + if (detailDTO == null) { + return; + } + String statusStr = normalizeOperStatusCode(detailDTO.getOperStatus()); + detailDTO.setOperStatus(statusStr); + detailDTO.setK3Status(mapOperStatusName(statusStr)); + + double planQty = detailDTO.getPlanQty() == null ? 0D : detailDTO.getPlanQty(); + double inQty = detailDTO.getInQty() == null ? 0D : detailDTO.getInQty(); + double okQty = detailDTO.getOkQty() == null ? 0D : detailDTO.getOkQty(); + double ngQty = detailDTO.getNgQty() == null ? 0D : detailDTO.getNgQty(); + double transOutQty = detailDTO.getTransOutQty() == null ? 0D : detailDTO.getTransOutQty(); + if ("5".equals(statusStr) || "6".equals(statusStr) + || "完工".equals(statusStr) || "关闭".equals(statusStr)) { + detailDTO.setStatus("已关闭"); + } else if (inQty == 0D) { + detailDTO.setStatus("未开工"); + } else if (isQtyFullyTransferredOut(planQty, inQty, okQty, transOutQty)) { + // 工序数=转入=合格=转出 → 真正完工转出 + detailDTO.setStatus("已转出"); + } else if (okQty + ngQty >= inQty && inQty > 0D) { + detailDTO.setStatus("工序完成"); + } else if (okQty + ngQty > 0D) { + detailDTO.setStatus("质检中"); + } else if (transOutQty > 0D) { + // 部分转出,不算整道工序完工 + detailDTO.setStatus("生产中"); + } else { + detailDTO.setStatus("生产中"); + } + } + + /** + * 工序完工数量条件:工序数 = 转入 = 合格 = 转出,且均 > 0。 + */ + public static boolean isQtyFullyTransferredOut(Double planQty, Double inQty, Double okQty, Double transOutQty) { + double plan = planQty == null ? 0D : planQty; + double in = inQty == null ? 0D : inQty; + double ok = okQty == null ? 0D : okQty; + double out = transOutQty == null ? 0D : transOutQty; + if (plan <= 0D) { + return false; + } + return qtyEquals(plan, in) && qtyEquals(plan, ok) && qtyEquals(plan, out); + } + + private static boolean qtyEquals(double a, double b) { + return BigDecimal.valueOf(a).subtract(BigDecimal.valueOf(b)).abs() + .compareTo(new BigDecimal("0.0001")) <= 0; + } + + /** FDocumentStatus:A创建 B审核中 C已审核 D重新审核 E暂存 */ + public static String mapDocumentStatusName(String code) { + if (StringUtils.isBlank(code)) { + return null; + } + switch (code.trim().toUpperCase()) { + case "A": + return "创建"; + case "B": + return "审核中"; + case "C": + return "已审核"; + case "D": + return "重新审核"; + case "E": + case "Z": + return "暂存"; + default: + return code; + } + } + + /** FStatus:1计划 2计划确认 3下达 4开工 5完工 6结案 7结算 */ + public static String mapBillStatusName(String code) { + if (StringUtils.isBlank(code)) { + return null; + } + switch (code.trim()) { + case "1": + return "计划"; + case "2": + return "计划确认"; + case "3": + return "下达"; + case "4": + return "开工"; + case "5": + return "完工"; + case "6": + return "结案"; + case "7": + return "结算"; + default: + return code; + } + } + + /** FOperStatus:0创建 1计划 2计划确认 3下达 4开工 5完工 6关闭 */ + public static String mapOperStatusName(String code) { + if (StringUtils.isBlank(code)) { + return null; + } + switch (code.trim()) { + case "0": + return "创建"; + case "1": + return "计划"; + case "2": + return "计划确认"; + case "3": + return "下达"; + case "4": + return "开工"; + case "5": + return "完工"; + case "6": + return "关闭"; + default: + return code; + } + } + + /** 历史同步曾写入中文,读出时尽量还原为码值 */ + public static String normalizeOperStatusCode(String raw) { + if (StringUtils.isBlank(raw)) { + return raw; + } + String v = raw.trim(); + switch (v) { + case "创建": + return "0"; + case "计划": + return "1"; + case "计划确认": + return "2"; + case "下达": + case "计划下达": + return "3"; + case "开工": + return "4"; + case "完工": + case "已完工": + return "5"; + case "关闭": + case "结案": + case "结算": + case "已关闭": + return "6"; + default: + return v; + } + } + public static boolean isProcessFinished(ProcessDetailDTO detail) { if (detail == null) { return false; } - String status = detail.getStatus(); - if ("已关闭".equals(status) || "已转出".equals(status) || "工序完成".equals(status)) { + // 金蝶工序状态完工/关闭 + String oper = normalizeOperStatusCode(detail.getOperStatus()); + if ("5".equals(oper) || "6".equals(oper)) { return true; } String k3 = detail.getK3Status(); - return "已完工".equals(k3) || "结案".equals(k3) || "结算".equals(k3); + if ("完工".equals(k3) || "关闭".equals(k3) || "已完工".equals(k3) || "结案".equals(k3) || "结算".equals(k3)) { + return true; + } + if ("已关闭".equals(detail.getStatus())) { + return true; + } + // 数量口径:工序数 = 转入 = 合格 = 转出 才算完工 + return isQtyFullyTransferredOut(detail.getPlanQty(), detail.getInQty(), detail.getOkQty(), detail.getTransOutQty()); } static boolean isProcessStarted(ProcessDetailDTO detail) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/support/WorkAlertPushTypeRegistry.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/WorkAlertPushTypeRegistry.java index ad840b3..65ad5d1 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/support/WorkAlertPushTypeRegistry.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/WorkAlertPushTypeRegistry.java @@ -3,9 +3,11 @@ package com.ruoyi.system.service.support; import lombok.Getter; import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -61,10 +63,24 @@ public final class WorkAlertPushTypeRegistry { private static final Map BY_CODE = TYPES.stream() .collect(Collectors.toMap(PushType::getCode, t -> t, (a, b) -> a, LinkedHashMap::new)); + /** 不走钉钉群会话(企业微信 / 固定私发 / 未接 conversationId) */ + private static final Set NON_GROUP_CODES = new HashSet<>(Arrays.asList( + "getMassageDelayDateSummary", + "getApplicationMessage", + "getConstructionDelay", + "getUninspectedData" + )); + public static List all() { return TYPES; } + public static List groupConversationTypes() { + return TYPES.stream() + .filter(t -> supportsGroupConversation(t.getCode())) + .collect(Collectors.toList()); + } + public static PushType find(String pushCode) { if (pushCode == null) { return null; @@ -75,4 +91,8 @@ public final class WorkAlertPushTypeRegistry { public static boolean isSupported(String pushCode) { return find(pushCode) != null; } + + public static boolean supportsGroupConversation(String pushCode) { + return isSupported(pushCode) && !NON_GROUP_CODES.contains(pushCode); + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/utils/ProductionOrderNoUtils.java b/ruoyi-system/src/main/java/com/ruoyi/system/utils/ProductionOrderNoUtils.java index 079e813..52d5db0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/utils/ProductionOrderNoUtils.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/utils/ProductionOrderNoUtils.java @@ -25,6 +25,25 @@ public final class ProductionOrderNoUtils { private ProductionOrderNoUtils() { } + /** + * 入库前去掉令号中的空白(首尾、中间空格、全角空格、不间断空格), + * 避免从 Excel/网页复制时带入空格导致后续对不上。 + */ + public static String stripSpaces(String productionOrderNo) { + if (productionOrderNo == null) { + return null; + } + StringBuilder sb = new StringBuilder(productionOrderNo.length()); + for (int i = 0; i < productionOrderNo.length(); i++) { + char c = productionOrderNo.charAt(i); + if (Character.isWhitespace(c) || c == '\u00A0' || c == '\u3000') { + continue; + } + sb.append(c); + } + return sb.length() == 0 ? null : sb.toString(); + } + public static String extractYear(String productionOrderNo) { if (StringUtils.isBlank(productionOrderNo)) { throw new IllegalArgumentException("生产令号为空,无法识别年份"); diff --git a/ruoyi-system/src/main/resources/EXCEL模板/生产及工艺计划模版.xlsx b/ruoyi-system/src/main/resources/EXCEL模板/生产及工艺计划模版.xlsx index 9cffa4a..f17c0d7 100644 Binary files a/ruoyi-system/src/main/resources/EXCEL模板/生产及工艺计划模版.xlsx and b/ruoyi-system/src/main/resources/EXCEL模板/生产及工艺计划模版.xlsx differ diff --git a/ruoyi-system/src/main/resources/EXCEL模板/生产项目进度模板.xlsx b/ruoyi-system/src/main/resources/EXCEL模板/生产项目进度模板.xlsx index e3e187b..719b8a8 100644 Binary files a/ruoyi-system/src/main/resources/EXCEL模板/生产项目进度模板.xlsx and b/ruoyi-system/src/main/resources/EXCEL模板/生产项目进度模板.xlsx differ diff --git a/ruoyi-system/src/main/resources/mapper/system/ProcessOrderProMapper.xml b/ruoyi-system/src/main/resources/mapper/system/ProcessOrderProMapper.xml index be9f8a5..ed0731d 100644 --- a/ruoyi-system/src/main/resources/mapper/system/ProcessOrderProMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/ProcessOrderProMapper.xml @@ -22,6 +22,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + diff --git a/ruoyi-system/src/main/resources/mapper/system/SfcOperationPlanningMainMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SfcOperationPlanningMainMapper.xml index 643d005..5df10e9 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SfcOperationPlanningMainMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SfcOperationPlanningMainMapper.xml @@ -16,6 +16,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + diff --git a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index 77ebd10..28a8012 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -12,6 +12,7 @@ + @@ -56,6 +57,7 @@ u.email, u.avatar, u.phonenumber, + u.employee_number, u.password, u.sex, u.status, @@ -85,7 +87,7 @@