You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

3475 lines
320 KiB

diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java b/crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java
index 27fe7f7..9999e00 100644
--- a/crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java
@@ -1,7 +1,7 @@
package com.crm.opportunity.constant;
/**
- * 鍟嗘満妯″潡甯搁噺锛堢エ 04 璧锋锛氶敊璇爜 + 杩涘叆鍏捣鍘熷洜锛?+ * 鍟嗘満妯″潡甯搁噺锛堢エ 04 璧锋锛氶敊璇爜 + 杩涘叆鍏捣鍘熷洜锛涚エ 07 鏂规鍗℃墿灞曪級
* <p>瀵圭О {@code LeadConstants} 鐨?interface 甯搁噺褰㈡€併€傜姸鎬佸€煎煙鍦? * {@link com.crm.opportunity.domain.enums.OpportunityStatus}锛堟灇涓惧嵆璇嶆眹琛紝涓嶅湪姝ら噸澶嶏級銆?/p>
*/
@@ -15,6 +15,19 @@ public interface OpportunityConstants {
int CODE_CAS_FAIL = 66004; // 涔愯閿?鐘舵€?CAS 鍐茬獊锛圓DR-0021锛? int CODE_STAGE_NOT_ALLOWED = 66005; // 褰撳墠鐘舵€佷笉鍏佽闃舵鍒囨崲锛堢エ 06锛氫粎鎺ㄨ繘涓彲鍒囷級
int CODE_STAGE_NODE_INVALID = 66006; // 鐩爣闃舵鑺傜偣闈炴硶锛堜笉灞炵粦瀹氭ā鏉跨増鏈?鍥哄畾鑺傜偣/涓嶅瓨鍦紝绁?06锛?+ int CODE_SCHEME_CARD_INVALID = 66007; // 鏂规鍗″叆鍙傞潪娉曪紙鍙傛暟缂哄け/瀹㈡埛涓嶅睘鏈晢鏈?瀛楁涓嶅睘缁戝畾妯℃澘/蹇呭~缂哄け锛岀エ 07锛?+ int CODE_SCHEME_CARD_NOT_EXIST = 66008; // 鏂规鍗′笉瀛樺湪鎴栧凡琚垹闄わ紙绁?07锛?+ int CODE_SCHEME_CARD_DUPLICATE = 66009; // 涓€瀹㈡埛涓€鍗★細璇ュ鎴峰湪鏈晢鏈轰笅宸叉湁鏂规鍗★紙浜у搧鎷嶆澘 Q2锛岀エ 07锛?+ int CODE_SCHEME_CARD_READONLY = 66010; // 宸叉帹閫佹柟妗堝崱鏁村崱鍙锛堝彲缂栬緫=card_status<3锛屾媿鏉?P1-1锛岀エ 07锛?+
+ /*-------- 鏂规鍗$姸鎬?card_status锛堜骇鍝佹媿鏉?P0-4锛岀エ 07銆?.1銆嶏級--------
+ * 銆屾湭鍒涘缓銆? 0 鍗″睍绀烘€侊紝涓嶅叆搴撲笉鍗犲€煎煙锛涘崱涓€缁忓垱寤鸿嚦灏戜负鑽夌銆?+ * 鎺ㄩ€佸姩浣滐紙A7-3-2-3锛夋湰鏈熸湭钀斤紝宸叉帹閫佺姸鎬佷綅棰勭暀銆?+ */
+
+ int CARD_STATUS_DRAFT = 1; // 鑽夌锛堝師鍨嬭鏍囥€岃崏绋?寰呮彁浜ゃ€嶃€屽~鍐欎腑銆嶅綊涓€锛?+ int CARD_STATUS_SUBMITTED = 2; // 宸叉彁浜わ紙鐐广€岀‘璁ゃ€嶏紝鏍稿績瀛楁閿佺矑搴?鍗?鐘舵€侊級
+ int CARD_STATUS_PUSHED = 3; // 宸叉帹閫侊紙鏁村崱鍙锛岃鏀规柊寤哄崱锛?
/*-------- 杩涘叆鍏捣鍘熷洜 pool_reason锛圥RD 搂3.5 鏋氫妇锛屽洓鍊煎皝闂紱varchar(32) 瀛樿涔夌爜锛?-------
* E3 涓変釜鏉ユ簮鍊肩敱璋冪敤鏂癸紙Service锛夋寜鎿嶄綔鍦烘櫙浼犲叆锛汦10 瓒呮湡鍥炴敹鐢?executeRecycle 鍐欐銆?diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySchemeCardController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySchemeCardController.java
new file mode 100644
index 0000000..b52c122
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySchemeCardController.java
@@ -0,0 +1,59 @@
+package com.crm.opportunity.controller;
+
+import com.crm.base.domain.result.Result;
+import com.crm.base.security.SecurityUtils;
+import com.crm.opportunity.domain.dto.SchemeCardDTO;
+import com.crm.opportunity.domain.dto.SchemeCardDetailDTO;
+import com.crm.opportunity.domain.dto.SchemeCardSaveDTO;
+import com.crm.opportunity.schemecard.OpportunitySchemeCardService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 鏂规鍗℃帴鍙o紙钖勯€傞厤灞傦紝鍙皟 {@link OpportunitySchemeCardService}锛岀エ 07銆屼笁銆嶏級
+ * <p>鏉冮檺绛栫暐锛氫笉绉嶅瓙鍖?button 鏉冮檺鐐癸紙鍚岄樁娈垫帴鍙o級锛孉piPermissionInterceptor 瀵规湭娉ㄥ唽
+ * URL fail-open锛涗竴瀹㈡埛涓€鍗?鏃犲绔嬪崱/宸叉帹閫佸彧璇荤瓑纭害鏉熺敱鏈嶅姟灞傛牎楠屽厹搴曘€?+ * 鏃?owner guard锛堢エ 01锛夈€傛帹閫佸姩浣滐紙A7-3-2-3锛変笉鍦ㄦ湰绁紝宸叉帹閫佺姸鎬佷綅棰勭暀銆?/p>
+ */
+@Tag(name = "鍟嗘満绠$悊/鏂规鍗?)
+@RestController
+@RequestMapping("/api/opportunity/scheme-card")
+@RequiredArgsConstructor
+public class OpportunitySchemeCardController {
+
+ private final OpportunitySchemeCardService schemeCardService;
+
+ @Operation(summary = "鏂规鍗″垪琛紙涓€鍟嗘満澶氬紶鍒嗙粍灞曠ず锛涙棤鍗?銆屾湭鍒涘缓銆嶅睍绀烘€佺敱鍓嶇娓叉煋锛?)
+ @GetMapping("/list")
+ public Result<List<SchemeCardDTO>> list(@RequestParam("oppId") Long oppId) {
+ return Result.success(schemeCardService.listCards(oppId));
+ }
+
+ @Operation(summary = "鏂规鍗¤鎯咃紙缁戝畾妯℃澘鐗堟湰鏄剧ず瀛楁椤规湁搴忓垪琛?+ 宸插~鍊硷級")
+ @GetMapping("/detail")
+ public Result<SchemeCardDetailDTO> detail(@RequestParam("id") Long id) {
+ return Result.success(schemeCardService.getCardDetail(id));
+ }
+
+ @Operation(summary = "淇濆瓨锛堟柊寤鸿崏绋?缂栬緫锛涙柊寤洪攣鏈€鏂板彂甯冩ā鏉跨増鏈紝缂栬緫缁敤閿佸畾鐗堟湰锛涜崏绋夸笌宸叉彁浜ゅ潎鍙敼锛?)
+ @PostMapping("/save")
+ public Result<Long> save(@RequestBody SchemeCardSaveDTO dto) {
+ return Result.success(schemeCardService.saveCard(dto));
+ }
+
+ @Operation(summary = "鎻愪氦锛堛€岀‘璁ゃ€嶁啋宸叉彁浜わ紱鎻愪氦鍓嶆牎楠屾ā鏉垮繀濉瓧娈碉級")
+ @PostMapping("/submit")
+ public Result<Void> submit(@RequestParam("id") Long id) {
+ schemeCardService.submitCard(id, Long.valueOf(SecurityUtils.getRequiredUserId()));
+ return Result.success();
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardDTO.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardDTO.java
new file mode 100644
index 0000000..ec84935
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardDTO.java
@@ -0,0 +1,56 @@
+package com.crm.opportunity.domain.dto;
+
+import com.crm.base.domain.dto.BaseDTO;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+/**
+ * 鏂规鍗″垪琛ㄩ」鍑哄弬锛堢エ 07銆?.1銆嶏細涓€鍟嗘満澶氬崱鍒嗙粍灞曠ず锛?+ * <p>銆屾湭鍒涘缓銆? 0 鍗″睍绀烘€佺敱鍓嶇鎸夌┖鍒楄〃娓叉煋锛屼笉鍦ㄦ湰 DTO 鍊煎煙鍐咃紱
+ * 瀹㈡埛鍚嶉殢鍏宠仈瀹㈡埛琛ㄥ揩鐓у洖鏄撅紙鍏?join锛夈€?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SchemeCardDTO extends BaseDTO {
+
+ @Schema(description = "鎵€灞炲晢鏈篒D")
+ private Long oppId;
+
+ @Schema(description = "鍏宠仈瀹㈡埛ID锛堝繀鎸傛湰鍟嗘満涓嬬殑鍏宠仈瀹㈡埛锛?)
+ private Long customerId;
+
+ @Schema(description = "瀹㈡埛鍚嶅揩鐓э紙鍥炴樉锛屾潵鑷叧鑱斿鎴疯〃锛?)
+ private String customerNameSnapshot;
+
+ @Schema(description = "缁戝畾鐨勬柟妗堝崱妯℃澘鐗堟湰琛孖D锛堥攣鐗堟湰锛?)
+ private Long templateId;
+
+ @Schema(description = "妯℃澘鐗堟湰鍙凤紙鍐椾綑鍥炴樉锛?)
+ private String templateVersion;
+
+ @Schema(description = "鍗$姸鎬侊細1鑽夌 2宸叉彁浜?3宸叉帹閫?)
+ private Integer cardStatus;
+
+ @Schema(description = "鍙紪杈戞爣璁帮紙鍑哄弬娲剧敓锛歝ard_status<3 鍙紪杈戯紝鎷嶆澘 P1-1锛?)
+ private Boolean editable;
+
+ @Schema(description = "鏂规棰勭畻閲戦")
+ private BigDecimal schemeBudget;
+
+ @Schema(description = "鎷涙爣褰㈠紡瀛楀吀 code")
+ private String biddingForm;
+
+ @Schema(description = "浠嬪叆闃舵瀛楀吀 code")
+ private String enterStage;
+
+ public static SchemeCardDTO fromEntity(OpportunitySchemeCard entity) {
+ SchemeCardDTO dto = new SchemeCardDTO();
+ org.springframework.beans.BeanUtils.copyProperties(entity, dto);
+ dto.setEditable(entity.getCardStatus() != null && entity.getCardStatus() < 3);
+ return dto;
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardDetailDTO.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardDetailDTO.java
new file mode 100644
index 0000000..8260ba2
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardDetailDTO.java
@@ -0,0 +1,53 @@
+package com.crm.opportunity.domain.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * 鏂规鍗¤鎯呭嚭鍙傦紙绁?07銆?.1銆嶏細鍔ㄦ€佸瓧娈靛€兼潵鑷粦瀹氱殑妯℃澘鐗堟湰锛?+ *
+ * <p>瀛楁鍒楄〃鎸夌粦瀹氭ā鏉跨増鏈殑瀛楁椤规湁搴忓洖鏄撅紙浠呮樉绀洪」锛夛紝鍊兼寜 {@code field_key} 瑁呴厤锛?+ * 缁撴瀯鍖栧瓧娈碉紙鎷涙爣褰㈠紡/浠嬪叆闃舵/鏂规棰勭畻閲戦锛夋彁鍗囦富琛紝姝ゅ鐢变富琛ㄥ垪鍥炲~
+ * 锛堜笌鍔ㄦ€佸瓧娈垫覆鏌撴垚涓€浣擄紝鍊间笉杩?{@code opportunity_scheme_card_value} 鍊艰〃锛夈€?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SchemeCardDetailDTO extends SchemeCardDTO {
+
+ @Schema(description = "瀛楁椤规湁搴忓垪琛紙缁戝畾妯℃澘鐗堟湰鐨勬樉绀洪」 + 宸插~鍊硷級")
+ private List<FieldEntry> fields;
+
+ /**
+ * 瀛楁鏉$洰锛堟ā鏉垮瓧娈甸」蹇収 + 鍗′笂宸插~鍊硷級銆?+ */
+ @Data
+ public static class FieldEntry {
+
+ @Schema(description = "搴忓彿锛堝~鍐欓〉灞曠ず椤哄簭锛?)
+ private Integer seqNo;
+
+ @Schema(description = "瀛楁閿?)
+ private String fieldKey;
+
+ @Schema(description = "灞曠ず瀛楁鍚嶏紙妯℃澘瀛楁椤瑰揩鐓э級")
+ private String fieldName;
+
+ @Schema(description = "瀛楁绫诲瀷锛堟ā鏉垮瓧娈甸」蹇収锛?)
+ private String fieldType;
+
+ @Schema(description = "蹇呭~锛?蹇呭~ 0闈炲繀濉紙妯℃澘瀛楁椤瑰揩鐓э級")
+ private Integer isRequired;
+
+ @Schema(description = "宸插~鍊硷紙缁撴瀯鍖栧瓧娈电敱涓昏〃鍥炲~锛屽叾浣欏彇鍊艰〃锛?)
+ private String fieldValue;
+ }
+
+ public static SchemeCardDetailDTO fromCardDTO(SchemeCardDTO card) {
+ SchemeCardDetailDTO dto = new SchemeCardDetailDTO();
+ org.springframework.beans.BeanUtils.copyProperties(card, dto);
+ return dto;
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardSaveDTO.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardSaveDTO.java
new file mode 100644
index 0000000..6f88667
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SchemeCardSaveDTO.java
@@ -0,0 +1,53 @@
+package com.crm.opportunity.domain.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 鏂规鍗′繚瀛樺叆鍙傦紙鑽夌淇濆瓨 / 缂栬緫锛岀エ 07銆屼笁銆嶏級
+ *
+ * <p>鏂板缓锛堟棤 {@code id}锛夛細妯℃澘鐢辩郴缁熸寜鍟嗘満璐熻矗浜洪儴闂ㄧ粡
+ * {@code resolveBindingVersion} 瑙f瀽锛堥攣鏈€鏂板彂甯冪増鏈锛夛紝鍏ュ弬涓嶆帴妯℃澘瀛楁闃蹭吉閫狅紱
+ * 缂栬緫锛堟湁 {@code id}锛夛細缁敤鍗′笂閿佸畾鐗堟湰锛寋@code customerId} 涓嶅彲鏇存崲銆?/p>
+ *
+ * <p>缁撴瀯鍖栧瓧娈碉紙棰勭畻/鎷涙爣褰㈠紡/浠嬪叆闃舵锛夎蛋涓昏〃鍒楋紱鍏朵綑妯℃澘瀛楁浠?+ * {@link FieldValue} 鍒楄〃涓婇€侊紝闅忎繚瀛樺叏閲忔浛鎹㈠€艰〃銆?/p>
+ */
+@Data
+public class SchemeCardSaveDTO {
+
+ @Schema(description = "鏂规鍗D锛堢紪杈戞椂蹇呭~锛涙柊寤烘棤锛?)
+ private Long id;
+
+ @Schema(description = "鎵€灞炲晢鏈篒D锛堝繀濉級")
+ private Long oppId;
+
+ @Schema(description = "鍏宠仈瀹㈡埛ID锛堝繀濉紝椤讳负鏈晢鏈轰笅鐨勫叧鑱斿鎴凤紱缂栬緫鏃朵笉鍙洿鎹級")
+ private Long customerId;
+
+ @Schema(description = "鏂规棰勭畻閲戦锛堢粨鏋勫寲涓昏〃鍒楋級")
+ private BigDecimal schemeBudget;
+
+ @Schema(description = "鎷涙爣褰㈠紡瀛楀吀 code锛堢粨鏋勫寲涓昏〃鍒楋級")
+ private String biddingForm;
+
+ @Schema(description = "浠嬪叆闃舵瀛楀吀 code锛堢粨鏋勫寲涓昏〃鍒楋級")
+ private String enterStage;
+
+ @Schema(description = "鍔ㄦ€佸瓧娈靛€煎垪琛紙field_key 椤诲睘缁戝畾妯℃澘鐗堟湰鐨勬樉绀哄瓧娈碉紱缁撴瀯鍖栧瓧娈甸敭涓嶅彲鍦ㄦ涓婇€侊級")
+ private List<FieldValue> values;
+
+ /** 鍔ㄦ€佸瓧娈靛€奸」 */
+ @Data
+ public static class FieldValue {
+
+ @Schema(description = "瀛楁閿紙蹇呭~锛?)
+ private String fieldKey;
+
+ @Schema(description = "瀛楁鍊硷紙鏂囨湰/JSON锛屾寜瀛楁绫诲瀷瑙f瀽锛涚┖涓茶涓烘竻绌猴級")
+ private String fieldValue;
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityCustomer.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityCustomer.java
new file mode 100644
index 0000000..6f7afc2
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityCustomer.java
@@ -0,0 +1,60 @@
+package com.crm.opportunity.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.crm.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+/**
+ * 鍟嗘満-鍏宠仈瀹㈡埛瀛愯〃锛堢エ 13锛氬叧鑱斿鎴?+ 涓昏鎰忓悜瀹㈡埛锛?+ *
+ * <p>涓€涓晢鏈虹淮鎶ゅ涓叧鑱斿鎴凤紝鍏朵腑鎭板ソ涓€涓爣璁颁负**涓昏鎰忓悜瀹㈡埛**
+ * 锛坽@code is_primary_intended=1}锛屽簲鐢ㄥ眰浜嬪姟淇濊瘉鍞竴锛涚嚎绱㈣浆鍟嗘満鐨勩€屾剰鍚戝鎴枫€嶈惤涓绘爣璁帮級銆?+ * 瀹㈡埛妯″潡 A4 鏈缓锛歿@code customer_id} 璧般€屽紩鐢?id + 鍚嶅揩鐓с€嶅苟瀛橈紙鍚岀エ 11 璧版硶锛夛紝
+ * {@code customer_name_snapshot} 鍏?join 楂橀灞曠ず銆?/p>
+ *
+ * <p>绾︽潫锛氫竴瀹㈡埛鍦ㄤ竴鍟嗘満涓嬪彧鍏宠仈涓€娆★紙UNIQUE(opportunity_id, customer_id)锛夛紱
+ * 鏃犲绔嬭鈥斺€斿繀灞炴煇鍟嗘満锛屽垹鍟嗘満绾ц仈娓呯悊锛堣蒋鍒犲彛寰勯殢鍟嗘満锛夈€?/p>
+ *
+ * <p>鏂规鍗¢敋鐐癸紙绁?07锛夛細姣忓紶鏂规鍗″繀鎸傛湰琛ㄤ腑鐨勪竴涓叧鑱斿鎴凤紙鏃犲绔嬪崱锛夛紝
+ * 涓€瀹㈡埛涓€鍗★紱鍒楄〃娲剧敓鍒楋紙鏂规棰勭畻/鏂规鍗$姸鎬侊級鍙栦富瑕佹剰鍚戝鎴烽偅寮犲崱鐨勫€硷紙绁?13/绁?10锛夈€?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("opportunity_customer")
+@Entity
+@Table(name = "opportunity_customer",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_opp_customer", columnNames = {"opportunity_id", "customer_id"})
+ },
+ indexes = {
+ @Index(name = "idx_oc_opportunity", columnList = "opportunity_id")
+ })
+public class OpportunityCustomer extends BaseEntity {
+
+ @Comment("鎵€灞炲晢鏈篒D锛坥pportunity.id锛?)
+ @Column(columnDefinition = "bigint not null comment '鎵€灞炲晢鏈篒D'")
+ private Long opportunityId;
+
+ @Comment("瀹㈡埛ID锛堝紩鐢?A4 瀹㈡埛妯″潡锛屾湭寤猴紱鍏堝瓨 id + 鍚嶅揩鐓э紝鍚岀エ 11 璧版硶锛?)
+ @Column(columnDefinition = "bigint not null comment '瀹㈡埛ID'")
+ private Long customerId;
+
+ @Comment("瀹㈡埛鍚嶅揩鐓э紙鍏?join锛屽垪琛?璇︽儏楂橀灞曠ず锛?)
+ @Column(columnDefinition = "varchar(200) not null comment '瀹㈡埛鍚嶅揩鐓?")
+ private String customerNameSnapshot;
+
+ @Comment("瀹㈡埛瑙掕壊瀛楀吀 code锛堟満鐢垫€诲寘/宸ョ▼鍟?闆嗘垚鍟嗏€︼紝绁?13锛氬瓧鍏稿垎缁勫緟浜у搧瀹氶€夐」闆嗭級")
+ @Column(columnDefinition = "varchar(32) comment '瀹㈡埛瑙掕壊瀛楀吀code'")
+ private String customerRole;
+
+ @Comment("鏄惁涓昏鎰忓悜瀹㈡埛锛?=鏄?0=鏅€氬叧鑱旓紙涓€鍟嗘満鍞竴 =1锛屽簲鐢ㄥ眰浜嬪姟淇濊瘉锛?)
+ @Column(columnDefinition = "tinyint not null default 0 comment '鏄惁涓昏鎰忓悜瀹㈡埛'")
+ private Integer isPrimaryIntended;
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySchemeCard.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySchemeCard.java
new file mode 100644
index 0000000..33f1cb1
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySchemeCard.java
@@ -0,0 +1,81 @@
+package com.crm.opportunity.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.crm.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+import java.math.BigDecimal;
+
+/**
+ * 鏂规鍗′富琛紙杩愯鏃讹紝绁?07銆屼笁銆? 鍘熷瀷 A3-1-1-1-3锛?+ *
+ * <p>鍟嗘満鎺ㄨ繘杩囩▼涓矇娣€銆屽鎴烽渶姹?棰勭畻/鎷涙爣褰㈠紡/浠嬪叆闃舵/鏂规鐘舵€併€嶇殑涓氬姟鍗曟嵁锛?+ * 鏄浆椤圭洰鐨勫熀纭€妗f锛堣浆椤圭洰绔彛濂戠害褰?A5锛屾湰琛ㄦ煇寮犲崱璧拌浆椤圭洰 鈫?鍥炶皟椹卞姩
+ * 杞? {@code opp_status=5} + 杞? {@code current_stage鈫掑凡杞」鐩妭鐐箎锛岀エ 07銆屽洓銆嶏級銆?/p>
+ *
+ * <p>纭害鏉燂細</p>
+ * <ul>
+ * <li>姣忓紶鍗″繀鍏宠仈褰撳墠鍟嗘満涓嬬殑涓€涓叧鑱斿鎴凤紙{@code customer_id} 鈫?opportunity_customer锛?+ * 涓嶅厑璁告棤瀹㈡埛褰掑睘鐨勫绔嬫柟妗堝崱锛夛紱</li>
+ * <li>涓€瀹㈡埛涓€鍗★紙浜у搧鎷嶆澘 Q2锛夛細UNIQUE(opp_id, customer_id)锛?/li>
+ * <li>缁戝畾鏂规鍗℃ā鏉?*鐗堟湰琛?id**锛堥攣鐗堟湰锛屽彂鏂扮増涓嶈縼绉伙紝瀵归綈 V-CONFIG锛夛細
+ * 鍦ㄩ€斿崱缁敤鍘熺増鏈紝鏂板缓鍗$敤鏈€鏂板彂甯冪増鏈紱</li>
+ * <li>鍗曞崱鐘舵€佹満锛堜骇鍝佹媿鏉?P0-4锛夛細鑽夌1/宸叉彁浜?/宸叉帹閫?锛涖€屾湭鍒涘缓銆? 0 鍗″睍绀烘€佷笉鍏ュ簱銆?+ * 鍙紪杈戝垽瀹?= {@code card_status < 3}锛堜粎宸叉帹閫佹暣鍗″彧璇伙紝涓嶅仛瀛楁绾ч攣锛屾媿鏉?P1-1锛夈€?/li>
+ * </ul>
+ *
+ * <p>缁撴瀯鍖栧瓧娈碉紙棰勭畻/鎷涙爣褰㈠紡/浠嬪叆闃舵/瀹㈡埛锛夋彁鍗囧埌涓昏〃渚夸簬鍒楄〃 join 涓庣瓫閫夛紱
+ * 鍏朵綑鎸夋ā鏉垮姩鎬佸瓧娈佃繘 {@link OpportunitySchemeCardValue} 鍊艰〃銆?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("opportunity_scheme_card")
+@Entity
+@Table(name = "opportunity_scheme_card",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_osc_opp_customer", columnNames = {"opp_id", "customer_id"})
+ },
+ indexes = {
+ @Index(name = "idx_osc_opp", columnList = "opp_id")
+ })
+public class OpportunitySchemeCard extends BaseEntity {
+
+ @Comment("鎵€灞炲晢鏈篒D锛坥pportunity.id锛?)
+ @Column(columnDefinition = "bigint not null comment '鎵€灞炲晢鏈篒D'")
+ private Long oppId;
+
+ @Comment("鍏宠仈瀹㈡埛ID锛堝繀濉紝= 褰撳墠鍟嗘満涓嬫煇鍏宠仈瀹㈡埛 opportunity_customer.customer_id锛屾棤瀛ょ珛鍗★級")
+ @Column(columnDefinition = "bigint not null comment '鍏宠仈瀹㈡埛ID'")
+ private Long customerId;
+
+ @Comment("缁戝畾鐨勬柟妗堝崱妯℃澘鐗堟湰琛孖D锛堚啋opportunity_scheme_card_template.id锛岄攣鐗堟湰鍙戞柊鐗堜笉杩佺Щ锛?)
+ @Column(columnDefinition = "bigint not null comment '妯℃澘鐗堟湰琛孖D'")
+ private Long templateId;
+
+ @Comment("妯℃澘鐗堟湰鍙峰啑浣欙紙濡?V1.2锛屽睍绀虹敤鍏?join锛屽榻愮エ 03/06 鍋氭硶锛?)
+ @Column(columnDefinition = "varchar(20) not null comment '妯℃澘鐗堟湰鍙峰啑浣?")
+ private String templateVersion;
+
+ @Comment("鍗$姸鎬侊細1鑽夌 2宸叉彁浜?3宸叉帹閫侊紙浜у搧鎷嶆澘 P0-4锛涗粎宸叉帹閫佸彧璇伙紝鍙紪杈?card_status<3锛?)
+ @Column(columnDefinition = "tinyint not null comment '鍗$姸鎬侊細1鑽夌 2宸叉彁浜?3宸叉帹閫?")
+ private Integer cardStatus;
+
+ @Comment("鏂规棰勭畻閲戦锛? CONTEXT銆愭柟妗堥绠椼€戣惤鐐癸紱鍟嗘満鍒楄〃鍒楀彇涓昏鎰忓悜瀹㈡埛鍗$殑姝ゅ€硷紝绁?13锛?)
+ @Column(columnDefinition = "decimal(14,2) comment '鏂规棰勭畻閲戦'")
+ private BigDecimal schemeBudget;
+
+ @Comment("鎷涙爣褰㈠紡瀛楀吀 code锛堢粨鏋勫寲鎻愬崌涓昏〃锛岃緟鍔╂帹杩涗紭鍏堢骇鍒ゆ柇锛?)
+ @Column(columnDefinition = "varchar(64) comment '鎷涙爣褰㈠紡瀛楀吀code'")
+ private String biddingForm;
+
+ @Comment("浠嬪叆闃舵瀛楀吀 code锛堢粨鏋勫寲鎻愬崌涓昏〃锛岃緟鍔╂帹杩涗紭鍏堢骇鍒ゆ柇锛?)
+ @Column(columnDefinition = "varchar(64) comment '浠嬪叆闃舵瀛楀吀code'")
+ private String enterStage;
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySchemeCardValue.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySchemeCardValue.java
new file mode 100644
index 0000000..e0f9d23
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunitySchemeCardValue.java
@@ -0,0 +1,50 @@
+package com.crm.opportunity.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+import org.hibernate.annotations.Comment;
+
+/**
+ * 鏂规鍗″姩鎬佸瓧娈靛€硷紙闅忕粦瀹氭ā鏉垮瓧娈碉紝绁?07銆?.2銆嶏級
+ *
+ * <p>妯℃澘瀛楁椤逛腑鏈粨鏋勫寲鎻愬崌涓昏〃鐨勯儴鍒嗘寜 {@code field_key} 钀藉€艰〃锛?+ * 鍊兼寜 {@code field_type} 瑙f瀽锛堟枃鏈?JSON锛夈€傚叧鑱旇〃锛屾棤瀹¤瀛楁锛?+ * 淇濆瓨鏃跺厛鍒犲悗鎻掑叏閲忔浛鎹紙鍗$骇鍐欑矑搴︼紝瀵归綈妯℃澘瀛楁椤瑰叏閲忔浛鎹㈠彛寰勶級銆?/p>
+ */
+@Data
+@TableName("opportunity_scheme_card_value")
+@Entity
+@Table(name = "opportunity_scheme_card_value",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_oscv_card_field", columnNames = {"card_id", "field_key"})
+ },
+ indexes = {
+ @Index(name = "idx_oscv_card", columnList = "card_id")
+ })
+public class OpportunitySchemeCardValue {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ @Id
+ @Column(name = "id", columnDefinition = "bigint comment '涓婚敭'")
+ private Long id;
+
+ @Comment("鎵€灞炴柟妗堝崱ID锛坥pportunity_scheme_card.id锛?)
+ @Column(columnDefinition = "bigint not null comment '鎵€灞炴柟妗堝崱ID'")
+ private Long cardId;
+
+ @Comment("瀛楁閿紙瀵瑰簲缁戝畾妯℃澘鐗堟湰鐨勫瓧娈甸」 field_key锛?)
+ @Column(columnDefinition = "varchar(64) not null comment '瀛楁閿?")
+ private String fieldKey;
+
+ @Comment("瀛楁鍊硷紙鏂囨湰/JSON锛屾寜妯℃澘瀛楁绫诲瀷瑙f瀽锛?)
+ @Column(columnDefinition = "text comment '瀛楁鍊?")
+ private String fieldValue;
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityCustomerMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityCustomerMapper.java
new file mode 100644
index 0000000..4642d84
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityCustomerMapper.java
@@ -0,0 +1,9 @@
+package com.crm.opportunity.mapper;
+
+import com.crm.base.mapper.CrmBaseMapper;
+import com.crm.opportunity.domain.entity.OpportunityCustomer;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OpportunityCustomerMapper extends CrmBaseMapper<OpportunityCustomer> {
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySchemeCardMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySchemeCardMapper.java
new file mode 100644
index 0000000..abb1670
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySchemeCardMapper.java
@@ -0,0 +1,9 @@
+package com.crm.opportunity.mapper;
+
+import com.crm.base.mapper.CrmBaseMapper;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OpportunitySchemeCardMapper extends CrmBaseMapper<OpportunitySchemeCard> {
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySchemeCardValueMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySchemeCardValueMapper.java
new file mode 100644
index 0000000..137d2d6
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySchemeCardValueMapper.java
@@ -0,0 +1,9 @@
+package com.crm.opportunity.mapper;
+
+import com.crm.base.mapper.CrmBaseMapper;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCardValue;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OpportunitySchemeCardValueMapper extends CrmBaseMapper<OpportunitySchemeCardValue> {
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/schemecard/OpportunitySchemeCardService.java b/crm-opportunity/src/main/java/com/crm/opportunity/schemecard/OpportunitySchemeCardService.java
new file mode 100644
index 0000000..d3477ad
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/schemecard/OpportunitySchemeCardService.java
@@ -0,0 +1,53 @@
+package com.crm.opportunity.schemecard;
+
+import com.crm.opportunity.domain.dto.SchemeCardDTO;
+import com.crm.opportunity.domain.dto.SchemeCardDetailDTO;
+import com.crm.opportunity.domain.dto.SchemeCardSaveDTO;
+
+import java.util.List;
+
+/**
+ * 鏂规鍗¤繍琛屾椂鏈嶅姟锛堢エ 07銆屼笁銆? 鍘熷瀷 A3-1-1-1-3锛?+ *
+ * <p>涓€涓晢鏈哄彲鍏宠仈澶氬紶鏂规鍗★紱姣忓紶鍗″繀鍏宠仈褰撳墠鍟嗘満涓嬬殑涓€涓叧鑱斿鎴凤紙鏃犲绔嬪崱锛夛紱
+ * 涓€瀹㈡埛涓€鍗★紙浜у搧鎷嶆澘 Q2锛夛紱瀛楁鍊兼潵鑷粦瀹氱殑鏂规鍗℃ā鏉跨増鏈紙鍔ㄦ€佸瓧娈碉紝閿佺増鏈彂鏂扮増涓嶈縼绉伙級銆?+ * 鍗曞崱鐘舵€佹満锛氳崏绋?/宸叉彁浜?/宸叉帹閫?锛涘彲缂栬緫鍒ゅ畾 = {@code cardStatus < 3}
+ * 锛堜粎宸叉帹閫佹暣鍗″彧璇伙紝涓嶅仛瀛楁绾ч攣锛屾媿鏉?P1-1锛夈€?/p>
+ *
+ * <p>妯℃澘鏌ヨ缁?{@code IOpportunitySchemeCardTemplateService}锛坈rm-opportunity 鈫?crm-rule
+ * 鍗曞悜渚濊禆锛岀エ 01锛夛紱妯℃澘閰嶇疆渚у鍟嗘満瀹炰綋闆跺弽渚濊禆銆傝浆椤圭洰绔彛濂戠害褰?A5锛堢エ 04/06 宸?+ * spin-out锛夛紝鏈湇鍔″彧鎵胯銆屾柟妗堝崱鏄浆椤圭洰瑙﹀彂婧愩€嶈繖涓€浜嬪疄锛堢エ 07銆屽洓銆嶏級銆?/p>
+ */
+public interface OpportunitySchemeCardService {
+
+ /**
+ * 鍟嗘満涓嬫柟妗堝崱鍒楄〃锛堝寮犲垎缁勫睍绀猴紱鎸夊垱寤烘椂闂村崌搴忥紱瀹㈡埛鍚嶅揩鐓у洖鏄撅級銆?+ * <p>銆屾湭鍒涘缓銆? 0 鍗″睍绀烘€侊細鍟嗘満鏃犱换浣曟柟妗堝崱鏃惰繑鍥炵┖鍒楄〃锛岀敱鍓嶇娓叉煋灞曠ず鎬併€?/p>
+ */
+ List<SchemeCardDTO> listCards(Long oppId);
+
+ /**
+ * 鏂规鍗¤鎯咃紙鍗′富淇℃伅 + 缁戝畾妯℃澘鐗堟湰鐨勬樉绀哄瓧娈甸」鏈夊簭鍒楄〃 + 宸插~鍊硷級銆?+ */
+ SchemeCardDetailDTO getCardDetail(Long cardId);
+
+ /**
+ * 淇濆瓨锛堟柊寤鸿崏绋?/ 缂栬緫锛夛細
+ * <ul>
+ * <li>鏂板缓锛氭ā鏉挎寜鍟嗘満璐熻矗浜洪儴闂ㄧ粡缁戝畾鍖归厤瑙f瀽锛堟棤鍙戝竷涓ā鏉垮垯鎷掔粷寤哄崱锛夛紱
+ * 鏍¢獙瀹㈡埛灞炴湰鍟嗘満鍏宠仈瀹㈡埛 + 涓€瀹㈡埛涓€鍗★紱</li>
+ * <li>缂栬緫锛氱画鐢ㄥ崱涓婇攣瀹氱増鏈紝鍏宠仈瀹㈡埛涓嶅彲鏇存崲锛涜崏绋?宸叉彁浜ゅ潎鍙慨鏀瑰叏閮ㄥ瓧娈?+ * 锛堜繚鎸佸師鐘舵€佷笉鍥為€€锛夛紝宸叉帹閫佹暣鍗″彧璇绘嫆缁濓紱</li>
+ * <li>鍔ㄦ€佸瓧娈靛€煎叏閲忔浛鎹細瀛楁閿』灞炵粦瀹氭ā鏉跨増鏈殑鏄剧ず瀛楁锛岀粨鏋勫寲瀛楁閿笉鍙笂閫佸€艰〃銆?/li>
+ * </ul>
+ *
+ * @return 鏂规鍗?id
+ */
+ Long saveCard(SchemeCardSaveDTO dto);
+
+ /**
+ * 鎻愪氦锛堛€岀‘璁ゃ€嶁啋 宸叉彁浜わ級锛氬厛鎵ц蹇呭~鏍¢獙锛堟ā鏉挎樉绀轰笖蹇呭~瀛楁鍧囬』宸插~锛?+ * 鍚粨鏋勫寲涓昏〃瀛楁锛夛紝閫氳繃鍚庤崏绋胯浆宸叉彁浜わ紱宸叉彁浜ゅ箓绛夛紱宸叉帹閫佹嫆缁濄€?+ */
+ void submitCard(Long cardId, Long operatorId);
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/schemecard/impl/OpportunitySchemeCardServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/schemecard/impl/OpportunitySchemeCardServiceImpl.java
new file mode 100644
index 0000000..9145c2f
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/schemecard/impl/OpportunitySchemeCardServiceImpl.java
@@ -0,0 +1,340 @@
+package com.crm.opportunity.schemecard.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.crm.base.domain.exception.BusinessErrorException;
+import com.crm.opportunity.constant.OpportunityConstants;
+import com.crm.opportunity.domain.dto.SchemeCardDTO;
+import com.crm.opportunity.domain.dto.SchemeCardDetailDTO;
+import com.crm.opportunity.domain.dto.SchemeCardSaveDTO;
+import com.crm.opportunity.domain.entity.Opportunity;
+import com.crm.opportunity.domain.entity.OpportunityCustomer;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCardValue;
+import com.crm.opportunity.mapper.OpportunityCustomerMapper;
+import com.crm.opportunity.mapper.OpportunityMapper;
+import com.crm.opportunity.mapper.OpportunitySchemeCardMapper;
+import com.crm.opportunity.mapper.OpportunitySchemeCardValueMapper;
+import com.crm.opportunity.schemecard.OpportunitySchemeCardService;
+import com.crm.rule.constant.OpportunityRuleConstants;
+import com.crm.rule.domain.entity.OpportunitySchemeCardField;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate;
+import com.crm.rule.service.IOpportunitySchemeCardTemplateService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 鏂规鍗¤繍琛屾椂鏈嶅姟瀹炵幇锛堢エ 07銆屼笁銆嶏級
+ *
+ * <p>纭害鏉熻惤鍦帮細鏃犲绔嬪崱锛堝鎴峰繀灞炴湰鍟嗘満鍏宠仈瀹㈡埛琛級銆佷竴瀹㈡埛涓€鍗★紙搴旂敤灞傛煡閲?+
+ * DB UNIQUE(opp_id, customer_id) 鍏滃簳锛夈€侀攣妯℃澘鐗堟湰锛堟柊寤虹粡缁戝畾鍖归厤瑙f瀽銆佺紪杈戠画鐢ㄤ笉杩佺Щ锛夈€?+ * 鍙紪杈?= {@code cardStatus < 3}锛堜粎宸叉帹閫佹暣鍗″彧璇伙紝鎷嶆澘 P1-1锛夈€?/p>
+ *
+ * <p>缁撴瀯鍖栧瓧娈碉紙棰勭畻/鎷涙爣褰㈠紡/浠嬪叆闃舵锛夋彁鍗囦富琛紙绁?07銆?.2銆嶏級锛氫繚瀛樿蛋涓婇€佷富琛ㄥ垪锛?+ * 鍊艰〃涓嶅彲閲嶅涓婇€侊紱璇︽儏鍥炴樉鏃剁敱涓昏〃鍥炲~瀛楁鏉$洰锛屼笌鍔ㄦ€佸瓧娈垫覆鏌撴垚涓€浣撱€?/p>
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OpportunitySchemeCardServiceImpl implements OpportunitySchemeCardService {
+
+ /** 缁撴瀯鍖栧瓧娈甸敭锛堟彁鍗囦富琛ㄥ垪锛岀エ 07銆?.2銆嶏級锛氬€间笉杩涘€艰〃锛屽繀濉牎楠屾煡涓昏〃鍒?*/
+ static final String FIELD_KEY_BIDDING_FORM = "bidding_form";
+ static final String FIELD_KEY_ENTER_STAGE = "enter_stage";
+ static final String FIELD_KEY_SCHEME_BUDGET = "scheme_budget";
+
+ private final OpportunityMapper oppMapper;
+ private final OpportunityCustomerMapper customerMapper;
+ private final OpportunitySchemeCardMapper cardMapper;
+ private final OpportunitySchemeCardValueMapper valueMapper;
+ private final IOpportunitySchemeCardTemplateService schemeTemplateService;
+
+ // ==================== 鏌ヨ ====================
+
+ @Override
+ public List<SchemeCardDTO> listCards(Long oppId) {
+ getOppOrThrow(oppId);
+ List<OpportunitySchemeCard> cards = cardMapper.selectList(
+ new LambdaQueryWrapper<OpportunitySchemeCard>()
+ .eq(OpportunitySchemeCard::getOppId, oppId)
+ .orderByAsc(OpportunitySchemeCard::getCreateTime));
+ if (CollUtil.isEmpty(cards)) {
+ return List.of(); // 銆屾湭鍒涘缓銆? 0 鍗″睍绀烘€侊細绌哄垪琛紝鍓嶇娓叉煋灞曠ず鎬?+ }
+ Map<Long, String> customerNames = customerNameMap(oppId);
+ return cards.stream().map(card -> {
+ SchemeCardDTO dto = SchemeCardDTO.fromEntity(card);
+ dto.setCustomerNameSnapshot(customerNames.get(card.getCustomerId()));
+ return dto;
+ }).collect(Collectors.toList());
+ }
+
+ @Override
+ public SchemeCardDetailDTO getCardDetail(Long cardId) {
+ OpportunitySchemeCard card = getCardByIdOrThrow(cardId);
+ SchemeCardDTO base = SchemeCardDTO.fromEntity(card);
+ base.setCustomerNameSnapshot(customerNameMap(card.getOppId()).get(card.getCustomerId()));
+ SchemeCardDetailDTO dto = SchemeCardDetailDTO.fromCardDTO(base);
+
+ Map<String, String> values = valueMapOf(card.getId());
+ List<SchemeCardDetailDTO.FieldEntry> entries = new ArrayList<>();
+ for (OpportunitySchemeCardField field : visibleFieldsOf(card.getTemplateId())) {
+ SchemeCardDetailDTO.FieldEntry entry = new SchemeCardDetailDTO.FieldEntry();
+ entry.setSeqNo(field.getSeqNo());
+ entry.setFieldKey(field.getFieldKey());
+ entry.setFieldName(field.getFieldName());
+ entry.setFieldType(field.getFieldType());
+ entry.setIsRequired(field.getIsRequired());
+ entry.setFieldValue(resolveFieldValue(card, field.getFieldKey(), values));
+ entries.add(entry);
+ }
+ dto.setFields(entries);
+ return dto;
+ }
+
+ // ==================== 淇濆瓨 ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long saveCard(SchemeCardSaveDTO dto) {
+ if (dto == null || dto.getOppId() == null || dto.getCustomerId() == null) {
+ throw cardInvalid("鏂规鍗′繚瀛樺弬鏁扮己澶憋紙鍟嗘満涓庡鎴峰繀濉級");
+ }
+ Opportunity opp = getOppOrThrow(dto.getOppId());
+ // 鏃犲绔嬪崱锛氬鎴峰繀灞炴湰鍟嗘満涓嬬殑鍏宠仈瀹㈡埛锛堢エ 13 瀛愯〃锛岀エ 07銆?.1銆嶇‖绾︽潫锛?+ OpportunityCustomer customer = customerMapper.selectOne(
+ new LambdaQueryWrapper<OpportunityCustomer>()
+ .eq(OpportunityCustomer::getOpportunityId, dto.getOppId())
+ .eq(OpportunityCustomer::getCustomerId, dto.getCustomerId()));
+ if (customer == null) {
+ throw cardInvalid("鍏宠仈瀹㈡埛涓嶅睘浜庢湰鍟嗘満锛堜笉鍏佽鏃犲鎴峰綊灞炵殑瀛ょ珛鏂规鍗★級");
+ }
+
+ List<SchemeCardSaveDTO.FieldValue> values = normalizeValues(dto.getValues());
+
+ if (dto.getId() == null) {
+ // 涓€瀹㈡埛涓€鍗★紙浜у搧鎷嶆澘 Q2锛夛細鍚屽晢鏈轰笅涓€涓鎴蜂粎涓€寮犲崱
+ OpportunitySchemeCard exist = cardMapper.selectOne(
+ new LambdaQueryWrapper<OpportunitySchemeCard>()
+ .eq(OpportunitySchemeCard::getOppId, dto.getOppId())
+ .eq(OpportunitySchemeCard::getCustomerId, dto.getCustomerId()));
+ if (exist != null) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_DUPLICATE,
+ "璇ュ鎴峰湪鏈晢鏈轰笅宸叉湁鏂规鍗★紙涓€瀹㈡埛涓€鍗★級锛岃鐩存帴缂栬緫鍘熷崱");
+ }
+ // 鏂板缓鍗$敤鏈€鏂板彂甯冪増鏈紙鍦ㄩ€斿崱缁敤鍘熺増鏈紝V-CONFIG 缁戝畾璇箟锛?+ OpportunitySchemeCardTemplate template =
+ schemeTemplateService.resolveBindingVersion(opp.getOwnerDeptId());
+ if (template == null) {
+ throw cardInvalid("褰撳墠鏃犲彲鐢紙鍙戝竷涓級鐨勬柟妗堝崱妯℃澘锛屾棤娉曟柊寤烘柟妗堝崱");
+ }
+ validateValuesAgainstTemplate(template.getId(), values);
+
+ OpportunitySchemeCard card = new OpportunitySchemeCard();
+ card.setOppId(dto.getOppId());
+ card.setCustomerId(dto.getCustomerId());
+ card.setTemplateId(template.getId());
+ card.setTemplateVersion(template.getVersionNo());
+ card.setCardStatus(OpportunityConstants.CARD_STATUS_DRAFT);
+ card.setSchemeBudget(dto.getSchemeBudget());
+ card.setBiddingForm(dto.getBiddingForm());
+ card.setEnterStage(dto.getEnterStage());
+ cardMapper.insert(card);
+ replaceValues(card.getId(), values);
+ return card.getId();
+ }
+
+ // 缂栬緫锛氱画鐢ㄩ攣瀹氱増鏈紱鑽夌/宸叉彁浜ゅ潎鍙敼锛堜繚鎸佸師鐘舵€佷笉鍥為€€锛夛紝宸叉帹閫佹暣鍗″彧璇?+ OpportunitySchemeCard card = getCardByIdOrThrow(dto.getId());
+ if (!Objects.equals(card.getOppId(), dto.getOppId())) {
+ throw cardInvalid("鏂规鍗′笉灞炰簬璇ュ晢鏈?);
+ }
+ if (!Objects.equals(card.getCustomerId(), dto.getCustomerId())) {
+ throw cardInvalid("鏂规鍗″叧鑱斿鎴蜂笉鍙洿鎹紙涓€瀹㈡埛涓€鍗¢敋鐐癸級");
+ }
+ if (card.getCardStatus() != null
+ && card.getCardStatus() >= OpportunityConstants.CARD_STATUS_PUSHED) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_READONLY,
+ "宸叉帹閫佹柟妗堝崱鏁村崱鍙锛屽闇€淇敼璇锋柊寤烘柟妗堝崱");
+ }
+ validateValuesAgainstTemplate(card.getTemplateId(), values);
+
+ OpportunitySchemeCard change = new OpportunitySchemeCard();
+ change.setId(card.getId());
+ change.setSchemeBudget(dto.getSchemeBudget());
+ change.setBiddingForm(dto.getBiddingForm());
+ change.setEnterStage(dto.getEnterStage());
+ cardMapper.updateById(change);
+ replaceValues(card.getId(), values);
+ return card.getId();
+ }
+
+ // ==================== 鎻愪氦 ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void submitCard(Long cardId, Long operatorId) {
+ if (cardId == null) {
+ throw cardInvalid("鏂规鍗℃彁浜ゅ弬鏁扮己澶?);
+ }
+ OpportunitySchemeCard card = getCardByIdOrThrow(cardId);
+ if (card.getCardStatus() != null
+ && card.getCardStatus() >= OpportunityConstants.CARD_STATUS_PUSHED) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_READONLY,
+ "宸叉帹閫佹柟妗堝崱涓嶅彲鍐嶆彁浜?);
+ }
+ if (card.getCardStatus() != null
+ && card.getCardStatus() == OpportunityConstants.CARD_STATUS_SUBMITTED) {
+ return; // 骞傜瓑锛氬凡鎻愪氦
+ }
+
+ // 蹇呭~鏍¢獙锛氭ā鏉挎樉绀轰笖蹇呭~瀛楁鍧囬』宸插~锛堢粨鏋勫寲瀛楁鏌ヤ富琛ㄥ垪锛?+ Map<String, String> values = valueMapOf(card.getId());
+ List<String> missing = new ArrayList<>();
+ for (OpportunitySchemeCardField field : visibleFieldsOf(card.getTemplateId())) {
+ if (field.getIsRequired() == null
+ || field.getIsRequired() != OpportunityRuleConstants.FLAG_YES) {
+ continue;
+ }
+ String filled = resolveFieldValue(card, field.getFieldKey(), values);
+ if (StrUtil.isBlank(filled)) {
+ missing.add(field.getFieldName());
+ }
+ }
+ if (!missing.isEmpty()) {
+ throw cardInvalid("鎻愪氦鍓嶈濉啓蹇呭~瀛楁锛? + String.join("銆?, missing));
+ }
+
+ OpportunitySchemeCard change = new OpportunitySchemeCard();
+ change.setId(card.getId());
+ change.setCardStatus(OpportunityConstants.CARD_STATUS_SUBMITTED);
+ cardMapper.updateById(change);
+ }
+
+ // ==================== 鍐呴儴宸ュ叿 ====================
+
+ private Opportunity getOppOrThrow(Long oppId) {
+ Opportunity opp = oppMapper.selectById(oppId);
+ if (opp == null) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_NOT_EXIST, "鍟嗘満涓嶅瓨鍦ㄦ垨宸茶鍒犻櫎");
+ }
+ return opp;
+ }
+
+ private OpportunitySchemeCard getCardByIdOrThrow(Long cardId) {
+ OpportunitySchemeCard card = cardMapper.selectById(cardId);
+ if (card == null) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_NOT_EXIST,
+ "鏂规鍗′笉瀛樺湪鎴栧凡琚垹闄?);
+ }
+ return card;
+ }
+
+ /** 鍟嗘満涓嬪叧鑱斿鎴?customerId 鈫?鍚嶅揩鐓?*/
+ private Map<Long, String> customerNameMap(Long oppId) {
+ return customerMapper.selectList(new LambdaQueryWrapper<OpportunityCustomer>()
+ .eq(OpportunityCustomer::getOpportunityId, oppId))
+ .stream().collect(Collectors.toMap(OpportunityCustomer::getCustomerId,
+ OpportunityCustomer::getCustomerNameSnapshot, (first, dup) -> first));
+ }
+
+ /** 缁戝畾妯℃澘鐗堟湰鐨勬樉绀哄瓧娈甸」锛堟寜搴忥紱闅愯棌椤逛笉鍑虹幇鍦ㄥ~鍐欓〉锛?*/
+ private List<OpportunitySchemeCardField> visibleFieldsOf(Long templateVersionId) {
+ return schemeTemplateService.listFieldsOfVersion(templateVersionId).stream()
+ .filter(f -> f.getIsVisible() != null
+ && f.getIsVisible() == OpportunityRuleConstants.FLAG_YES)
+ .collect(Collectors.toList());
+ }
+
+ /** 瀛楁鏉$洰鍙栧€硷細缁撴瀯鍖栧瓧娈电敱涓昏〃鍥炲~锛屽叾浣欏彇鍊艰〃 */
+ private String resolveFieldValue(OpportunitySchemeCard card, String fieldKey,
+ Map<String, String> values) {
+ return switch (fieldKey) {
+ case FIELD_KEY_BIDDING_FORM -> card.getBiddingForm();
+ case FIELD_KEY_ENTER_STAGE -> card.getEnterStage();
+ case FIELD_KEY_SCHEME_BUDGET -> card.getSchemeBudget() == null
+ ? null : card.getSchemeBudget().toPlainString();
+ default -> values.get(fieldKey);
+ };
+ }
+
+ /** 鍊艰〃鏌ヨ 鈫?fieldKey 鏄犲皠锛堢┖鍊奸槻寰℃€ц烦杩囷級 */
+ private Map<String, String> valueMapOf(Long cardId) {
+ return valueMapper.selectList(new LambdaQueryWrapper<OpportunitySchemeCardValue>()
+ .eq(OpportunitySchemeCardValue::getCardId, cardId))
+ .stream().filter(v -> v.getFieldKey() != null)
+ .collect(Collectors.toMap(OpportunitySchemeCardValue::getFieldKey,
+ v -> v.getFieldValue() == null ? "" : v.getFieldValue(),
+ (first, dup) -> first));
+ }
+
+ /** 鍏ュ弬鍊艰鑼冨寲锛氭护绌洪」銆佸瓧娈甸敭闈炵┖銆佹嫆缁濈粨鏋勫寲瀛楁閿笌閲嶅閿?*/
+ private List<SchemeCardSaveDTO.FieldValue> normalizeValues(List<SchemeCardSaveDTO.FieldValue> values) {
+ if (CollUtil.isEmpty(values)) {
+ return List.of();
+ }
+ Set<String> seen = new HashSet<>();
+ List<SchemeCardSaveDTO.FieldValue> result = new ArrayList<>();
+ for (SchemeCardSaveDTO.FieldValue value : values) {
+ if (value == null || StrUtil.isBlank(value.getFieldKey())) {
+ throw cardInvalid("瀛楁鍊肩殑瀛楁閿笉鑳戒负绌?);
+ }
+ String key = value.getFieldKey();
+ if (FIELD_KEY_BIDDING_FORM.equals(key) || FIELD_KEY_ENTER_STAGE.equals(key)
+ || FIELD_KEY_SCHEME_BUDGET.equals(key)) {
+ throw cardInvalid("缁撴瀯鍖栧瓧娈碉紙鎷涙爣褰㈠紡/浠嬪叆闃舵/鏂规棰勭畻閲戦锛夎濉富琛ㄥ瓧娈碉紝鍕垮叆鍔ㄦ€佸瓧娈靛€?);
+ }
+ if (!seen.add(key)) {
+ throw cardInvalid("瀛楁鍊奸噸澶嶄笂閫侊細" + key);
+ }
+ result.add(value);
+ }
+ return result;
+ }
+
+ /** 鍊兼牎楠岋細瀛楁閿』灞炵粦瀹氭ā鏉跨増鏈殑**鏄剧ず**瀛楁锛堥殣钘忓瓧娈典笉鍙~锛屾ā鏉块攣鐗堟湰闃叉紓绉伙級 */
+ private void validateValuesAgainstTemplate(Long templateVersionId,
+ List<SchemeCardSaveDTO.FieldValue> values) {
+ if (values.isEmpty()) {
+ return;
+ }
+ Set<String> visibleKeys = visibleFieldsOf(templateVersionId).stream()
+ .map(OpportunitySchemeCardField::getFieldKey).collect(Collectors.toSet());
+ for (SchemeCardSaveDTO.FieldValue value : values) {
+ if (!visibleKeys.contains(value.getFieldKey())) {
+ throw cardInvalid("瀛楁涓嶅睘浜庣粦瀹氭ā鏉跨増鏈殑鏄剧ず瀛楁锛? + value.getFieldKey());
+ }
+ }
+ }
+
+ /** 鍊艰〃鍏ㄩ噺鏇挎崲锛堝厛鍒犲悗鎻掞紱绌虹櫧鍊间笉钀藉簱=娓呯┖璇箟锛?*/
+ private void replaceValues(Long cardId, List<SchemeCardSaveDTO.FieldValue> values) {
+ valueMapper.delete(new LambdaQueryWrapper<OpportunitySchemeCardValue>()
+ .eq(OpportunitySchemeCardValue::getCardId, cardId));
+ for (SchemeCardSaveDTO.FieldValue value : values) {
+ if (StrUtil.isBlank(value.getFieldValue())) {
+ continue;
+ }
+ OpportunitySchemeCardValue row = new OpportunitySchemeCardValue();
+ row.setCardId(cardId);
+ row.setFieldKey(value.getFieldKey());
+ row.setFieldValue(value.getFieldValue());
+ valueMapper.insert(row);
+ }
+ }
+
+ private BusinessErrorException cardInvalid(String message) {
+ return new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_INVALID, message);
+ }
+}
diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/schemecard/impl/OpportunitySchemeCardServiceImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/schemecard/impl/OpportunitySchemeCardServiceImplTest.java
new file mode 100644
index 0000000..aa55813
--- /dev/null
+++ b/crm-opportunity/src/test/java/com/crm/opportunity/schemecard/impl/OpportunitySchemeCardServiceImplTest.java
@@ -0,0 +1,480 @@
+package com.crm.opportunity.schemecard.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.crm.base.domain.exception.BusinessErrorException;
+import com.crm.opportunity.constant.OpportunityConstants;
+import com.crm.opportunity.domain.dto.SchemeCardDTO;
+import com.crm.opportunity.domain.dto.SchemeCardDetailDTO;
+import com.crm.opportunity.domain.dto.SchemeCardSaveDTO;
+import com.crm.opportunity.domain.entity.Opportunity;
+import com.crm.opportunity.domain.entity.OpportunityCustomer;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCardValue;
+import com.crm.opportunity.mapper.OpportunityCustomerMapper;
+import com.crm.opportunity.mapper.OpportunityMapper;
+import com.crm.opportunity.mapper.OpportunitySchemeCardMapper;
+import com.crm.opportunity.mapper.OpportunitySchemeCardValueMapper;
+import com.crm.rule.constant.OpportunityRuleConstants;
+import com.crm.rule.domain.entity.OpportunitySchemeCardField;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate;
+import com.crm.rule.service.IOpportunitySchemeCardTemplateService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 鏂规鍗¤繍琛屾椂鏈嶅姟瑙勬牸楠岃瘉锛堢エ 07銆屼笁銆? 鍘熷瀷 A3-1-1-1-3锛夈€?+ *
+ * <p>鏂█鐐癸細</p>
+ * <ul>
+ * <li>纭害鏉燂細鏃犲绔嬪崱锛堝鎴峰繀灞炴湰鍟嗘満鍏宠仈瀹㈡埛锛夈€佷竴瀹㈡埛涓€鍗★紙66009锛夈€?+ * 鏂板缓閿佹渶鏂板彂甯冩ā鏉跨増鏈紙鏃犳ā鏉挎嫆缁濆缓鍗★級銆佺紪杈戠画鐢ㄩ攣瀹氱増鏈笖瀹㈡埛涓嶅彲鏇存崲銆?+ * 宸叉帹閫佹暣鍗″彧璇伙紙66010锛屽彲缂栬緫=card_status<3 鎷嶆澘 P1-1锛?/li>
+ * <li>鍔ㄦ€佸瓧娈靛€硷細鍏ㄩ噺鏇挎崲銆佸瓧娈甸敭椤诲睘缁戝畾妯℃澘鐗堟湰鏄剧ず瀛楁銆佺粨鏋勫寲瀛楁閿嫆鍏ュ€艰〃銆?+ * 绌虹櫧鍊间笉钀藉簱锛涜鎯呯粨鏋勫寲瀛楁鐢变富琛ㄥ洖濉?/li>
+ * <li>鎻愪氦锛氬繀濉牎楠岋紙鍚粨鏋勫寲涓昏〃瀛楁锛夈€佽崏绋库啋宸叉彁浜ゃ€佸凡鎻愪氦骞傜瓑銆佸凡鎺ㄩ€佹嫆缁?/li>
+ * <li>鍒楄〃锛? 鍗?銆屾湭鍒涘缓銆嶅睍绀烘€侊紙绌哄垪琛級銆佸鎴峰悕蹇収鍥炴樉銆乪ditable 娲剧敓</li>
+ * </ul>
+ */
+@DisplayName("鏂规鍗¤繍琛屾椂鏈嶅姟锛堢エ 07銆屼笁銆嶏級")
+@ExtendWith(MockitoExtension.class)
+class OpportunitySchemeCardServiceImplTest {
+
+ private static final Long OPP_ID = 8101L;
+ private static final Long CUSTOMER_ID = 901L;
+ private static final Long OTHER_CUSTOMER_ID = 902L;
+ private static final Long CARD_ID = 8201L;
+ private static final Long TPL_ID = 7301L;
+ private static final Long DEPT_10 = 5001L;
+
+ @Mock private OpportunityMapper oppMapper;
+ @Mock private OpportunityCustomerMapper customerMapper;
+ @Mock private OpportunitySchemeCardMapper cardMapper;
+ @Mock private OpportunitySchemeCardValueMapper valueMapper;
+ @Mock private IOpportunitySchemeCardTemplateService schemeTemplateService;
+
+ @InjectMocks
+ private OpportunitySchemeCardServiceImpl service;
+
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant =
+ new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, Opportunity.class);
+ TableInfoHelper.initTableInfo(assistant, OpportunityCustomer.class);
+ TableInfoHelper.initTableInfo(assistant, OpportunitySchemeCard.class);
+ TableInfoHelper.initTableInfo(assistant, OpportunitySchemeCardValue.class);
+ }
+
+ // ==================== 宸ュ巶 ====================
+
+ private Opportunity opp() {
+ Opportunity opp = new Opportunity();
+ opp.setId(OPP_ID);
+ opp.setOwnerDeptId(DEPT_10);
+ opp.setOppStatus(2);
+ return opp;
+ }
+
+ private OpportunityCustomer customerRow(Long customerId, String name, int primary) {
+ OpportunityCustomer c = new OpportunityCustomer();
+ c.setOpportunityId(OPP_ID);
+ c.setCustomerId(customerId);
+ c.setCustomerNameSnapshot(name);
+ c.setIsPrimaryIntended(primary);
+ return c;
+ }
+
+ private OpportunitySchemeCard card(Long id, Long customerId, int status) {
+ OpportunitySchemeCard card = new OpportunitySchemeCard();
+ card.setId(id);
+ card.setOppId(OPP_ID);
+ card.setCustomerId(customerId);
+ card.setTemplateId(TPL_ID);
+ card.setTemplateVersion("V1.0");
+ card.setCardStatus(status);
+ return card;
+ }
+
+ private OpportunitySchemeCardTemplate template() {
+ OpportunitySchemeCardTemplate tpl = new OpportunitySchemeCardTemplate();
+ tpl.setId(TPL_ID);
+ tpl.setVersionNo("V1.0");
+ tpl.setStatus(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ return tpl;
+ }
+
+ private OpportunitySchemeCardField tplField(int seqNo, String key, int visible, int required) {
+ OpportunitySchemeCardField f = new OpportunitySchemeCardField();
+ f.setTemplateId(TPL_ID);
+ f.setSeqNo(seqNo);
+ f.setFieldKey(key);
+ f.setFieldName("瀛楁" + key);
+ f.setFieldType(OpportunityRuleConstants.FIELD_TYPE_TEXT_SINGLE);
+ f.setIsVisible(visible);
+ f.setIsRequired(required);
+ return f;
+ }
+
+ private SchemeCardSaveDTO saveDto(Long id) {
+ SchemeCardSaveDTO dto = new SchemeCardSaveDTO();
+ dto.setId(id);
+ dto.setOppId(OPP_ID);
+ dto.setCustomerId(CUSTOMER_ID);
+ dto.setSchemeBudget(new BigDecimal("120000.00"));
+ dto.setBiddingForm("BID_OPEN");
+ dto.setEnterStage("STAGE_DESIGN");
+ dto.setValues(List.of(value("customer_requirement", "闇€瑕佷竴濂楁櫤鑳芥ゼ瀹囨柟妗?)));
+ return dto;
+ }
+
+ private SchemeCardSaveDTO.FieldValue value(String key, String val) {
+ SchemeCardSaveDTO.FieldValue v = new SchemeCardSaveDTO.FieldValue();
+ v.setFieldKey(key);
+ v.setFieldValue(val);
+ return v;
+ }
+
+ /** 榛樿妯℃澘瀛楁妗╋細鏄剧ず瀛楁 = 闇€姹傛弿杩?蹇呭~) + 鐪嬬幇鍦?閫夊~) + 缁撴瀯鍖栦袱閿紱鍙︽湁涓€涓殣钘忓瓧娈?*/
+ private void stubTemplateFields() {
+ lenient().when(schemeTemplateService.listFieldsOfVersion(TPL_ID)).thenReturn(List.of(
+ tplField(1, "bidding_form", 1, 1),
+ tplField(2, "customer_requirement", 1, 1),
+ tplField(3, "site_visit", 1, 0),
+ tplField(4, "hidden_field", 0, 1)));
+ }
+
+ // ==================== 鍒楄〃 ====================
+
+ @Test
+ @DisplayName("鍒楄〃锛氬晢鏈轰笉瀛樺湪 鈫?66002")
+ void listRequiresOpp() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(null);
+ assertThatThrownBy(() -> service.listCards(OPP_ID))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_OPP_NOT_EXIST);
+ }
+
+ @Test
+ @DisplayName("鍒楄〃锛? 鍗?=銆屾湭鍒涘缓銆嶅睍绀烘€?鈫?绌哄垪琛?)
+ void zeroCardsMeansEmptyList() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(cardMapper.selectList(any())).thenReturn(List.of());
+ assertThat(service.listCards(OPP_ID)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("鍒楄〃锛氬鍗″洖鏄惧鎴峰悕蹇収 + editable 娲剧敓锛?3 鍙紪杈戯級")
+ void listShowsCustomerNameAndEditable() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(cardMapper.selectList(any())).thenReturn(List.of(
+ card(CARD_ID, CUSTOMER_ID, OpportunityConstants.CARD_STATUS_DRAFT),
+ card(8202L, OTHER_CUSTOMER_ID, OpportunityConstants.CARD_STATUS_PUSHED)));
+ when(customerMapper.selectList(any())).thenReturn(List.of(
+ customerRow(CUSTOMER_ID, "鐢插鎴?, 1),
+ customerRow(OTHER_CUSTOMER_ID, "涔欏鎴?, 0)));
+
+ List<SchemeCardDTO> list = service.listCards(OPP_ID);
+ assertThat(list).hasSize(2);
+ assertThat(list.get(0).getCustomerNameSnapshot()).isEqualTo("鐢插鎴?);
+ assertThat(list.get(0).getEditable()).isTrue();
+ assertThat(list.get(1).getEditable()).isFalse();
+ }
+
+ // ==================== 璇︽儏 ====================
+
+ @Test
+ @DisplayName("璇︽儏锛氬崱涓嶅瓨鍦?鈫?66008")
+ void detailRequiresCard() {
+ when(cardMapper.selectById(CARD_ID)).thenReturn(null);
+ assertThatThrownBy(() -> service.getCardDetail(CARD_ID))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_NOT_EXIST);
+ }
+
+ @Test
+ @DisplayName("璇︽儏锛氫粎鏄剧ず瀛楁鏈夊簭鍥炴樉 + 鍊艰閰?+ 缁撴瀯鍖栧瓧娈典富琛ㄥ洖濉?)
+ void detailAssemblesFieldsAndValues() {
+ OpportunitySchemeCard card = card(CARD_ID, CUSTOMER_ID,
+ OpportunityConstants.CARD_STATUS_SUBMITTED);
+ card.setSchemeBudget(new BigDecimal("88000.00"));
+ card.setBiddingForm("BID_INVITE");
+ when(cardMapper.selectById(CARD_ID)).thenReturn(card);
+ when(customerMapper.selectList(any())).thenReturn(
+ List.of(customerRow(CUSTOMER_ID, "鐢插鎴?, 1)));
+ stubTemplateFields();
+ when(valueMapper.selectList(any())).thenReturn(List.of(
+ valueRow("customer_requirement", "鏃у€?)));
+
+ SchemeCardDetailDTO detail = service.getCardDetail(CARD_ID);
+ // 闅愯棌瀛楁涓嶅嚭鐜帮紱鏄剧ず瀛楁鎸夊簭
+ assertThat(detail.getFields()).extracting(SchemeCardDetailDTO.FieldEntry::getFieldKey)
+ .containsExactly("bidding_form", "customer_requirement", "site_visit");
+ assertThat(detail.getFields().get(0).getFieldValue()).isEqualTo("BID_INVITE"); // 涓昏〃鍥炲~
+ assertThat(detail.getFields().get(1).getFieldValue()).isEqualTo("鏃у€?);
+ assertThat(detail.getSchemeBudget()).isEqualByComparingTo("88000.00");
+ assertThat(detail.getEditable()).isTrue();
+ }
+
+ // ==================== 淇濆瓨锛堟柊寤猴級 ====================
+
+ @Test
+ @DisplayName("鏂板缓锛氬弬鏁扮己澶?鈫?66007")
+ void saveRequiresParams() {
+ assertThatThrownBy(() -> service.saveCard(null))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ SchemeCardSaveDTO dto = saveDto(null);
+ dto.setCustomerId(null);
+ assertThatThrownBy(() -> service.saveCard(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ }
+
+ @Test
+ @DisplayName("鏂板缓锛氬鎴蜂笉灞炴湰鍟嗘満鍏宠仈瀹㈡埛锛堟棤瀛ょ珛鍗★級鈫?66007")
+ void saveRejectsForeignCustomer() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(null);
+ assertThatThrownBy(() -> service.saveCard(saveDto(null)))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ verify(cardMapper, never()).insert(any(OpportunitySchemeCard.class));
+ }
+
+ @Test
+ @DisplayName("鏂板缓锛氫竴瀹㈡埛涓€鍗?鈫?宸叉湁鍗℃嫆缁濓紙66009锛?)
+ void saveRejectsDuplicatePerCustomer() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, "鐢插鎴?, 1));
+ when(cardMapper.selectOne(any())).thenReturn(
+ card(CARD_ID, CUSTOMER_ID, OpportunityConstants.CARD_STATUS_DRAFT));
+ assertThatThrownBy(() -> service.saveCard(saveDto(null)))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_DUPLICATE);
+ }
+
+ @Test
+ @DisplayName("鏂板缓锛氭棤鍙戝竷涓ā鏉?鈫?鎷掔粷寤哄崱锛?6007锛?)
+ void saveRejectsWithoutPublishedTemplate() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, "鐢插鎴?, 1));
+ when(cardMapper.selectOne(any())).thenReturn(null);
+ when(schemeTemplateService.resolveBindingVersion(DEPT_10)).thenReturn(null);
+ assertThatThrownBy(() -> service.saveCard(saveDto(null)))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ }
+
+ @Test
+ @DisplayName("鏂板缓锛氶攣鏈€鏂板彂甯冩ā鏉跨増鏈?+ 鑽夌鎬?+ 缁撴瀯鍖栧瓧娈佃惤涓昏〃 + 鍊艰惤搴?)
+ void createCardLocksTemplateVersion() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, "鐢插鎴?, 1));
+ when(cardMapper.selectOne(any())).thenReturn(null);
+ when(schemeTemplateService.resolveBindingVersion(DEPT_10)).thenReturn(template());
+ stubTemplateFields();
+ doAnswer(inv -> {
+ OpportunitySchemeCard c = inv.getArgument(0);
+ c.setId(CARD_ID);
+ return 1;
+ }).when(cardMapper).insert(any(OpportunitySchemeCard.class));
+
+ Long cardId = service.saveCard(saveDto(null));
+ assertThat(cardId).isEqualTo(CARD_ID);
+
+ ArgumentCaptor<OpportunitySchemeCard> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCard.class);
+ verify(cardMapper).insert(captor.capture());
+ OpportunitySchemeCard saved = captor.getValue();
+ assertThat(saved.getTemplateId()).isEqualTo(TPL_ID);
+ assertThat(saved.getTemplateVersion()).isEqualTo("V1.0");
+ assertThat(saved.getCardStatus()).isEqualTo(OpportunityConstants.CARD_STATUS_DRAFT);
+ assertThat(saved.getSchemeBudget()).isEqualByComparingTo("120000.00");
+ assertThat(saved.getBiddingForm()).isEqualTo("BID_OPEN");
+ assertThat(saved.getEnterStage()).isEqualTo("STAGE_DESIGN");
+
+ ArgumentCaptor<OpportunitySchemeCardValue> valueCaptor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardValue.class);
+ verify(valueMapper).insert(valueCaptor.capture());
+ assertThat(valueCaptor.getValue().getFieldKey()).isEqualTo("customer_requirement");
+ }
+
+ @Test
+ @DisplayName("鏂板缓锛氬€间笂閫佺粨鏋勫寲瀛楁閿?鈫?鎷掔粷锛?6007锛?)
+ void saveRejectsStructuredKeysInValues() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, "鐢插鎴?, 1));
+ SchemeCardSaveDTO dto = saveDto(null);
+ dto.setValues(List.of(value("scheme_budget", "999")));
+ assertThatThrownBy(() -> service.saveCard(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ }
+
+ @Test
+ @DisplayName("鏂板缓锛氬€煎瓧娈甸敭涓嶅睘缁戝畾妯℃澘鏄剧ず瀛楁锛堝惈闅愯棌瀛楁锛夆啋 鎷掔粷锛?6007锛?)
+ void saveRejectsUnknownFieldKey() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, "鐢插鎴?, 1));
+ when(cardMapper.selectOne(any())).thenReturn(null);
+ when(schemeTemplateService.resolveBindingVersion(DEPT_10)).thenReturn(template());
+ stubTemplateFields();
+
+ SchemeCardSaveDTO dto = saveDto(null);
+ dto.setValues(List.of(value("hidden_field", "x"))); // 闅愯棌瀛楁涓嶅彲濉?+ assertThatThrownBy(() -> service.saveCard(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+
+ dto.setValues(List.of(value("not_in_template", "x")));
+ assertThatThrownBy(() -> service.saveCard(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ }
+
+ // ==================== 淇濆瓨锛堢紪杈戯級 ====================
+
+ @Test
+ @DisplayName("缂栬緫锛氬崱涓嶅瓨鍦?鈫?66008锛涘凡鎺ㄩ€佹暣鍗″彧璇?鈫?66010")
+ void editGuardsExistenceAndPushed() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, "鐢插鎴?, 1));
+ when(cardMapper.selectById(CARD_ID)).thenReturn(null);
+ assertThatThrownBy(() -> service.saveCard(saveDto(CARD_ID)))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_NOT_EXIST);
+
+ when(cardMapper.selectById(CARD_ID)).thenReturn(
+ card(CARD_ID, CUSTOMER_ID, OpportunityConstants.CARD_STATUS_PUSHED));
+ assertThatThrownBy(() -> service.saveCard(saveDto(CARD_ID)))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_READONLY);
+ }
+
+ @Test
+ @DisplayName("缂栬緫锛氬叧鑱斿鎴蜂笉鍙洿鎹紙涓€瀹㈡埛涓€鍗¢敋鐐癸級鈫?66007")
+ void editCannotChangeCustomer() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(OTHER_CUSTOMER_ID, "涔欏鎴?, 0));
+ when(cardMapper.selectById(CARD_ID)).thenReturn(
+ card(CARD_ID, CUSTOMER_ID, OpportunityConstants.CARD_STATUS_DRAFT));
+ SchemeCardSaveDTO dto = saveDto(CARD_ID);
+ dto.setCustomerId(OTHER_CUSTOMER_ID);
+ assertThatThrownBy(() -> service.saveCard(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ }
+
+ @Test
+ @DisplayName("缂栬緫锛氬凡鎻愪氦鍗″彲鏀逛笖鐘舵€佷笉鍥為€€锛堟媿鏉?P1-1锛夛紱鍊煎叏閲忔浛鎹€佺┖鐧藉€间笉钀藉簱")
+ void editSubmittedKeepsStatusAndReplacesValues() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, "鐢插鎴?, 1));
+ when(cardMapper.selectById(CARD_ID)).thenReturn(
+ card(CARD_ID, CUSTOMER_ID, OpportunityConstants.CARD_STATUS_SUBMITTED));
+ stubTemplateFields();
+
+ SchemeCardSaveDTO dto = saveDto(CARD_ID);
+ dto.setValues(List.of(value("site_visit", "鏄?), value("customer_requirement", " ")));
+ Long cardId = service.saveCard(dto);
+ assertThat(cardId).isEqualTo(CARD_ID);
+
+ // 浠呮洿鏂扮粨鏋勫寲涓昏〃鍒楋紝鐘舵€佷笉鍥為€€锛堟棤 cardStatus 缃綅锛?+ ArgumentCaptor<OpportunitySchemeCard> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCard.class);
+ verify(cardMapper).updateById(captor.capture());
+ assertThat(captor.getValue().getCardStatus()).isNull();
+ // 鍊艰〃鍏堝垹鍚庢彃锛涚┖鐧藉€间笉钀藉簱
+ verify(valueMapper).delete(any());
+ ArgumentCaptor<OpportunitySchemeCardValue> valueCaptor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardValue.class);
+ verify(valueMapper, times(1)).insert(valueCaptor.capture());
+ assertThat(valueCaptor.getValue().getFieldKey()).isEqualTo("site_visit");
+ }
+
+ // ==================== 鎻愪氦 ====================
+
+ @Test
+ @DisplayName("鎻愪氦锛氬繀濉己澶?鈫?66007锛堢粨鏋勫寲瀛楁鍙備笌蹇呭~鏍¢獙锛?)
+ void submitValidatesRequired() {
+ OpportunitySchemeCard card = card(CARD_ID, CUSTOMER_ID,
+ OpportunityConstants.CARD_STATUS_DRAFT);
+ card.setBiddingForm(null); // 蹇呭~缁撴瀯鍖栧瓧娈垫湭濉?+ when(cardMapper.selectById(CARD_ID)).thenReturn(card);
+ stubTemplateFields();
+ when(valueMapper.selectList(any())).thenReturn(List.of(
+ valueRow("customer_requirement", "宸插~")));
+
+ assertThatThrownBy(() -> service.submitCard(CARD_ID, 1L))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+ verify(cardMapper, never()).updateById(any(OpportunitySchemeCard.class));
+ }
+
+ @Test
+ @DisplayName("鎻愪氦锛氳崏绋?鈫?宸叉彁浜わ紙蹇呭~榻愬叏锛?)
+ void submitDraftToSubmitted() {
+ OpportunitySchemeCard card = card(CARD_ID, CUSTOMER_ID,
+ OpportunityConstants.CARD_STATUS_DRAFT);
+ card.setBiddingForm("BID_OPEN");
+ when(cardMapper.selectById(CARD_ID)).thenReturn(card);
+ stubTemplateFields();
+ when(valueMapper.selectList(any())).thenReturn(List.of(
+ valueRow("customer_requirement", "宸插~")));
+
+ service.submitCard(CARD_ID, 1L);
+ ArgumentCaptor<OpportunitySchemeCard> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCard.class);
+ verify(cardMapper).updateById(captor.capture());
+ assertThat(captor.getValue().getCardStatus())
+ .isEqualTo(OpportunityConstants.CARD_STATUS_SUBMITTED);
+ }
+
+ @Test
+ @DisplayName("鎻愪氦锛氬凡鎻愪氦骞傜瓑锛涘凡鎺ㄩ€佹嫆缁濓紙66010锛夛紱鍙傛暟缂哄け 鈫?66007")
+ void submitEdgeCases() {
+ assertThatThrownBy(() -> service.submitCard(null, 1L))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_INVALID);
+
+ when(cardMapper.selectById(CARD_ID)).thenReturn(
+ card(CARD_ID, CUSTOMER_ID, OpportunityConstants.CARD_STATUS_SUBMITTED));
+ service.submitCard(CARD_ID, 1L);
+ verify(cardMapper, never()).updateById(any(OpportunitySchemeCard.class)); // 骞傜瓑鏃犳搷浣?+
+ when(cardMapper.selectById(CARD_ID)).thenReturn(
+ card(CARD_ID, CUSTOMER_ID, OpportunityConstants.CARD_STATUS_PUSHED));
+ assertThatThrownBy(() -> service.submitCard(CARD_ID, 1L))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityConstants.CODE_SCHEME_CARD_READONLY);
+ }
+
+ private OpportunitySchemeCardValue valueRow(String key, String val) {
+ OpportunitySchemeCardValue v = new OpportunitySchemeCardValue();
+ v.setCardId(CARD_ID);
+ v.setFieldKey(key);
+ v.setFieldValue(val);
+ return v;
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/config/OpportunitySchemeCardTemplatePermissionInitializer.java b/crm-rule/src/main/java/com/crm/rule/config/OpportunitySchemeCardTemplatePermissionInitializer.java
new file mode 100644
index 0000000..ab2e5f5
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/config/OpportunitySchemeCardTemplatePermissionInitializer.java
@@ -0,0 +1,43 @@
+package com.crm.rule.config;
+
+import com.crm.base.domain.dto.PermissionModuleDescriptor;
+import com.crm.base.service.PermissionSeeder;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 鏂规鍗℃ā鏉挎潈闄愮瀛愬寲锛堜粎鑿滃崟锛岀エ 07銆屼簩銆嶏級
+ * <p>涓?{@link OpportunityStageTemplatePermissionInitializer}锛堥樁娈垫ā鏉匡級鍒嗗垪锛氬悓灞炲晢鏈鸿鍒欏瓙鍩燂紝
+ * 鑿滃崟鎸傘€屽晢鏈虹鐞嗐€嶇洰褰曪紙鍘熷瀷 A7-3-2-2 鏂规鍗℃ā鏉胯缃級銆傚悓绛栫暐涓嶇瀛愬寲 button锛?+ * ApiPermissionInterceptor 瀵规湭娉ㄥ唽 URL fail-open銆?/p>
+ */
+@Slf4j
+@Component
+@Order(16)
+@RequiredArgsConstructor
+public class OpportunitySchemeCardTemplatePermissionInitializer implements CommandLineRunner {
+
+ private final PermissionSeeder permissionSeeder;
+
+ @Override
+ public void run(String... args) {
+ log.info("鎵ц鏂规鍗℃ā鏉挎潈闄愮瀛愬寲妫€鏌ワ紙浠呰彍鍗曪級...");
+
+ permissionSeeder.seedModule(new PermissionModuleDescriptor(
+ "鍟嗘満绠$悊",
+ "鏂规鍗℃ā鏉胯缃?,
+ "/opportunity/scheme-template",
+ "opportunity/scheme-template/index",
+ 7,
+ List.of(), // 涓嶇瀛愬寲 button锛岀敱绠$悊鍛樺悗缁厤缃?+ List.of("ROLE_ADMIN")
+ ));
+
+ log.info("鏂规鍗℃ā鏉挎潈闄愮瀛愬寲妫€鏌ュ畬鎴愶細浠呰彍鍗曪紙0 涓?button锛?);
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/config/SchemeFieldDefInitializer.java b/crm-rule/src/main/java/com/crm/rule/config/SchemeFieldDefInitializer.java
new file mode 100644
index 0000000..736c9ab
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/config/SchemeFieldDefInitializer.java
@@ -0,0 +1,103 @@
+package com.crm.rule.config;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.crm.rule.constant.OpportunityRuleConstants;
+import com.crm.rule.domain.entity.OpportunitySchemeFieldDef;
+import com.crm.rule.mapper.OpportunitySchemeFieldDefMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 鏂规鍗$郴缁熷瓧娈靛簱绉嶅瓙鍖栵紙绁?07銆?.4銆嶏紝浜у搧/鏋舵瀯鎷嶆澘 P0-3 鑷缓瀛楁鍏冩暟鎹級
+ *
+ * <p>骞傜瓑锛氭寜 {@code field_key} 鍒ら噸锛岀己鍟ヨˉ鍟ャ€佷笉鍒犱笉鏀规棦鏈夎锛堝瓧娈靛悕/绫诲瀷濡傞渶璋冩暣
+ * 璧版暟鎹淮鎶わ紝涓嶅湪鍚姩绉嶅瓙鍖栬寖鍥达級銆傚瓧娈靛悕涓哄師鍨嬫牱渚嬪€硷紙闈為獙鏀跺€硷級銆?/p>
+ *
+ * <p>銆屽崟閫変笅鎷夈€嶇被瀛楁鐨勯€夐」闆嗚蛋 {@code option_source} 寮?crm-dict 鍒嗙粍 code
+ * 锛堢粍鍚堣€岄潪浜岄€変竴锛岀エ 07銆?.4銆嶏級锛涘瓧鍏稿垎缁勬湰韬敱瀛楀吀鍩熺瀛愬寲/杩愯惀缁存姢锛屾湰澶勪粎鐧昏寮曠敤璇箟銆?/p>
+ */
+@Slf4j
+@Component
+@Order(15)
+@RequiredArgsConstructor
+public class SchemeFieldDefInitializer implements CommandLineRunner {
+
+ private final OpportunitySchemeFieldDefMapper fieldDefMapper;
+
+ @Override
+ public void run(String... args) {
+ log.info("鎵ц鏂规鍗$郴缁熷瓧娈靛簱绉嶅瓙鍖栨鏌?..");
+
+ Set<String> existingKeys = fieldDefMapper.selectList(
+ new LambdaQueryWrapper<OpportunitySchemeFieldDef>())
+ .stream().map(OpportunitySchemeFieldDef::getFieldKey).collect(Collectors.toSet());
+
+ int inserted = 0;
+ for (OpportunitySchemeFieldDef def : sampleDefs()) {
+ if (existingKeys.contains(def.getFieldKey())) {
+ continue;
+ }
+ fieldDefMapper.insert(def);
+ inserted++;
+ }
+ log.info("鏂规鍗$郴缁熷瓧娈靛簱绉嶅瓙鍖栨鏌ュ畬鎴愶細鐜板瓨 {} 涓紝鏂板 {} 涓?,
+ existingKeys.size() + inserted, inserted);
+ }
+
+ /** 鏍蜂緥瀛楁闆嗭紙绁?07銆?.4銆嶅師鍨嬪垪涓撅紱瀹㈡埛鍚嶇О/鏂规棰勭畻閲戦涓哄唴缃笉鍙垹锛屽叾浣欏彲鍒狅級 */
+ private List<OpportunitySchemeFieldDef> sampleDefs() {
+ return List.of(
+ def("customer_name", "瀹㈡埛鍚嶇О", OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT,
+ null, "customer", null, null, 0),
+ def("customer_type", "瀹㈡埛绫诲瀷", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "customer_type", null, null, null, 1),
+ def("customer_contact", "瀹㈡埛鑱旂郴浜?, OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT,
+ null, "contact", null, null, 1),
+ def("customer_phone", "瀹㈡埛鑱旂郴鐢佃瘽", OpportunityRuleConstants.FIELD_TYPE_AUTOFILL,
+ null, null, "customer_contact.phone", null, 1),
+ def("project_stage", "椤圭洰闃舵", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "project_stage", null, null, null, 1),
+ def("bidding_form", "鎷涙爣褰㈠紡", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "bidding_form", null, null, null, 1),
+ def("site_visit", "鏄惁鏈夌湅鐜板満", OpportunityRuleConstants.FIELD_TYPE_YES_NO,
+ null, null, null, null, 1),
+ def("budget_clear", "鏂规棰勭畻鏄惁鏄庣‘", OpportunityRuleConstants.FIELD_TYPE_YES_NO,
+ null, null, null, null, 1),
+ def("scheme_budget", "鏂规棰勭畻閲戦", OpportunityRuleConstants.FIELD_TYPE_AMOUNT,
+ null, null, null, null, 0),
+ def("design_config", "璁捐閰嶇疆", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "design_config", null, null, null, 1),
+ def("brand_requirement", "鍝佺墝瑕佹眰", OpportunityRuleConstants.FIELD_TYPE_TEXT_SINGLE,
+ null, null, null, null, 1),
+ def("customer_requirement", "瀹㈡埛闇€姹傛弿杩?, OpportunityRuleConstants.FIELD_TYPE_TEXT_MULTI,
+ null, null, null, null, 1),
+ def("expected_deal_time", "棰勮鎴愪氦鏃堕棿", OpportunityRuleConstants.FIELD_TYPE_DATE,
+ null, null, null, null, 1),
+ def("deal_probability", "棰勮鎴愪氦姒傜巼", OpportunityRuleConstants.FIELD_TYPE_TEXT_SINGLE,
+ null, null, null, null, 1),
+ def("expected_bid_time", "棰勮鎶曟爣鏃堕棿", OpportunityRuleConstants.FIELD_TYPE_DATE,
+ null, null, null, null, 1));
+ }
+
+ private OpportunitySchemeFieldDef def(String fieldKey, String fieldName, String fieldType,
+ String optionSource, String refTarget, String autofillSource,
+ String defaultValue, int isDeletable) {
+ OpportunitySchemeFieldDef fieldDef = new OpportunitySchemeFieldDef();
+ fieldDef.setFieldKey(fieldKey);
+ fieldDef.setFieldName(fieldName);
+ fieldDef.setFieldType(fieldType);
+ fieldDef.setOptionSource(optionSource);
+ fieldDef.setRefTarget(refTarget);
+ fieldDef.setAutofillSource(autofillSource);
+ fieldDef.setDefaultValue(defaultValue);
+ fieldDef.setIsDeletable(isDeletable);
+ return fieldDef;
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java b/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java
index 2cb7c88..62c9d81 100644
--- a/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java
+++ b/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java
@@ -84,4 +84,47 @@ public interface OpportunityRuleConstants {
/** 鍙戝竷鏍¢獙澶辫触锛氶樁娈佃妭鐐归厤缃笉瀹屾暣锛堟棤鏅€氳妭鐐?瀛楀吀 code 缂哄け锛?*/
int CODE_STAGE_TPL_NODE_INVALID = 64017;
+
+ /*-------- 鏂规鍗℃ā鏉匡紙绁?07锛夛細缂栫爜鍓嶇紑 + 瀛楁绫诲瀷鍊煎煙 ---------*/
+
+ /** 鏂规鍗℃ā鏉跨紪鐮佸墠缂€锛堢郴缁熺敓鎴愶紝搴忓彿椤哄欢锛孷-CONFIG 1锛?*/
+ String SCHEME_TPL_CODE_PREFIX = "OPP_SCHEME_TPL_";
+
+ /* 瀛楁绫诲瀷鏋氫妇锛堝師鍨?A7-3-2-2銆屽瓧娈电被鍨嬨€嶅垪宸叉牳瀹?8 鍊?+ 鏍蜂緥琛ュ厖鏃ユ湡锛岀エ 07銆?.4銆嶏級 */
+
+ /** 鍏宠仈瀵硅薄锛坮ef_target 鎸囧悜鐩爣瀹炰綋锛?*/
+ String FIELD_TYPE_RELATED_OBJECT = "RELATED_OBJECT";
+ /** 鍗曢€変笅鎷夛紙閫夐」闆嗗彲 option_source 寮?crm-dict 鍒嗙粍锛?*/
+ String FIELD_TYPE_SINGLE_SELECT = "SINGLE_SELECT";
+ /** 鑷姩鍥炲~锛坅utofill_source 鎸囨槑鏉ユ簮锛?*/
+ String FIELD_TYPE_AUTOFILL = "AUTOFILL";
+ /** 鏄惁锛堟槸/鍚︼級 */
+ String FIELD_TYPE_YES_NO = "YES_NO";
+ /** 闄勪欢涓婁紶 */
+ String FIELD_TYPE_ATTACHMENT = "ATTACHMENT";
+ /** 閲戦 */
+ String FIELD_TYPE_AMOUNT = "AMOUNT";
+ /** 鍗曡鏂囨湰 */
+ String FIELD_TYPE_TEXT_SINGLE = "TEXT_SINGLE";
+ /** 澶氳鏂囨湰 */
+ String FIELD_TYPE_TEXT_MULTI = "TEXT_MULTI";
+ /** 鏃ユ湡锛堟牱渚嬪瓧娈点€岄璁℃垚浜ゆ椂闂淬€嶇敤锛岀エ 07 浜у搧鎷嶆澘鏂规鍗″唴閮ㄥ瓧娈碉級 */
+ String FIELD_TYPE_DATE = "DATE";
+
+ /*-------- 鏂规鍗℃ā鏉夸笟鍔¢敊璇爜锛堢エ 07锛?4018 璧烽『寤讹級 --------*/
+
+ /** 妯℃澘鍙傛暟/鐘舵€佹牎楠屽け璐?*/
+ int CODE_SCHEME_TPL_INVALID = 64018;
+
+ /** 妯℃澘鐗堟湰涓嶅瓨鍦?*/
+ int CODE_SCHEME_TPL_NOT_EXIST = 64019;
+
+ /** 浠呰崏绋垮彲鍒狅紙鍙戝竷涓€佸凡鍋滅敤涓嶅彲鐩存帴鍒狅級 */
+ int CODE_SCHEME_TPL_NOT_DRAFT = 64020;
+
+ /** 鍚屾ā鏉跨紪鐮佸凡瀛樺湪鑽夌锛堢紪杈戝彂甯冧腑/鍋滅敤鐗堟湰鏃朵笉鍐嶇敓鎴愭柊鑽夌锛?*/
+ int CODE_SCHEME_TPL_DRAFT_EXISTS = 64021;
+
+ /** 瀛楁椤规牎楠屽け璐ワ紙瀛楁涓嶅湪绯荤粺瀛楁搴?閲嶅/鍙戝竷鏃舵棤瀛楁锛?*/
+ int CODE_SCHEME_TPL_FIELD_INVALID = 64022;
}
diff --git a/crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java b/crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java
new file mode 100644
index 0000000..39d30d9
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java
@@ -0,0 +1,85 @@
+package com.crm.rule.controller;
+
+import com.crm.base.domain.result.PageResult;
+import com.crm.base.domain.result.Result;
+import com.crm.rule.domain.dto.OpportunitySchemeCardTemplateDTO;
+import com.crm.rule.domain.dto.OpportunitySchemeFieldDefDTO;
+import com.crm.rule.domain.param.OpportunitySchemeCardTemplatePageParam;
+import com.crm.rule.service.IOpportunitySchemeCardTemplateService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 鏂规鍗℃ā鏉挎帴鍙o紙钖勯€傞厤灞傦紝鍙皟 {@link IOpportunitySchemeCardTemplateService}锛岀エ 07銆屼簩銆嶏級
+ * <p>鏉冮檺绛栫暐锛氫笉绉嶅瓙鍖?button 鏉冮檺鐐癸紙鍚屽叕娴疯鍒?闃舵妯℃澘锛夛紝ApiPermissionInterceptor
+ * 瀵规湭娉ㄥ唽 URL fail-open锛涜鎿嶄綔鐭╅樀锛堣崏绋?璇︽儏路缂栬緫路鍒犻櫎锛屽彂甯冧腑/鍋滅敤=璇︽儏路缂栬緫路澶嶅埗锛?+ * 鐢辨湇鍔″眰鐘舵€佹満鏍¢獙鍏滃簳銆傘€岄瑙堝~鍐欐晥鏋溿€嶄负绾墠绔寜瀛楁閰嶇疆娓叉煋锛屾湰鎺ュ彛涓嶅缓棰勮涓撳睘缁撴瀯锛堢エ 07銆?.5銆嶏級銆?/p>
+ */
+@Tag(name = "鍟嗘満绠$悊/鍟嗘満瑙勫垯")
+@RestController
+@RequestMapping("/api/rule/opp-scheme-template")
+@RequiredArgsConstructor
+public class OpportunitySchemeCardTemplateController {
+
+ private final IOpportunitySchemeCardTemplateService templateService;
+
+ @Operation(summary = "鏂规鍗℃ā鏉垮垎椤靛垪琛紙鎸夌増鏈淮搴﹀睍绀猴級")
+ @PostMapping("/page")
+ public Result<PageResult<OpportunitySchemeCardTemplateDTO>> page(OpportunitySchemeCardTemplatePageParam param) {
+ return Result.success(templateService.pageTemplates(param));
+ }
+
+ @Operation(summary = "鏂规鍗℃ā鏉胯鎯咃紙鍚€傜敤閮ㄩ棬 + 瀛楁椤规湁搴忓垪琛級")
+ @GetMapping("/detail")
+ public Result<OpportunitySchemeCardTemplateDTO> detail(@RequestParam("id") Long id) {
+ return Result.success(templateService.getTemplateDetail(id));
+ }
+
+ @Operation(summary = "绯荤粺瀛楁搴撳垪琛紙妯℃澘閰嶇疆椤点€岄€夊瓧娈点€嶆暟鎹簮锛屽彧璇伙級")
+ @GetMapping("/field-defs")
+ public Result<List<OpportunitySchemeFieldDefDTO>> fieldDefs() {
+ return Result.success(templateService.listFieldDefs());
+ }
+
+ @Operation(summary = "淇濆瓨鑽夌锛堟柊寤?鍘熶綅缂栬緫鑽夌/浠庡彂甯冧腑鍋滅敤鐢熸垚鏂拌崏绋匡紱瀛楁椤瑰叏閲忔浛鎹級")
+ @PostMapping("/save-draft")
+ public Result<Void> saveDraft(OpportunitySchemeCardTemplateDTO dto) {
+ templateService.saveDraft(dto);
+ return Result.success();
+ }
+
+ @Operation(summary = "淇濆瓨骞跺彂甯冿紙鑷冲皯涓€涓瓧娈甸」锛涘師鍙戝竷涓浆鍋滅敤銆佸師榛樿鍙栨秷榛樿锛?)
+ @PostMapping("/publish")
+ public Result<Void> publish(OpportunitySchemeCardTemplateDTO dto) {
+ templateService.saveAndPublish(dto);
+ return Result.success();
+ }
+
+ @Operation(summary = "澶嶅埗妯℃澘锛堢嫭绔嬫柊妯℃澘锛氭柊缂栫爜 + V1.0 鑽夌锛屽瓧娈甸厤缃殢澶嶅埗锛?)
+ @PostMapping("/copy")
+ public Result<Long> copy(@RequestParam("id") Long id) {
+ return Result.success(templateService.copyTemplate(id));
+ }
+
+ @Operation(summary = "鍋滅敤锛堜粎鍙戝竷涓彲鍋滅敤锛?)
+ @PostMapping("/disable")
+ public Result<Void> disable(@RequestParam("id") Long id) {
+ templateService.disableTemplate(id);
+ return Result.success();
+ }
+
+ @Operation(summary = "鍒犻櫎锛堜粎鑽夌鍙垹锛岀骇鑱斿垹瀛楁椤逛笌閫傜敤閮ㄩ棬锛?)
+ @PostMapping("/delete")
+ public Result<Void> delete(@RequestParam("id") Long id) {
+ templateService.deleteDraft(id);
+ return Result.success();
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunitySchemeCardTemplateDTO.java b/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunitySchemeCardTemplateDTO.java
new file mode 100644
index 0000000..8bb19cd
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunitySchemeCardTemplateDTO.java
@@ -0,0 +1,97 @@
+package com.crm.rule.domain.dto;
+
+import com.crm.base.domain.dto.BaseDTO;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * 鏂规鍗℃ā鏉夸紶杈撳璞★紙鍐欏叆鍙?+ 鍑哄弬鍙屽悜锛岀エ 07銆?.3銆嶏紝鍚岀エ 06 Route A 鍙e緞锛?+ *
+ * <p>templateCode/versionNo/status 绯荤粺鐢熸垚鍙锛氬叆鍙備晶蹇界暐锛屽嚭鍙備晶鍥炴樉锛?+ * 閫傜敤閮ㄩ棬浠?ID 鍒楄〃闅忎富瀵硅薄涓婇€?鍥炴樉锛屽瓧娈甸」浠?{@link FieldItem} 鏈夊簭鍒楄〃鏁翠綋鎻愪氦
+ * 锛堝簭鍙锋寜鎻愪氦椤哄簭閲嶇畻锛夛紝Service 灞傝礋璐f媶鍐欏瓙琛ㄣ€?/p>
+ *
+ * <p>瀛楁椤瑰叆鍙備晶浠?{@code fieldKey}锛堝紩绯荤粺瀛楁搴擄級+ 鏄剧ず/蹇呭~寮€鍏虫湁鏁堬紱
+ * fieldName/fieldType 鐢辨湇鍔″眰浠庣郴缁熷瓧娈靛畾涔夊揩鐓у啓鍏ワ紙闃蹭吉閫狅級锛屽嚭鍙備晶鍥炴樉銆?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OpportunitySchemeCardTemplateDTO extends BaseDTO {
+
+ @Schema(description = "妯℃澘缂栫爜锛堢郴缁熺敓鎴愶紝鍙锛?)
+ private String templateCode;
+
+ @Schema(description = "妯℃澘鍚嶇О")
+ private String templateName;
+
+ @Schema(description = "鐗堟湰鍙凤紙绯荤粺鐢熸垚鍙锛歏1.0/V1.1鈥︼級")
+ private String versionNo;
+
+ @Schema(description = "鐘舵€侊細1鑽夌 2鍙戝竷涓?3宸插仠鐢紙鍙锛?)
+ private Integer status;
+
+ @Schema(description = "閫傜敤鑼冨洿锛?鎵€鏈夐儴闂ㄩ€氱敤 2瀵瑰簲閮ㄩ棬涓撶敤锛堜袱妗e崟閫夛紝绁?07銆?.2銆嶏級")
+ private Integer applyScope;
+
+ @Schema(description = "鏄惁榛樿锛?鏄?0鍚︼紙浠呮墍鏈夐儴闂ㄩ€氱敤鍙锛岄儴闂ㄤ笓鐢ㄤ笉鍏佽锛?)
+ private Integer isDefault;
+
+ @Schema(description = "妯℃澘璇存槑")
+ private String templateDesc;
+
+ @Schema(description = "閫傜敤閮ㄩ棬ID鍒楄〃锛坅pplyScope=2 鏃跺繀濉嚦灏戜竴涓級")
+ private List<Long> deptIds;
+
+ @Schema(description = "閫傜敤閮ㄩ棬鍚嶇О鍒楄〃锛堝嚭鍙傚洖鏄撅級")
+ private List<String> deptNames;
+
+ @Schema(description = "瀛楁椤规湁搴忓垪琛紙搴忓彿鎸夊垪琛ㄩ『搴忛噸绠楋紱瀛楁寮曠郴缁熷瓧娈靛簱锛?)
+ private List<FieldItem> fields;
+
+ public static OpportunitySchemeCardTemplateDTO fromEntity(OpportunitySchemeCardTemplate entity) {
+ OpportunitySchemeCardTemplateDTO dto = new OpportunitySchemeCardTemplateDTO();
+ org.springframework.beans.BeanUtils.copyProperties(entity, dto);
+ return dto;
+ }
+
+ public OpportunitySchemeCardTemplate toEntity() {
+ OpportunitySchemeCardTemplate entity = new OpportunitySchemeCardTemplate();
+ org.springframework.beans.BeanUtils.copyProperties(this, entity);
+ return entity;
+ }
+
+ /**
+ * 瀛楁椤癸紙閫夊瓧娈?+ 寮€鍏虫樉绀?+ 寮€鍏冲繀濉紝绁?07銆?.3銆嶏級銆?+ * <p>鍏ュ弬锛歿@code fieldKey} 蹇呭~锛堝紩绯荤粺瀛楁瀹氫箟锛夛紝{@code isVisible}/{@code isRequired} 寮€鍏筹紱
+ * 鏄剧ず/蹇呭~纭害鏉燂細鍏虫樉绀哄悓姝ユ竻蹇呭~锛屾湇鍔″眰淇濆瓨鏃惰鑼冨寲淇濊瘉銆?+ * 鍑哄弬锛氬洖鏄惧瓧娈靛悕/绫诲瀷蹇収涓庣湡瀹炲簭鍙枫€?/p>
+ */
+ @Data
+ public static class FieldItem {
+
+ @Schema(description = "瀛楁椤笽D锛堝嚭鍙傚洖鏄撅紱鏂板缓鏃狅級")
+ private Long id;
+
+ @Schema(description = "搴忓彿锛堝嚭鍙傚洖鏄撅紱鍏ュ弬鎸夊垪琛ㄩ『搴忛噸绠楋級")
+ private Integer seqNo;
+
+ @Schema(description = "瀛楁閿紙蹇呭~锛屽紩鐢ㄧ郴缁熷瓧娈靛畾涔夛紝闈炶嚜鐢遍€狅級")
+ private String fieldKey;
+
+ @Schema(description = "灞曠ず瀛楁鍚嶏紙蹇収鍥炴樉锛涘叆鍙傚拷鐣ワ紝鍙栫郴缁熷瓧娈靛畾涔夛級")
+ private String fieldName;
+
+ @Schema(description = "瀛楁绫诲瀷锛堝揩鐓у洖鏄撅紱鍏ュ弬蹇界暐锛屽彇绯荤粺瀛楁瀹氫箟锛?)
+ private String fieldType;
+
+ @Schema(description = "鏄剧ず寮€鍏筹細1鏄剧ず 0涓嶆樉绀猴紙鍏抽棴鍒欎笉鍑虹幇鍦ㄥ~鍐欓〉涓旀竻蹇呭~锛?)
+ private Integer isVisible;
+
+ @Schema(description = "蹇呭~锛?蹇呭~ 0闈炲繀濉紙浠呮樉绀烘椂鍙负 1锛?)
+ private Integer isRequired;
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunitySchemeFieldDefDTO.java b/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunitySchemeFieldDefDTO.java
new file mode 100644
index 0000000..f83e75a
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunitySchemeFieldDefDTO.java
@@ -0,0 +1,47 @@
+package com.crm.rule.domain.dto;
+
+import com.crm.base.domain.dto.BaseDTO;
+import com.crm.rule.domain.entity.OpportunitySchemeFieldDef;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 鏂规鍗$郴缁熷瓧娈靛畾涔夊嚭鍙傦紙绁?07銆?.4銆嶏細妯℃澘閰嶇疆椤点€岄€夊瓧娈点€嶆暟鎹簮锛?+ * <p>瀛楁搴撳彧璇伙紙绉嶅瓙鍖栫淮鎶わ級锛屾湰 DTO 浠呭嚭鍙傦紱鍗曢€変笅鎷夐€夐」闆嗘寜 {@code optionSource}
+ * 寮?crm-dict 鍒嗙粍 code 鐢卞墠绔嚜琛屾媺鍙栧瓧鍏搁」锛堢粍鍚堣€岄潪浜岄€変竴锛夈€?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OpportunitySchemeFieldDefDTO extends BaseDTO {
+
+ @Schema(description = "瀛楁閿紙妯℃澘瀛楁椤瑰紩鐢ㄥ畠锛?)
+ private String fieldKey;
+
+ @Schema(description = "瀛楁鍚嶏紙灞曠ず鍚嶏級")
+ private String fieldName;
+
+ @Schema(description = "瀛楁绫诲瀷锛圧ELATED_OBJECT/SINGLE_SELECT/AUTOFILL/YES_NO/ATTACHMENT/AMOUNT/TEXT_SINGLE/TEXT_MULTI/DATE锛?)
+ private String fieldType;
+
+ @Schema(description = "閫夐」鏉ユ簮锛堝崟閫変笅鎷夌被锛氬瓧鍏稿垎缁?code锛?)
+ private String optionSource;
+
+ @Schema(description = "鍏宠仈瀵硅薄鎸囧悜锛堝叧鑱斿璞$被锛氱洰鏍囧疄浣撹涔夛級")
+ private String refTarget;
+
+ @Schema(description = "鑷姩鍥炲~鏉ユ簮锛堣嚜鍔ㄥ洖濉被锛氭潵婧愬瓧娈佃涔夛級")
+ private String autofillSource;
+
+ @Schema(description = "榛樿鍊?)
+ private String defaultValue;
+
+ @Schema(description = "鏄惁鍙垹闄わ細1鍙垹 0鍐呯疆涓嶅彲鍒?)
+ private Integer isDeletable;
+
+ public static OpportunitySchemeFieldDefDTO fromEntity(OpportunitySchemeFieldDef entity) {
+ OpportunitySchemeFieldDefDTO dto = new OpportunitySchemeFieldDefDTO();
+ org.springframework.beans.BeanUtils.copyProperties(entity, dto);
+ return dto;
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardField.java b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardField.java
new file mode 100644
index 0000000..35752a6
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardField.java
@@ -0,0 +1,70 @@
+package com.crm.rule.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+import org.hibernate.annotations.Comment;
+
+/**
+ * 鏂规鍗℃ā鏉垮瓧娈甸」锛堟湁搴忥紝浠庡睘妯℃澘鐗堟湰锛岀エ 07銆?.3銆嶏級
+ *
+ * <p>妯℃澘 = 銆岄€夊瓧娈?+ 鎺掗『搴?+ 寮€鍏虫樉绀?+ 寮€鍏冲繀濉€嶏細{@code field_key} 寮曠郴缁熷瓧娈靛簱
+ * 锛坽@link OpportunitySchemeFieldDef}锛岄潪鑷敱閫狅級锛寋@code field_name}/{@code field_type}
+ * 涓轰繚瀛樻椂鐨勭郴缁熷瓧娈靛揩鐓э紙闃?join銆侀槻瀛楁搴撴敼鍚嶆紓绉伙級銆傚垹闄ゅ瓧娈典粎褰卞搷褰撳墠鑽夌鐗堟湰锛?+ * 涓嶅垹绯荤粺瀛楁瀹氫箟锛堝師鍨嬩笟鍔¤鍒?10锛夈€?/p>
+ *
+ * <p><b>鏄剧ず/蹇呭~纭害鏉?/b>锛堢エ 07銆?.3銆嶏級锛歿@code is_visible=0} 寮哄埗 {@code is_required=0}
+ * 锛堝叧鏄剧ず鍚屾娓呭繀濉級锛岀敱鏈嶅姟灞備繚瀛樻椂瑙勮寖鍖栦繚璇併€?/p>
+ */
+@Data
+@TableName("opportunity_scheme_card_field")
+@Entity
+@Table(name = "opportunity_scheme_card_field",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_oscf_template_field", columnNames = {"template_id", "field_key"})
+ },
+ indexes = {
+ @Index(name = "idx_oscf_template_seq", columnList = "template_id, seq_no")
+ })
+public class OpportunitySchemeCardField {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ @Id
+ @Column(name = "id", columnDefinition = "bigint comment '涓婚敭'")
+ private Long id;
+
+ @Comment("鎵€灞炴ā鏉跨増鏈琁D锛坥pportunity_scheme_card_template.id锛岀増鏈骇闈?code 绾э級")
+ @Column(columnDefinition = "bigint not null comment '鎵€灞炴ā鏉跨増鏈琁D'")
+ private Long templateId;
+
+ @Comment("搴忓彿 = 鏂规鍗″~鍐欓〉灞曠ず椤哄簭锛堜繚瀛樻椂鎸夋彁浜ら『搴忛噸绠楋級")
+ @Column(columnDefinition = "int not null comment '搴忓彿'")
+ private Integer seqNo;
+
+ @Comment("瀛楁閿紙寮曠敤绯荤粺瀛楁瀹氫箟 opportunity_scheme_field_def.field_key锛岄潪鑷敱閫狅級")
+ @Column(columnDefinition = "varchar(64) not null comment '瀛楁閿?")
+ private String fieldKey;
+
+ @Comment("灞曠ず瀛楁鍚嶏紙蹇収锛屾潵鑷郴缁熷瓧娈靛畾涔夛級")
+ @Column(columnDefinition = "varchar(100) not null comment '灞曠ず瀛楁鍚?")
+ private String fieldName;
+
+ @Comment("鎺т欢/鏁版嵁绫诲瀷锛堝揩鐓э紝鏉ヨ嚜绯荤粺瀛楁瀹氫箟锛?)
+ @Column(columnDefinition = "varchar(32) not null comment '瀛楁绫诲瀷'")
+ private String fieldType;
+
+ @Comment("鏄剧ず寮€鍏筹細1=鏄剧ず 0=涓嶆樉绀猴紙鍏抽棴鍒欎笉鍑虹幇鍦ㄥ~鍐欓〉锛屼笖寮哄埗娓呭繀濉級")
+ @Column(columnDefinition = "tinyint not null default 1 comment '鏄剧ず寮€鍏?")
+ private Integer isVisible;
+
+ @Comment("蹇呭~锛?=蹇呭~ 0=闈炲繀濉紙浠?is_visible=1 鏃跺彲涓?1锛岀‖绾︽潫锛?)
+ @Column(columnDefinition = "tinyint not null default 0 comment '蹇呭~'")
+ private Integer isRequired;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardTemplate.java b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardTemplate.java
new file mode 100644
index 0000000..140bfe1
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardTemplate.java
@@ -0,0 +1,57 @@
+package com.crm.rule.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.crm.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+/**
+ * 鏂规鍗℃ā鏉垮ご琛紙鐗堟湰鍖栭厤缃紝绁?07銆?.3銆? 鍘熷瀷 A7-3-2-2锛?+ *
+ * <p>V-CONFIG 绗笁涓疄渚嬶紙绁?05 鍏捣瑙勫垯 / 绁?06 闃舵妯℃澘 / 绁?07 鏂规鍗℃ā鏉垮悓鏃忥級锛?+ * 鍚屼竴 {@code template_code} 涓€涓ā鏉裤€佸鐗堟湰锛屾瘡鐗堟湰涓€琛岋紱鐗堟湰鐘舵€佹満绾︽潫鐢辨湇鍔″眰鏍¢獙銆?+ * 缁戝畾瀹炰綋锛堟柟妗堝崱锛夐攣鐗堟湰琛?id锛屽彂鏂扮増涓嶈縼绉烩€斺€斿湪閫旀柟妗堝崱缁敤鍘熺増鏈紝鏂板缓鏂规鍗$敤鏈€鏂板彂甯冪増鏈€?/p>
+ *
+ * <p><b>閫傜敤鑼冨洿涓ゆ。</b>锛堜笌绁?05 鍏捣瑙勫垯鍚屻€佷笌绁?06 闃舵妯℃澘璇箟鍚屾。鍚嶅紓锛夛細
+ * 1=鎵€鏈夐儴闂ㄩ€氱敤锛堝彲璁鹃粯璁わ級/ 2=瀵瑰簲閮ㄩ棬涓撶敤锛堝繀閫夐儴闂ㄣ€佷笉鍏佽璁鹃粯璁わ級銆?+ * 榛樿鍞竴锛氫粎閫氱敤鍙榛樿锛屽彂甯冧腑榛樿鍞竴锛屽彂鏂伴粯璁ゅ師榛樿鑷姩鍙栨秷锛圴-CONFIG 6锛夈€?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("opportunity_scheme_card_template")
+@Entity
+@Table(name = "opportunity_scheme_card_template")
+public class OpportunitySchemeCardTemplate extends BaseEntity {
+
+ @Comment("妯℃澘缂栫爜锛堢郴缁熺敓鎴愶紝鍚屾ā鏉垮鐗堟湰鍏辩敤锛屽 OPP_SCHEME_TPL_01锛?)
+ @Column(columnDefinition = "varchar(64) not null comment '妯℃澘缂栫爜'")
+ private String templateCode;
+
+ @Comment("妯℃澘鍚嶇О锛堝悓妯℃澘澶氱増鏈部鐢ㄥ悓鍚嶏級")
+ @Column(columnDefinition = "varchar(100) not null comment '妯℃澘鍚嶇О'")
+ private String templateName;
+
+ @Comment("鐗堟湰鍙凤紙绯荤粺鐢熸垚鍙锛歏1.0/V1.1/V2.0鈥︼級")
+ @Column(columnDefinition = "varchar(20) not null comment '鐗堟湰鍙?")
+ private String versionNo;
+
+ @Comment("鐘舵€侊細1=鑽夌 2=鍙戝竷涓?3=宸插仠鐢紙V-CONFIG 3-5锛?)
+ @Column(columnDefinition = "tinyint not null comment '鐘舵€侊細1鑽夌 2鍙戝竷涓?3宸插仠鐢?")
+ private Integer status;
+
+ @Comment("閫傜敤鑼冨洿锛?=鎵€鏈夐儴闂ㄩ€氱敤 2=瀵瑰簲閮ㄩ棬涓撶敤锛堜袱妗e崟閫夛紝绁?07銆?.2銆嶏級")
+ @Column(columnDefinition = "tinyint not null comment '閫傜敤鑼冨洿锛?鎵€鏈夐儴闂ㄩ€氱敤 2瀵瑰簲閮ㄩ棬涓撶敤'")
+ private Integer applyScope;
+
+ @Comment("鏄惁榛樿锛?=鏄?0=鍚︼紱浠呮墍鏈夐儴闂ㄩ€氱敤鍙锛屽彂甯冧腑榛樿鍞竴锛圴-CONFIG 6锛?)
+ @Column(columnDefinition = "tinyint not null default 0 comment '鏄惁榛樿'")
+ private Integer isDefault;
+
+ @Comment("妯℃澘璇存槑锛堜笟鍔$敤閫?閫傜敤鍦烘櫙锛?)
+ @Column(columnDefinition = "varchar(500) comment '妯℃澘璇存槑'")
+ private String templateDesc;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardTemplateDept.java b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardTemplateDept.java
new file mode 100644
index 0000000..4f737e6
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeCardTemplateDept.java
@@ -0,0 +1,40 @@
+package com.crm.rule.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+import org.hibernate.annotations.Comment;
+
+/**
+ * 鏂规鍗℃ā鏉?閫傜敤閮ㄩ棬鍏宠仈锛坅pply_scope=2 瀵瑰簲閮ㄩ棬涓撶敤鐨勫鍊硷紝绁?07銆?.3銆嶏級
+ *
+ * <p>鍏宠仈琛紝鏃犲璁″瓧娈碉紝纭垹闄わ紙妯℃澘鐗堟湰淇濆瓨鏃跺厛鍒犲悗鎻掞級锛?+ * apply_scope=1锛堟墍鏈夐儴闂ㄩ€氱敤锛夋椂鏈〃鏃犺銆?/p>
+ */
+@Data
+@TableName("opportunity_scheme_card_template_dept")
+@Entity
+@Table(name = "opportunity_scheme_card_template_dept", uniqueConstraints = {
+ @UniqueConstraint(name = "uk_osct_version_dept", columnNames = {"template_id", "dept_id"})
+})
+public class OpportunitySchemeCardTemplateDept {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ @Id
+ @Column(name = "id", columnDefinition = "bigint comment '涓婚敭'")
+ private Long id;
+
+ @Comment("妯℃澘鐗堟湰ID锛坥pportunity_scheme_card_template.id锛岀増鏈涓婚敭锛?)
+ @Column(columnDefinition = "bigint not null comment '妯℃澘鐗堟湰ID'")
+ private Long templateId;
+
+ @Comment("閫傜敤閮ㄩ棬ID")
+ @Column(columnDefinition = "bigint not null comment '閫傜敤閮ㄩ棬ID'")
+ private Long deptId;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeFieldDef.java b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeFieldDef.java
new file mode 100644
index 0000000..859ca93
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunitySchemeFieldDef.java
@@ -0,0 +1,64 @@
+package com.crm.rule.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.crm.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+/**
+ * 鏂规鍗$郴缁熷瓧娈靛畾涔夛紙绁?07銆?.4銆嶏紝浜у搧/鏋舵瀯鎷嶆澘 P0-3 鑷缓锛?+ *
+ * <p>鏂规鍗″瓧娈靛簱 = 瀛楁鍏冩暟鎹細妯℃澘鍋氱殑鏄€岄€夊瓧娈?+ 鎺掗『搴?+ 寮€鍏虫樉绀?+ 寮€鍏冲繀濉€嶏紝
+ * 涓嶆槸鑷敱杈撳叆瀛楁鍚?绫诲瀷锛堝師鍨嬩笟鍔¤鍒?10锛氬垹闄ゅ瓧娈典粎褰卞搷褰撳墠鑽夌鐗堟湰锛屼笉鍒犵郴缁熷瓧娈靛畾涔夛級銆?+ * <b>涓嶅鐢?crm-dict</b>锛氬叾鐮佽〃妯″瀷锛圖ictGroup+DictItem锛夊彧鑳借〃銆屼竴缁勫彲閫夊€笺€嶏紝
+ * 鏃?field_type/鍏宠仈鐩爣/鍥炲~鏉ユ簮鍒楋紝褰㈢姸涓嶅尮閰嶃€?/p>
+ *
+ * <p>缁勫悎鑰岄潪浜岄€変竴锛氥€屽崟閫変笅鎷夈€嶇被瀛楁鐨勯€夐」闆嗗彲 {@code option_source} 寮?crm-dict
+ * 鍒嗙粍 code锛堣璧板瓧鍏哥殑閫夐」璧板瓧鍏革紝瀛楁瀹氫箟鏈韩鑷缓锛夈€?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("opportunity_scheme_field_def")
+@Entity
+@Table(name = "opportunity_scheme_field_def", uniqueConstraints = {
+ @UniqueConstraint(name = "uk_scheme_field_key", columnNames = {"field_key"})
+})
+public class OpportunitySchemeFieldDef extends BaseEntity {
+
+ @Comment("瀛楁閿紙绯荤粺瀛楁鍞竴鏍囪瘑锛屾ā鏉垮瓧娈甸」寮曠敤瀹冿紝闈炶嚜鐢遍€狅級")
+ @Column(columnDefinition = "varchar(64) not null comment '瀛楁閿?")
+ private String fieldKey;
+
+ @Comment("瀛楁鍚嶏紙灞曠ず鍚嶏紝妯℃澘瀛楁椤瑰揩鐓ф潵婧愶級")
+ @Column(columnDefinition = "varchar(100) not null comment '瀛楁鍚?")
+ private String fieldName;
+
+ @Comment("鎺т欢/鏁版嵁绫诲瀷锛圧ELATED_OBJECT/SINGLE_SELECT/AUTOFILL/YES_NO/ATTACHMENT/AMOUNT/TEXT_SINGLE/TEXT_MULTI/DATE锛?)
+ @Column(columnDefinition = "varchar(32) not null comment '瀛楁绫诲瀷'")
+ private String fieldType;
+
+ @Comment("閫夐」鏉ユ簮锛堝崟閫変笅鎷夌被锛氬紩 crm-dict 鍒嗙粍 code锛涘叾瀹冪被鍨嬩负绌猴級")
+ @Column(columnDefinition = "varchar(64) comment '閫夐」鏉ユ簮'")
+ private String optionSource;
+
+ @Comment("鍏宠仈瀵硅薄鎸囧悜锛堝叧鑱斿璞$被锛氱洰鏍囧疄浣撹涔夛紱鍏跺畠绫诲瀷涓虹┖锛?)
+ @Column(columnDefinition = "varchar(64) comment '鍏宠仈瀵硅薄鎸囧悜'")
+ private String refTarget;
+
+ @Comment("鑷姩鍥炲~鏉ユ簮锛堣嚜鍔ㄥ洖濉被锛氭潵婧愬瓧娈佃涔夛紱鍏跺畠绫诲瀷涓虹┖锛?)
+ @Column(columnDefinition = "varchar(64) comment '鑷姩鍥炲~鏉ユ簮'")
+ private String autofillSource;
+
+ @Comment("榛樿鍊硷紙棰勮/濉啓椤靛甫鍑猴級")
+ @Column(columnDefinition = "varchar(200) comment '榛樿鍊?")
+ private String defaultValue;
+
+ @Comment("鏄惁鍙垹闄わ細1=鍙垹 0=鍐呯疆涓嶅彲鍒狅紙鍒犳ā鏉垮瓧娈典笉鍒犳湰瀹氫箟锛屽師鍨嬩笟鍔¤鍒?10锛?)
+ @Column(columnDefinition = "tinyint not null default 0 comment '鏄惁鍙垹闄?")
+ private Integer isDeletable;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/param/OpportunitySchemeCardTemplatePageParam.java b/crm-rule/src/main/java/com/crm/rule/domain/param/OpportunitySchemeCardTemplatePageParam.java
new file mode 100644
index 0000000..cfd2efc
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/domain/param/OpportunitySchemeCardTemplatePageParam.java
@@ -0,0 +1,21 @@
+package com.crm.rule.domain.param;
+
+import com.crm.base.domain.param.BaseParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 鏂规鍗℃ā鏉垮垎椤垫煡璇㈠弬鏁帮紙绁?07銆?.3銆嶏紝瀵圭О绁?06锛?+ * <p>keyword 鍖归厤妯℃澘鍚嶇О/妯℃澘缂栫爜/鐗堟湰鍙凤紱鍒楄〃鎸夌増鏈淮搴﹀睍绀猴紙鍚?code 鑽夌/鍙戝竷涓?鍋滅敤鍚勫崰涓€琛岋級銆?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OpportunitySchemeCardTemplatePageParam extends BaseParam {
+
+ @Schema(description = "閫傜敤鑼冨洿绛涢€夛細1鎵€鏈夐儴闂ㄩ€氱敤 2瀵瑰簲閮ㄩ棬涓撶敤")
+ private Integer applyScope;
+
+ @Schema(description = "鐘舵€佺瓫閫夛細1鑽夌 2鍙戝竷涓?3宸插仠鐢?)
+ private Integer status;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardFieldMapper.java b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardFieldMapper.java
new file mode 100644
index 0000000..1cc0ed3
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardFieldMapper.java
@@ -0,0 +1,9 @@
+package com.crm.rule.mapper;
+
+import com.crm.base.mapper.CrmBaseMapper;
+import com.crm.rule.domain.entity.OpportunitySchemeCardField;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OpportunitySchemeCardFieldMapper extends CrmBaseMapper<OpportunitySchemeCardField> {
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardTemplateDeptMapper.java b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardTemplateDeptMapper.java
new file mode 100644
index 0000000..2d1aae0
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardTemplateDeptMapper.java
@@ -0,0 +1,9 @@
+package com.crm.rule.mapper;
+
+import com.crm.base.mapper.CrmBaseMapper;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplateDept;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OpportunitySchemeCardTemplateDeptMapper extends CrmBaseMapper<OpportunitySchemeCardTemplateDept> {
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardTemplateMapper.java b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardTemplateMapper.java
new file mode 100644
index 0000000..10f64f7
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeCardTemplateMapper.java
@@ -0,0 +1,9 @@
+package com.crm.rule.mapper;
+
+import com.crm.base.mapper.CrmBaseMapper;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OpportunitySchemeCardTemplateMapper extends CrmBaseMapper<OpportunitySchemeCardTemplate> {
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeFieldDefMapper.java b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeFieldDefMapper.java
new file mode 100644
index 0000000..e8bd20e
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunitySchemeFieldDefMapper.java
@@ -0,0 +1,9 @@
+package com.crm.rule.mapper;
+
+import com.crm.base.mapper.CrmBaseMapper;
+import com.crm.rule.domain.entity.OpportunitySchemeFieldDef;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OpportunitySchemeFieldDefMapper extends CrmBaseMapper<OpportunitySchemeFieldDef> {
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/service/IOpportunitySchemeCardTemplateService.java b/crm-rule/src/main/java/com/crm/rule/service/IOpportunitySchemeCardTemplateService.java
new file mode 100644
index 0000000..afa9b34
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/service/IOpportunitySchemeCardTemplateService.java
@@ -0,0 +1,107 @@
+package com.crm.rule.service;
+
+import com.crm.base.domain.result.PageResult;
+import com.crm.rule.domain.dto.OpportunitySchemeCardTemplateDTO;
+import com.crm.rule.domain.dto.OpportunitySchemeFieldDefDTO;
+import com.crm.rule.domain.entity.OpportunitySchemeCardField;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate;
+import com.crm.rule.domain.param.OpportunitySchemeCardTemplatePageParam;
+
+import java.util.List;
+
+/**
+ * 鏂规鍗℃ā鏉跨増鏈姸鎬佹満鏈嶅姟锛堢エ 07銆屼簩銆? 鍘熷瀷 A7-3-2-2锛?+ *
+ * <p>V-CONFIG 绗笁涓疄渚嬶紙涓庣エ 05 鍏捣瑙勫垯 / 绁?06 闃舵妯℃澘鍚屾棌锛夛細鍚屼竴
+ * template_code 澶氱増鏈瘡鐗堟湰涓€琛岋紱鍚?code 浠呬竴鑽夌涓€鍙戝竷涓紱鍙戝竷浣垮師鍙戝竷涓浆鍋滅敤銆?+ * 鍘熼粯璁よ嚜鍔ㄥ彇娑堥粯璁わ紱浠呰崏绋垮彲鍒狅紱缂栬緫鍙戝竷涓?鍋滅敤鈫掔敓鏂拌崏绋匡紱澶嶅埗鈫掓柊 code 鏂颁富浣撱€?/p>
+ *
+ * <p>涓庨樁娈?鍏捣妯℃澘鐨勫叧閿樊寮傦紙绁?07銆?.2銆嶏級锛?/p>
+ * <ul>
+ * <li>閫傜敤鑼冨洿涓ゆ。锛氭墍鏈夐儴闂ㄩ€氱敤锛堝彲璁鹃粯璁わ級/ 瀵瑰簲閮ㄩ棬涓撶敤锛堝繀閫夐儴闂ㄣ€佷笉鍏佽璁鹃粯璁わ級锛?/li>
+ * <li>瀛楁椤瑰紩绯荤粺瀛楁搴擄紙{@code opportunity_scheme_field_def}锛岄潪鑷敱閫狅級锛?+ * 瀛楁鍚?绫诲瀷淇濆瓨鏃跺揩鐓э紱鏄剧ず/蹇呭~纭害鏉燂紙鍏虫樉绀哄悓姝ユ竻蹇呭~锛夛紱</li>
+ * <li>鍙戝竷鏍¢獙锛氳嚦灏戜竴涓瓧娈甸」锛堟棤鍥哄畾鑺傜偣姒傚康锛屽紓浜庣エ 06锛夈€?/li>
+ * </ul>
+ *
+ * <p>杩愯鏃舵煡璇㈢紳锛坽@code getVersionById}/{@code listFieldsOfVersion}/{@code resolveBindingVersion}锛?+ * 渚?{@code crm-opportunity} 鏂规鍗″垱寤?璇︽儏鏃惰皟鐢紱鏈帴鍙d笉鍙嶅悜渚濊禆鍟嗘満瀹炰綋锛堢エ 01锛夈€?/p>
+ */
+public interface IOpportunitySchemeCardTemplateService {
+
+ /**
+ * 鍒嗛〉鏌ヨ妯℃澘鍒楄〃锛堟寜鐗堟湰缁村害灞曠ず锛沰eyword 鍖归厤鍚?鐮?鐗堟湰锛夈€?+ */
+ PageResult<OpportunitySchemeCardTemplateDTO> pageTemplates(OpportunitySchemeCardTemplatePageParam param);
+
+ /**
+ * 妯℃澘鐗堟湰璇︽儏锛堝惈閫傜敤閮ㄩ棬 ID/鍚嶇О鍥炴樉 + 瀛楁椤规湁搴忓垪琛級銆?+ */
+ OpportunitySchemeCardTemplateDTO getTemplateDetail(Long id);
+
+ /**
+ * 绯荤粺瀛楁搴撳叏閲忓垪琛紙妯℃澘閰嶇疆椤点€岄€夊瓧娈点€嶆暟鎹簮锛屾寜 id 鍗囧簭锛夈€?+ */
+ List<OpportunitySchemeFieldDefDTO> listFieldDefs();
+
+ /**
+ * 淇濆瓨鑽夌锛?+ * <ul>
+ * <li>鏃?id 鈫?鏂版ā鏉挎柊鑽夌锛堢郴缁熺敓鎴?templateCode + V1.0锛?/li>
+ * <li>id 鎸囧悜鑽夌 鈫?鍘熶綅缂栬緫锛坱emplateCode/versionNo/status 涓嶅彉锛?/li>
+ * <li>id 鎸囧悜鍙戝竷涓?鍋滅敤 鈫?鐢熸垚鏂拌崏绋匡紙鍚?code锛岀増鏈彿 minor 椤哄欢锛?+ * 鍚?code 宸叉湁鑽夌鍒欐嫆缁濓紝杩涘凡鏈夎崏绋跨紪杈戯級</li>
+ * </ul>
+ * 瀛楁椤瑰叏閲忔浛鎹紱瀛楁鍚?绫诲瀷蹇収鑷郴缁熷瓧娈靛畾涔夛紱鍏虫樉绀哄悓姝ユ竻蹇呭~銆?+ */
+ void saveDraft(OpportunitySchemeCardTemplateDTO dto);
+
+ /**
+ * 淇濆瓨骞跺彂甯冿細鍏堟寜 {@link #saveDraft} 璇箟钀借崏绋匡紝鍐嶆墽琛屽彂甯冩牎楠?+ * 锛堣嚦灏戜竴涓瓧娈甸」 鈫?鍘熷彂甯冧腑杞仠鐢?鈫?鍘熼粯璁ゅ彇娑堥粯璁?鈫?鏈増鏈浆鍙戝竷涓級銆?+ */
+ void saveAndPublish(OpportunitySchemeCardTemplateDTO dto);
+
+ /**
+ * 澶嶅埗妯℃澘 鈫?鐙珛鏂版ā鏉匡紙鏂?templateCode銆乂1.0 鑽夌锛岄€傜敤鑼冨洿/瀛楁閰嶇疆甯﹁繃鏉ワ級銆?+ *
+ * @return 鏂拌崏绋跨増鏈?id
+ */
+ Long copyTemplate(Long id);
+
+ /**
+ * 鍋滅敤锛堜粎鍙戝竷涓彲鍋滅敤锛涘彂甯冧腑 鈫?宸插仠鐢紝浣滃巻鍙插揩鐓т繚鐣欙級銆?+ */
+ void disableTemplate(Long id);
+
+ /**
+ * 鍒犻櫎锛堜粎鑽夌鍙垹锛涚骇鑱旂‖鍒犻€傜敤閮ㄩ棬瀛愯〃 + 瀛楁椤癸級銆?+ */
+ void deleteDraft(Long id);
+
+ // ==================== 杩愯鏃舵煡璇㈢紳锛堜緵 crm-opportunity 娑堣垂锛?====================
+
+ /**
+ * 鎸夌増鏈 id 鏌ユā鏉跨増鏈紙鏂规鍗$粦瀹氭牎楠?鍥炴樉鐢級銆?+ *
+ * @return 妯℃澘鐗堟湰琛岋紱涓嶅瓨鍦ㄨ繑鍥?{@code null}
+ */
+ OpportunitySchemeCardTemplate getVersionById(Long templateVersionId);
+
+ /**
+ * 妯℃澘鐗堟湰涓嬪叏閮ㄥ瓧娈甸」锛堟寜 seq_no 鍗囧簭锛涙柟妗堝崱濉啓椤?蹇呭~鏍¢獙鐢級銆?+ */
+ List<OpportunitySchemeCardField> listFieldsOfVersion(Long templateVersionId);
+
+ /**
+ * 缁戝畾鍖归厤锛氭寜閮ㄩ棬瑙f瀽搴旂粦瀹氱殑**鍙戝竷涓?*妯℃澘鐗堟湰锛堟柊寤烘柟妗堝崱鐢紝鍚岀エ 06 鍙e緞锛夈€?+ * <ul>
+ * <li>浼樺厛锛氬搴旈儴闂ㄤ笓鐢紙apply_scope=2锛夎鐩栬閮ㄩ棬鐨勫彂甯冧腑妯℃澘锛屽鍛戒腑鍙栨渶杩戝垱寤鸿€?/li>
+ * <li>鍏滃簳锛氭墍鏈夐儴闂ㄩ€氱敤锛坅pply_scope=1锛夌殑鍙戝竷涓粯璁ゆā鏉?/li>
+ * </ul>
+ *
+ * @param deptId 鍟嗘満璐熻矗浜洪儴闂紙鍙┖锛氫粎璧伴粯璁ゅ厹搴曪級
+ * @return 鍛戒腑妯℃澘鐗堟湰琛岋紱鏃犱换浣曞彂甯冧腑妯℃澘鍙尮閰嶆椂杩斿洖 {@code null}
+ */
+ OpportunitySchemeCardTemplate resolveBindingVersion(Long deptId);
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunitySchemeCardTemplateServiceImpl.java b/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunitySchemeCardTemplateServiceImpl.java
new file mode 100644
index 0000000..804bd35
--- /dev/null
+++ b/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunitySchemeCardTemplateServiceImpl.java
@@ -0,0 +1,561 @@
+package com.crm.rule.service.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.crm.auth.domain.entity.SysDept;
+import com.crm.auth.service.ISysDeptService;
+import com.crm.base.domain.exception.BusinessErrorException;
+import com.crm.base.domain.result.PageResult;
+import com.crm.base.service.impl.BaseServiceImpl;
+import com.crm.base.utils.PageConverter;
+import com.crm.rule.constant.OpportunityRuleConstants;
+import com.crm.rule.domain.dto.OpportunitySchemeCardTemplateDTO;
+import com.crm.rule.domain.dto.OpportunitySchemeFieldDefDTO;
+import com.crm.rule.domain.entity.OpportunitySchemeCardField;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplateDept;
+import com.crm.rule.domain.entity.OpportunitySchemeFieldDef;
+import com.crm.rule.domain.param.OpportunitySchemeCardTemplatePageParam;
+import com.crm.rule.mapper.OpportunitySchemeCardFieldMapper;
+import com.crm.rule.mapper.OpportunitySchemeCardTemplateDeptMapper;
+import com.crm.rule.mapper.OpportunitySchemeCardTemplateMapper;
+import com.crm.rule.mapper.OpportunitySchemeFieldDefMapper;
+import com.crm.rule.service.IOpportunitySchemeCardTemplateService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 鏂规鍗℃ā鏉跨増鏈姸鎬佹満瀹炵幇锛堢エ 07銆屼簩銆? 鍘熷瀷 A7-3-2-2锛?+ *
+ * <p>V-CONFIG 鐗堟湰鍖栭厤缃寖寮忕涓変釜瀹炰緥锛堢エ 05 鍏捣瑙勫垯 / 绁?06 闃舵妯℃澘鍚屾棌涓嶅叡琛級锛?+ * 鍚屼竴 template_code 澶氱増鏈瘡鐗堟湰涓€琛岋紱鍚?code 浠呬竴鑽夌涓€鍙戝竷涓紱鍙戝竷浣垮師鍙戝竷涓浆鍋滅敤銆?+ * 鍘熼粯璁よ嚜鍔ㄥ彇娑堥粯璁わ紱浠呰崏绋垮彲鍒狅紱缂栬緫鍙戝竷涓?鍋滅敤鈫掔敓鏂拌崏绋匡紱澶嶅埗鈫掓柊 code 鏂颁富浣擄紱
+ * 缁戝畾瀹炰綋锛堟柟妗堝崱锛夐攣鐗堟湰琛?id锛屽彂鏂扮増涓嶈縼绉汇€?/p>
+ *
+ * <p>涓庨樁娈垫ā鏉跨殑宸紓鍖栫害瀹氾紙绁?07銆?.2銆嶏級锛?/p>
+ * <ul>
+ * <li>閫傜敤鑼冨洿涓ゆ。锛堜笌绁?05 鍚屻€佷笌绁?06 涓夋。涓嶅悓锛夛細閫氱敤鍙榛樿锛岄儴闂ㄤ笓鐢ㄥ繀閫夐儴闂ㄤ笖涓嶅厑璁歌榛樿锛?/li>
+ * <li>瀛楁椤瑰紩绯荤粺瀛楁搴擄紙闈炶嚜鐢遍€狅級锛氬瓧娈靛悕/绫诲瀷淇濆瓨鏃跺揩鐓у啓鍏ワ紙闃蹭吉閫狅級锛?+ * 瀛楁涓嶅湪搴撳唴/鍚岀増閲嶅寮曠敤鐩存帴鎷掔粷锛涘垹瀛楁浠呭奖鍝嶅綋鍓嶈崏绋跨増鏈紝涓嶅垹绯荤粺瀛楁瀹氫箟锛堝師鍨嬩笟鍔¤鍒?10锛夛紱</li>
+ * <li>鏄剧ず/蹇呭~纭害鏉燂細{@code is_visible=0} 寮哄埗 {@code is_required=0}锛屼繚瀛樻椂瑙勮寖鍖栵紱</li>
+ * <li>鍙戝竷鏍¢獙锛氳嚦灏戜竴涓瓧娈甸」锛堟棤鍥哄畾鑺傜偣姒傚康锛屽紓浜庣エ 06锛夛紱</li>
+ * <li>缁戝畾鍖归厤涓庣エ 06 鍚屽彛寰勶細閮ㄩ棬涓撶敤浼樺厛澶氬懡涓彇鏈€杩戝垱寤鸿€咃紝閫氱敤榛樿鍏滃簳銆?/li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class OpportunitySchemeCardTemplateServiceImpl
+ extends BaseServiceImpl<OpportunitySchemeCardTemplateMapper, OpportunitySchemeCardTemplate>
+ implements IOpportunitySchemeCardTemplateService {
+
+ private final OpportunitySchemeCardTemplateDeptMapper deptMapper;
+ private final OpportunitySchemeCardFieldMapper fieldMapper;
+ private final OpportunitySchemeFieldDefMapper fieldDefMapper;
+ private final ISysDeptService sysDeptService;
+
+ // ==================== 鏌ヨ ====================
+
+ @Override
+ public PageResult<OpportunitySchemeCardTemplateDTO> pageTemplates(OpportunitySchemeCardTemplatePageParam param) {
+ String keyword = param.getKeyword();
+ Page<OpportunitySchemeCardTemplate> page = this.page(PageConverter.toMpPage(param),
+ new LambdaQueryWrapper<OpportunitySchemeCardTemplate>()
+ .and(StrUtil.isNotBlank(keyword), w -> w
+ .like(OpportunitySchemeCardTemplate::getTemplateName, keyword)
+ .or().like(OpportunitySchemeCardTemplate::getTemplateCode, keyword)
+ .or().like(OpportunitySchemeCardTemplate::getVersionNo, keyword))
+ .eq(param.getApplyScope() != null,
+ OpportunitySchemeCardTemplate::getApplyScope, param.getApplyScope())
+ .eq(param.getStatus() != null,
+ OpportunitySchemeCardTemplate::getStatus, param.getStatus())
+ .orderByDesc(OpportunitySchemeCardTemplate::getCreateTime));
+
+ PageResult<OpportunitySchemeCardTemplateDTO> result =
+ new PageResult<>(page).convert(OpportunitySchemeCardTemplateDTO::fromEntity);
+ fillDeptNames(result.getContent());
+ return result;
+ }
+
+ @Override
+ public OpportunitySchemeCardTemplateDTO getTemplateDetail(Long id) {
+ OpportunitySchemeCardTemplate template = getTemplateByIdOrThrow(id);
+ OpportunitySchemeCardTemplateDTO dto = OpportunitySchemeCardTemplateDTO.fromEntity(template);
+ fillDeptNames(List.of(dto));
+ dto.setFields(listFieldsOfVersion(id).stream()
+ .map(OpportunitySchemeCardTemplateServiceImpl::toFieldItem)
+ .collect(Collectors.toList()));
+ return dto;
+ }
+
+ @Override
+ public List<OpportunitySchemeFieldDefDTO> listFieldDefs() {
+ return fieldDefMapper.selectList(new LambdaQueryWrapper<OpportunitySchemeFieldDef>()
+ .orderByAsc(OpportunitySchemeFieldDef::getId))
+ .stream().map(OpportunitySchemeFieldDefDTO::fromEntity).collect(Collectors.toList());
+ }
+
+ @Override
+ public OpportunitySchemeCardTemplate getVersionById(Long templateVersionId) {
+ if (templateVersionId == null) {
+ return null;
+ }
+ return this.getById(templateVersionId);
+ }
+
+ @Override
+ public List<OpportunitySchemeCardField> listFieldsOfVersion(Long templateVersionId) {
+ if (templateVersionId == null) {
+ return List.of();
+ }
+ return fieldMapper.selectList(new LambdaQueryWrapper<OpportunitySchemeCardField>()
+ .eq(OpportunitySchemeCardField::getTemplateId, templateVersionId)
+ .orderByAsc(OpportunitySchemeCardField::getSeqNo));
+ }
+
+ @Override
+ public OpportunitySchemeCardTemplate resolveBindingVersion(Long deptId) {
+ List<OpportunitySchemeCardTemplate> published = this.list(
+ new LambdaQueryWrapper<OpportunitySchemeCardTemplate>()
+ .eq(OpportunitySchemeCardTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
+ if (CollUtil.isEmpty(published)) {
+ return null;
+ }
+ if (deptId != null) {
+ Map<Long, OpportunitySchemeCardTemplate> specificById = published.stream()
+ .filter(t -> Integer.valueOf(OpportunityRuleConstants.APPLY_SCOPE_DEPT)
+ .equals(t.getApplyScope()))
+ .collect(Collectors.toMap(OpportunitySchemeCardTemplate::getId, t -> t, (first, dup) -> first));
+ if (!specificById.isEmpty()) {
+ List<OpportunitySchemeCardTemplateDept> rows = deptMapper.selectList(
+ new LambdaQueryWrapper<OpportunitySchemeCardTemplateDept>()
+ .in(OpportunitySchemeCardTemplateDept::getTemplateId, specificById.keySet())
+ .eq(OpportunitySchemeCardTemplateDept::getDeptId, deptId));
+ OpportunitySchemeCardTemplate hit = rows.stream()
+ .map(row -> specificById.get(row.getTemplateId()))
+ .filter(Objects::nonNull)
+ // 涓庣エ 06 鍚屽彛寰勶細鏃犻儴闂ㄨ鐩栧敮涓€绾︽潫锛屽鍛戒腑鍙栨渶杩戝垱寤鸿€咃紝淇濊瘉缁戝畾纭畾
+ .max(Comparator.comparing(OpportunitySchemeCardTemplate::getCreateTime,
+ Comparator.nullsFirst(Comparator.naturalOrder())))
+ .orElse(null);
+ if (hit != null) {
+ return hit;
+ }
+ }
+ }
+ return published.stream()
+ .filter(t -> Integer.valueOf(OpportunityRuleConstants.APPLY_SCOPE_ALL)
+ .equals(t.getApplyScope()))
+ .filter(t -> Integer.valueOf(OpportunityRuleConstants.FLAG_YES)
+ .equals(t.getIsDefault()))
+ .findFirst()
+ .orElse(null);
+ }
+
+ // ==================== 淇濆瓨鑽夌 / 淇濆瓨骞跺彂甯?====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void saveDraft(OpportunitySchemeCardTemplateDTO dto) {
+ validateTemplate(dto);
+ resolveDraft(dto);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void saveAndPublish(OpportunitySchemeCardTemplateDTO dto) {
+ validateTemplate(dto);
+ validateForPublish(dto);
+ OpportunitySchemeCardTemplate draft = resolveDraft(dto);
+ publish(draft);
+ }
+
+ /**
+ * 钀借崏绋夸笁鍒嗘敮锛氭柊寤猴紙鏂扮紪鐮?+ V1.0锛? 鍘熶綅缂栬緫鑽夌 / 浠庡彂甯冧腑-鍋滅敤鐢熸垚鏂拌崏绋匡紙minor 椤哄欢锛夈€?+ * 瀛楁椤?+ 閫傜敤閮ㄩ棬瀛愯〃鍧囬殢鐗堟湰鍏ㄩ噺鏇挎崲銆?+ *
+ * @return 宸叉寔涔呭寲鐨勮崏绋胯锛堝惈 id锛?+ */
+ private OpportunitySchemeCardTemplate resolveDraft(OpportunitySchemeCardTemplateDTO dto) {
+ if (dto.getId() == null) {
+ OpportunitySchemeCardTemplate template = dto.toEntity();
+ template.setTemplateCode(nextTemplateCode());
+ template.setVersionNo("V1.0");
+ template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ this.save(template);
+ replaceChildRows(template.getId(), dto);
+ return template;
+ }
+
+ OpportunitySchemeCardTemplate exist = getTemplateByIdOrThrow(dto.getId());
+ if (exist.getStatus() != null && exist.getStatus() == OpportunityRuleConstants.RULE_STATUS_DRAFT) {
+ // 鑽夌鍘熶綅缂栬緫锛氱紪鐮?鐗堟湰鍙?鐘舵€佷笉鍙橈紙鍙瀛楁闃插墠绔吉閫狅級
+ OpportunitySchemeCardTemplate template = dto.toEntity();
+ template.setTemplateCode(exist.getTemplateCode());
+ template.setVersionNo(exist.getVersionNo());
+ template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ this.updateById(template);
+ replaceChildRows(exist.getId(), dto);
+ return template;
+ }
+
+ // 鍙戝竷涓?鍋滅敤 鈫?鐢熸垚鏂拌崏绋匡紱鍚?code 宸叉湁鑽夌鍒欐嫆缁濓紙杩涘凡鏈夎崏绋跨紪杈戯級
+ List<OpportunitySchemeCardTemplate> versions = this.list(
+ new LambdaQueryWrapper<OpportunitySchemeCardTemplate>()
+ .eq(OpportunitySchemeCardTemplate::getTemplateCode, exist.getTemplateCode()));
+ boolean hasDraft = versions.stream().anyMatch(v ->
+ v.getStatus() != null && v.getStatus() == OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ if (hasDraft) {
+ throw new BusinessErrorException(OpportunityRuleConstants.CODE_SCHEME_TPL_DRAFT_EXISTS,
+ "璇ユā鏉垮凡鏈夎崏绋跨増鏈紝璇疯繘鍏ヨ崏绋跨紪杈?);
+ }
+ OpportunitySchemeCardTemplate template = dto.toEntity();
+ template.setTemplateCode(exist.getTemplateCode());
+ template.setVersionNo(nextVersionNo(versions.stream()
+ .map(OpportunitySchemeCardTemplate::getVersionNo).collect(Collectors.toList())));
+ template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ this.save(template);
+ replaceChildRows(template.getId(), dto);
+ return template;
+ }
+
+ /**
+ * 鍙戝竷锛氬師鍙戝竷涓浆鍋滅敤锛堝悓 code 鑷垜椤舵浛锛夆啋 鏂伴粯璁ら《鏇挎棫榛樿 鈫?鏈増鏈浆鍙戝竷涓€?+ * 瀛楁椤瑰畬鏁存€ф牎楠屽墠缃湪 {@link #validateForPublish}銆?+ */
+ private void publish(OpportunitySchemeCardTemplate template) {
+ List<OpportunitySchemeCardTemplate> olds = this.list(
+ new LambdaQueryWrapper<OpportunitySchemeCardTemplate>()
+ .eq(OpportunitySchemeCardTemplate::getTemplateCode, template.getTemplateCode())
+ .eq(OpportunitySchemeCardTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED)
+ .ne(OpportunitySchemeCardTemplate::getId, template.getId()));
+ for (OpportunitySchemeCardTemplate old : olds) {
+ OpportunitySchemeCardTemplate change = new OpportunitySchemeCardTemplate();
+ change.setId(old.getId());
+ change.setStatus(OpportunityRuleConstants.RULE_STATUS_DISABLED);
+ this.updateById(change);
+ }
+
+ // 鏂伴粯璁ら《鏇挎棫榛樿锛堝彂甯冧腑榛樿鍞竴锛岃嚜鍔ㄥ彇娑堣€岄潪鎷掔粷锛沄-CONFIG 6锛?+ if (template.getIsDefault() != null && template.getIsDefault() == OpportunityRuleConstants.FLAG_YES) {
+ List<OpportunitySchemeCardTemplate> oldDefaults = this.list(
+ new LambdaQueryWrapper<OpportunitySchemeCardTemplate>()
+ .eq(OpportunitySchemeCardTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED)
+ .eq(OpportunitySchemeCardTemplate::getIsDefault, OpportunityRuleConstants.FLAG_YES)
+ .ne(OpportunitySchemeCardTemplate::getId, template.getId()));
+ for (OpportunitySchemeCardTemplate old : oldDefaults) {
+ OpportunitySchemeCardTemplate change = new OpportunitySchemeCardTemplate();
+ change.setId(old.getId());
+ change.setIsDefault(OpportunityRuleConstants.FLAG_NO);
+ this.updateById(change);
+ }
+ }
+
+ OpportunitySchemeCardTemplate self = new OpportunitySchemeCardTemplate();
+ self.setId(template.getId());
+ self.setStatus(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ this.updateById(self);
+ }
+
+ // ==================== 鍋滅敤 / 鍒犻櫎 / 澶嶅埗锛堣鎿嶄綔鐭╅樀锛?====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void disableTemplate(Long id) {
+ OpportunitySchemeCardTemplate exist = getTemplateByIdOrThrow(id);
+ if (exist.getStatus() == null || exist.getStatus() != OpportunityRuleConstants.RULE_STATUS_PUBLISHED) {
+ throw new BusinessErrorException(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID,
+ "浠呭彂甯冧腑鐨勬ā鏉垮彲鍋滅敤");
+ }
+ OpportunitySchemeCardTemplate change = new OpportunitySchemeCardTemplate();
+ change.setId(id);
+ change.setStatus(OpportunityRuleConstants.RULE_STATUS_DISABLED);
+ this.updateById(change);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void deleteDraft(Long id) {
+ OpportunitySchemeCardTemplate exist = getTemplateByIdOrThrow(id);
+ if (exist.getStatus() == null || exist.getStatus() != OpportunityRuleConstants.RULE_STATUS_DRAFT) {
+ throw new BusinessErrorException(OpportunityRuleConstants.CODE_SCHEME_TPL_NOT_DRAFT,
+ "浠呰崏绋跨増鏈彲鍒犻櫎");
+ }
+ this.removeById(id);
+ deptMapper.delete(new LambdaQueryWrapper<OpportunitySchemeCardTemplateDept>()
+ .eq(OpportunitySchemeCardTemplateDept::getTemplateId, id));
+ fieldMapper.delete(new LambdaQueryWrapper<OpportunitySchemeCardField>()
+ .eq(OpportunitySchemeCardField::getTemplateId, id));
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long copyTemplate(Long id) {
+ OpportunitySchemeCardTemplate source = getTemplateByIdOrThrow(id);
+
+ OpportunitySchemeCardTemplate copy = new OpportunitySchemeCardTemplate();
+ copy.setTemplateCode(nextTemplateCode());
+ copy.setTemplateName(source.getTemplateName());
+ copy.setVersionNo("V1.0");
+ copy.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ copy.setApplyScope(source.getApplyScope());
+ copy.setIsDefault(source.getIsDefault());
+ copy.setTemplateDesc(source.getTemplateDesc());
+ this.save(copy);
+
+ // 閫傜敤閮ㄩ棬闅忓鍒?+ deptMapper.selectList(new LambdaQueryWrapper<OpportunitySchemeCardTemplateDept>()
+ .eq(OpportunitySchemeCardTemplateDept::getTemplateId, id))
+ .forEach(row -> {
+ OpportunitySchemeCardTemplateDept target = new OpportunitySchemeCardTemplateDept();
+ target.setTemplateId(copy.getId());
+ target.setDeptId(row.getDeptId());
+ deptMapper.insert(target);
+ });
+
+ // 瀛楁椤归殢澶嶅埗锛堜繚搴忎繚蹇収锛?+ listFieldsOfVersion(id).forEach(field -> {
+ OpportunitySchemeCardField target = new OpportunitySchemeCardField();
+ target.setTemplateId(copy.getId());
+ target.setSeqNo(field.getSeqNo());
+ target.setFieldKey(field.getFieldKey());
+ target.setFieldName(field.getFieldName());
+ target.setFieldType(field.getFieldType());
+ target.setIsVisible(field.getIsVisible());
+ target.setIsRequired(field.getIsRequired());
+ fieldMapper.insert(target);
+ });
+ return copy.getId();
+ }
+
+ // ==================== 鏍¢獙 ====================
+
+ /**
+ * 澶?+ 瀛楁椤瑰熀纭€鏍¢獙锛堣崏绋?鍙戝竷鍏辩敤锛夛細
+ * 涓ゆ。閫傜敤鑼冨洿绾︽潫锛堥儴闂ㄤ笓鐢ㄥ繀閫夐儴闂ㄤ笖涓嶅彲璁鹃粯璁わ級锛涘瓧娈甸敭蹇呭~銆佷笉閲嶅銆侀』瀛樺湪浜庣郴缁熷瓧娈靛簱銆?+ */
+ private void validateTemplate(OpportunitySchemeCardTemplateDTO dto) {
+ if (dto == null) {
+ throw invalid("璇锋眰鍙傛暟缂哄け");
+ }
+ if (StrUtil.isBlank(dto.getTemplateName())) {
+ throw invalid("妯℃澘鍚嶇О涓嶈兘涓虹┖");
+ }
+ Integer scope = dto.getApplyScope();
+ if (scope == null || (scope != OpportunityRuleConstants.APPLY_SCOPE_ALL
+ && scope != OpportunityRuleConstants.APPLY_SCOPE_DEPT)) {
+ throw invalid("閫傜敤鑼冨洿鍙傛暟闈炴硶");
+ }
+ Integer isDefault = dto.getIsDefault();
+ if (isDefault == null || (isDefault != OpportunityRuleConstants.FLAG_NO
+ && isDefault != OpportunityRuleConstants.FLAG_YES)) {
+ throw invalid("鏄惁榛樿鍙傛暟闈炴硶");
+ }
+ if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT
+ && isDefault == OpportunityRuleConstants.FLAG_YES) {
+ throw invalid("閮ㄩ棬涓撶敤妯℃澘涓嶅厑璁歌涓洪粯璁わ紙绁?07銆?.2銆嶏細浠呮墍鏈夐儴闂ㄩ€氱敤鍙榛樿锛?);
+ }
+ if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT && CollUtil.isEmpty(dto.getDeptIds())) {
+ throw invalid("閮ㄩ棬涓撶敤妯℃澘椤婚€夋嫨鑷冲皯涓€涓€傜敤閮ㄩ棬");
+ }
+
+ Map<String, OpportunitySchemeFieldDef> defByKey = null; // 鎳掑姞杞斤細鏃犲瓧娈甸」鏃朵笉鏌ュ瓧娈靛簱
+ Set<String> seen = new HashSet<>();
+ for (OpportunitySchemeCardTemplateDTO.FieldItem field : safeFields(dto)) {
+ if (StrUtil.isBlank(field.getFieldKey())) {
+ throw fieldInvalid("瀛楁椤圭殑瀛楁閿笉鑳戒负绌猴紙淇濆瓨鏃剁粺涓€鏍¢獙鎵€鏈夊瓧娈甸」鏃犳湭瀹屾垚閰嶇疆锛?);
+ }
+ if (!seen.add(field.getFieldKey())) {
+ throw fieldInvalid("鍚屼竴妯℃澘鐗堟湰涓嶅彲閲嶅寮曠敤瀛楁锛? + field.getFieldKey());
+ }
+ if (defByKey == null) {
+ defByKey = fieldDefMapper.selectList(new LambdaQueryWrapper<>()).stream()
+ .collect(Collectors.toMap(OpportunitySchemeFieldDef::getFieldKey,
+ Function.identity(), (first, dup) -> first));
+ }
+ // 瀛楁鏉ユ簮=绯荤粺瀛楁搴擄紙闈炶嚜鐢遍€狅紝绁?07銆?.4銆嶏級锛氫笉鍦ㄥ簱鍐呯殑瀛楁閿洿鎺ユ嫆缁?+ if (!defByKey.containsKey(field.getFieldKey())) {
+ throw fieldInvalid("瀛楁椤诲紩鐢ㄧ郴缁熷瓧娈靛畾涔夛細" + field.getFieldKey());
+ }
+ }
+ }
+
+ /** 鍙戝竷闄勫姞鏍¢獙锛氳嚦灏戦厤缃竴涓瓧娈甸」锛堟棤瀛楁鐨勬ā鏉垮彂甯冨悗鏃犳硶濉啓锛?*/
+ private void validateForPublish(OpportunitySchemeCardTemplateDTO dto) {
+ if (safeFields(dto).isEmpty()) {
+ throw fieldInvalid("鏂规鍗℃ā鏉垮彂甯冨墠鑷冲皯閰嶇疆涓€涓瓧娈?);
+ }
+ }
+
+ private List<OpportunitySchemeCardTemplateDTO.FieldItem> safeFields(OpportunitySchemeCardTemplateDTO dto) {
+ return dto.getFields() == null ? List.of() : dto.getFields().stream()
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ }
+
+ private BusinessErrorException invalid(String message) {
+ return new BusinessErrorException(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID, message);
+ }
+
+ private BusinessErrorException fieldInvalid(String message) {
+ return new BusinessErrorException(OpportunityRuleConstants.CODE_SCHEME_TPL_FIELD_INVALID, message);
+ }
+
+ // ==================== 鍐呴儴鏂规硶 ====================
+
+ private OpportunitySchemeCardTemplate getTemplateByIdOrThrow(Long id) {
+ OpportunitySchemeCardTemplate template = this.getById(id);
+ if (template == null) {
+ throw new BusinessErrorException(OpportunityRuleConstants.CODE_SCHEME_TPL_NOT_EXIST,
+ "妯℃澘鐗堟湰涓嶅瓨鍦ㄦ垨宸茶鍒犻櫎");
+ }
+ return template;
+ }
+
+ /**
+ * 瀛愯〃鍏ㄩ噺鏇挎崲锛氶€傜敤閮ㄩ棬 + 瀛楁椤癸紙鍏堝垹鍚庢彃锛夈€?+ * 瀛楁瑙勮寖鍖栵細鍏ュ弬椤哄簭=灞曠ず椤哄簭锛坰eq 1..n锛夛紱瀛楁鍚?绫诲瀷鍙栫郴缁熷瓧娈靛畾涔夊揩鐓э紙闃蹭吉閫狅級锛?+ * 鏄剧ず/蹇呭~纭害鏉熲€斺€斿叧鏄剧ず鍚屾娓呭繀濉紙绁?07銆?.3銆嶏級銆?+ */
+ private void replaceChildRows(Long templateVersionId, OpportunitySchemeCardTemplateDTO dto) {
+ deptMapper.delete(new LambdaQueryWrapper<OpportunitySchemeCardTemplateDept>()
+ .eq(OpportunitySchemeCardTemplateDept::getTemplateId, templateVersionId));
+ if (dto.getApplyScope() != null
+ && dto.getApplyScope() == OpportunityRuleConstants.APPLY_SCOPE_DEPT
+ && CollUtil.isNotEmpty(dto.getDeptIds())) {
+ for (Long deptId : dto.getDeptIds()) {
+ if (deptId == null) {
+ continue;
+ }
+ OpportunitySchemeCardTemplateDept row = new OpportunitySchemeCardTemplateDept();
+ row.setTemplateId(templateVersionId);
+ row.setDeptId(deptId);
+ deptMapper.insert(row);
+ }
+ }
+
+ Map<String, OpportunitySchemeFieldDef> defByKey = fieldDefMapper.selectList(new LambdaQueryWrapper<>())
+ .stream().collect(Collectors.toMap(OpportunitySchemeFieldDef::getFieldKey,
+ Function.identity(), (first, dup) -> first));
+
+ fieldMapper.delete(new LambdaQueryWrapper<OpportunitySchemeCardField>()
+ .eq(OpportunitySchemeCardField::getTemplateId, templateVersionId));
+ int seq = 1;
+ for (OpportunitySchemeCardTemplateDTO.FieldItem item : safeFields(dto)) {
+ OpportunitySchemeFieldDef def = defByKey.get(item.getFieldKey());
+ if (def == null) {
+ // validateTemplate 宸叉嫆缁濓紝姝ゅ闃插尽鎬ц烦杩?+ continue;
+ }
+ int visible = item.getIsVisible() != null
+ && item.getIsVisible() == OpportunityRuleConstants.FLAG_YES
+ ? OpportunityRuleConstants.FLAG_YES : OpportunityRuleConstants.FLAG_NO;
+ // 纭害鏉燂細浠呮樉绀烘椂鍙负蹇呭~锛涘叧鏄剧ず鍚屾娓呭繀濉?+ int required = visible == OpportunityRuleConstants.FLAG_YES
+ && item.getIsRequired() != null
+ && item.getIsRequired() == OpportunityRuleConstants.FLAG_YES
+ ? OpportunityRuleConstants.FLAG_YES : OpportunityRuleConstants.FLAG_NO;
+
+ OpportunitySchemeCardField field = new OpportunitySchemeCardField();
+ field.setTemplateId(templateVersionId);
+ field.setSeqNo(seq++);
+ field.setFieldKey(def.getFieldKey());
+ field.setFieldName(def.getFieldName());
+ field.setFieldType(def.getFieldType());
+ field.setIsVisible(visible);
+ field.setIsRequired(required);
+ fieldMapper.insert(field);
+ }
+ }
+
+ private static OpportunitySchemeCardTemplateDTO.FieldItem toFieldItem(OpportunitySchemeCardField field) {
+ OpportunitySchemeCardTemplateDTO.FieldItem item = new OpportunitySchemeCardTemplateDTO.FieldItem();
+ item.setId(field.getId());
+ item.setSeqNo(field.getSeqNo());
+ item.setFieldKey(field.getFieldKey());
+ item.setFieldName(field.getFieldName());
+ item.setFieldType(field.getFieldType());
+ item.setIsVisible(field.getIsVisible());
+ item.setIsRequired(field.getIsRequired());
+ return item;
+ }
+
+ /** 鎵归噺鍥炴樉閫傜敤閮ㄩ棬 ID 涓庡悕绉帮紙涓€娆″瓙琛ㄦ煡璇?+ 涓€娆¢儴闂ㄥ悕鏌ヨ锛?*/
+ private void fillDeptNames(List<OpportunitySchemeCardTemplateDTO> dtos) {
+ if (CollUtil.isEmpty(dtos)) {
+ return;
+ }
+ List<OpportunitySchemeCardTemplateDept> rows = deptMapper.selectList(
+ new LambdaQueryWrapper<OpportunitySchemeCardTemplateDept>()
+ .in(OpportunitySchemeCardTemplateDept::getTemplateId,
+ dtos.stream().map(OpportunitySchemeCardTemplateDTO::getId)
+ .filter(Objects::nonNull).collect(Collectors.toSet())));
+ Map<Long, List<Long>> versionToDepts = rows.stream().collect(Collectors.groupingBy(
+ OpportunitySchemeCardTemplateDept::getTemplateId,
+ Collectors.mapping(OpportunitySchemeCardTemplateDept::getDeptId, Collectors.toList())));
+
+ Set<Long> deptIds = rows.stream().map(OpportunitySchemeCardTemplateDept::getDeptId)
+ .filter(Objects::nonNull).collect(Collectors.toSet());
+ Map<Long, String> nameMap = deptIds.isEmpty() ? Map.of()
+ : sysDeptService.listByIds(deptIds).stream()
+ .collect(Collectors.toMap(SysDept::getId, SysDept::getDeptName));
+
+ dtos.forEach(dto -> {
+ List<Long> ids = versionToDepts.getOrDefault(dto.getId(), List.of());
+ dto.setDeptIds(ids);
+ dto.setDeptNames(ids.stream().map(nameMap::get)
+ .filter(Objects::nonNull).collect(Collectors.toList()));
+ });
+ }
+
+ /** 妯℃澘缂栫爜椤哄欢锛歄PP_SCHEME_TPL_ + 鐜版湁鏈€澶у簭鍙?1锛堜袱浣嶈ˉ闆讹級 */
+ private String nextTemplateCode() {
+ int max = this.list(new LambdaQueryWrapper<OpportunitySchemeCardTemplate>()
+ .likeRight(OpportunitySchemeCardTemplate::getTemplateCode,
+ OpportunityRuleConstants.SCHEME_TPL_CODE_PREFIX))
+ .stream().map(OpportunitySchemeCardTemplate::getTemplateCode)
+ .filter(Objects::nonNull)
+ .map(code -> code.substring(OpportunityRuleConstants.SCHEME_TPL_CODE_PREFIX.length()))
+ .filter(suffix -> suffix.matches("\\d+"))
+ .mapToInt(Integer::parseInt)
+ .max().orElse(0);
+ return OpportunityRuleConstants.SCHEME_TPL_CODE_PREFIX + String.format("%02d", max + 1);
+ }
+
+ /**
+ * 鐗堟湰鍙?minor 椤哄欢锛氬彇鍏ㄧ増鏈渶澶?(major, minor) 鐨?minor+1锛坢ajor 涓嶅姩锛夛紱
+ * 鏃犲巻鍙茬増鏈?鈫?V1.0锛涢潪娉曟牸寮忚闃插尽鎬у拷鐣ャ€傦紙鍚岀エ 05/06 鍙e緞锛孷-CONFIG 2锛?+ */
+ static String nextVersionNo(List<String> versionNos) {
+ int bestMajor = -1;
+ int bestMinor = -1;
+ for (String v : new ArrayList<>(versionNos)) {
+ if (v == null || !v.matches("V\\d+\\.\\d+")) {
+ continue;
+ }
+ String[] parts = v.substring(1).split("\\.");
+ int major = Integer.parseInt(parts[0]);
+ int minor = Integer.parseInt(parts[1]);
+ if (major > bestMajor || (major == bestMajor && minor > bestMinor)) {
+ bestMajor = major;
+ bestMinor = minor;
+ }
+ }
+ if (bestMajor < 0) {
+ return "V1.0";
+ }
+ return "V" + bestMajor + "." + (bestMinor + 1);
+ }
+}
diff --git a/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunitySchemeCardTemplateServiceImplTest.java b/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunitySchemeCardTemplateServiceImplTest.java
new file mode 100644
index 0000000..93be845
--- /dev/null
+++ b/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunitySchemeCardTemplateServiceImplTest.java
@@ -0,0 +1,680 @@
+package com.crm.rule.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.crm.auth.domain.entity.SysDept;
+import com.crm.auth.service.ISysDeptService;
+import com.crm.base.domain.exception.BusinessErrorException;
+import com.crm.rule.constant.OpportunityRuleConstants;
+import com.crm.rule.domain.dto.OpportunitySchemeCardTemplateDTO;
+import com.crm.rule.domain.entity.OpportunitySchemeCardField;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplate;
+import com.crm.rule.domain.entity.OpportunitySchemeCardTemplateDept;
+import com.crm.rule.domain.entity.OpportunitySchemeFieldDef;
+import com.crm.rule.mapper.OpportunitySchemeCardFieldMapper;
+import com.crm.rule.mapper.OpportunitySchemeCardTemplateDeptMapper;
+import com.crm.rule.mapper.OpportunitySchemeCardTemplateMapper;
+import com.crm.rule.mapper.OpportunitySchemeFieldDefMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 鏂规鍗℃ā鏉跨増鏈姸鎬佹満瑙勬牸楠岃瘉锛堢エ 07銆屼簩銆? 鍘熷瀷 A7-3-2-2锛夈€?+ *
+ * <p>鏂█鐐癸細</p>
+ * <ul>
+ * <li>澶?瀛楁椤规牎楠岋細鍚嶇О蹇呭~銆佷袱妗h寖鍥寸害鏉燂紙閮ㄩ棬涓撶敤蹇呴€夐儴闂ㄤ笖涓嶅彲璁鹃粯璁わ紝绁?07銆?.2銆嶏級銆?+ * 瀛楁閿繀濉?涓嶉噸澶?椤诲睘绯荤粺瀛楁搴擄紙闈炶嚜鐢遍€狅紝绁?07銆?.4銆嶏紝64022锛?/li>
+ * <li>淇濆瓨鑽夌涓夊垎鏀細鏂板缓锛圴1.0 + 缂栫爜椤哄欢锛? 鍘熶綅缂栬緫 / 鍙戝竷涓?鍋滅敤鐢熸柊鑽夌锛坢inor 椤哄欢锛?+ * 鍚?code 宸叉湁鑽夌鎷掔粷 64021锛?/li>
+ * <li>瀛楁瑙勮寖鍖栵細鍏ュ弬椤哄簭=seq 1..n銆佸瓧娈靛悕/绫诲瀷蹇収闃蹭吉閫犮€佸叧鏄剧ず鍚屾娓呭繀濉紙纭害鏉燂級</li>
+ * <li>鍙戝竷锛氳嚦灏戜竴涓瓧娈甸」锛?4022锛夈€佸師鍙戝竷涓浆鍋滅敤锛堣嚜鎴戦《鏇匡級銆佹柊榛樿椤舵浛鏃ч粯璁?/li>
+ * <li>琛屾搷浣滅煩闃碉細浠呰崏绋垮彲鍒狅紙64020锛岀骇鑱斿垹閮ㄩ棬+瀛楁锛夈€佷粎鍙戝竷涓彲鍋滅敤銆佸鍒?鈫?鏂扮紪鐮?+ V1.0</li>
+ * <li>缁戝畾鍖归厤锛氶儴闂ㄤ笓鐢ㄥ彂甯冩ā鏉夸紭鍏堬紙澶氬懡涓彇鏈€杩戝垱寤猴級锛屽厹搴?閫氱敤+榛樿</li>
+ * </ul>
+ */
+@DisplayName("鏂规鍗℃ā鏉跨増鏈姸鎬佹満锛堢エ 07銆屼簩銆嶏級")
+@ExtendWith(MockitoExtension.class)
+class OpportunitySchemeCardTemplateServiceImplTest {
+
+ private static final Long DRAFT_ID = 7201L;
+ private static final Long PUB_ID = 7202L;
+ private static final Long NEW_TPL_ID = 7203L;
+ private static final Long OLD_DEFAULT_ID = 7204L;
+ private static final Long COPY_ID = 7205L;
+ private static final Long DEPT_10 = 5001L;
+ private static final Long DEPT_20 = 5002L;
+ private static final String CODE_C1 = "OPP_SCHEME_TPL_01";
+
+ @Mock private OpportunitySchemeCardTemplateMapper templateMapper;
+ @Mock private OpportunitySchemeCardTemplateDeptMapper deptMapper;
+ @Mock private OpportunitySchemeCardFieldMapper fieldMapper;
+ @Mock private OpportunitySchemeFieldDefMapper fieldDefMapper;
+ @Mock private ISysDeptService sysDeptService;
+
+ @InjectMocks
+ private OpportunitySchemeCardTemplateServiceImpl service;
+
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant =
+ new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, OpportunitySchemeCardTemplate.class);
+ TableInfoHelper.initTableInfo(assistant, OpportunitySchemeCardTemplateDept.class);
+ TableInfoHelper.initTableInfo(assistant, OpportunitySchemeCardField.class);
+ TableInfoHelper.initTableInfo(assistant, OpportunitySchemeFieldDef.class);
+ }
+
+ @BeforeEach
+ void setUp() {
+ // ServiceImpl.getEntityClass() 绾?Mockito 涓嬮渶鎵嬪姩璁惧€硷紝lambdaQuery() 鎵嶈兘宸ヤ綔锛堝悓绁?05/06 娴嬭瘯锛?+ ReflectionTestUtils.setField(service, "baseMapper", templateMapper);
+ ReflectionTestUtils.setField(service, "entityClass", OpportunitySchemeCardTemplate.class);
+ // 榛樿妗╋細绯荤粺瀛楁搴擄紙鏍蜂緥闆嗗瓙闆嗭級锛沴enient锛氭牎楠屽墠鍗冲け璐ョ殑鐢ㄤ緥锛堝悕绉扮┖绛夛級涓嶈Е杈惧瓧娈靛簱鏌ヨ
+ lenient().when(fieldDefMapper.selectList(any())).thenReturn(List.of(
+ defRow("customer_name", "瀹㈡埛鍚嶇О", OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT),
+ defRow("scheme_budget", "鏂规棰勭畻閲戦", OpportunityRuleConstants.FIELD_TYPE_AMOUNT),
+ defRow("bidding_form", "鎷涙爣褰㈠紡", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT),
+ defRow("site_visit", "鏄惁鏈夌湅鐜板満", OpportunityRuleConstants.FIELD_TYPE_YES_NO)));
+ }
+
+ // ==================== 宸ュ巶 ====================
+
+ private OpportunitySchemeCardTemplateDTO validDto() {
+ OpportunitySchemeCardTemplateDTO dto = new OpportunitySchemeCardTemplateDTO();
+ dto.setTemplateName("鏍囧噯鏂规鍗℃ā鏉?);
+ dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL);
+ dto.setIsDefault(OpportunityRuleConstants.FLAG_NO);
+ dto.setFields(List.of(
+ fieldItem("customer_name", 1, 1),
+ fieldItem("scheme_budget", 1, 0)));
+ return dto;
+ }
+
+ private OpportunitySchemeCardTemplateDTO.FieldItem fieldItem(String key, int visible, int required) {
+ OpportunitySchemeCardTemplateDTO.FieldItem item = new OpportunitySchemeCardTemplateDTO.FieldItem();
+ item.setFieldKey(key);
+ item.setIsVisible(visible);
+ item.setIsRequired(required);
+ return item;
+ }
+
+ private OpportunitySchemeFieldDef defRow(String key, String name, String type) {
+ OpportunitySchemeFieldDef def = new OpportunitySchemeFieldDef();
+ def.setFieldKey(key);
+ def.setFieldName(name);
+ def.setFieldType(type);
+ def.setIsDeletable(1);
+ return def;
+ }
+
+ private OpportunitySchemeCardTemplate tplRow(Long id, String code, String versionNo, int status) {
+ OpportunitySchemeCardTemplate t = new OpportunitySchemeCardTemplate();
+ t.setId(id);
+ t.setTemplateCode(code);
+ t.setTemplateName("妯℃澘" + code);
+ t.setVersionNo(versionNo);
+ t.setStatus(status);
+ t.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL);
+ t.setIsDefault(OpportunityRuleConstants.FLAG_NO);
+ return t;
+ }
+
+ private OpportunitySchemeCardField fieldRow(Long id, Long tplId, int seqNo, String key,
+ int visible, int required) {
+ OpportunitySchemeCardField f = new OpportunitySchemeCardField();
+ f.setId(id);
+ f.setTemplateId(tplId);
+ f.setSeqNo(seqNo);
+ f.setFieldKey(key);
+ f.setFieldName("鍚嶇О" + key);
+ f.setFieldType(OpportunityRuleConstants.FIELD_TYPE_TEXT_SINGLE);
+ f.setIsVisible(visible);
+ f.setIsRequired(required);
+ return f;
+ }
+
+ // ==================== 澶?+ 瀛楁椤规牎楠?====================
+
+ @Test
+ @DisplayName("鍏ュ弬 null 鈫?64018")
+ void nullDtoRejected() {
+ assertThatThrownBy(() -> service.saveDraft(null))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID);
+ }
+
+ @Test
+ @DisplayName("妯℃澘鍚嶇О涓虹┖ 鈫?64018")
+ void blankNameRejected() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setTemplateName(" ");
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID);
+ }
+
+ @Test
+ @DisplayName("閫傜敤鑼冨洿闈炴硶鍊?鈫?64018")
+ void invalidScopeRejected() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setApplyScope(9);
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID);
+ }
+
+ @Test
+ @DisplayName("閮ㄩ棬涓撶敤涓嶅厑璁歌涓洪粯璁わ紙绁?07銆?.2銆嶄袱妗e樊寮傦級鈫?64018")
+ void deptScopeCannotBeDefault() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ dto.setDeptIds(List.of(DEPT_10));
+ dto.setIsDefault(OpportunityRuleConstants.FLAG_YES);
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID);
+ }
+
+ @Test
+ @DisplayName("閮ㄩ棬涓撶敤鏈€夐儴闂?鈫?64018")
+ void deptScopeWithoutDeptRejected() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ dto.setIsDefault(OpportunityRuleConstants.FLAG_NO);
+ dto.setDeptIds(List.of());
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID);
+ }
+
+ @Test
+ @DisplayName("瀛楁閿负绌?鈫?64022锛堜繚瀛樻椂缁熶竴鏍¢獙鏃犳湭瀹屾垚閰嶇疆锛?)
+ void blankFieldKeyRejected() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setFields(List.of(fieldItem(" ", 1, 0)));
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_FIELD_INVALID);
+ }
+
+ @Test
+ @DisplayName("鍚岀増鏈噸澶嶅紩鐢ㄥ瓧娈?鈫?64022")
+ void duplicateFieldKeyRejected() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setFields(List.of(fieldItem("customer_name", 1, 0), fieldItem("customer_name", 1, 1)));
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_FIELD_INVALID);
+ }
+
+ @Test
+ @DisplayName("瀛楁涓嶅湪绯荤粺瀛楁搴擄紙闈炶嚜鐢遍€狅級鈫?64022")
+ void unknownFieldKeyRejected() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setFields(List.of(fieldItem("made_up_field", 1, 0)));
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_FIELD_INVALID);
+ }
+
+ // ==================== 鑽夌涓夊垎鏀?====================
+
+ @Test
+ @DisplayName("鏂板缓锛氱郴缁熺敓鎴愮紪鐮?+ V1.0 + 鑽夌锛堝彧璇诲瓧娈甸槻浼€狅級")
+ void newDraftGetsCodeAndV10() {
+ when(templateMapper.selectList(any())).thenReturn(List.of());
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setTemplateCode("FAKE_CODE"); // 浼€犵紪鐮佽蹇界暐
+ service.saveDraft(dto);
+
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper).insert(captor.capture());
+ OpportunitySchemeCardTemplate saved = captor.getValue();
+ assertThat(saved.getTemplateCode()).isEqualTo("OPP_SCHEME_TPL_01");
+ assertThat(saved.getVersionNo()).isEqualTo("V1.0");
+ assertThat(saved.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ }
+
+ @Test
+ @DisplayName("鏂板缓缂栫爜椤哄欢锛氱幇鏈夋渶澶у簭鍙?1 涓や綅琛ラ浂")
+ void newCodeIncrementsMax() {
+ when(templateMapper.selectList(any())).thenReturn(List.of(
+ tplRow(PUB_ID, "OPP_SCHEME_TPL_02", "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED)));
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ service.saveDraft(validDto());
+
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper).insert(captor.capture());
+ assertThat(captor.getValue().getTemplateCode()).isEqualTo("OPP_SCHEME_TPL_03");
+ }
+
+ @Test
+ @DisplayName("鑽夌鍘熶綅缂栬緫锛氱紪鐮?鐗堟湰鍙?鐘舵€佷笉鍙?)
+ void editDraftInPlace() {
+ when(templateMapper.selectById(DRAFT_ID)).thenReturn(
+ tplRow(DRAFT_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_DRAFT));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setId(DRAFT_ID);
+ service.saveDraft(dto);
+
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper).updateById(captor.capture());
+ OpportunitySchemeCardTemplate updated = captor.getValue();
+ assertThat(updated.getId()).isEqualTo(DRAFT_ID);
+ assertThat(updated.getTemplateCode()).isEqualTo(CODE_C1);
+ assertThat(updated.getVersionNo()).isEqualTo("V1.0");
+ assertThat(updated.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ verify(templateMapper, never()).insert(any(OpportunitySchemeCardTemplate.class));
+ }
+
+ @Test
+ @DisplayName("鍙戝竷涓?鍋滅敤缂栬緫 鈫?鏂拌崏绋匡紙minor 椤哄欢锛?)
+ void editPublishedSpawnsDraft() {
+ when(templateMapper.selectById(PUB_ID)).thenReturn(
+ tplRow(PUB_ID, CODE_C1, "V1.2", OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
+ when(templateMapper.selectList(any())).thenReturn(List.of(
+ tplRow(PUB_ID, CODE_C1, "V1.2", OpportunityRuleConstants.RULE_STATUS_PUBLISHED),
+ tplRow(9L, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_DISABLED)));
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setId(PUB_ID);
+ service.saveDraft(dto);
+
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper).insert(captor.capture());
+ OpportunitySchemeCardTemplate draft = captor.getValue();
+ assertThat(draft.getTemplateCode()).isEqualTo(CODE_C1);
+ assertThat(draft.getVersionNo()).isEqualTo("V1.3");
+ assertThat(draft.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ }
+
+ @Test
+ @DisplayName("鍚?code 宸叉湁鑽夌 鈫?鎷掔粷鐢熸柊鑽夌锛?4021锛?)
+ void draftExistsRejectsNewDraft() {
+ when(templateMapper.selectById(PUB_ID)).thenReturn(
+ tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
+ when(templateMapper.selectList(any())).thenReturn(List.of(
+ tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED),
+ tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT)));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setId(PUB_ID);
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_DRAFT_EXISTS);
+ }
+
+ @Test
+ @DisplayName("妯℃澘涓嶅瓨鍦?鈫?64019")
+ void missingTemplateRejected() {
+ when(templateMapper.selectById(DRAFT_ID)).thenReturn(null);
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setId(DRAFT_ID);
+ assertThatThrownBy(() -> service.saveDraft(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_NOT_EXIST);
+ }
+
+ // ==================== 瀛楁瑙勮寖鍖?====================
+
+ @Test
+ @DisplayName("瀛楁椤瑰叏閲忔浛鎹細seq 鎸夋彁浜ら『搴忛噸绠椼€佸悕/绫诲瀷鍙栧瓧娈靛簱蹇収锛堥槻浼€狅級")
+ void fieldsNormalizedWithSnapshot() {
+ when(templateMapper.selectList(any())).thenReturn(List.of());
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ // 浼€犲悕/绫诲瀷琚拷鐣ワ細钀藉簱鍙栫郴缁熷瓧娈靛畾涔夊揩鐓?+ dto.getFields().get(0).setFieldName("浼€犲悕");
+ dto.getFields().get(0).setFieldType("FAKE_TYPE");
+ service.saveDraft(dto);
+
+ verify(fieldMapper).delete(any());
+ ArgumentCaptor<OpportunitySchemeCardField> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardField.class);
+ verify(fieldMapper, times(2)).insert(captor.capture());
+ List<OpportunitySchemeCardField> rows = captor.getAllValues();
+ assertThat(rows.get(0).getSeqNo()).isEqualTo(1);
+ assertThat(rows.get(1).getSeqNo()).isEqualTo(2);
+ assertThat(rows.get(0).getFieldName()).isEqualTo("瀹㈡埛鍚嶇О");
+ assertThat(rows.get(0).getFieldType())
+ .isEqualTo(OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT);
+ assertThat(rows.get(0).getIsRequired()).isEqualTo(OpportunityRuleConstants.FLAG_YES);
+ }
+
+ @Test
+ @DisplayName("鏄剧ず/蹇呭~纭害鏉燂細鍏虫樉绀哄悓姝ユ竻蹇呭~锛堢エ 07銆?.3銆嶏級")
+ void invisibleFieldClearsRequired() {
+ when(templateMapper.selectList(any())).thenReturn(List.of());
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setFields(List.of(fieldItem("site_visit", 0, 1))); // 涓嶆樉绀轰絾瑕佹眰蹇呭~ 鈫?娓呭繀濉?+ service.saveDraft(dto);
+
+ ArgumentCaptor<OpportunitySchemeCardField> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardField.class);
+ verify(fieldMapper).insert(captor.capture());
+ assertThat(captor.getValue().getIsVisible()).isEqualTo(OpportunityRuleConstants.FLAG_NO);
+ assertThat(captor.getValue().getIsRequired()).isEqualTo(OpportunityRuleConstants.FLAG_NO);
+ }
+
+ @Test
+ @DisplayName("閮ㄩ棬涓撶敤钀介儴闂ㄥ瓙琛紱閫氱敤涓嶈惤")
+ void deptRowsOnlyForDeptScope() {
+ when(templateMapper.selectList(any())).thenReturn(List.of());
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ dto.setDeptIds(List.of(DEPT_10, DEPT_20));
+ service.saveDraft(dto);
+ verify(deptMapper, times(2)).insert(any(OpportunitySchemeCardTemplateDept.class));
+
+ OpportunitySchemeCardTemplateDTO general = validDto();
+ service.saveDraft(general);
+ verify(deptMapper, times(2)).insert(any(OpportunitySchemeCardTemplateDept.class)); // 閫氱敤淇濆瓨涓嶆柊澧為儴闂ㄨ
+ }
+
+ // ==================== 鍙戝竷 ====================
+
+ @Test
+ @DisplayName("鍙戝竷鏍¢獙锛氭棤瀛楁椤?鈫?64022")
+ void publishWithoutFieldsRejected() {
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setFields(List.of());
+ assertThatThrownBy(() -> service.saveAndPublish(dto))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_FIELD_INVALID);
+ verify(templateMapper, never()).insert(any(OpportunitySchemeCardTemplate.class));
+ }
+
+ @Test
+ @DisplayName("鍙戝竷锛氬師鍙戝竷涓浆鍋滅敤锛堣嚜鎴戦《鏇匡級+ 鏈増鏈浆鍙戝竷涓?)
+ void publishDisablesOldPublished() {
+ when(templateMapper.selectList(any())).thenReturn(List.of(
+ tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED)));
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setTemplateCode(CODE_C1);
+ service.saveAndPublish(dto);
+
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper, times(2)).updateById(captor.capture());
+ List<OpportunitySchemeCardTemplate> updates = captor.getAllValues();
+ // 鏃у彂甯冧腑 鈫?鍋滅敤
+ assertThat(updates.get(0).getId()).isEqualTo(PUB_ID);
+ assertThat(updates.get(0).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DISABLED);
+ // 鏈増鏈?鈫?鍙戝竷涓?+ assertThat(updates.get(1).getId()).isEqualTo(NEW_TPL_ID);
+ assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ }
+
+ @Test
+ @DisplayName("鍙戝竷锛氭柊榛樿椤舵浛鏃ч粯璁わ紙鍙戝竷涓粯璁ゅ敮涓€锛孷-CONFIG 6锛?)
+ void publishReplacesOldDefault() {
+ OpportunitySchemeCardTemplate oldDefault = tplRow(OLD_DEFAULT_ID, "OPP_SCHEME_TPL_09",
+ "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ oldDefault.setIsDefault(OpportunityRuleConstants.FLAG_YES);
+ // 涓夋 list锛歯ewCode(绌? / 鑷垜椤舵浛(鏃犲悓 code 鍙戝竷涓? / 鏃ч粯璁ゆ煡璇?+ when(templateMapper.selectList(any()))
+ .thenReturn(List.of())
+ .thenReturn(List.of())
+ .thenReturn(List.of(oldDefault));
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(NEW_TPL_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDTO dto = validDto();
+ dto.setIsDefault(OpportunityRuleConstants.FLAG_YES);
+ service.saveAndPublish(dto);
+
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper, times(2)).updateById(captor.capture());
+ assertThat(captor.getAllValues().get(0).getId()).isEqualTo(OLD_DEFAULT_ID);
+ assertThat(captor.getAllValues().get(0).getIsDefault())
+ .isEqualTo(OpportunityRuleConstants.FLAG_NO);
+ }
+
+ // ==================== 琛屾搷浣滅煩闃?====================
+
+ @Test
+ @DisplayName("鍋滅敤锛氫粎鍙戝竷涓彲鍋滅敤")
+ void disableOnlyPublished() {
+ when(templateMapper.selectById(DRAFT_ID)).thenReturn(
+ tplRow(DRAFT_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_DRAFT));
+ assertThatThrownBy(() -> service.disableTemplate(DRAFT_ID))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID);
+
+ when(templateMapper.selectById(PUB_ID)).thenReturn(
+ tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
+ service.disableTemplate(PUB_ID);
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper).updateById(captor.capture());
+ assertThat(captor.getValue().getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DISABLED);
+ }
+
+ @Test
+ @DisplayName("鍒犻櫎锛氫粎鑽夌鍙垹锛?4020锛夛紝鑽夌鍒犻櫎绾ц仈閮ㄩ棬+瀛楁瀛愯〃")
+ void deleteOnlyDraftWithCascade() {
+ when(templateMapper.selectById(PUB_ID)).thenReturn(
+ tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
+ assertThatThrownBy(() -> service.deleteDraft(PUB_ID))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_NOT_DRAFT);
+
+ when(templateMapper.selectById(DRAFT_ID)).thenReturn(
+ tplRow(DRAFT_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_DRAFT));
+ service.deleteDraft(DRAFT_ID);
+ verify(templateMapper).deleteById(DRAFT_ID);
+ verify(deptMapper).delete(any());
+ verify(fieldMapper).delete(any());
+ }
+
+ @Test
+ @DisplayName("澶嶅埗锛氭柊缂栫爜 + V1.0 + 鑽夌锛岄儴闂ㄤ笌瀛楁闅忓鍒?)
+ void copySpawnsIndependentDraft() {
+ OpportunitySchemeCardTemplate source = tplRow(PUB_ID, CODE_C1, "V2.3",
+ OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ source.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ source.setTemplateDesc("鍘熻鏄?);
+ when(templateMapper.selectById(PUB_ID)).thenReturn(source);
+ when(templateMapper.selectList(any())).thenReturn(List.of(source));
+ doAnswer(inv -> {
+ OpportunitySchemeCardTemplate t = inv.getArgument(0);
+ t.setId(COPY_ID);
+ return 1;
+ }).when(templateMapper).insert(any(OpportunitySchemeCardTemplate.class));
+
+ OpportunitySchemeCardTemplateDept deptRow = new OpportunitySchemeCardTemplateDept();
+ deptRow.setTemplateId(PUB_ID);
+ deptRow.setDeptId(DEPT_10);
+ when(deptMapper.selectList(any())).thenReturn(List.of(deptRow));
+ when(fieldMapper.selectList(any())).thenReturn(List.of(
+ fieldRow(91L, PUB_ID, 1, "customer_name", 1, 1)));
+
+ Long newId = service.copyTemplate(PUB_ID);
+ assertThat(newId).isEqualTo(COPY_ID);
+
+ ArgumentCaptor<OpportunitySchemeCardTemplate> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardTemplate.class);
+ verify(templateMapper).insert(captor.capture());
+ OpportunitySchemeCardTemplate copy = captor.getValue();
+ assertThat(copy.getTemplateCode()).isEqualTo("OPP_SCHEME_TPL_02");
+ assertThat(copy.getVersionNo()).isEqualTo("V1.0");
+ assertThat(copy.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
+ assertThat(copy.getTemplateDesc()).isEqualTo("鍘熻鏄?);
+ verify(deptMapper).insert(any(OpportunitySchemeCardTemplateDept.class));
+ ArgumentCaptor<OpportunitySchemeCardField> fieldCaptor =
+ ArgumentCaptor.forClass(OpportunitySchemeCardField.class);
+ verify(fieldMapper).insert(fieldCaptor.capture());
+ assertThat(fieldCaptor.getValue().getFieldKey()).isEqualTo("customer_name");
+ assertThat(fieldCaptor.getValue().getIsRequired()).isEqualTo(OpportunityRuleConstants.FLAG_YES);
+ }
+
+ // ==================== nextVersionNo ====================
+
+ @Test
+ @DisplayName("鐗堟湰鍙烽『寤讹細鏃犲巻鍙?鈫?V1.0锛沄1.2 鈫?V1.3锛涢潪娉曟牸寮忛槻寰″拷鐣?)
+ void nextVersionNoSemantics() {
+ assertThat(OpportunitySchemeCardTemplateServiceImpl.nextVersionNo(List.of())).isEqualTo("V1.0");
+ assertThat(OpportunitySchemeCardTemplateServiceImpl.nextVersionNo(List.of("V1.0", "V1.2")))
+ .isEqualTo("V1.3");
+ assertThat(OpportunitySchemeCardTemplateServiceImpl.nextVersionNo(List.of("V2.4", "V1.9")))
+ .isEqualTo("V2.5");
+ assertThat(OpportunitySchemeCardTemplateServiceImpl.nextVersionNo(
+ java.util.Arrays.asList("bad", null, "V1.1"))).isEqualTo("V1.2");
+ }
+
+ // ==================== 缁戝畾鍖归厤 ====================
+
+ @Test
+ @DisplayName("缁戝畾鍖归厤锛氶儴闂ㄤ笓鐢ㄤ紭鍏堬紱閫氱敤+榛樿鍏滃簳锛涙棤鍙戝竷涓繑鍥?null")
+ void resolveBindingVersion() {
+ // 鏃犱换浣曞彂甯冧腑
+ when(templateMapper.selectList(any())).thenReturn(List.of());
+ assertThat(service.resolveBindingVersion(DEPT_10)).isNull();
+
+ // 閮ㄩ棬涓撶敤鍛戒腑锛堝鍛戒腑鍙栨渶杩戝垱寤猴級
+ OpportunitySchemeCardTemplate deptTplOld = tplRow(7L, "OPP_SCHEME_TPL_07", "V1.0",
+ OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ deptTplOld.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ deptTplOld.setCreateTime(LocalDateTime.of(2026, 1, 1, 0, 0));
+ OpportunitySchemeCardTemplate deptTplNew = tplRow(8L, "OPP_SCHEME_TPL_08", "V1.0",
+ OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ deptTplNew.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ deptTplNew.setCreateTime(LocalDateTime.of(2026, 2, 1, 0, 0));
+ when(templateMapper.selectList(any())).thenReturn(List.of(deptTplOld, deptTplNew));
+ OpportunitySchemeCardTemplateDept row1 = new OpportunitySchemeCardTemplateDept();
+ row1.setTemplateId(7L);
+ row1.setDeptId(DEPT_10);
+ OpportunitySchemeCardTemplateDept row2 = new OpportunitySchemeCardTemplateDept();
+ row2.setTemplateId(8L);
+ row2.setDeptId(DEPT_10);
+ when(deptMapper.selectList(any())).thenReturn(List.of(row1, row2));
+ assertThat(service.resolveBindingVersion(DEPT_10).getId()).isEqualTo(8L);
+
+ // 鏃犻儴闂ㄤ笓鐢ㄥ懡涓?鈫?閫氱敤榛樿鍏滃簳
+ OpportunitySchemeCardTemplate defaultTpl = tplRow(9L, "OPP_SCHEME_TPL_09", "V1.0",
+ OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ defaultTpl.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL);
+ defaultTpl.setIsDefault(OpportunityRuleConstants.FLAG_YES);
+ when(templateMapper.selectList(any())).thenReturn(List.of(defaultTpl));
+ assertThat(service.resolveBindingVersion(DEPT_10).getId()).isEqualTo(9L);
+ assertThat(service.resolveBindingVersion(null).getId()).isEqualTo(9L);
+ }
+
+ // ==================== 鏌ヨ ====================
+
+ @Test
+ @DisplayName("璇︽儏鍥炴樉瀛楁椤规湁搴忓垪琛?+ 閮ㄩ棬鍚?)
+ void detailShowsFieldsAndDepts() {
+ OpportunitySchemeCardTemplate tpl = tplRow(PUB_ID, CODE_C1, "V1.0",
+ OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ tpl.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ when(templateMapper.selectById(PUB_ID)).thenReturn(tpl);
+ OpportunitySchemeCardTemplateDept deptRow = new OpportunitySchemeCardTemplateDept();
+ deptRow.setTemplateId(PUB_ID);
+ deptRow.setDeptId(DEPT_10);
+ when(deptMapper.selectList(any())).thenReturn(List.of(deptRow));
+ SysDept dept = new SysDept();
+ dept.setId(DEPT_10);
+ dept.setDeptName("鍗庝笢涓€閮?);
+ when(sysDeptService.listByIds(any())).thenReturn(List.of(dept));
+ when(fieldMapper.selectList(any())).thenReturn(List.of(
+ fieldRow(91L, PUB_ID, 1, "customer_name", 1, 1),
+ fieldRow(92L, PUB_ID, 2, "scheme_budget", 1, 0)));
+
+ OpportunitySchemeCardTemplateDTO detail = service.getTemplateDetail(PUB_ID);
+ assertThat(detail.getDeptIds()).containsExactly(DEPT_10);
+ assertThat(detail.getDeptNames()).containsExactly("鍗庝笢涓€閮?);
+ assertThat(detail.getFields()).hasSize(2);
+ assertThat(detail.getFields().get(0).getFieldKey()).isEqualTo("customer_name");
+ assertThat(detail.getFields().get(1).getSeqNo()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("杩愯鏃舵煡璇㈢紳锛歯ull-safe锛坙istFieldsOfVersion / getVersionById锛?)
+ void runtimeSeamsNullSafe() {
+ assertThat(service.listFieldsOfVersion(null)).isEmpty();
+ assertThat(service.getVersionById(null)).isNull();
+ assertThat(service.getVersionById(PUB_ID)).isNull(); // mapper 鏈々杩斿洖 null
+ when(fieldMapper.selectList(any())).thenReturn(List.of(
+ fieldRow(91L, PUB_ID, 1, "customer_name", 1, 1)));
+ assertThat(service.listFieldsOfVersion(PUB_ID)).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("瀛楁搴撳垪琛細瀹炰綋鏄犲皠 DTO")
+ void listFieldDefsMaps() {
+ assertThat(service.listFieldDefs()).hasSize(4);
+ assertThat(service.listFieldDefs().get(0).getFieldKey()).isEqualTo("customer_name");
+ }
+}