修改同步接口,添加字段
parent
8dc9fe2117
commit
ea61c75d58
|
|
@ -36,11 +36,13 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
|
||||||
statement.execute("alter table crm_opportunity add column if not exists next_plan text");
|
statement.execute("alter table crm_opportunity add column if not exists next_plan text");
|
||||||
statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz");
|
statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz");
|
||||||
statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)");
|
statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)");
|
||||||
|
statement.execute("alter table crm_opportunity add column if not exists is_poc boolean not null default false");
|
||||||
statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)");
|
statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)");
|
||||||
statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'");
|
statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'");
|
||||||
statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'");
|
statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'");
|
||||||
statement.execute("comment on column crm_opportunity.archived_at is '归档时间'");
|
statement.execute("comment on column crm_opportunity.archived_at is '归档时间'");
|
||||||
statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'");
|
statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'");
|
||||||
|
statement.execute("comment on column crm_opportunity.is_poc is '是否POC测试项目'");
|
||||||
}
|
}
|
||||||
ensureArchivedAtStorage(connection);
|
ensureArchivedAtStorage(connection);
|
||||||
ensureConfidenceGradeStorage(connection);
|
ensureConfidenceGradeStorage(connection);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public class OpportunityItemDTO {
|
||||||
private String operatorCode;
|
private String operatorCode;
|
||||||
private String operatorName;
|
private String operatorName;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
|
private BigDecimal actualSignedAmount;
|
||||||
private String date;
|
private String date;
|
||||||
private String confidence;
|
private String confidence;
|
||||||
private String stageCode;
|
private String stageCode;
|
||||||
|
|
@ -25,6 +26,7 @@ public class OpportunityItemDTO {
|
||||||
private String type;
|
private String type;
|
||||||
private Boolean archived;
|
private Boolean archived;
|
||||||
private Boolean pushedToOms;
|
private Boolean pushedToOms;
|
||||||
|
private Boolean isPoc;
|
||||||
private String product;
|
private String product;
|
||||||
private String source;
|
private String source;
|
||||||
private Long salesExpansionId;
|
private Long salesExpansionId;
|
||||||
|
|
@ -139,6 +141,14 @@ public class OpportunityItemDTO {
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigDecimal getActualSignedAmount() {
|
||||||
|
return actualSignedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActualSignedAmount(BigDecimal actualSignedAmount) {
|
||||||
|
this.actualSignedAmount = actualSignedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
public String getDate() {
|
public String getDate() {
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +205,14 @@ public class OpportunityItemDTO {
|
||||||
this.pushedToOms = pushedToOms;
|
this.pushedToOms = pushedToOms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsPoc() {
|
||||||
|
return isPoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsPoc(Boolean isPoc) {
|
||||||
|
this.isPoc = isPoc;
|
||||||
|
}
|
||||||
|
|
||||||
public String getProduct() {
|
public String getProduct() {
|
||||||
return product;
|
return product;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ public class OpportunityMetaDTO {
|
||||||
private List<OpportunityDictOptionDTO> projectLocationOptions;
|
private List<OpportunityDictOptionDTO> projectLocationOptions;
|
||||||
private List<OpportunityDictOptionDTO> opportunityTypeOptions;
|
private List<OpportunityDictOptionDTO> opportunityTypeOptions;
|
||||||
private List<OpportunityDictOptionDTO> confidenceOptions;
|
private List<OpportunityDictOptionDTO> confidenceOptions;
|
||||||
|
private List<OpportunityDictOptionDTO> sysIsOptions;
|
||||||
|
|
||||||
public OpportunityMetaDTO() {
|
public OpportunityMetaDTO() {
|
||||||
}
|
}
|
||||||
|
|
@ -18,12 +19,14 @@ public class OpportunityMetaDTO {
|
||||||
List<OpportunityDictOptionDTO> operatorOptions,
|
List<OpportunityDictOptionDTO> operatorOptions,
|
||||||
List<OpportunityDictOptionDTO> projectLocationOptions,
|
List<OpportunityDictOptionDTO> projectLocationOptions,
|
||||||
List<OpportunityDictOptionDTO> opportunityTypeOptions,
|
List<OpportunityDictOptionDTO> opportunityTypeOptions,
|
||||||
List<OpportunityDictOptionDTO> confidenceOptions) {
|
List<OpportunityDictOptionDTO> confidenceOptions,
|
||||||
|
List<OpportunityDictOptionDTO> sysIsOptions) {
|
||||||
this.stageOptions = stageOptions;
|
this.stageOptions = stageOptions;
|
||||||
this.operatorOptions = operatorOptions;
|
this.operatorOptions = operatorOptions;
|
||||||
this.projectLocationOptions = projectLocationOptions;
|
this.projectLocationOptions = projectLocationOptions;
|
||||||
this.opportunityTypeOptions = opportunityTypeOptions;
|
this.opportunityTypeOptions = opportunityTypeOptions;
|
||||||
this.confidenceOptions = confidenceOptions;
|
this.confidenceOptions = confidenceOptions;
|
||||||
|
this.sysIsOptions = sysIsOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OpportunityDictOptionDTO> getStageOptions() {
|
public List<OpportunityDictOptionDTO> getStageOptions() {
|
||||||
|
|
@ -65,4 +68,12 @@ public class OpportunityMetaDTO {
|
||||||
public void setConfidenceOptions(List<OpportunityDictOptionDTO> confidenceOptions) {
|
public void setConfidenceOptions(List<OpportunityDictOptionDTO> confidenceOptions) {
|
||||||
this.confidenceOptions = confidenceOptions;
|
this.confidenceOptions = confidenceOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<OpportunityDictOptionDTO> getSysIsOptions() {
|
||||||
|
return sysIsOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSysIsOptions(List<OpportunityDictOptionDTO> sysIsOptions) {
|
||||||
|
this.sysIsOptions = sysIsOptions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package com.unis.crm.dto.opportunity;
|
package com.unis.crm.dto.opportunity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import jakarta.validation.constraints.AssertTrue;
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
@ -24,9 +24,11 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
|
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@JsonAlias("actual_signed_amount")
|
||||||
|
private BigDecimal actualSignedAmount;
|
||||||
|
|
||||||
private LocalDate expectedCloseDate;
|
private LocalDate expectedCloseDate;
|
||||||
|
|
||||||
@Pattern(regexp = "^(A|B|C|a|b|c|40|60|80)$", message = "把握度仅支持A、B、C")
|
|
||||||
private String confidencePct;
|
private String confidencePct;
|
||||||
|
|
||||||
@Size(max = 50, message = "项目阶段不能超过50字符")
|
@Size(max = 50, message = "项目阶段不能超过50字符")
|
||||||
|
|
@ -61,6 +63,9 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
|
|
||||||
private OffsetDateTime omsPushTime;
|
private OffsetDateTime omsPushTime;
|
||||||
|
|
||||||
|
@JsonAlias("is_poc")
|
||||||
|
private Boolean isPoc;
|
||||||
|
|
||||||
@Size(max = 30, message = "状态不能超过30字符")
|
@Size(max = 30, message = "状态不能超过30字符")
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
|
|
@ -72,32 +77,6 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
return opportunityCode != null && !opportunityCode.trim().isEmpty();
|
return opportunityCode != null && !opportunityCode.trim().isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@AssertTrue(message = "至少传入一个需要更新的字段")
|
|
||||||
@JsonIgnore
|
|
||||||
public boolean hasAnyUpdateField() {
|
|
||||||
return opportunityName != null
|
|
||||||
|| projectLocation != null
|
|
||||||
|| operatorName != null
|
|
||||||
|| amount != null
|
|
||||||
|| expectedCloseDate != null
|
|
||||||
|| confidencePct != null
|
|
||||||
|| stage != null
|
|
||||||
|| opportunityType != null
|
|
||||||
|| productType != null
|
|
||||||
|| source != null
|
|
||||||
|| salesExpansionId != null
|
|
||||||
|| channelExpansionId != null
|
|
||||||
|| preSalesId != null
|
|
||||||
|| preSalesName != null
|
|
||||||
|| competitorName != null
|
|
||||||
|| archived != null
|
|
||||||
|| archivedAt != null
|
|
||||||
|| pushedToOms != null
|
|
||||||
|| omsPushTime != null
|
|
||||||
|| status != null
|
|
||||||
|| description != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getOpportunityCode() {
|
public String getOpportunityCode() {
|
||||||
return opportunityCode;
|
return opportunityCode;
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +117,14 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigDecimal getActualSignedAmount() {
|
||||||
|
return actualSignedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActualSignedAmount(BigDecimal actualSignedAmount) {
|
||||||
|
this.actualSignedAmount = actualSignedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDate getExpectedCloseDate() {
|
public LocalDate getExpectedCloseDate() {
|
||||||
return expectedCloseDate;
|
return expectedCloseDate;
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +245,14 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
this.omsPushTime = omsPushTime;
|
this.omsPushTime = omsPushTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsPoc() {
|
||||||
|
return isPoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsPoc(Boolean isPoc) {
|
||||||
|
this.isPoc = isPoc;
|
||||||
|
}
|
||||||
|
|
||||||
public String getStatus() {
|
public String getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
|
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
|
||||||
private static final String OPPORTUNITY_TYPE_CODE = "sj_jslx";
|
private static final String OPPORTUNITY_TYPE_CODE = "sj_jslx";
|
||||||
private static final String CONFIDENCE_TYPE_CODE = "sj_xmbwd";
|
private static final String CONFIDENCE_TYPE_CODE = "sj_xmbwd";
|
||||||
|
private static final String SYS_IS_TYPE_CODE = "sys_is";
|
||||||
private static final String ARCHIVED_OPPORTUNITY_EDIT_MESSAGE = "已签单商机不允许编辑";
|
private static final String ARCHIVED_OPPORTUNITY_EDIT_MESSAGE = "已签单商机不允许编辑";
|
||||||
private static final String ARCHIVED_OPPORTUNITY_PUSH_MESSAGE = "已签单商机不允许推送";
|
private static final String ARCHIVED_OPPORTUNITY_PUSH_MESSAGE = "已签单商机不允许推送";
|
||||||
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
|
||||||
|
|
@ -58,7 +59,8 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
|
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
|
||||||
opportunityMapper.selectProvinceAreaOptions(),
|
opportunityMapper.selectProvinceAreaOptions(),
|
||||||
opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions,
|
opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions,
|
||||||
confidenceOptions.isEmpty() ? buildDefaultConfidenceOptions() : confidenceOptions);
|
confidenceOptions.isEmpty() ? buildDefaultConfidenceOptions() : confidenceOptions,
|
||||||
|
buildSysIsOptions(opportunityMapper.selectDictItems(SYS_IS_TYPE_CODE)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<OpportunityDictOptionDTO> buildDefaultOpportunityTypeOptions() {
|
private List<OpportunityDictOptionDTO> buildDefaultOpportunityTypeOptions() {
|
||||||
|
|
@ -75,6 +77,15 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
buildDictOption("C", "C"));
|
buildDictOption("C", "C"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<OpportunityDictOptionDTO> buildSysIsOptions(List<OpportunityDictOptionDTO> sysIsOptions) {
|
||||||
|
if (sysIsOptions != null && !sysIsOptions.isEmpty()) {
|
||||||
|
return sysIsOptions;
|
||||||
|
}
|
||||||
|
return List.of(
|
||||||
|
buildDictOption("否", "0"),
|
||||||
|
buildDictOption("是", "1"));
|
||||||
|
}
|
||||||
|
|
||||||
private OpportunityDictOptionDTO buildDictOption(String label, String value) {
|
private OpportunityDictOptionDTO buildDictOption(String label, String value) {
|
||||||
OpportunityDictOptionDTO option = new OpportunityDictOptionDTO();
|
OpportunityDictOptionDTO option = new OpportunityDictOptionDTO();
|
||||||
option.setLabel(label);
|
option.setLabel(label);
|
||||||
|
|
@ -157,7 +168,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
@Transactional
|
@Transactional
|
||||||
public Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request) {
|
public Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request) {
|
||||||
OpportunityIntegrationTargetDTO target = requireOpportunityIntegrationTarget(request);
|
OpportunityIntegrationTargetDTO target = requireOpportunityIntegrationTarget(request);
|
||||||
normalizeIntegrationUpdateRequest(request, target);
|
normalizeIntegrationUpdateRequest(request);
|
||||||
|
|
||||||
int updated = opportunityMapper.updateOpportunityByIntegration(target.getId(), request);
|
int updated = opportunityMapper.updateOpportunityByIntegration(target.getId(), request);
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
|
|
@ -635,38 +646,31 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void normalizeIntegrationUpdateRequest(
|
private void normalizeIntegrationUpdateRequest(UpdateOpportunityIntegrationRequest request) {
|
||||||
UpdateOpportunityIntegrationRequest request,
|
|
||||||
OpportunityIntegrationTargetDTO target) {
|
|
||||||
if (request.getOpportunityName() != null) {
|
if (request.getOpportunityName() != null) {
|
||||||
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "商机名称不能为空"));
|
request.setOpportunityName(trimToEmpty(request.getOpportunityName()));
|
||||||
}
|
}
|
||||||
if (request.getProjectLocation() != null) {
|
if (request.getProjectLocation() != null) {
|
||||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "项目地不能为空"));
|
request.setProjectLocation(trimToEmpty(request.getProjectLocation()));
|
||||||
}
|
}
|
||||||
if (request.getOperatorName() != null) {
|
if (request.getOperatorName() != null) {
|
||||||
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "运作方不能为空"));
|
request.setOperatorName(trimToEmpty(request.getOperatorName()));
|
||||||
}
|
}
|
||||||
if (request.getAmount() != null) {
|
if (request.getAmount() != null) {
|
||||||
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
|
request.setAmount(request.getAmount());
|
||||||
}
|
}
|
||||||
if (request.getConfidencePct() != null) {
|
if (request.getConfidencePct() != null && trimToNull(request.getConfidencePct()) == null) {
|
||||||
|
request.setConfidencePct(null);
|
||||||
|
} else if (request.getConfidencePct() != null) {
|
||||||
request.setConfidencePct(normalizeConfidenceGrade(request.getConfidencePct(), "请选择项目把握度"));
|
request.setConfidencePct(normalizeConfidenceGrade(request.getConfidencePct(), "请选择项目把握度"));
|
||||||
}
|
}
|
||||||
if (request.getSalesExpansionId() != null && request.getSalesExpansionId() <= 0) {
|
if (request.getStage() != null && trimToNull(request.getStage()) == null) {
|
||||||
throw new BusinessException("salesExpansionId 必须大于0");
|
request.setStage(null);
|
||||||
}
|
} else if (request.getStage() != null) {
|
||||||
if (request.getChannelExpansionId() != null && request.getChannelExpansionId() <= 0) {
|
|
||||||
throw new BusinessException("channelExpansionId 必须大于0");
|
|
||||||
}
|
|
||||||
if (request.getPreSalesId() != null && request.getPreSalesId() <= 0) {
|
|
||||||
throw new BusinessException("preSalesId 必须大于0");
|
|
||||||
}
|
|
||||||
if (request.getStage() != null) {
|
|
||||||
request.setStage(normalizeStageValue(request.getStage()));
|
request.setStage(normalizeStageValue(request.getStage()));
|
||||||
}
|
}
|
||||||
if (request.getOpportunityType() != null) {
|
if (request.getOpportunityType() != null) {
|
||||||
request.setOpportunityType(normalizeRequiredText(request.getOpportunityType(), "建设类型不能为空"));
|
request.setOpportunityType(trimToEmpty(request.getOpportunityType()));
|
||||||
}
|
}
|
||||||
if (request.getProductType() != null) {
|
if (request.getProductType() != null) {
|
||||||
request.setProductType(trimToEmpty(request.getProductType()));
|
request.setProductType(trimToEmpty(request.getProductType()));
|
||||||
|
|
@ -686,7 +690,6 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
normalizeArchivedTime(request);
|
normalizeArchivedTime(request);
|
||||||
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
|
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
|
||||||
autoFillOmsPushTime(request);
|
autoFillOmsPushTime(request);
|
||||||
validateIntegrationOperatorRelations(request, target);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void normalizeArchivedTime(UpdateOpportunityIntegrationRequest request) {
|
private void normalizeArchivedTime(UpdateOpportunityIntegrationRequest request) {
|
||||||
|
|
@ -704,32 +707,6 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateIntegrationOperatorRelations(
|
|
||||||
UpdateOpportunityIntegrationRequest request,
|
|
||||||
OpportunityIntegrationTargetDTO target) {
|
|
||||||
boolean operatorRelatedUpdated = request.getOperatorName() != null
|
|
||||||
|| request.getSalesExpansionId() != null
|
|
||||||
|| request.getChannelExpansionId() != null;
|
|
||||||
if (!operatorRelatedUpdated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String effectiveOperatorName = request.getOperatorName() != null
|
|
||||||
? request.getOperatorName()
|
|
||||||
: target.getOperatorName();
|
|
||||||
Long effectiveSalesExpansionId = request.getSalesExpansionId() != null
|
|
||||||
? request.getSalesExpansionId()
|
|
||||||
: target.getSalesExpansionId();
|
|
||||||
Long effectiveChannelExpansionId = request.getChannelExpansionId() != null
|
|
||||||
? request.getChannelExpansionId()
|
|
||||||
: target.getChannelExpansionId();
|
|
||||||
|
|
||||||
if (isBlank(effectiveOperatorName)) {
|
|
||||||
throw new BusinessException("运作方不能为空");
|
|
||||||
}
|
|
||||||
validateOperatorRelations(effectiveOperatorName, effectiveSalesExpansionId, effectiveChannelExpansionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveIntegrationStatus(String status, String stage) {
|
private String resolveIntegrationStatus(String status, String stage) {
|
||||||
String normalizedStage = trimToNull(stage);
|
String normalizedStage = trimToNull(stage);
|
||||||
String normalizedStatus = trimToNull(status);
|
String normalizedStatus = trimToNull(status);
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ unisbase:
|
||||||
access-token-safety-seconds: 120
|
access-token-safety-seconds: 120
|
||||||
oms:
|
oms:
|
||||||
enabled: ${OMS_ENABLED:true}
|
enabled: ${OMS_ENABLED:true}
|
||||||
base-url: ${OMS_BASE_URL:http://10.100.52.135:28080}
|
base-url: ${OMS_BASE_URL:http://192.168.4.78:28080}
|
||||||
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
|
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
|
||||||
api-key-header: ${OMS_API_KEY_HEADER:apiKey}
|
api-key-header: ${OMS_API_KEY_HEADER:apiKey}
|
||||||
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}
|
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@
|
||||||
coalesce(operator_dict.item_value, o.operator_name, '') as operatorCode,
|
coalesce(operator_dict.item_value, o.operator_name, '') as operatorCode,
|
||||||
coalesce(operator_dict.item_label, nullif(o.operator_name, ''), '') as operatorName,
|
coalesce(operator_dict.item_label, nullif(o.operator_name, ''), '') as operatorName,
|
||||||
o.amount,
|
o.amount,
|
||||||
|
o.actual_signed_amount as actualSignedAmount,
|
||||||
to_char(o.expected_close_date, 'YYYY-MM-DD') as date,
|
to_char(o.expected_close_date, 'YYYY-MM-DD') as date,
|
||||||
o.confidence_pct as confidence,
|
o.confidence_pct as confidence,
|
||||||
coalesce(stage_dict.item_value, o.stage, '') as stageCode,
|
coalesce(stage_dict.item_value, o.stage, '') as stageCode,
|
||||||
|
|
@ -239,6 +240,7 @@
|
||||||
coalesce(o.opportunity_type, '新建') as type,
|
coalesce(o.opportunity_type, '新建') as type,
|
||||||
coalesce(o.archived, false) as archived,
|
coalesce(o.archived, false) as archived,
|
||||||
coalesce(o.pushed_to_oms, false) as pushedToOms,
|
coalesce(o.pushed_to_oms, false) as pushedToOms,
|
||||||
|
coalesce(o.is_poc, false) as isPoc,
|
||||||
coalesce(o.product_type, 'VDI云桌面') as product,
|
coalesce(o.product_type, 'VDI云桌面') as product,
|
||||||
coalesce(o.source, '主动开发') as source,
|
coalesce(o.source, '主动开发') as source,
|
||||||
o.sales_expansion_id as salesExpansionId,
|
o.sales_expansion_id as salesExpansionId,
|
||||||
|
|
@ -605,6 +607,9 @@
|
||||||
<if test="request.amount != null">
|
<if test="request.amount != null">
|
||||||
amount = #{request.amount},
|
amount = #{request.amount},
|
||||||
</if>
|
</if>
|
||||||
|
<if test="request.actualSignedAmount != null">
|
||||||
|
actual_signed_amount = #{request.actualSignedAmount},
|
||||||
|
</if>
|
||||||
<if test="request.expectedCloseDate != null">
|
<if test="request.expectedCloseDate != null">
|
||||||
expected_close_date = #{request.expectedCloseDate},
|
expected_close_date = #{request.expectedCloseDate},
|
||||||
</if>
|
</if>
|
||||||
|
|
@ -656,6 +661,9 @@
|
||||||
<if test="request.omsPushTime != null">
|
<if test="request.omsPushTime != null">
|
||||||
oms_push_time = #{request.omsPushTime},
|
oms_push_time = #{request.omsPushTime},
|
||||||
</if>
|
</if>
|
||||||
|
<if test="request.isPoc != null">
|
||||||
|
is_poc = #{request.isPoc},
|
||||||
|
</if>
|
||||||
<if test="request.description != null">
|
<if test="request.description != null">
|
||||||
description = nullif(#{request.description}, ''),
|
description = nullif(#{request.description}, ''),
|
||||||
</if>
|
</if>
|
||||||
|
|
|
||||||
|
|
@ -85,4 +85,34 @@ class OpportunityIntegrationControllerWebMvcTest {
|
||||||
|
|
||||||
verify(opportunityService).updateOpportunityByIntegration(any());
|
verify(opportunityService).updateOpportunityByIntegration(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateOpportunityShouldAllowTargetOnlyPayloadForHistoricalData() throws Exception {
|
||||||
|
when(opportunityService.updateOpportunityByIntegration(any())).thenReturn(1L);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/opportunities/integration/update")
|
||||||
|
.header(INTERNAL_SECRET_HEADER, INTERNAL_SECRET_VALUE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opportunityCode\":\"OPP-20260401-001\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value("0"))
|
||||||
|
.andExpect(jsonPath("$.data").value(1L));
|
||||||
|
|
||||||
|
verify(opportunityService).updateOpportunityByIntegration(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateOpportunityShouldAcceptSignedAmountAndIsPocFields() throws Exception {
|
||||||
|
when(opportunityService.updateOpportunityByIntegration(any())).thenReturn(1L);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/opportunities/integration/update")
|
||||||
|
.header(INTERNAL_SECRET_HEADER, INTERNAL_SECRET_VALUE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opportunityCode\":\"OPP-20260401-001\",\"actual_signed_amount\":123.45,\"is_poc\":true}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value("0"))
|
||||||
|
.andExpect(jsonPath("$.data").value(1L));
|
||||||
|
|
||||||
|
verify(opportunityService).updateOpportunityByIntegration(any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
|
||||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
||||||
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
|
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
|
||||||
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
|
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
|
||||||
|
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
|
||||||
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
||||||
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
|
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
|
||||||
|
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||||
import com.unis.crm.mapper.OpportunityMapper;
|
import com.unis.crm.mapper.OpportunityMapper;
|
||||||
import com.unis.crm.service.OmsClient;
|
import com.unis.crm.service.OmsClient;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
@ -113,6 +115,45 @@ class OpportunityServiceImplTest {
|
||||||
verify(opportunityMapper).insertCustomer(any(), eq(1L), eq("客户B"), any());
|
verify(opportunityMapper).insertCustomer(any(), eq(1L), eq("客户B"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateOpportunityByIntegration_shouldAllowBlankAndLegacyValues() {
|
||||||
|
OpportunityIntegrationTargetDTO target = new OpportunityIntegrationTargetDTO();
|
||||||
|
target.setId(10L);
|
||||||
|
target.setOpportunityCode("V001");
|
||||||
|
when(opportunityMapper.selectOpportunityIntegrationTarget("V001")).thenReturn(target);
|
||||||
|
when(opportunityMapper.updateOpportunityByIntegration(eq(10L), any(UpdateOpportunityIntegrationRequest.class))).thenReturn(1);
|
||||||
|
|
||||||
|
UpdateOpportunityIntegrationRequest request = new UpdateOpportunityIntegrationRequest();
|
||||||
|
request.setOpportunityCode(" V001 ");
|
||||||
|
request.setOpportunityName("");
|
||||||
|
request.setProjectLocation(" ");
|
||||||
|
request.setOperatorName("");
|
||||||
|
request.setAmount(BigDecimal.ZERO);
|
||||||
|
request.setConfidencePct(" ");
|
||||||
|
request.setStage(" ");
|
||||||
|
request.setOpportunityType("");
|
||||||
|
request.setSalesExpansionId(0L);
|
||||||
|
request.setChannelExpansionId(0L);
|
||||||
|
request.setPreSalesId(0L);
|
||||||
|
request.setActualSignedAmount(new BigDecimal("123.45"));
|
||||||
|
request.setIsPoc(Boolean.TRUE);
|
||||||
|
request.setStatus(" ");
|
||||||
|
|
||||||
|
Long result = opportunityService.updateOpportunityByIntegration(request);
|
||||||
|
|
||||||
|
assertEquals(10L, result);
|
||||||
|
verify(opportunityMapper).updateOpportunityByIntegration(eq(10L), argThat(normalized ->
|
||||||
|
normalized.getConfidencePct() == null
|
||||||
|
&& normalized.getStage() == null
|
||||||
|
&& normalized.getStatus() == null
|
||||||
|
&& BigDecimal.ZERO.equals(normalized.getAmount())
|
||||||
|
&& new BigDecimal("123.45").equals(normalized.getActualSignedAmount())
|
||||||
|
&& Boolean.TRUE.equals(normalized.getIsPoc())
|
||||||
|
&& Long.valueOf(0L).equals(normalized.getSalesExpansionId())
|
||||||
|
&& Long.valueOf(0L).equals(normalized.getChannelExpansionId())
|
||||||
|
&& Long.valueOf(0L).equals(normalized.getPreSalesId())));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createOpportunity_shouldPersistEditableSnapshotFields() {
|
void createOpportunity_shouldPersistEditableSnapshotFields() {
|
||||||
when(opportunityMapper.selectOwnedCustomerIdByName(1L, "客户A")).thenReturn(200L);
|
when(opportunityMapper.selectOwnedCustomerIdByName(1L, "客户A")).thenReturn(200L);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,466 @@
|
||||||
|
# OMS 接口整理
|
||||||
|
|
||||||
|
整理日期:2026-06-10
|
||||||
|
|
||||||
|
本文档基于当前后端代码整理,覆盖两类接口:
|
||||||
|
|
||||||
|
- CRM 主动推送/同步项目到 OMS。
|
||||||
|
- OMS 或内部系统回写更新 CRM 项目/商机。
|
||||||
|
|
||||||
|
相关代码位置:
|
||||||
|
|
||||||
|
- `backend/src/main/java/com/unis/crm/controller/OpportunityController.java`
|
||||||
|
- `backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java`
|
||||||
|
- `backend/src/main/java/com/unis/crm/service/OmsClient.java`
|
||||||
|
- `backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java`
|
||||||
|
- `backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml`
|
||||||
|
|
||||||
|
## 一、CRM 推送项目到 OMS
|
||||||
|
|
||||||
|
### 1. 前端/CRM 触发推送接口
|
||||||
|
|
||||||
|
该接口由 CRM 前端调用后端,后端再调用 OMS。
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 请求方式 | `POST` |
|
||||||
|
| CRM 路径 | `/api/opportunities/{opportunityId}/push-oms` |
|
||||||
|
| 鉴权头 | `X-User-Id: <当前用户ID>` |
|
||||||
|
| Content-Type | `application/json` |
|
||||||
|
| 返回 | `ApiResponse<Long>`,成功时 `data` 为 CRM 商机 ID |
|
||||||
|
|
||||||
|
请求体可为空;如需要指定 OMS 售前人员,可传:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preSalesId": 123,
|
||||||
|
"preSalesName": "张三"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `preSalesId` | number | 否 | OMS 售前用户 ID。优先按该字段匹配 OMS 售前人员。 |
|
||||||
|
| `preSalesName` | string | 否 | OMS 售前姓名。未传 `preSalesId` 时按姓名匹配。 |
|
||||||
|
|
||||||
|
成功示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "0",
|
||||||
|
"msg": "success",
|
||||||
|
"data": 10001
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 推送前校验规则
|
||||||
|
|
||||||
|
后端会在推送前校验:
|
||||||
|
|
||||||
|
| 校验项 | 失败提示 |
|
||||||
|
| --- | --- |
|
||||||
|
| 商机 ID 不能为空且必须大于 0 | `商机不存在` |
|
||||||
|
| 当前用户有权操作该商机 | `无权操作该商机` |
|
||||||
|
| 已签单/归档商机不可推送 | `已签单商机不允许推送` |
|
||||||
|
| 商机名称不能为空 | `商机名称不能为空` |
|
||||||
|
| 最终客户不能为空 | `最终客户不能为空` |
|
||||||
|
| 运作方不能为空 | `运作方不能为空` |
|
||||||
|
| 预计金额必须大于 0 | `预计金额不能为空` |
|
||||||
|
| 预计下单时间不能为空 | `预计下单时间不能为空` |
|
||||||
|
| 项目把握度不能为空且需为字典有效值,兼容 A/B/C | `项目把握度不能为空` 或 `项目把握度无效: xxx` |
|
||||||
|
| 项目阶段不能为空且需为字典有效值 | `项目阶段不能为空` 或 `项目阶段无效: xxx` |
|
||||||
|
| 建设类型不能为空 | `建设类型不能为空` |
|
||||||
|
| 运作方包含渠道时必须关联渠道名称 | `推送 OMS 前请先关联渠道名称` |
|
||||||
|
| 必须能在 OMS 售前列表中解析售前人员 | `推送 OMS 前请选择售前人员` / `所选售前人员在OMS中不存在` |
|
||||||
|
|
||||||
|
### 3. CRM 调用 OMS 售前列表
|
||||||
|
|
||||||
|
用于获取 OMS 售前人员列表,也是推送前校验售前人员的依据。
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| CRM 路径 | `GET /api/opportunities/oms/pre-sales` |
|
||||||
|
| CRM 鉴权头 | `X-User-Id: <当前用户ID>` |
|
||||||
|
| 后端调用 OMS | `GET {OMS_BASE_URL}{OMS_USER_INFO_PATH}` |
|
||||||
|
| 默认 OMS 路径 | `/api/v1/user/info` |
|
||||||
|
| OMS 请求头 | `{OMS_API_KEY_HEADER}: {OMS_API_KEY}`,默认头名为 `apiKey` |
|
||||||
|
| OMS 查询参数 | `userCode=`、`roleName=售前` |
|
||||||
|
|
||||||
|
CRM 返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "0",
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"userId": 123,
|
||||||
|
"loginName": "zhangsan",
|
||||||
|
"userName": "张三"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. CRM 确保当前用户存在于 OMS
|
||||||
|
|
||||||
|
推送项目时,CRM 会把当前登录用户作为 OMS 创建人来源:
|
||||||
|
|
||||||
|
1. 先调用 `GET {OMS_BASE_URL}{OMS_USER_INFO_PATH}` 查询用户:
|
||||||
|
|
||||||
|
```text
|
||||||
|
userCode=<CRM当前用户username>
|
||||||
|
roleName=
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 如果 OMS 中不存在该用户,则调用 `POST {OMS_BASE_URL}{OMS_USER_ADD_PATH}` 新增用户。
|
||||||
|
|
||||||
|
默认新增用户路径:`/api/v1/user/add`
|
||||||
|
|
||||||
|
新增用户请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userName": "CRM用户显示名",
|
||||||
|
"loginName": "CRM用户名"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. CRM 调用 OMS 新增/更新项目
|
||||||
|
|
||||||
|
后端统一调用 OMS 的项目新增路径;当请求体中带 `projectCode` 时,该调用被当前代码视为更新已有 OMS 项目。
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 后端调用 OMS | `POST {OMS_BASE_URL}{OMS_PROJECT_ADD_PATH}` |
|
||||||
|
| 默认 OMS 路径 | `/api/v1/project/add` |
|
||||||
|
| OMS 请求头 | `{OMS_API_KEY_HEADER}: {OMS_API_KEY}` |
|
||||||
|
| Content-Type | `application/json` |
|
||||||
|
| 连接超时 | `OMS_CONNECT_TIMEOUT_SECONDS`,默认 5 秒 |
|
||||||
|
| 读取超时 | `OMS_READ_TIMEOUT_SECONDS`,默认 15 秒 |
|
||||||
|
|
||||||
|
请求体字段映射:
|
||||||
|
|
||||||
|
| OMS 字段 | 来源 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `projectCode` | `crm_opportunity.opportunity_code` | 有值时传入,用于更新已有 OMS 项目;无值时不传。 |
|
||||||
|
| `projectName` | `opportunity_name` | 项目/商机名称。 |
|
||||||
|
| `operateInstitution` | `operator_name` | 运作方。 |
|
||||||
|
| `h3cPerson` | `crm_sales_expansion.candidate_name` | 新华三负责人姓名。 |
|
||||||
|
| `h3cPhone` | `crm_sales_expansion.mobile` | 新华三负责人手机号。 |
|
||||||
|
| `estimatedAmount` | `amount` | 预计金额,转为字符串并去掉多余 0。 |
|
||||||
|
| `estimatedOrderTime` | `expected_close_date` | 预计下单时间,格式 `YYYY-MM-DD`。 |
|
||||||
|
| `projectGraspDegree` | `confidence_pct` | 项目把握度,统一映射为 `A`、`B`、`C`。 |
|
||||||
|
| `projectStage` | `stage` | CRM 项目阶段码值。 |
|
||||||
|
| `competitorList` | `competitor_name` | 竞品名称按 `,,、;;换行` 拆分为数组。 |
|
||||||
|
| `hzSupportUser` | `pre_sales_id` | 售前 ID,转字符串。 |
|
||||||
|
| `createBy` | 当前 CRM 用户在 OMS 的 `userId` | 创建人。 |
|
||||||
|
| `constructionType` | `opportunity_type` | 建设类型。 |
|
||||||
|
| `partner` | 渠道拓展信息 | 渠道伙伴对象。 |
|
||||||
|
|
||||||
|
`partner` 对象字段:
|
||||||
|
|
||||||
|
| OMS 字段 | 来源 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `partnerCode` | 固定 `null` | 当前代码不传渠道编码。 |
|
||||||
|
| `partnerName` | `crm_channel_expansion.channel_name` | 渠道名称。 |
|
||||||
|
| `province` | 渠道省份,优先匹配 `cnarea.name` | 省份。 |
|
||||||
|
| `city` | 渠道城市,优先匹配 `cnarea.name` | 城市。 |
|
||||||
|
| `address` | `crm_channel_expansion.office_address` | 办公地址。 |
|
||||||
|
| `contactPerson` | 首要渠道联系人或渠道主联系人 | 联系人。 |
|
||||||
|
| `contactPhone` | 首要渠道联系人手机号或渠道主联系人手机号 | 联系电话。 |
|
||||||
|
| `level` | `crm_channel_expansion.certification_level` | 认证级别。 |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectCode": "V001234",
|
||||||
|
"projectName": "某云桌面项目",
|
||||||
|
"operateInstitution": "新华三+渠道",
|
||||||
|
"h3cPerson": "李四",
|
||||||
|
"h3cPhone": "13800000000",
|
||||||
|
"estimatedAmount": "2800000",
|
||||||
|
"estimatedOrderTime": "2026-06-30",
|
||||||
|
"projectGraspDegree": "A",
|
||||||
|
"projectStage": "business_negotiation",
|
||||||
|
"competitorList": ["竞品A", "竞品B"],
|
||||||
|
"hzSupportUser": "123",
|
||||||
|
"createBy": "456",
|
||||||
|
"constructionType": "新建",
|
||||||
|
"partner": {
|
||||||
|
"partnerCode": null,
|
||||||
|
"partnerName": "某渠道公司",
|
||||||
|
"province": "浙江省",
|
||||||
|
"city": "杭州市",
|
||||||
|
"address": "杭州市某地址",
|
||||||
|
"contactPerson": "王五",
|
||||||
|
"contactPhone": "13900000000",
|
||||||
|
"level": "金牌"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. OMS 返回约定
|
||||||
|
|
||||||
|
后端期望 OMS 返回 JSON。HTTP 非 2xx、非 JSON、业务 `code` 非成功都会被视为失败。
|
||||||
|
|
||||||
|
成功 `code` 兼容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0, 200, success, SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` 为空也按成功处理。
|
||||||
|
|
||||||
|
项目编号提取规则:
|
||||||
|
|
||||||
|
1. 如果 `data` 是字符串/数字,直接作为项目编号。
|
||||||
|
2. 如果 `data` 是对象或数组,优先查找以下字段:
|
||||||
|
- `project_code`
|
||||||
|
- `projectCode`
|
||||||
|
- `projectNo`
|
||||||
|
- `omsProjectCode`
|
||||||
|
- `code`
|
||||||
|
- `projectId`
|
||||||
|
- `id`
|
||||||
|
3. 如果仍未找到,会递归查找字段名包含 `code`、`no` 或以 `id` 结尾的标量字段。
|
||||||
|
|
||||||
|
若 OMS 返回成功但无法提取项目编号,后端抛出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OMS返回成功,但未返回项目编号
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "0",
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"projectCode": "V001234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 推送成功后 CRM 本地更新
|
||||||
|
|
||||||
|
手动推送成功后,CRM 会更新 `crm_opportunity`:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pushed_to_oms` | `true` |
|
||||||
|
| `oms_push_time` | `now()` |
|
||||||
|
| `opportunity_code` | OMS 返回的项目编号,或已有非 `OPP-` 编号 |
|
||||||
|
| `updated_at` | `now()` |
|
||||||
|
|
||||||
|
项目编号处理规则:
|
||||||
|
|
||||||
|
- 如果 CRM 已有 `opportunity_code` 且不是 `OPP-` 开头,保留已有编号。
|
||||||
|
- 否则使用 OMS 返回的项目编号。
|
||||||
|
|
||||||
|
## 二、CRM 新增/编辑商机时自动同步 OMS
|
||||||
|
|
||||||
|
除手动推送外,当前服务层还在新增/编辑商机时调用 OMS。
|
||||||
|
|
||||||
|
### 1. 新增商机
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| CRM 接口 | `POST /api/opportunities` |
|
||||||
|
| 同步方式 | 新增本地商机后,严格调用 OMS 项目新增接口 |
|
||||||
|
| 是否传 `projectCode` | 不传 |
|
||||||
|
| 成功后 | 将 OMS 返回项目编号写入 `crm_opportunity.opportunity_code` |
|
||||||
|
| 失败影响 | 抛出异常,新增流程失败 |
|
||||||
|
| 是否标记 `pushed_to_oms` | 否 |
|
||||||
|
|
||||||
|
### 2. 编辑商机
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| CRM 接口 | `PUT /api/opportunities/{opportunityId}` |
|
||||||
|
| 同步方式 | 本地更新成功后,尽力调用 OMS 项目接口 |
|
||||||
|
| 是否传 `projectCode` | 有 `opportunity_code` 时传入 |
|
||||||
|
| 成功后 | 如返回编号与本地需更新编号不同,则更新 `opportunity_code` |
|
||||||
|
| 失败影响 | 仅记录 warn 日志,不阻断编辑流程 |
|
||||||
|
| 是否标记 `pushed_to_oms` | 否 |
|
||||||
|
|
||||||
|
## 三、OMS 或内部系统更新 CRM 项目/商机
|
||||||
|
|
||||||
|
该接口用于外部系统按 `opportunityCode` 回写 CRM 商机信息。
|
||||||
|
|
||||||
|
### 1. 接口信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 请求方式 | `PUT` |
|
||||||
|
| CRM 路径 | `/api/opportunities/integration/update` |
|
||||||
|
| Content-Type | `application/json` |
|
||||||
|
| 鉴权头 | `X-Internal-Secret: <内部接口密钥>` |
|
||||||
|
| 返回 | `ApiResponse<Long>`,成功时 `data` 为 CRM 商机 ID |
|
||||||
|
|
||||||
|
该路径已加入安全白名单,但仍由接口自身校验内部密钥。
|
||||||
|
|
||||||
|
### 2. 请求参数
|
||||||
|
|
||||||
|
必填字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `opportunityCode` | string | 商机编号/OMS 项目编号,用于定位 CRM 商机。最大 50 字符。 |
|
||||||
|
|
||||||
|
其他字段均为可选;未传字段不修改。为兼容历史数据,当前接口不再要求除 `opportunityCode` 外必须存在更新字段。
|
||||||
|
|
||||||
|
可更新字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `opportunityName` | string | 商机名称,最大 200 字符。 |
|
||||||
|
| `projectLocation` | string | 项目地,最大 100 字符。 |
|
||||||
|
| `operatorName` | string | 运作方,最大 100 字符。 |
|
||||||
|
| `amount` | number | 预计金额;历史数据回写不再限制必须大于 0。 |
|
||||||
|
| `actualSignedAmount` | number | 实际签约金额;兼容字段名 `actual_signed_amount`。 |
|
||||||
|
| `expectedCloseDate` | string | 预计结单日期,格式 `YYYY-MM-DD`。 |
|
||||||
|
| `confidencePct` | string | 项目把握度,支持 `A`、`B`、`C`,兼容 `40`、`60`、`80`。 |
|
||||||
|
| `stage` | string | 项目阶段,支持 CRM 字典值,也兼容部分中文阶段名。 |
|
||||||
|
| `opportunityType` | string | 建设类型,最大 50 字符。 |
|
||||||
|
| `productType` | string | 产品类型,最大 100 字符;空字符串会存为 `null`。 |
|
||||||
|
| `source` | string | 商机来源,最大 50 字符;空字符串会存为 `null`。 |
|
||||||
|
| `salesExpansionId` | number | 销售拓展 ID;历史数据回写不再限制必须大于 0。 |
|
||||||
|
| `channelExpansionId` | number | 渠道拓展 ID;历史数据回写不再限制必须大于 0。 |
|
||||||
|
| `preSalesId` | number | 售前 ID;历史数据回写不再限制必须大于 0。 |
|
||||||
|
| `preSalesName` | string | 售前姓名,最大 100 字符;空字符串会存为 `null`。 |
|
||||||
|
| `competitorName` | string | 竞品名称,最大 200 字符;空字符串会存为 `null`。 |
|
||||||
|
| `archived` | boolean | 是否归档。 |
|
||||||
|
| `archivedAt` | string | 归档时间,ISO 8601 格式。传该字段且未传 `archived` 时会自动视为归档。 |
|
||||||
|
| `pushedToOms` | boolean | 是否已推送 OMS。传 `true` 且未传 `omsPushTime` 时自动填当前时间。 |
|
||||||
|
| `omsPushTime` | string | 推送 OMS 时间,ISO 8601 格式。 |
|
||||||
|
| `isPoc` | boolean | 是否 POC 测试项目;兼容字段名 `is_poc`。 |
|
||||||
|
| `status` | string | 商机状态,支持 `active`、`closed`、`won`、`lost`,但 `won/lost` 需配合对应阶段。 |
|
||||||
|
| `description` | string | 备注;空字符串会存为 `null`。 |
|
||||||
|
|
||||||
|
### 3. 阶段与状态规则
|
||||||
|
|
||||||
|
阶段兼容值:
|
||||||
|
|
||||||
|
| 传入值 | 归一化结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `初步沟通` / `initial_contact` | `initial_contact` |
|
||||||
|
| `方案交流` / `solution_discussion` | `solution_discussion` |
|
||||||
|
| `招投标` / `bidding` | `bidding` |
|
||||||
|
| `商务谈判` / `business_negotiation` | `business_negotiation` |
|
||||||
|
| `已成交` / `won` | `won` |
|
||||||
|
| `已放弃` / `lost` | `lost` |
|
||||||
|
|
||||||
|
状态联动规则:
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `stage = won` 且未传 `status` | 自动设置 `status = won` |
|
||||||
|
| `stage = won` 且 `status != won` | 报错:`项目阶段为已成交时,状态必须为won` |
|
||||||
|
| `stage = lost` 且未传 `status` | 自动设置 `status = lost` |
|
||||||
|
| `stage = lost` 且 `status != lost` | 报错:`项目阶段为已放弃时,状态必须为lost` |
|
||||||
|
| 传入非终态 `stage` 且未传 `status` | 自动设置 `status = active` |
|
||||||
|
| 仅传 `status = won/lost`,未传对应 `stage` | 报错:`更新状态为won或lost时,请同步传入对应的项目阶段` |
|
||||||
|
|
||||||
|
### 4. 请求示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"opportunityCode": "V001234",
|
||||||
|
"stage": "won",
|
||||||
|
"status": "won",
|
||||||
|
"confidencePct": "A",
|
||||||
|
"amount": 2800000,
|
||||||
|
"expectedCloseDate": "2026-06-30",
|
||||||
|
"pushedToOms": true,
|
||||||
|
"description": "OMS回写成交结果"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "0",
|
||||||
|
"msg": "success",
|
||||||
|
"data": 10001
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "-1",
|
||||||
|
"msg": "商机不存在",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
curl 示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT 'http://localhost:8080/api/opportunities/integration/update' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'X-Internal-Secret: <内部接口密钥>' \
|
||||||
|
-d '{
|
||||||
|
"opportunityCode": "V001234",
|
||||||
|
"stage": "won",
|
||||||
|
"status": "won",
|
||||||
|
"confidencePct": "A",
|
||||||
|
"amount": 2800000,
|
||||||
|
"expectedCloseDate": "2026-06-30",
|
||||||
|
"pushedToOms": true,
|
||||||
|
"description": "OMS回写成交结果"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 常见错误
|
||||||
|
|
||||||
|
| 错误信息 | 原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| `内部接口鉴权失败` | 未传内部密钥、密钥错误,或服务端未配置有效密钥。 |
|
||||||
|
| `opportunityCode 不能为空` | 未传商机编号。 |
|
||||||
|
| `商机不存在` | CRM 中找不到对应 `opportunity_code`。 |
|
||||||
|
| `项目把握度无效: xxx` | 把握度无法匹配 CRM 字典或兼容值。 |
|
||||||
|
| `项目阶段无效: xxx` | 阶段无法匹配 CRM 字典或兼容值。 |
|
||||||
|
|
||||||
|
## 四、配置项
|
||||||
|
|
||||||
|
OMS 配置前缀:`unisbase.app.oms`
|
||||||
|
|
||||||
|
| 配置项 | 环境变量 | 默认值/说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `OMS_ENABLED` | 默认 `true`。关闭后调用 OMS 会报 `OMS推送未启用`。 |
|
||||||
|
| `base-url` | `OMS_BASE_URL` | OMS 基础地址。 |
|
||||||
|
| `api-key` | `OMS_API_KEY` | OMS 接口密钥。 |
|
||||||
|
| `api-key-header` | `OMS_API_KEY_HEADER` | 默认 `apiKey`。 |
|
||||||
|
| `user-info-path` | `OMS_USER_INFO_PATH` | 默认 `/api/v1/user/info`。 |
|
||||||
|
| `user-add-path` | `OMS_USER_ADD_PATH` | 默认 `/api/v1/user/add`。 |
|
||||||
|
| `project-add-path` | `OMS_PROJECT_ADD_PATH` | 默认 `/api/v1/project/add`。 |
|
||||||
|
| `pre-sales-role-name` | `OMS_PRE_SALES_ROLE_NAME` | 默认 `售前`。 |
|
||||||
|
| `connect-timeout-seconds` | `OMS_CONNECT_TIMEOUT_SECONDS` | 默认 5 秒。 |
|
||||||
|
| `read-timeout-seconds` | `OMS_READ_TIMEOUT_SECONDS` | 默认 15 秒。 |
|
||||||
|
|
||||||
|
内部回写鉴权配置前缀:`unisbase.internal-auth`
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `enabled` | 默认 `true`。关闭后不校验内部密钥。 |
|
||||||
|
| `secret` | 内部接口密钥。 |
|
||||||
|
| `header-name` | 默认 `X-Internal-Secret`。 |
|
||||||
|
|
||||||
|
## 五、接口方向速查
|
||||||
|
|
||||||
|
| 方向 | 场景 | 接口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CRM 前端 -> CRM 后端 | 获取 OMS 售前列表 | `GET /api/opportunities/oms/pre-sales` |
|
||||||
|
| CRM 前端 -> CRM 后端 | 手动推送商机到 OMS | `POST /api/opportunities/{opportunityId}/push-oms` |
|
||||||
|
| CRM 后端 -> OMS | 查询 OMS 用户/售前 | `GET {OMS_BASE_URL}/api/v1/user/info` |
|
||||||
|
| CRM 后端 -> OMS | 创建 OMS 用户 | `POST {OMS_BASE_URL}/api/v1/user/add` |
|
||||||
|
| CRM 后端 -> OMS | 新增或更新 OMS 项目 | `POST {OMS_BASE_URL}/api/v1/project/add` |
|
||||||
|
| OMS/内部系统 -> CRM 后端 | 按项目编号回写更新 CRM 商机 | `PUT /api/opportunities/integration/update` |
|
||||||
|
|
@ -29,27 +29,31 @@
|
||||||
| `opportunityName` | string | 商机名称 |
|
| `opportunityName` | string | 商机名称 |
|
||||||
| `projectLocation` | string | 项目地 |
|
| `projectLocation` | string | 项目地 |
|
||||||
| `operatorName` | string | 运作方 |
|
| `operatorName` | string | 运作方 |
|
||||||
| `amount` | number | 商机金额,必须大于 0 |
|
| `amount` | number | 商机金额,历史数据回写不再限制必须大于 0 |
|
||||||
|
| `actualSignedAmount` | number | 实际签约金额,兼容字段名 `actual_signed_amount` |
|
||||||
| `expectedCloseDate` | string | 预计结单日期,格式 `YYYY-MM-DD` |
|
| `expectedCloseDate` | string | 预计结单日期,格式 `YYYY-MM-DD` |
|
||||||
| `confidencePct` | string | 把握度,建议传 `A`、`B`、`C` |
|
| `confidencePct` | string | 把握度,建议传 `A`、`B`、`C` |
|
||||||
| `stage` | string | 项目阶段,建议传 CRM 当前字典码值 |
|
| `stage` | string | 项目阶段,建议传 CRM 当前字典码值 |
|
||||||
| `opportunityType` | string | 建设类型 |
|
| `opportunityType` | string | 建设类型 |
|
||||||
| `productType` | string | 产品类型 |
|
| `productType` | string | 产品类型 |
|
||||||
| `source` | string | 商机来源 |
|
| `source` | string | 商机来源 |
|
||||||
| `salesExpansionId` | number | 销售拓展 ID,必须大于 0 |
|
| `salesExpansionId` | number | 销售拓展 ID,历史数据回写不再限制必须大于 0 |
|
||||||
| `channelExpansionId` | number | 渠道拓展 ID,必须大于 0 |
|
| `channelExpansionId` | number | 渠道拓展 ID,历史数据回写不再限制必须大于 0 |
|
||||||
| `preSalesId` | number | 售前 ID,必须大于 0 |
|
| `preSalesId` | number | 售前 ID,历史数据回写不再限制必须大于 0 |
|
||||||
| `preSalesName` | string | 售前姓名 |
|
| `preSalesName` | string | 售前姓名 |
|
||||||
| `competitorName` | string | 竞品名称 |
|
| `competitorName` | string | 竞品名称 |
|
||||||
| `archived` | boolean | 是否归档 |
|
| `archived` | boolean | 是否归档 |
|
||||||
| `pushedToOms` | boolean | 是否已推送 OMS |
|
| `pushedToOms` | boolean | 是否已推送 OMS |
|
||||||
| `omsPushTime` | string | 推送 OMS 时间,建议 ISO 8601 格式 |
|
| `omsPushTime` | string | 推送 OMS 时间,建议 ISO 8601 格式 |
|
||||||
|
| `isPoc` | boolean | 是否 POC 测试项目,兼容字段名 `is_poc` |
|
||||||
| `status` | string | 商机状态,支持 `active`、`closed`、`won`、`lost` |
|
| `status` | string | 商机状态,支持 `active`、`closed`、`won`、`lost` |
|
||||||
| `description` | string | 备注 |
|
| `description` | string | 备注 |
|
||||||
|
|
||||||
## 关键规则
|
## 关键规则
|
||||||
|
|
||||||
- 除 `opportunityCode` 外,至少还要传一个更新字段
|
- `opportunityCode` 仅用于定位商机,仍需传入
|
||||||
|
- 其他字段均为可选;未传字段不修改
|
||||||
|
- 为兼容历史数据,接口不再要求除 `opportunityCode` 外必须存在更新字段
|
||||||
|
|
||||||
## 请求示例
|
## 请求示例
|
||||||
|
|
||||||
|
|
@ -92,9 +96,8 @@
|
||||||
- `内部接口鉴权失败`
|
- `内部接口鉴权失败`
|
||||||
- `商机不存在`
|
- `商机不存在`
|
||||||
- `opportunityCode 不能为空`
|
- `opportunityCode 不能为空`
|
||||||
- `至少传入一个需要更新的字段`
|
|
||||||
- `项目阶段无效: xxx`
|
- `项目阶段无效: xxx`
|
||||||
- `项目把握度仅支持A、B、C`
|
- `项目把握度无效: xxx`
|
||||||
|
|
||||||
## curl 示例
|
## curl 示例
|
||||||
|
|
||||||
|
|
@ -112,4 +115,3 @@ curl -X PUT 'http://localhost:8080/api/opportunities/integration/update' \
|
||||||
"description": "外部系统回写成交结果"
|
"description": "外部系统回写成交结果"
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,7 @@ export interface OpportunityItem {
|
||||||
operatorCode?: string;
|
operatorCode?: string;
|
||||||
operatorName?: string;
|
operatorName?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
|
actualSignedAmount?: number;
|
||||||
date?: string;
|
date?: string;
|
||||||
confidence?: string;
|
confidence?: string;
|
||||||
stageCode?: string;
|
stageCode?: string;
|
||||||
|
|
@ -423,6 +424,7 @@ export interface OpportunityItem {
|
||||||
type?: string;
|
type?: string;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
pushedToOms?: boolean;
|
pushedToOms?: boolean;
|
||||||
|
isPoc?: boolean;
|
||||||
product?: string;
|
product?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
salesExpansionId?: number;
|
salesExpansionId?: number;
|
||||||
|
|
@ -457,6 +459,7 @@ export interface OpportunityMeta {
|
||||||
projectLocationOptions?: OpportunityDictOption[];
|
projectLocationOptions?: OpportunityDictOption[];
|
||||||
opportunityTypeOptions?: OpportunityDictOption[];
|
opportunityTypeOptions?: OpportunityDictOption[];
|
||||||
confidenceOptions?: OpportunityDictOption[];
|
confidenceOptions?: OpportunityDictOption[];
|
||||||
|
sysIsOptions?: OpportunityDictOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OmsPreSalesOption {
|
export interface OmsPreSalesOption {
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,14 @@ function formatOpportunityBoolean(value?: boolean, trueLabel = "是", falseLabel
|
||||||
return value ? trueLabel : falseLabel;
|
return value ? trueLabel : falseLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSysIsDictValue(value: boolean | null | undefined, options: OpportunityDictOption[]) {
|
||||||
|
const normalizedValue = value ? "1" : "0";
|
||||||
|
const fallbackLabel = value ? "是" : "否";
|
||||||
|
const matched = options.find((option) => getOpportunityDictOptionValue(option) === normalizedValue);
|
||||||
|
const optionLabel = matched ? getOpportunityDictOptionLabel(matched) : fallbackLabel;
|
||||||
|
return optionLabel;
|
||||||
|
}
|
||||||
|
|
||||||
function loadOpportunityExportPreferences(): OpportunityExportFilters {
|
function loadOpportunityExportPreferences(): OpportunityExportFilters {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return {};
|
return {};
|
||||||
|
|
@ -1849,6 +1857,7 @@ export default function Opportunities() {
|
||||||
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
|
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
const [confidenceOptions, setConfidenceOptions] = useState<OpportunityDictOption[]>([]);
|
const [confidenceOptions, setConfidenceOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
|
const [sysIsOptions, setSysIsOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
||||||
const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined);
|
const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined);
|
||||||
const [pushPreSalesName, setPushPreSalesName] = useState("");
|
const [pushPreSalesName, setPushPreSalesName] = useState("");
|
||||||
|
|
@ -2079,6 +2088,7 @@ export default function Opportunities() {
|
||||||
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
|
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
|
||||||
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
|
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
|
||||||
setConfidenceOptions((data.confidenceOptions ?? []).filter((item) => item.value));
|
setConfidenceOptions((data.confidenceOptions ?? []).filter((item) => item.value));
|
||||||
|
setSysIsOptions((data.sysIsOptions ?? []).filter((item) => item.value));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -2087,6 +2097,7 @@ export default function Opportunities() {
|
||||||
setProjectLocationOptions([]);
|
setProjectLocationOptions([]);
|
||||||
setOpportunityTypeOptions([]);
|
setOpportunityTypeOptions([]);
|
||||||
setConfidenceOptions([]);
|
setConfidenceOptions([]);
|
||||||
|
setSysIsOptions([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3498,6 +3509,13 @@ export default function Opportunities() {
|
||||||
) : null}
|
) : null}
|
||||||
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
|
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
|
||||||
<DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(detailItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
|
<DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(detailItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
|
||||||
|
{detailItem.archived ? (
|
||||||
|
<DetailItem
|
||||||
|
label="实际签约金额(元)"
|
||||||
|
value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(detailItem.actualSignedAmount)}</span>}
|
||||||
|
icon={<DollarSign className="h-3 w-3" />}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<DetailItem label="预计下单时间" value={detailItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
|
<DetailItem label="预计下单时间" value={detailItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
|
||||||
<DetailItem label="项目把握度" value={getConfidenceLabel(detailItem.confidence, effectiveConfidenceOptions)} />
|
<DetailItem label="项目把握度" value={getConfidenceLabel(detailItem.confidence, effectiveConfidenceOptions)} />
|
||||||
<DetailItem label="项目阶段" value={detailItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
<DetailItem label="项目阶段" value={detailItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
||||||
|
|
@ -3516,6 +3534,9 @@ export default function Opportunities() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{detailItem.pushedToOms || detailItem.archived ? (
|
||||||
|
<DetailItem label="POC测试项目" value={formatSysIsDictValue(detailItem.isPoc, sysIsOptions)} icon={<Check className="h-3 w-3" />} />
|
||||||
|
) : null}
|
||||||
<DetailItem label="竞争对手" value={detailItem.competitorName || "无"} />
|
<DetailItem label="竞争对手" value={detailItem.competitorName || "无"} />
|
||||||
<DetailItem label="建设类型" value={detailItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
<DetailItem label="建设类型" value={detailItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
||||||
<DetailItem label="项目最新进展" value={detailItem.latestProgress || "暂无进展"} className="md:col-span-2" />
|
<DetailItem label="项目最新进展" value={detailItem.latestProgress || "暂无进展"} className="md:col-span-2" />
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,12 @@ psql -d your_database -f sql/init_owner_transfer_permissions_pg17.sql
|
||||||
psql -d your_database -f sql/upgrade_opportunity_actual_signed_amount_pg17.sql
|
psql -d your_database -f sql/upgrade_opportunity_actual_signed_amount_pg17.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果老环境需要补“是否 POC 测试项目”字段,请执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -d your_database -f sql/upgrade_opportunity_is_poc_pg17.sql
|
||||||
|
```
|
||||||
|
|
||||||
如果希望直接按当前版本代码一次补齐“经营分析最新结构 + 商机快照字段 + 实际签约金额字段”,可直接执行合并脚本:
|
如果希望直接按当前版本代码一次补齐“经营分析最新结构 + 商机快照字段 + 实际签约金额字段”,可直接执行合并脚本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -125,6 +131,9 @@ psql -d your_database -f sql/upgrade_dashboard_analytics_card_display_config_pg1
|
||||||
- `sql/upgrade_opportunity_actual_signed_amount_pg17.sql`
|
- `sql/upgrade_opportunity_actual_signed_amount_pg17.sql`
|
||||||
老环境补齐“商机实际签约金额”字段时使用的正式增量脚本。
|
老环境补齐“商机实际签约金额”字段时使用的正式增量脚本。
|
||||||
|
|
||||||
|
- `sql/upgrade_opportunity_is_poc_pg17.sql`
|
||||||
|
老环境补齐“是否 POC 测试项目”字段时使用的正式增量脚本。
|
||||||
|
|
||||||
- `sql/init_report_reminder_pg17.sql`
|
- `sql/init_report_reminder_pg17.sql`
|
||||||
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。
|
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ create table if not exists crm_opportunity (
|
||||||
operator_name varchar(100),
|
operator_name varchar(100),
|
||||||
amount numeric(18, 2) not null default 0,
|
amount numeric(18, 2) not null default 0,
|
||||||
actual_signed_amount numeric(18, 2),
|
actual_signed_amount numeric(18, 2),
|
||||||
|
is_poc boolean not null default false,
|
||||||
expected_close_date date,
|
expected_close_date date,
|
||||||
confidence_pct varchar(1) not null default 'C' check (confidence_pct in ('A', 'B', 'C')),
|
confidence_pct varchar(1) not null default 'C' check (confidence_pct in ('A', 'B', 'C')),
|
||||||
stage varchar(50) not null default 'initial_contact',
|
stage varchar(50) not null default 'initial_contact',
|
||||||
|
|
@ -560,7 +561,9 @@ ALTER TABLE IF EXISTS crm_opportunity
|
||||||
ADD COLUMN IF NOT EXISTS pre_sales_name varchar(100),
|
ADD COLUMN IF NOT EXISTS pre_sales_name varchar(100),
|
||||||
ADD COLUMN IF NOT EXISTS project_location varchar(100),
|
ADD COLUMN IF NOT EXISTS project_location varchar(100),
|
||||||
ADD COLUMN IF NOT EXISTS operator_name varchar(100),
|
ADD COLUMN IF NOT EXISTS operator_name varchar(100),
|
||||||
ADD COLUMN IF NOT EXISTS competitor_name varchar(200);
|
ADD COLUMN IF NOT EXISTS competitor_name varchar(200),
|
||||||
|
ADD COLUMN IF NOT EXISTS actual_signed_amount numeric(18, 2),
|
||||||
|
ADD COLUMN IF NOT EXISTS is_poc boolean not null default false;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|
@ -819,6 +822,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
|
||||||
('crm_opportunity', 'operator_name', '运作方'),
|
('crm_opportunity', 'operator_name', '运作方'),
|
||||||
('crm_opportunity', 'amount', '商机金额'),
|
('crm_opportunity', 'amount', '商机金额'),
|
||||||
('crm_opportunity', 'actual_signed_amount', '实际签约金额'),
|
('crm_opportunity', 'actual_signed_amount', '实际签约金额'),
|
||||||
|
('crm_opportunity', 'is_poc', '是否POC测试项目'),
|
||||||
('crm_opportunity', 'expected_close_date', '预计结单日期'),
|
('crm_opportunity', 'expected_close_date', '预计结单日期'),
|
||||||
('crm_opportunity', 'confidence_pct', '把握度等级(A/B/C)'),
|
('crm_opportunity', 'confidence_pct', '把握度等级(A/B/C)'),
|
||||||
('crm_opportunity', 'stage', '商机阶段'),
|
('crm_opportunity', 'stage', '商机阶段'),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
begin;
|
||||||
|
|
||||||
|
set search_path to public;
|
||||||
|
|
||||||
|
-- 商机表补充是否 POC 测试项目字段
|
||||||
|
-- 说明:
|
||||||
|
-- 1. 为 crm_opportunity 增加 is_poc 字段。
|
||||||
|
-- 2. 字段类型为 boolean,默认 false,兼容历史数据。
|
||||||
|
-- 3. 可重复执行。
|
||||||
|
|
||||||
|
alter table if exists crm_opportunity
|
||||||
|
add column if not exists is_poc boolean not null default false;
|
||||||
|
|
||||||
|
comment on column crm_opportunity.is_poc is '是否POC测试项目';
|
||||||
|
|
||||||
|
commit;
|
||||||
Loading…
Reference in New Issue