From ea61c75d586e2733df267c3eb6115e5cc67a8a39 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Wed, 10 Jun 2026 18:05:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=90=8C=E6=AD=A5=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/OpportunitySchemaInitializer.java | 2 + .../dto/opportunity/OpportunityItemDTO.java | 18 + .../dto/opportunity/OpportunityMetaDTO.java | 13 +- .../UpdateOpportunityIntegrationRequest.java | 51 +- .../service/impl/OpportunityServiceImpl.java | 73 +-- backend/src/main/resources/application.yml | 2 +- .../mapper/opportunity/OpportunityMapper.xml | 8 + ...tunityIntegrationControllerWebMvcTest.java | 30 ++ .../impl/OpportunityServiceImplTest.java | 41 ++ docs/OMS接口整理.md | 466 ++++++++++++++++++ docs/opportunity-integration-api.md | 18 +- frontend/src/lib/auth.ts | 3 + frontend/src/pages/Opportunities.tsx | 21 + sql/README.md | 9 + sql/init_full_pg17.sql | 6 +- sql/upgrade_opportunity_is_poc_pg17.sql | 16 + 16 files changed, 690 insertions(+), 87 deletions(-) create mode 100644 docs/OMS接口整理.md create mode 100644 sql/upgrade_opportunity_is_poc_pg17.sql diff --git a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java index 64341c7e..2d7ab5a1 100644 --- a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java @@ -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); diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java index e84952c4..5ae5b9fc 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java @@ -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; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java index 0faa347e..5953758a 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java @@ -9,6 +9,7 @@ public class OpportunityMetaDTO { private List projectLocationOptions; private List opportunityTypeOptions; private List confidenceOptions; + private List sysIsOptions; public OpportunityMetaDTO() { } @@ -18,12 +19,14 @@ public class OpportunityMetaDTO { List operatorOptions, List projectLocationOptions, List opportunityTypeOptions, - List confidenceOptions) { + List confidenceOptions, + List sysIsOptions) { this.stageOptions = stageOptions; this.operatorOptions = operatorOptions; this.projectLocationOptions = projectLocationOptions; this.opportunityTypeOptions = opportunityTypeOptions; this.confidenceOptions = confidenceOptions; + this.sysIsOptions = sysIsOptions; } public List getStageOptions() { @@ -65,4 +68,12 @@ public class OpportunityMetaDTO { public void setConfidenceOptions(List confidenceOptions) { this.confidenceOptions = confidenceOptions; } + + public List getSysIsOptions() { + return sysIsOptions; + } + + public void setSysIsOptions(List sysIsOptions) { + this.sysIsOptions = sysIsOptions; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java index 015abf8f..335eb421 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java @@ -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; } diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index cac7399b..10215301 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -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 buildDefaultOpportunityTypeOptions() { @@ -75,6 +77,15 @@ public class OpportunityServiceImpl implements OpportunityService { buildDictOption("C", "C")); } + private List buildSysIsOptions(List 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); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 5c5b6f70..0999bc43 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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} diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index aa3bc6e1..a7cdde1a 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -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 @@ amount = #{request.amount}, + + actual_signed_amount = #{request.actualSignedAmount}, + expected_close_date = #{request.expectedCloseDate}, @@ -656,6 +661,9 @@ oms_push_time = #{request.omsPushTime}, + + is_poc = #{request.isPoc}, + description = nullif(#{request.description}, ''), diff --git a/backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java b/backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java index 9e7b1fe7..28c4149e 100644 --- a/backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java +++ b/backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java @@ -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()); + } } diff --git a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java index e9b2c0b6..09222e80 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java @@ -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); diff --git a/docs/OMS接口整理.md b/docs/OMS接口整理.md new file mode 100644 index 00000000..d4e47555 --- /dev/null +++ b/docs/OMS接口整理.md @@ -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`,成功时 `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= +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`,成功时 `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` | diff --git a/docs/opportunity-integration-api.md b/docs/opportunity-integration-api.md index cbbb13eb..b10309fd 100644 --- a/docs/opportunity-integration-api.md +++ b/docs/opportunity-integration-api.md @@ -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": "外部系统回写成交结果" }' ``` - diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 00b6ff54..8024c765 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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 { diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx index a8aca8dd..6222ed37 100644 --- a/frontend/src/pages/Opportunities.tsx +++ b/frontend/src/pages/Opportunities.tsx @@ -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([]); const [opportunityTypeOptions, setOpportunityTypeOptions] = useState([]); const [confidenceOptions, setConfidenceOptions] = useState([]); + const [sysIsOptions, setSysIsOptions] = useState([]); const [form, setForm] = useState(defaultForm); const [pushPreSalesId, setPushPreSalesId] = useState(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} } /> ¥{formatAmount(detailItem.amount)}} icon={} /> + {detailItem.archived ? ( + ¥{formatAmount(detailItem.actualSignedAmount)}} + icon={} + /> + ) : null} } /> } /> @@ -3516,6 +3534,9 @@ export default function Opportunities() { )} /> + {detailItem.pushedToOms || detailItem.archived ? ( + } /> + ) : null} } /> diff --git a/sql/README.md b/sql/README.md index 2e241b61..f4e2964f 100644 --- a/sql/README.md +++ b/sql/README.md @@ -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` 老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。 diff --git a/sql/init_full_pg17.sql b/sql/init_full_pg17.sql index f6ce3755..a70041d0 100644 --- a/sql/init_full_pg17.sql +++ b/sql/init_full_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', '商机阶段'), diff --git a/sql/upgrade_opportunity_is_poc_pg17.sql b/sql/upgrade_opportunity_is_poc_pg17.sql new file mode 100644 index 00000000..9c04dd4a --- /dev/null +++ b/sql/upgrade_opportunity_is_poc_pg17.sql @@ -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;