xiaoyi
3 天以前 fae95228037f0c1304c1917ae5fff452f2160c5a
feat: add process knowledge and after-sales workflows
已添加31个文件
已修改4个文件
1394 ■■■■■ 文件已修改
sql/mysql/after_sale_collaboration_20261105.sql 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/after_sale_collaboration_menu_20261105.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/after_sale_finance_draft_20261105.sql 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/after_sale_finance_draft_menu_20261105.sql 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/process_knowledge_entry_20261105.sql 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/process_knowledge_menu_20261105.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/process_knowledge_schema_20261105.sql 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/pom.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/collaboration/AfterSaleCollaborationController.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/collaboration/AfterSaleCollaborationDO.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/collaboration/AfterSaleCollaborationMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/collaboration/AfterSaleCollaborationService.java 231 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleLedgerService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/finance/AfterSaleFinanceDraftApi.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/finance/dto/AfterSaleFinanceDraftCreateDTO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/finance/dto/AfterSaleFinanceReviewDTO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpAfterSaleFinanceDraftController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpAfterSaleFinanceDraftDO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpAfterSaleFinanceDraftMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpAfterSaleFinanceDraftService.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pd/knowledge/KnowledgeController.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeDO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeReferenceDO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeResourceDO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeStandardDO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeReferenceMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeResourceMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeStandardMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeMatch.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeSearchService.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeWriteService.java 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/after_sale_collaboration_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
-- å”®åŽè·¨éƒ¨é—¨ååŒä»»åŠ¡ï¼›ä¸ç›´æŽ¥åˆ›å»ºè´¢åŠ¡å‡­è¯æˆ–ä¾›åº”å•†ç´¢èµ”å•ã€‚
CREATE TABLE IF NOT EXISTS after_sale_collaboration (
  id BIGINT NOT NULL AUTO_INCREMENT,
  ticket_id BIGINT NOT NULL COMMENT '售后工单编号',
  category VARCHAR(32) NOT NULL COMMENT 'QUALITY、FINANCE、SUPPLIER、WARRANTY、RESEARCH、PROCESS',
  title VARCHAR(200) NOT NULL COMMENT '协同事项',
  content VARCHAR(2000) NOT NULL COMMENT '待处理内容',
  assignee_user_id BIGINT NOT NULL COMMENT '接收人',
  requested_by BIGINT NOT NULL COMMENT '发起人',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '0 å¾…处理,1 å·²å®Œæˆï¼Œ2 å·²å–消',
  result VARCHAR(2000) DEFAULT NULL COMMENT '处理结果',
  completed_time DATETIME DEFAULT NULL,
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id), KEY idx_collaboration_ticket (ticket_id, deleted),
  KEY idx_collaboration_assignee (assignee_user_id, status, deleted),
  KEY idx_collaboration_requester (requested_by, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='售后协同任务';
sql/mysql/after_sale_collaboration_menu_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
-- ä¾èµ–既有售后管理目录,所有 ID ç”±ç›®æ ‡åº“生成;角色权限需在目标系统显式分配。
SET @after_sale_parent := (SELECT id FROM system_menu WHERE path = '/aftersales' AND parent_id = 0 AND deleted = b'0' LIMIT 1);
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, updater)
SELECT '售后协同', '', 2, 20, @after_sale_parent, 'collaboration', 'lucide:users', 'aftersales/collaboration/index', 'AftersalesCollaboration', 0, b'1', b'1', b'1', '1', '1'
WHERE @after_sale_parent IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component = 'aftersales/collaboration/index' AND deleted = b'0');
SET @collaboration_page := (SELECT id FROM system_menu WHERE component = 'aftersales/collaboration/index' AND deleted = b'0' LIMIT 1);
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, status, visible, keep_alive, always_show, creator, updater)
SELECT p.name, p.permission, 3, p.sort_no, @collaboration_page, '', '', 0, b'1', b'1', b'1', '1', '1'
FROM (SELECT '协同查询' name, 'aftersales:collaboration:query' permission, 1 sort_no UNION ALL
      SELECT '协同发起', 'aftersales:collaboration:create', 2 UNION ALL
      SELECT '协同处理', 'aftersales:collaboration:update', 3 UNION ALL
      SELECT '业务审核', 'aftersales:collaboration:approve', 4) p
WHERE @collaboration_page IS NOT NULL AND NOT EXISTS
 (SELECT 1 FROM system_menu m WHERE m.permission = p.permission AND m.deleted = b'0');
sql/mysql/after_sale_finance_draft_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
-- åœ¨ after_sale_collaboration_20261105.sql åŽæ‰§è¡Œã€‚仅保存财务待审核草稿,不产生付款或凭证。
SET @sql_amount := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='amount')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN amount DECIMAL(18,2) NULL COMMENT ''金额(元)''', 'SELECT 1');
PREPARE stmt_amount FROM @sql_amount; EXECUTE stmt_amount; DEALLOCATE PREPARE stmt_amount;
SET @sql_direction := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='direction')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN direction VARCHAR(16) NULL COMMENT ''RECEIVABLE、PAYABLE、INTERNAL''', 'SELECT 1');
PREPARE stmt_direction FROM @sql_direction; EXECUTE stmt_direction; DEALLOCATE PREPARE stmt_direction;
SET @sql_party := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='party_name')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN party_name VARCHAR(200) NULL COMMENT ''往来方名称''', 'SELECT 1');
PREPARE stmt_party FROM @sql_party; EXECUTE stmt_party; DEALLOCATE PREPARE stmt_party;
SET @sql_finance_id := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='finance_draft_id')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN finance_draft_id BIGINT NULL COMMENT ''财务草稿编号''', 'SELECT 1');
PREPARE stmt_finance_id FROM @sql_finance_id; EXECUTE stmt_finance_id; DEALLOCATE PREPARE stmt_finance_id;
SET @sql_business_actor := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='business_approver_id')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN business_approver_id BIGINT NULL COMMENT ''业务审核人''', 'SELECT 1');
PREPARE stmt_business_actor FROM @sql_business_actor; EXECUTE stmt_business_actor; DEALLOCATE PREPARE stmt_business_actor;
SET @sql_business_time := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='business_approval_time')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN business_approval_time DATETIME NULL COMMENT ''业务审核时间''', 'SELECT 1');
PREPARE stmt_business_time FROM @sql_business_time; EXECUTE stmt_business_time; DEALLOCATE PREPARE stmt_business_time;
SET @sql_business_remark := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='business_remark')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN business_remark VARCHAR(1000) NULL COMMENT ''业务审核意见''', 'SELECT 1');
PREPARE stmt_business_remark FROM @sql_business_remark; EXECUTE stmt_business_remark; DEALLOCATE PREPARE stmt_business_remark;
SET @sql_previous := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='previous_id')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN previous_id BIGINT NULL COMMENT ''上一次退回的协同事项''', 'SELECT 1');
PREPARE stmt_previous FROM @sql_previous; EXECUTE stmt_previous; DEALLOCATE PREPARE stmt_previous;
SET @sql_previous_index := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND index_name='uq_collaboration_previous')=0,
  'ALTER TABLE after_sale_collaboration ADD UNIQUE KEY uq_collaboration_previous (previous_id)', 'SELECT 1');
PREPARE stmt_previous_index FROM @sql_previous_index; EXECUTE stmt_previous_index; DEALLOCATE PREPARE stmt_previous_index;
CREATE TABLE IF NOT EXISTS erp_after_sale_finance_draft (
  id BIGINT NOT NULL AUTO_INCREMENT,
  collaboration_id BIGINT NOT NULL COMMENT '售后协同事项',
  ticket_id BIGINT NOT NULL COMMENT '售后工单',
  category VARCHAR(32) NOT NULL COMMENT '协同类型',
  direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE、PAYABLE、INTERNAL',
  amount DECIMAL(18,2) NOT NULL COMMENT '金额(元)',
  party_name VARCHAR(200) DEFAULT NULL COMMENT '往来方名称',
  description VARCHAR(2000) NOT NULL COMMENT '业务说明',
  business_approver_id BIGINT NOT NULL COMMENT '业务确认人',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '0 å¾…财务审核,1 å®¡æ ¸é€šè¿‡ï¼Œ2 é€€å›ž',
  reviewer_id BIGINT DEFAULT NULL,
  review_remark VARCHAR(1000) DEFAULT NULL,
  review_time DATETIME DEFAULT NULL,
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id), UNIQUE KEY uq_as_finance_collaboration (collaboration_id),
  KEY idx_as_finance_status (status, deleted), KEY idx_as_finance_ticket (ticket_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='售后财务审核草稿';
-- å…³è”主数据,同时保留名称快照;历史记录允许为空。
SET @sql_party_type := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='party_type')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN party_type VARCHAR(16) NULL', 'SELECT 1');
PREPARE stmt_party_type FROM @sql_party_type; EXECUTE stmt_party_type; DEALLOCATE PREPARE stmt_party_type;
SET @sql_party_id := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='after_sale_collaboration' AND column_name='party_id')=0,
  'ALTER TABLE after_sale_collaboration ADD COLUMN party_id BIGINT NULL', 'SELECT 1');
PREPARE stmt_party_id FROM @sql_party_id; EXECUTE stmt_party_id; DEALLOCATE PREPARE stmt_party_id;
SET @sql_draft_party_type := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='erp_after_sale_finance_draft' AND column_name='party_type')=0,
  'ALTER TABLE erp_after_sale_finance_draft ADD COLUMN party_type VARCHAR(16) NULL', 'SELECT 1');
