Compare commits

...

1 Commits

Author SHA1 Message Date
WuYongJie
15c082a712 fix(intake): require directory selections for suppliers and warehouses 2026-08-12 17:17:51 +08:00
11 changed files with 335 additions and 64 deletions

View File

@ -501,6 +501,7 @@ export function SearchableInlineSelect({
emptyOptionDisabled = false,
maxVisibleOptions,
onQueryChange,
clearValueOnUnselectedQuery = false,
onChange,
}: {
ariaLabel: string;
@ -517,6 +518,7 @@ export function SearchableInlineSelect({
emptyOptionDisabled?: boolean;
maxVisibleOptions?: number;
onQueryChange?: (value: string) => void;
clearValueOnUnselectedQuery?: boolean;
onChange: (value: string) => void;
}) {
const listId = useId();
@ -525,6 +527,7 @@ export function SearchableInlineSelect({
const inputRef = useRef<HTMLInputElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [hasUnselectedQuery, setHasUnselectedQuery] = useState(false);
const normalizedOptions = normalizeInlineSelectOptions(options);
const shouldIncludeEmptyOption = includeEmptyOption ?? false;
const selectOptions = shouldIncludeEmptyOption
@ -595,6 +598,7 @@ export function SearchableInlineSelect({
const openPanel = useCallback(() => {
if (!hasOptions) return;
setQuery(displayValue);
setHasUnselectedQuery(false);
setIsOpen(true);
window.setTimeout(() => inputRef.current?.select(), 0);
}, [displayValue, hasOptions]);
@ -604,6 +608,7 @@ export function SearchableInlineSelect({
if (option.disabled) return;
onChange(option.value);
setQuery(option.label);
setHasUnselectedQuery(false);
setIsOpen(false);
},
[onChange],
@ -629,11 +634,13 @@ export function SearchableInlineSelect({
if (!hasOptions) return;
const nextQuery = event.target.value;
setQuery(nextQuery);
setHasUnselectedQuery(true);
onQueryChange?.(nextQuery);
setIsOpen(true);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setHasUnselectedQuery(false);
setIsOpen(false);
return;
}
@ -650,6 +657,10 @@ export function SearchableInlineSelect({
}
}}
onBlur={() => {
if (clearValueOnUnselectedQuery && hasUnselectedQuery) {
onChange("");
}
setHasUnselectedQuery(false);
setQuery(displayValue);
setIsOpen(false);
}}

View File

@ -3,6 +3,7 @@ import { SUPPLIER_DIRECTORY } from "../supplierDirectory";
import {
InlineSelect,
InlineSuggestionInput,
SearchableInlineSelect,
} from "./InlineControls";
import { SkuColumnResizableFrame } from "./SkuTableFields";
@ -72,6 +73,7 @@ export function SkuSupplierNameField({
options = SUPPLIER_DIRECTORY.map((entry) => entry.name),
readOnly = false,
columnWidth = SKU_SUPPLIER_COLUMN_WIDTH,
requireDirectorySelection = false,
onChange,
onConfirm,
onColumnResize,
@ -80,6 +82,7 @@ export function SkuSupplierNameField({
options?: readonly string[];
readOnly?: boolean;
columnWidth?: number;
requireDirectorySelection?: boolean;
onChange: (value: string) => void;
onConfirm: () => void;
onColumnResize?: (width: number) => void;
@ -93,6 +96,22 @@ export function SkuSupplierNameField({
readOnly ? "bg-apple-bg-primary" : "bg-apple-bg-secondary"
}`}
>
{requireDirectorySelection ? (
<SearchableInlineSelect
ariaLabel={"\u4f9b\u5e94\u5546\u540d\u79f0"}
value={value}
options={options}
placeholder={"\u641c\u7d22\u4f9b\u5e94\u5546\u540d\u79f0"}
readOnly={readOnly}
maxVisibleOptions={10}
clearValueOnUnselectedQuery
onChange={onChange}
inputClassName={`h-8 w-full border-0 bg-transparent px-2 pr-8 text-xs text-apple-label-primary outline-none ${
readOnly ? "text-apple-label-secondary" : ""
}`}
panelClassName="absolute left-0 top-[calc(100%+4px)] z-50 max-h-56 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md"
/>
) : (
<InlineSuggestionInput
ariaLabel={"\u4f9b\u5e94\u5546\u540d\u79f0"}
value={value}
@ -107,6 +126,7 @@ export function SkuSupplierNameField({
}`}
panelClassName="absolute left-0 top-[calc(100%+4px)] z-50 max-h-56 min-w-full overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md"
/>
)}
</SkuColumnResizableFrame>
);
}
@ -124,31 +144,6 @@ export function FactoryWarehouseField({
columnWidth?: number;
onChange: (value: string) => void;
}) {
const handleWarehouseChange = (input: string) => {
const trimmed = input.trim();
// 精确匹配就直接用
if (options.includes(trimmed)) {
onChange(trimmed);
return;
}
// 自动纠正:如果输入不完整,尝试匹配唯一包含的选项
// 例如输入"河北源沃代发仓",自动匹配为"工厂-河北源沃代发仓"
const normalizedInput = trimmed.toLowerCase();
const containsMatches = options.filter((option) =>
option.toLowerCase().includes(normalizedInput),
);
if (containsMatches.length === 1) {
onChange(containsMatches[0]);
return;
}
// 没匹配到就原样传,让用户自己处理
onChange(trimmed);
};
return (
<SkuColumnResizableFrame
columnWidth={columnWidth}
@ -157,14 +152,15 @@ export function FactoryWarehouseField({
readOnly ? "bg-apple-bg-primary" : "bg-apple-bg-secondary"
}`}
>
<InlineSuggestionInput
<SearchableInlineSelect
ariaLabel={"\u4ed3\u5e93"}
value={value}
options={options}
placeholder={"\u641c\u7d22\u4ed3\u5e93"}
readOnly={readOnly}
filterOptions
onChange={handleWarehouseChange}
maxVisibleOptions={10}
clearValueOnUnselectedQuery
onChange={onChange}
inputClassName={`h-8 w-full border-0 bg-transparent px-2 pr-8 text-xs text-apple-label-primary outline-none ${
readOnly ? "text-apple-label-secondary" : ""
}`}

View File

@ -432,6 +432,7 @@ export function SkuInfoRow({
options={supplierNameOptions}
readOnly={skuRowReadOnly}
columnWidth={skuColumnWidths.supplierName}
requireDirectorySelection
onChange={(value) => onUpdateSkuSupplierName(item.id, value)}
onConfirm={() => void onConfirmSkuSupplierName(item.id)}
onColumnResize={(width) => onColumnResize("supplierName", width)}

View File

@ -36,6 +36,22 @@ export function normalizeFactorySupplier(
};
}
export function normalizeProductFactorySupplier(
value: string,
supplierCodeByName: ReadonlyMap<string, string> = SUPPLIER_CODE_BY_NAME,
) {
const normalizedName = value.trim();
const matchedSupplier = resolveSupplierEntryByName(
normalizedName,
supplierCodeByName,
);
return {
name: matchedSupplier?.name ?? "",
code: matchedSupplier?.code?.trim().toUpperCase() ?? "",
};
}
export function hasPackageSharedFactoryRowContent(
row: NonIntegratedFactoryRow,
) {
@ -207,13 +223,15 @@ export function createProductFactoryRowFromSku(
const savedSupplierCode =
existingRow?.supplierCode?.trim().toUpperCase() ?? "";
const supplierSource = sku.manufacturer || savedSupplierName;
const supplier = normalizeFactorySupplier(supplierSource, supplierCodeByName);
const supplier = normalizeProductFactorySupplier(
supplierSource,
supplierCodeByName,
);
const supplierCode =
supplier.code ||
sku.manufacturerCode.trim().toUpperCase() ||
savedSupplierCode;
const supplierName =
supplierSource.trim() || supplier.name || sku.manufacturer;
const supplierName = supplier.name;
const skuCode = sku.skuCode.trim();
const fallbackBarcode =
sku.barcode.trim() || buildAutoBarcode(skuCode, supplierCode, sku.versionNo);
@ -297,13 +315,12 @@ export function normalizeFactoryRowsForPayload(
supplierCodeByName: ReadonlyMap<string, string> = SUPPLIER_CODE_BY_NAME,
): NonIntegratedFactoryRow[] {
const normalizedRows = rows.map((row) => {
const supplier = normalizeFactorySupplier(
const supplier = normalizeProductFactorySupplier(
row.supplierName,
supplierCodeByName,
);
const supplierName = supplier.name || row.supplierName.trim();
const supplierCode =
row.supplierCode.trim().toUpperCase() || supplier.code;
const supplierName = supplier.name;
const supplierCode = supplier.code;
const skuCode = row.skuCode.trim();
return {

View File

@ -186,6 +186,7 @@ export function ProductFactoryInfoTable({
value={row.supplierName}
options={supplierNameOptions}
readOnly={isFactoryFieldReadOnly(row, "supplierName")}
requireDirectorySelection
onChange={(value) =>
onUpdateRow(row.skuId, "supplierName", value)
}

View File

@ -11,12 +11,15 @@ import { buildAutoBarcode } from "../basic-info/skuAutoHelpers";
import {
buildProductFactoryRows,
normalizeFactoryRowsForPayload,
normalizeFactorySupplier,
normalizeProductFactorySupplier,
resolveAutoAwareBarcode,
} from "../basic-info/productFactoryHelpers";
import { getProductFactoryModelAutofillKey } from "../basic-info/productFactoryKeyHelpers";
import { getProductFactoryTargetSkuIds } from "./productFactoryTargetSkuHelpers";
export const resolveProductFactoryDirectorySupplier =
normalizeProductFactorySupplier;
export function updateProductFactoryRowsForSku({
autofillEntries,
currentRows,
@ -55,8 +58,11 @@ export function updateProductFactoryRowsForSku({
const versionNo = derivedSkuItem?.versionNo ?? "V01";
if (key === "supplierName") {
const supplier = normalizeFactorySupplier(value, supplierCodeByName);
const supplierName = supplier.name || value;
const supplier = resolveProductFactoryDirectorySupplier(
value,
supplierCodeByName,
);
const supplierName = supplier.name;
const supplierCode = supplier.code;
return {
@ -121,13 +127,12 @@ export function confirmProductFactorySupplierRowsForSku({
(item) => item.id === row.skuId,
);
const versionNo = derivedSkuItem?.versionNo ?? "V01";
const supplier = normalizeFactorySupplier(
const supplier = resolveProductFactoryDirectorySupplier(
row.supplierName,
supplierCodeByName,
);
const supplierName = supplier.name || row.supplierName.trim();
const supplierCode =
row.supplierCode.trim().toUpperCase() || supplier.code;
const supplierName = supplier.name;
const supplierCode = supplier.code;
return {
...row,

View File

@ -20,7 +20,6 @@ import {
import {
getProductFactoryStatusMessage,
normalizeFactoryRowsForPayload,
normalizeFactorySupplier,
} from "../basic-info/productFactoryHelpers";
import { buildSkuItemsAfterSetCountCommit } from "../basic-info/skuSetCountCommitHelpers";
import { getStyleKeyFromModel } from "../style-assets/styleFileHelpers";
@ -29,6 +28,7 @@ import type { WorkflowNodeCategory } from "../documents/documentWorkspaceHelpers
import {
confirmProductFactorySupplierRowsForSku,
reopenProductFactoryRows,
resolveProductFactoryDirectorySupplier,
updateProductFactoryRowsForSku,
} from "./productFactoryRowUpdateHelpers";
import { getProductFactoryTargetSkuIds } from "./productFactoryTargetSkuHelpers";
@ -129,8 +129,11 @@ export function useProductFactoryRowController({
if (currentFactoryRow?.confirmed) return;
if (key === "supplierName") {
const supplier = normalizeFactorySupplier(value, supplierCodeByName);
const supplierName = supplier.name || value;
const supplier = resolveProductFactoryDirectorySupplier(
value,
supplierCodeByName,
);
const supplierName = supplier.name;
setSkuItems((current) => {
const targetSkuIds = getProductFactoryTargetSkuIds(
@ -228,12 +231,11 @@ export function useProductFactoryRowController({
if (isReadOnly) return;
const currentRow = productFactoryRows.find((row) => row.skuId === skuId);
if (currentRow?.confirmed) return;
const supplier = normalizeFactorySupplier(
const supplier = resolveProductFactoryDirectorySupplier(
currentRow?.supplierName ?? "",
supplierCodeByName,
);
const supplierName =
supplier.name || currentRow?.supplierName.trim() || "";
const supplierName = supplier.name;
if (supplierName) {
setSkuItems((current) =>

View File

@ -138,6 +138,10 @@ jest.mock('../intake-non-integrated-operation-log.service', () => ({
mockRecordIntakeWorkspaceConfirmationChange(...args),
}))
jest.mock('../intake-directory-selection.service', () => ({
normalizeIntakeDirectorySelectionsForSave: jest.fn(async (product) => product),
}))
import { intakeNonIntegratedProductRepository } from '../../repositories/intake-non-integrated-product.repository'
import { userRepository } from '../../repositories/user.repository'
import { assetSkuGroupRepository, assetVariantRepository } from '../../repositories/asset-center.repository'

View File

@ -0,0 +1,142 @@
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { normalizeIntakeDirectorySelections } from "./intake-directory-selection.service";
function createProduct(
overrides: Partial<NonIntegratedProductRecord> = {},
): NonIntegratedProductRecord {
return {
skuItems: [
{
id: "sku-1",
skuSequence: 1,
batchNo: 1,
packageGroupId: "",
packageSuffix: "",
image: "",
imageName: "",
model: "",
skuCode: "SKU-1",
setCount: "1",
specName: "",
barcode: "",
size: "",
color: "",
material: "",
versionNo: "V01",
attributeCode: "",
drawingId: "",
installMethod: "",
executeStandard: "",
nationalStandardCompliant: "",
manufacturer: "",
manufacturerCode: "",
salesStatus: "",
tagMaterial: "",
model3dFileName: "",
model3dDataUrl: "",
},
],
factoryRows: [
{
skuId: "sku-1",
confirmed: false,
model: "",
skuCode: "SKU-1",
setCount: "1",
barcode: "",
specName: "",
size: "",
color: "",
material: "",
installMethod: "",
supplierName: "",
supplierCode: "",
warehouse: "",
shippingMethod: "",
minimumOrderQuantity: "",
taxIncludedUnitPrice: "",
leadTime: "",
assemblyMode: "",
productionType: "",
installationService: "",
supplierCategory: "",
purchaseOrderMethod: "",
executeStandard: "",
},
],
} as NonIntegratedProductRecord;
}
describe("intake directory selections", () => {
const directory = {
suppliers: [{ name: "阿凡达商贸", code: "N123" }],
warehouses: ["成品代发仓"],
};
it("only keeps an explicitly directory-backed supplier and warehouse", () => {
const product = createProduct({
skuItems: [
{
...createProduct().skuItems[0],
manufacturer: "阿",
manufacturerCode: "OLD",
},
],
factoryRows: [
{
...createProduct().factoryRows[0],
supplierName: "阿",
supplierCode: "OLD",
warehouse: "不存在的仓库",
},
],
});
const result = normalizeIntakeDirectorySelections(product, directory);
expect(result.skuItems[0]).toEqual(
expect.objectContaining({ manufacturer: "", manufacturerCode: "" }),
);
expect(result.factoryRows[0]?.warehouse).toBe("");
expect(result.factoryRows[0]).toEqual(
expect.objectContaining({ supplierName: "", supplierCode: "" }),
);
});
it("stores the selected directory value and its supplier code", () => {
const product = createProduct({
skuItems: [
{
...createProduct().skuItems[0],
manufacturer: "阿凡达商贸",
manufacturerCode: "OLD",
},
],
factoryRows: [
{
...createProduct().factoryRows[0],
supplierName: "阿凡达商贸",
supplierCode: "OLD",
warehouse: "成品代发仓",
},
],
});
const result = normalizeIntakeDirectorySelections(product, directory);
expect(result.skuItems[0]).toEqual(
expect.objectContaining({
manufacturer: "阿凡达商贸",
manufacturerCode: "N123",
}),
);
expect(result.factoryRows[0]?.warehouse).toBe("成品代发仓");
expect(result.factoryRows[0]).toEqual(
expect.objectContaining({
supplierName: "阿凡达商贸",
supplierCode: "N123",
}),
);
});
});

View File

@ -0,0 +1,89 @@
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import {
jstPlanningDirectoryService,
type JstPlanningDirectoryOptions,
} from "./jst-planning-directory.service";
function normalizeDirectoryKey(value: string) {
return value.trim().toLocaleLowerCase();
}
function hasDirectoryBackedValues(product: NonIntegratedProductRecord) {
return (
product.skuItems.some(
(item) => item.manufacturer.trim() || item.manufacturerCode.trim(),
) ||
product.factoryRows.some(
(row) =>
row.supplierName.trim() ||
row.supplierCode.trim() ||
row.warehouse.trim(),
)
);
}
export function normalizeIntakeDirectorySelections<
TProduct extends NonIntegratedProductRecord,
>(product: TProduct, directory: JstPlanningDirectoryOptions): TProduct {
const suppliersByName = new Map(
directory.suppliers.map((supplier) => [
normalizeDirectoryKey(supplier.name),
{
name: supplier.name.trim(),
code: supplier.code.trim().toUpperCase(),
},
]),
);
const warehousesByName = new Map(
directory.warehouses.map((warehouse) => [
normalizeDirectoryKey(warehouse),
warehouse.trim(),
]),
);
return {
...product,
skuItems: product.skuItems.map((item) => {
const supplier = suppliersByName.get(
normalizeDirectoryKey(item.manufacturer),
);
return supplier
? {
...item,
manufacturer: supplier.name,
manufacturerCode: supplier.code,
}
: {
...item,
manufacturer: "",
manufacturerCode: "",
};
}),
factoryRows: product.factoryRows.map((row) => {
const supplier = suppliersByName.get(
normalizeDirectoryKey(row.supplierName),
);
const warehouse = warehousesByName.get(
normalizeDirectoryKey(row.warehouse),
);
return {
...row,
supplierName: supplier?.name ?? "",
supplierCode: supplier?.code ?? "",
warehouse: warehouse ?? "",
};
}),
};
}
export async function normalizeIntakeDirectorySelectionsForSave<
TProduct extends NonIntegratedProductRecord,
>(product: TProduct): Promise<TProduct> {
if (!hasDirectoryBackedValues(product)) return product;
const directory = await jstPlanningDirectoryService.listOptions();
return normalizeIntakeDirectorySelections(product, directory);
}

View File

@ -95,6 +95,7 @@ import { skuQualityInspectionService } from './sku-quality-inspection.service'
import { spuNumberReservationService } from './spu-number-reservation.service'
import { supplyChainProductArchiveSyncService } from './supply-chain-product-archive-sync.service'
import { archiveChangeActorFromUser } from './supply-chain-product-archive-change-log.service'
import { normalizeIntakeDirectorySelectionsForSave } from './intake-directory-selection.service'
type NonIntegratedUploadPresentationPreview = {
pdfUrl: string
@ -6620,7 +6621,7 @@ class IntakeNonIntegratedProductService {
useExactFormalSpu: Boolean(existingFormalSpu),
},
)
const normalizedProduct = buildPersistedProductPayload(
let normalizedProduct = buildPersistedProductPayload(
assignedProductInput,
existingRecord?.payload,
existingRecord?.createdAt.toISOString(),
@ -6628,6 +6629,7 @@ class IntakeNonIntegratedProductService {
if (!normalizedProduct) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
}
normalizedProduct = await normalizeIntakeDirectorySelectionsForSave(normalizedProduct)
assertNonIntegratedProductMasterDataReadyForFormalUse(normalizedProduct, {
allowedMissingFields:
normalizedProduct.skuItems.length === 1 ? ['spec_name'] : [],
@ -6966,7 +6968,7 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
}
const normalizedProduct = buildPersistedProductPayload(
let normalizedProduct = buildPersistedProductPayload(
productInput,
existingRecordCandidate?.payload,
existingRecordCandidate?.createdAt.toISOString(),
@ -6974,6 +6976,7 @@ class IntakeNonIntegratedProductService {
if (!normalizedProduct) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
}
normalizedProduct = await normalizeIntakeDirectorySelectionsForSave(normalizedProduct)
const normalizedSpu = resolveNonIntegratedPersistedSpu(normalizedProduct)