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.

3518 lines
320 KiB

2 weeks ago
��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;
/**
- * _��U�n�Y3 ao/udzV�"X�0 04 �t ���6lJe�t�r + ig�m�SO��ccX��qm�?+ * _��U�n�Y3 ao/udzV�"X�0 04 �t ���6lJe�t�r + ig�m�SO��ccX��qm��m�0 07 “ĉ �W�!�X^p�f}
* <p>5p-W {@code LeadConstants} (�?interface /udzV0�!2� uO� �P�Y��xO� NqYqf�? * {@link com.crm.opportunity.domain.enums.OpportunityStatus}�XGp�m�`F]�t�]9wt��}�m�]jn�Y�0xV�o�]}��?/p>
*/
@@ -15,6 +15,19 @@ public interface OpportunityConstants {
int CODE_CAS_FAIL = 66004; // �m/aG翕?�5�� ?CAS P�,�Js�WDR-0021�? int CODE_STAGE_NOT_ALLOWED = 66005; // 0��d�X�5�� wO {O�}O��Õ5���R��V2]�"X�0 06�+l�|��(1X~�m^�r_R��V}
int CODE_STAGE_NODE_INVALID = 66006; // )���#rÕ5���z��PcPȕ�pvx�X {^p�p�|9p-lɓ荗Xȓ?e��T~uz��PcP/�m�]�tf���}�~?06�?+ int CODE_SCHEME_CARD_INVALID = 66007; // “ĉ �W�3 �SY��Pjo Z�f}Y��P�f�TQ0/9p!2�W�m�]Xwȓ�bfȓ?pAi���m�]Xwb~u�Y!�o/G�mT^��TQ0��\�0 07�?+ int CODE_SCHEME_CARD_NOT_EXIST = 66008; // “ĉ �W�2 {p:jjn��'h�Qt���Wĕ�0}�~?07�?+ int CODE_SCHEME_CARD_DUPLICATE = 66009; // �m� 9p!2�W�m� W�&0}�t�0yᴓ�\jnȓ�bfȓp�{�[�SAn“ĉ �W�&}\mC'd���]�o Q2��\�0 07�?+ int CODE_SCHEME_CARD_READONLY = 66010; // �[�S9^��yO�g�YX1]��Qg1]Y�G����Xr_,h�}=card_status<3�~\�Zɓ?P1-1��\�0 07�?+
+ /*-------- “ĉ �W���Y��?card_status�X��]�yO�Zɓ?P0-4��\�0 07��?.1���]}--------
+ * ��~\mnR��m��? 0 W�3 Mw�~�p� �O}�m�]�S4d�d {W��r� NqYq��m1]�m� �_�W�[?��VOpb�}� Y�熒?+ * ��)1� xO�Ycm�n}A7-3-2-3� Ypnȓ�qmn���e}�[�S9^��zO�Y��wO�}�h�R�f��?+ */
+
+ int CARD_STATUS_DRAFT = 1; // }� Y���X+^h�-[W�͓�V� �\]�~?�[nTA_\m�0� �]� }\^�P�kQ����]�}�m� �?+ int CARD_STATUS_SUBMITTED = 2; // �[�SA_\m�0}�^� �\ �t�0� �]}͓?z>~pAi�῕zO�w4d?W�?�5�� �O}
+ int CARD_STATUS_PUSHED = 3; // �[�S9^���O}��Qg1]Y�G�����\����ĉ�g�[�T1]�?
/*-------- ig�m�SO��ccX��qm pool_reason�%WRD d3.5 ˓+l�Y�}\mJ�Nq�v•h�1}varchar(32) p?j���m Yr�?-------
* E3 �m Yܑɓ�0.|J���qe�t�Qde“xv}Service� Y�[���]�}f��p�j|m�r�S�fl10 �tnTane��pye"�?executeRecycle P�k4ᆒ?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;
+
+/**
+ * “ĉ �W�!4^Y�O�}���R� �P�S^p�P}Y�G��v {@link OpportunitySchemeCardService}��\�0 07��|\{���]}
+ * <p>ɓ�Q�j�~+h�f�+l {�~�]�tV�?button ɓ�Q�j�xv}Z��\jZ�W4^Y�O�}�I[piPermissionInterceptor 5pĉmn Z%1=U
+ * URL fail-open��m�z9p!2�W�m� W�?Ó�r��~*[1]/�[�S9^��xOg_�td��t�~��[ɓ�qqeȓ�]�Y^p�PNr`i}\�S4d�f� ?+ * Ó?owner guard�"X�0 01�Y� �P9^��xO�Ycm�n}A7-3-2-3� Y {f�&1pn�~��}�[�S9^��zO�Y��wO�}�h�R�f��?/p>
+ */
+@Tag(name = "_��U�n�~��`/“ĉ �W�?)
+@RestController
+@RequestMapping("/api/opportunity/scheme-card")
+@RequiredArgsConstructor
+public class OpportunitySchemeCardController {
+
+ private final OpportunitySchemeCardService schemeCardService;
+
+ @Operation(summary = "“ĉ �W�3 �Wt��}�m� _��U�n�o,l6}R��U�|^p�fZ0��m�hW�?��~\mnR��m���]Mw�~�p� zOqeS��]l�Z�SKq�?)
+ @GetMapping("/list")
+ public Result<List<SchemeCardDTO>> list(@RequestParam("oppId") Long oppId) {
+ return Result.success(schemeCardService.listCards(oppId));
+ }
+
+ @Operation(summary = "“ĉ �W���ᯓ�T}b~u�Y!�o�XpnēgRZ0pAi��$iĉAn4d�_�Wt?+ �[�c^�J�wx}")
+ @GetMapping("/detail")
+ public Result<SchemeCardDetailDTO> detail(@RequestParam("id") Long id) {
+ return Result.success(schemeCardService.getCardDetail(id));
+ }
+
+ @Operation(summary = "�m�o�t�X�g�[?�]�~?,h�}��m�g�[*m#eȓ� “gB_/u�Qɓ荗Xȓ
�},h�}`�de��xO~u�Xpn��m]�~8Y {�[�SA_\m�0NoY��|e�?)
+ @PostMapping("/save")
+ public Result<Long> save(@RequestBody SchemeCardSaveDTO dto) {
+ return Result.success(schemeCardService.saveCard(dto));
+ }
+
+ @Operation(summary = "��*a&l�X� �\ �t�0� �]KU�[�SA_\m�01}��*a&lS��]Nr`i~\ɓ�W@~�o���tZ�x}")
+ @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;
+
+/**
+ * “ĉ �W�3 �Wt)1 0Q��T,_�"X�0 07��?.1���]0}�m� _��U�n�o,l1]R��U�|^p�fZ0�?+ * <p>��~\mnR��m��? 0 W�3 Mw�~�p� zOqeS��]lณ Y%R�Di0Z�SKq�|\ {f�&1pn DTO J�NqYqP��T1}
+ * 9p!2�WZ��]�kO��[�N9p!2�Wt%1�c�Cmē�d}O�?join�Y� ?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SchemeCardDTO extends BaseDTO {
+
+ @Schema(description = "��� ^p�pbfȓ�{D")
+ private Long oppId;
+
+ @Schema(description = "O��[�N9p!2�WID�X@~���Ppn_��U�n�m,[�kO��[�N9p!2�W�?)
+ private Long customerId;
+
+ @Schema(description = "9p!2�WZ��]�c�M}e��p j�~\uow�D��Sq��eyᴓ�u0�?)
+ private String customerNameSnapshot;
+
+ @Schema(description = "b~u(��R�g�YX1]�Y!�o�XpntV[D�%X#e�Xpn�?)
+ private Long templateId;
+
+ @Schema(description = "�Y!�o�XpnY��Q}P�>i�}e��p j�?)
+ private String templateVersion;
+
+ @Schema(description = "W���Y���O0}1}� Y�� 2�[�SA_\m?3�[�S9^��?)
+ private Integer cardStatus;
+
+ @Schema(description = "Y��*}Hgb#r�t.^}Q��T,_2ZgRSe�]kard_status<3 Y��*}Hg/b}���]�o P1-1�?)
+ private Boolean editable;
+
+ @Schema(description = "“ĉ ��h�R{u��&b��")
+ private BigDecimal schemeBudget;
+
+ @Schema(description = "���m#r0� 2!}p@iT code")
+ private String biddingForm;
+
+ @Schema(description = "`m*[�SÕ5���p@iT 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;
+
+/**
+ * “ĉ �W���ᯓmT�VY��P}�~?07��?.1���]0}T�&1� xO�tZ[�� |Quow�F��|9p1l�k�Y!�o�Xpn�?+ *
+ * <p>pAi��R�Di0�� Y�|9p-lɓ荗Xȓ��kpAi��$iĉAn4d�_mē�d}`mnT j�~*m 0�Y}J�|Q�[ {@code field_key} AttT�S�?+ * �d/pV�'h�tZ�x}���m#r0� 2!}/`m*[�SÕ5���/“ĉ ��h�R{u��&b��� YA_W��V�[t��}�Y�0)�"��S�[t%1�We��p^�
+ * �X {T�&1� xO�tZ�W��̓�d�W�m� cm�d}J��� {ig?{@code opportunity_scheme_card_value} J�p�0�Y� ?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SchemeCardDetailDTO extends SchemeCardDTO {
+
+ @Schema(description = "pAi��$iĉAn4d�_�Wt��}b~u�Y!�o�Xpn(��R j�~*m 0 + �[�c^�J�wx}")
+ private List<FieldEntry> fields;
+
+ /**
+ * pAi��ɓ�0m�Xɓ�W�tZ8u 0G����S + W�2 {�[�c^�J�wx}��?+ */
+ @Data
+ public static class FieldEntry {
+
+ @Schema(description = "4d�__�X^�P�k 0^p�fZ0$i�T-|�?)
+ private Integer seqNo;
+
+ @Schema(description = "pAi�῕?)
+ private String fieldKey;
+
+ @Schema(description = "^p�fZ0pAi��Z��]}�Y!�opAi��$ipt�c�M}")
+ private String fieldName;
+
+ @Schema(description = "pAi���~��7p�Xɓ�W�tZ8u 0G����S�?)
+ private String fieldType;
+
+ @Schema(description = "G�mT^��?G�mT^� 0ȕ�p@~�o��}�Y!�opAi��$ipt�c�M}")
+ private Integer isRequired;
+
+ @Schema(description = "�[�c^�J�wx}�d/pV�'h�tZ5uqe�mf0e��p^��}\�ScmkG_J�p�0�?)
+ 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;
+
+/**
+ * “ĉ �W�2 Z~p:j�SY��P}}� Y���m�o�t / ,h�}��\�0 07��|\{���]}
+ *
+ * <p>“g�X�h {@code id}�Y0}�Y!�o"������q�[_��U�n�t�q�w\m*m4Q•'1�|
+ * {@code resolveBindingVersion} YtF�=p�%X#eȓ� “gB_/u�Q�Xȓ���Y}O��0,_�m�]4^�Y!�opAi��Õm� T���r1}
+ * ,h�}�XAn {@code id}�Y0}`�deW�2 {��xO~u�Xpn��[@code customerId} �m�]r_ǓX[2]��?/p>
+ *
+ * <p>�d/pV�'h�tZ�x}�h�R{u/���m#r0� 2!}/`m*[�SÕ5����Yˆ�mf0R�Ki1}O�5g�}�Y!�opAi��`m?+ * {@link FieldValue} R�Di0�mGZ� �O}ŕ�_Z~p:j�S���_[m�� 2� p�0��?/p>
+ */
+@Data
+public class SchemeCardSaveDTO {
+
+ @Schema(description = "“ĉ �W���D�"X*}HgbiG�mT^���m�g�[�p�h�?)
+ private Long id;
+
+ @Schema(description = "��� ^p�pbfȓ�{D�X@~�o��}")
+ private Long oppId;
+
+ @Schema(description = "O��[�N9p!2�WID�X@~�o��}$i���ȓ�bfȓp�{(��R�Sq��eyᴓ�Q1},h�}Ó5g {Y��?m��n�}")
+ private Long customerId;
+
+ @Schema(description = "“ĉ ��h�R{u��&b���"X�|˓�R�[�mf0R�Ki}")
+ private BigDecimal schemeBudget;
+
+ @Schema(description = "���m#r0� 2!}p@iT code�"X�|˓�R�[�mf0R�Ki}")
+ private String biddingForm;
+
+ @Schema(description = "`m*[�SÕ5���p@iT code�"X�|˓�R�[�mf0R�Ki}")
+ private String enterStage;
+
+ @Schema(description = "T�&1� xO�tZ[�� Nq�Wt��}field_key $i��Xwb~u�Y!�o�Xpn(��R j�~�T�tZ�x1}�d/pV�'h�tZ8ume�m�]r_f�&1��mGZ� �O}")
+ private List<FieldValue> values;
+
+ /** T�&1� xO�tZ[�� xY 0 */
+ @Data
+ public static class FieldValue {
+
+ @Schema(description = "pAi�῕��}G�mT^��?)
+ private String fieldKey;
+
+ @Schema(description = "pAi��J�wx}“�Vpn/JSON�~\�[pAi���~��7pYtF�=p��m%�m6�K��m�p�z�~4s}")
+ 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;
+
+/**
+ * _��U�n-O��[�N9p!2�Wp/a0�"X�0 13�,l�Sq��eyᴓ?+ �mf�氓�_�`9p!2�W�?+ *
+ * <p>�m� �mD�bfȓy��m���0?��mD��Sq��eyᴓ�Q}O�5gQ���g�0�m� �mE�#r�t���**�mf�氓�_�`9p!2�W**
+ * �}W@code is_primary_intended=1}�}\2|"�%10w\m*[�Y�m�o v^���z��m�V�~#2Fm_��U�n(��R� ~\pRZ�byᴓ�g� �]�`�m�~#r�t.^}��?+ * 9p!2�W�Y3 ao A4 ȓD��k@code customer_id} �t,�� }\)}"�?id + Z��]�c�A� �]߂pHj}Z��\�0 11 �tHrvx�Y}
+ * {@code customer_name_snapshot} O�?join Bi@jv�^p�fZ0��?/p>
+ *
+ * <p>�~=�ko�+l�z9p!2�Wf�$1�z_��U�n�m*[g_O��[�N�m� Z&}UNIQUE(opportunity_id, customer_id)�Y1}
+ * �r��~-[�%��e� �e@~^p�pGq_��U�n�}\�W_��U�n�~F�NZoT�`�#X��R��r[_�[�R�k_��U�n�Y� ?/p>
+ *
+ * <p>“ĉ �W���Ke�xv}�~?07�Y0}�Y�_6}“ĉ �W�3 @~���Ppnt$1Q�(��R�z�mD��Sq��eyᴓ�Q}Ó�r��~*[1]�Y}
+ * �m� 9p!2�W�m� W�&1}R�Di02ZgRSeR�Ki}“ĉ ��h�R{u/“ĉ �W���Y���O}Y�&h�[UtyOpRZ�byᴓ�pEP�[�r1](��R� wx}�~?13/�~?10�Y� ?/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("��� ^p�pbfȓ�{D�eWpportunity.id�?)
+ @Column(columnDefinition = "bigint not null comment '��� ^p�pbfȓ�{D'")
+ private Long opportunityId;
+
+ @Comment("9p!2�WID�X)}"�?A4 9p!2�W�Y3 ao�~\mn�[4s1}O�X�t id + Z��]�c�M}Z��\�0 11 �tHrvx�?)
+ @Column(columnDefinition = "bigint not null comment '9p!2�WID'")
+ private Long customerId;
+
+ @Comment("9p!2�WZ��]�c�M}O�?join�}\�Wt?�t=�QBi@jv�^p�fZ0�?)
+ @Column(columnDefinition = "varchar(200) not null comment '9p!2�WZ��]�c�?")
+ private String customerNameSnapshot;
+
+ @Comment("9p!2�WYt�c�Xp@iT code�X�n"��W� ���[/�[�0�%_�?ƕ�U�W_��U� <�}�~?13�,l�tO�?z�W�R�}\mC'd9p6l� Y 0ƕ�U}")
+ @Column(columnDefinition = "varchar(32) comment '9p!2�WYt�c�Xp@iTcode'")
+ private String customerRole;
+
+ @Comment("ē��`�mf�氓�_�`9p!2�W�?=ē?0=œ��� ,l�Sq��e}�m� _��U�n^���z =1�}\2|"�%10w\m*[�Y�m�o v�?)
+ @Column(columnDefinition = "tinyint not null default 0 comment 'ē��`�mf�氓�_�`9p!2�W'")
+ 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;
+
+/**
+ * “ĉ �W�2 �[t��}ig/a�Ó��}�~?07��|\{��? X��q7p A3-1-1-1-3�?+ *
+ * <p>_��U�n��(1X~ig�V�%�m_��w#Z� ��}\yᴓ�p6n�Y?�h�R{u/���m#r0� 2!}/`m*[�SÕ5���/“ĉ ��5�� uO� �]�k�m,l�YW��fA]�?+ * ē�Fm$i-W0m(��R�q�~� �YF� ��#XFm$i-W0m�~�[_�o b�[0�?A5�~\pnt&1Gq�[�r1]�t�bFm$i-W0m +�?e��p�v9i^S�Y
+ * ^g? {@code opp_status=5} + ^g? {@code current_stage+��c�Q^g� 0)����Y��{��\�0 07��}\m���]}��?/p>
+ *
+ * <p>�~��[ɓ�q0}</p>
+ * <ul>
+ * <li>�Y�_6}W�3 @~O��[�N0��d�X_��U�n�m,[�k�m� �mD��Sq��eyᴓ�Q}{@code customer_id} +�?opportunity_customer�?+ * �m�]�S�tJT�h9p!2�W0��cXw(��R��~+[�g�YX1]�Y1}</li>
+ * <li>�m� 9p!2�W�m� W�&}\mC'd���]�o Q2�Y0}UNIQUE(opp_id, customer_id)�?/li>
+ * <li>b~u“ĉ �W�!ɓ?*�Xpnt?id**�%X#e�Xpn�}\B_“nb�X�m�]<~�~O}5pR_�} V-CONFIG�Y0}
+ * f�)1� �e1]`�deX��q�Xȓ
�}“gW��deȓ� “gB_/u�Q�Xȓ
�1}</li>
+ * <li>W��f1]�5�� yO�n�X��]�yO�Zɓ?P0-4�Y0}}� Y��1/�[�SA_\m?/�[�S9^��?��m� ~\mnR��m��? 0 W�3 Mw�~�p� wO {O��01|��?+ * Y��*}Hgb�W9p?= {@code card_status < 3}�X�|�[�S9^��yO�fW�3 g_�tO}�m�]�NpAi���~G#e�~\�Zɓ?P1-1�Y� ?/li>
+ * </ul>
+ *
+ * <p>�d/pV�'h�tZ�x}�h�R{u/���m#r0� 2!}/`m*[�SÕ5���/9p!2�W� YA_W��V�W�mf0n8Y,|R�Di0 join �m�^�t��Y1}
+ * O�5g�}�� Yɓ�W�Y��xO�tZCOX~ {@link OpportunitySchemeCardValue} J�p�0��?/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("��� ^p�pbfȓ�{D�eWpportunity.id�?)
+ @Column(columnDefinition = "bigint not null comment '��� ^p�pbfȓ�{D'")
+ private Long oppId;
+
+ @Comment("O��[�N9p!2�WID�X@~�o��}= 0��d�X_��U�n�m+[GqO��[�N9p!2�W opportunity_customer.customer_id�~\�hp�0�sW�&}")
+ @Column(columnDefinition = "bigint not null comment 'O��[�N9p!2�WID'")
+ private Long customerId;
+
+ @Comment("b~u(��R�g�YX1]�Y!�o�XpntV[D�XKUopportunity_scheme_card_template.id��\#e�XpnY�b�g�X {igzO)�?)
+ @Column(columnDefinition = "bigint not null comment '�Y!�o�XpntV[D'")
+ private Long templateId;
+
+ @Comment("�Y!�o�XpnY��\QUcmk}�o?V1.2�}\Mw�~y�deO�?join�}\���i.a�0 03/06 K�-lvx�?)
+ @Column(columnDefinition = "varchar(20) not null comment '�Y!�o�XpnY��\QUcm?")
+ private String templateVersion;
+
+ @Comment("W���Y���O0}1}� Y�� 2�[�SA_\m?3�[�S9^���O}\mC'd���]�o P0-4��m�|�[�S9^��xOg_�tO}Y��*}Hg?card_status<3�?)
+ @Column(columnDefinition = "tinyint not null comment 'W���Y���O0}1}� Y�� 2�[�SA_\m?3�[�S9^��?")
+ private Integer cardStatus;
+
+ @Comment("“ĉ ��h�R{u��&b���? CONTEXT��-a�g�Y%Xi��~<i� #b�`�xv1}_��U�nR�Di0R�@iG_�mf�氓�_�`9p!2�WW���k�Y�0� wx}�~?13�?)
+ @Column(columnDefinition = "decimal(14,2) comment '“ĉ ��h�R{u��&b��'")
+ private BigDecimal schemeBudget;
+
+ @Comment("���m#r0� 2!}p@iT code�"X�|˓�R�[��,a ]�mf0��\�}T�B%9^ig�m-}O�"X��R��0�g�?)
+ @Column(columnDefinition = "varchar(64) comment '���m#r0� 2!}p@iTcode'")
+ private String biddingForm;
+
+ @Comment("`m*[�SÕ5���p@iT code�"X�|˓�R�[��,a ]�mf0��\�}T�B%9^ig�m-}O�"X��R��0�g�?)
+ @Column(columnDefinition = "varchar(64) comment '`m*[�SÕ5���p@iTcode'")
+ 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;
+
+/**
+ * “ĉ �W�3 �Y��xO�tZ[�� wx}ŕ�_�|9p-lɓ�W�tZ�x}�~?07��?.2���]}
+ *
+ * <p>�Y!�opAi��$i�Q�ȓF��|˓�R�[��,a ]�mf0(��R4QR��U�[ {@code field_key} ��Ʌ� p�0�?+ * J�|Q�[ {@code field_type} YtF�=p�X�gȓ?JSON�Y� �P�Sq��e0�~\�h9p�x�pAi���?+ * �m�o�tÓ���SR��r�`���c�S���_[m��n�}W����P�k�w4d<�}5pR_�}�Y!�opAi��$ipt�S���_[m�� 2[_�[�R}��?/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 '�mZZme'")
+ private Long id;
+
+ @Comment("��� ^p�p�g�YX1]ID�eWpportunity_scheme_card.id�?)
+ @Column(columnDefinition = "bigint not null comment '��� ^p�p�g�YX1]ID'")
+ private Long cardId;
+
+ @Comment("pAi�῕��}5ppt2|b~u�Y!�o�Xpn(��R�tZ8u 0 field_key�?)
+ @Column(columnDefinition = "varchar(64) not null comment 'pAi�῕?")
+ private String fieldKey;
+
+ @Comment("pAi��J�wx}“�Vpn/JSON�~\�[�Y!�opAi���~��7pYtF�=p�?)
+ @Column(columnDefinition = "text comment 'pAi��J�?")
+ 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;
+
+/**
+ * “ĉ �W��M~t~\iȓ�]�Y�"X�0 07��|\{��? X��q7p A3-1-1-1-3�?+ *
+ * <p>�m� �mD�bfȓ�Tr_O��[�N�o,l6}“ĉ �W�&1}�Y�_6}W�3 @~O��[�N0��d�X_��U�n�m,[�k�m� �mD��Sq��eyᴓ�Q}Ó�r��~*[1]�Y1}
+ * �m� 9p!2�W�m� W�&}\mC'd���]�o Q2�Y1}pAi��J�|Quow�F��|9p1l�k“ĉ �W�!ɓ荗Xȓ
�}T�&1� xO�tZ�x}��zO�Xȓ�B_“nb�X�m�]<~�~O}��?+ * W��f1]�5�� yO�n�3l]�~?/�[�SA_\m?/�[�S9^��?��mr_,h�}R��0~u = {@code cardStatus < 3}
+ * �X�|�[�S9^��yO�fW�3 g_�tO}�m�]�NpAi���~G#e�~\�Zɓ?P1-1�Y� ?/p>
+ *
+ * <p>�Y!�o̓�0��?{@code IOpportunitySchemeCardTemplateService}�HWrm-opportunity +�?crm-rule
+ * W��f�`n�o�y��\�0 01�Y1}�Y!�o���]�unC��_��U�n9p�p�}ƕ��=_n�o�y���PFm$i-W0m�~�[_�o b�[0�?A5�"X�0 04/06 �[?+ * spin-out�Y}ȓ�GnT�3 g_����{ᆒ~\�g�YX1]ē�Fm$i-W0mYt@�B_gZ)a� �]V~�m� \m*[�u�"X�0 07��}\m���]}��?/p>
+ */
+public interface OpportunitySchemeCardService {
+
+ /**
+ * _��U�n�m+[�g�YX1]R�Di0�X?��[�r�W�RMw�~4s1}��
Y�W�[�pi•Qg ]4d�_1}9p!2�WZ��]�c�Cmē�d}��?+ * <p>��~\mnR��m��? 0 W�3 Mw�~�p� �O0}_��U�nÓ�rbccm�f�g�YX1]Ó�`Q~e��p%R�Di0��\qeS��]l�Z�SKq^p�fZ0��uO� ?/p>
+ */
+ List<SchemeCardDTO> listCards(Long oppId);
+
+ /**
+ * “ĉ �W���ᯓ�T}W�2 �[�m!O + b~u�Y!�o�Xpn(��R j�~�T�tZ8u 0ȓ
Y-|R�Di0 + �[�c^�J�wx}��?+ */
+ SchemeCardDetailDTO getCardDetail(Long cardId);
+
+ /**
+ * �m�o�t�X�g�[?�]�~?/ ,h�}�Y0}
+ * <ul>
+ * <li>“g�-lɓc�[_��U�n�t�q�w\m*m4Q•'1�|b~uV�R_�SYtF�=p�X�hY�b�z�m_�ɓ�W�W���c�|�[�T1]�Y1}
+ * ͓��Ys9p!2�W^p�ppn_��U�nO��[�N9p!2�W + �m� 9p!2�W�m� W�&1}</li>
+ * <li>,h�}�1l;u"�%11]�mGZ#e9p1l�Xȓ
�}O��[�N9p!2�W�m�]r_ǓX[2]��m]�~?�[�SA_\m�0NoY��ha��pt�S��%1�tZ?+ * �XZ~��xO+^�5�� wO {e��p� � �Y}�[�S9^��yO�fW�3 g_�t�~�Z�o1}</li>
+ * <li>T�&1� xO�tZ[�� Nq�S���_[m��n�0}pAi�῕��0^p�p�|9p-lɓ荗Xȓ��kēgRZ0pAi����\�|˓�R�[pAi�῕�� {Y��{��xO� p�0��?/li>
+ * </ul>
+ *
+ * @return “ĉ �W�?id
+ */
+ Long saveCard(SchemeCardSaveDTO dto);
+
+ /**
+ * ��*a&l�X� �\ �t�0� �]KU �[�SA_\m�0}�,l�S��F�G�mT^�͓��Ys�Xɓc j�~p�{G�mT^�pAi��g��V0�[�c^��?+ * Z����|˓�R�[�mf0pAi���Y}��3lC~Z��^]�~��Fm�[�SA_\m�01}�[�SA_\m�0�{�~Y1}�[�S9^��yO�Z�o� ?+ */
+ 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;
+
+/**
+ * “ĉ �W��M~t~\iȓ�]�Y9p�pG^�"X�0 07��|\{���]}
+ *
+ * <p>�~��[ɓ�q�`f�.^0}Ó�r��~*[1]�Xyᴓ�\@~^p�ppn_��U�nO��[�N9p!2�Wt��}��wO�z9p!2�W�m� W�&}4d�ede^p�Paq��?+
+ * DB UNIQUE(opp_id, customer_id) O��n3|�Y� �O#e�Y!�o�Xpn�X�g�[y��|b~uV�R_�SYtF�=p��zO*}Hg b;u"�$1 {igzO)�Y� ?+ * Y��*}Hg?= {@code cardStatus < 3}�X�|�[�S9^��yO�fW�3 g_�tO}���]�o P1-1�Y� ?/p>
+ *
+ * <p>�d/pV�'h�tZ�x}�h�R{u/���m#r0� 2!}/`m*[�SÕ5���� YA_W��V�[t��}�~?07��?.2���]}�+lZ~p?jˆ�mGZ� wO�[t%1�W�?+ * J�p�0�m�]r_���]2��mGZ� �O1}�t=�Qe��p jÓARqe�mf0e��p^�pAi��ɓ�0m�|\ {T�&1� xO�tZ�W��̓�d�W�m� cm�d� ?/p>
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OpportunitySchemeCardServiceImpl implements OpportunitySchemeCardService {
+
+ /** �d/pV�'h�tZ8ume�XA_W��V�[t%1�W��\�0 07��?.2���]}�,l� �� {ig�m� p�0�}\@~�o��Nr`i~\aq�mf0R�?*/
+ 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;
+
+ // ==================== ̓�0�� ====================
+
+ @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(); // ��~\mnR��m��? 0 W�3 Mw�~�p� �O0}�~�T�Wt��}S��]l�Z�SKq^p�fZ0��?+ }
+ 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;
+ }
+
+ // ==================== �m�o�t ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long saveCard(SchemeCardSaveDTO dto) {
+ if (dto == null || dto.getOppId() == null || dto.getCustomerId() == null) {
+ throw cardInvalid("“ĉ �W�2 Z~p:j,_��nb�]�o�a}_��U�n�m�^yᴓ�\@~�o��}");
+ }
+ Opportunity opp = getOppOrThrow(dto.getOppId());
+ // Ó�r��~*[1]�,lyᴓ�\@~^p�ppn_��U�n�m,[�kO��[�N9p!2�W�"X�0 13 p/a0��\�0 07��?.1���] �~=�ko�?+ OpportunityCustomer customer = customerMapper.selectOne(
+ new LambdaQueryWrapper<OpportunityCustomer>()
+ .eq(OpportunityCustomer::getOpportunityId, dto.getOppId())
+ .eq(OpportunityCustomer::getCustomerId, dto.getCustomerId()));
+ if (customer == null) {
+ throw cardInvalid("O��[�N9p!2�W�m�]Xw\m�^pn_��U�n�X {O�}O��Ó�ryᴓ�\�}^p�p�kp�0�s“ĉ �W�&}");
+ }
+
+ List<SchemeCardSaveDTO.FieldValue> values = normalizeValues(dto.getValues());
+
+ if (dto.getId() == null) {
+ // �m� 9p!2�W�m� W�&}\mC'd���]�o Q2�Y0}Z�}\bfȓp�{�m� �mD�yᴓ��|�m� �[�r1]
+ 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,
+ "�t�0yᴓ�\jnȓ�bfȓp�{�[�SAn“ĉ �W�&}�m� 9p!2�W�m� W�&}��\��)�X[4^,h�}X��q1]");
+ }
+ // “gW��deȓ� “gB_/u�Q�Xȓ
�}f�)1� �e1]`�deX��q�Xȓ
�}V-CONFIG b~u�t]��{�?+ OpportunitySchemeCardTemplate template =
+ schemeTemplateService.resolveBindingVersion(opp.getOwnerDeptId());
+ if (template == null) {
+ throw cardInvalid("0��d�X�rr_"���}Y�b�z�mh�}(��R�g�YX1]�Y!�o�~\�h Z�f�g�[�p�g�YX1]");
+ }
+ 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();
+ }
+
+ // ,h�}�1l;u"�)1#e9p1l�Xȓ
�1}}� Y��/�[�SA_\m�0NoY��|e�XZ~��xO+^�5�� wO {e��p� � �Y}�[�S9^��yO�fW�3 g_�t?+ OpportunitySchemeCard card = getCardByIdOrThrow(dto.getId());
+ if (!Objects.equals(card.getOppId(), dto.getOppId())) {
+ throw cardInvalid("“ĉ �W�2 {^p�p,|�t�0bfȓ?);
+ }
+ if (!Objects.equals(card.getCustomerId(), dto.getCustomerId())) {
+ throw cardInvalid("“ĉ �W�3 �Sq��eyᴓ� {Y��?m��n�}�m� 9p!2�W�m� W���Ke�xv}");
+ }
+ if (card.getCardStatus() != null
+ && card.getCardStatus() >= OpportunityConstants.CARD_STATUS_PUSHED) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_READONLY,
+ "�[�S9^��yO�g�YX1]��Qg1]Y�G����}\��Ǖ� �m��|e�t ��g�[�p�g�YX1]");
+ }
+ 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();
+ }
+
+ // ==================== ��*a&l ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void submitCard(Long cardId, Long operatorId) {
+ if (cardId == null) {
+ throw cardInvalid("“ĉ �W�!A_\m�0,_��nb�]�o?);
+ }
+ OpportunitySchemeCard card = getCardByIdOrThrow(cardId);
+ if (card.getCardStatus() != null
+ && card.getCardStatus() >= OpportunityConstants.CARD_STATUS_PUSHED) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_READONLY,
+ "�[�S9^��yO�g�YX1]�m�]r_P��]A_\m?);
+ }
+ if (card.getCardStatus() != null
+ && card.getCardStatus() == OpportunityConstants.CARD_STATUS_SUBMITTED) {
+ return; // ���P�t�,l�Q��*a&l
+ }
+
+ // G�mT^�͓��Ys�-lɓc j�~p�{G�mT^�pAi��g��V0�[�c^��"X�|˓�R�[pAi��̓�0�[t%1�W�?+ 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("��*a&lS��]���o��SUG�mT^�pAi���? + String.join("��?, missing));
+ }
+
+ OpportunitySchemeCard change = new OpportunitySchemeCard();
+ change.setId(card.getId());
+ change.setCardStatus(OpportunityConstants.CARD_STATUS_SUBMITTED);
+ cardMapper.updateById(change);
+ }
+
+ // ==================== P�tT4Q�[�0�S ====================
+
+ private Opportunity getOppOrThrow(Long oppId) {
+ Opportunity opp = oppMapper.selectById(oppId);
+ if (opp == null) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_OPP_NOT_EXIST, "_��U�n�m�]�tf�&1�W�[6�f�R��r�j");
+ }
+ return opp;
+ }
+
+ private OpportunitySchemeCard getCardByIdOrThrow(Long cardId) {
+ OpportunitySchemeCard card = cardMapper.selectById(cardId);
+ if (card == null) {
+ throw new BusinessErrorException(OpportunityConstants.CODE_SCHEME_CARD_NOT_EXIST,
+ "“ĉ �W�2 {p:jjn��'h�Qt���Wĕ?);
+ }
+ return card;
+ }
+
+ /** _��U�n�m*[�Sq��eyᴓ?customerId +�?Z��]�c�?*/
+ 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));
+ }
+
+ /** b~u�Y!�o�Xpn(��R j�~�T�tZ8u 0�X�[4d�_1}ŕ/a�h$i� {Q�y�G^f�%1^�P�k 0�?*/
+ private List<OpportunitySchemeCardField> visibleFieldsOf(Long templateVersionId) {
+ return schemeTemplateService.listFieldsOfVersion(templateVersionId).stream()
+ .filter(f -> f.getIsVisible() != null
+ && f.getIsVisible() == OpportunityRuleConstants.FLAG_YES)
+ .collect(Collectors.toList());
+ }
+
+ /** pAi��ɓ�0mY�'h� wx0}�d/pV�'h�tZ5uqe�mf0e��p^��}\�ScmkG_J�p�0 */
+ 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);
+ };
+ }
+
+ /** J�p�0̓�0�� +�?fieldKey ē�r�v�"X%J�xY�i�[!� F�pig�V} */
+ 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));
+ }
+
+ /** O��0,_J�p�I�|��Q�[�-l�b�~*m 0��xO�tZ8umeȕ�p%��yO�Z�o�|˓�R�[pAi�῕�� {���]2濕?*/
+ 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("pAi��J����kpAi�῕�� {s�b��~?);
+ }
+ 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("�d/pV�'h�tZ�x}���m#r0� 2!}/`m*[�SÕ5���/“ĉ ��h�R{u��&b���Y���o���[t%1�tZ�x}U��W�ST�&1� xO�tZ[�� ?);
+ }
+ if (!seen.add(key)) {
+ throw cardInvalid("pAi��J�xYxV�o�]{���O0}" + key);
+ }
+ result.add(value);
+ }
+ return result;
+ }
+
+ /** J�|QNr`i�\0}pAi�῕��0^p�p�|9p-lɓ荗Xȓ��k**ēgRZ0**pAi���%X�k���_�tZxQ {Y��^��~\ɓWW#e�XpnÕ�S}�~O} */
+ 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("pAi���m�]Xw\m�^�|9p-lɓ荗Xȓ��kēgRZ0pAi���? + value.getFieldKey());
+ }
+ }
+ }
+
+ /** J�p�0O�)1zVǓc2]�X�SR��r�`���c1}�~y��jJ��� {��Ʌ1|=ZoT%�t]��{�?*/
+ 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;
+
+/**
+ * “ĉ �W��M~t~\iȓ�]�YYt�Rxr`i�\ v�"X�0 07��|\{��? X��q7p A3-1-1-1-3�Y� ?+ *
+ * <p>“a��%�xv0}</p>
+ * <ul>
+ * <li>�~��[ɓ�q0}Ó�r��~*[1]�Xyᴓ�\@~^p�ppn_��U�nO��[�N9p!2�W�Y� wO�z9p!2�W�m� W�&}66009�Y� ?+ * “g��yO6n“gB_/u�Qɓ荗Xȓ
�}Ó�rɓc�Z�oW�&}��zO*}Hg b;u"�)1#e9p1l�Xȓ��{9p!2�W�m�]r_ǓX[2]��?+ * �[�S9^��yO�fW�3 g_�tO}66010�}\r_,h�}=card_status<3 ���]�o P1-1�?/li>
+ * <li>T�&1� xO�tZ[�� wx0}O�)1zVǓc2]��xO�tZ8ume$i��Xwb~u�Y!�o�XpnēgRZ0pAi�ᆒzO�|˓�R�[pAi�῕���ZO��0� p�0��?+ * �~y��jJ��� {��Ʌ1|��m�ᯓoT�|˓�R�[pAi��"��S�[t%1m�o?/li>
+ * <li>��*a&l�,l@~�o��Nr`i�\}Z����|˓�R�[�mf0pAi���Y� }O]�~�^KU�[�SA_\m�0� xO�Q��*a&l���P�t��xO�Q��)1� yO�Z?/li>
+ * <li>R�Di0�? W�?��~\mnR��m���]Mw�~�p� �O}�~�T�Wt��}��xOyᴓ�\�`G����Se��p j��jNditable 2ZgRSe</li>
+ * </ul>
+ */
+@DisplayName("“ĉ �W��M~t~\iȓ�]�Y�"X�0 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);
+ }
+
+ // ==================== �[�0�] ====================
+
+ 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("pAi��" + 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", "Ǖ� UtwO�z�oAi�js����09p�V�g�Y?)));
+ return dto;
+ }
+
+ private SchemeCardSaveDTO.FieldValue value(String key, String val) {
+ SchemeCardSaveDTO.FieldValue v = new SchemeCardSaveDTO.FieldValue();
+ v.setFieldKey(key);
+ v.setFieldValue(val);
+ return v;
+ }
+
+ /** �i?j{��Y!�opAi���YK%0}ēgRZ0pAi�� = Ǖ� �Y�P?_ig?G�mT^�) + *�,[G^f�?��
Y^�) + �d/pV�&h������1}Y�=�An�m� �mH��k���_�tZ?*/
+ 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)));
+ }
+
+ // ==================== R�Di0 ====================
+
+ @Test
+ @DisplayName("R�Di0�,lbfȓp� {p:jjn +�?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("R�Di0�? W�?=��~\mnR��m���]Mw�~�p� ?+�?�~�T�Wt?)
+ void zeroCardsMeansEmptyList() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(cardMapper.selectList(any())).thenReturn(List.of());
+ assertThat(service.listCards(OPP_ID)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("R�Di0�,l?�W�3 mē�`yᴓ�\�`G����S + editable 2ZgRSe�?3 Y��*}Hg/b}")
+ 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, ""��cyᴓ?, 1),
+ customerRow(OTHER_CUSTOMER_ID, "�mkyᴓ?, 0)));
+
+ List<SchemeCardDTO> list = service.listCards(OPP_ID);
+ assertThat(list).hasSize(2);
+ assertThat(list.get(0).getCustomerNameSnapshot()).isEqualTo(""��cyᴓ?);
+ assertThat(list.get(0).getEditable()).isTrue();
+ assertThat(list.get(1).getEditable()).isFalse();
+ }
+
+ // ==================== �t=�Q ====================
+
+ @Test
+ @DisplayName("�t=�Q�,l1]�m�]�tf�?+�?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("�t=�Q�+l�|ēgRZ0pAi��ȓ
Y-|e��p j + J�p��尕?+ �d/pV�'h�tZxQ�[t%1m�o?)
+ 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, ""��cyᴓ?, 1)));
+ stubTemplateFields();
+ when(valueMapper.selectList(any())).thenReturn(List.of(
+ valueRow("customer_requirement", "ÓC� ?)));
+
+ SchemeCardDetailDTO detail = service.getCardDetail(CARD_ID);
+ // ŕ/a�hpAi���m�]�V�.^1}ēgRZ0pAi�ḓ
Y-|
+ assertThat(detail.getFields()).extracting(SchemeCardDetailDTO.FieldEntry::getFieldKey)
+ .containsExactly("bidding_form", "customer_requirement", "site_visit");
+ assertThat(detail.getFields().get(0).getFieldValue()).isEqualTo("BID_INVITE"); // �mf0e��p^�
+ assertThat(detail.getFields().get(1).getFieldValue()).isEqualTo("ÓC� ?);
+ assertThat(detail.getSchemeBudget()).isEqualByComparingTo("88000.00");
+ assertThat(detail.getEditable()).isTrue();
+ }
+
+ // ==================== �m�o�t�X�g�[4s} ====================
+
+ @Test
+ @DisplayName("“g�,l,_��nb�]�o?+�?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("“g�,lyᴓ� {^p�ppn_��U�nO��[�N9p!2�W�X�hp�0�sW�&}+�?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("“g�+l�z9p!2�W�m� W�?+�?�[�SAnW�!�Z�o}66009�?)
+ void saveRejectsDuplicatePerCustomer() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, ""��cyᴓ?, 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("“g�-l�hY�b�z�m_�ɓ?+�?���c�|�[�T1]�?6007�?)
+ void saveRejectsWithoutPublishedTemplate() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, ""��cyᴓ?, 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("“g�6l#eȓ� “gB_/u�Qɓ荗Xȓ?+ }� Y�笓?+ �d/pV�'h�tZCO�`�mf0 + J�p��`4d?)
+ void createCardLocksTemplateVersion() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, ""��cyᴓ?, 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("“g�,l� ��{��zO�|˓�R�[pAi�῕?+�?���c�|�?6007�?)
+ void saveRejectsStructuredKeysInValues() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, ""��cyᴓ?, 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("“g�,l� Nq�tZ8ume�m�]Xwb~u�Y!�oēgRZ0pAi���X�`ŕ/a�hpAi���YKU ���c�|�?6007�?)
+ void saveRejectsUnknownFieldKey() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, ""��cyᴓ?, 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"))); // ŕ/a�hpAi���m�]r_�o?+ 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);
+ }
+
+ // ==================== �m�o�t�"X*}Hg/b} ====================
+
+ @Test
+ @DisplayName(",h�}�,l1]�m�]�tf�?+�?66008��m�Q��)1� yO�fW�3 g_�t?+�?66010")
+ void editGuardsExistenceAndPushed() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, ""��cyᴓ?, 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(",h�}�,l�Sq��eyᴓ� {Y��?m��n�}�m� 9p!2�W�m� W���Ke�xv}+�?66007")
+ void editCannotChangeCustomer() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(OTHER_CUSTOMER_ID, "�mkyᴓ?, 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(",h�}�,l�Q��*a&lW�3 r_���{�5�� wO {e��p� � �X�Zɓ?P1-1�Y1}J�Nq�S���_[m��l�� zO%'�Ʌ� �� {��Ʌ1|")
+ void editSubmittedKeepsStatusAndReplacesValues() {
+ when(oppMapper.selectById(OPP_ID)).thenReturn(opp());
+ when(customerMapper.selectOne(any())).thenReturn(customerRow(CUSTOMER_ID, ""��cyᴓ?, 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);
+
+ // `mnT?m“nb�|˓�R�[�mf0R�Ki}�5�� wO {e��p� � �X�h cardStatus ���}�?+ ArgumentCaptor<OpportunitySchemeCard> captor =
+ ArgumentCaptor.forClass(OpportunitySchemeCard.class);
+ verify(cardMapper).updateById(captor.capture());
+ assertThat(captor.getValue().getCardStatus()).isNull();
+ // J�p�0O�X�WZ��^C_��m%'�Ʌ� �� {��Ʌ1|
+ 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");
+ }
+
+ // ==================== ��*a&l ====================
+
+ @Test
+ @DisplayName("��*a&l�,l@~�o���]�o?+�?66007�"X�|˓�R�[pAi��Y��P {G�mT^�͓��Ys�?)
+ void submitValidatesRequired() {
+ OpportunitySchemeCard card = card(CARD_ID, CUSTOMER_ID,
+ OpportunityConstants.CARD_STATUS_DRAFT);
+ card.setBiddingForm(null); // G�mT^��d/pV�'h�tZ�Wmn�o?+ when(cardMapper.selectById(CARD_ID)).thenReturn(card);
+ stubTemplateFields();
+ when(valueMapper.selectList(any())).thenReturn(List.of(
+ valueRow("customer_requirement", "�[�c^�")));
+
+ 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("��*a&l�3l]�~?+�?�[�SA_\m�0}G�mT^��i,a�S�?)
+ 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", "�[�c^�")));
+
+ 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("��*a&l�,l�Q��*a&l���P�t��m�Q��)1� yO�Z�o}66010�Y1}Y��P�f�TQ0 +�?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)); // ���P�t�r7dcm?+
+ 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;
+
+/**
+ * “ĉ �W�!ɓcHoĕ.aR�p,a�[�X�|��n]��\�0 07��|\)|���]}
+ * <p>�m?{@link OpportunityStageTemplatePermissionInitializer}�%XjZ�Wɓ!S}R��U�W�,l�`^p�pbfȓ?�I�R�k�ti��q}
+ * ��n]���P� }\bfȓy�x���U� �]0m0��f}X��q7p A7-3-2-2 “ĉ �W�!ɓ���}���P�`�~+h�f�m�]R�p,a�[ button�?+ * ApiPermissionInterceptor 5pĉmn Z%1=U 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("��F�“ĉ �W�!ɓcHoĕ.aR�p,a�[�Y� ̓�0}`mpTM_W��f}...");
+
+ permissionSeeder.seedModule(new PermissionModuleDescriptor(
+ "_��U�n�~��`",
+ "“ĉ �W�!ɓ�?,
+ "/opportunity/scheme-template",
+ "opportunity/scheme-template/index",
+ 7,
+ List.of(), // �m�]R�p,a�[ button��\qe�~��`[�:j�`b��S?+ List.of("ROLE_ADMIN")
+ ));
+
+ log.info("“ĉ �W�!ɓcHoĕ.aR�p,a�[�Y� ̓�0lu��6a0}`mpTM_W��f}0 �m?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;
+
+/**
+ * “ĉ �W�����q�tZ[�1|�~�]�tV�5h}�~?07��?.4���]}\mC'd/˓5�/p���]�o P0-3 w�D�pAi��O��Q�f����}
+ *
+ * <p>���P�t�-l�[ {@code field_key} R��0xV��\�]_��0�_��0� wO {R��r {��ĉ�hȓY��X�tZ[��`/�~��7p�o�P6n�t�Q�f
+ * �tHr�f�����m���0}�m�]jnZ���Y�~�]�tV�,h�[e���}���P�tZ[��`�m�T+^h�+[qrn*[� wx}ȕ�pYs����� wx}��?/p>
+ *
+ * <p>��}\]�� Y{��Y� �]��pAi��(��R� Y 0ƕ�Uˆ {@code option_source} �[?crm-dict R��U�| code
+ * �"X�|Z�#X� �\jo\m�\� Y�z��\�0 07��?.4���]}��m�tO�?z�W�Rpn엤�qep@iTi��qR�p,a�[/ig/a�`X[�Y�~\pn�o�R�|'�f���[�fde�t]��{��?/p>
+ */
+@Slf4j
+@Component
+@Order(15)
+@RequiredArgsConstructor
+public class SchemeFieldDefInitializer implements CommandLineRunner {
+
+ private final OpportunitySchemeFieldDefMapper fieldDefMapper;
+
+ @Override
+ public void run(String... args) {
+ log.info("��F�“ĉ �W�����q�tZ[�1|�~�]�tV�(h��̓?..");
+
+ 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("“ĉ �W�����q�tZ[�1|�~�]�tV�(h��̓�0lu��6a0}�g�t {} �mN�}“g�� {} �m?,
+ existingKeys.size() + inserted, inserted);
+ }
+
+ /** ͓��}pAi��ƕ�U}�~?07��?.4���]+^h�*[�W�m�d1}9p!2�WZ��]/“ĉ ��h�R{u��&b���m�T4U�� {Y���W�}\�Scmkr_R��r} */
+ private List<OpportunitySchemeFieldDef> sampleDefs() {
+ return List.of(
+ def("customer_name", "9p!2�WZ��]", OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT,
+ null, "customer", null, null, 0),
+ def("customer_type", "9p!2�W�~��7p", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "customer_type", null, null, null, 1),
+ def("customer_contact", "9p!2�Wq��e��\m?, OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT,
+ null, "contact", null, null, 1),
+ def("customer_phone", "9p!2�Wq��e��"�CO=v", OpportunityRuleConstants.FIELD_TYPE_AUTOFILL,
+ null, null, "customer_contact.phone", null, 1),
+ def("project_stage", "$i-W0mÕ5���", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "project_stage", null, null, null, 1),
+ def("bidding_form", "���m#r0� 2!}", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "bidding_form", null, null, null, 1),
+ def("site_visit", "ē��`ȓ YEn�g�n", OpportunityRuleConstants.FIELD_TYPE_YES_NO,
+ null, null, null, null, 1),
+ def("budget_clear", "“ĉ ��h�R{uē��`ē�^ ", OpportunityRuleConstants.FIELD_TYPE_YES_NO,
+ null, null, null, null, 1),
+ def("scheme_budget", "“ĉ ��h�R{u��&b��", OpportunityRuleConstants.FIELD_TYPE_AMOUNT,
+ null, null, null, null, 0),
+ def("design_config", "�tPcxᰕ�]�u", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT,
+ "design_config", null, null, null, 1),
+ def("brand_requirement", "]�zO�XUtyO0w", OpportunityRuleConstants.FIELD_TYPE_TEXT_SINGLE,
+ null, null, null, null, 1),
+ def("customer_requirement", "9p!2�WǕ� �Y�P?_ig?, OpportunityRuleConstants.FIELD_TYPE_TEXT_MULTI,
+ null, null, null, null, 1),
+ def("expected_deal_time", "�h�Rxᴓ*a&lÓX�h", OpportunityRuleConstants.FIELD_TYPE_DATE,
+ null, null, null, null, 1),
+ def("deal_probability", "�h�Rxᴓ*a&l�Y�P�]", OpportunityRuleConstants.FIELD_TYPE_TEXT_SINGLE,
+ null, null, null, null, 1),
+ def("expected_bid_time", "�h�Rxᶓ�f#rÓX�h", 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 {
/** Y�b�z͓��Ys�o�����6ljZCO�Y�R_�S�� {9p~\�f�X�hœ��� 3l�Y�?p@iT code �TQ0�?*/
int CODE_STAGE_TPL_NODE_INVALID = 64017;
+
+ /*-------- “ĉ �W�!ɓ!S}�~?07�Y0}+hrS��]} + pAi���~��7pJ�NqYq ---------*/
+
+ /** “ĉ �W�!ɓ��*}.�xO�X� �"X���qSe��6a}4d�__$i�T"k�w[-CONFIG 1�?*/
+ String SCHEME_TPL_CODE_PREFIX = "OPP_SCHEME_TPL_";
+
+ /* pAi���~��7p˓+l�Y�X+^h�?A7-3-2-2��}\�tZ5u��h�([� �]�W�[�Ssr9p?8 J�?+ ͓��}t�0�SÓ�0an��\�0 07��?.4���]} */
+
+ /** O��[�N5pEx���nWef_target ���V�`)���#r9p�p�}�?*/
+ String FIELD_TYPE_RELATED_OBJECT = "RELATED_OBJECT";
+ /** W��f� Y{��Y}��Y 0ƕ�Ur_ option_source �[?crm-dict R��U�|�?*/
+ String FIELD_TYPE_SINGLE_SELECT = "SINGLE_SELECT";
+ /** w�D��Ye��p^��EWutofill_source ���V�iɓ�0.|�?*/
+ String FIELD_TYPE_AUTOFILL = "AUTOFILL";
+ /** �`�X�i/Z�<�} */
+ String FIELD_TYPE_YES_NO = "YES_NO";
+ /** ĕ�R"k�mAZ6} */
+ String FIELD_TYPE_ATTACHMENT = "ATTACHMENT";
+ /** ��&b�� */
+ String FIELD_TYPE_AMOUNT = "AMOUNT";
+ /** W��f�“�Vpn */
+ String FIELD_TYPE_TEXT_SINGLE = "TEXT_SINGLE";
+ /** �o3l�“�Vpn */
+ String FIELD_TYPE_TEXT_MULTI = "TEXT_MULTI";
+ /** Ó�0an�Xqrn*[�tZ�p� �\i��t!�W\m�0i•�m� �]de��\�0 07 \mC'd���]�o“ĉ �W�3 4U��%1�tZ�x} */
+ String FIELD_TYPE_DATE = "DATE";
+
+ /*-------- “ĉ �W�!ɓ8Y{T���Je�t�r�"X�0 07�?4018 �t�p0�[��} --------*/
+
+ /** �Y!�oY��P�f/�5�� yONr`i}\Q0�t?*/
+ int CODE_SCHEME_TPL_INVALID = 64018;
+
+ /** �Y!�o�Xpn�m�]�tf�?*/
+ int CODE_SCHEME_TPL_NOT_EXIST = 64019;
+
+ /** `mpT]�~�Wr_R��r}Y�b�z�m\�� xO�QK��nde�m�]r_)�X[4^R��r} */
+ int CODE_SCHEME_TPL_NOT_DRAFT = 64020;
+
+ /** Z�~\ɓ��*}.�xO�Qp:jjn}� Y���"X*}HgbB_/u�QQ�/K��nde�XpnÓ5g {P��]Se��-a�g}� Y���?*/
+ int CODE_SCHEME_TPL_DRAFT_EXISTS = 64021;
+
+ /** pAi��$iĉNr`i}\Q0�t�0}pAi���m�]jn�~d��|pAi��4d?���]2�/Y�b�zÓ5��hpAi���?*/
+ 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;
+
+/**
+ * “ĉ �W�!ɓc4^Y�O�}���R� �P�S^p�P}Y�G��v {@link IOpportunitySchemeCardTemplateService}��\�0 07��|\)|���]}
+ * <p>ɓ�Q�j�~+h�f�+l {�~�]�tV�?button ɓ�Q�j�xv}Z�}\�S4Z�uI�R�?Õ5����Y!�o�Y}ApiPermissionInterceptor
+ * 5pĉmn Z%1=U URL fail-open��m忓�]�}-�E%j�#X]�~?�t=�Q��,h�}��R��r�j�}\B_/u�QQ�/K��nde=�t=�Q��,h�}o�]�W�?+ * "���GnT�3 0w�5�� yO�n͓��YsO��n3|���P� �\i�YtX^�P�kef˓�n� �]��~��X�~��[pAi�ᰕ�]�uZ�SKq�~\pn���0[_�m�]�h�RM��m�dXw�d/p�"X�0 07��?.5���]}��?/p>
+ */
+@Tag(name = "_��U�n�~��`/_��U�nYt�R�W")
+@RestController
+@RequestMapping("/api/rule/opp-scheme-template")
+@RequiredArgsConstructor
+public class OpportunitySchemeCardTemplateController {
+
+ private final IOpportunitySchemeCardTemplateService templateService;
+
+ @Operation(summary = "“ĉ �W�!ɓ�W�W$i[��Wt��}�� Y�Xȓ��m4d@�Mw�~4s}")
+ @PostMapping("/page")
+ public Result<PageResult<OpportunitySchemeCardTemplateDTO>> page(OpportunitySchemeCardTemplatePageParam param) {
+ return Result.success(templateService.pageTemplates(param));
+ }
+
+ @Operation(summary = "“ĉ �W�!ɓ���ᯓ�T}Z���� �Pde��)1�h + pAi��$iĉAn4d�_�Wt��}")
+ @GetMapping("/detail")
+ public Result<OpportunitySchemeCardTemplateDTO> detail(@RequestParam("id") Long id) {
+ return Result.success(templateService.getTemplateDetail(id));
+ }
+
+ @Operation(summary = "�~d��|pAi��4d�d�Wt��}�Y!�o���]�u$i�p� �\�
Y�tZ�p� �]�f����.|�}\g_�tO}")
+ @GetMapping("/field-defs")
+ public Result<List<OpportunitySchemeFieldDefDTO>> fieldDefs() {
+ return Result.success(templateService.listFieldDefs());
+ }
+
+ @Operation(summary = "�m�o�t}� Y���X�g�[?X��q�},h�}}� Y��/`m�^B_/u�QQ�K��nde"��q�W“�b]�~!S1}pAi��$ipt�S���_[m��n�}")
+ @PostMapping("/save-draft")
+ public Result<Void> saveDraft(OpportunitySchemeCardTemplateDTO dto) {
+ templateService.saveDraft(dto);
+ return Result.success();
+ }
+
+ @Operation(summary = "�m�o�t����B_/u�Q}w��Q�v�m� �mD��tZ8u 0��m+^Y�b�z�ma�FmK��nde��xO+^�i?j{�Y�(h�y�i?j{��?)
+ @PostMapping("/publish")
+ public Result<Void> publish(OpportunitySchemeCardTemplateDTO dto) {
+ templateService.saveAndPublish(dto);
+ return Result.success();
+ }
+
+ @Operation(summary = "�o�]�W�Y!�o�"X�Z�~+[�g�Y!�o�-l�g+hr + V1.0 }� Y���}\�tZ8u�S���k�o�]�W�?)
+ @PostMapping("/copy")
+ public Result<Long> copy(@RequestParam("id") Long id) {
+ return Result.success(templateService.copyTemplate(id));
+ }
+
+ @Operation(summary = "K��nde�X�|Y�b�z�m^�r_K��nde�?)
+ @PostMapping("/disable")
+ public Result<Void> disable(@RequestParam("id") Long id) {
+ templateService.disableTemplate(id);
+ return Result.success();
+ }
+
+ @Operation(summary = "R��r�j�X�|}� Y��Y���W��\��q��e�WpAi��$i� {���Pde��)1�h�?)
+ @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;
+
+/**
+ * “ĉ �W�!ɓ8Y6}Hg�d���t&}P�k�SY�?+ Q��T,_Y�}\�`��\�0 07��?.3���]}Z��\�0 06 Route A Y�E��}�?+ *
+ * <p>templateCode/versionNo/status �~d��|"��q�WY�G����,l�SY��PvfG�Lu�f�}\�VY��Pvfe��p j�?+ * ���Pde��)1�h`m?ID R�Di0ŕ�_�[5pEx���mGZ� ?e��p j�}\�tZ8u 0`m?{@link FieldItem} ȓ
Y-|R�Di0����}��*a&l
+ * �X-|Y� ��[��*a&l$i�T-|���]{u�Y}Service ^p�P y�tF��ZP�k�tt#1� ?/p>
+ *
+ * <p>pAi��$ipt�SY��Pvf`m?{@code fieldKey}�X)}�~d��|pAi��4d�d}+ ēgRZ0/G�mT^��[� O�k�An��,X1}
+ * fieldName/fieldType "���GnT�3 0w`m�^���q�tZ[�~u�m
Y�c�CSUO��0}Õm� T���r}�}\�VY��Pvfe��p j��?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OpportunitySchemeCardTemplateDTO extends BaseDTO {
+
+ @Schema(description = "�Y!�o+hr�"X���qSe��6a}Y�G����?)
+ private String templateCode;
+
+ @Schema(description = "�Y!�oZ��]")
+ private String templateName;
+
+ @Schema(description = "�XpnY��Q}�~d��|"��q�WY�G����Ok1.0/V1.1%�<�}")
+ private String versionNo;
+
+ @Schema(description = "�5�� �O0}1}� Y�� 2Y�b�z�m?3�[�c�N"���}Y�G����?)
+ private Integer status;
+
+ @Schema(description = "���Pde|��Q?m�?��� ȓY4Q•)1� 1lde 25ppt2|��)1�h�m�dde�X���YE�]��Y}�~?07��?.2���]}")
+ private Integer applyScope;
+
+ @Schema(description = "ē��`�i?j{��?ē?0Z�<�}`mnT�XȓY4Q•)1� 1ldeY������\4Q•$1{"�$1 {O�}O���?)
+ private Integer isDefault;
+
+ @Schema(description = "�Y!�o�tX[�i")
+ private String templateDesc;
+
+ @Schema(description = "���Pde��)1�hIDR�Di0�EWpplyScope=2 ��@~�o���VOpb�z�mN�}")
+ private List<Long> deptIds;
+
+ @Schema(description = "���Pde��)1�hZ��]R�Di0�X�VY��Pmē�d}")
+ private List<String> deptNames;
+
+ @Schema(description = "pAi��$iĉAn4d�_�Wt��}4d�__��
Y�Wt)104d�_xV�~Ki1}pAi���[�f���q�tZ[�1|�?)
+ 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;
+ }
+
+ /**
+ * pAi��$ixv}��
Y�tZ?+ �[� O�k� j�~?+ �[� O��Q@~�o��}�~?07��?.3���]}��?+ * <p>O��0,_�k@code fieldKey} G�mT^��X)}�~d��|pAi��9p+l�{�Y}{@code isVisible}/{@code isRequired} �[� O�y{1}
+ * ēgRZ0/G�mT^��~��[ɓ�q0}O�k� j�~�T�`�Y�0�zG�mT^��~\GnT�3 0w�m�o�tÓ�`I�|��Q�[�m�o v��?+ * Q��T,_�,lmē�`�tZ[��`/�~��7pG����S�m�^an9p�p-|Y��g� ?/p>
+ */
+ @Data
+ public static class FieldItem {
+
+ @Schema(description = "pAi��$i={D�X�VY��Pmē�d1}“gÓ�r}")
+ private Long id;
+
+ @Schema(description = "4d�__�X�VY��Pmē�d1}O��0,_��
Y�Wt)104d�_xV�~Ki}")
+ private Integer seqNo;
+
+ @Schema(description = "pAi�῕��}G�mT^��}\)}"�'1���q�tZ[�~u�mY}ȕ�p�V"�M�� �r}")
+ private String fieldKey;
+
+ @Schema(description = "^p�fZ0pAi��Z��]}G����Se��p j��m�SY��P�b#��0}Y�+h���q�tZ[�~u�mY}")
+ private String fieldName;
+
+ @Schema(description = "pAi���~��7p�X�c�Cmē�d1}O��0,_G�Lu�f�}\G_�~d��|pAi��9p+l�{�?)
+ private String fieldType;
+
+ @Schema(description = "ēgRZ0�[� O�y{0}1ēgRZ0 0�m�] j�~4s}O��b�hR�k {Q�y�G^f�%1^�P�k 0�m�e�zG�mT^��?)
+ private Integer isVisible;
+
+ @Schema(description = "G�mT^��?G�mT^� 0ȕ�p@~�o��}`mnT j�~�piY��� 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;
+
+/**
+ * “ĉ �W�����q�tZ[�~u�m
Y�VY��P}�~?07��?.4���]0}�Y!�o���]�u$i�p� �\�
Y�tZ�p� �]�f����.|�?+ * <p>pAi��4d�dg_�tO}�~�]�tV�+h�m���0}�~\pn DTO `mmT�VY��P1}W��f� Y{��Y� Y 0ƕ�U�[ {@code optionSource}
+ * �[?crm-dict R��U�| code "�^S�X�~��Vt~\�ZY�'h�tO�d 0�"X�|Z�#X� �\jo\m�\� Y�z�Y� ?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OpportunitySchemeFieldDefDTO extends BaseDTO {
+
+ @Schema(description = "pAi�῕��}�Y!�opAi��$ipt)}"�%1`u�?)
+ private String fieldKey;
+
+ @Schema(description = "pAi��Z��]}^p�fZ0Z��]}")
+ private String fieldName;
+
+ @Schema(description = "pAi���~��7p�'WELATED_OBJECT/SINGLE_SELECT/AUTOFILL/YES_NO/ATTACHMENT/AMOUNT/TEXT_SINGLE/TEXT_MULTI/DATE�?)
+ private String fieldType;
+
+ @Schema(description = "��Y 0ɓ�0.|�X]�� Y{�� Y���,l�tO�?z�W?code�?)
+ private String optionSource;
+
+ @Schema(description = "O��[�N5pEx�����V�`�X�Sq��e���t����1l0m͓�V�ucm�d���mY}")
+ private String refTarget;
+
+ @Schema(description = "w�D��Ye��p^�ɓ�0.|�#X�VT�%1m�o�ૈ�-luogZ,a�tZCO���mY}")
+ private String autofillSource;
+
+ @Schema(description = "�i?j{�J�?)
+ private String defaultValue;
+
+ @Schema(description = "ē��`Y���Wĕ�00}1Y���W 0P�oT�u�m�]r_R�?)
+ 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;
+
+/**
+ * “ĉ �W�!ɓ�W�tZ8u 0�XAn4d�_}`m�^Xw�Y!�o�Xpn��\�0 07��?.3���]}
+ *
+ * <p>�Y!�o = ���\�
Y�tZ?+ ���c04d?+ �[� O�k� j�~?+ �[� O��Q@~�o��� �]0}{@code field_key} �[�f���q�tZ[�1|
+ * �}W@link OpportunitySchemeFieldDef}��\jow�F�qe���r}��[@code field_name}/{@code field_type}
+ * �mp�Z~p;ji(��R���q�tZ[��c�M}Õ?join���O�ipAi��4d�d|eZ��]}�~O}���P�Wĕ�0�tZxQ�|0�^S7d0��d�X}� Y���Xpn�?+ * �m�]�W�~d��|pAi��9p+l�{�X+^h�)[{T��I�R�?10�Y� ?/p>
+ *
+ * <p><b>ēgRZ0/G�mT^��~��[ɓ?/b>�"X�0 07��?.3���]}�k@code is_visible=0} �[�T�W {@code is_required=0}
+ * �X�SēgRZ0Z�~\�ZmT@~�o��}��\qeȓ�]�Y^p�PZ~p;jiYt�R�[V�&hZ~�tuO� ?/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 '�mZZme'")
+ private Long id;
+
+ @Comment("��� ^p�pɓ荗XȓtD�eWpportunity_scheme_card_template.id��\�Xȓᇚȕ?code �~M}")
+ @Column(columnDefinition = "bigint not null comment '��� ^p�pɓ荗XȓtD'")
+ private Long templateId;
+
+ @Comment("4d�__ = “ĉ �W�3 ^�P�k 0^p�fZ0$i�T-|�XZ~p;ji�� YA_\m�004d�_xV�~Ki}")
+ @Column(columnDefinition = "int not null comment '4d�__'")
+ private Integer seqNo;
+
+ @Comment("pAi�῕��}�[�fde�~d��|pAi��9p+l�{ opportunity_scheme_field_def.field_key��\jow�F�qe���r}")
+ @Column(columnDefinition = "varchar(64) not null comment 'pAi�῕?")
+ private String fieldKey;
+
+ @Comment("^p�fZ0pAi��Z��]}G����S�~\uow�F����q�tZ[�~u�mY}")
+ @Column(columnDefinition = "varchar(100) not null comment '^p�fZ0pAi��Z�?")
+ private String fieldName;
+
+ @Comment("��B"k/��HrA]�~��7p�X�c�M}ɓ�0�V�~d��|pAi��9p+l�{�?)
+ @Column(columnDefinition = "varchar(32) not null comment 'pAi���~��7p'")
+ private String fieldType;
+
+ @Comment("ēgRZ0�[� O�y{0}1=ēgRZ0 0=�m�] j�~4s}O��b�hR�k {Q�y�G^f�%1^�P�k 0�|\{�[�T�WZmT@~�o��}")
+ @Column(columnDefinition = "tinyint not null default 1 comment 'ēgRZ0�[� O�?")
+ private Integer isVisible;
+
+ @Comment("G�mT^��?=G�mT^� 0=ȕ�p@~�o��}`m?is_visible=1 Ó��r_�m?1��\ �~=�ko�?)
+ @Column(columnDefinition = "tinyint not null default 0 comment 'G�mT^�'")
+ 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;
+
+/**
+ * “ĉ �W�!ɓ�WT0t��}�XpnV�-h�S��}�~?07��?.3��? X��q7p A7-3-2-2�?+ *
+ * <p>V-CONFIG �~��{�mD��un6[}�~?05 O��ccYt�R�W / �~?06 Õ5����Y!�o / �~?07 “ĉ �W�!ɓ�W�`Ó�_}�?+ * Z�|\�z {@code template_code} �m� �mE�ɓ䈬 xO?��Xpn�~\!v�Xpn�m� t�\1}�Xpn�5�� yO�n�~=�ko"���GnT�3 0w͓��Ys��?+ * b~u9p�p�}�X�g�YX1]�Y#e�Xpnt?id�}\B_“nb�X�m�]<~�~�p� �e� �ejn���e�g�YX1]`�deX��q�Xȓ
�}“g“ĉ �W��deȓ� “gB_/u�Q�Xȓ��� ?/p>
+ *
+ * <p><b>���Pde|��Q?m�m�00</b>�X {�~?05 O��ccYt�R�WZ�{\� wO {�~?06 Õ5����Y!�o�t]��{Z�~\0Z��]}�Y0}
+ * 1=��� ȓY4Q•)1� 1lde�Xr_�tC��|�t�0}/ 2=5ppt2|��)1�h�m�dde�X@~��Y4Q•#1� wO {O�}O���tC��|�t�0}��?+ * �i?j{�^���z�+l�|��1ldeY�����i?j{��}\B_/u�QQ��i?j{�^���z�}\B_“4O�|�t�0+^�i?j{�w�D��YY�(h�y�4W-CONFIG 6�Y� ?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("opportunity_scheme_card_template")
+@Entity
+@Table(name = "opportunity_scheme_card_template")
+public class OpportunitySchemeCardTemplate extends BaseEntity {
+
+ @Comment("�Y!�o+hr�"X���qSe��6a}Z�~\ɓ�W?��XpnO���de�}\�� OPP_SCHEME_TPL_01�?)
+ @Column(columnDefinition = "varchar(64) not null comment '�Y!�o+hr'")
+ private String templateCode;
+
+ @Comment("�Y!�oZ��]�X�`�Y!�o�o1l�Xȓ���"�%1�`Z��]}")
+ @Column(columnDefinition = "varchar(100) not null comment '�Y!�oZ��]'")
+ private String templateName;
+
+ @Comment("�XpnY��Q}�~d��|"��q�WY�G����Ok1.0/V1.1/V2.0%�<�}")
+ @Column(columnDefinition = "varchar(20) not null comment '�XpnY�?")
+ private String versionNo;
+
+ @Comment("�5�� �O0}1=}� Y�� 2=Y�b�z�m?3=�[�c�N"���}V-CONFIG 3-5�?)
+ @Column(columnDefinition = "tinyint not null comment '�5�� �O0}1}� Y�� 2Y�b�z�m?3�[�c�N"�?")
+ private Integer status;
+
+ @Comment("���Pde|��Q?m�?=��� ȓY4Q•)1� 1lde 2=5ppt2|��)1�h�m�dde�X���YE�]��Y}�~?07��?.2���]}")
+ @Column(columnDefinition = "tinyint not null comment '���Pde|��Q?m�?��� ȓY4Q•)1� 1lde 25ppt2|��)1�h�m�dde'")
+ private Integer applyScope;
+
+ @Comment("ē��`�i?j{��?=ē?0=Z�<�1}`mnT�XȓY4Q•)1� 1ldeY�����}\B_/u�QQ��i?j{�^���z�4W-CONFIG 6�?)
+ @Column(columnDefinition = "tinyint not null default 0 comment '�`�i?j{�'")
+ private Integer isDefault;
+
+ @Comment("�Y!�o�tX[�i�X{T��de��?���Pdef��p�j�?)
+ @Column(columnDefinition = "varchar(500) comment '�Y!�o�tX[�i'")
+ 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;
+
+/**
+ * “ĉ �W�!ɓ?���Pde��)1�hO��[�N�EWpply_scope=2 5ppt2|��)1�h�m�dde(��R?�J�wx}�~?07��?.3���]}
+ *
+ * <p>O��[�Nt��}Ó�rx��t3 �tZ�x}�~��Wĕ�0}�Y!�o�Xpn�m�o�tÓ���SR��r�`���c}�?+ * apply_scope=1�X�XȓY4Q•)1� 1lde� Yiȓ�0Ó�r冒?/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 '�mZZme'")
+ private Long id;
+
+ @Comment("�Y!�o�XpnID�eWpportunity_scheme_card_template.id��\�Xȓ���mZZme�?)
+ @Column(columnDefinition = "bigint not null comment '�Y!�o�XpnID'")
+ private Long templateId;
+
+ @Comment("���Pde��)1�hID")
+ @Column(columnDefinition = "bigint not null comment '���Pde��)1�hID'")
+ 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;
+
+/**
+ * “ĉ �W�����q�tZ[�~u�mY}�~?07��?.4���]}\mC'd/˓5�/p���]�o P0-3 w�D��?+ *
+ * <p>“ĉ �W�3 �tZ[�1| = pAi��O��Q�f����0}�Y!�oK�1l�kē�� �\�
Y�tZ?+ ���c04d?+ �[� O�k� j�~?+ �[� O��Q@~�o��� �]}
+ * �m�]�iw�F�qeHg�d�SpAi��Z�?�~��7p�X+^h�)[{T��I�R�?10�,l�Wĕ�0�tZxQ�|0�^S7d0��d�X}� Y���Xpn�|\ {R��r���q�tZ[�~u�mY}��?+ * <b>�m�]2�"�?crm-dict</b>�,l�S.�}O0�Y3 7p�WictGroup+DictItem�
Yg_s�P0��|\�z�Rr_��
Y� :{� �]}
+ * Ó?field_type/O��[�N)���#r/e��p^�ɓ�0.|R�Ki}0�"2�Y�m�].\���]� ?/p>
+ *
+ * <p>�R�`p��\jo\m�\� Y�z�%l� }\]�� Y{��Y� �]��pAi��(��R� Y 0ƕ�Ur_ {@code option_source} �[?crm-dict
+ * R��U�| code�#X���tg�tO��T�k��Y 0�tg�tO�i�}pAi��9p+l�{ȓ���w�D��Y� ?/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("pAi�῕��}�~d��|pAi��^���z͓�Vv�~\ɓ�W�tZ8u 0�[�fde9p�Q}ȕ�p�V"�M�� �r}")
+ @Column(columnDefinition = "varchar(64) not null comment 'pAi�῕?")
+ private String fieldKey;
+
+ @Comment("pAi��Z��]}^p�fZ0Z��]}�Y!�opAi��$ipt�c�DuogZ6a}")
+ @Column(columnDefinition = "varchar(100) not null comment 'pAi��Z�?")
+ private String fieldName;
+
+ @Comment("��B"k/��HrA]�~��7p�'WELATED_OBJECT/SINGLE_SELECT/AUTOFILL/YES_NO/ATTACHMENT/AMOUNT/TEXT_SINGLE/TEXT_MULTI/DATE�?)
+ @Column(columnDefinition = "varchar(32) not null comment 'pAi���~��7p'")
+ private String fieldType;
+
+ @Comment("��Y 0ɓ�0.|�X]�� Y{�� Y���,l)} crm-dict R��U�| code��m�S9p�Q��h�)[��~4s}")
+ @Column(columnDefinition = "varchar(64) comment '��Y 0ɓ�0.|'")
+ private String optionSource;
+
+ @Comment("O��[�N5pEx�����V�`�X�Sq��e���t����1l0m͓�V�ucm�d���mY1}O���`u�~��7p�my�%�?)
+ @Column(columnDefinition = "varchar(64) comment 'O��[�N5pEx�����V�`'")
+ private String refTarget;
+
+ @Comment("w�D��Ye��p^�ɓ�0.|�#X�VT�%1m�o�ૈ�-luogZ,a�tZCO���mY1}O���`u�~��7p�my�%�?)
+ @Column(columnDefinition = "varchar(64) comment 'w�D��Ye��p^�ɓ�0.|'")
+ private String autofillSource;
+
+ @Comment("�i?j{�J�wx}�h�RM�/�o��SU$i[�+uQ�4s}")
+ @Column(columnDefinition = "varchar(200) comment '�i?j{�J�?")
+ private String defaultValue;
+
+ @Comment("ē��`Y���Wĕ�00}1=Y���W 0=P�oT�u�m�]r_R��r}R��rɓ�W�tZxQ {R��rpn9p+l�{�}\+^h�)[{T��I�R�?10�?)
+ @Column(columnDefinition = "tinyint not null default 0 comment 'ē��`Y���Wĕ?")
+ 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;
+
+/**
+ * “ĉ �W�!ɓ�W�W$i�Waq�t 2,_��.^}�~?07��?.3���]}5p-W�~?06�?+ * <p>keyword V�R_�S�Y!�oZ��]/�Y!�o+hr/�XpnY��Q1}R�Di0�� Y�Xȓ��m4d@�Mw�~4s}Z�?code }� Y��/Y�b�z�m?K��ndeZ��R0]�m� t�\}��?/p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OpportunitySchemeCardTemplatePageParam extends BaseParam {
+
+ @Schema(description = "���Pde|��Q?m�~�m� Y0}1��� ȓY4Q•)1� 1lde 25ppt2|��)1�h�m�dde")
+ private Integer applyScope;
+
+ @Schema(description = "�5�� zO�t��Y0}1}� Y�� 2Y�b�z�m?3�[�c�N"�?)
+ 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;
+
+/**
+ * “ĉ �W�!ɓ荗Xȓ��Y��yO�nȓ�]�Y�"X�0 07��|\)|��? X��q7p A7-3-2-2�?+ *
+ * <p>V-CONFIG �~��{�mD��un6[}�m�^�0 05 O��ccYt�R�W / �~?06 Õ5����Y!�oZ�~\�h�Y0}Z�|\�z
+ * template_code �o1l�Xȓ�!v�Xpn�m� t�\1}Z�?code `mlT�z}� Y���m� Y�b�z�mh�1}Y�b�zcm�W+^Y�b�z�ma�FmK��nde��?+ * X��q�|�t�0�VT�%1G_Z%X�|�t�01}`mpT]�~�Wr_R��r1},h�}Y�b�z�m?K��nde+��cSe“�b]�~!S1}�o�]�W+��c�g code “���[cm�d� ?/p>
+ *
+ * <p>�m�^jZ?O��cc�Y!�o(��R�S����
j�[�P}�~?07��?.2���]}�?/p>
+ * <ul>
+ * <li>���Pde|��Q?m�m�00�-l�XȓY4Q•)1� 1lde�Xr_�tC��|�t�0}/ 5ppt2|��)1�h�m�dde�X@~��Y4Q•#1� wO {O�}O���tC��|�t�0}�?/li>
+ * <li>pAi��$ipt)}�~d��|pAi��4d�d}{@code opportunity_scheme_field_def}��\jow�F�qe���r}�?+ * pAi��Z�?�~��7p�m�o�tÓ���c�M1}ēgRZ0/G�mT^��~��[ɓ�q}O�k� j�~�T�`�Y�0�zG�mT^��Y1}</li>
+ * <li>Y�b�z͓��Ys�3l�VOpb�z�mD��tZ8u 0�X�he��T~uz��PcP�Y�P�^�}\}\m�^�0 06�Y� ?/li>
+ * </ul>
+ *
+ * <p>ig/a�Ó5�aq�t"23}�}W@code getVersionById}/{@code listFieldsOfVersion}/{@code resolveBindingVersion}�?+ * n?{@code crm-opportunity} “ĉ �W�3 �W�[?�t=�QÓ�`�v"���1}ȓ�4^Y�D� {Y��]�`n�o�y_��U�n9p�p�}�"X�0 01�Y� ?/p>
+ */
+public interface IOpportunitySchemeCardTemplateService {
+
+ /**
+ * R��U 0̓�0���Y!�oR�Di0�X�[�XpnQg�[^p�fZ0��leyword V�R_�SZ�?.�?�Xpn�Y� ?+ */
+ PageResult<OpportunitySchemeCardTemplateDTO> pageTemplates(OpportunitySchemeCardTemplatePageParam param);
+
+ /**
+ * �Y!�o�Xpn�t=�Q�X�`���Pde��)1�h ID/Z��]e��p j + pAi��$iĉAn4d�_�Wt��}��?+ */
+ OpportunitySchemeCardTemplateDTO getTemplateDetail(Long id);
+
+ /**
+ * �~d��|pAi��4d�d�S���_�Wt��}�Y!�o���]�u$i�p� �\�
Y�tZ�p� �]�f����.|�~\�[ id W��V-|�Y� ?+ */
+ List<OpportunitySchemeFieldDefDTO> listFieldDefs();
+
+ /**
+ * �m�o�t}� Y���?+ * <ul>
+ * <li>Ó?id +�?“Hrɓc�g}� Y���"X���qSe��?templateCode + V1.0�?/li>
+ * <li>id ���V�`}� Y�� +�?X��q�},h�}�qWemplateCode/versionNo/status �m�]I_�?/li>
+ * <li>id ���V�`Y�b�z�m?K��nde +�?"��q�W“�b]�~!S}Z�?code��\�Xȓ�_ minor $i�T"k�?+ * Z�?code �[�SAn}� Y��R�k�Z�o}ig�m�QȓY]�~��*}Hg/b}</li>
+ * </ul>
+ * pAi��$ipt�S���_[m��n�1}pAi��Z�?�~��7pG����Sw�F����q�tZ[�~u�mY1}O�k� j�~�T�`�Y�0�zG�mT^���?+ */
+ void saveDraft(OpportunitySchemeCardTemplateDTO dto);
+
+ /**
+ * �m�o�t����B_/u�Q0}O�X�[ {@link #saveDraft} �t]��{��P]�~!S}P��]�Xt}\B_/u�QNr`i?+ * �#X�VOpb�z�mD��tZ8u 0 +�?X��qB_/u�QQ�^g��N"�?+�?X��q�|�t�0G_Z%X�|�t?+�?ȓ��Xȓ�FmY�b�z�mh�}��?+ */
+ void saveAndPublish(OpportunitySchemeCardTemplateDTO dto);
+
+ /**
+ * �o�]�W�Y!�o +�?���s“Hrɓ!S}“?templateCode��BN1.0 }� Y����\� �Pde|��Q?m/pAi�ᰕ�]�u/uA�C~ɓ�0}��?+ *
+ * @return “�b]�~荗Xȓ?id
+ */
+ Long copyTemplate(Long id);
+
+ /**
+ * K��nde�X�|Y�b�z�m^�r_K��nde��mB_/u�QQ� +�?�[�c�N"���}cm�n�]Y��c�c�BZ~#�k}��?+ */
+ void disableTemplate(Long id);
+
+ /**
+ * R��r�j�X�|}� Y��Y���W��m��q��e R��r� �Pde��)1�hp/a0 + pAi��$ixv}��?+ */
+ void deleteDraft(Long id);
+
+ // ==================== ig/a�Ó5�aq�t"23}�X�} crm-opportunity Z#X�W�?====================
+
+ /**
+ * �� Y�Xȓ�� id ̓�0ɓ荗Xȓ
�}“ĉ �W���|9p-lNr`i?e��p j"���}��?+ *
+ * @return �Y!�o�Xpnt�\1}�m�]�tf�(1Q~e�?{@code null}
+ */
+ OpportunitySchemeCardTemplate getVersionById(Long templateVersionId);
+
+ /**
+ * �Y!�o�Xpn�m*[�S��%1�tZ8u 0�X�[ seq_no W��V-|��m�g�YX1]�o��SU$i?G�mT^�͓��Ys"���}��?+ */
+ List<OpportunitySchemeCardField> listFieldsOfVersion(Long templateVersionId);
+
+ /**
+ * b~uV�R_�S�-l�[��)1�hYtF�=p4d�e�|9p1l�k**Y�b�z�m?*�Y!�o�Xpn�X�g�[�p�g�YX1]"���}Z��\�0 06 Y�E��}�Y� ?+ * <ul>
+ * <li>|m:j�S�,l��4d�e4Q•$1{"���}apply_scope=2�Y��)�,h�ᮕ)1�h(��RB_/u�QQ��Y!�o�}\?�[�bQ�Y�(h6nigb�W�[?�� ?/li>
+ * <li>O��n3|�-l�XȓY4Q•)1� 1lde�EWpply_scope=1� Y�kY�b�z�mb��|�t�0ɓ?/li>
+ * </ul>
+ *
+ * @param deptId _��U�n�t�q�w\m*m4Q•��}Y��%�+l�|�t4O�|�t�0�S4d�f}
+ * @return [�bQ��Y!�o�Xpnt�\1}�rbccm�fB_/u�QQ��Y!�oY��.\���]iig�em {@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;
+
+/**
+ * “ĉ �W�!ɓ荗Xȓ��Y��yO�n9p�pG^�"X�0 07��|\)|��? X��q7p A7-3-2-2�?+ *
+ * <p>V-CONFIG �XpnV�-h�S���[�[�_���m Yܑ9p�p�}�"X�0 05 O��ccYt�R�W / �~?06 Õ5����Y!�oZ�~\�h�m�]�St��}�?+ * Z�|\�z template_code �o1l�Xȓ�!v�Xpn�m� t�\1}Z�?code `mlT�z}� Y���m� Y�b�z�mh�1}Y�b�zcm�W+^Y�b�z�ma�FmK��nde��?+ * X��q�|�t�0�VT�%1G_Z%X�|�t�01}`mpT]�~�Wr_R��r1},h�}Y�b�z�m?K��nde+��cSe“�b]�~!S1}�o�]�W+��c�g code “���[cm�d1}
+ * b~u9p�p�}�X�g�YX1]�Y#e�Xpnt?id�}\B_“nb�X�m�]<~�~Gl� ?/p>
+ *
+ * <p>�m�^jZ�Wɓ荑k�[��}V�+h�[9p>l}�~?07��?.2���]}�?/p>
+ * <ul>
+ * <li>���Pde|��Q?m�m�00�X {�~?05 Z�{\� wO {�~?06 �m Y0�m�]�`�Y0}��1ldeY�����i?j{���\4Q•$1{"�%1@~��Y4Q•$1{�m�]�S�tLk���i?j{��?/li>
+ * <li>pAi��$ipt)}�~d��|pAi��4d�d}ȕ�p�V"�M�� �r}�,l�tZ[��`/�~��7p�m�o�tÓ���c�CSUO��0}Õm� T���r}�?+ * pAi���m�]jn4d�d4U/Z��\�X���]2��[�fde)�X[4^���c�|��m�WpAi��`mmTVY]��]�}S��]]�~荗Xȓ
�}�m�]�W�~d��|pAi��9p+l�{�X+^h�)[{T��I�R�?10�Y1}</li>
+ * <li>ēgRZ0/G�mT^��~��[ɓ�q0}{@code is_visible=0} �[�T�W {@code is_required=0}�|\Z~p;jiYt�R�[V�5h1}</li>
+ * <li>Y�b�z͓��Ys�3l�VOpb�z�mD��tZ8u 0�X�he��T~uz��PcP�Y�P�^�}\}\m�^�0 06�Y1}</li>
+ * <li>b~uV�R_�S�m�^�0 06 Z�}\[_�[�R0}��)1�h�m�dde|m:j�S�o,l�a�m^�G_ȓ� igb�W�[?�� �T}��1lde�i?j{�O��n3|��?/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;
+
+ // ==================== ̓�0�� ====================
+
+ @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)
+ // �m�^�0 06 Z�}\[_�[�R0}Ó�r4Q•(1��)�'hne�m� �~=�ko�}\?�[�bQ�Y�(h6nigb�W�[?�� �T}�m�o vb~u�~��~u
+ .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);
+ }
+
+ // ==================== �m�o�t}� Y�� / �m�o�t����B_/u?====================
+
+ @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);
+ }
+
+ /**
+ * ��P]�~8Y{R��Une�-l�g�[4s}“nb*}.�?+ V1.0�? X��q�},h�}}� Y�� / `m�^B_/u�QQ�-K��nde"��q�W“�b]�~!S}minor $i�T"k�Y� ?+ * pAi��$i?+ ���Pde��)1�hp/a0g��V�k�XpnO�)1zVǓc2]��?+ *
+ * @return �[�S�[�mmT�[(��R]�~����X�` 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) {
+ // }� Y��X��q�},h�}�1l*}.�?�XpnY�?�5�� wO {Y�Hj}Y�G���pAi���c�X�~� T���r}
+ 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;
+ }
+
+ // Y�b�z�m?K��nde +�?"��q�W“�b]�~!S1}Z�?code �[�SAn}� Y��R�k�Z�o}ig�m�QȓY]�~��*}Hg/b}
+ 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,
+ "�t�0ɓ�W�QȓY]�~荗Xȓ
�}�t�uX~O��0]�~��*}Hg?);
+ }
+ 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;
+ }
+
+ /**
+ * Y�b�z�,l+^Y�b�z�ma�FmK��nde�X�` code w�E��W$i5�[m�YKU “4O�|�t�0
0Ǔc�h�i?j{� +�?ȓ��Xȓ�FmY�b�z�m\�� ?+ * pAi��$iptlu��X[� DNr`i}\�X��jn {@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);
+ }
+
+ // “4O�|�t�0
0Ǔc�h�i?j{��XB_/u�QQ��i?j{�^���z��\�VT�%1G_Z#X� �\jo���c�|��l-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);
+ }
+
+ // ==================== K��nde / R��r�j / �o�]�W�#X忓�]�}-�E%j�?====================
+
+ @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,
+ "`mmTB_/u�QQ�(��Rɓ�Wr_K��nde");
+ }
+ 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,
+ "`mpT]�~荗Xȓ�r_R��r�j");
+ }
+ 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);
+
+ // ���Pde��)1�hŕ�_2�R�?+ 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);
+ });
+
+ // pAi��$iR_�k�o�]�W�XZ~4d�_Z~G����S�?+ 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();
+ }
+
+ // ==================== ͓��Ys ====================
+
+ /**
+ * �o?+ pAi��$ipt�q�~� ͓��Ys�#X]�~?Y�b�zO���de�Y0}
+ * �m�00���Pde|��Q?m�~=�ko�%X4Q•$1{"�%1@~��Y4Q•$1{�m�]r_�tC��|�t�0}��m�tZ8umeG�mT^���wO {���]2憒�O0p:jjn\m�^���q�tZ[�1|��?+ */
+ private void validateTemplate(OpportunitySchemeCardTemplateDTO dto) {
+ if (dto == null) {
+ throw invalid("�t �0wY��P�f�TQ0");
+ }
+ if (StrUtil.isBlank(dto.getTemplateName())) {
+ throw invalid("�Y!�oZ��]�m�]XQ�my�%");
+ }
+ Integer scope = dto.getApplyScope();
+ if (scope == null || (scope != OpportunityRuleConstants.APPLY_SCOPE_ALL
+ && scope != OpportunityRuleConstants.APPLY_SCOPE_DEPT)) {
+ throw invalid("���Pde|��Q?mY��P�fȕ�pvx");
+ }
+ Integer isDefault = dto.getIsDefault();
+ if (isDefault == null || (isDefault != OpportunityRuleConstants.FLAG_NO
+ && isDefault != OpportunityRuleConstants.FLAG_YES)) {
+ throw invalid("ē��`�i?j{�Y��P�fȕ�pvx");
+ }
+ if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT
+ && isDefault == OpportunityRuleConstants.FLAG_YES) {
+ throw invalid("��)1�h�m�dde�Y!�o�m�]�S�tLk���m*m�|�t�0}�~?07��?.2���]0}`mnT�XȓY4Q•)1� 1ldeY�����i?j{��?);
+ }
+ if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT && CollUtil.isEmpty(dto.getDeptIds())) {
+ throw invalid("��)1�h�m�dde�Y!�o$iZZ� Y�Zw��Q�v�m� �mH�� �Pde��)1�h");
+ }
+
+ Map<String, OpportunitySchemeFieldDef> defByKey = null; // ���c�Y^g�e0}Ó�r�tZ8u 0Ó5g {̓�0�tZ[�1|
+ Set<String> seen = new HashSet<>();
+ for (OpportunitySchemeCardTemplateDTO.FieldItem field : safeFields(dto)) {
+ if (StrUtil.isBlank(field.getFieldKey())) {
+ throw fieldInvalid("pAi��$i-W�kpAi�῕�� {s�b��~4s}�m�o�tÓAR�|�m� ͓��Ys��� ȓ
Y�tZ8u 0�rmn9p~\�W���]�u�?);
+ }
+ if (!seen.add(field.getFieldKey())) {
+ throw fieldInvalid("Z�|\�z�Y!�o�Xpn�m�]r_���]2��[�fdepAi���? + field.getFieldKey());
+ }
+ if (defByKey == null) {
+ defByKey = fieldDefMapper.selectList(new LambdaQueryWrapper<>()).stream()
+ .collect(Collectors.toMap(OpportunitySchemeFieldDef::getFieldKey,
+ Function.identity(), (first, dup) -> first));
+ }
+ // pAi��ɓ�0.|=�~d��|pAi��4d�d}ȕ�p�V"�M�� �r}�~?07��?.4���]}�+l {f�%11|P�oT�kpAi�῕��?m���0�Z?+ if (!defByKey.containsKey(field.getFieldKey())) {
+ throw fieldInvalid("pAi��$i��)}"�'1���q�tZ[�~u�mY0}" + field.getFieldKey());
+ }
+ }
+ }
+
+ /** Y�b�zĕ�R�Y͓��Ys�3l�VOp&b�S���z�mD��tZ8u 0�X�hpAi��(��Rɓ�WB_/u�Q�`Ó�rvx�o��SU�?*/
+ private void validateForPublish(OpportunitySchemeCardTemplateDTO dto) {
+ if (safeFields(dto).isEmpty()) {
+ throw fieldInvalid("“ĉ �W�!ɓ�WB_/u�Q�Xw��Q�v���]�u�m� �mD��tZ?);
+ }
+ }
+
+ 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);
+ }
+
+ // ==================== P�tT4Q“ĉvx ====================
+
+ private OpportunitySchemeCardTemplate getTemplateByIdOrThrow(Long id) {
+ OpportunitySchemeCardTemplate template = this.getById(id);
+ if (template == null) {
+ throw new BusinessErrorException(OpportunityRuleConstants.CODE_SCHEME_TPL_NOT_EXIST,
+ "�Y!�o�Xpn�m�]�tf�&1�W�[6�f�R��r�j");
+ }
+ return template;
+ }
+
+ /**
+ * p/a0O�)1zVǓc2]�6l� �Pde��)1�h + pAi��$ixv}O�X�WZ��^C_�Y� ?+ * pAi��Yt�R�[V�5h0}O��0,_$i�T-|=^p�fZ0$i�T-|�pWeq 1..n�Y1}pAi��Z�?�~��7pY�+h���q�tZ[�~u�m
Y�c�M}Õm� T���r}�?+ * ēgRZ0/G�mT^��~��[ɓ�q� �e� �e�SēgRZ0Z�~\�ZmT@~�o��}�~?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 �[�S�Z�o}�Y�0)��c=\��F�pig?+ continue;
+ }
+ int visible = item.getIsVisible() != null
+ && item.getIsVisible() == OpportunityRuleConstants.FLAG_YES
+ ? OpportunityRuleConstants.FLAG_YES : OpportunityRuleConstants.FLAG_NO;
+ // �~��[ɓ�q0}`mnT j�~�piY���G�mT^���m�SēgRZ0Z�~\�ZmT@~�o?+ 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;
+ }
+
+ /** ��R_zVe��p j���Pde��)1�h ID �m�^�`�~.^}�m� Z3 �tt&1aq�t?+ �m� Z��4Q•%1�`̓�0���?*/
+ 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()));
+ });
+ }
+
+ /** �Y!�o+hr$i�T"k�DkPP_SCHEME_TPL_ + �HrAnȓ� �oC-|Y�?1�X��cm�]�ƕ��} */
+ 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);
+ }
+
+ /**
+ * �XpnY�?minor $i�T"k�,lG_O�'1�Xȓ�6n�o?(major, minor) (�?minor+1�bWajor �m�]�Y�Y1}
+ * Ó�r�]Y�,��Xȓ?+�?V1.0��mjo Z�fxr�[�_�Õ�c=\��C�b#��0� �P}Z��\�0 05/06 Y�E��}�w[-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;
+
+/**
+ * “ĉ �W�!ɓ荗Xȓ��Y��yO�nYt�Rxr`i�\ v�"X�0 07��|\)|��? X��q7p A7-3-2-2�Y� ?+ *
+ * <p>“a��%�xv0}</p>
+ * <ul>
+ * <li>�o?pAi��$iĉNr`i�\0}Z��]G�mT^���wO���YH��[e��[�[ɓ�q}��)1�h�m�ddeG�tT� Y4Q•$1{�m�]r_�tC��|�t�0}�~?07��?.2���]}��?+ * pAi�῕��@~�o?�m�]xV�o?$i��Xw�~d��|pAi��4d�d}ȕ�p�V"�M�� �r}�~?07��?.4���]}64022�?/li>
+ * <li>�m�o�t}� Y���m
Y�W��$�0}“g�4W1.0 + +hr$i�T"k�? X��q�},h�} / Y�b�z�m?K��nde"��q�g}� Y���bWinor $i�T"k�?+ * Z�?code �[�SAn}� Y�緓�c�| 64021�?/li>
+ * <li>pAi��Yt�R�[V�5h0}O��0,_$i�T-|=seq 1..n��xO�tZ[��`/�~��7pG����SÕm� T���r� xO�SēgRZ0Z�~\�ZmT@~�o��}�~��[ɓ�q}</li>
+ * <li>Y�b�z�3l�VOpb�z�mD��tZ8u 0�?4022�Y� xO+^Y�b�z�ma�FmK��nde�#X�V��&b
0Ǔ!S}��yO�g�i?j{�$i5�[mÓG�|�t?/li>
+ * <li>t~\7dcm�niqÕ�x0}`mpT]�~�Wr_R��r}64020��\��q��e�W��)1�h+pAi���Y� wO�|Y�b�z�m^�r_K��nde��xO2�R�?+�?“nb*}.�?+ V1.0</li>
+ * <li>b~uV�R_�S�6l4Q•$1{"�%1B_/u�Qɓ8Y-}O�,X}�o,l�a�m^�G_ȓ� igb�W�[4s}�}\�S4d?��1lde+�i?j{�</li>
+ * </ul>
+ */
+@DisplayName("“ĉ �W�!ɓ荗Xȓ��Y��yO�n�"X�0 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 �m.[6n��*[�Y�t�`� wx}lambdaQuery() ���]XQ�[�0�}�X�`�~?05/06 4Z-[/v�?+ ReflectionTestUtils.setField(service, "baseMapper", templateMapper);
+ ReflectionTestUtils.setField(service, "entityClass", OpportunitySchemeCardTemplate.class);
+ // �i?j{��YK%0}�~d��|pAi��4d�d}͓��}ƕ�U�tƕ�U}��lenient�-lNr`i}\�XW��QQ0�t�0�k"�$1�}�X�`�~nb%�~Y}�m�]Hg�`�tZ[�1|̓�0��
+ lenient().when(fieldDefMapper.selectList(any())).thenReturn(List.of(
+ defRow("customer_name", "9p!2�WZ��]", OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT),
+ defRow("scheme_budget", "“ĉ ��h�R{u��&b��", OpportunityRuleConstants.FIELD_TYPE_AMOUNT),
+ defRow("bidding_form", "���m#r0� 2!}", OpportunityRuleConstants.FIELD_TYPE_SINGLE_SELECT),
+ defRow("site_visit", "ē��`ȓ YEn�g�n", OpportunityRuleConstants.FIELD_TYPE_YES_NO)));
+ }
+
+ // ==================== �[�0�] ====================
+
+ private OpportunitySchemeCardTemplateDTO validDto() {
+ OpportunitySchemeCardTemplateDTO dto = new OpportunitySchemeCardTemplateDTO();
+ dto.setTemplateName("͓�VoV“ĉ �W�!ɓ?);
+ 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("�Y!�o" + 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("Z��]" + key);
+ f.setFieldType(OpportunityRuleConstants.FIELD_TYPE_TEXT_SINGLE);
+ f.setIsVisible(visible);
+ f.setIsRequired(required);
+ return f;
+ }
+
+ // ==================== �o?+ pAi��$iĉNr`i?====================
+
+ @Test
+ @DisplayName("O��0,_ null +�?64018")
+ void nullDtoRejected() {
+ assertThatThrownBy(() -> service.saveDraft(null))
+ .isInstanceOf(BusinessErrorException.class)
+ .extracting("code").isEqualTo(OpportunityRuleConstants.CODE_SCHEME_TPL_INVALID);
+ }
+
+ @Test
+ @DisplayName("�Y!�oZ��]�my�% +�?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("���Pde|��Q?mȕ�pvxJ�?+�?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("��)1�h�m�dde�m�]�S�tLk���m*m�|�t�0}�~?07��?.2���]���YE�
j�[�P}+�?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("��)1�h�m�ddeȓH�� Y4Q•?+�?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("pAi�῕����~?+�?64022�XZ~p;ji�q�z͓��YsÓ�rmn9p~\�W���]�u�?)
+ 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("Z��\�Xȓ�xV�o�])}"�%1�tZ?+�?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("pAi���m�]jn�~d��|pAi��4d�d}ȕ�p�V"�M�� �r}+�?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);
+ }
+
+ // ==================== }� Y���m
Y�W��?====================
+
+ @Test
+ @DisplayName("“g�1l���qSe��.a*}.�?+ V1.0 + }� Y���Xg_�t���tZ8u�i|mH�� �r}")
+ 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"); // |mH�� �r*}.�}Of�G�Lu�f
+ 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("“g+hr$i�T"k�1lG^ȓ Y6n�oC-|Y�?1 �m�0�}t�0Bm")
+ 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("}� Y��X��q�},h�}�1l*}.�?�XpnY�?�5�� wO {Y�?)
+ 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("Y�b�z�m?K��nde,h�} +�?“�b]�~!S}minor $i�T"k�?)
+ 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("Z�?code �[�SAn}� Y�� +�?���c�|"��q�g}� Y���?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("�Y!�o�m�]�tf�?+�?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);
+ }
+
+ // ==================== pAi��Yt�R�[V�?====================
+
+ @Test
+ @DisplayName("pAi��$ipt�S���_[m��n�0}seq �� YA_\m�004d�_xV�~<i� xO�`/�~��7pY�'h�tZ[�1|G����S�%X�i|mH�� �r}")
+ 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();
+ // |mH�� �r�`/�~��7pt���b#��00}��Ʌ1|Y�+h���q�tZ[�~u�m
Y�c�?+ dto.getFields().get(0).setFieldName("|mH�� �r�`");
+ 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("9p!2�WZ��]");
+ assertThat(rows.get(0).getFieldType())
+ .isEqualTo(OpportunityRuleConstants.FIELD_TYPE_RELATED_OBJECT);
+ assertThat(rows.get(0).getIsRequired()).isEqualTo(OpportunityRuleConstants.FLAG_YES);
+ }
+
+ @Test
+ @DisplayName("ēgRZ0/G�mT^��~��[ɓ�q0}O�k� j�~�T�`�Y�0�zG�mT^��"X�0 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))); // �m�] j�~p�~}UtyO0wG�mT^� +�?ZmT@~�o?+ 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("��)1�h�m�dde���N4Q•%1�tt��1}��1lde�m�]�`")
+ 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)); // ��1lde�m�o�t�m�]�g�o�p4Q•(1�
+ }
+
+ // ==================== Y�b�z ====================
+
+ @Test
+ @DisplayName("Y�b�z͓��Ys�-l�hpAi��$i?+�?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("Y�b�z�,l+^Y�b�z�ma�FmK��nde�#X�V��&b
0Ǔ!S}+ ȓ��Xȓ�FmY�b�z�m?)
+ 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();
+ // ÓCB_/u�QQ� +�?K��nde
+ assertThat(updates.get(0).getId()).isEqualTo(PUB_ID);
+ assertThat(updates.get(0).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DISABLED);
+ // ȓ��Xȓ?+�?Y�b�z�m?+ assertThat(updates.get(1).getId()).isEqualTo(NEW_TPL_ID);
+ assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ }
+
+ @Test
+ @DisplayName("Y�b�z�-l�g�i?j{�$i5�[mÓG�|�t�0}Y�b�z�mb��|�t�0ne�m� �w[-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);
+ // �m Y�� list�okewCode(�~? / w�E��W$i5�[m(Ó�r�` code Y�b�z�m? / ÓG�|�t�0aq�t?+ 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);
+ }
+
+ // ==================== t~\7dcm�niqÕ?====================
+
+ @Test
+ @DisplayName("K��nde�+l�|Y�b�z�m^�r_K��nde")
+ 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("R��r�j�+l�|}� Y��Y���W�?4020�Y}}� Y��R��r�j�~F�N��)1�h+pAi��p/a0")
+ 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("�o�]�W�-l�g+hr + V1.0 + }� Y����\4Q•$1 {pAi��ŕ�_2�R�?)
+ void copySpawnsIndependentDraft() {
+ OpportunitySchemeCardTemplate source = tplRow(PUB_ID, CODE_C1, "V2.3",
+ OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
+ source.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
+ source.setTemplateDesc("X��q��ē?);
+ 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("X��q��ē?);
+ 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("�XpnY��p0�[��0}�r�]Y�?+�?V1.0��l1.2 +�?V1.3��mjo Z�fxr�[�_�i�[3 �b#�?)
+ 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");
+ }
+
+ // ==================== b~uV�R_�S ====================
+
+ @Test
+ @DisplayName("b~uV�R_�S�6l4Q•$1{"�$1-}O�,X1}��1lde+�i?j{�O��n3|��m�hY�b�z�ma�Q~e�?null")
+ void resolveBindingVersion() {
+ // Ó�rbccm�fB_/u�QQ�
+ when(templateMapper.selectList(any())).thenReturn(List.of());
+ assertThat(service.resolveBindingVersion(DEPT_10)).isNull();
+
+ // ��)1�h�m�dde[�bQ��X?�[�bQ�Y�(h6nigb�W�[4s}
+ 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);
+
+ // Ó�r4Q•$1{"�%1�a�m?+�?��1lde�i?j{�O��n3|
+ 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);
+ }
+
+ // ==================== ̓�0�� ====================
+
+ @Test
+ @DisplayName("�t=�Qe��p jpAi��$iĉAn4d�_�Wt?+ ��)1�hZ�?)
+ 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("W��^"{�m� ��?);
+ 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("W��^"{�m� ��?);
+ assertThat(detail.getFields()).hasSize(2);
+ assertThat(detail.getFields().get(0).getFieldKey()).isEqualTo("customer_name");
+ assertThat(detail.getFields().get(1).getSeqNo()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("ig/a�Ó5�aq�t"23}�okull-safe�YWistFieldsOfVersion / getVersionById�?)
+ void runtimeSeamsNullSafe() {
+ assertThat(service.listFieldsOfVersion(null)).isEmpty();
+ assertThat(service.getVersionById(null)).isNull();
+ assertThat(service.getVersionById(PUB_ID)).isNull(); // mapper ȓE�0ig�em 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("pAi��4d�d�Wt��0}9p�p�}ē�r�v DTO")
+ void listFieldDefsMaps() {
+ assertThat(service.listFieldDefs()).hasSize(4);
+ assertThat(service.listFieldDefs().get(0).getFieldKey()).isEqualTo("customer_name");
+ }
+}