feat(intake): add product factory workbook export and import by sku code

the product factory workspace gains export/import template buttons (purchase/merchandiser already hold node download grants): the export brings out the sku code, set count and every factory fillable field, splitting set count 2 rows into their A/B package codes; the import matches rows by sku code, enforces a header layout identical to the exported template, validates supplier names against the supplier directory, warehouses against the warehouse directory, enum fields against system options and number/date formats, collapses or splits A/B packages when the imported set count changes, and rejects confirmed rows - any row error aborts the whole import with per-row line numbers and reasons. Size, color and material columns are reference only.
This commit is contained in:
WuYongJie 2026-09-07 17:12:59 +08:00
parent d4d26c43bf
commit e9f83b16ee
14 changed files with 1158 additions and 0 deletions

View File

@ -37,6 +37,7 @@ describe("buildNonIntegratedDetailChrome", () => {
permissionState: {},
productContext: {},
productFactoryActions: {},
productFactoryWorkbook: {},
productLimitWorkbook: {},
productPricingWorkbook: {},
productPhotoUploadActions: {},

View File

@ -63,6 +63,7 @@ type DetailChromeProps = Required<
permissionState: ProductSetupState["permissionState"];
productContext: ProductSetupState["productContext"];
productFactoryActions: FormActionState["productFactoryActions"];
productFactoryWorkbook: SkuReviewActionState["productFactoryWorkbook"];
productLimitWorkbook: SkuReviewActionState["productLimitWorkbook"];
productPricingWorkbook: SkuReviewActionState["productPricingWorkbook"];
productPhotoUploadActions: FileActionState["productPhotoUploadActions"];
@ -110,6 +111,7 @@ export function buildNonIntegratedDetailChrome({
permissionState,
productContext,
productFactoryActions,
productFactoryWorkbook,
productLimitWorkbook,
productPricingWorkbook,
productPhotoUploadActions,
@ -150,6 +152,8 @@ export function buildNonIntegratedDetailChrome({
onDownloadUploadFile: documentDownloads.handleDownloadUploadFile,
onDownloadWorkspaceFiles:
documentDownloads.handleDownloadWorkspaceFiles,
onExportProductFactoryWorkbook:
productFactoryWorkbook.handleExportProductFactoryWorkbook,
onExportProductLimitWorkbook:
productLimitWorkbook.handleExportProductLimitWorkbook,
onExportProductPricingWorkbook:
@ -161,6 +165,8 @@ export function buildNonIntegratedDetailChrome({
onExportReviewSheet: reviewSheetAssets.handleExportReviewSheet,
onFinishedSampleShipmentStatusClick:
sampleWorkflowActions.handleFinishedSampleShipmentStatusClick,
onImportProductFactoryWorkbookChange:
productFactoryWorkbook.handleImportProductFactoryWorkbookChange,
onImportProductLimitWorkbookChange:
productLimitWorkbook.handleImportProductLimitWorkbookChange,
onImportProductPricingWorkbookChange:
@ -242,6 +248,8 @@ export function buildNonIntegratedDetailChrome({
isCurrentUserAdmin: permissionState.isCurrentUserAdmin,
isCurrentUserPlainReadOnlyViewer:
permissionState.isCurrentUserPlainReadOnlyViewer,
isExportingProductFactory:
productFactoryWorkbook.isExportingProductFactory,
isExportingProductLimit:
productLimitWorkbook.isExportingProductLimit,
isExportingProductPricing:
@ -251,6 +259,8 @@ export function buildNonIntegratedDetailChrome({
isExportingReviewSheet: reviewSheetAssets.isExportingReviewSheet,
isExportingSampleMeasurement:
sampleMeasurementWorkbook.isExportingSampleMeasurement,
isImportingProductFactory:
productFactoryWorkbook.isImportingProductFactory,
isImportingProductLimit:
productLimitWorkbook.isImportingProductLimit,
isImportingProductPricing:
@ -266,6 +276,8 @@ export function buildNonIntegratedDetailChrome({
isReadOnly: editability.isReadOnly,
productFactoryReopenField:
workflowState.productFactoryReopenField,
productFactoryWorkbookInputRef:
inputRefs.productFactoryWorkbookInputRef,
productLimitWorkbookInputRef:
inputRefs.productLimitWorkbookInputRef,
productPricingWorkbookInputRef:

View File

@ -163,6 +163,7 @@ export function buildNonIntegratedDetailRenderers({
permissionState: setup.permissionState,
productContext: setup.productContext,
productFactoryActions: formActions.productFactoryActions,
productFactoryWorkbook: skuActions.productFactoryWorkbook,
productLimitWorkbook: skuActions.productLimitWorkbook,
productPricingWorkbook: skuActions.productPricingWorkbook,
sampleMeasurementWorkbook: skuActions.sampleMeasurementWorkbook,

View File

@ -1,5 +1,6 @@
import type { ComponentProps } from "react";
import { NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_ACCEPT } from "@/lib/non-integrated-product-factory-workbook";
import { NON_INTEGRATED_PRODUCT_LIMIT_WORKBOOK_ACCEPT } from "@/lib/non-integrated-product-limit-workbook";
import { NON_INTEGRATED_PRODUCT_PRICING_WORKBOOK_ACCEPT } from "@/lib/non-integrated-product-pricing-workbook";
import { NON_INTEGRATED_SAMPLE_MEASUREMENT_WORKBOOK_ACCEPT } from "@/lib/non-integrated-sample-measurement-workbook";
@ -50,11 +51,13 @@ type DirectStateName =
| "installationWorkspaceUploadInputRef"
| "isCurrentUserAdmin"
| "isCurrentUserPlainReadOnlyViewer"
| "isExportingProductFactory"
| "isExportingProductLimit"
| "isExportingProductPricing"
| "isExportingQualityInspection"
| "isExportingReviewSheet"
| "isExportingSampleMeasurement"
| "isImportingProductFactory"
| "isImportingProductLimit"
| "isImportingProductPricing"
| "isImportingSampleMeasurement"
@ -63,6 +66,7 @@ type DirectStateName =
| "isProductPricingNodeLocked"
| "isReadOnly"
| "productFactoryReopenField"
| "productFactoryWorkbookInputRef"
| "productLimitWorkbookInputRef"
| "productPricingWorkbookInputRef"
| "sampleMeasurementWorkbookInputRef"
@ -156,6 +160,8 @@ export function buildNonIntegratedDocumentWorkspaceHeaderActions({
isProjectReviewWorkspace: presentation.isProjectReviewWorkspace,
isReviewWorkspace: presentation.isReviewWorkspace,
isSampleMeasurementWorkspace: presentation.isSampleMeasurementWorkspace,
productFactoryWorkbookAccept:
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_ACCEPT,
productLimitWorkbookAccept:
NON_INTEGRATED_PRODUCT_LIMIT_WORKBOOK_ACCEPT,
productPricingWorkbookAccept:

View File

@ -239,6 +239,7 @@ export function useNonIntegratedDetailDomainControllers(
});
const skuActions = useNonIntegratedSkuReviewActionControllers({
artifactState,
warehouseOptions: directoryOptions.warehouseDirectoryOptions,
workflowCompletion: workflow.workflowCompletion,
basicInfoController: formActions.basicInfoController,
basicInfoDerived: setup.basicInfoDerived,

View File

@ -10,6 +10,7 @@ export function useNonIntegratedDetailInputRefs() {
const batchSkuImageInputRef = useRef<HTMLInputElement | null>(null);
const skuWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const productLimitWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const productFactoryWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const productPricingWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const sampleMeasurementWorkbookInputRef = useRef<HTMLInputElement | null>(
null,
@ -47,6 +48,7 @@ export function useNonIntegratedDetailInputRefs() {
pendingProductPhotoCurrentUploadTargetRef,
pendingProductPhotoPanelUploadRef,
previewFileInputRef,
productFactoryWorkbookInputRef,
productLimitWorkbookInputRef,
productPhotoCurrentUploadInputRef,
productPhotoDetailUploadInputRef,

View File

@ -8,6 +8,7 @@ import { useSkuBatchLifecycleController } from "./workspaces/basic-info/useSkuBa
import { useSkuCreationController } from "./workspaces/basic-info/useSkuCreationController";
import { useSkuDisplayState } from "./workspaces/basic-info/useSkuDisplayState";
import { useSkuWorkbookController } from "./workspaces/basic-info/useSkuWorkbookController";
import { useProductFactoryWorkbookController } from "./workspaces/product-factory/useProductFactoryWorkbookController";
import { useProductLimitWorkbookController } from "./workspaces/product-workflow/useProductLimitWorkbookController";
import { useProductPricingWorkbookController } from "./workspaces/product-workflow/useProductPricingWorkbookController";
import { useSampleMeasurementWorkbookController } from "./workspaces/sample-workflow/useSampleMeasurementWorkbookController";
@ -23,6 +24,9 @@ jest.mock("./workspaces/basic-info/useSkuBatchLifecycleController");
jest.mock("./workspaces/basic-info/useSkuCreationController");
jest.mock("./workspaces/basic-info/useSkuDisplayState");
jest.mock("./workspaces/basic-info/useSkuWorkbookController");
jest.mock(
"./workspaces/product-factory/useProductFactoryWorkbookController",
);
jest.mock("./workspaces/product-workflow/useProductLimitWorkbookController");
jest.mock(
"./workspaces/product-workflow/useProductPricingWorkbookController",
@ -42,6 +46,9 @@ describe("useNonIntegratedSkuReviewActionControllers", () => {
aiWhiteBackground: { canUseAiWhiteBackground: true },
skuBatchLifecycle: { handleAddSkuBatch: jest.fn() },
skuWorkbook: { handleExportSkuWorkbook: jest.fn() },
productFactoryWorkbook: {
handleExportProductFactoryWorkbook: jest.fn(),
},
productLimitWorkbook: { handleExportProductLimitWorkbook: jest.fn() },
productPricingWorkbook: {
handleExportProductPricingWorkbook: jest.fn(),
@ -65,6 +72,9 @@ describe("useNonIntegratedSkuReviewActionControllers", () => {
.mocked(useSkuBatchLifecycleController)
.mockReturnValue(returns.skuBatchLifecycle as never);
jest.mocked(useSkuWorkbookController).mockReturnValue(returns.skuWorkbook as never);
jest
.mocked(useProductFactoryWorkbookController)
.mockReturnValue(returns.productFactoryWorkbook as never);
jest
.mocked(useProductLimitWorkbookController)
.mockReturnValue(returns.productLimitWorkbook as never);
@ -150,6 +160,7 @@ describe("useNonIntegratedSkuReviewActionControllers", () => {
expect(result.current).toEqual({
aiWhiteBackground: returns.aiWhiteBackground,
bomWorkbook: returns.bomWorkbook,
productFactoryWorkbook: returns.productFactoryWorkbook,
productLimitWorkbook: returns.productLimitWorkbook,
productPricingWorkbook: returns.productPricingWorkbook,
sampleMeasurementWorkbook: returns.sampleMeasurementWorkbook,

View File

@ -8,6 +8,7 @@ import { useSkuBatchLifecycleController } from "./workspaces/basic-info/useSkuBa
import { useSkuCreationController } from "./workspaces/basic-info/useSkuCreationController";
import { useSkuDisplayState } from "./workspaces/basic-info/useSkuDisplayState";
import { useSkuWorkbookController } from "./workspaces/basic-info/useSkuWorkbookController";
import { useProductFactoryWorkbookController } from "./workspaces/product-factory/useProductFactoryWorkbookController";
import { useProductLimitWorkbookController } from "./workspaces/product-workflow/useProductLimitWorkbookController";
import { useProductPricingWorkbookController } from "./workspaces/product-workflow/useProductPricingWorkbookController";
import { useSampleMeasurementWorkbookController } from "./workspaces/sample-workflow/useSampleMeasurementWorkbookController";
@ -91,6 +92,7 @@ type SkuReviewActionControllerProps = Pick<
seriesDerived: ProductSetupState["seriesDerived"];
seriesDraft: ProductSetupState["seriesDraft"];
supplierCodeByName: ReadonlyMap<string, string>;
warehouseOptions: readonly string[];
skuBatchAction: WorkflowDerivedState["skuBatchAction"];
skuState: ReturnType<typeof useNonIntegratedSkuState>;
spuEstimate: ReturnType<typeof useSpuEstimateRefreshToken>;
@ -139,6 +141,7 @@ export function useNonIntegratedSkuReviewActionControllers({
seriesDerived,
seriesDraft,
supplierCodeByName,
warehouseOptions,
skuBatchAction,
skuState,
spuEstimate,
@ -271,6 +274,32 @@ export function useNonIntegratedSkuReviewActionControllers({
transferProgress.updateGeneratedDownloadProgress,
updateImportProgress: transferProgress.updateImportProgress,
});
const productFactoryWorkbook = useProductFactoryWorkbookController({
allProductFactoryRows: workflowRows.productFactoryRows,
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser:
documentDownloads.denyWorkspaceDownloadForCurrentUser,
factoryRows: workflowState.factoryRows,
finishDownloadProgress: transferProgress.finishDownloadProgress,
finishImportProgress: transferProgress.finishImportProgress,
isReadOnly: editability.isReadOnly,
skuItems: skuState.skuItems,
setDownloadProgress: transferProgress.setDownloadProgress,
setFactoryRows: workflowState.setFactoryRows,
setSkuItems: skuState.setSkuItems,
setSaveMessage: overlayState.setSaveMessage,
setSaveMessageType: overlayState.setSaveMessageType,
setUploadProgress: transferProgress.setUploadProgress,
spu: formState.formValues.spu || formState.formValues.productCode,
startGeneratedDownloadProgress:
transferProgress.startGeneratedDownloadProgress,
startImportProgress: transferProgress.startImportProgress,
supplierCodeByName,
updateGeneratedDownloadProgress:
transferProgress.updateGeneratedDownloadProgress,
updateImportProgress: transferProgress.updateImportProgress,
warehouseOptions,
});
const productLimitWorkbook = useProductLimitWorkbookController({
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser:
@ -493,6 +522,7 @@ export function useNonIntegratedSkuReviewActionControllers({
return {
aiWhiteBackground,
bomWorkbook,
productFactoryWorkbook,
productLimitWorkbook,
productPricingWorkbook,
qualityInspectionActions,

View File

@ -1,3 +1,4 @@
import type { ChangeEvent, RefObject } from "react";
import Button from "@/components/ui/Button";
import {
PRODUCT_FACTORY_REOPEN_FIELD_OPTIONS,
@ -7,8 +8,15 @@ import {
type ProductFactoryHeaderActionsProps = {
actionGroupClassName: string;
basicInfoIsConfirmed: boolean;
canDownloadWorkspaceFiles: boolean;
isAdmin: boolean;
isExportingWorkbook: boolean;
isImportingWorkbook: boolean;
isReadOnly: boolean;
workbookAccept: string;
workbookInputRef: RefObject<HTMLInputElement | null>;
onExportWorkbook: () => void;
onImportWorkbookChange: (event: ChangeEvent<HTMLInputElement>) => void;
rowCount: number;
hasPendingRows: boolean;
isConfirmed: boolean;
@ -25,8 +33,15 @@ type ProductFactoryHeaderActionsProps = {
export function ProductFactoryHeaderActions({
actionGroupClassName,
basicInfoIsConfirmed,
canDownloadWorkspaceFiles,
isAdmin,
isExportingWorkbook,
isImportingWorkbook,
isReadOnly,
workbookAccept,
workbookInputRef,
onExportWorkbook,
onImportWorkbookChange,
rowCount,
hasPendingRows,
isConfirmed,
@ -51,6 +66,36 @@ export function ProductFactoryHeaderActions({
return (
<div className={actionGroupClassName}>
{canDownloadWorkspaceFiles ? (
<Button
variant="plain"
size="small"
onClick={onExportWorkbook}
disabled={isExportingWorkbook || rowCount === 0}
>
{isExportingWorkbook ? "导出中..." : "导出模板"}
</Button>
) : null}
{isReadOnly ? null : (
<>
<Button
variant="plain"
size="small"
onClick={() => workbookInputRef.current?.click()}
disabled={isImportingWorkbook || rowCount === 0}
>
{isImportingWorkbook ? "导入中..." : "导入模板"}
</Button>
<input
ref={workbookInputRef}
type="file"
accept={workbookAccept}
className="hidden"
data-testid="product-factory-import-input"
onChange={onImportWorkbookChange}
/>
</>
)}
{isAdmin ? (
<div className="relative">
<Button

View File

@ -0,0 +1,441 @@
import type {
NonIntegratedFactoryRow,
NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import {
INSTALL_METHOD_OPTIONS,
PRODUCT_FACTORY_INSTALLATION_SERVICE_OPTIONS,
PRODUCT_FACTORY_PRODUCTION_TYPE_OPTIONS,
PRODUCT_FACTORY_PURCHASE_ORDER_METHOD_OPTIONS,
PRODUCT_FACTORY_SHIPPING_METHOD_OPTIONS,
PRODUCT_FACTORY_SUPPLIER_CATEGORY_OPTIONS,
normalizeSetCountValue,
} from "../basic-info/skuFieldConfig";
import { buildAutoBarcode } from "../basic-info/skuAutoHelpers";
import {
buildProductFactoryRows,
normalizeProductFactorySupplier,
} from "../basic-info/productFactoryHelpers";
import { buildSkuItemsAfterSetCountCommit } from "../basic-info/skuSetCountCommitHelpers";
import { isImportedSkuContentLocked } from "../basic-info/skuSourceHelpers";
import type { NonIntegratedProductFactoryWorkbookImportRow } from "@/lib/non-integrated-product-factory-workbook";
export type ProductFactoryWorkbookEntryValues = {
supplierName?: string;
warehouse?: string;
shippingMethod?: string;
minimumOrderQuantity?: string;
taxIncludedUnitPrice?: string;
leadTime?: string;
installMethod?: string;
productionType?: string;
installationService?: string;
supplierCategory?: string;
purchaseOrderMethod?: string;
setCount?: string;
};
export type ProductFactoryWorkbookEntry = {
rowNumber: number;
skuCode: string;
targetRow: NonIntegratedFactoryRow;
values: ProductFactoryWorkbookEntryValues;
setCountChanged: boolean;
};
export type ProductFactoryWorkbookValidationResult = {
errors: string[];
entries: ProductFactoryWorkbookEntry[];
};
const WORKBOOK_VALUE_OPTION_RULES = [
{ key: "shippingMethod", label: "发货方式", options: PRODUCT_FACTORY_SHIPPING_METHOD_OPTIONS },
{ key: "installMethod", label: "安装方式", options: INSTALL_METHOD_OPTIONS },
{ key: "productionType", label: "产品生产类型", options: PRODUCT_FACTORY_PRODUCTION_TYPE_OPTIONS },
{ key: "installationService", label: "安装服务", options: PRODUCT_FACTORY_INSTALLATION_SERVICE_OPTIONS },
{ key: "supplierCategory", label: "供应商分类", options: PRODUCT_FACTORY_SUPPLIER_CATEGORY_OPTIONS },
{ key: "purchaseOrderMethod", label: "采购下单方式", options: PRODUCT_FACTORY_PURCHASE_ORDER_METHOD_OPTIONS },
] as const;
function pushValueError(
errors: string[],
rowNumber: number,
label: string,
value: string,
reason: string,
) {
errors.push(`第${rowNumber}行:${label}「${value}」${reason}`);
}
// 交期支持多种常见写法,统一归一化为 UI 存储用的「YYYY年MM月DD日」格式。
export const NORMALIZE_LEADTIME_VERSION = 2;
export function normalizeLeadTimeValue(value: string) {
const normalizedValue = value.trim();
if (!normalizedValue) return "";
const zhMatch = normalizedValue.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日$/);
const isoMatch = normalizedValue.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
const fullYearMatch = normalizedValue.match(
/^(\d{4})[/.](\d{1,2})[/.](\d{1,2})$/,
);
const shortYearMatch = normalizedValue.match(
/^(\d{1,2})[/.](\d{1,2})[/.](\d{2}|\d{4})$/,
);
let year: number;
let month: number;
let day: number;
if (zhMatch || isoMatch || fullYearMatch) {
const match = zhMatch ?? isoMatch ?? fullYearMatch;
year = Number.parseInt(match?.[1] ?? "", 10);
month = Number.parseInt(match?.[2] ?? "", 10);
day = Number.parseInt(match?.[3] ?? "", 10);
} else if (shortYearMatch) {
const yearText = shortYearMatch[3] ?? "";
year = Number.parseInt(
yearText.length === 2 ? `20${yearText}` : yearText,
10,
);
month = Number.parseInt(shortYearMatch[1] ?? "", 10);
day = Number.parseInt(shortYearMatch[2] ?? "", 10);
} else {
return "";
}
if (
!Number.isFinite(year) ||
!Number.isFinite(month) ||
!Number.isFinite(day) ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return "";
}
const parsedDate = new Date(Date.UTC(year, month - 1, day));
if (
parsedDate.getUTCFullYear() !== year ||
parsedDate.getUTCMonth() !== month - 1 ||
parsedDate.getUTCDate() !== day
) {
return "";
}
return `${year}年${String(month).padStart(2, "0")}月${String(day).padStart(2, "0")}日`;
}
export function validateProductFactoryWorkbookRows({
rows,
productFactoryRows,
skuItems,
supplierCodeByName,
warehouseOptions,
}: {
rows: ReadonlyArray<NonIntegratedProductFactoryWorkbookImportRow>;
productFactoryRows: NonIntegratedFactoryRow[];
skuItems: NonIntegratedSkuItem[];
supplierCodeByName: ReadonlyMap<string, string>;
warehouseOptions: readonly string[];
}): ProductFactoryWorkbookValidationResult {
const errors: string[] = [];
const entries: ProductFactoryWorkbookEntry[] = [];
const rowBySkuCode = new Map(
productFactoryRows
.map((row) => [row.skuCode.trim().toUpperCase(), row] as const)
.filter(([code]) => code.length > 0),
);
const normalizedWarehouseOptions = new Set(
warehouseOptions.map((option) => option.trim()),
);
const seenSkuCodes = new Set<string>();
for (const row of rows) {
const rowLabel = `第${row.rowNumber}行`;
const skuCode = row.skuCode.trim();
if (!skuCode) {
errors.push(`${rowLabel}:货号为空`);
continue;
}
const targetRow = rowBySkuCode.get(skuCode.toUpperCase());
if (!targetRow) {
errors.push(`${rowLabel}:货号「${skuCode}」不存在`);
continue;
}
if (seenSkuCodes.has(targetRow.skuId)) {
errors.push(`${rowLabel}:货号「${skuCode}」在文件中重复`);
continue;
}
seenSkuCodes.add(targetRow.skuId);
if (targetRow.confirmed) {
errors.push(`${rowLabel}:货号「${skuCode}」已确认定厂,不可导入修改`);
continue;
}
const values: ProductFactoryWorkbookEntryValues = {};
const supplierName = row.supplierName.trim();
if (supplierName) {
const supplier = normalizeProductFactorySupplier(
supplierName,
supplierCodeByName,
);
if (!supplier.name) {
pushValueError(
errors,
row.rowNumber,
"供应商名称",
supplierName,
"未匹配到供应商目录",
);
} else {
values.supplierName = supplierName;
}
}
const warehouse = row.warehouse.trim();
if (warehouse) {
if (!normalizedWarehouseOptions.has(warehouse)) {
pushValueError(errors, row.rowNumber, "仓库", warehouse, "未匹配到仓库目录");
} else {
values.warehouse = warehouse;
}
}
for (const rule of WORKBOOK_VALUE_OPTION_RULES) {
const value = row[rule.key].trim();
if (!value) continue;
if (!(rule.options as readonly string[]).includes(value)) {
pushValueError(errors, row.rowNumber, rule.label, value, "不是有效选项");
} else {
values[rule.key] = value;
}
}
const minimumOrderQuantity = row.minimumOrderQuantity.trim();
if (minimumOrderQuantity && !/^\d+$/.test(minimumOrderQuantity)) {
pushValueError(
errors,
row.rowNumber,
"最低起订量",
minimumOrderQuantity,
"需为正整数",
);
} else if (minimumOrderQuantity) {
values.minimumOrderQuantity = minimumOrderQuantity;
}
const taxIncludedUnitPrice = row.taxIncludedUnitPrice.trim();
if (taxIncludedUnitPrice && !/^\d+(\.\d{1,2})?$/.test(taxIncludedUnitPrice)) {
pushValueError(
errors,
row.rowNumber,
"含税单价",
taxIncludedUnitPrice,
"需为数字",
);
} else if (taxIncludedUnitPrice) {
values.taxIncludedUnitPrice = taxIncludedUnitPrice;
}
const leadTime = row.leadTime.trim();
if (leadTime) {
const normalizedLeadTime = normalizeLeadTimeValue(leadTime);
if (!normalizedLeadTime) {
pushValueError(
errors,
row.rowNumber,
"交期",
leadTime,
"无法识别为日期,支持 2026/09/30、2026年9月30日、9/10/26",
);
} else {
values.leadTime = normalizedLeadTime;
}
}
const setCount = row.setCount.trim();
let setCountChanged = false;
if (setCount) {
const normalizedSetCount = Number.parseInt(setCount, 10);
if (
!Number.isFinite(normalizedSetCount) ||
normalizedSetCount < 1 ||
normalizedSetCount > 26 ||
String(normalizedSetCount) !== setCount
) {
pushValueError(errors, row.rowNumber, "套件量", setCount, "需为 1-26 的整数");
} else if (normalizeSetCountValue(setCount) !== normalizeSetCountValue(targetRow.setCount)) {
const skuItem = skuItems.find((item) => item.id === targetRow.skuId);
if (skuItem && isImportedSkuContentLocked(skuItem)) {
errors.push(
`${rowLabel}:货号「${skuCode}」为导入锁定内容,不可修改套件量`,
);
} else {
values.setCount = String(normalizedSetCount);
setCountChanged = true;
}
}
}
entries.push({
rowNumber: row.rowNumber,
skuCode,
targetRow,
values,
setCountChanged,
});
}
return { errors, entries };
}
export function applyProductFactoryWorkbookRows({
currentRows,
entries,
skuItems,
supplierCodeByName,
}: {
currentRows: NonIntegratedFactoryRow[];
entries: ProductFactoryWorkbookEntry[];
skuItems: NonIntegratedSkuItem[];
supplierCodeByName: ReadonlyMap<string, string>;
}) {
let nextRows = [...currentRows];
let nextSkuItems = [...skuItems];
const appliedSkuIds = new Set<string>();
for (const entry of entries) {
const rowIndex = nextRows.findIndex(
(row) => row.skuId === entry.targetRow.skuId,
);
if (rowIndex < 0) continue;
const row = nextRows[rowIndex];
const values = entry.values;
let supplierName = row.supplierName;
let supplierCode = row.supplierCode;
let barcode = row.barcode;
if (values.supplierName !== undefined) {
const supplier = normalizeProductFactorySupplier(
values.supplierName,
supplierCodeByName,
);
supplierName = supplier.name;
supplierCode = supplier.code;
const versionNo =
nextSkuItems.find((item) => item.id === row.skuId)?.versionNo ?? "V01";
barcode = buildAutoBarcode(row.skuCode, supplierCode, versionNo);
}
nextRows[rowIndex] = {
...row,
supplierName,
supplierCode,
barcode,
...(values.warehouse !== undefined
? { warehouse: values.warehouse, warehouseBatchInitialized: true }
: {}),
...(values.shippingMethod !== undefined
? { shippingMethod: values.shippingMethod }
: {}),
...(values.minimumOrderQuantity !== undefined
? { minimumOrderQuantity: values.minimumOrderQuantity }
: {}),
...(values.taxIncludedUnitPrice !== undefined
? { taxIncludedUnitPrice: values.taxIncludedUnitPrice }
: {}),
...(values.leadTime !== undefined ? { leadTime: values.leadTime } : {}),
...(values.installMethod !== undefined
? { installMethod: values.installMethod }
: {}),
...(values.productionType !== undefined
? { productionType: values.productionType }
: {}),
...(values.installationService !== undefined
? { installationService: values.installationService }
: {}),
...(values.supplierCategory !== undefined
? { supplierCategory: values.supplierCategory }
: {}),
...(values.purchaseOrderMethod !== undefined
? { purchaseOrderMethod: values.purchaseOrderMethod }
: {}),
...(values.setCount !== undefined ? { setCount: values.setCount } : {}),
};
if (values.setCount !== undefined) {
const skuItemIndex = nextSkuItems.findIndex(
(item) => item.id === row.skuId,
);
if (skuItemIndex >= 0) {
nextSkuItems[skuItemIndex] = {
...nextSkuItems[skuItemIndex],
setCount: values.setCount,
};
}
}
appliedSkuIds.add(row.skuId);
}
for (const skuId of appliedSkuIds) {
const before = skuItems.find((item) => item.id === skuId);
const after = nextSkuItems.find((item) => item.id === skuId);
if (!before || !after) continue;
if (
normalizeSetCountValue(before.setCount) ===
normalizeSetCountValue(after.setCount)
) {
continue;
}
nextSkuItems = buildSkuItemsAfterSetCountCommit({
allowImportedContentLocked: true,
current: nextSkuItems,
isSkuItemFactoryConfirmed: (item) =>
Boolean(nextRows.find((row) => row.skuId === item.id)?.confirmed),
targetId: skuId,
});
}
nextRows = buildProductFactoryRows(nextSkuItems, nextRows, supplierCodeByName);
// 供应商值二次固化:工厂行重建会以 sku.manufacturer 优先,
// 导入的供应商值需要在重建后的行(含 A/B 包行)上重新写入。
for (const entry of entries) {
if (entry.values.supplierName === undefined) continue;
const anchor = nextSkuItems.find(
(item) => item.id === entry.targetRow.skuId,
);
if (!anchor) continue;
nextRows = nextRows.map((row) => {
const rowSkuItem = nextSkuItems.find((item) => item.id === row.skuId);
const rowGroupId = rowSkuItem
? rowSkuItem.packageGroupId || rowSkuItem.id
: "";
const anchorGroupId = anchor.packageGroupId || anchor.id;
if (rowGroupId !== anchorGroupId) return row;
const supplier = normalizeProductFactorySupplier(
entry.values.supplierName as string,
supplierCodeByName,
);
if (!supplier.name) return row;
const versionNo = rowSkuItem?.versionNo ?? "V01";
return {
...row,
supplierName: supplier.name,
supplierCode: supplier.code,
barcode: buildAutoBarcode(row.skuCode, supplier.code, versionNo),
};
});
}
return { nextRows, nextSkuItems, appliedCount: appliedSkuIds.size };
}

View File

@ -0,0 +1,192 @@
import { useState, type ChangeEvent, type Dispatch, type SetStateAction } from "react";
import {
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_ACCEPT,
createNonIntegratedProductFactoryWorkbook,
downloadNonIntegratedProductFactoryWorkbook,
parseNonIntegratedProductFactoryWorkbook,
} from "@/lib/non-integrated-product-factory-workbook";
import type {
NonIntegratedFactoryRow,
NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import type {
DownloadProgressState,
UploadProgressState,
} from "../documents/progressHelpers";
import {
applyProductFactoryWorkbookRows,
validateProductFactoryWorkbookRows,
} from "./productFactoryWorkbookHelpers";
type SaveMessageType = "success" | "error";
function buildProductFactoryWorkbookFileName(spu: string) {
return `${spu.trim() || "产品定厂"}-产品定厂模板.xlsx`;
}
type UseProductFactoryWorkbookControllerParams = {
allProductFactoryRows: NonIntegratedFactoryRow[];
canDownloadWorkspaceFiles: boolean;
denyWorkspaceDownloadForCurrentUser: () => void;
factoryRows: NonIntegratedFactoryRow[];
finishDownloadProgress: (fileName?: string) => void;
finishImportProgress: (fileName?: string) => void;
isReadOnly: boolean;
skuItems: NonIntegratedSkuItem[];
setDownloadProgress: Dispatch<SetStateAction<DownloadProgressState | null>>;
setFactoryRows: Dispatch<SetStateAction<NonIntegratedFactoryRow[]>>;
setSkuItems: Dispatch<SetStateAction<NonIntegratedSkuItem[]>>;
setSaveMessage: Dispatch<SetStateAction<string>>;
setSaveMessageType: Dispatch<SetStateAction<SaveMessageType>>;
setUploadProgress: Dispatch<SetStateAction<UploadProgressState | null>>;
spu: string;
startGeneratedDownloadProgress: (fileName: string) => void;
startImportProgress: (file: File) => void;
supplierCodeByName: ReadonlyMap<string, string>;
updateGeneratedDownloadProgress: (percent: number, fileName?: string) => void;
updateImportProgress: (percent: number, fileName?: string) => void;
warehouseOptions: readonly string[];
};
export function useProductFactoryWorkbookController({
allProductFactoryRows,
canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser,
factoryRows,
finishDownloadProgress,
finishImportProgress,
isReadOnly,
skuItems,
setDownloadProgress,
setFactoryRows,
setSkuItems,
setSaveMessage,
setSaveMessageType,
setUploadProgress,
spu,
startGeneratedDownloadProgress,
startImportProgress,
supplierCodeByName,
updateGeneratedDownloadProgress,
updateImportProgress,
warehouseOptions,
}: UseProductFactoryWorkbookControllerParams) {
const [isExportingProductFactory, setIsExportingProductFactory] =
useState(false);
const [isImportingProductFactory, setIsImportingProductFactory] =
useState(false);
async function handleExportProductFactoryWorkbook() {
if (!canDownloadWorkspaceFiles) {
denyWorkspaceDownloadForCurrentUser();
return;
}
if (allProductFactoryRows.length === 0 || isExportingProductFactory) {
return;
}
const fileName = buildProductFactoryWorkbookFileName(spu);
setIsExportingProductFactory(true);
try {
startGeneratedDownloadProgress(fileName);
const workbook = await createNonIntegratedProductFactoryWorkbook(
allProductFactoryRows.map((row) => ({
skuCode: row.skuCode.trim(),
setCount: row.setCount.trim(),
supplierName: row.supplierName.trim(),
warehouse: row.warehouse.trim(),
shippingMethod: row.shippingMethod.trim(),
minimumOrderQuantity: row.minimumOrderQuantity.trim(),
taxIncludedUnitPrice: row.taxIncludedUnitPrice.trim(),
leadTime: row.leadTime.trim(),
installMethod: row.installMethod.trim(),
productionType: row.productionType.trim(),
installationService: row.installationService.trim(),
supplierCategory: row.supplierCategory.trim(),
purchaseOrderMethod: row.purchaseOrderMethod.trim(),
size: row.size.trim(),
color: row.color.trim(),
material: row.material.trim(),
})),
);
updateGeneratedDownloadProgress(80, fileName);
downloadNonIntegratedProductFactoryWorkbook(workbook, fileName);
finishDownloadProgress(fileName);
} catch (error) {
setSaveMessage(
error instanceof Error ? error.message : "产品定厂模板导出失败",
);
setSaveMessageType("error");
setDownloadProgress(null);
} finally {
setIsExportingProductFactory(false);
}
}
async function handleImportProductFactoryWorkbookChange(
event: ChangeEvent<HTMLInputElement>,
) {
if (isReadOnly) {
event.target.value = "";
return;
}
const file = event.target.files?.[0];
if (!file) return;
setIsImportingProductFactory(true);
try {
startImportProgress(file);
const workbookImport =
await parseNonIntegratedProductFactoryWorkbook(file);
updateImportProgress(45, file.name);
if (workbookImport.rows.length === 0) {
throw new Error("上传资料中没有可导入的产品定厂行");
}
const { errors, entries } = validateProductFactoryWorkbookRows({
productFactoryRows: factoryRows,
rows: workbookImport.rows,
skuItems,
supplierCodeByName,
warehouseOptions,
});
if (errors.length > 0) {
throw new Error(`导入已取消,本次未导入任何数据。${errors.join(" ")}`);
}
updateImportProgress(70, file.name);
const applyResult = applyProductFactoryWorkbookRows({
currentRows: factoryRows,
entries,
skuItems,
supplierCodeByName,
});
setFactoryRows(applyResult.nextRows);
setSkuItems(applyResult.nextSkuItems);
setSaveMessage(
`已导入 ${applyResult.appliedCount} 行产品定厂,请核对后确认。`,
);
setSaveMessageType("success");
finishImportProgress(file.name);
} catch (error) {
setSaveMessage(
error instanceof Error ? error.message : "产品定厂模板导入失败",
);
setSaveMessageType("error");
setUploadProgress(null);
} finally {
setIsImportingProductFactory(false);
event.target.value = "";
}
}
return {
handleExportProductFactoryWorkbook,
handleImportProductFactoryWorkbookChange,
isExportingProductFactory,
isImportingProductFactory,
};
}

View File

@ -70,6 +70,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
isCurrentUserAdmin,
isCurrentUserPlainReadOnlyViewer,
isDetectionWorkspace,
isExportingProductFactory,
isExportingProductLimit,
isExportingProductPricing,
isExportingQualityInspection,
@ -77,6 +78,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
isExportingSampleMeasurement,
isFactoryQualityWorkspace,
isFinishedSampleShipmentWorkspace,
isImportingProductFactory,
isImportingProductLimit,
isImportingProductPricing,
isImportingSampleMeasurement,
@ -99,6 +101,8 @@ export function DocumentWorkspaceHeaderActionsSlot({
isReviewWorkspace,
isSampleMeasurementWorkspace,
productFactoryReopenField,
productFactoryWorkbookAccept,
productFactoryWorkbookInputRef,
productLimitWorkbookAccept,
productLimitWorkbookInputRef,
productPhotoCurrentUploadInputRef,
@ -119,12 +123,14 @@ export function DocumentWorkspaceHeaderActionsSlot({
onDeleteUploadFile,
onDownloadUploadFile,
onDownloadWorkspaceFiles,
onExportProductFactoryWorkbook,
onExportProductLimitWorkbook,
onExportProductPricingWorkbook,
onExportQualityInspection,
onExportReviewSheet,
onExportSampleMeasurementWorkbook,
onFinishedSampleShipmentStatusClick,
onImportProductFactoryWorkbookChange,
onImportProductLimitWorkbookChange,
onImportProductPricingWorkbookChange,
onImportSampleMeasurementWorkbookChange,
@ -187,6 +193,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
isCurrentUserAdmin: boolean;
isCurrentUserPlainReadOnlyViewer: boolean;
isDetectionWorkspace: boolean;
isExportingProductFactory: boolean;
isExportingProductLimit: boolean;
isExportingProductPricing: boolean;
isExportingQualityInspection: boolean;
@ -194,6 +201,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
isExportingSampleMeasurement: boolean;
isFactoryQualityWorkspace: boolean;
isFinishedSampleShipmentWorkspace: boolean;
isImportingProductFactory: boolean;
isImportingProductLimit: boolean;
isImportingProductPricing: boolean;
isImportingSampleMeasurement: boolean;
@ -216,6 +224,8 @@ export function DocumentWorkspaceHeaderActionsSlot({
isReviewWorkspace: boolean;
isSampleMeasurementWorkspace: boolean;
productFactoryReopenField: ProductFactoryReopenField;
productFactoryWorkbookAccept: string;
productFactoryWorkbookInputRef: RefObject<HTMLInputElement | null>;
productLimitWorkbookAccept: string;
productLimitWorkbookInputRef: RefObject<HTMLInputElement | null>;
productPhotoCurrentUploadInputRef: RefObject<HTMLInputElement | null>;
@ -236,12 +246,16 @@ export function DocumentWorkspaceHeaderActionsSlot({
onDeleteUploadFile: (id: string) => void;
onDownloadUploadFile: (file: NonIntegratedUploadFile) => void;
onDownloadWorkspaceFiles: (files: NonIntegratedUploadFile[]) => void;
onExportProductFactoryWorkbook: () => void;
onExportProductLimitWorkbook: () => void;
onExportProductPricingWorkbook: () => void;
onExportQualityInspection: () => void;
onExportReviewSheet: () => void;
onExportSampleMeasurementWorkbook: () => void;
onFinishedSampleShipmentStatusClick: () => void;
onImportProductFactoryWorkbookChange: (
event: ChangeEvent<HTMLInputElement>,
) => void;
onImportProductLimitWorkbookChange: (
event: ChangeEvent<HTMLInputElement>,
) => void;
@ -294,8 +308,15 @@ export function DocumentWorkspaceHeaderActionsSlot({
<ProductFactoryHeaderActions
actionGroupClassName={actionGroupClassName}
basicInfoIsConfirmed={basicInfoIsConfirmed}
canDownloadWorkspaceFiles={canDownloadWorkspaceFiles}
isAdmin={isCurrentUserAdmin}
isExportingWorkbook={isExportingProductFactory}
isImportingWorkbook={isImportingProductFactory}
isReadOnly={isReadOnly}
workbookAccept={productFactoryWorkbookAccept}
workbookInputRef={productFactoryWorkbookInputRef}
onExportWorkbook={onExportProductFactoryWorkbook}
onImportWorkbookChange={onImportProductFactoryWorkbookChange}
rowCount={displayedProductFactoryRowCount}
hasPendingRows={displayedProductFactoryHasPendingRows}
isConfirmed={displayedProductFactoryIsConfirmed}

View File

@ -0,0 +1,132 @@
import {
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS,
createNonIntegratedProductFactoryWorkbook,
parseNonIntegratedProductFactoryWorkbook,
} from '@/lib/non-integrated-product-factory-workbook'
function readBlobAsArrayBuffer(blob: Blob) {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
resolve(reader.result)
return
}
reject(new Error('failed to read workbook blob'))
}
reader.onerror = () => reject(reader.error ?? new Error('failed to read workbook blob'))
reader.readAsArrayBuffer(blob)
})
}
function createWorkbookFile(rows: unknown[][], name = 'product-factory.xlsx') {
const XLSX = require('xlsx')
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.aoa_to_sheet(rows),
'产品定厂',
)
const bytes = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }) as ArrayBuffer
const file = new File([bytes], name, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
Object.defineProperty(file, 'arrayBuffer', {
value: async () => bytes,
})
return file
}
describe('non-integrated-product-factory-workbook', () => {
it('exports every factory template column in order', async () => {
const blob = await createNonIntegratedProductFactoryWorkbook([
{
skuCode: 'LRSF009501',
setCount: '1',
supplierName: '兰考华兰家具',
warehouse: '郑州仓',
shippingMethod: '入仓',
minimumOrderQuantity: '50',
taxIncludedUnitPrice: '1999',
leadTime: '2026/09/30',
installMethod: '整装',
productionType: 'OEM',
installationService: '送货',
supplierCategory: '主力供应商',
purchaseOrderMethod: '订单式',
size: '140/60/75',
color: '胡桃色',
material: '橡胶木',
},
])
const ExcelJS = await import('exceljs')
const workbook = new ExcelJS.Workbook()
const workbookBytes = await readBlobAsArrayBuffer(blob)
await workbook.xlsx.load(workbookBytes as ArrayBuffer)
const worksheet = workbook.getWorksheet(1)
expect(worksheet?.name).toBe('产品定厂')
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.forEach((column, index) => {
expect(worksheet?.getCell(1, index + 1).value).toBe(column.label)
})
expect(worksheet?.getCell(2, 1).value).toBe('LRSF009501')
expect(worksheet?.getCell(2, 2).value).toBe(1)
expect(worksheet?.getCell(2, 3).value).toBe('兰考华兰家具')
expect(worksheet?.getCell(2, 8).value).toBe('2026/09/30')
})
it('parses strictly-ordered template rows with worksheet row numbers', async () => {
const headers = NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.map(
(column) => column.label,
)
const file = createWorkbookFile([
headers,
headers.map((label) =>
label === '货号'
? 'LRSF009501'
: label === '套件量'
? '2'
: label === '供应商名称'
? '兰考华兰家具'
: '',
),
])
const result = await parseNonIntegratedProductFactoryWorkbook(file)
expect(result.sheetName).toBe('产品定厂')
expect(result.rows).toEqual([
{
rowNumber: 2,
skuCode: 'LRSF009501',
setCount: '2',
supplierName: '兰考华兰家具',
warehouse: '',
shippingMethod: '',
minimumOrderQuantity: '',
taxIncludedUnitPrice: '',
leadTime: '',
installMethod: '',
productionType: '',
installationService: '',
supplierCategory: '',
purchaseOrderMethod: '',
size: '',
color: '',
material: '',
},
])
})
it('rejects templates whose headers differ from the exported template', async () => {
const file = createWorkbookFile([
['货号', '数量', '供应商'],
['LRSF009501', '1', '兰考华兰家具'],
])
await expect(
parseNonIntegratedProductFactoryWorkbook(file),
).rejects.toThrow('上传资料表头与模板不一致')
})
})

View File

@ -0,0 +1,263 @@
export type NonIntegratedProductFactoryWorkbookRow = {
skuCode: string
setCount: string
supplierName: string
warehouse: string
shippingMethod: string
minimumOrderQuantity: string
taxIncludedUnitPrice: string
leadTime: string
installMethod: string
productionType: string
installationService: string
supplierCategory: string
purchaseOrderMethod: string
size: string
color: string
material: string
}
export type NonIntegratedProductFactoryWorkbookImportRow = {
rowNumber: number
} & NonIntegratedProductFactoryWorkbookRow
export const NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS = [
{ key: 'skuCode', label: '货号' },
{ key: 'setCount', label: '套件量' },
{ key: 'supplierName', label: '供应商名称' },
{ key: 'warehouse', label: '仓库' },
{ key: 'shippingMethod', label: '发货方式' },
{ key: 'minimumOrderQuantity', label: '最低起订量' },
{ key: 'taxIncludedUnitPrice', label: '含税单价' },
{ key: 'leadTime', label: '交期' },
{ key: 'installMethod', label: '安装方式' },
{ key: 'productionType', label: '产品生产类型' },
{ key: 'installationService', label: '安装服务' },
{ key: 'supplierCategory', label: '供应商分类' },
{ key: 'purchaseOrderMethod', label: '采购下单方式' },
{ key: 'size', label: '尺寸' },
{ key: 'color', label: '颜色' },
{ key: 'material', label: '材质' },
] as const
const EXCEL_MIME_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
export const NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_ACCEPT =
'.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const EXCEL_TEXT_STYLE_COLUMNS = new Set(['skuCode', 'supplierName', 'leadTime', 'size', 'color', 'material'])
function applyHeaderCellStyle(cell: import('exceljs').Cell) {
cell.border = {
top: { style: 'thin', color: { argb: 'FFD6DEE7' } },
left: { style: 'thin', color: { argb: 'FFD6DEE7' } },
bottom: { style: 'thin', color: { argb: 'FFD6DEE7' } },
right: { style: 'thin', color: { argb: 'FFD6DEE7' } },
}
cell.alignment = {
vertical: 'middle',
horizontal: 'center',
wrapText: true,
}
cell.font = {
name: 'Microsoft YaHei',
size: 10,
bold: true,
}
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF5F7FA' },
}
}
function applyBodyCellStyle(cell: import('exceljs').Cell) {
cell.border = {
top: { style: 'thin', color: { argb: 'FFE1E6ED' } },
left: { style: 'thin', color: { argb: 'FFE1E6ED' } },
bottom: { style: 'thin', color: { argb: 'FFE1E6ED' } },
right: { style: 'thin', color: { argb: 'FFE1E6ED' } },
}
cell.alignment = {
vertical: 'middle',
horizontal: 'left',
wrapText: true,
}
cell.font = {
name: 'Microsoft YaHei',
size: 10,
}
}
export async function createNonIntegratedProductFactoryWorkbook(
rows: NonIntegratedProductFactoryWorkbookRow[],
sheetName = '产品定厂',
) {
const ExcelJS = await import('exceljs')
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.lastModifiedBy = 'Codex'
workbook.created = new Date()
workbook.modified = new Date()
const worksheet = workbook.addWorksheet(sheetName)
worksheet.properties.defaultRowHeight = 24
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.forEach((column, index) => {
worksheet.getColumn(index + 1).width = column.key === 'skuCode' ? 22 : 16
})
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.forEach((column, index) => {
const headerCell = worksheet.getCell(1, index + 1)
headerCell.value = column.label
applyHeaderCellStyle(headerCell)
})
worksheet.getRow(1).height = 28
rows.forEach((row, rowIndex) => {
const worksheetRowNumber = rowIndex + 2
const worksheetRow = worksheet.getRow(worksheetRowNumber)
worksheetRow.height = 24
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.forEach(
(column, columnIndex) => {
const cell = worksheet.getCell(worksheetRowNumber, columnIndex + 1)
const rawValue = (row[column.key as keyof NonIntegratedProductFactoryWorkbookRow] ?? '').toString()
cell.value = EXCEL_TEXT_STYLE_COLUMNS.has(column.key)
? rawValue
: parseWorkbookNumericValue(rawValue) ?? rawValue
applyBodyCellStyle(cell)
},
)
})
const buffer = await workbook.xlsx.writeBuffer()
return new Blob([buffer], { type: EXCEL_MIME_TYPE })
}
export function downloadNonIntegratedProductFactoryWorkbook(
blob: Blob,
fileName: string,
) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
function parseWorkbookNumericValue(value: string) {
const normalizedValue = value.trim()
if (!normalizedValue) return null
if (!/^-?\d+(?:\.\d+)?$/.test(normalizedValue)) return null
const parsedValue = Number(normalizedValue)
return Number.isFinite(parsedValue) ? parsedValue : null
}
function normalizeWorkbookCellValue(value: unknown) {
if (value === null || value === undefined) return ''
if (value instanceof Date) return value.toISOString()
return String(value).trim()
}
function readWorkbookFileArrayBuffer(file: File) {
if (typeof file.arrayBuffer === 'function') {
return file.arrayBuffer()
}
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
resolve(reader.result)
return
}
reject(new Error('无法读取产品定厂模板'))
}
reader.onerror = () => reject(new Error('无法读取产品定厂模板'))
reader.readAsArrayBuffer(file)
})
}
export async function parseNonIntegratedProductFactoryWorkbook(file: File) {
const XLSX = await import('xlsx')
const bytes = await readWorkbookFileArrayBuffer(file)
const workbook = XLSX.read(bytes, { type: 'array', cellDates: false })
const sheetName = workbook.SheetNames[0]
if (!sheetName) {
throw new Error('上传资料中没有可读取的工作表')
}
const worksheet = workbook.Sheets[sheetName]
const sheetRows = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
raw: false,
defval: '',
blankrows: false,
}) as unknown[][]
const headerRowIndex = sheetRows.findIndex((row) =>
row.some((cell) => normalizeWorkbookCellValue(cell).length > 0),
)
if (headerRowIndex < 0) {
throw new Error('上传资料中没有可读取的表头')
}
const expectedLabels = NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.map(
(column) => column.label,
)
const headerCells = sheetRows[headerRowIndex].map((cell) =>
normalizeWorkbookCellValue(cell),
)
while (headerCells.length > 0 && headerCells[headerCells.length - 1] === '') {
headerCells.pop()
}
const headerDiff: string[] = []
const maxColumns = Math.max(expectedLabels.length, headerCells.length)
for (let index = 0; index < maxColumns; index++) {
const expected = expectedLabels[index] ?? ''
const actual = headerCells[index] ?? ''
if (expected !== actual) {
headerDiff.push(
actual
? `第${index + 1}列应为「${expected}」实际为「${actual}」`
: `缺少第${index + 1}列「${expected}」`,
)
}
}
if (headerDiff.length > 0) {
throw new Error(
`上传资料表头与模板不一致,请使用最新导出的模板重新填写:${headerDiff.join(';')}`,
)
}
const rows = sheetRows
.slice(headerRowIndex + 1)
.map((row, index): NonIntegratedProductFactoryWorkbookImportRow => {
const importRow = {
rowNumber: headerRowIndex + index + 2,
} as NonIntegratedProductFactoryWorkbookImportRow
for (const column of NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS) {
const cellIndex = NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.indexOf(column)
importRow[column.key] = normalizeWorkbookCellValue(row[cellIndex])
}
return importRow
})
.filter((row) =>
NON_INTEGRATED_PRODUCT_FACTORY_WORKBOOK_COLUMNS.some(
(column) => row[column.key].trim().length > 0,
),
)
return {
rows,
sheetName,
}
}