修改同步接口,添加字段

main
kangwenjing 2026-06-10 18:05:20 +08:00
parent 8dc9fe2117
commit ea61c75d58
16 changed files with 690 additions and 87 deletions

View File

@ -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);

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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}

View File

@ -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>

View File

@ -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());
}
}

View File

@ -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);

View File

@ -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=<CRMusername>
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` |

View File

@ -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": "外部系统回写成交结果"
}'
```

View File

@ -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 {

View File

@ -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" />

View File

@ -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`
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。

View File

@ -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', '商机阶段'),

View File

@ -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;