From b56d509cc9a434b3a1be6e47f76eaa709d4689f7 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 10 Jul 2026 16:13:20 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../17-1b15b1247eed41098a90b8157e0d849d.zip | 1 + .../opportunity/CreateOpportunityRequest.java | 1 + .../dto/opportunity/OpportunityItemDTO.java | 9 ++ .../service/impl/OpportunityServiceImpl.java | 3 +- .../mapper/opportunity/OpportunityMapper.xml | 2 + .../impl/OpportunityServiceImplTest.java | 1 + frontend/src/lib/auth.ts | 1 + frontend/src/pages/Opportunities.tsx | 89 +++++++++++++++++-- frontend/src/pages/Work.tsx | 5 +- 9 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 backend/build/test-uploads/work-report-attachments/17-1b15b1247eed41098a90b8157e0d849d.zip diff --git a/backend/build/test-uploads/work-report-attachments/17-1b15b1247eed41098a90b8157e0d849d.zip b/backend/build/test-uploads/work-report-attachments/17-1b15b1247eed41098a90b8157e0d849d.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-1b15b1247eed41098a90b8157e0d849d.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java index 64c7ce30..c0b0f401 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java @@ -22,6 +22,7 @@ public class CreateOpportunityRequest { @Size(max = 100, message = "项目地不能超过100字符") private String projectLocation; + @NotBlank(message = "业绩归属地不能为空") @Size(max = 100, message = "业绩归属地不能超过100字符") private String projectOwnershipLocation; 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 5c81c21d..bf514a69 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 @@ -14,6 +14,7 @@ public class OpportunityItemDTO { private String owner; private String createdAt; private String updatedAt; + private String archivedAt; private String projectLocation; private String projectOwnershipLocation; private String projectOwnershipLocationName; @@ -111,6 +112,14 @@ public class OpportunityItemDTO { this.updatedAt = updatedAt; } + public String getArchivedAt() { + return archivedAt; + } + + public void setArchivedAt(String archivedAt) { + this.archivedAt = archivedAt; + } + public String getProjectLocation() { return projectLocation; } 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 64b532a7..f7e8766a 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 @@ -533,7 +533,8 @@ public class OpportunityServiceImpl implements OpportunityService { request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空")); request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空")); request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请选择项目地")); - request.setProjectOwnershipLocation(normalizeProjectOwnershipLocationCode(request.getProjectOwnershipLocation())); + request.setProjectOwnershipLocation(normalizeProjectOwnershipLocationCode( + normalizeRequiredText(request.getProjectOwnershipLocation(), "请选择业绩归属地"))); request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方")); request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额")); request.setDescription(normalizeOptionalText(request.getDescription())); diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index b3a52e7a..0abb7002 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -134,6 +134,7 @@ ), '无' ) as updatedAt, + coalesce(to_char(o.archived_at, 'YYYY-MM-DD HH24:MI'), '') as archivedAt, coalesce(o.project_location, '') as projectLocation, coalesce(o.project_ownership_location, '') as projectOwnershipLocation, coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as projectOwnershipLocationName, @@ -296,6 +297,7 @@ ), '无' ) as updatedAt, + coalesce(to_char(o.archived_at, 'YYYY-MM-DD HH24:MI'), '') as archivedAt, coalesce(o.project_location, '') as projectLocation, coalesce(o.project_ownership_location, '') as projectOwnershipLocation, coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as projectOwnershipLocationName, 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 a240be9b..e1112848 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 @@ -297,6 +297,7 @@ class OpportunityServiceImplTest { request.setCustomerName(customerName); request.setOpportunityName("商机A"); request.setProjectLocation("上海"); + request.setProjectOwnershipLocation("上海"); request.setOperatorName("直营"); request.setAmount(new BigDecimal("100")); request.setExpectedCloseDate(LocalDate.of(2026, 4, 30)); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 4d4f20e6..3e20b088 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -472,6 +472,7 @@ export interface OpportunityItem { owner?: string; createdAt?: string; updatedAt?: string; + archivedAt?: string; projectLocation?: string; projectOwnershipLocation?: string; projectOwnershipLocationName?: string; diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx index 495f20f2..df7d8712 100644 --- a/frontend/src/pages/Opportunities.tsx +++ b/frontend/src/pages/Opportunities.tsx @@ -137,6 +137,7 @@ type OpportunityExportFieldKey = | "owner" | "createdAt" | "updatedAt" + | "archivedAt" | "archived" | "pushedToOms"; type OpportunityExportColumn = { @@ -328,6 +329,7 @@ const opportunityExportColumns: OpportunityExportColumn[] = [ { key: "owner", label: "创建人", value: (item) => normalizeOpportunityExportText(item.owner) }, { key: "createdAt", label: "创建时间", value: (item) => normalizeOpportunityExportText(item.createdAt) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeOpportunityExportText(item.updatedAt) }, + { key: "archivedAt", label: "签单时间", value: (item) => normalizeOpportunityExportText(item.archivedAt) }, { key: "archived", label: "是否签单", value: (item) => formatOpportunityBoolean(item.archived, "已签单", "未签单") }, { key: "pushedToOms", label: "是否推送OMS", value: (item) => formatOpportunityBoolean(item.pushedToOms, "已推送", "未推送") }, { key: "actualSignedAmount", label: "实际签约金额(元)", numFmt: "#,##0.00", value: (item) => normalizeOpportunityExportNumber(item.actualSignedAmount) ?? "" }, @@ -811,6 +813,9 @@ function validateOpportunityForm( if (!form.projectLocation?.trim()) { errors.projectLocation = "请选择项目地"; } + if (!form.projectOwnershipLocation?.trim()) { + errors.projectOwnershipLocation = "请选择业绩归属地"; + } if (!form.opportunityName?.trim()) { errors.opportunityName = "请填写项目名称"; } @@ -1900,6 +1905,8 @@ export default function Opportunities() { const [items, setItems] = useState([]); const [salesExpansionOptions, setSalesExpansionOptions] = useState([]); const [channelExpansionOptions, setChannelExpansionOptions] = useState([]); + const [selectedSalesExpansionOption, setSelectedSalesExpansionOption] = useState(null); + const [selectedChannelExpansionOption, setSelectedChannelExpansionOption] = useState(null); const [omsPreSalesOptions, setOmsPreSalesOptions] = useState([]); const [stageOptions, setStageOptions] = useState([]); const [operatorOptions, setOperatorOptions] = useState([]); @@ -2390,11 +2397,29 @@ export default function Opportunities() { const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both"; const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both"; const showCustomCompetitorInput = selectedCompetitors.includes("其他"); - const selectedSalesExpansionFallbackOption = form.salesExpansionId && selectedSalesExpansionName - ? { value: form.salesExpansionId, label: selectedSalesExpansionName, keywords: [] } + const formSalesExpansionOption = form.salesExpansionId + ? salesExpansionOptions.find((item) => item.id === form.salesExpansionId) ?? null : null; - const selectedChannelExpansionFallbackOption = form.channelExpansionId && selectedChannelExpansionName - ? { value: form.channelExpansionId, label: selectedChannelExpansionName, keywords: [] } + const formChannelExpansionOption = form.channelExpansionId + ? channelExpansionOptions.find((item) => item.id === form.channelExpansionId) ?? null + : null; + const selectedSalesExpansionFallbackOption = formSalesExpansionOption + ? { + value: formSalesExpansionOption.id, + label: formatSalesExpansionOptionLabel(formSalesExpansionOption), + keywords: [formSalesExpansionOption.employeeNo || "", formSalesExpansionOption.officeName || "", formSalesExpansionOption.phone || "", formSalesExpansionOption.title || ""], + } + : form.salesExpansionId && selectedSalesExpansionOption?.value === form.salesExpansionId + ? selectedSalesExpansionOption + : null; + const selectedChannelExpansionFallbackOption = formChannelExpansionOption + ? { + value: formChannelExpansionOption.id, + label: formChannelExpansionOption.name || `渠道#${formChannelExpansionOption.id}`, + keywords: [formChannelExpansionOption.channelCode || "", formChannelExpansionOption.province || "", formChannelExpansionOption.primaryContactName || "", formChannelExpansionOption.primaryContactMobile || ""], + } + : form.channelExpansionId && selectedChannelExpansionOption?.value === form.channelExpansionId + ? selectedChannelExpansionOption : null; const salesExpansionSearchOptions: SearchableOption[] = appendSearchableOptionIfMissing(salesExpansionOptions.map((item) => ({ value: item.id, @@ -2569,6 +2594,20 @@ export default function Opportunities() { } }; + const handleSalesExpansionChange = (value?: number) => { + setSelectedSalesExpansionOption( + value ? salesExpansionSearchOptions.find((option) => option.value === value) ?? null : null, + ); + handleChange("salesExpansionId", value); + }; + + const handleChannelExpansionChange = (value?: number) => { + setSelectedChannelExpansionOption( + value ? channelExpansionSearchOptions.find((option) => option.value === value) ?? null : null, + ); + handleChange("channelExpansionId", value); + }; + const handleQuickSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setQuickSalesForm((current) => ({ ...current, [key]: value })); if (key in quickSalesFieldErrors) { @@ -2705,9 +2744,25 @@ export default function Opportunities() { const latestOverview = await refreshExpansionOptions(); if (quickCreateType === "sales") { const createdItem = (latestOverview.salesItems ?? []).find((item) => item.id === createdId); + const createdOption = createdItem + ? { + value: createdItem.id, + label: formatSalesExpansionOptionLabel(createdItem), + keywords: [createdItem.employeeNo || "", createdItem.officeName || "", createdItem.phone || "", createdItem.title || ""], + } + : { value: createdId, label: quickSalesForm.candidateName?.trim() || `拓展人员#${createdId}`, keywords: [] }; + setSelectedSalesExpansionOption(createdOption); handleChange("salesExpansionId", createdItem?.id ?? createdId); } else { const createdItem = (latestOverview.channelItems ?? []).find((item) => item.id === createdId); + const createdOption = createdItem + ? { + value: createdItem.id, + label: createdItem.name || `渠道#${createdItem.id}`, + keywords: [createdItem.channelCode || "", createdItem.province || "", createdItem.primaryContactName || "", createdItem.primaryContactMobile || ""], + } + : { value: createdId, label: quickChannelForm.channelName?.trim() || `渠道#${createdId}`, keywords: [] }; + setSelectedChannelExpansionOption(createdOption); handleChange("channelExpansionId", createdItem?.id ?? createdId); } resetQuickCreateState(); @@ -2721,6 +2776,8 @@ export default function Opportunities() { setError(""); setFieldErrors({}); setForm(buildEmptyForm()); + setSelectedSalesExpansionOption(null); + setSelectedChannelExpansionOption(null); setSelectedCompetitors([]); setCustomCompetitorName(""); setCreateOpen(true); @@ -2733,6 +2790,8 @@ export default function Opportunities() { setError(""); setFieldErrors({}); setForm(buildEmptyForm()); + setSelectedSalesExpansionOption(null); + setSelectedChannelExpansionOption(null); setSelectedCompetitors([]); setCustomCompetitorName(""); }; @@ -2787,6 +2846,16 @@ export default function Opportunities() { setError(""); setFieldErrors({}); setForm(toFormFromItem(sourceItem, effectiveConfidenceOptions)); + setSelectedSalesExpansionOption(sourceItem.salesExpansionId && sourceItem.salesExpansionName ? { + value: sourceItem.salesExpansionId, + label: sourceItem.salesExpansionName, + keywords: [], + } : null); + setSelectedChannelExpansionOption(sourceItem.channelExpansionId && sourceItem.channelExpansionName ? { + value: sourceItem.channelExpansionId, + label: sourceItem.channelExpansionName, + keywords: [], + } : null); const competitorState = parseCompetitorState(sourceItem.competitorName); setSelectedCompetitors(competitorState.selections); setCustomCompetitorName(competitorState.customName); @@ -3034,6 +3103,8 @@ export default function Opportunities() { {visibleItems.length > 0 ? ( visibleItems.map((opp, i) => { const isOwnedByCurrentUser = currentUserId !== undefined && opp.ownerUserId === currentUserId; + const amountLabel = archiveTab === "archived" ? "实际签约金额" : "预计金额"; + const displayAmount = archiveTab === "archived" ? opp.actualSignedAmount : opp.amount; return ( {opp.date || "待定"}
- 预计金额: - ¥{formatAmount(opp.amount)} + {amountLabel}: + ¥{formatAmount(displayAmount)}
项目最新进展: @@ -3212,7 +3283,7 @@ export default function Opportunities() { {fieldErrors.projectLocation ?

{fieldErrors.projectLocation}

: null}