修改同步接口,添加字段
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 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 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("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.archived_at 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);
|
||||
ensureConfidenceGradeStorage(connection);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ public class OpportunityItemDTO {
|
|||
private String operatorCode;
|
||||
private String operatorName;
|
||||
private BigDecimal amount;
|
||||
private BigDecimal actualSignedAmount;
|
||||
private String date;
|
||||
private String confidence;
|
||||
private String stageCode;
|
||||
|
|
@ -25,6 +26,7 @@ public class OpportunityItemDTO {
|
|||
private String type;
|
||||
private Boolean archived;
|
||||
private Boolean pushedToOms;
|
||||
private Boolean isPoc;
|
||||
private String product;
|
||||
private String source;
|
||||
private Long salesExpansionId;
|
||||
|
|
@ -139,6 +141,14 @@ public class OpportunityItemDTO {
|
|||
this.amount = amount;
|
||||
}
|
||||
|
||||
public BigDecimal getActualSignedAmount() {
|
||||
return actualSignedAmount;
|
||||
}
|
||||
|
||||
public void setActualSignedAmount(BigDecimal actualSignedAmount) {
|
||||
this.actualSignedAmount = actualSignedAmount;
|
||||
}
|
||||
|
||||
public String getDate() {
|
||||
return date;
|
||||
}
|
||||
|
|
@ -195,6 +205,14 @@ public class OpportunityItemDTO {
|
|||
this.pushedToOms = pushedToOms;
|
||||
}
|
||||
|
||||
public Boolean getIsPoc() {
|
||||
return isPoc;
|
||||
}
|
||||
|
||||
public void setIsPoc(Boolean isPoc) {
|
||||
this.isPoc = isPoc;
|
||||
}
|
||||
|
||||
public String getProduct() {
|
||||
return product;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public class OpportunityMetaDTO {
|
|||
private List<OpportunityDictOptionDTO> projectLocationOptions;
|
||||
private List<OpportunityDictOptionDTO> opportunityTypeOptions;
|
||||
private List<OpportunityDictOptionDTO> confidenceOptions;
|
||||
private List<OpportunityDictOptionDTO> sysIsOptions;
|
||||
|
||||
public OpportunityMetaDTO() {
|
||||
}
|
||||
|
|
@ -18,12 +19,14 @@ public class OpportunityMetaDTO {
|
|||
List<OpportunityDictOptionDTO> operatorOptions,
|
||||
List<OpportunityDictOptionDTO> projectLocationOptions,
|
||||
List<OpportunityDictOptionDTO> opportunityTypeOptions,
|
||||
List<OpportunityDictOptionDTO> confidenceOptions) {
|
||||
List<OpportunityDictOptionDTO> confidenceOptions,
|
||||
List<OpportunityDictOptionDTO> sysIsOptions) {
|
||||
this.stageOptions = stageOptions;
|
||||
this.operatorOptions = operatorOptions;
|
||||
this.projectLocationOptions = projectLocationOptions;
|
||||
this.opportunityTypeOptions = opportunityTypeOptions;
|
||||
this.confidenceOptions = confidenceOptions;
|
||||
this.sysIsOptions = sysIsOptions;
|
||||
}
|
||||
|
||||
public List<OpportunityDictOptionDTO> getStageOptions() {
|
||||
|
|
@ -65,4 +68,12 @@ public class OpportunityMetaDTO {
|
|||
public void setConfidenceOptions(List<OpportunityDictOptionDTO> 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;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
|
@ -24,9 +24,11 @@ public class UpdateOpportunityIntegrationRequest {
|
|||
|
||||
private BigDecimal amount;
|
||||
|
||||
@JsonAlias("actual_signed_amount")
|
||||
private BigDecimal actualSignedAmount;
|
||||
|
||||
private LocalDate expectedCloseDate;
|
||||
|
||||
@Pattern(regexp = "^(A|B|C|a|b|c|40|60|80)$", message = "把握度仅支持A、B、C")
|
||||
private String confidencePct;
|
||||
|
||||
@Size(max = 50, message = "项目阶段不能超过50字符")
|
||||
|
|
@ -61,6 +63,9 @@ public class UpdateOpportunityIntegrationRequest {
|
|||
|
||||
private OffsetDateTime omsPushTime;
|
||||
|
||||
@JsonAlias("is_poc")
|
||||
private Boolean isPoc;
|
||||
|
||||
@Size(max = 30, message = "状态不能超过30字符")
|
||||
private String status;
|
||||
|
||||
|
|
@ -72,32 +77,6 @@ public class UpdateOpportunityIntegrationRequest {
|
|||
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() {
|
||||
return opportunityCode;
|
||||
}
|
||||
|
|
@ -138,6 +117,14 @@ public class UpdateOpportunityIntegrationRequest {
|
|||
this.amount = amount;
|
||||
}
|
||||
|
||||
public BigDecimal getActualSignedAmount() {
|
||||
return actualSignedAmount;
|
||||
}
|
||||
|
||||
public void setActualSignedAmount(BigDecimal actualSignedAmount) {
|
||||
this.actualSignedAmount = actualSignedAmount;
|
||||
}
|
||||
|
||||
public LocalDate getExpectedCloseDate() {
|
||||
return expectedCloseDate;
|
||||
}
|
||||
|
|
@ -258,6 +245,14 @@ public class UpdateOpportunityIntegrationRequest {
|
|||
this.omsPushTime = omsPushTime;
|
||||
}
|
||||
|
||||
public Boolean getIsPoc() {
|
||||
return isPoc;
|
||||
}
|
||||
|
||||
public void setIsPoc(Boolean isPoc) {
|
||||
this.isPoc = isPoc;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
|
||||
private static final String OPPORTUNITY_TYPE_CODE = "sj_jslx";
|
||||
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_PUSH_MESSAGE = "已签单商机不允许推送";
|
||||
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
|
||||
|
|
@ -58,7 +59,8 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
|
||||
opportunityMapper.selectProvinceAreaOptions(),
|
||||
opportunityTypeOptions.isEmpty() ? buildDefaultOpportunityTypeOptions() : opportunityTypeOptions,
|
||||
confidenceOptions.isEmpty() ? buildDefaultConfidenceOptions() : confidenceOptions);
|
||||
confidenceOptions.isEmpty() ? buildDefaultConfidenceOptions() : confidenceOptions,
|
||||
buildSysIsOptions(opportunityMapper.selectDictItems(SYS_IS_TYPE_CODE)));
|
||||
}
|
||||
|
||||
private List<OpportunityDictOptionDTO> buildDefaultOpportunityTypeOptions() {
|
||||
|
|
@ -75,6 +77,15 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
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) {
|
||||
OpportunityDictOptionDTO option = new OpportunityDictOptionDTO();
|
||||
option.setLabel(label);
|
||||
|
|
@ -157,7 +168,7 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
@Transactional
|
||||
public Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request) {
|
||||
OpportunityIntegrationTargetDTO target = requireOpportunityIntegrationTarget(request);
|
||||
normalizeIntegrationUpdateRequest(request, target);
|
||||
normalizeIntegrationUpdateRequest(request);
|
||||
|
||||
int updated = opportunityMapper.updateOpportunityByIntegration(target.getId(), request);
|
||||
if (updated <= 0) {
|
||||
|
|
@ -635,38 +646,31 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return target;
|
||||
}
|
||||
|
||||
private void normalizeIntegrationUpdateRequest(
|
||||
UpdateOpportunityIntegrationRequest request,
|
||||
OpportunityIntegrationTargetDTO target) {
|
||||
private void normalizeIntegrationUpdateRequest(UpdateOpportunityIntegrationRequest request) {
|
||||
if (request.getOpportunityName() != null) {
|
||||
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "商机名称不能为空"));
|
||||
request.setOpportunityName(trimToEmpty(request.getOpportunityName()));
|
||||
}
|
||||
if (request.getProjectLocation() != null) {
|
||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "项目地不能为空"));
|
||||
request.setProjectLocation(trimToEmpty(request.getProjectLocation()));
|
||||
}
|
||||
if (request.getOperatorName() != null) {
|
||||
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "运作方不能为空"));
|
||||
request.setOperatorName(trimToEmpty(request.getOperatorName()));
|
||||
}
|
||||
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(), "请选择项目把握度"));
|
||||
}
|
||||
if (request.getSalesExpansionId() != null && request.getSalesExpansionId() <= 0) {
|
||||
throw new BusinessException("salesExpansionId 必须大于0");
|
||||
}
|
||||
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) {
|
||||
if (request.getStage() != null && trimToNull(request.getStage()) == null) {
|
||||
request.setStage(null);
|
||||
} else if (request.getStage() != null) {
|
||||
request.setStage(normalizeStageValue(request.getStage()));
|
||||
}
|
||||
if (request.getOpportunityType() != null) {
|
||||
request.setOpportunityType(normalizeRequiredText(request.getOpportunityType(), "建设类型不能为空"));
|
||||
request.setOpportunityType(trimToEmpty(request.getOpportunityType()));
|
||||
}
|
||||
if (request.getProductType() != null) {
|
||||
request.setProductType(trimToEmpty(request.getProductType()));
|
||||
|
|
@ -686,7 +690,6 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
normalizeArchivedTime(request);
|
||||
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
|
||||
autoFillOmsPushTime(request);
|
||||
validateIntegrationOperatorRelations(request, target);
|
||||
}
|
||||
|
||||
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) {
|
||||
String normalizedStage = trimToNull(stage);
|
||||
String normalizedStatus = trimToNull(status);
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ unisbase:
|
|||
access-token-safety-seconds: 120
|
||||
oms:
|
||||
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-header: ${OMS_API_KEY_HEADER:apiKey}
|
||||
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_label, nullif(o.operator_name, ''), '') as operatorName,
|
||||
o.amount,
|
||||
o.actual_signed_amount as actualSignedAmount,
|
||||
to_char(o.expected_close_date, 'YYYY-MM-DD') as date,
|
||||
o.confidence_pct as confidence,
|
||||
coalesce(stage_dict.item_value, o.stage, '') as stageCode,
|
||||
|
|
@ -239,6 +240,7 @@
|
|||
coalesce(o.opportunity_type, '新建') as type,
|
||||
coalesce(o.archived, false) as archived,
|
||||
coalesce(o.pushed_to_oms, false) as pushedToOms,
|
||||
coalesce(o.is_poc, false) as isPoc,
|
||||
coalesce(o.product_type, 'VDI云桌面') as product,
|
||||
coalesce(o.source, '主动开发') as source,
|
||||
o.sales_expansion_id as salesExpansionId,
|
||||
|
|
@ -605,6 +607,9 @@
|
|||
<if test="request.amount != null">
|
||||
amount = #{request.amount},
|
||||
</if>
|
||||
<if test="request.actualSignedAmount != null">
|
||||
actual_signed_amount = #{request.actualSignedAmount},
|
||||
</if>
|
||||
<if test="request.expectedCloseDate != null">
|
||||
expected_close_date = #{request.expectedCloseDate},
|
||||
</if>
|
||||
|
|
@ -656,6 +661,9 @@
|
|||
<if test="request.omsPushTime != null">
|
||||
oms_push_time = #{request.omsPushTime},
|
||||
</if>
|
||||
<if test="request.isPoc != null">
|
||||
is_poc = #{request.isPoc},
|
||||
</if>
|
||||
<if test="request.description != null">
|
||||
description = nullif(#{request.description}, ''),
|
||||
</if>
|
||||
|
|
|
|||
|
|
@ -85,4 +85,34 @@ class OpportunityIntegrationControllerWebMvcTest {
|
|||
|
||||
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.OmsPreSalesOptionDTO;
|
||||
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.PushOpportunityToOmsRequest;
|
||||
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||
import com.unis.crm.mapper.OpportunityMapper;
|
||||
import com.unis.crm.service.OmsClient;
|
||||
import java.math.BigDecimal;
|
||||
|
|
@ -113,6 +115,45 @@ class OpportunityServiceImplTest {
|
|||
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
|
||||
void createOpportunity_shouldPersistEditableSnapshotFields() {
|
||||
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 | 商机名称 |
|
||||
| `projectLocation` | string | 项目地 |
|
||||
| `operatorName` | string | 运作方 |
|
||||
| `amount` | number | 商机金额,必须大于 0 |
|
||||
| `amount` | number | 商机金额,历史数据回写不再限制必须大于 0 |
|
||||
| `actualSignedAmount` | number | 实际签约金额,兼容字段名 `actual_signed_amount` |
|
||||
| `expectedCloseDate` | string | 预计结单日期,格式 `YYYY-MM-DD` |
|
||||
| `confidencePct` | string | 把握度,建议传 `A`、`B`、`C` |
|
||||
| `stage` | string | 项目阶段,建议传 CRM 当前字典码值 |
|
||||
| `opportunityType` | string | 建设类型 |
|
||||
| `productType` | string | 产品类型 |
|
||||
| `source` | string | 商机来源 |
|
||||
| `salesExpansionId` | number | 销售拓展 ID,必须大于 0 |
|
||||
| `channelExpansionId` | number | 渠道拓展 ID,必须大于 0 |
|
||||
| `preSalesId` | number | 售前 ID,必须大于 0 |
|
||||
| `salesExpansionId` | number | 销售拓展 ID,历史数据回写不再限制必须大于 0 |
|
||||
| `channelExpansionId` | number | 渠道拓展 ID,历史数据回写不再限制必须大于 0 |
|
||||
| `preSalesId` | number | 售前 ID,历史数据回写不再限制必须大于 0 |
|
||||
| `preSalesName` | string | 售前姓名 |
|
||||
| `competitorName` | string | 竞品名称 |
|
||||
| `archived` | boolean | 是否归档 |
|
||||
| `pushedToOms` | boolean | 是否已推送 OMS |
|
||||
| `omsPushTime` | string | 推送 OMS 时间,建议 ISO 8601 格式 |
|
||||
| `isPoc` | boolean | 是否 POC 测试项目,兼容字段名 `is_poc` |
|
||||
| `status` | string | 商机状态,支持 `active`、`closed`、`won`、`lost` |
|
||||
| `description` | string | 备注 |
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 除 `opportunityCode` 外,至少还要传一个更新字段
|
||||
- `opportunityCode` 仅用于定位商机,仍需传入
|
||||
- 其他字段均为可选;未传字段不修改
|
||||
- 为兼容历史数据,接口不再要求除 `opportunityCode` 外必须存在更新字段
|
||||
|
||||
## 请求示例
|
||||
|
||||
|
|
@ -92,9 +96,8 @@
|
|||
- `内部接口鉴权失败`
|
||||
- `商机不存在`
|
||||
- `opportunityCode 不能为空`
|
||||
- `至少传入一个需要更新的字段`
|
||||
- `项目阶段无效: xxx`
|
||||
- `项目把握度仅支持A、B、C`
|
||||
- `项目把握度无效: xxx`
|
||||
|
||||
## curl 示例
|
||||
|
||||
|
|
@ -112,4 +115,3 @@ curl -X PUT 'http://localhost:8080/api/opportunities/integration/update' \
|
|||
"description": "外部系统回写成交结果"
|
||||
}'
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@ export interface OpportunityItem {
|
|||
operatorCode?: string;
|
||||
operatorName?: string;
|
||||
amount?: number;
|
||||
actualSignedAmount?: number;
|
||||
date?: string;
|
||||
confidence?: string;
|
||||
stageCode?: string;
|
||||
|
|
@ -423,6 +424,7 @@ export interface OpportunityItem {
|
|||
type?: string;
|
||||
archived?: boolean;
|
||||
pushedToOms?: boolean;
|
||||
isPoc?: boolean;
|
||||
product?: string;
|
||||
source?: string;
|
||||
salesExpansionId?: number;
|
||||
|
|
@ -457,6 +459,7 @@ export interface OpportunityMeta {
|
|||
projectLocationOptions?: OpportunityDictOption[];
|
||||
opportunityTypeOptions?: OpportunityDictOption[];
|
||||
confidenceOptions?: OpportunityDictOption[];
|
||||
sysIsOptions?: OpportunityDictOption[];
|
||||
}
|
||||
|
||||
export interface OmsPreSalesOption {
|
||||
|
|
|
|||
|
|
@ -184,6 +184,14 @@ function formatOpportunityBoolean(value?: boolean, 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 {
|
||||
if (typeof window === "undefined") {
|
||||
return {};
|
||||
|
|
@ -1849,6 +1857,7 @@ export default function Opportunities() {
|
|||
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [confidenceOptions, setConfidenceOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [sysIsOptions, setSysIsOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
||||
const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined);
|
||||
const [pushPreSalesName, setPushPreSalesName] = useState("");
|
||||
|
|
@ -2079,6 +2088,7 @@ export default function Opportunities() {
|
|||
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
|
||||
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
|
||||
setConfidenceOptions((data.confidenceOptions ?? []).filter((item) => item.value));
|
||||
setSysIsOptions((data.sysIsOptions ?? []).filter((item) => item.value));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
|
|
@ -2087,6 +2097,7 @@ export default function Opportunities() {
|
|||
setProjectLocationOptions([]);
|
||||
setOpportunityTypeOptions([]);
|
||||
setConfidenceOptions([]);
|
||||
setSysIsOptions([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3498,6 +3509,13 @@ export default function Opportunities() {
|
|||
) : null}
|
||||
<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.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={getConfidenceLabel(detailItem.confidence, effectiveConfidenceOptions)} />
|
||||
<DetailItem label="项目阶段" value={detailItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
||||
|
|
@ -3516,6 +3534,9 @@ export default function Opportunities() {
|
|||
</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.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
||||
<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
|
||||
```
|
||||
|
||||
如果老环境需要补“是否 POC 测试项目”字段,请执行:
|
||||
|
||||
```bash
|
||||
psql -d your_database -f sql/upgrade_opportunity_is_poc_pg17.sql
|
||||
```
|
||||
|
||||
如果希望直接按当前版本代码一次补齐“经营分析最新结构 + 商机快照字段 + 实际签约金额字段”,可直接执行合并脚本:
|
||||
|
||||
```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_is_poc_pg17.sql`
|
||||
老环境补齐“是否 POC 测试项目”字段时使用的正式增量脚本。
|
||||
|
||||
- `sql/init_report_reminder_pg17.sql`
|
||||
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ create table if not exists crm_opportunity (
|
|||
operator_name varchar(100),
|
||||
amount numeric(18, 2) not null default 0,
|
||||
actual_signed_amount numeric(18, 2),
|
||||
is_poc boolean not null default false,
|
||||
expected_close_date date,
|
||||
confidence_pct varchar(1) not null default 'C' check (confidence_pct in ('A', 'B', 'C')),
|
||||
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 project_location 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 $$
|
||||
BEGIN
|
||||
|
|
@ -819,6 +822,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
|
|||
('crm_opportunity', 'operator_name', '运作方'),
|
||||
('crm_opportunity', 'amount', '商机金额'),
|
||||
('crm_opportunity', 'actual_signed_amount', '实际签约金额'),
|
||||
('crm_opportunity', 'is_poc', '是否POC测试项目'),
|
||||
('crm_opportunity', 'expected_close_date', '预计结单日期'),
|
||||
('crm_opportunity', 'confidence_pct', '把握度等级(A/B/C)'),
|
||||
('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