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:
parent
d4d26c43bf
commit
e9f83b16ee
@ -37,6 +37,7 @@ describe("buildNonIntegratedDetailChrome", () => {
|
||||
permissionState: {},
|
||||
productContext: {},
|
||||
productFactoryActions: {},
|
||||
productFactoryWorkbook: {},
|
||||
productLimitWorkbook: {},
|
||||
productPricingWorkbook: {},
|
||||
productPhotoUploadActions: {},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -239,6 +239,7 @@ export function useNonIntegratedDetailDomainControllers(
|
||||
});
|
||||
const skuActions = useNonIntegratedSkuReviewActionControllers({
|
||||
artifactState,
|
||||
warehouseOptions: directoryOptions.warehouseDirectoryOptions,
|
||||
workflowCompletion: workflow.workflowCompletion,
|
||||
basicInfoController: formActions.basicInfoController,
|
||||
basicInfoDerived: setup.basicInfoDerived,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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('上传资料表头与模板不一致')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user