PREPARE stmt_draft_party_type FROM @sql_draft_party_type; EXECUTE stmt_draft_party_type; DEALLOCATE PREPARE stmt_draft_party_type;
SET @sql_draft_party_id := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='erp_after_sale_finance_draft' AND column_name='party_id')=0,
  'ALTER TABLE erp_after_sale_finance_draft ADD COLUMN party_id BIGINT NULL', 'SELECT 1');
PREPARE stmt_draft_party_id FROM @sql_draft_party_id; EXECUTE stmt_draft_party_id; DEALLOCATE PREPARE stmt_draft_party_id;
sql/mysql/after_sale_finance_draft_menu_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
-- åœ¨è´¢åŠ¡ç®¡ç†ä¸‹å¢žåŠ ç‹¬ç«‹å®¡æ ¸è‰ç¨¿å…¥å£ï¼›è§’è‰²æƒé™åœ¨ç›®æ ‡ç³»ç»Ÿä¸­æ˜¾å¼åˆ†é…ã€‚
SET @finance_parent := (SELECT id FROM system_menu WHERE path='finance' AND type=1 AND deleted=b'0' AND name='财务管理' LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater)
SELECT '售后财务草稿','',2,30,@finance_parent,'after-sale-draft','lucide:file-check','erp/finance/after-sale-draft/index','ErpAfterSaleFinanceDraft',0,b'1',b'1',b'1','1','1'
WHERE @finance_parent IS NOT NULL AND NOT EXISTS(SELECT 1 FROM system_menu WHERE component='erp/finance/after-sale-draft/index' AND deleted=b'0');
SET @finance_page := (SELECT id FROM system_menu WHERE component='erp/finance/after-sale-draft/index' AND deleted=b'0' LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,status,visible,keep_alive,always_show,creator,updater)
SELECT p.name,p.permission,3,p.sort_no,@finance_page,'','',0,b'1',b'1',b'1','1','1'
FROM (SELECT '草稿查询' name,'erp:after-sale-finance:query' permission,1 sort_no UNION ALL SELECT '草稿审核','erp:after-sale-finance:review',2) p
WHERE @finance_page IS NOT NULL AND NOT EXISTS(SELECT 1 FROM system_menu m WHERE m.permission=p.permission AND m.deleted=b'0');
sql/mysql/process_knowledge_entry_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
-- ä¾èµ– process_knowledge_menu_20261105.sql;菜单 ID ç”±æ•°æ®åº“生成。
SET @process_parent := (SELECT id FROM system_menu WHERE path = '/process-design' AND parent_id = 0 AND deleted = b'0' LIMIT 1);
SET @knowledge_menu := (SELECT id FROM system_menu WHERE parent_id = @process_parent AND path = 'knowledge' AND type = 1 AND deleted = b'0' LIMIT 1);
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, updater)
SELECT '知识维护', '', 2, 0, @knowledge_menu, 'catalog', 'lucide:book-open', 'mes/process-design/knowledge/index', 'MesPdKnowledge', 0, b'1', b'1', b'1', '1', '1'
WHERE @knowledge_menu IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component = 'mes/process-design/knowledge/index' AND deleted = b'0');
SET @knowledge_page := (SELECT id FROM system_menu WHERE component = 'mes/process-design/knowledge/index' AND deleted = b'0' LIMIT 1);
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, status, visible, keep_alive, always_show, creator, updater)
SELECT p.name, p.permission, 3, p.sort_no, @knowledge_page, '', '', 0, b'1', b'1', b'1', '1', '1'
FROM (SELECT '知识查询' name, 'mes:pd-knowledge:query' permission, 1 sort_no UNION ALL
      SELECT '知识创建', 'mes:pd-knowledge:create', 2 UNION ALL
      SELECT '知识修改', 'mes:pd-knowledge:update', 3 UNION ALL
      SELECT '知识发布', 'mes:pd-knowledge:publish', 4 UNION ALL
      SELECT '知识参考', 'mes:pd-knowledge:reference', 5) p
WHERE @knowledge_page IS NOT NULL AND NOT EXISTS
 (SELECT 1 FROM system_menu m WHERE m.permission = p.permission AND m.deleted = b'0');
-- é¡µé¢ä¸Žæ“ä½œæƒé™é€šè¿‡ç³»ç»Ÿè§’色管理显式分配;不自动扩大已有角色权限。
sql/mysql/process_knowledge_menu_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
-- å·¥è‰ºçŸ¥è¯†åº“菜单整理:保留原页面、权限及按钮,仅新增目录并调整父级。
-- åœ¨ç›®æ ‡åº“执行前请先备份 system_menu å’Œ system_role_menu。
-- é€šè¿‡ç»„件路径定位已有菜单,不依赖跨项目的固定菜单 ID。
SET @process_parent := (SELECT id FROM system_menu WHERE path = '/process-design' AND parent_id = 0 AND deleted = b'0' LIMIT 1);
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, status, visible, keep_alive, always_show, creator, updater)
SELECT '工艺知识库', '', 1, 1, @process_parent, 'knowledge', 'lucide:library-big', 0, b'1', b'1', b'1', '1', '1'
WHERE @process_parent IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM system_menu WHERE parent_id = @process_parent AND path = 'knowledge' AND deleted = b'0');
SET @knowledge_menu := (SELECT id FROM system_menu WHERE parent_id = @process_parent AND path = 'knowledge' AND type = 1 AND deleted = b'0' LIMIT 1);
-- åªå‘已有相关页面权限的角色开放目录,不扩大业务页面权限。
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, tenant_id)
SELECT DISTINCT rm.role_id, @knowledge_menu, '1', '1', rm.tenant_id
FROM system_role_menu rm JOIN system_menu m ON m.id = rm.menu_id
WHERE @knowledge_menu IS NOT NULL AND rm.deleted = b'0' AND m.deleted = b'0'
  AND m.component IN ('mes/process-design/parameter/index', 'mes/process-design/template/index', 'mes/process-design/process/index')
  AND NOT EXISTS (SELECT 1 FROM system_role_menu existing WHERE existing.role_id = rm.role_id AND existing.tenant_id = rm.tenant_id AND existing.menu_id = @knowledge_menu AND existing.deleted = b'0');
UPDATE system_menu SET parent_id = @knowledge_menu, updater = '1', update_time = NOW()
WHERE @knowledge_menu IS NOT NULL AND parent_id = @process_parent AND deleted = b'0'
  AND component IN ('mes/process-design/parameter/index', 'mes/process-design/template/index', 'mes/process-design/process/index');
