feat(intake): prefill create form from oa opening code

This commit is contained in:
WuYongJie 2026-06-30 16:25:09 +08:00
parent 70594a287d
commit 6d89e1c2c3
6 changed files with 468 additions and 10 deletions

View File

@ -39,6 +39,7 @@ import { useSeriesComponentAssetSkuMerge } from "./workspaces/basic-info/useSeri
import { useSeriesComponentDerivedSkuState } from "./workspaces/basic-info/useSeriesComponentDerivedSkuState";
import { useSeriesComponentPreviewImageSync } from "./workspaces/basic-info/useSeriesComponentPreviewImageSync";
import { useSkuImageUploadController } from "./workspaces/basic-info/useSkuImageUploadController";
import { useOpeningCodeOaPrefill } from "./workspaces/basic-info/useOpeningCodeOaPrefill";
import { buildFirstQualityWorkspaceRenderers } from "./workspaces/first-quality/firstQualityWorkspaceRenderers";
import { usePatentLedgerEntryController } from "./workspaces/patent/usePatentLedgerEntryController";
import { useRawMaterialProcessConfigEntryController } from "./workspaces/useRawMaterialProcessConfigEntryController";
@ -2783,6 +2784,25 @@ export default function NonIntegratedDetailClient({
setSpuCreateMode,
setStockProductSpu,
});
const {
handleOpeningCodeCommit,
handleOpeningCodeInputChange,
openingCodeLookupMessage,
openingCodeLookupState,
} = useOpeningCodeOaPrefill({
enabled:
mode === "create" &&
!isReadOnly &&
!isSeriesDevelopment &&
!isSeriesComponentCreate &&
!isStockProductCreate,
formValuesRef,
openingReferenceRef,
refreshSpuEstimate,
scheduleAutoSave,
setFormValuesAndRef,
setOpeningReferenceAndRef,
});
const {
applySkuSupplierName,
@ -4817,6 +4837,8 @@ export default function NonIntegratedDetailClient({
isSeriesDevelopment={isSeriesDevelopment}
isStockProductCreate={isStockProductCreate}
openingReference={openingReference}
openingCodeLookupMessage={openingCodeLookupMessage}
openingCodeLookupState={openingCodeLookupState}
previewFileInputRef={previewFileInputRef}
canUseExistingSpuCreateMode={
canUseExistingSpuCreateMode
@ -4836,6 +4858,8 @@ export default function NonIntegratedDetailClient({
stockProductSpu={stockProductSpu}
onAdaptableBrandsChange={updateAdaptableBrands}
onExistingSpuSelection={handleExistingSpuSelection}
onOpeningCodeCommit={handleOpeningCodeCommit}
onOpeningCodeInputChange={handleOpeningCodeInputChange}
onOpeningReferenceFieldChange={
handleOpeningReferenceFieldChange
}

View File

@ -44,6 +44,7 @@ export function BasicField({
searchableSelect = false,
includeEmptyOption = false,
clearable = false,
onCommit,
onChange,
}: {
label: string;
@ -57,6 +58,7 @@ export function BasicField({
searchableSelect?: boolean;
includeEmptyOption?: boolean;
clearable?: boolean;
onCommit?: () => void;
onChange: (value: string) => void;
}) {
if (type === "select") {
@ -150,6 +152,7 @@ export function BasicField({
placeholder={placeholder}
readOnly={readOnly}
filterOptions={Boolean((options ?? []).length)}
onCommit={onCommit}
onChange={onChange}
inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none ${
!readOnly && (options ?? []).length > 0 ? "pr-9" : ""

View File

@ -69,6 +69,39 @@ export type NonIntegratedProductListParams = {
stockDataScope?: string
}
export type OaBoardProjectLookupItem = {
title: string
productName: string | null
brand: string | null
productCategory: string | null
categoryName: string | null
openingCode: string | null
currentStageName: string | null
currentApprovalStageName: string | null
styleImages: Array<{
url: string
alt: string
}>
formSummary: {
openingCode: string
demandType: string
category: string
categoryName: string
productPlanner: string
operationBrandOwner: string
spuOwner: string
appearanceDesigner: string
structureDesigner: string
purchaseOwner: string
merchandiserOwner: string
competitorLink: string
openingReason: string
salesPriceBand: string
platformPriceBand: string
openingBrand: string
}
}
export type GenerateNonIntegratedAiWhiteBackgroundSessionPayload = {
sessionId?: string
styleKey: string
@ -336,6 +369,28 @@ export async function listSharedNonIntegratedProductPage(
}>(response)
}
export async function listOaBoardProjects(params: {
approvalNumber?: string
keyword?: string
} = {}) {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (!value?.trim()) return
searchParams.set(key, value.trim())
})
const query = searchParams.toString()
const response = await fetchWithTimeout(`/api/intake/oa-board${query ? `?${query}` : ''}`, {
method: 'GET',
cache: 'no-store',
})
return readApiData<{
items: OaBoardProjectLookupItem[]
}>(response)
}
export async function listSharedNonIntegratedStockCategoryOptions() {
const response = await fetchWithTimeout('/api/intake/non-integrated/stock-category-options', {
method: 'GET',

View File

@ -1,4 +1,5 @@
import type { ChangeEvent, RefObject } from "react";
import { Loader2 } from "lucide-react";
import { AdaptableBrandField } from "../../components/BrandControls";
import { BasicField } from "../../components/FormControls";
@ -37,6 +38,8 @@ export function BasicInfoCreateSection({
isSeriesDevelopment,
isStockProductCreate,
openingReference,
openingCodeLookupMessage,
openingCodeLookupState,
previewFileInputRef,
canUseExistingSpuCreateMode,
createSpuDisplayLabel,
@ -49,6 +52,8 @@ export function BasicInfoCreateSection({
stockProductSpu,
onAdaptableBrandsChange,
onExistingSpuSelection,
onOpeningCodeCommit,
onOpeningCodeInputChange,
onOpeningReferenceFieldChange,
onPreviewImageChange,
onPreviewProductImage,
@ -72,6 +77,13 @@ export function BasicInfoCreateSection({
isSeriesDevelopment: boolean;
isStockProductCreate: boolean;
openingReference: NonIntegratedOpeningReference;
openingCodeLookupMessage?: string;
openingCodeLookupState?:
| "idle"
| "loading"
| "success"
| "missing"
| "error";
previewFileInputRef: RefObject<HTMLInputElement | null>;
canUseExistingSpuCreateMode: boolean;
createSpuDisplayLabel: string;
@ -84,6 +96,8 @@ export function BasicInfoCreateSection({
stockProductSpu: string;
onAdaptableBrandsChange: (value: string[]) => void;
onExistingSpuSelection: (value: string) => void;
onOpeningCodeCommit?: () => void;
onOpeningCodeInputChange?: (value: string) => void;
onOpeningReferenceFieldChange: (
field: OpeningReferenceFieldKey,
value: string,
@ -98,6 +112,14 @@ export function BasicInfoCreateSection({
}) {
const handlePreview = () =>
formValues.previewImage ? onPreviewProductImage() : undefined;
const openingCodeLookupMessageClassName =
openingCodeLookupState === "error"
? "text-[#a32135]"
: openingCodeLookupState === "success"
? "text-[#156b3f]"
: openingCodeLookupState === "missing"
? "text-[#8a5a00]"
: "text-apple-label-secondary";
return (
<SectionCard title={developmentCreateTitle} compact>
@ -183,16 +205,38 @@ export function BasicInfoCreateSection({
readOnly={isReadOnly}
/>
</div>
<BasicField
label="开款编号"
placeholder="请输入开款编号"
value={openingReference.temporaryCode}
readOnly={isReadOnly}
compact
onChange={(value) =>
onOpeningReferenceFieldChange("temporaryCode", value)
}
/>
<div className="space-y-1">
<BasicField
label="开款编号"
placeholder="请输入开款编号"
value={openingReference.temporaryCode}
readOnly={isReadOnly}
compact
onCommit={onOpeningCodeCommit}
onChange={(value) => {
onOpeningReferenceFieldChange("temporaryCode", value);
onOpeningCodeInputChange?.(value);
}}
/>
{openingCodeLookupState === "loading" ? (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] leading-4 text-apple-label-secondary">
<Loader2 className="h-3 w-3 animate-spin" />
<span>正在从 OA 新品流程看板带入信息...</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-apple-bg-tertiary">
<div className="h-full w-2/5 rounded-full bg-[#5b7c83] animate-pulse" />
</div>
</div>
) : null}
{openingCodeLookupState !== "loading" && openingCodeLookupMessage ? (
<p
className={`text-[11px] leading-4 ${openingCodeLookupMessageClassName}`}
>
{openingCodeLookupMessage}
</p>
) : null}
</div>
<BasicField
label={developmentNameLabel}
required

View File

@ -0,0 +1,150 @@
import {
NON_INTEGRATED_DEFAULT_BRAND,
normalizeNonIntegratedBrand,
normalizeNonIntegratedBrandList,
type NonIntegratedOpeningReference,
} from "../../nonIntegratedStorage";
import type { OaBoardProjectLookupItem } from "../../core/nonIntegratedApi";
import type { FormValues } from "./productFormValueHelpers";
import { BRAND_OPTIONS } from "./basicInfoConfig";
type OaBoardImportPayload = {
formValues: FormValues;
openingReference: NonIntegratedOpeningReference;
};
function pickFirstNonEmpty(...values: Array<string | null | undefined>) {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
}
function resolveImportedBrand(
currentBrand: string,
...brandCandidates: Array<string | null | undefined>
) {
const rawBrand = pickFirstNonEmpty(...brandCandidates);
const normalizedBrand = normalizeNonIntegratedBrand(rawBrand);
if (normalizedBrand && BRAND_OPTIONS.includes(normalizedBrand)) {
return normalizedBrand;
}
const fuzzyMatchedBrand = BRAND_OPTIONS.find((option) =>
rawBrand ? option.includes(rawBrand) : false,
);
if (fuzzyMatchedBrand) {
return fuzzyMatchedBrand;
}
return currentBrand || NON_INTEGRATED_DEFAULT_BRAND;
}
export function buildOaBoardOpeningCodeImportPayload(
item: OaBoardProjectLookupItem,
current: OaBoardImportPayload,
): OaBoardImportPayload {
const styleImage = item.styleImages[0];
const normalizedBrand = resolveImportedBrand(
current.formValues.brand,
item.formSummary.openingBrand,
item.brand,
);
const nextAdaptableBrands = normalizeNonIntegratedBrandList([
normalizedBrand,
...current.formValues.adaptableBrands,
...current.openingReference.adaptableBrands,
]).filter((brand) => BRAND_OPTIONS.includes(brand));
const nextSalesCategory = pickFirstNonEmpty(
item.formSummary.category,
item.productCategory,
);
const nextProductCategory = pickFirstNonEmpty(
item.formSummary.categoryName,
item.categoryName,
);
const nextProductName = pickFirstNonEmpty(item.productName, item.title);
const nextTargetPriceBand = pickFirstNonEmpty(
item.formSummary.salesPriceBand,
item.formSummary.platformPriceBand,
);
const nextPurchaseOwner = pickFirstNonEmpty(
item.formSummary.purchaseOwner,
item.formSummary.merchandiserOwner,
);
const nextCurrentStage = pickFirstNonEmpty(
item.currentApprovalStageName,
item.currentStageName,
);
return {
openingReference: {
...current.openingReference,
temporaryCode:
pickFirstNonEmpty(item.formSummary.openingCode, item.openingCode) ||
current.openingReference.temporaryCode,
productImage: pickFirstNonEmpty(styleImage?.url) || current.openingReference.productImage,
productImageName:
pickFirstNonEmpty(styleImage?.alt) || current.openingReference.productImageName,
productName: nextProductName || current.openingReference.productName,
brand: normalizedBrand,
adaptableBrands: nextAdaptableBrands,
salesCategory: nextSalesCategory || current.openingReference.salesCategory,
productCategory:
nextProductCategory || current.openingReference.productCategory,
designer:
pickFirstNonEmpty(item.formSummary.appearanceDesigner) ||
current.openingReference.designer,
planningOwner:
pickFirstNonEmpty(item.formSummary.productPlanner) ||
current.openingReference.planningOwner,
structureDesigner:
pickFirstNonEmpty(item.formSummary.structureDesigner) ||
current.openingReference.structureDesigner,
currentStage: nextCurrentStage || current.openingReference.currentStage,
competitorLink:
pickFirstNonEmpty(item.formSummary.competitorLink) ||
current.openingReference.competitorLink,
targetPriceBand:
nextTargetPriceBand || current.openingReference.targetPriceBand,
urgent:
pickFirstNonEmpty(item.formSummary.demandType) ||
current.openingReference.urgent,
reason:
pickFirstNonEmpty(item.formSummary.openingReason) ||
current.openingReference.reason,
},
formValues: {
...current.formValues,
previewImage: pickFirstNonEmpty(styleImage?.url) || current.formValues.previewImage,
previewImageName:
pickFirstNonEmpty(styleImage?.alt) || current.formValues.previewImageName,
brand: normalizedBrand,
adaptableBrands: nextAdaptableBrands,
salesCategory: nextSalesCategory || current.formValues.salesCategory,
productCategory:
nextProductCategory || current.formValues.productCategory,
designer:
pickFirstNonEmpty(item.formSummary.appearanceDesigner) ||
current.formValues.designer,
productPlanning:
pickFirstNonEmpty(item.formSummary.productPlanner) ||
current.formValues.productPlanning,
spuOwner:
pickFirstNonEmpty(item.formSummary.spuOwner) || current.formValues.spuOwner,
operator:
pickFirstNonEmpty(item.formSummary.operationBrandOwner) ||
current.formValues.operator,
operationsOwner:
pickFirstNonEmpty(item.formSummary.operationBrandOwner) ||
current.formValues.operationsOwner,
purchaseOwner: nextPurchaseOwner || current.formValues.purchaseOwner,
purchaser: nextPurchaseOwner || current.formValues.purchaser,
priceBand: nextTargetPriceBand || current.formValues.priceBand,
},
};
}

View File

@ -0,0 +1,182 @@
import { useCallback, useRef, useState, type MutableRefObject } from "react";
import type { FormValues } from "./productFormValueHelpers";
import type { NonIntegratedOpeningReference } from "../../nonIntegratedStorage";
import {
listOaBoardProjects,
type OaBoardProjectLookupItem,
} from "../../core/nonIntegratedApi";
import { buildOaBoardOpeningCodeImportPayload } from "./oaBoardOpeningCodeImport";
type LookupState = "idle" | "loading" | "success" | "missing" | "error";
type UseOpeningCodeOaPrefillParams = {
enabled: boolean;
formValuesRef: MutableRefObject<FormValues>;
openingReferenceRef: MutableRefObject<NonIntegratedOpeningReference>;
refreshSpuEstimate: () => void;
scheduleAutoSave: () => void;
setFormValuesAndRef: (updater: (current: FormValues) => FormValues) => void;
setOpeningReferenceAndRef: (
updater: (
current: NonIntegratedOpeningReference,
) => NonIntegratedOpeningReference,
) => void;
};
function normalizeOpeningCode(value: string | null | undefined) {
return String(value ?? "").trim().toUpperCase();
}
function matchesOpeningCode(
item: OaBoardProjectLookupItem,
openingCode: string,
) {
return [
item.formSummary.openingCode,
item.openingCode,
].some((value) => normalizeOpeningCode(value) === openingCode);
}
function resolveMatchingOaBoardItem(
items: OaBoardProjectLookupItem[],
openingCode: string,
) {
const exactMatches = items.filter((item) => matchesOpeningCode(item, openingCode));
if (exactMatches.length === 1) {
return exactMatches[0];
}
if (exactMatches.length > 1) {
throw new Error("这个开款编号在 OA 看板里查到了多条记录,请先人工确认。");
}
return items.length === 1 ? items[0] : null;
}
export function useOpeningCodeOaPrefill({
enabled,
formValuesRef,
openingReferenceRef,
refreshSpuEstimate,
scheduleAutoSave,
setFormValuesAndRef,
setOpeningReferenceAndRef,
}: UseOpeningCodeOaPrefillParams) {
const [lookupState, setLookupState] = useState<LookupState>("idle");
const [lookupMessage, setLookupMessage] = useState("");
const lastImportedOpeningCodeRef = useRef("");
const activeRequestIdRef = useRef(0);
const handleOpeningCodeInputChange = useCallback((value: string) => {
const normalizedValue = normalizeOpeningCode(value);
if (!normalizedValue) {
lastImportedOpeningCodeRef.current = "";
setLookupState("idle");
setLookupMessage("");
return;
}
if (normalizedValue !== lastImportedOpeningCodeRef.current) {
setLookupState("idle");
setLookupMessage("");
}
}, []);
const handleOpeningCodeCommit = useCallback(async () => {
if (!enabled) return;
const openingCode = normalizeOpeningCode(
openingReferenceRef.current.temporaryCode,
);
if (!openingCode) {
lastImportedOpeningCodeRef.current = "";
setLookupState("idle");
setLookupMessage("");
return;
}
if (openingCode === lastImportedOpeningCodeRef.current) {
return;
}
const requestId = activeRequestIdRef.current + 1;
activeRequestIdRef.current = requestId;
setLookupState("loading");
setLookupMessage("正在从 OA 新品流程看板带入信息...");
try {
const { items } = await listOaBoardProjects({
approvalNumber: openingCode,
});
if (activeRequestIdRef.current !== requestId) {
return;
}
if (
normalizeOpeningCode(openingReferenceRef.current.temporaryCode) !==
openingCode
) {
return;
}
const matchedItem = resolveMatchingOaBoardItem(items, openingCode);
if (!matchedItem) {
lastImportedOpeningCodeRef.current = "";
setLookupState("missing");
setLookupMessage("未找到对应 OA 开款信息,先手动填写也可以。");
return;
}
const nextState = buildOaBoardOpeningCodeImportPayload(matchedItem, {
formValues: formValuesRef.current,
openingReference: openingReferenceRef.current,
});
const importedDisplayName =
nextState.openingReference.productName.trim() || openingCode;
setFormValuesAndRef(() => nextState.formValues);
setOpeningReferenceAndRef(() => nextState.openingReference);
refreshSpuEstimate();
scheduleAutoSave();
lastImportedOpeningCodeRef.current = openingCode;
setLookupState("success");
setLookupMessage(`已带入「${importedDisplayName}」的 OA 信息。`);
} catch (error) {
if (activeRequestIdRef.current !== requestId) {
return;
}
if (
normalizeOpeningCode(openingReferenceRef.current.temporaryCode) !==
openingCode
) {
return;
}
setLookupState("error");
setLookupMessage(
error instanceof Error ? error.message : "读取 OA 信息失败,请稍后重试。",
);
}
}, [
enabled,
formValuesRef,
openingReferenceRef,
refreshSpuEstimate,
scheduleAutoSave,
setFormValuesAndRef,
setOpeningReferenceAndRef,
]);
return {
handleOpeningCodeCommit,
handleOpeningCodeInputChange,
openingCodeLookupMessage: lookupMessage,
openingCodeLookupState: lookupState,
};
}