sql/mysql/process_knowledge_schema_20261105.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
-- å·¥è‰ºçŸ¥è¯†åº“独立数据结构;不修改既有路线、参数和工序数据。
-- ä½¿ç”¨ä¸šåŠ¡ ID å…³è”现有主数据,不跨模块设置物理外键,便于项目间迁移。
CREATE TABLE IF NOT EXISTS mes_pd_knowledge (
  id BIGINT NOT NULL AUTO_INCREMENT,
  code VARCHAR(64) NOT NULL COMMENT '知识编码',
  name VARCHAR(200) NOT NULL COMMENT '知识名称',
  version INT NOT NULL DEFAULT 1 COMMENT '知识版本',
  product_item_id BIGINT DEFAULT NULL COMMENT '适用成品物料',
  bom_id BIGINT DEFAULT NULL COMMENT '适用 BOM å­ç‰©æ–™ç¼–号',
  voltage_min DECIMAL(16,4) DEFAULT NULL COMMENT '最低电压,单位 kV',
  voltage_max DECIMAL(16,4) DEFAULT NULL COMMENT '最高电压,单位 kV',
  market VARCHAR(16) NOT NULL DEFAULT 'ALL' COMMENT 'ALL、DOMESTIC æˆ– EXPORT',
  route_id BIGINT DEFAULT NULL COMMENT '参考工艺路线',
  template_id BIGINT DEFAULT NULL COMMENT '参考参数模板',
  description TEXT COMMENT '适用说明',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '0 è‰ç¨¿ã€1 å·²å‘布、2 åœç”¨',
  creator VARCHAR(64) DEFAULT '',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '',
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id),
  UNIQUE KEY uq_knowledge_code_version (code, version),
  KEY idx_knowledge_match (status, market, product_item_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺知识版本';
-- åŒæ­¥ä¸ºå…ˆå‰å·²åˆ›å»ºçš„表补建唯一索引;如已有重复数据则应先人工处理,不能静默跳过。
SET @knowledge_unique_sql := IF(
  (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE()
     AND table_name = 'mes_pd_knowledge' AND index_name = 'uq_knowledge_code_version') = 0,
  'ALTER TABLE mes_pd_knowledge ADD UNIQUE KEY uq_knowledge_code_version (code, version)',
  'SELECT 1');
PREPARE knowledge_unique_stmt FROM @knowledge_unique_sql;
EXECUTE knowledge_unique_stmt;
DEALLOCATE PREPARE knowledge_unique_stmt;
CREATE TABLE IF NOT EXISTS mes_pd_knowledge_standard (
  id BIGINT NOT NULL AUTO_INCREMENT,
  knowledge_id BIGINT NOT NULL,
  standard_code VARCHAR(128) NOT NULL COMMENT '适用标准编码',
  standard_name VARCHAR(200) DEFAULT NULL COMMENT '适用标准名称',
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id), KEY idx_knowledge_standard_parent (knowledge_id, deleted),
  KEY idx_knowledge_standard_code (standard_code, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺知识适用标准';
CREATE TABLE IF NOT EXISTS mes_pd_knowledge_resource (
  id BIGINT NOT NULL AUTO_INCREMENT,
  knowledge_id BIGINT NOT NULL,
  process_id BIGINT DEFAULT NULL COMMENT '现有工序',
  resource_type VARCHAR(32) NOT NULL COMMENT '资源类型',
  resource_id BIGINT DEFAULT NULL COMMENT '现有资源编号',
  capability_description VARCHAR(500) DEFAULT NULL COMMENT '能力及约束描述',
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id), KEY idx_knowledge_resource_parent (knowledge_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺知识资源能力';
CREATE TABLE IF NOT EXISTS mes_pd_knowledge_reference (
  id BIGINT NOT NULL AUTO_INCREMENT,
  knowledge_id BIGINT NOT NULL,
  knowledge_version INT NOT NULL COMMENT '引用时版本',
  target_type VARCHAR(32) NOT NULL COMMENT '引用目标类型',
  target_id BIGINT NOT NULL COMMENT '引用目标编号',
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id), UNIQUE KEY uq_knowledge_reference (knowledge_id, target_type, target_id),
  KEY idx_knowledge_reference_source (knowledge_id, knowledge_version, deleted),
  KEY idx_knowledge_reference_target (target_type, target_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺知识引用记录';
SET @knowledge_reference_sql := IF(
  (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE()
     AND table_name = 'mes_pd_knowledge_reference' AND index_name = 'uq_knowledge_reference') = 0,
  'ALTER TABLE mes_pd_knowledge_reference ADD UNIQUE KEY uq_knowledge_reference (knowledge_id, target_type, target_id)',
  'SELECT 1');
PREPARE knowledge_reference_stmt FROM @knowledge_reference_sql;
EXECUTE knowledge_reference_stmt;
DEALLOCATE PREPARE knowledge_reference_stmt;
yudao-module-aftersales/pom.xml
@@ -18,6 +18,11 @@
    </description>
    <dependencies>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-srm-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- å”®åŽ API æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/collaboration/AfterSaleCollaborationController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.collaboration;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.collaboration.AfterSaleCollaborationDO;
import cn.iocoder.yudao.module.aftersales.service.collaboration.AfterSaleCollaborationService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@RestController
@RequestMapping("/aftersales/collaboration")
public class AfterSaleCollaborationController {
    @Resource private AfterSaleCollaborationService service;
    @PostMapping("/create")
    @PreAuthorize("@ss.hasPermission('aftersales:collaboration:create')")
    public CommonResult<Long> create(@RequestBody AfterSaleCollaborationService.CreateRequest input) {
        return success(service.create(input, getLoginUserId()));
    }
    @GetMapping("/my-page")
    @PreAuthorize("@ss.hasPermission('aftersales:collaboration:query')")
    public CommonResult<PageResult<AfterSaleCollaborationDO>> myPage(@Valid PageParam page) {
        return success(service.myTasks(page, getLoginUserId()));
    }
    @GetMapping("/ticket-list")
    @PreAuthorize("@ss.hasPermission('aftersales:collaboration:query')")
    public CommonResult<List<AfterSaleCollaborationDO>> ticketList(@RequestParam("ticketId") Long ticketId) {
        return success(service.ticketTasks(ticketId, getLoginUserId()));
    }
    @PutMapping("/complete")
    @PreAuthorize("@ss.hasPermission('aftersales:collaboration:update')")
    public CommonResult<Boolean> complete(@RequestParam("id") Long id, @RequestBody CompleteRequest input) {
        service.complete(id, getLoginUserId(), input == null ? null : input.result());
        return success(true);
    }
    @PutMapping("/cancel")
    @PreAuthorize("@ss.hasPermission('aftersales:collaboration:update')")
    public CommonResult<Boolean> cancel(@RequestParam("id") Long id) {
        service.cancel(id, getLoginUserId());
        return success(true);
    }
    @PutMapping("/review-financial")
    @PreAuthorize("@ss.hasPermission('aftersales:collaboration:approve')")
    public CommonResult<Boolean> reviewFinancial(@RequestParam("id") Long id, @RequestBody ReviewRequest input) {
        if (input == null || input.approved() == null) throw invalidParamException("请选择审核结果");
        service.reviewFinancial(id, getLoginUserId(), input.approved(), input.remark());
        return success(true);
    }
    public record CompleteRequest(String result) {}
    public record ReviewRequest(Boolean approved, String remark) {}
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/AfterSaleTicketController.java
@@ -11,6 +11,7 @@
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketItemDO;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketService;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleLedgerService;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
@@ -38,6 +39,8 @@
    @Resource
    private AfterSaleTicketService ticketService;
    @Resource
    private AfterSaleLedgerService ledgerService;
    @Resource
    private CrmCustomerApi customerApi;
@@ -94,6 +97,16 @@
        return success(new PageResult<>(list, pageResult.getTotal()));
    }
    @GetMapping("/overview")
    @Operation(summary = "统计当前用户可见的售后工单状态")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:query')")
    public CommonResult<Map<Integer, Long>> getOverview(@RequestParam("sceneType") Integer sceneType) {
        if (sceneType == null || sceneType < 1 || sceneType > 4) {
            throw cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException("工单场景不正确");
        }
        return success(ledgerService.overview(getLoginUserId(), sceneType));
    }
    @PostMapping("/assign")
    @Operation(summary = "分配处理人")
    @PreAuthorize("@ss.hasPermission('aftersales:ticket:update')")
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/collaboration/AfterSaleCollaborationDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.aftersales.dal.dataobject.collaboration;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceReviewDTO;
import tools.jackson.databind.annotation.JsonSerialize;
import tools.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.math.BigDecimal;
@TableName("after_sale_collaboration")
@Data
@EqualsAndHashCode(callSuper = true)
public class AfterSaleCollaborationDO extends BaseDO {
    @TableId private Long id;
    private Long ticketId;
    private String category;
    private String title;
    private String content;
    private Long assigneeUserId;
    private Long requestedBy;
    private Integer status;
    private String result;
    private LocalDateTime completedTime;
    @JsonSerialize(using = ToStringSerializer.class)
    private BigDecimal amount;
    private String direction;
    private String partyName;
    private String partyType;
    private Long partyId;
    private Long financeDraftId;
    private Long previousId;
    private Long businessApproverId;
    private LocalDateTime businessApprovalTime;
    private String businessRemark;
    @TableField(exist = false)
    private AfterSaleFinanceReviewDTO financeReview;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/collaboration/AfterSaleCollaborationMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.aftersales.dal.mysql.collaboration;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.collaboration.AfterSaleCollaborationDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AfterSaleCollaborationMapper extends BaseMapperX<AfterSaleCollaborationDO> {}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/collaboration/AfterSaleCollaborationService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,231 @@
package cn.iocoder.yudao.module.aftersales.service.collaboration;
import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.collaboration.AfterSaleCollaborationDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.collaboration.AfterSaleCollaborationMapper;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleBizTypeEnum;
import cn.iocoder.yudao.module.aftersales.enums.permission.AfterSalePermissionLevelEnum;
import cn.iocoder.yudao.module.aftersales.service.permission.AfterSalePermissionService;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.erp.api.finance.AfterSaleFinanceDraftApi;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceDraftCreateDTO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.Data;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@Service
public class AfterSaleCollaborationService {
    @Resource private AfterSaleCollaborationMapper mapper;
    @Resource private AfterSaleTicketService ticketService;
    @Resource private AfterSalePermissionService permissionService;
    @Resource private PermissionCommonApi permissionApi;
    @Resource private AdminUserApi userApi;
    @Resource private AfterSaleFinanceDraftApi financeDraftApi;
    @Resource private SrmSupplierApi supplierApi;
    @Resource private CrmCustomerApi customerApi;
    @Data
    public static class CreateRequest {
        private Long ticketId;
        private String category;
        private String title;
        private String content;
        private Long assigneeUserId;
        private BigDecimal amount;
        private String direction;
        private String partyName;
        private Long partyId;
        private Long previousId;
    }
    @Transactional(rollbackFor = Exception.class)
    public Long create(CreateRequest input, Long userId) {
        if (userId == null || input == null || input.getTicketId() == null || input.getAssigneeUserId() == null
                || input.getCategory() == null || !Set.of("QUALITY", "FINANCE", "SUPPLIER", "WARRANTY", "RESEARCH", "PROCESS").contains(input.getCategory())
                || input.getTitle() == null || input.getTitle().isBlank() || input.getTitle().length() > 200
                || input.getContent() == null || input.getContent().isBlank() || input.getContent().length() > 2000)
            throw invalidParamException("请完整填写协同事项及接收人");
        boolean financialCategory = Set.of("FINANCE", "SUPPLIER", "WARRANTY").contains(input.getCategory());
        if (financialCategory && input.getAmount() == null) throw invalidParamException("该协同类型必须填写金额");
        if (input.getAmount() != null) {
            if (!Set.of("QUALITY", "FINANCE", "SUPPLIER", "WARRANTY").contains(input.getCategory())
                    || input.getAmount().signum() <= 0 || input.getAmount().stripTrailingZeros().scale() > 2
                    || input.getAmount().compareTo(new BigDecimal("9999999999999999.99")) > 0
                    || input.getDirection() == null || !Set.of("RECEIVABLE", "PAYABLE", "INTERNAL").contains(input.getDirection())
                    || (input.getPartyName() != null && input.getPartyName().length() > 200)
                    || (!Set.of("SUPPLIER", "WARRANTY").contains(input.getCategory()) && !"INTERNAL".equals(input.getDirection()) && (input.getPartyName() == null || input.getPartyName().isBlank())))
                throw invalidParamException("金额、资金方向或往来方不正确");
        } else if (input.getDirection() != null || input.getPartyId() != null || (input.getPartyName() != null && !input.getPartyName().isBlank())) {
            throw invalidParamException("填写资金方向或往来方时必须填写金额");
        }
        AfterSaleTicketDO ticket = ticketService.getTicket(input.getTicketId());
        if (ticket == null) throw invalidParamException("售后工单不存在");
        if (Objects.equals(ticket.getStatus(), 60) || Objects.equals(ticket.getStatus(), 99))
            throw invalidParamException("已关闭或取消的工单不能发起协同");
        if (!canWrite(ticket, userId)) throw invalidParamException("无权发起该工单的协同事项");
        String partyType = null, partyName = null;
        Long partyId = null;
        if (input.getAmount() != null) {
            if ("SUPPLIER".equals(input.getCategory())) {
                if (input.getPartyId() == null) throw invalidParamException("请选择供应商");
                var supplier = supplierApi.getSupplier(input.getPartyId()).getCheckedData();
                if (supplier == null || !Objects.equals(supplier.getStatus(), 0) || supplier.getName() == null || supplier.getName().isBlank())
                    throw invalidParamException("供应商不存在或已停用");
                partyType = "SUPPLIER"; partyId = supplier.getId(); partyName = supplier.getName();
            } else if ("WARRANTY".equals(input.getCategory())) {
                if (ticket.getCustomerId() == null) throw invalidParamException("工单未关联客户,请先完善工单");
                var customer = customerApi.getCustomer(ticket.getCustomerId());
                if (customer == null || customer.getName() == null || customer.getName().isBlank())
                    throw invalidParamException("工单关联的客户不存在");
                partyType = "CUSTOMER"; partyId = customer.getId(); partyName = customer.getName();
            } else if ("INTERNAL".equals(input.getDirection())) {
                partyType = "INTERNAL";
            } else {
                partyType = "OTHER"; partyName = input.getPartyName().trim();
            }
        }
        userApi.validateUser(input.getAssigneeUserId());
        if (input.getPreviousId() != null) {
            AfterSaleCollaborationDO previous = mapper.selectById(input.getPreviousId());
            if (previous == null || !Objects.equals(previous.getRequestedBy(), userId)
                    || !Objects.equals(previous.getTicketId(), input.getTicketId()) || !Objects.equals(previous.getCategory(), input.getCategory()))
                throw invalidParamException("原协同事项不匹配或无权重新发起");
            boolean allowed = Objects.equals(previous.getStatus(), 4)
                    || (Objects.equals(previous.getStatus(), 2) && previous.getPreviousId() != null);
            if (Objects.equals(previous.getStatus(), 3) && previous.getFinanceDraftId() != null) {
                var review = financeDraftApi.getReviewResults(List.of(previous.getFinanceDraftId())).get(previous.getFinanceDraftId());
                allowed = review != null && Objects.equals(review.getStatus(), 2);
            }
            if (!allowed) throw invalidParamException("只有业务退回、财务退回或已取消的重办事项可以重新发起");
        }
        var task = new AfterSaleCollaborationDO();
        task.setTicketId(input.getTicketId()); task.setCategory(input.getCategory()); task.setTitle(input.getTitle().trim());
        task.setContent(input.getContent().trim()); task.setAssigneeUserId(input.getAssigneeUserId());
        task.setRequestedBy(userId); task.setStatus(0); task.setAmount(input.getAmount());
        task.setDirection(input.getDirection()); task.setPartyName(partyName); task.setPartyType(partyType); task.setPartyId(partyId); task.setPreviousId(input.getPreviousId());
        try { mapper.insert(task); }
        catch (DuplicateKeyException e) { throw invalidParamException("该事项已重新发起,请刷新后查看"); }
        return task.getId();
    }
    public PageResult<AfterSaleCollaborationDO> myTasks(PageParam page, Long userId) {
        if (userId == null) throw invalidParamException("用户未登录");
        boolean canApprove = permissionApi.hasAnyPermissions(userId, "aftersales:collaboration:approve");
        boolean isAdmin = canApprove && permissionApi.hasAnyRoles(userId, "aftersales_admin");
        var query = new LambdaQueryWrapperX<AfterSaleCollaborationDO>();
        query.and(w -> {
            w.eq(AfterSaleCollaborationDO::getAssigneeUserId, userId).or().eq(AfterSaleCollaborationDO::getRequestedBy, userId)
                    .or().eq(AfterSaleCollaborationDO::getBusinessApproverId, userId);
            if (canApprove) w.or(approval -> {
                approval.eq(AfterSaleCollaborationDO::getStatus, 1).isNotNull(AfterSaleCollaborationDO::getAmount);
                if (!isAdmin) approval.exists("SELECT 1 FROM after_sale_ticket t WHERE t.id = after_sale_collaboration.ticket_id AND t.deleted = 0 "
                        + "AND (t.owner_user_id = {0} OR EXISTS (SELECT 1 FROM after_sale_permission p WHERE p.biz_type = 1 "
                        + "AND p.biz_id = t.id AND p.user_id = {1} AND p.level IN (1, 3) AND p.deleted = 0))", userId, userId);
            });
        });
        PageResult<AfterSaleCollaborationDO> result = mapper.selectPage(page, query.orderByDesc(AfterSaleCollaborationDO::getId));
        attachFinanceReviews(result.getList());
        return result;
    }
    public List<AfterSaleCollaborationDO> ticketTasks(Long ticketId, Long userId) {
        if (userId == null || ticketId == null) throw invalidParamException("参数不正确");
        AfterSaleTicketDO ticket = ticketService.getTicket(ticketId);
        if (ticket == null) throw invalidParamException("售后工单不存在");
        var query = new LambdaQueryWrapperX<AfterSaleCollaborationDO>().eq(AfterSaleCollaborationDO::getTicketId, ticketId);
        if (!canRead(ticket, userId)) query.and(w -> w.eq(AfterSaleCollaborationDO::getAssigneeUserId, userId).or().eq(AfterSaleCollaborationDO::getRequestedBy, userId));
        List<AfterSaleCollaborationDO> result = mapper.selectList(query.orderByDesc(AfterSaleCollaborationDO::getId));
        attachFinanceReviews(result);
        return result;
    }
    private void attachFinanceReviews(List<AfterSaleCollaborationDO> tasks) {
        List<Long> ids = tasks.stream().map(AfterSaleCollaborationDO::getFinanceDraftId).filter(Objects::nonNull).distinct().toList();
        if (ids.isEmpty()) return;
        var reviews = financeDraftApi.getReviewResults(ids);
        tasks.forEach(task -> task.setFinanceReview(reviews.get(task.getFinanceDraftId())));
    }
    @Transactional(rollbackFor = Exception.class)
    public void complete(Long id, Long userId, String result) {
        if (id == null || userId == null || result == null || result.isBlank() || result.length() > 2000)
            throw invalidParamException("请填写不超过 2000 å­—的处理结果");
        AfterSaleCollaborationDO task = mapper.selectById(id);
        if (task == null || !Objects.equals(task.getAssigneeUserId(), userId) || !Objects.equals(task.getStatus(), 0))
            throw invalidParamException("协同事项不存在、已处理或当前用户不是接收人");
        if (mapper.update(null, new LambdaUpdateWrapper<AfterSaleCollaborationDO>()
                .eq(AfterSaleCollaborationDO::getId, id).eq(AfterSaleCollaborationDO::getStatus, 0)
                .eq(AfterSaleCollaborationDO::getAssigneeUserId, userId).set(AfterSaleCollaborationDO::getStatus, 1)
                .set(AfterSaleCollaborationDO::getResult, result.trim()).set(AfterSaleCollaborationDO::getCompletedTime, LocalDateTime.now())) != 1)
            throw invalidParamException("协同事项状态已变化,请刷新后重试");
    }
    @Transactional(rollbackFor = Exception.class)
    public void cancel(Long id, Long userId) {
        if (id == null || userId == null) throw invalidParamException("参数不正确");
        AfterSaleCollaborationDO task = mapper.selectById(id);
        if (task == null || !Objects.equals(task.getRequestedBy(), userId) || !Objects.equals(task.getStatus(), 0))
            throw invalidParamException("只有发起人可以取消待处理事项");
        if (mapper.update(null, new LambdaUpdateWrapper<AfterSaleCollaborationDO>()
                .eq(AfterSaleCollaborationDO::getId, id).eq(AfterSaleCollaborationDO::getStatus, 0)
                .eq(AfterSaleCollaborationDO::getRequestedBy, userId).set(AfterSaleCollaborationDO::getStatus, 2)) != 1)
            throw invalidParamException("协同事项状态已变化,请刷新后重试");
    }
    /** ä¸šåŠ¡å®¡æ‰¹é€šè¿‡åŽåˆ›å»ºè´¢åŠ¡å®¡æ ¸è‰ç¨¿ï¼›è‰ç¨¿åˆ›å»ºå¤±è´¥æ—¶æœ¬æ¬¡å®¡æ‰¹æ•´ä½“å›žæ»šã€‚ */
    @Transactional(rollbackFor = Exception.class)
    public void reviewFinancial(Long id, Long userId, boolean approved, String remark) {
        if (id == null || userId == null || (remark != null && remark.length() > 1000)
                || (!approved && (remark == null || remark.isBlank()))) throw invalidParamException("退回时请填写业务审核意见");
        AfterSaleCollaborationDO task = mapper.selectById(id);
        if (task == null || !Objects.equals(task.getStatus(), 1) || task.getAmount() == null)
            throw invalidParamException("事项不存在或未处于待业务审核状态");
        AfterSaleTicketDO ticket = ticketService.getTicket(task.getTicketId());
        if (ticket == null || !canWrite(ticket, userId)) throw invalidParamException("无权审核该工单的协同事项");
        int changed = mapper.update(null, new LambdaUpdateWrapper<AfterSaleCollaborationDO>()
                .eq(AfterSaleCollaborationDO::getId, id).eq(AfterSaleCollaborationDO::getStatus, 1)
                .isNotNull(AfterSaleCollaborationDO::getAmount)
                .set(AfterSaleCollaborationDO::getStatus, approved ? 3 : 4)
                .set(AfterSaleCollaborationDO::getBusinessApproverId, userId)
                .set(AfterSaleCollaborationDO::getBusinessApprovalTime, LocalDateTime.now())
                .set(AfterSaleCollaborationDO::getBusinessRemark, remark));
        if (changed != 1) throw invalidParamException("事项状态已变化,请刷新后重试");
        if (approved) {
            var input = new AfterSaleFinanceDraftCreateDTO();
            input.setCollaborationId(id); input.setTicketId(task.getTicketId()); input.setCategory(task.getCategory());
            input.setDirection(task.getDirection()); input.setAmount(task.getAmount()); input.setPartyName(task.getPartyName());
            input.setPartyType(task.getPartyType()); input.setPartyId(task.getPartyId());
            input.setDescription(task.getResult()); input.setBusinessApproverId(userId);
            Long draftId = financeDraftApi.createDraft(input);
            if (draftId == null) throw invalidParamException("财务草稿创建失败");
            var update = new AfterSaleCollaborationDO(); update.setId(id); update.setFinanceDraftId(draftId);
            if (mapper.updateById(update) != 1) throw invalidParamException("关联财务草稿失败,请重试");
        }
    }
    private boolean canWrite(AfterSaleTicketDO ticket, Long userId) {
        return Objects.equals(ticket.getOwnerUserId(), userId) || permissionApi.hasAnyRoles(userId, "aftersales_admin")
                || permissionService.hasPermission(AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType(), ticket.getId(), userId, AfterSalePermissionLevelEnum.WRITE)
                || permissionService.hasPermission(AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType(), ticket.getId(), userId, AfterSalePermissionLevelEnum.OWNER);
    }
    private boolean canRead(AfterSaleTicketDO ticket, Long userId) {
        return canWrite(ticket, userId) || permissionService.hasPermission(AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType(), ticket.getId(), userId, AfterSalePermissionLevelEnum.READ);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleLedgerService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.aftersales.service.ticket;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketPageReqVO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
/** å¤ç”¨å·¥å•列表权限和场景,统计当前用户可见工单。 */
@Service
public class AfterSaleLedgerService {
    @Resource private AfterSaleTicketService ticketService;
    public Map<Integer, Long> overview(Long userId, Integer sceneType) {
        Map<Integer, Long> counts = new LinkedHashMap<>();
        for (AfterSaleTicketStatusEnum status : AfterSaleTicketStatusEnum.values()) {
            var request = new AfterSaleTicketPageReqVO();
            request.setPageNo(1); request.setPageSize(1); request.setSceneType(sceneType); request.setStatus(status.getStatus());
            counts.put(status.getStatus(), ticketService.getTicketPage(request, userId).getTotal());
        }
        return counts;
    }
}
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/finance/AfterSaleFinanceDraftApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.erp.api.finance;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceDraftCreateDTO;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceReviewDTO;
import java.util.List;
import java.util.Map;
/** ä¾›å”®åŽä¸šåŠ¡ç¡®è®¤åŽåˆ›å»ºè´¢åŠ¡å®¡æ ¸è‰ç¨¿ï¼›ä¸æ‰§è¡Œä»˜æ¬¾æˆ–è®°è´¦ã€‚ */
public interface AfterSaleFinanceDraftApi {
    Long createDraft(AfterSaleFinanceDraftCreateDTO input);
    Map<Long, AfterSaleFinanceReviewDTO> getReviewResults(List<Long> ids);
}
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/finance/dto/AfterSaleFinanceDraftCreateDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.erp.api.finance.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class AfterSaleFinanceDraftCreateDTO {
    private Long collaborationId;
    private Long ticketId;
    private String category;
    private String direction;
    private BigDecimal amount;
    private String partyName;
    private String partyType;
    private Long partyId;
    private String description;
    private Long businessApproverId;
}
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/finance/dto/AfterSaleFinanceReviewDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
package cn.iocoder.yudao.module.erp.api.finance.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class AfterSaleFinanceReviewDTO {
    private Integer status;
    private String remark;
    private LocalDateTime reviewTime;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/finance/ErpAfterSaleFinanceDraftController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.erp.controller.admin.finance;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpAfterSaleFinanceDraftDO;
import cn.iocoder.yudao.module.erp.service.finance.ErpAfterSaleFinanceDraftService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@RestController
@RequestMapping("/erp/finance/after-sale-draft")
public class ErpAfterSaleFinanceDraftController {
    @Resource private ErpAfterSaleFinanceDraftService service;
    @GetMapping("/page")
    @PreAuthorize("@ss.hasPermission('erp:after-sale-finance:query')")
    public CommonResult<PageResult<ErpAfterSaleFinanceDraftDO>> page(@Valid PageParam page,
            @RequestParam(value = "status", required = false) Integer status) {
        return success(service.page(page, status));
    }
    @PutMapping("/review")
    @PreAuthorize("@ss.hasPermission('erp:after-sale-finance:review')")
    public CommonResult<Boolean> review(@RequestParam("id") Long id, @RequestBody ReviewRequest request) {
        if (request == null || request.approved() == null) throw invalidParamException("请选择审核结果");
        service.review(id, getLoginUserId(), request.approved(), request.remark());
        return success(true);
    }
    public record ReviewRequest(Boolean approved, String remark) {}
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/dataobject/finance/ErpAfterSaleFinanceDraftDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.erp.dal.dataobject.finance;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import tools.jackson.databind.annotation.JsonSerialize;
import tools.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@TableName("erp_after_sale_finance_draft")
@Data
@EqualsAndHashCode(callSuper = true)
public class ErpAfterSaleFinanceDraftDO extends BaseDO {
    @TableId private Long id;
    private Long collaborationId;
    private Long ticketId;
    private String category;
    private String direction;
    @JsonSerialize(using = ToStringSerializer.class)
    private BigDecimal amount;
    private String partyName;
    private String partyType;
    private Long partyId;
    private String description;
    private Long businessApproverId;
    private Integer status;
    private Long reviewerId;
    private String reviewRemark;
    private LocalDateTime reviewTime;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpAfterSaleFinanceDraftMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.erp.dal.mysql.finance;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpAfterSaleFinanceDraftDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ErpAfterSaleFinanceDraftMapper extends BaseMapperX<ErpAfterSaleFinanceDraftDO> {}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/enums/ErrorCodeConstants.java
@@ -100,6 +100,8 @@
    ErrorCode SALE_ORDER_SUBMIT_FAIL_ITEM_EMPTY = new ErrorCode(1_020_201_018, "提交审批失败,销售订单({})没有订单明细,无法提交");
    ErrorCode SALE_ORDER_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_020_201_019, "销售订单审核分类不存在,请先在BPM模块创建销售订单审核流程分类");
    ErrorCode SALE_ORDER_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_020_201_020, "销售订单审核分类下没有可用的流程模型,请先创建并发布流程");
    ErrorCode SALE_ORDER_CANCEL_FAIL_ACTIVE_MPS = new ErrorCode(1_020_201_021, "作废失败,订单仍有关联生产计划,请先处理并取消生产计划");
    ErrorCode SALE_ORDER_CANCEL_FAIL_MPS_UNKNOWN = new ErrorCode(1_020_201_022, "无法确认关联生产计划状态,请稍后重试");
    // ========== ERP é”€å”®å‡ºåº“(1-030-202-000) ==========
    ErrorCode SALE_OUT_NOT_EXISTS = new ErrorCode(1_020_202_000, "销售出库单不存在");
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/finance/ErpAfterSaleFinanceDraftService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.erp.service.finance;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.erp.api.finance.AfterSaleFinanceDraftApi;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceDraftCreateDTO;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceReviewDTO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpAfterSaleFinanceDraftDO;
import cn.iocoder.yudao.module.erp.dal.mysql.finance.ErpAfterSaleFinanceDraftMapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Set;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@Service
public class ErpAfterSaleFinanceDraftService implements AfterSaleFinanceDraftApi {
    @Resource private ErpAfterSaleFinanceDraftMapper mapper;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createDraft(AfterSaleFinanceDraftCreateDTO input) {
        if (input == null || input.getCollaborationId() == null || input.getTicketId() == null || input.getBusinessApproverId() == null
                || input.getCategory() == null || !Set.of("QUALITY", "FINANCE", "SUPPLIER", "WARRANTY").contains(input.getCategory())
                || input.getDirection() == null || !Set.of("RECEIVABLE", "PAYABLE", "INTERNAL").contains(input.getDirection())
                || input.getAmount() == null || input.getAmount().signum() <= 0 || input.getAmount().stripTrailingZeros().scale() > 2
                || input.getAmount().compareTo(new BigDecimal("9999999999999999.99")) > 0
                || input.getDescription() == null || input.getDescription().isBlank() || input.getDescription().length() > 2000
                || (input.getPartyName() != null && input.getPartyName().length() > 200)
                || (!"INTERNAL".equals(input.getDirection()) && (input.getPartyName() == null || input.getPartyName().isBlank())))
            throw invalidParamException("财务草稿的金额、方向、往来方或来源不正确");
        var draft = new ErpAfterSaleFinanceDraftDO();
        draft.setCollaborationId(input.getCollaborationId()); draft.setTicketId(input.getTicketId());
        draft.setCategory(input.getCategory()); draft.setDirection(input.getDirection()); draft.setAmount(input.getAmount());
        draft.setPartyName(input.getPartyName()); draft.setDescription(input.getDescription());
        draft.setPartyType(input.getPartyType()); draft.setPartyId(input.getPartyId());
        draft.setBusinessApproverId(input.getBusinessApproverId()); draft.setStatus(0);
        try { mapper.insert(draft); }
        catch (DuplicateKeyException e) { throw invalidParamException("该协同事项已生成财务草稿"); }
        return draft.getId();
    }
    public PageResult<ErpAfterSaleFinanceDraftDO> page(PageParam page, Integer status) {
        if (status != null && !Set.of(0, 1, 2).contains(status)) throw invalidParamException("审核状态不正确");
        return mapper.selectPage(page, new LambdaQueryWrapperX<ErpAfterSaleFinanceDraftDO>()
                .eqIfPresent(ErpAfterSaleFinanceDraftDO::getStatus, status).orderByDesc(ErpAfterSaleFinanceDraftDO::getId));
    }
    @Override
    public Map<Long, AfterSaleFinanceReviewDTO> getReviewResults(List<Long> ids) {
        if (ids == null || ids.isEmpty()) return Map.of();
        return mapper.selectBatchIds(ids).stream().collect(Collectors.toMap(ErpAfterSaleFinanceDraftDO::getId, draft -> {
            var result = new AfterSaleFinanceReviewDTO(); result.setStatus(draft.getStatus());
            result.setRemark(draft.getReviewRemark()); result.setReviewTime(draft.getReviewTime()); return result;
        }));
    }
    @Transactional(rollbackFor = Exception.class)
    public void review(Long id, Long reviewerId, boolean approved, String remark) {
        if (id == null || reviewerId == null || (remark != null && remark.length() > 1000)
                || (!approved && (remark == null || remark.isBlank()))) throw invalidParamException("退回时请填写审核意见");
        var draft = mapper.selectById(id);
        if (draft == null || !Objects.equals(draft.getStatus(), 0)) throw invalidParamException("草稿不存在或已审核");
        if (mapper.update(null, new LambdaUpdateWrapper<ErpAfterSaleFinanceDraftDO>()
                .eq(ErpAfterSaleFinanceDraftDO::getId, id).eq(ErpAfterSaleFinanceDraftDO::getStatus, 0)
                .set(ErpAfterSaleFinanceDraftDO::getStatus, approved ? 1 : 2).set(ErpAfterSaleFinanceDraftDO::getReviewerId, reviewerId)
                .set(ErpAfterSaleFinanceDraftDO::getReviewRemark, remark).set(ErpAfterSaleFinanceDraftDO::getReviewTime, LocalDateTime.now())) != 1)
            throw invalidParamException("草稿状态已变化,请刷新后重试");
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.java
@@ -605,6 +605,17 @@
            throw exception(SALE_ORDER_CANCEL_FAIL_EXISTS_OUT);
        }
        // é”€å”®è®¢å•审核后可能已生成 MPS。未取消的计划不能随着订单作废继续执行。
        List<ErpSaleOrderItemDO> orderItems = saleOrderItemMapper.selectListByOrderId(id);
        if (CollUtil.isNotEmpty(orderItems)) {
            List<Long> itemIds = convertList(orderItems, ErpSaleOrderItemDO::getId);
            List<MesProMpsRespDTO> plans = mpsApi.getMpsListBySaleOrderItemIds(itemIds);
            if (plans == null) throw exception(SALE_ORDER_CANCEL_FAIL_MPS_UNKNOWN);
            if (plans.stream().anyMatch(plan -> plan == null || ObjectUtil.notEqual(plan.getStatus(), MPS_STATUS_CANCELED))) {
                throw exception(SALE_ORDER_CANCEL_FAIL_ACTIVE_MPS);
            }
        }
        // 2. æ›´æ–°çŠ¶æ€ä¸ºå·²ä½œåºŸ
        int updateCount = saleOrderMapper.updateByIdAndStatus(id, saleOrder.getStatus(),
                new ErpSaleOrderDO().setStatus(ErpAuditStatus.CANCEL.getStatus()));
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pd/knowledge/KnowledgeController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
package cn.iocoder.yudao.module.mes.controller.admin.pd.knowledge;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeDO;
import cn.iocoder.yudao.module.mes.service.pd.knowledge.KnowledgeMatch;
import cn.iocoder.yudao.module.mes.service.pd.knowledge.KnowledgeSearchService;
import cn.iocoder.yudao.module.mes.service.pd.knowledge.KnowledgeWriteService;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@RestController
@RequestMapping("/mes/pd/knowledge")
public class KnowledgeController {
    @Resource private KnowledgeSearchService searchService;
    @Resource private KnowledgeWriteService writeService;
    @GetMapping("/page")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:query')")
    public CommonResult<PageResult<KnowledgeDO>> page(PageParam page,
            @RequestParam(value = "name", required = false) String name) {
        return success(searchService.page(page, name));
    }
    @GetMapping("/get")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:query')")
    public CommonResult<KnowledgeSearchService.Detail> get(@RequestParam("id") Long id) {
        return success(searchService.get(id));
    }
    @PostMapping("/create")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:create')")
    public CommonResult<Long> create(@RequestBody KnowledgeWriteService.SaveRequest input) {
        return success(writeService.create(input));
    }
    @PutMapping("/update")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:update')")
    public CommonResult<Boolean> update(@RequestBody KnowledgeWriteService.SaveRequest input) {
        writeService.update(input);
        return success(true);
    }
    @PutMapping("/publish")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:publish')")
    public CommonResult<Boolean> publish(@RequestParam("id") Long id) {
        writeService.publish(id);
        return success(true);
    }
    @PostMapping("/revise")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:create')")
    public CommonResult<Long> revise(@RequestParam("id") Long id) {
        return success(writeService.revise(id));
    }
    @PostMapping("/reference-route")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:reference')")
    public CommonResult<Boolean> referenceRoute(@RequestParam("id") Long id, @RequestParam("routeId") Long routeId) {
        writeService.referenceRoute(id, routeId);
        return success(true);
    }
    @GetMapping("/match")
    @PreAuthorize("@ss.hasPermission('mes:pd-knowledge:query')")
    public CommonResult<List<KnowledgeDO>> match(
            @RequestParam(value = "productId", required = false) Long productId,
            @RequestParam(value = "bomId", required = false) Long bomId,
            @RequestParam(value = "voltage", required = false) BigDecimal voltage,
            @RequestParam(value = "market", required = false) String market,
            @RequestParam(value = "standards", required = false) Set<String> standards) {
        if (market != null && !Set.of("DOMESTIC", "EXPORT").contains(market)) {
            throw invalidParamException("内外销类型不正确");
        }
        if (voltage != null && voltage.signum() < 0) {
            throw invalidParamException("电压不能小于零");
        }
        return success(searchService.match(new KnowledgeMatch.Request(productId, bomId, voltage, market, standards)));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
@TableName("mes_pd_knowledge")
@Data
@EqualsAndHashCode(callSuper = true)
public class KnowledgeDO extends BaseDO {
    @TableId private Long id;
    private String code;
    private String name;
    private Integer version;
    private Long productItemId;
    private Long bomId;
    private BigDecimal voltageMin;
    private BigDecimal voltageMax;
    private String market;
    private Long routeId;
    private Long templateId;
    private String description;
    private Integer status;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeReferenceDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@TableName("mes_pd_knowledge_reference")
@Data
@EqualsAndHashCode(callSuper = true)
public class KnowledgeReferenceDO extends BaseDO {
    @TableId private Long id;
    private Long knowledgeId;
    private Integer knowledgeVersion;
    private String targetType;
    private Long targetId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeResourceDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@TableName("mes_pd_knowledge_resource")
@Data
@EqualsAndHashCode(callSuper = true)
public class KnowledgeResourceDO extends BaseDO {
    @TableId private Long id;
    private Long knowledgeId;
    private Long processId;
    private String resourceType;
    private Long resourceId;
    private String capabilityDescription;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pd/knowledge/KnowledgeStandardDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@TableName("mes_pd_knowledge_standard")
@Data
@EqualsAndHashCode(callSuper = true)
public class KnowledgeStandardDO extends BaseDO {
    @TableId private Long id;
    private Long knowledgeId;
    private String standardCode;
    private String standardName;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KnowledgeMapper extends BaseMapperX<KnowledgeDO> {}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeReferenceMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeReferenceDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KnowledgeReferenceMapper extends BaseMapperX<KnowledgeReferenceDO> {}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeResourceMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeResourceDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KnowledgeResourceMapper extends BaseMapperX<KnowledgeResourceDO> {}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pd/knowledge/KnowledgeStandardMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeStandardDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KnowledgeStandardMapper extends BaseMapperX<KnowledgeStandardDO> {}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeMatch.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.mes.service.pd.knowledge;
import java.math.BigDecimal;
import java.util.Objects;
import java.util.Set;
/** åŒ¹é…å·²å‘布工艺知识的适用范围;未知条件不能满足有约束的知识。 */
public final class KnowledgeMatch {
    private KnowledgeMatch() {}
    public record Criteria(Long productId, Long bomId, BigDecimal minVoltage,
                           BigDecimal maxVoltage, String market, Set<String> standards) {}
    public record Request(Long productId, Long bomId, BigDecimal voltage,
                          String market, Set<String> standards) {}
    public static boolean matches(Criteria criteria, Request request) {
        Objects.requireNonNull(criteria, "criteria");
        Objects.requireNonNull(request, "request");
        if (criteria.productId() != null && !criteria.productId().equals(request.productId())) return false;
        if (criteria.bomId() != null && !criteria.bomId().equals(request.bomId())) return false;
        if (criteria.minVoltage() != null || criteria.maxVoltage() != null) {
            if (request.voltage() == null) return false;
            if (criteria.minVoltage() != null && request.voltage().compareTo(criteria.minVoltage()) < 0) return false;
            if (criteria.maxVoltage() != null && request.voltage().compareTo(criteria.maxVoltage()) > 0) return false;
        }
        if (!"ALL".equals(criteria.market()) && !Objects.equals(criteria.market(), request.market())) return false;
        Set<String> available = criteria.standards() == null ? Set.of() : criteria.standards();
        Set<String> required = request.standards() == null ? Set.of() : request.standards();
        return available.containsAll(required);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeSearchService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
package cn.iocoder.yudao.module.mes.service.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeStandardDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeResourceDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeReferenceDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeStandardMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeResourceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeReferenceMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdProductBomService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/** å·²å‘布知识的条件匹配;草稿和停用版本不参与推荐。 */
@Service
public class KnowledgeSearchService {
    @Resource private KnowledgeMapper knowledgeMapper;
    @Resource private KnowledgeStandardMapper standardMapper;
    @Resource private KnowledgeResourceMapper resourceMapper;
    @Resource private KnowledgeReferenceMapper referenceMapper;
    @Resource private MesMdItemService itemService;
    @Resource private MesMdProductBomService bomService;
    public PageResult<KnowledgeDO> page(PageParam page, String name) {
        return knowledgeMapper.selectPage(page, new LambdaQueryWrapperX<KnowledgeDO>()
                .likeIfPresent(KnowledgeDO::getName, name).orderByDesc(KnowledgeDO::getId));
    }
    public Detail get(Long id) {
        KnowledgeDO record = knowledgeMapper.selectById(id);
        if (record == null) return null;
        List<String> standards = standardMapper.selectList(new LambdaQueryWrapperX<KnowledgeStandardDO>()
                .eq(KnowledgeStandardDO::getKnowledgeId, id)).stream()
                .map(KnowledgeStandardDO::getStandardCode).toList();
        List<KnowledgeResourceDO> resources = resourceMapper.selectList(new LambdaQueryWrapperX<KnowledgeResourceDO>()
                .eq(KnowledgeResourceDO::getKnowledgeId, id));
        List<KnowledgeReferenceDO> references = referenceMapper.selectList(new LambdaQueryWrapperX<KnowledgeReferenceDO>()
                .eq(KnowledgeReferenceDO::getKnowledgeId, id));
        return new Detail(record, standards, resources, references);
    }
    public record Detail(KnowledgeDO knowledge, List<String> standards, List<KnowledgeResourceDO> resources, List<KnowledgeReferenceDO> references) {}
    public List<KnowledgeDO> match(KnowledgeMatch.Request request) {
        if (request.bomId() != null && request.productId() == null) throw invalidParamException("请选择 BOM æ˜Žç»†æ‰€å±žçš„物料");
        if (request.productId() != null) itemService.validateItemExists(request.productId());
        Set<Long> productBomItems;
        if (request.productId() != null) {
            productBomItems = bomService.getProductBomListByItemId(request.productId()).stream()
                    .filter(bom -> Objects.equals(bom.getItemId(), request.productId()) && Objects.equals(bom.getStatus(), 0)).map(MesMdProductBomDO::getBomItemId)
                    .filter(Objects::nonNull).collect(Collectors.toSet());
        } else productBomItems = Set.of();
        if (request.bomId() != null && !productBomItems.contains(request.bomId()))
            throw invalidParamException("所选 BOM å­ç‰©æ–™ä¸å­˜åœ¨ã€æœªå¯ç”¨æˆ–不属于该物料");
        Set<Long> candidateBomItems = request.bomId() == null ? productBomItems : Set.of(request.bomId());
        List<KnowledgeDO> records = knowledgeMapper.selectList(new LambdaQueryWrapperX<KnowledgeDO>()
                .eq(KnowledgeDO::getStatus, 1).orderByDesc(KnowledgeDO::getId));
        if (records.isEmpty()) return List.of();
        List<Long> ids = records.stream().map(KnowledgeDO::getId).toList();
        Map<Long, Set<String>> standards = standardMapper.selectList(new LambdaQueryWrapperX<KnowledgeStandardDO>()
                        .in(KnowledgeStandardDO::getKnowledgeId, ids)).stream()
                .collect(Collectors.groupingBy(KnowledgeStandardDO::getKnowledgeId,
                        Collectors.mapping(KnowledgeStandardDO::getStandardCode, Collectors.toSet())));
        return records.stream().filter(record -> {
            if (record.getBomId() != null && !candidateBomItems.contains(record.getBomId())) return false;
            // å·²æŒ‰ BOM å­ç‰©æ–™æ¯”对,向纯规则传递当前知识对应的约束。
            var resolvedRequest = new KnowledgeMatch.Request(request.productId(), record.getBomId(), request.voltage(), request.market(), request.standards());
            return KnowledgeMatch.matches(new KnowledgeMatch.Criteria(record.getProductItemId(), record.getBomId(),
                    record.getVoltageMin(), record.getVoltageMax(), record.getMarket(),
                    standards.getOrDefault(record.getId(), Set.of())), resolvedRequest);
        }).toList();
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pd/knowledge/KnowledgeWriteService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,242 @@
package cn.iocoder.yudao.module.mes.service.pd.knowledge;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeStandardDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeResourceDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pd.knowledge.KnowledgeReferenceDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeStandardMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeResourceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pd.knowledge.KnowledgeReferenceMapper;
import cn.iocoder.yudao.module.mes.service.dv.machinery.MesDvMachineryService;
import cn.iocoder.yudao.module.mes.service.md.workstation.MesMdWorkstationService;
import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdProductBomService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteService;
import cn.iocoder.yudao.module.mes.service.pd.processparamtemplate.MesPdProcessParamTemplateService;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.Data;
import org.springframework.stereotype.Service;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@Service
public class KnowledgeWriteService {
    @Resource private KnowledgeMapper knowledgeMapper;
    @Resource private KnowledgeStandardMapper standardMapper;
    @Resource private KnowledgeResourceMapper resourceMapper;
    @Resource private KnowledgeReferenceMapper referenceMapper;
    @Resource private MesDvMachineryService machineryService;
    @Resource private MesMdWorkstationService workstationService;
    @Resource private MesProProcessService processService;
    @Resource private MesMdItemService itemService;
    @Resource private MesMdProductBomService bomService;
    @Resource private MesProRouteService routeService;
    @Resource private MesPdProcessParamTemplateService templateService;
    @Data
    public static class SaveRequest {
        private Long id;
        private String code;
        private String name;
        private Long productItemId;
        private Long bomId;
        private BigDecimal voltageMin;
        private BigDecimal voltageMax;
        private String market;
        private Long routeId;
        private Long templateId;
        private String description;
        private List<String> standards;
        private List<ResourceInput> resources;
    }
    @Data
    public static class ResourceInput {
        private Long processId;
        private String resourceType;
        private Long resourceId;
        private String capabilityDescription;
    }
    @Transactional(rollbackFor = Exception.class)
    public Long create(SaveRequest input) {
        validate(input);
        String code = input.getCode().trim();
        if (!knowledgeMapper.selectList(new LambdaQueryWrapperX<KnowledgeDO>()
                .eq(KnowledgeDO::getCode, code).eq(KnowledgeDO::getVersion, 1)).isEmpty()) {
            throw invalidParamException("知识编码已存在");
        }
        var record = new KnowledgeDO();
        record.setCode(code); record.setVersion(1); record.setStatus(0);
        copyFields(record, input);
        try {
            knowledgeMapper.insert(record);
        } catch (DuplicateKeyException e) {
            throw invalidParamException("知识编码已存在");
        }
        saveStandards(record.getId(), input.getStandards());
        saveResources(record.getId(), input.getResources());
        return record.getId();
    }
    @Transactional(rollbackFor = Exception.class)
    public void update(SaveRequest input) {
        KnowledgeDO existing = input == null || input.getId() == null ? null : knowledgeMapper.selectById(input.getId());
        if (existing == null || !Integer.valueOf(0).equals(existing.getStatus())) {
            throw invalidParamException("知识不存在或不是可编辑的草稿");
        }
        validate(input);
        if (!existing.getCode().equals(input.getCode().trim())) throw invalidParamException("知识编码不可修改");
        var update = new LambdaUpdateWrapper<KnowledgeDO>()
                .eq(KnowledgeDO::getId, existing.getId()).eq(KnowledgeDO::getStatus, 0)
                .set(KnowledgeDO::getName, input.getName().trim())
                .set(KnowledgeDO::getProductItemId, input.getProductItemId()).set(KnowledgeDO::getBomId, input.getBomId())
                .set(KnowledgeDO::getVoltageMin, input.getVoltageMin()).set(KnowledgeDO::getVoltageMax, input.getVoltageMax())
                .set(KnowledgeDO::getMarket, input.getMarket()).set(KnowledgeDO::getRouteId, input.getRouteId())
                .set(KnowledgeDO::getTemplateId, input.getTemplateId()).set(KnowledgeDO::getDescription, input.getDescription());
        if (knowledgeMapper.update(null, update) != 1) throw invalidParamException("知识状态已变化,请刷新后重试");
        if (input.getStandards() != null) {
            standardMapper.delete(new LambdaQueryWrapperX<KnowledgeStandardDO>().eq(KnowledgeStandardDO::getKnowledgeId, existing.getId()));
            saveStandards(existing.getId(), input.getStandards());
        }
        if (input.getResources() != null) {
            resourceMapper.delete(new LambdaQueryWrapperX<KnowledgeResourceDO>().eq(KnowledgeResourceDO::getKnowledgeId, existing.getId()));
            saveResources(existing.getId(), input.getResources());
        }
    }
    @Transactional(rollbackFor = Exception.class)
    public void publish(Long id) {
        KnowledgeDO record = id == null ? null : knowledgeMapper.selectById(id);
        if (record == null || !Integer.valueOf(0).equals(record.getStatus()))
            throw invalidParamException("知识不存在或不是草稿");
        if (record.getRouteId() == null) throw invalidParamException("发布前请选择工艺路线");
        routeService.validateRouteExists(record.getRouteId());
        if (record.getTemplateId() != null) templateService.validateProcessParamTemplateExists(record.getTemplateId());
        var update = new LambdaUpdateWrapper<KnowledgeDO>().eq(KnowledgeDO::getId, id)
                .eq(KnowledgeDO::getStatus, 0).eq(KnowledgeDO::getRouteId, record.getRouteId());
        if (record.getTemplateId() == null) update.isNull(KnowledgeDO::getTemplateId);
        else update.eq(KnowledgeDO::getTemplateId, record.getTemplateId());
        if (knowledgeMapper.update(null, update.set(KnowledgeDO::getStatus, 1)) != 1)
            throw invalidParamException("知识状态已变化,请刷新后重试");
        // åŒä¸€ç¼–码只保留当前发布版本,旧版仍可作为引用快照追溯。
        knowledgeMapper.update(null, new LambdaUpdateWrapper<KnowledgeDO>()
                .eq(KnowledgeDO::getCode, record.getCode()).eq(KnowledgeDO::getStatus, 1)
                .lt(KnowledgeDO::getVersion, record.getVersion()).set(KnowledgeDO::getStatus, 2));
    }
    @Transactional(rollbackFor = Exception.class)
    public Long revise(Long id) {
        KnowledgeDO source = id == null ? null : knowledgeMapper.selectById(id);
        if (source == null || !Integer.valueOf(1).equals(source.getStatus()))
            throw invalidParamException("只能从已发布的知识创建新版本");
        List<KnowledgeDO> versions = knowledgeMapper.selectList(new LambdaQueryWrapperX<KnowledgeDO>()
                .eq(KnowledgeDO::getCode, source.getCode()).orderByDesc(KnowledgeDO::getVersion));
        if (versions.stream().anyMatch(version -> Integer.valueOf(0).equals(version.getStatus())))
            throw invalidParamException("该知识已有未发布的草稿版本");
        int nextVersion = versions.stream().map(KnowledgeDO::getVersion).max(Integer::compareTo).orElse(0) + 1;
        var draft = new KnowledgeDO();
        draft.setCode(source.getCode()); draft.setVersion(nextVersion); draft.setStatus(0); draft.setName(source.getName());
        draft.setProductItemId(source.getProductItemId()); draft.setBomId(source.getBomId());
        draft.setVoltageMin(source.getVoltageMin()); draft.setVoltageMax(source.getVoltageMax()); draft.setMarket(source.getMarket());
        draft.setRouteId(source.getRouteId()); draft.setTemplateId(source.getTemplateId()); draft.setDescription(source.getDescription());
        try { knowledgeMapper.insert(draft); }
        catch (DuplicateKeyException e) { throw invalidParamException("版本已变化,请刷新后重试"); }
        List<String> standards = standardMapper.selectList(new LambdaQueryWrapperX<KnowledgeStandardDO>()
                .eq(KnowledgeStandardDO::getKnowledgeId, source.getId())).stream().map(KnowledgeStandardDO::getStandardCode).toList();
        saveStandards(draft.getId(), standards);
        List<KnowledgeResourceDO> resources = resourceMapper.selectList(new LambdaQueryWrapperX<KnowledgeResourceDO>()
                .eq(KnowledgeResourceDO::getKnowledgeId, source.getId()));
        for (KnowledgeResourceDO resource : resources) {
            var copy = new KnowledgeResourceDO(); copy.setKnowledgeId(draft.getId());
            copy.setProcessId(resource.getProcessId()); copy.setResourceType(resource.getResourceType());
            copy.setResourceId(resource.getResourceId()); copy.setCapabilityDescription(resource.getCapabilityDescription());
            resourceMapper.insert(copy);
        }
        return draft.getId();
    }
    /** è®°å½•参考关系,不修改目标路线或复制路线内容。 */
    @Transactional(rollbackFor = Exception.class)
    public void referenceRoute(Long knowledgeId, Long targetRouteId) {
        if (knowledgeId == null || targetRouteId == null) throw invalidParamException("请选择知识及目标工艺路线");
        KnowledgeDO source = knowledgeMapper.selectOne(new LambdaQueryWrapperX<KnowledgeDO>()
                .eq(KnowledgeDO::getId, knowledgeId).last("FOR UPDATE"));
        if (source == null || !Integer.valueOf(1).equals(source.getStatus()))
            throw invalidParamException("只能参考当前已发布的知识");
        routeService.validateRouteExists(targetRouteId);
        var reference = new KnowledgeReferenceDO(); reference.setKnowledgeId(source.getId());
        reference.setKnowledgeVersion(source.getVersion()); reference.setTargetType("ROUTE"); reference.setTargetId(targetRouteId);
        try { referenceMapper.insert(reference); }
        catch (DuplicateKeyException e) { throw invalidParamException("该路线已参考此知识版本"); }
    }
    private void validate(SaveRequest input) {
        if (input == null || input.getCode() == null || input.getCode().isBlank() || input.getCode().length() > 64
                || input.getName() == null || input.getName().isBlank() || input.getName().length() > 200)
            throw invalidParamException("请填写有效的知识编码和名称");
        if (input.getMarket() == null || !Set.of("ALL", "DOMESTIC", "EXPORT").contains(input.getMarket())) throw invalidParamException("内外销类型不正确");
        if ((input.getVoltageMin() != null && input.getVoltageMin().signum() < 0)
                || (input.getVoltageMax() != null && input.getVoltageMax().signum() < 0)
                || (input.getVoltageMin() != null && input.getVoltageMax() != null && input.getVoltageMin().compareTo(input.getVoltageMax()) > 0))
            throw invalidParamException("电压范围不正确");
        if (input.getProductItemId() != null) itemService.validateItemExists(input.getProductItemId());
        if (input.getBomId() != null) {
            if (input.getProductItemId() == null) throw invalidParamException("选择 BOM æ˜Žç»†å‰è¯·å…ˆé€‰æ‹©é€‚用物料");
            boolean found = bomService.getProductBomListByItemId(input.getProductItemId()).stream()
                    .anyMatch(bom -> Objects.equals(bom.getItemId(), input.getProductItemId())
                            && Objects.equals(bom.getBomItemId(), input.getBomId()) && Objects.equals(bom.getStatus(), 0));
            if (!found) throw invalidParamException("所选 BOM å­ç‰©æ–™ä¸å­˜åœ¨ã€æœªå¯ç”¨æˆ–不属于该物料");
        }
        if (input.getRouteId() != null) routeService.validateRouteExists(input.getRouteId());
        if (input.getTemplateId() != null) templateService.validateProcessParamTemplateExists(input.getTemplateId());
        if (input.getStandards() != null && input.getStandards().stream().anyMatch(s -> s == null || s.isBlank() || s.trim().length() > 128))
            throw invalidParamException("标准编码不能为空或超过 128 å­—符");
        if (input.getResources() != null) for (ResourceInput resource : input.getResources()) {
            if (resource == null || resource.getResourceType() == null || !Set.of("MACHINE", "WORKSTATION", "OTHER").contains(resource.getResourceType()))
                throw invalidParamException("资源类型不正确");
            if (resource.getCapabilityDescription() == null || resource.getCapabilityDescription().isBlank()
                    || resource.getCapabilityDescription().length() > 500) throw invalidParamException("请填写不超过 500 å­—的资源能力说明");
            if (resource.getProcessId() != null) processService.validateProcessExists(resource.getProcessId());
            if ("MACHINE".equals(resource.getResourceType())) {
                if (resource.getResourceId() == null) throw invalidParamException("请选择设备");
                machineryService.validateMachineryExists(resource.getResourceId());
            } else if ("WORKSTATION".equals(resource.getResourceType())) {
                if (resource.getResourceId() == null) throw invalidParamException("请选择工作站");
                workstationService.validateWorkstationExists(resource.getResourceId());
            } else if (resource.getResourceId() != null) throw invalidParamException("其他资源不能指定设备或工作站编号");
        }
    }
    private void copyFields(KnowledgeDO record, SaveRequest input) {
        record.setName(input.getName().trim()); record.setProductItemId(input.getProductItemId()); record.setBomId(input.getBomId());
        record.setVoltageMin(input.getVoltageMin()); record.setVoltageMax(input.getVoltageMax()); record.setMarket(input.getMarket());
        record.setRouteId(input.getRouteId()); record.setTemplateId(input.getTemplateId()); record.setDescription(input.getDescription());
    }
    private void saveStandards(Long id, List<String> standards) {
        if (standards == null) return;
        standards.stream().map(String::trim).distinct().forEach(code -> {
            var standard = new KnowledgeStandardDO(); standard.setKnowledgeId(id); standard.setStandardCode(code);
            standardMapper.insert(standard);
        });
    }
    private void saveResources(Long id, List<ResourceInput> resources) {
        if (resources == null) return;
        for (ResourceInput resource : resources) {
            var item = new KnowledgeResourceDO(); item.setKnowledgeId(id); item.setProcessId(resource.getProcessId());
            item.setResourceType(resource.getResourceType()); item.setResourceId(resource.getResourceId());
            item.setCapabilityDescription(resource.getCapabilityDescription().trim()); resourceMapper.insert(item);
        }
    }
}