feat(intake): add sample measurement and product pricing workbook import/export

sample measurement: export template with image/barcode/L-W-H-weight columns (all batches) and import backfill by barcode into draft rows with per-row skip reasons; product pricing: export template with image/barcode/cost-price and import backfill cost price only; both reuse the product limit workbook pattern, keep edits in draft until the existing confirm/push flow, no API or schema changes.
This commit is contained in:
WuYongJie 2026-09-02 15:19:04 +08:00
parent 58966bba20
commit adfecb4c67
19 changed files with 2073 additions and 0 deletions

View File

@ -38,12 +38,14 @@ describe("buildNonIntegratedDetailChrome", () => {
productContext: {},
productFactoryActions: {},
productLimitWorkbook: {},
productPricingWorkbook: {},
productPhotoUploadActions: {},
productWorkflowActions: {},
qualityInspectionActions: {},
reviewDerived: {},
reviewSheetAssets: {},
reviewSheetFields: {},
sampleMeasurementWorkbook: {},
sampleWorkflowActions: {},
secondaryRenderers: { workspaceImageDialog: {} },
seriesDerived: {},

View File

@ -64,12 +64,14 @@ type DetailChromeProps = Required<
productContext: ProductSetupState["productContext"];
productFactoryActions: FormActionState["productFactoryActions"];
productLimitWorkbook: SkuReviewActionState["productLimitWorkbook"];
productPricingWorkbook: SkuReviewActionState["productPricingWorkbook"];
productPhotoUploadActions: FileActionState["productPhotoUploadActions"];
productWorkflowActions: FormActionState["productWorkflowActions"];
qualityInspectionActions: SkuReviewActionState["qualityInspectionActions"];
reviewDerived: WorkflowDerivedState["reviewDerived"];
reviewSheetAssets: SkuReviewActionState["reviewSheetAssets"];
reviewSheetFields: SkuReviewActionState["reviewSheetFields"];
sampleMeasurementWorkbook: SkuReviewActionState["sampleMeasurementWorkbook"];
sampleWorkflowActions: DocumentActionState["sampleWorkflowActions"];
secondaryRenderers: SecondaryRendererState;
seriesDerived: ProductSetupState["seriesDerived"];
@ -109,12 +111,14 @@ export function buildNonIntegratedDetailChrome({
productContext,
productFactoryActions,
productLimitWorkbook,
productPricingWorkbook,
productPhotoUploadActions,
productWorkflowActions,
qualityInspectionActions,
reviewDerived,
reviewSheetAssets,
reviewSheetFields,
sampleMeasurementWorkbook,
sampleWorkflowActions,
secondaryRenderers,
seriesDerived,
@ -148,6 +152,10 @@ export function buildNonIntegratedDetailChrome({
documentDownloads.handleDownloadWorkspaceFiles,
onExportProductLimitWorkbook:
productLimitWorkbook.handleExportProductLimitWorkbook,
onExportProductPricingWorkbook:
productPricingWorkbook.handleExportProductPricingWorkbook,
onExportSampleMeasurementWorkbook:
sampleMeasurementWorkbook.handleExportSampleMeasurementWorkbook,
onExportQualityInspection:
qualityInspectionActions.handleExportQualityInspection,
onExportReviewSheet: reviewSheetAssets.handleExportReviewSheet,
@ -155,6 +163,10 @@ export function buildNonIntegratedDetailChrome({
sampleWorkflowActions.handleFinishedSampleShipmentStatusClick,
onImportProductLimitWorkbookChange:
productLimitWorkbook.handleImportProductLimitWorkbookChange,
onImportProductPricingWorkbookChange:
productPricingWorkbook.handleImportProductPricingWorkbookChange,
onImportSampleMeasurementWorkbookChange:
sampleMeasurementWorkbook.handleImportSampleMeasurementWorkbookChange,
onOpenProductPhotoCurrentUpload:
documentSelectionActions.openProductPhotoCurrentUpload,
onOpenProductPhotoPanelUpload:
@ -232,11 +244,19 @@ export function buildNonIntegratedDetailChrome({
permissionState.isCurrentUserPlainReadOnlyViewer,
isExportingProductLimit:
productLimitWorkbook.isExportingProductLimit,
isExportingProductPricing:
productPricingWorkbook.isExportingProductPricing,
isExportingQualityInspection:
qualityInspectionActions.isExportingQualityInspection,
isExportingReviewSheet: reviewSheetAssets.isExportingReviewSheet,
isExportingSampleMeasurement:
sampleMeasurementWorkbook.isExportingSampleMeasurement,
isImportingProductLimit:
productLimitWorkbook.isImportingProductLimit,
isImportingProductPricing:
productPricingWorkbook.isImportingProductPricing,
isImportingSampleMeasurement:
sampleMeasurementWorkbook.isImportingSampleMeasurement,
isProductFactoryReopenPickerOpen:
workflowState.isProductFactoryReopenPickerOpen,
isProductLimitNodeLocked:
@ -248,6 +268,10 @@ export function buildNonIntegratedDetailChrome({
workflowState.productFactoryReopenField,
productLimitWorkbookInputRef:
inputRefs.productLimitWorkbookInputRef,
productPricingWorkbookInputRef:
inputRefs.productPricingWorkbookInputRef,
sampleMeasurementWorkbookInputRef:
inputRefs.sampleMeasurementWorkbookInputRef,
productPhotoCurrentUploadInputRef:
inputRefs.productPhotoCurrentUploadInputRef,
productPhotoDetailUploadInputRef:

View File

@ -164,6 +164,8 @@ export function buildNonIntegratedDetailRenderers({
productContext: setup.productContext,
productFactoryActions: formActions.productFactoryActions,
productLimitWorkbook: skuActions.productLimitWorkbook,
productPricingWorkbook: skuActions.productPricingWorkbook,
sampleMeasurementWorkbook: skuActions.sampleMeasurementWorkbook,
productPhotoUploadActions: fileActions.productPhotoUploadActions,
productWorkflowActions: formActions.productWorkflowActions,
qualityInspectionActions: skuActions.qualityInspectionActions,

View File

@ -1,6 +1,8 @@
import type { ComponentProps } from "react";
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";
import {
DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS,
DOCUMENT_WORKSPACE_CONFIRMED_CLASS,
@ -49,15 +51,21 @@ type DirectStateName =
| "isCurrentUserAdmin"
| "isCurrentUserPlainReadOnlyViewer"
| "isExportingProductLimit"
| "isExportingProductPricing"
| "isExportingQualityInspection"
| "isExportingReviewSheet"
| "isExportingSampleMeasurement"
| "isImportingProductLimit"
| "isImportingProductPricing"
| "isImportingSampleMeasurement"
| "isProductFactoryReopenPickerOpen"
| "isProductLimitNodeLocked"
| "isProductPricingNodeLocked"
| "isReadOnly"
| "productFactoryReopenField"
| "productLimitWorkbookInputRef"
| "productPricingWorkbookInputRef"
| "sampleMeasurementWorkbookInputRef"
| "productPhotoCurrentUploadInputRef"
| "productPhotoDetailUploadInputRef"
| "selectedProductPhotoTarget";
@ -150,6 +158,10 @@ export function buildNonIntegratedDocumentWorkspaceHeaderActions({
isSampleMeasurementWorkspace: presentation.isSampleMeasurementWorkspace,
productLimitWorkbookAccept:
NON_INTEGRATED_PRODUCT_LIMIT_WORKBOOK_ACCEPT,
productPricingWorkbookAccept:
NON_INTEGRATED_PRODUCT_PRICING_WORKBOOK_ACCEPT,
sampleMeasurementWorkbookAccept:
NON_INTEGRATED_SAMPLE_MEASUREMENT_WORKBOOK_ACCEPT,
qualityStandardFileAccept: QUALITY_STANDARD_FILE_ACCEPT,
reviewRoundCount: rows.reviewRounds.length,
};

View File

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

View File

@ -10,6 +10,10 @@ export function useNonIntegratedDetailInputRefs() {
const batchSkuImageInputRef = useRef<HTMLInputElement | null>(null);
const skuWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const productLimitWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const productPricingWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const sampleMeasurementWorkbookInputRef = useRef<HTMLInputElement | null>(
null,
);
const fileUploadInputRef = useRef<HTMLInputElement | null>(null);
const documentWorkspaceUploadInputRef = useRef<HTMLInputElement | null>(null);
const installationWorkspaceUploadInputRef = useRef<HTMLInputElement | null>(
@ -46,6 +50,8 @@ export function useNonIntegratedDetailInputRefs() {
productLimitWorkbookInputRef,
productPhotoCurrentUploadInputRef,
productPhotoDetailUploadInputRef,
productPricingWorkbookInputRef,
sampleMeasurementWorkbookInputRef,
skuWorkbookInputRef,
};
}

View File

@ -9,6 +9,8 @@ import { useSkuCreationController } from "./workspaces/basic-info/useSkuCreation
import { useSkuDisplayState } from "./workspaces/basic-info/useSkuDisplayState";
import { useSkuWorkbookController } from "./workspaces/basic-info/useSkuWorkbookController";
import { useProductLimitWorkbookController } from "./workspaces/product-workflow/useProductLimitWorkbookController";
import { useProductPricingWorkbookController } from "./workspaces/product-workflow/useProductPricingWorkbookController";
import { useSampleMeasurementWorkbookController } from "./workspaces/sample-workflow/useSampleMeasurementWorkbookController";
import { useQualityInspectionController } from "./workspaces/quality-inspection/useQualityInspectionController";
import { useReviewSheetAssetController } from "./workspaces/review-sheet/useReviewSheetAssetController";
import { useReviewSheetFieldController } from "./workspaces/review-sheet/useReviewSheetFieldController";
@ -22,6 +24,12 @@ jest.mock("./workspaces/basic-info/useSkuCreationController");
jest.mock("./workspaces/basic-info/useSkuDisplayState");
jest.mock("./workspaces/basic-info/useSkuWorkbookController");
jest.mock("./workspaces/product-workflow/useProductLimitWorkbookController");
jest.mock(
"./workspaces/product-workflow/useProductPricingWorkbookController",
);
jest.mock(
"./workspaces/sample-workflow/useSampleMeasurementWorkbookController",
);
jest.mock("./workspaces/quality-inspection/useQualityInspectionController");
jest.mock("./workspaces/review-sheet/useReviewSheetAssetController");
jest.mock("./workspaces/review-sheet/useReviewSheetFieldController");
@ -35,6 +43,12 @@ describe("useNonIntegratedSkuReviewActionControllers", () => {
skuBatchLifecycle: { handleAddSkuBatch: jest.fn() },
skuWorkbook: { handleExportSkuWorkbook: jest.fn() },
productLimitWorkbook: { handleExportProductLimitWorkbook: jest.fn() },
productPricingWorkbook: {
handleExportProductPricingWorkbook: jest.fn(),
},
sampleMeasurementWorkbook: {
handleExportSampleMeasurementWorkbook: jest.fn(),
},
bomWorkbook: { handleExportBomWorkbook: jest.fn() },
workspaceNavigation: { navigateToIntakeList: jest.fn() },
qualityInspectionActions: { handleExportQualityInspection: jest.fn() },
@ -54,6 +68,12 @@ describe("useNonIntegratedSkuReviewActionControllers", () => {
jest
.mocked(useProductLimitWorkbookController)
.mockReturnValue(returns.productLimitWorkbook as never);
jest
.mocked(useProductPricingWorkbookController)
.mockReturnValue(returns.productPricingWorkbook as never);
jest
.mocked(useSampleMeasurementWorkbookController)
.mockReturnValue(returns.sampleMeasurementWorkbook as never);
jest.mocked(useBomWorkbookController).mockReturnValue(returns.bomWorkbook as never);
jest
.mocked(useNonIntegratedWorkspaceNavigationController)
@ -115,6 +135,7 @@ describe("useNonIntegratedSkuReviewActionControllers", () => {
spuEstimate: {},
supplementSelection: {},
transferProgress: {},
workflowCompletion: {},
workflowConfirmation: {},
workflowRows: {},
workflowState: {},
@ -130,6 +151,8 @@ describe("useNonIntegratedSkuReviewActionControllers", () => {
aiWhiteBackground: returns.aiWhiteBackground,
bomWorkbook: returns.bomWorkbook,
productLimitWorkbook: returns.productLimitWorkbook,
productPricingWorkbook: returns.productPricingWorkbook,
sampleMeasurementWorkbook: returns.sampleMeasurementWorkbook,
qualityInspectionActions: returns.qualityInspectionActions,
reviewSheetAssets: returns.reviewSheetAssets,
reviewSheetFields: returns.reviewSheetFields,

View File

@ -9,6 +9,8 @@ import { useSkuCreationController } from "./workspaces/basic-info/useSkuCreation
import { useSkuDisplayState } from "./workspaces/basic-info/useSkuDisplayState";
import { useSkuWorkbookController } from "./workspaces/basic-info/useSkuWorkbookController";
import { useProductLimitWorkbookController } from "./workspaces/product-workflow/useProductLimitWorkbookController";
import { useProductPricingWorkbookController } from "./workspaces/product-workflow/useProductPricingWorkbookController";
import { useSampleMeasurementWorkbookController } from "./workspaces/sample-workflow/useSampleMeasurementWorkbookController";
import { useQualityInspectionController } from "./workspaces/quality-inspection/useQualityInspectionController";
import { useReviewSheetAssetController } from "./workspaces/review-sheet/useReviewSheetAssetController";
import { useReviewSheetFieldController } from "./workspaces/review-sheet/useReviewSheetFieldController";
@ -25,6 +27,7 @@ import type { useNonIntegratedPersistenceCoordinator } from "./useNonIntegratedP
import type { useNonIntegratedProductSetupController } from "./useNonIntegratedProductSetupController";
import type { useNonIntegratedProjectState } from "./useNonIntegratedProjectState";
import type { useNonIntegratedSkuState } from "./useNonIntegratedSkuState";
import type { useNonIntegratedWorkflowCompletion } from "./useNonIntegratedWorkflowCompletion";
import type { useNonIntegratedWorkflowDerivedController } from "./useNonIntegratedWorkflowDerivedController";
import type { useNonIntegratedWorkflowState } from "./useNonIntegratedWorkflowState";
import type { useSpuEstimateRefreshToken } from "./useSpuEstimateRefreshToken";
@ -93,6 +96,7 @@ type SkuReviewActionControllerProps = Pick<
spuEstimate: ReturnType<typeof useSpuEstimateRefreshToken>;
supplementSelection: WorkflowDerivedState["supplementSelection"];
transferProgress: ReturnType<typeof useTransferProgressState>;
workflowCompletion: ReturnType<typeof useNonIntegratedWorkflowCompletion>;
workflowConfirmation: WorkflowDerivedState["workflowConfirmation"];
workflowRows: WorkflowDerivedState["workflowRows"];
workflowState: ReturnType<typeof useNonIntegratedWorkflowState>;
@ -140,6 +144,7 @@ export function useNonIntegratedSkuReviewActionControllers({
spuEstimate,
supplementSelection,
transferProgress,
workflowCompletion,
workflowConfirmation,
workflowRows,
workflowState,
@ -291,6 +296,56 @@ export function useNonIntegratedSkuReviewActionControllers({
updateImportProgress: transferProgress.updateImportProgress,
workflowSkuItems: seriesDerived.workflowSkuItems,
});
const productPricingWorkbook = useProductPricingWorkbookController({
allProductPricingRows: workflowRows.productPricingRows,
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser:
documentDownloads.denyWorkspaceDownloadForCurrentUser,
finishDownloadProgress: transferProgress.finishDownloadProgress,
finishImportProgress: transferProgress.finishImportProgress,
isProductPricingNodeLocked: workflowConfirmation.isProductPricingNodeLocked,
isReadOnly: editability.isReadOnly,
pricingRows: workflowState.pricingRows,
setDownloadProgress: transferProgress.setDownloadProgress,
setPricingRows: workflowState.setPricingRows,
setSaveMessage: overlayState.setSaveMessage,
setSaveMessageType: overlayState.setSaveMessageType,
setUploadProgress: transferProgress.setUploadProgress,
spu: formState.formValues.spu || formState.formValues.productCode,
startGeneratedDownloadProgress:
transferProgress.startGeneratedDownloadProgress,
startImportProgress: transferProgress.startImportProgress,
updateGeneratedDownloadProgress:
transferProgress.updateGeneratedDownloadProgress,
updateImportProgress: transferProgress.updateImportProgress,
workflowSkuItems: seriesDerived.workflowSkuItems,
});
const sampleMeasurementWorkbook = useSampleMeasurementWorkbookController({
allSampleMeasurementRows: workflowRows.productSampleMeasurementRows,
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser:
documentDownloads.denyWorkspaceDownloadForCurrentUser,
finishDownloadProgress: transferProgress.finishDownloadProgress,
finishImportProgress: transferProgress.finishImportProgress,
isReadOnly: editability.isReadOnly,
isSampleMeasurementRowEditable: (skuId: string) =>
workflowRows.isSkuItemFactoryConfirmed(skuId) &&
!workflowCompletion.isSampleMeasurementSkuLocked(skuId),
sampleMeasurementRows: workflowState.sampleMeasurementRows,
setDownloadProgress: transferProgress.setDownloadProgress,
setSampleMeasurementRows: workflowState.setSampleMeasurementRows,
setSaveMessage: overlayState.setSaveMessage,
setSaveMessageType: overlayState.setSaveMessageType,
setUploadProgress: transferProgress.setUploadProgress,
spu: formState.formValues.spu || formState.formValues.productCode,
startGeneratedDownloadProgress:
transferProgress.startGeneratedDownloadProgress,
startImportProgress: transferProgress.startImportProgress,
updateGeneratedDownloadProgress:
transferProgress.updateGeneratedDownloadProgress,
updateImportProgress: transferProgress.updateImportProgress,
workflowSkuItems: seriesDerived.workflowSkuItems,
});
const bomWorkbook = useBomWorkbookController({
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser:
@ -439,9 +494,11 @@ export function useNonIntegratedSkuReviewActionControllers({
aiWhiteBackground,
bomWorkbook,
productLimitWorkbook,
productPricingWorkbook,
qualityInspectionActions,
reviewSheetAssets,
reviewSheetFields,
sampleMeasurementWorkbook,
skuBatchLifecycle,
skuCreation,
skuDisplay,

View File

@ -1,8 +1,12 @@
import type { ChangeEvent, RefObject } from "react";
import Button from "@/components/ui/Button";
type ProductPricingHeaderActionsProps = {
actionGroupClassName: string;
canDownloadWorkspaceFiles: boolean;
canUsePushControls: boolean;
isExporting: boolean;
isImporting: boolean;
isReadOnly: boolean;
isNodeLocked: boolean;
rowCount: number;
@ -10,12 +14,19 @@ type ProductPricingHeaderActionsProps = {
rowsComplete: boolean;
rowsReadyForPush: boolean;
isJstPushEnabled: boolean;
workbookInputRef: RefObject<HTMLInputElement | null>;
workbookAccept: string;
onExportWorkbook: () => void;
onImportWorkbookChange: (event: ChangeEvent<HTMLInputElement>) => void;
onBatchConfirmPush: () => void;
};
export function ProductPricingHeaderActions({
actionGroupClassName,
canDownloadWorkspaceFiles,
canUsePushControls,
isExporting,
isImporting,
isReadOnly,
isNodeLocked,
rowCount,
@ -23,6 +34,10 @@ export function ProductPricingHeaderActions({
rowsComplete,
rowsReadyForPush,
isJstPushEnabled,
workbookInputRef,
workbookAccept,
onExportWorkbook,
onImportWorkbookChange,
onBatchConfirmPush,
}: ProductPricingHeaderActionsProps) {
const disabled =
@ -44,6 +59,37 @@ export function ProductPricingHeaderActions({
return (
<div className={actionGroupClassName}>
{canDownloadWorkspaceFiles ? (
<Button
variant="plain"
size="small"
onClick={onExportWorkbook}
disabled={isExporting || rowCount === 0}
>
{isExporting ? "导出中..." : "导出模板"}
</Button>
) : null}
{isReadOnly ? null : (
<>
<Button
variant="plain"
size="small"
onClick={() => workbookInputRef.current?.click()}
disabled={isNodeLocked || isImporting || rowCount === 0}
title={isNodeLocked ? "已推送后不可导入修改" : undefined}
>
{isImporting ? "导入中..." : "导入模板"}
</Button>
<input
ref={workbookInputRef}
type="file"
accept={workbookAccept}
className="hidden"
data-testid="product-pricing-import-input"
onChange={onImportWorkbookChange}
/>
</>
)}
{canUsePushControls ? (
<Button
size="small"

View File

@ -10,6 +10,12 @@ import {
downloadNonIntegratedProductLimitWorkbook,
type NonIntegratedProductLimitWorkbookRow,
} from "@/lib/non-integrated-product-limit-workbook";
import {
createNonIntegratedProductPricingWorkbook,
downloadNonIntegratedProductPricingWorkbook,
type NonIntegratedProductPricingWorkbookImportRow,
type NonIntegratedProductPricingWorkbookRow,
} from "@/lib/non-integrated-product-pricing-workbook";
import { PLANNING_LAYOUT_STYLE_TONE_OPTIONS } from "../../planningLayoutConfig";
import {
getPlanningLayoutHundredPriceBand,
@ -20,6 +26,7 @@ import { normalizeNationalStandardCompliance } from "../../skuFieldConfig";
export const PRODUCT_PRICING_JST_PUSH_ENABLED = true;
export type ProductPricingDisplayRow = NonIntegratedPricingRow & {
image: string;
model: string;
skuCode: string;
barcode: string;
@ -107,6 +114,7 @@ export function createProductPricingRowFromSku(
): ProductPricingDisplayRow {
return {
skuId: sku.id,
image: sku.image.trim(),
model: sku.model.trim(),
skuCode: sku.skuCode.trim(),
barcode: sku.barcode.trim(),
@ -158,6 +166,136 @@ export function buildProductPricingRows(
);
}
export function buildProductPricingWorkbookFileName(spu: string) {
return `${spu.trim() || "产品核价"}-产品核价模板.xlsx`;
}
export function buildProductPricingWorkbookRows(
rows: ProductPricingDisplayRow[],
): NonIntegratedProductPricingWorkbookRow[] {
return rows.map((row) => ({
image: row.image.trim(),
barcode: row.barcode.trim(),
costPrice: row.costPrice.trim(),
}));
}
export async function downloadProductPricingWorkbook({
onProgress,
rows,
spu,
}: {
onProgress?: (percent: number, fileName: string) => void;
rows: ProductPricingDisplayRow[];
spu: string;
}) {
const fileName = buildProductPricingWorkbookFileName(spu);
const workbook = await createNonIntegratedProductPricingWorkbook(
buildProductPricingWorkbookRows(rows),
);
onProgress?.(80, fileName);
downloadNonIntegratedProductPricingWorkbook(workbook, fileName);
return fileName;
}
export type ProductPricingWorkbookSkippedRow = {
rowNumber: number;
barcode: string;
reason: string;
};
export function applyProductPricingWorkbookRows({
currentRows,
importedRows,
displayRows,
isProductPricingRowEditable,
skuItems,
}: {
currentRows: NonIntegratedPricingRow[];
importedRows: NonIntegratedProductPricingWorkbookImportRow[];
displayRows: ProductPricingDisplayRow[];
isProductPricingRowEditable: (skuId: string) => boolean;
skuItems: NonIntegratedSkuItem[];
}) {
const skuByBarcode = new Map(
displayRows
.map((row) => [row.barcode.trim().toUpperCase(), row] as const)
.filter(([barcode]) => barcode.length > 0),
);
const skippedRows: ProductPricingWorkbookSkippedRow[] = [];
const importedSkuIds = new Set<string>();
const rowBySkuId = new Map(currentRows.map((row) => [row.skuId, row]));
for (const row of importedRows) {
const barcode = row.barcode.trim();
if (!barcode) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode: "",
reason: "条码为空",
});
continue;
}
const matchedSku = skuByBarcode.get(barcode.toUpperCase());
if (!matchedSku) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: "未匹配到当前产品的条码",
});
continue;
}
const costPrice = row.costPrice.trim();
if (!costPrice) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: "成本价为空",
});
continue;
}
if (!isProductPricingRowEditable(matchedSku.skuId)) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: "已推送或已锁定,不可修改",
});
continue;
}
if (!/^\d+(?:\.\d+)?$/.test(costPrice)) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: `成本价“${costPrice}”不是数字`,
});
continue;
}
const existingRow = rowBySkuId.get(matchedSku.skuId);
rowBySkuId.set(matchedSku.skuId, {
...existingRow,
skuId: matchedSku.skuId,
costPrice,
productTag: existingRow?.productTag?.trim() ?? "",
});
importedSkuIds.add(matchedSku.skuId);
}
return {
importedCount: importedSkuIds.size,
nextRows: skuItems
.map((sku) => rowBySkuId.get(sku.id))
.filter((row): row is NonIntegratedPricingRow => Boolean(row)),
skippedRows,
};
}
export function normalizePricingRowsForPayload(
rows: NonIntegratedPricingRow[],
): NonIntegratedPricingRow[] {

View File

@ -0,0 +1,181 @@
import { useState, type ChangeEvent, type Dispatch, type SetStateAction } from "react";
import { parseNonIntegratedProductPricingWorkbook } from "@/lib/non-integrated-product-pricing-workbook";
import type {
NonIntegratedPricingRow,
NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import type {
DownloadProgressState,
UploadProgressState,
} from "../documents/progressHelpers";
import {
applyProductPricingWorkbookRows,
buildProductPricingWorkbookFileName,
downloadProductPricingWorkbook,
type ProductPricingDisplayRow,
type ProductPricingWorkbookSkippedRow,
} from "./productWorkflowRowHelpers";
type SaveMessageType = "success" | "error";
const MAX_SKIPPED_ROWS_IN_MESSAGE = 8;
function buildSkippedRowsMessage(skippedRows: ProductPricingWorkbookSkippedRow[]) {
const entries = skippedRows
.slice(0, MAX_SKIPPED_ROWS_IN_MESSAGE)
.map(
(row) =>
`第${row.rowNumber}行${row.barcode ? `(${row.barcode})` : ""}:${row.reason}`,
);
const suffix =
skippedRows.length > MAX_SKIPPED_ROWS_IN_MESSAGE
? `等共 ${skippedRows.length} 行`
: entries.join(";");
return entries.length > 0 ? `,跳过 ${skippedRows.length} 行:${suffix}` : "";
}
type UseProductPricingWorkbookControllerParams = {
allProductPricingRows: ProductPricingDisplayRow[];
canDownloadWorkspaceFiles: boolean;
denyWorkspaceDownloadForCurrentUser: () => void;
finishDownloadProgress: (fileName?: string) => void;
finishImportProgress: (fileName?: string) => void;
isProductPricingNodeLocked: boolean;
isReadOnly: boolean;
pricingRows: NonIntegratedPricingRow[];
setDownloadProgress: Dispatch<SetStateAction<DownloadProgressState | null>>;
setPricingRows: Dispatch<SetStateAction<NonIntegratedPricingRow[]>>;
setSaveMessage: Dispatch<SetStateAction<string>>;
setSaveMessageType: Dispatch<SetStateAction<SaveMessageType>>;
setUploadProgress: Dispatch<SetStateAction<UploadProgressState | null>>;
spu: string;
startGeneratedDownloadProgress: (fileName: string) => void;
startImportProgress: (file: File) => void;
updateGeneratedDownloadProgress: (percent: number, fileName?: string) => void;
updateImportProgress: (percent: number, fileName?: string) => void;
workflowSkuItems: NonIntegratedSkuItem[];
};
export function useProductPricingWorkbookController({
allProductPricingRows,
canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser,
finishDownloadProgress,
finishImportProgress,
isProductPricingNodeLocked,
isReadOnly,
pricingRows,
setDownloadProgress,
setPricingRows,
setSaveMessage,
setSaveMessageType,
setUploadProgress,
spu,
startGeneratedDownloadProgress,
startImportProgress,
updateGeneratedDownloadProgress,
updateImportProgress,
workflowSkuItems,
}: UseProductPricingWorkbookControllerParams) {
const [isExportingProductPricing, setIsExportingProductPricing] =
useState(false);
const [isImportingProductPricing, setIsImportingProductPricing] =
useState(false);
async function handleExportProductPricingWorkbook() {
if (!canDownloadWorkspaceFiles) {
denyWorkspaceDownloadForCurrentUser();
return;
}
if (allProductPricingRows.length === 0 || isExportingProductPricing) {
return;
}
const fileName = buildProductPricingWorkbookFileName(spu);
setIsExportingProductPricing(true);
try {
startGeneratedDownloadProgress(fileName);
const downloadedFileName = await downloadProductPricingWorkbook({
onProgress: updateGeneratedDownloadProgress,
rows: allProductPricingRows,
spu,
});
finishDownloadProgress(downloadedFileName);
} catch (error) {
setSaveMessage(
error instanceof Error ? error.message : "产品核价模板导出失败",
);
setSaveMessageType("error");
setDownloadProgress(null);
} finally {
setIsExportingProductPricing(false);
}
}
async function handleImportProductPricingWorkbookChange(
event: ChangeEvent<HTMLInputElement>,
) {
if (isReadOnly || isProductPricingNodeLocked) {
event.target.value = "";
return;
}
const file = event.target.files?.[0];
if (!file) return;
setIsImportingProductPricing(true);
try {
startImportProgress(file);
const { rows } = await parseNonIntegratedProductPricingWorkbook(file);
updateImportProgress(45, file.name);
if (rows.length === 0) {
throw new Error("上传资料中没有可导入的产品核价行");
}
const applyImport = (currentRows: NonIntegratedPricingRow[]) =>
applyProductPricingWorkbookRows({
currentRows,
displayRows: allProductPricingRows,
importedRows: rows,
isProductPricingRowEditable: (skuId) =>
!isProductPricingNodeLocked,
skuItems: workflowSkuItems,
});
const importResult = applyImport(pricingRows);
setPricingRows((current) =>
current === pricingRows ? importResult.nextRows : applyImport(current).nextRows,
);
const skippedMessage = buildSkippedRowsMessage(importResult.skippedRows);
if (importResult.importedCount === 0) {
setSaveMessage(`没有可导入的产品核价行${skippedMessage}`);
setSaveMessageType("error");
} else {
setSaveMessage(
`已导入 ${importResult.importedCount} 行产品核价${skippedMessage},请核对后确认。`,
);
setSaveMessageType("success");
}
finishImportProgress(file.name);
} catch (error) {
setSaveMessage(
error instanceof Error ? error.message : "产品核价模板导入失败",
);
setSaveMessageType("error");
setUploadProgress(null);
} finally {
setIsImportingProductPricing(false);
event.target.value = "";
}
}
return {
handleExportProductPricingWorkbook,
handleImportProductPricingWorkbookChange,
isExportingProductPricing,
isImportingProductPricing,
};
}

View File

@ -1,24 +1,71 @@
import type { ChangeEvent, RefObject } from "react";
import Button from "@/components/ui/Button";
type SampleMeasurementHeaderActionsProps = {
actionGroupClassName: string;
canDownloadWorkspaceFiles: boolean;
isExporting: boolean;
isImporting: boolean;
isReadOnly: boolean;
rowCount: number;
isProductFactoryConfirmed: boolean;
hasPendingRows: boolean;
isSampleMeasurementConfirmed: boolean;
workbookInputRef: RefObject<HTMLInputElement | null>;
workbookAccept: string;
onExportWorkbook: () => void;
onImportWorkbookChange: (event: ChangeEvent<HTMLInputElement>) => void;
onStatusClick: () => void;
};
export function SampleMeasurementHeaderActions({
actionGroupClassName,
canDownloadWorkspaceFiles,
isExporting,
isImporting,
isReadOnly,
rowCount,
isProductFactoryConfirmed,
hasPendingRows,
isSampleMeasurementConfirmed,
workbookInputRef,
workbookAccept,
onExportWorkbook,
onImportWorkbookChange,
onStatusClick,
}: SampleMeasurementHeaderActionsProps) {
return (
<div className={actionGroupClassName}>
{canDownloadWorkspaceFiles ? (
<Button
variant="plain"
size="small"
onClick={onExportWorkbook}
disabled={isExporting || rowCount === 0}
>
{isExporting ? "导出中..." : "导出模板"}
</Button>
) : null}
{isReadOnly ? null : (
<>
<Button
variant="plain"
size="small"
onClick={() => workbookInputRef.current?.click()}
disabled={isImporting || rowCount === 0}
>
{isImporting ? "导入中..." : "导入模板"}
</Button>
<input
ref={workbookInputRef}
type="file"
accept={workbookAccept}
className="hidden"
data-testid="sample-measurement-import-input"
onChange={onImportWorkbookChange}
/>
</>
)}
<Button
size="small"
data-testid="sample-measurement-confirm"

View File

@ -6,6 +6,12 @@ import {
SAMPLE_MEASUREMENT_REQUIRED_FIELDS,
type FinishedSampleShipmentEditableKey,
} from "../../skuFieldConfig";
import {
createNonIntegratedSampleMeasurementWorkbook,
downloadNonIntegratedSampleMeasurementWorkbook,
type NonIntegratedSampleMeasurementWorkbookImportRow,
type NonIntegratedSampleMeasurementWorkbookRow,
} from "@/lib/non-integrated-sample-measurement-workbook";
import type {
NonIntegratedFinishedSampleShipmentRow,
NonIntegratedSampleMeasurementRow,
@ -18,6 +24,7 @@ import {
export type ProductSampleMeasurementDisplayRow =
NonIntegratedSampleMeasurementRow & {
image: string;
model: string;
skuCode: string;
setCount: string;
@ -32,6 +39,7 @@ export function createSampleMeasurementRowFromSku(
): ProductSampleMeasurementDisplayRow {
return {
skuId: sku.id,
image: sku.image.trim(),
model: sku.model.trim(),
skuCode: sku.skuCode.trim(),
setCount: sku.setCount.trim() || "1",
@ -86,6 +94,155 @@ export function updateSampleMeasurementRowsForSku(
.filter((row): row is NonIntegratedSampleMeasurementRow => Boolean(row));
}
export function buildSampleMeasurementWorkbookFileName(spu: string) {
return `${spu.trim() || "样包测量"}-样包测量模板.xlsx`;
}
export function buildSampleMeasurementWorkbookRows(
rows: ProductSampleMeasurementDisplayRow[],
): NonIntegratedSampleMeasurementWorkbookRow[] {
return rows.map((row) => ({
image: row.image.trim(),
barcode: row.barcode.trim(),
lengthCm: row.lengthCm.trim(),
widthCm: row.widthCm.trim(),
heightCm: row.heightCm.trim(),
weightKg: row.weightKg.trim(),
}));
}
export async function downloadSampleMeasurementWorkbook({
onProgress,
rows,
spu,
}: {
onProgress?: (percent: number, fileName: string) => void;
rows: ProductSampleMeasurementDisplayRow[];
spu: string;
}) {
const fileName = buildSampleMeasurementWorkbookFileName(spu);
const workbook = await createNonIntegratedSampleMeasurementWorkbook(
buildSampleMeasurementWorkbookRows(rows),
);
onProgress?.(80, fileName);
downloadNonIntegratedSampleMeasurementWorkbook(workbook, fileName);
return fileName;
}
export type SampleMeasurementWorkbookSkippedRow = {
rowNumber: number;
barcode: string;
reason: string;
};
const SAMPLE_MEASUREMENT_WORKBOOK_VALUE_FIELDS = [
{ key: "lengthCm", label: "长(CM)" },
{ key: "widthCm", label: "宽(CM)" },
{ key: "heightCm", label: "高(CM)" },
{ key: "weightKg", label: "重量(KG)" },
] as const;
export function applySampleMeasurementWorkbookRows({
currentRows,
importedRows,
displayRows,
isSampleMeasurementRowEditable,
skuItems,
}: {
currentRows: NonIntegratedSampleMeasurementRow[];
importedRows: NonIntegratedSampleMeasurementWorkbookImportRow[];
displayRows: ProductSampleMeasurementDisplayRow[];
isSampleMeasurementRowEditable: (skuId: string) => boolean;
skuItems: NonIntegratedSkuItem[];
}) {
const skuByBarcode = new Map(
displayRows
.map((row) => [row.barcode.trim().toUpperCase(), row] as const)
.filter(([barcode]) => barcode.length > 0),
);
const skippedRows: SampleMeasurementWorkbookSkippedRow[] = [];
const importedSkuIds = new Set<string>();
const rowBySkuId = new Map(currentRows.map((row) => [row.skuId, row]));
for (const row of importedRows) {
const barcode = row.barcode.trim();
if (!barcode) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode: "",
reason: "条码为空",
});
continue;
}
const matchedSku = skuByBarcode.get(barcode.toUpperCase());
if (!matchedSku) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: "未匹配到当前产品的条码",
});
continue;
}
const allValuesEmpty = SAMPLE_MEASUREMENT_WORKBOOK_VALUE_FIELDS.every(
({ key }) => !row[key].trim(),
);
if (allValuesEmpty) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: "长宽高重量均为空",
});
continue;
}
if (!isSampleMeasurementRowEditable(matchedSku.skuId)) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: "定厂未完成或样包测量已锁定",
});
continue;
}
const invalidField = SAMPLE_MEASUREMENT_WORKBOOK_VALUE_FIELDS.find(
({ key }) => {
const value = row[key].trim();
return value.length > 0 && !/^\d+(?:\.\d+)?$/.test(value);
},
);
if (invalidField) {
skippedRows.push({
rowNumber: row.rowNumber,
barcode,
reason: `${invalidField.label}“${row[invalidField.key].trim()}”不是数字`,
});
continue;
}
const existingRow = rowBySkuId.get(matchedSku.skuId);
rowBySkuId.set(matchedSku.skuId, {
skuId: matchedSku.skuId,
lengthCm: row.lengthCm.trim() || existingRow?.lengthCm?.trim() || "",
widthCm: row.widthCm.trim() || existingRow?.widthCm?.trim() || "",
heightCm: row.heightCm.trim() || existingRow?.heightCm?.trim() || "",
weightKg: row.weightKg.trim() || existingRow?.weightKg?.trim() || "",
});
importedSkuIds.add(matchedSku.skuId);
}
return {
importedCount: importedSkuIds.size,
nextRows: skuItems
.map((sku) => rowBySkuId.get(sku.id))
.filter((row): row is NonIntegratedSampleMeasurementRow => Boolean(row)),
skippedRows,
};
}
export function normalizeSampleMeasurementRowsForPayload(
rows: NonIntegratedSampleMeasurementRow[],
): NonIntegratedSampleMeasurementRow[] {

View File

@ -0,0 +1,193 @@
import { useState, type ChangeEvent, type Dispatch, type SetStateAction } from "react";
import { parseNonIntegratedSampleMeasurementWorkbook } from "@/lib/non-integrated-sample-measurement-workbook";
import type {
NonIntegratedSampleMeasurementRow,
NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import type {
DownloadProgressState,
UploadProgressState,
} from "../documents/progressHelpers";
import {
applySampleMeasurementWorkbookRows,
buildSampleMeasurementWorkbookFileName,
downloadSampleMeasurementWorkbook,
type ProductSampleMeasurementDisplayRow,
type SampleMeasurementWorkbookSkippedRow,
} from "./sampleWorkflowHelpers";
type SaveMessageType = "success" | "error";
const MAX_SKIPPED_ROWS_IN_MESSAGE = 8;
function buildSkippedRowsMessage(skippedRows: SampleMeasurementWorkbookSkippedRow[]) {
const entries = skippedRows
.slice(0, MAX_SKIPPED_ROWS_IN_MESSAGE)
.map(
(row) =>
`第${row.rowNumber}行${row.barcode ? `(${row.barcode})` : ""}:${row.reason}`,
);
const suffix =
skippedRows.length > MAX_SKIPPED_ROWS_IN_MESSAGE
? `等共 ${skippedRows.length} 行`
: entries.join(";");
return entries.length > 0 ? `,跳过 ${skippedRows.length} 行:${suffix}` : "";
}
type UseSampleMeasurementWorkbookControllerParams = {
allSampleMeasurementRows: ProductSampleMeasurementDisplayRow[];
canDownloadWorkspaceFiles: boolean;
denyWorkspaceDownloadForCurrentUser: () => void;
finishDownloadProgress: (fileName?: string) => void;
finishImportProgress: (fileName?: string) => void;
isReadOnly: boolean;
isSampleMeasurementRowEditable: (skuId: string) => boolean;
sampleMeasurementRows: NonIntegratedSampleMeasurementRow[];
setDownloadProgress: Dispatch<SetStateAction<DownloadProgressState | null>>;
setSampleMeasurementRows: Dispatch<
SetStateAction<NonIntegratedSampleMeasurementRow[]>
>;
setSaveMessage: Dispatch<SetStateAction<string>>;
setSaveMessageType: Dispatch<SetStateAction<SaveMessageType>>;
setUploadProgress: Dispatch<SetStateAction<UploadProgressState | null>>;
spu: string;
startGeneratedDownloadProgress: (fileName: string) => void;
startImportProgress: (file: File) => void;
updateGeneratedDownloadProgress: (percent: number, fileName?: string) => void;
updateImportProgress: (percent: number, fileName?: string) => void;
workflowSkuItems: NonIntegratedSkuItem[];
};
export function useSampleMeasurementWorkbookController({
allSampleMeasurementRows,
canDownloadWorkspaceFiles,
denyWorkspaceDownloadForCurrentUser,
finishDownloadProgress,
finishImportProgress,
isReadOnly,
isSampleMeasurementRowEditable,
sampleMeasurementRows,
setDownloadProgress,
setSampleMeasurementRows,
setSaveMessage,
setSaveMessageType,
setUploadProgress,
spu,
startGeneratedDownloadProgress,
startImportProgress,
updateGeneratedDownloadProgress,
updateImportProgress,
workflowSkuItems,
}: UseSampleMeasurementWorkbookControllerParams) {
const [isExportingSampleMeasurement, setIsExportingSampleMeasurement] =
useState(false);
const [isImportingSampleMeasurement, setIsImportingSampleMeasurement] =
useState(false);
async function handleExportSampleMeasurementWorkbook() {
if (!canDownloadWorkspaceFiles) {
denyWorkspaceDownloadForCurrentUser();
return;
}
if (
allSampleMeasurementRows.length === 0 ||
isExportingSampleMeasurement
) {
return;
}
const fileName = buildSampleMeasurementWorkbookFileName(spu);
setIsExportingSampleMeasurement(true);
try {
startGeneratedDownloadProgress(fileName);
const downloadedFileName = await downloadSampleMeasurementWorkbook({
onProgress: updateGeneratedDownloadProgress,
rows: allSampleMeasurementRows,
spu,
});
finishDownloadProgress(downloadedFileName);
} catch (error) {
setSaveMessage(
error instanceof Error ? error.message : "样包测量模板导出失败",
);
setSaveMessageType("error");
setDownloadProgress(null);
} finally {
setIsExportingSampleMeasurement(false);
}
}
async function handleImportSampleMeasurementWorkbookChange(
event: ChangeEvent<HTMLInputElement>,
) {
if (isReadOnly) {
event.target.value = "";
return;
}
const file = event.target.files?.[0];
if (!file) return;
setIsImportingSampleMeasurement(true);
try {
startImportProgress(file);
const { rows } = await parseNonIntegratedSampleMeasurementWorkbook(file);
updateImportProgress(45, file.name);
if (rows.length === 0) {
throw new Error("上传资料中没有可导入的样包测量行");
}
const importResult = applySampleMeasurementWorkbookRows({
currentRows: sampleMeasurementRows,
displayRows: allSampleMeasurementRows,
importedRows: rows,
isSampleMeasurementRowEditable,
skuItems: workflowSkuItems,
});
setSampleMeasurementRows((current) =>
current === sampleMeasurementRows
? importResult.nextRows
: applySampleMeasurementWorkbookRows({
currentRows: current,
displayRows: allSampleMeasurementRows,
importedRows: rows,
isSampleMeasurementRowEditable,
skuItems: workflowSkuItems,
}).nextRows,
);
const skippedMessage = buildSkippedRowsMessage(
importResult.skippedRows,
);
if (importResult.importedCount === 0) {
setSaveMessage(`没有可导入的样包测量行${skippedMessage}`);
setSaveMessageType("error");
} else {
setSaveMessage(
`已导入 ${importResult.importedCount} 行样包测量${skippedMessage},请核对后确认。`,
);
setSaveMessageType("success");
}
finishImportProgress(file.name);
} catch (error) {
setSaveMessage(
error instanceof Error ? error.message : "样包测量模板导入失败",
);
setSaveMessageType("error");
setUploadProgress(null);
} finally {
setIsImportingSampleMeasurement(false);
event.target.value = "";
}
}
return {
handleExportSampleMeasurementWorkbook,
handleImportSampleMeasurementWorkbookChange,
isExportingSampleMeasurement,
isImportingSampleMeasurement,
};
}

View File

@ -71,11 +71,15 @@ export function DocumentWorkspaceHeaderActionsSlot({
isCurrentUserPlainReadOnlyViewer,
isDetectionWorkspace,
isExportingProductLimit,
isExportingProductPricing,
isExportingQualityInspection,
isExportingReviewSheet,
isExportingSampleMeasurement,
isFactoryQualityWorkspace,
isFinishedSampleShipmentWorkspace,
isImportingProductLimit,
isImportingProductPricing,
isImportingSampleMeasurement,
isInstallationWorkspace,
isJstPushEnabled,
isOpeningReferenceWorkspace,
@ -99,8 +103,12 @@ export function DocumentWorkspaceHeaderActionsSlot({
productLimitWorkbookInputRef,
productPhotoCurrentUploadInputRef,
productPhotoDetailUploadInputRef,
productPricingWorkbookAccept,
productPricingWorkbookInputRef,
qualityStandardFileAccept,
reviewRoundCount,
sampleMeasurementWorkbookAccept,
sampleMeasurementWorkbookInputRef,
selectedProductPhotoTarget,
onAddReviewRound,
onBatchConfirmProductPricingPush,
@ -112,10 +120,14 @@ export function DocumentWorkspaceHeaderActionsSlot({
onDownloadUploadFile,
onDownloadWorkspaceFiles,
onExportProductLimitWorkbook,
onExportProductPricingWorkbook,
onExportQualityInspection,
onExportReviewSheet,
onExportSampleMeasurementWorkbook,
onFinishedSampleShipmentStatusClick,
onImportProductLimitWorkbookChange,
onImportProductPricingWorkbookChange,
onImportSampleMeasurementWorkbookChange,
onOpenProductPhotoCurrentUpload,
onOpenProductPhotoPanelUpload,
onProductFactoryReopenFieldChange,
@ -176,11 +188,15 @@ export function DocumentWorkspaceHeaderActionsSlot({
isCurrentUserPlainReadOnlyViewer: boolean;
isDetectionWorkspace: boolean;
isExportingProductLimit: boolean;
isExportingProductPricing: boolean;
isExportingQualityInspection: boolean;
isExportingReviewSheet: boolean;
isExportingSampleMeasurement: boolean;
isFactoryQualityWorkspace: boolean;
isFinishedSampleShipmentWorkspace: boolean;
isImportingProductLimit: boolean;
isImportingProductPricing: boolean;
isImportingSampleMeasurement: boolean;
isInstallationWorkspace: boolean;
isJstPushEnabled: boolean;
isOpeningReferenceWorkspace: boolean;
@ -204,8 +220,12 @@ export function DocumentWorkspaceHeaderActionsSlot({
productLimitWorkbookInputRef: RefObject<HTMLInputElement | null>;
productPhotoCurrentUploadInputRef: RefObject<HTMLInputElement | null>;
productPhotoDetailUploadInputRef: RefObject<HTMLInputElement | null>;
productPricingWorkbookAccept: string;
productPricingWorkbookInputRef: RefObject<HTMLInputElement | null>;
qualityStandardFileAccept: string;
reviewRoundCount: number;
sampleMeasurementWorkbookAccept: string;
sampleMeasurementWorkbookInputRef: RefObject<HTMLInputElement | null>;
selectedProductPhotoTarget: ProductPhotoSelection;
onAddReviewRound: () => void;
onBatchConfirmProductPricingPush: () => void;
@ -217,12 +237,20 @@ export function DocumentWorkspaceHeaderActionsSlot({
onDownloadUploadFile: (file: NonIntegratedUploadFile) => void;
onDownloadWorkspaceFiles: (files: NonIntegratedUploadFile[]) => void;
onExportProductLimitWorkbook: () => void;
onExportProductPricingWorkbook: () => void;
onExportQualityInspection: () => void;
onExportReviewSheet: () => void;
onExportSampleMeasurementWorkbook: () => void;
onFinishedSampleShipmentStatusClick: () => void;
onImportProductLimitWorkbookChange: (
event: ChangeEvent<HTMLInputElement>,
) => void;
onImportProductPricingWorkbookChange: (
event: ChangeEvent<HTMLInputElement>,
) => void;
onImportSampleMeasurementWorkbookChange: (
event: ChangeEvent<HTMLInputElement>,
) => void;
onOpenProductPhotoCurrentUpload: (target: ProductPhotoSelection) => void;
onOpenProductPhotoPanelUpload: (panel: ProductPhotoPanel) => void;
onProductFactoryReopenFieldChange: (value: ProductFactoryReopenField) => void;
@ -289,10 +317,18 @@ export function DocumentWorkspaceHeaderActionsSlot({
return (
<SampleMeasurementHeaderActions
actionGroupClassName={actionGroupClassName}
canDownloadWorkspaceFiles={canDownloadWorkspaceFiles}
isExporting={isExportingSampleMeasurement}
isImporting={isImportingSampleMeasurement}
isReadOnly={isReadOnly}
rowCount={displayedProductSampleMeasurementRowCount}
isProductFactoryConfirmed={displayedProductFactoryIsConfirmed}
hasPendingRows={displayedSampleMeasurementHasPendingRows}
isSampleMeasurementConfirmed={displayedSampleMeasurementIsConfirmed}
workbookInputRef={sampleMeasurementWorkbookInputRef}
workbookAccept={sampleMeasurementWorkbookAccept}
onExportWorkbook={onExportSampleMeasurementWorkbook}
onImportWorkbookChange={onImportSampleMeasurementWorkbookChange}
onStatusClick={onSampleMeasurementStatusClick}
/>
);
@ -313,7 +349,10 @@ export function DocumentWorkspaceHeaderActionsSlot({
return (
<ProductPricingHeaderActions
actionGroupClassName={actionGroupClassName}
canDownloadWorkspaceFiles={canDownloadWorkspaceFiles}
canUsePushControls={canUseProductPricingPushControls}
isExporting={isExportingProductPricing}
isImporting={isImportingProductPricing}
isReadOnly={isReadOnly}
isNodeLocked={isProductPricingNodeLocked}
rowCount={displayedProductPricingRowCount}
@ -321,6 +360,10 @@ export function DocumentWorkspaceHeaderActionsSlot({
rowsComplete={displayedProductPricingRowsComplete}
rowsReadyForPush={displayedProductPricingRowsReadyForPush}
isJstPushEnabled={isJstPushEnabled}
workbookInputRef={productPricingWorkbookInputRef}
workbookAccept={productPricingWorkbookAccept}
onExportWorkbook={onExportProductPricingWorkbook}
onImportWorkbookChange={onImportProductPricingWorkbookChange}
onBatchConfirmPush={onBatchConfirmProductPricingPush}
/>
);

View File

@ -0,0 +1,116 @@
import {
createNonIntegratedProductPricingWorkbook,
parseNonIntegratedProductPricingWorkbook,
} from '@/lib/non-integrated-product-pricing-workbook'
const PRODUCT_PRICING_TEST_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aMGsAAAAASUVORK5CYII='
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)
})
}
describe('non-integrated-product-pricing-workbook', () => {
it('exports the product pricing template with barcode and embedded images', async () => {
const blob = await createNonIntegratedProductPricingWorkbook([
{
image: PRODUCT_PRICING_TEST_IMAGE,
barcode: 'GBGJ-XJ009601N1280V01',
costPrice: '88',
},
])
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('产品核价')
expect(worksheet?.getCell(1, 1).value).toBe('产品图片')
expect(worksheet?.getCell(1, 2).value).toBe('条码')
expect(worksheet?.getCell(1, 3).value).toBe('成本价')
expect(worksheet?.getCell(2, 2).value).toBe('GBGJ-XJ009601N1280V01')
expect(worksheet?.getCell(2, 3).value).toBe(88)
const media = (workbook as import('exceljs').Workbook & {
model?: { media?: unknown[] }
}).model?.media
expect(media?.length ?? 0).toBeGreaterThan(0)
})
it('parses cost price rows by barcode and keeps worksheet row numbers', async () => {
const XLSX = await import('xlsx')
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet([
['产品图片', '条码', '成本价'],
[],
['无图', 'GBGJ-XJ009601N1280V01', '88'],
['无图', 'GBGJ-XJ009602N1280V01', ''],
])
XLSX.utils.book_append_sheet(workbook, worksheet, '产品核价')
const workbookBytes = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
}) as ArrayBuffer
const file = new File([workbookBytes], 'product-pricing.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
Object.defineProperty(file, 'arrayBuffer', {
value: async () => workbookBytes,
})
const result = await parseNonIntegratedProductPricingWorkbook(file)
expect(result.sheetName).toBe('产品核价')
expect(result.rows).toEqual([
{
rowNumber: 3,
barcode: 'GBGJ-XJ009601N1280V01',
costPrice: '88',
},
{
rowNumber: 4,
barcode: 'GBGJ-XJ009602N1280V01',
costPrice: '',
},
])
})
it('rejects templates missing required headers', async () => {
const XLSX = await import('xlsx')
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet([
['产品图片', '条码', '价格'],
['无图', 'GBGJ-XJ009601N1280V01', '88'],
])
XLSX.utils.book_append_sheet(workbook, worksheet, '产品核价')
const workbookBytes = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
}) as ArrayBuffer
const file = new File([workbookBytes], 'product-pricing.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
Object.defineProperty(file, 'arrayBuffer', {
value: async () => workbookBytes,
})
await expect(
parseNonIntegratedProductPricingWorkbook(file),
).rejects.toThrow('上传资料缺少必要表头')
})
})

View File

@ -0,0 +1,442 @@
export type NonIntegratedProductPricingWorkbookRow = {
image: string
barcode: string
costPrice: string
}
export type NonIntegratedProductPricingWorkbookImportRow = {
rowNumber: number
barcode: string
costPrice: string
}
const EXCEL_MIME_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
export const NON_INTEGRATED_PRODUCT_PRICING_WORKBOOK_ACCEPT =
'.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
type ProductPricingWorkbookImageExtension = 'jpeg' | 'png' | 'gif'
type ProductPricingWorkbookExportColumn =
| {
key: 'barcode'
label: string
width: number
kind: 'text'
}
| {
key: 'image'
label: string
width: number
kind: 'image'
}
| {
key: 'costPrice'
label: string
width: number
kind: 'number'
}
const NON_INTEGRATED_PRODUCT_PRICING_EXPORT_COLUMNS: ProductPricingWorkbookExportColumn[] =
[
{ key: 'image', label: '产品图片', width: 14, kind: 'image' },
{ key: 'barcode', label: '条码', width: 24, kind: 'text' },
{ key: 'costPrice', label: '成本价', width: 12, kind: 'number' },
]
const NON_INTEGRATED_PRODUCT_PRICING_IMPORT_COLUMNS: Array<{
key: Exclude<keyof NonIntegratedProductPricingWorkbookImportRow, 'rowNumber'>
label: string
}> = [
{ key: 'barcode', label: '条码' },
{ key: 'costPrice', label: '成本价' },
]
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,
horizontal: 'left' | 'center' = 'left',
) {
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,
wrapText: true,
}
cell.font = {
name: 'Microsoft YaHei',
size: 10,
}
}
function getImageExtensionFromMimeType(
mimeType: string,
): ProductPricingWorkbookImageExtension {
if (mimeType.includes('png')) return 'png'
if (mimeType.includes('gif')) return 'gif'
if (mimeType.includes('webp')) return 'png'
return 'jpeg'
}
async function arrayBufferToDataUrl(arrayBuffer: ArrayBuffer, mimeType: string) {
const bytes = new Uint8Array(arrayBuffer)
let binary = ''
const chunkSize = 0x8000
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.slice(index, index + chunkSize))
}
return `data:${mimeType};base64,${btoa(binary)}`
}
function resolveImageDimensions(
source: string,
): Promise<{ width?: number; height?: number }> {
if (typeof Image === 'undefined') return Promise.resolve({})
return new Promise((resolve) => {
const image = new Image()
let settled = false
const timer = globalThis.setTimeout(() => {
if (settled) return
settled = true
resolve({})
}, 500)
const finish = (result: { width?: number; height?: number }) => {
if (settled) return
settled = true
globalThis.clearTimeout(timer)
resolve(result)
}
image.onload = () =>
finish({
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
})
image.onerror = () => finish({})
image.src = source
})
}
async function resolveWorkbookImageSource(
source: string,
): Promise<{
base64: string
extension: ProductPricingWorkbookImageExtension
width?: number
height?: number
} | null> {
const normalizedSource = source.trim()
if (!normalizedSource) return null
if (normalizedSource.startsWith('data:image/')) {
const mimeType = normalizedSource.slice(5, normalizedSource.indexOf(';'))
const dimensions = await resolveImageDimensions(normalizedSource)
return {
base64: normalizedSource,
extension: getImageExtensionFromMimeType(mimeType),
...dimensions,
}
}
try {
const response = await fetch(normalizedSource)
if (!response.ok) return null
const mimeType = response.headers.get('content-type') || 'image/jpeg'
if (!mimeType.startsWith('image/')) return null
const dataUrl = await arrayBufferToDataUrl(
await response.arrayBuffer(),
mimeType,
)
const dimensions = await resolveImageDimensions(dataUrl)
return {
base64: dataUrl,
extension: getImageExtensionFromMimeType(mimeType),
...dimensions,
}
} catch {
return null
}
}
function fitImageSize({
sourceWidth,
sourceHeight,
maxWidth,
maxHeight,
}: {
sourceWidth?: number
sourceHeight?: number
maxWidth: number
maxHeight: number
}) {
if (!sourceWidth || !sourceHeight || sourceWidth <= 0 || sourceHeight <= 0) {
return { width: maxWidth, height: maxHeight }
}
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight)
return {
width: Math.max(1, sourceWidth * scale),
height: Math.max(1, sourceHeight * scale),
}
}
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
}
export async function createNonIntegratedProductPricingWorkbook(
rows: NonIntegratedProductPricingWorkbookRow[],
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_PRICING_EXPORT_COLUMNS.forEach((column, index) => {
worksheet.getColumn(index + 1).width = column.width
})
NON_INTEGRATED_PRODUCT_PRICING_EXPORT_COLUMNS.forEach((column, index) => {
const headerCell = worksheet.getCell(1, index + 1)
headerCell.value = column.label
applyHeaderCellStyle(headerCell)
})
worksheet.getRow(1).height = 28
const imageSourceCache = new Map<
string,
Promise<Awaited<ReturnType<typeof resolveWorkbookImageSource>>>
>()
const imageTasks: Promise<void>[] = []
rows.forEach((row, rowIndex) => {
const worksheetRowNumber = rowIndex + 2
const worksheetRow = worksheet.getRow(worksheetRowNumber)
const imageSource = row.image.trim()
worksheetRow.height = imageSource ? 44 : 24
NON_INTEGRATED_PRODUCT_PRICING_EXPORT_COLUMNS.forEach(
(column, columnIndex) => {
const cell = worksheet.getCell(worksheetRowNumber, columnIndex + 1)
if (column.kind === 'image') {
cell.value = imageSource ? '' : '无图'
applyBodyCellStyle(cell, 'center')
if (!imageSource) return
let cachedTask = imageSourceCache.get(imageSource)
if (!cachedTask) {
cachedTask = resolveWorkbookImageSource(imageSource)
imageSourceCache.set(imageSource, cachedTask)
}
imageTasks.push(
cachedTask.then((resolvedImage) => {
if (!resolvedImage) return
const imageId = workbook.addImage({
base64: resolvedImage.base64,
extension: resolvedImage.extension,
})
const fittedSize = fitImageSize({
sourceWidth: resolvedImage.width,
sourceHeight: resolvedImage.height,
maxWidth: 52,
maxHeight: 52,
})
worksheet.addImage(imageId, {
tl: {
col: columnIndex + 0.08,
row: worksheetRowNumber - 1 + 0.08,
},
ext: fittedSize,
editAs: 'oneCell',
})
}),
)
return
}
if (column.kind === 'number') {
const numericValue = parseWorkbookNumericValue(row[column.key] || '')
cell.value = numericValue ?? (row[column.key] || '')
} else {
cell.value = row[column.key] || ''
}
applyBodyCellStyle(cell)
},
)
})
await Promise.all(imageTasks)
const buffer = await workbook.xlsx.writeBuffer()
return new Blob([buffer], { type: EXCEL_MIME_TYPE })
}
export function downloadNonIntegratedProductPricingWorkbook(
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 normalizeWorkbookCellValue(value: unknown) {
if (value === null || value === undefined) return ''
if (value instanceof Date) return value.toISOString()
return String(value).trim()
}
function createEmptyImportRow(
rowNumber: number,
): NonIntegratedProductPricingWorkbookImportRow {
return {
rowNumber,
...Object.fromEntries(
NON_INTEGRATED_PRODUCT_PRICING_IMPORT_COLUMNS.map((column) => [
column.key,
'',
]),
),
} as NonIntegratedProductPricingWorkbookImportRow
}
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 parseNonIntegratedProductPricingWorkbook(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: true,
}) as unknown[][]
const headerRowIndex = sheetRows.findIndex((row) =>
row.some((cell) => normalizeWorkbookCellValue(cell).length > 0),
)
if (headerRowIndex < 0) {
throw new Error('上传资料中没有可读取的表头')
}
const headerCells = sheetRows[headerRowIndex].map(normalizeWorkbookCellValue)
const headerIndexByLabel = new Map<string, number>()
headerCells.forEach((label, index) => {
if (label && !headerIndexByLabel.has(label)) {
headerIndexByLabel.set(label, index)
}
})
const missingLabels = NON_INTEGRATED_PRODUCT_PRICING_IMPORT_COLUMNS.filter(
(column) => !headerIndexByLabel.has(column.label),
).map((column) => column.label)
if (missingLabels.length > 0) {
throw new Error(
`上传资料缺少必要表头:${missingLabels.join('、')}`,
)
}
const rows = sheetRows
.slice(headerRowIndex + 1)
.map((row, index) => {
const importRow = createEmptyImportRow(headerRowIndex + 2 + index)
for (const column of NON_INTEGRATED_PRODUCT_PRICING_IMPORT_COLUMNS) {
const cellIndex = headerIndexByLabel.get(column.label)
importRow[column.key] =
cellIndex === undefined
? ''
: normalizeWorkbookCellValue(row[cellIndex])
}
return importRow
})
.filter((row) =>
NON_INTEGRATED_PRODUCT_PRICING_IMPORT_COLUMNS.some(
(column) => row[column.key].trim().length > 0,
),
)
return {
rows,
sheetName,
}
}

View File

@ -0,0 +1,129 @@
import {
createNonIntegratedSampleMeasurementWorkbook,
parseNonIntegratedSampleMeasurementWorkbook,
} from '@/lib/non-integrated-sample-measurement-workbook'
const SAMPLE_MEASUREMENT_TEST_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aMGsAAAAASUVORK5CYII='
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)
})
}
describe('non-integrated-sample-measurement-workbook', () => {
it('exports the sample measurement template with barcode and embedded images', async () => {
const blob = await createNonIntegratedSampleMeasurementWorkbook([
{
image: SAMPLE_MEASUREMENT_TEST_IMAGE,
barcode: 'XJ010101N3576V01',
lengthCm: '30',
widthCm: '40',
heightCm: '60',
weightKg: '',
},
])
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('样包测量')
expect(worksheet?.getCell(1, 1).value).toBe('产品图片')
expect(worksheet?.getCell(1, 2).value).toBe('条码')
expect(worksheet?.getCell(1, 3).value).toBe('长(CM)')
expect(worksheet?.getCell(1, 4).value).toBe('宽(CM)')
expect(worksheet?.getCell(1, 5).value).toBe('高(CM)')
expect(worksheet?.getCell(1, 6).value).toBe('重量(KG)')
expect(worksheet?.getCell(2, 2).value).toBe('XJ010101N3576V01')
expect(worksheet?.getCell(2, 3).value).toBe(30)
expect(worksheet?.getCell(2, 6).value ?? '').toBe('')
const media = (workbook as import('exceljs').Workbook & {
model?: { media?: unknown[] }
}).model?.media
expect(media?.length ?? 0).toBeGreaterThan(0)
})
it('parses measurement rows by barcode and keeps worksheet row numbers', async () => {
const XLSX = await import('xlsx')
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet([
['产品图片', '条码', '长(CM)', '宽(CM)', '高(CM)', '重量(KG)'],
[],
['无图', 'XJ010101N3576V01', '30', '40', '60', '12.5'],
['无图', 'XJ010102N3576V01', '35', '', '', ''],
])
XLSX.utils.book_append_sheet(workbook, worksheet, '样包测量')
const workbookBytes = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
}) as ArrayBuffer
const file = new File([workbookBytes], 'sample-measurement.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
Object.defineProperty(file, 'arrayBuffer', {
value: async () => workbookBytes,
})
const result = await parseNonIntegratedSampleMeasurementWorkbook(file)
expect(result.sheetName).toBe('样包测量')
expect(result.rows).toEqual([
{
rowNumber: 3,
barcode: 'XJ010101N3576V01',
lengthCm: '30',
widthCm: '40',
heightCm: '60',
weightKg: '12.5',
},
{
rowNumber: 4,
barcode: 'XJ010102N3576V01',
lengthCm: '35',
widthCm: '',
heightCm: '',
weightKg: '',
},
])
})
it('rejects templates missing required headers', async () => {
const XLSX = await import('xlsx')
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet([
['产品图片', '条码', '长度'],
['无图', 'XJ010101N3576V01', '30'],
])
XLSX.utils.book_append_sheet(workbook, worksheet, '样包测量')
const workbookBytes = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
}) as ArrayBuffer
const file = new File([workbookBytes], 'sample-measurement.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
Object.defineProperty(file, 'arrayBuffer', {
value: async () => workbookBytes,
})
await expect(
parseNonIntegratedSampleMeasurementWorkbook(file),
).rejects.toThrow('上传资料缺少必要表头')
})
})

View File

@ -0,0 +1,454 @@
export type NonIntegratedSampleMeasurementWorkbookRow = {
image: string
barcode: string
lengthCm: string
widthCm: string
heightCm: string
weightKg: string
}
export type NonIntegratedSampleMeasurementWorkbookImportRow = {
rowNumber: number
barcode: string
lengthCm: string
widthCm: string
heightCm: string
weightKg: string
}
const EXCEL_MIME_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
export const NON_INTEGRATED_SAMPLE_MEASUREMENT_WORKBOOK_ACCEPT =
'.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
type SampleMeasurementWorkbookImageExtension = 'jpeg' | 'png' | 'gif'
type SampleMeasurementWorkbookExportColumn =
| {
key: 'barcode'
label: string
width: number
kind: 'text'
}
| {
key: 'image'
label: string
width: number
kind: 'image'
}
| {
key: 'lengthCm' | 'widthCm' | 'heightCm' | 'weightKg'
label: string
width: number
kind: 'number'
}
const NON_INTEGRATED_SAMPLE_MEASUREMENT_EXPORT_COLUMNS: SampleMeasurementWorkbookExportColumn[] =
[
{ key: 'image', label: '产品图片', width: 14, kind: 'image' },
{ key: 'barcode', label: '条码', width: 24, kind: 'text' },
{ key: 'lengthCm', label: '长(CM)', width: 12, kind: 'number' },
{ key: 'widthCm', label: '宽(CM)', width: 12, kind: 'number' },
{ key: 'heightCm', label: '高(CM)', width: 12, kind: 'number' },
{ key: 'weightKg', label: '重量(KG)', width: 12, kind: 'number' },
]
const NON_INTEGRATED_SAMPLE_MEASUREMENT_IMPORT_COLUMNS: Array<{
key: Exclude<keyof NonIntegratedSampleMeasurementWorkbookImportRow, 'rowNumber'>
label: string
}> = [
{ key: 'barcode', label: '条码' },
{ key: 'lengthCm', label: '长(CM)' },
{ key: 'widthCm', label: '宽(CM)' },
{ key: 'heightCm', label: '高(CM)' },
{ key: 'weightKg', label: '重量(KG)' },
]
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,
horizontal: 'left' | 'center' = 'left',
) {
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,
wrapText: true,
}
cell.font = {
name: 'Microsoft YaHei',
size: 10,
}
}
function getImageExtensionFromMimeType(
mimeType: string,
): SampleMeasurementWorkbookImageExtension {
if (mimeType.includes('png')) return 'png'
if (mimeType.includes('gif')) return 'gif'
if (mimeType.includes('webp')) return 'png'
return 'jpeg'
}
async function arrayBufferToDataUrl(arrayBuffer: ArrayBuffer, mimeType: string) {
const bytes = new Uint8Array(arrayBuffer)
let binary = ''
const chunkSize = 0x8000
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.slice(index, index + chunkSize))
}
return `data:${mimeType};base64,${btoa(binary)}`
}
function resolveImageDimensions(
source: string,
): Promise<{ width?: number; height?: number }> {
if (typeof Image === 'undefined') return Promise.resolve({})
return new Promise((resolve) => {
const image = new Image()
let settled = false
const timer = globalThis.setTimeout(() => {
if (settled) return
settled = true
resolve({})
}, 500)
const finish = (result: { width?: number; height?: number }) => {
if (settled) return
settled = true
globalThis.clearTimeout(timer)
resolve(result)
}
image.onload = () =>
finish({
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
})
image.onerror = () => finish({})
image.src = source
})
}
async function resolveWorkbookImageSource(
source: string,
): Promise<{
base64: string
extension: SampleMeasurementWorkbookImageExtension
width?: number
height?: number
} | null> {
const normalizedSource = source.trim()
if (!normalizedSource) return null
if (normalizedSource.startsWith('data:image/')) {
const mimeType = normalizedSource.slice(5, normalizedSource.indexOf(';'))
const dimensions = await resolveImageDimensions(normalizedSource)
return {
base64: normalizedSource,
extension: getImageExtensionFromMimeType(mimeType),
...dimensions,
}
}
try {
const response = await fetch(normalizedSource)
if (!response.ok) return null
const mimeType = response.headers.get('content-type') || 'image/jpeg'
if (!mimeType.startsWith('image/')) return null
const dataUrl = await arrayBufferToDataUrl(
await response.arrayBuffer(),
mimeType,
)
const dimensions = await resolveImageDimensions(dataUrl)
return {
base64: dataUrl,
extension: getImageExtensionFromMimeType(mimeType),
...dimensions,
}
} catch {
return null
}
}
function fitImageSize({
sourceWidth,
sourceHeight,
maxWidth,
maxHeight,
}: {
sourceWidth?: number
sourceHeight?: number
maxWidth: number
maxHeight: number
}) {
if (!sourceWidth || !sourceHeight || sourceWidth <= 0 || sourceHeight <= 0) {
return { width: maxWidth, height: maxHeight }
}
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight)
return {
width: Math.max(1, sourceWidth * scale),
height: Math.max(1, sourceHeight * scale),
}
}
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
}
export async function createNonIntegratedSampleMeasurementWorkbook(
rows: NonIntegratedSampleMeasurementWorkbookRow[],
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_SAMPLE_MEASUREMENT_EXPORT_COLUMNS.forEach((column, index) => {
worksheet.getColumn(index + 1).width = column.width
})
NON_INTEGRATED_SAMPLE_MEASUREMENT_EXPORT_COLUMNS.forEach((column, index) => {
const headerCell = worksheet.getCell(1, index + 1)
headerCell.value = column.label
applyHeaderCellStyle(headerCell)
})
worksheet.getRow(1).height = 28
const imageSourceCache = new Map<
string,
Promise<Awaited<ReturnType<typeof resolveWorkbookImageSource>>>
>()
const imageTasks: Promise<void>[] = []
rows.forEach((row, rowIndex) => {
const worksheetRowNumber = rowIndex + 2
const worksheetRow = worksheet.getRow(worksheetRowNumber)
const imageSource = row.image.trim()
worksheetRow.height = imageSource ? 44 : 24
NON_INTEGRATED_SAMPLE_MEASUREMENT_EXPORT_COLUMNS.forEach(
(column, columnIndex) => {
const cell = worksheet.getCell(worksheetRowNumber, columnIndex + 1)
if (column.kind === 'image') {
cell.value = imageSource ? '' : '无图'
applyBodyCellStyle(cell, 'center')
if (!imageSource) return
let cachedTask = imageSourceCache.get(imageSource)
if (!cachedTask) {
cachedTask = resolveWorkbookImageSource(imageSource)
imageSourceCache.set(imageSource, cachedTask)
}
imageTasks.push(
cachedTask.then((resolvedImage) => {
if (!resolvedImage) return
const imageId = workbook.addImage({
base64: resolvedImage.base64,
extension: resolvedImage.extension,
})
const fittedSize = fitImageSize({
sourceWidth: resolvedImage.width,
sourceHeight: resolvedImage.height,
maxWidth: 52,
maxHeight: 52,
})
worksheet.addImage(imageId, {
tl: {
col: columnIndex + 0.08,
row: worksheetRowNumber - 1 + 0.08,
},
ext: fittedSize,
editAs: 'oneCell',
})
}),
)
return
}
if (column.kind === 'number') {
const numericValue = parseWorkbookNumericValue(row[column.key] || '')
cell.value = numericValue ?? (row[column.key] || '')
} else {
cell.value = row[column.key] || ''
}
applyBodyCellStyle(cell)
},
)
})
await Promise.all(imageTasks)
const buffer = await workbook.xlsx.writeBuffer()
return new Blob([buffer], { type: EXCEL_MIME_TYPE })
}
export function downloadNonIntegratedSampleMeasurementWorkbook(
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 normalizeWorkbookCellValue(value: unknown) {
if (value === null || value === undefined) return ''
if (value instanceof Date) return value.toISOString()
return String(value).trim()
}
function createEmptyImportRow(
rowNumber: number,
): NonIntegratedSampleMeasurementWorkbookImportRow {
return {
rowNumber,
...Object.fromEntries(
NON_INTEGRATED_SAMPLE_MEASUREMENT_IMPORT_COLUMNS.map((column) => [
column.key,
'',
]),
),
} as NonIntegratedSampleMeasurementWorkbookImportRow
}
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 parseNonIntegratedSampleMeasurementWorkbook(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: true,
}) as unknown[][]
const headerRowIndex = sheetRows.findIndex((row) =>
row.some((cell) => normalizeWorkbookCellValue(cell).length > 0),
)
if (headerRowIndex < 0) {
throw new Error('上传资料中没有可读取的表头')
}
const headerCells = sheetRows[headerRowIndex].map(normalizeWorkbookCellValue)
const headerIndexByLabel = new Map<string, number>()
headerCells.forEach((label, index) => {
if (label && !headerIndexByLabel.has(label)) {
headerIndexByLabel.set(label, index)
}
})
const missingLabels = NON_INTEGRATED_SAMPLE_MEASUREMENT_IMPORT_COLUMNS.filter(
(column) => !headerIndexByLabel.has(column.label),
).map((column) => column.label)
if (missingLabels.length > 0) {
throw new Error(
`上传资料缺少必要表头:${missingLabels.join('、')}`,
)
}
const rows = sheetRows
.slice(headerRowIndex + 1)
.map((row, index) => {
const importRow = createEmptyImportRow(headerRowIndex + 2 + index)
for (const column of NON_INTEGRATED_SAMPLE_MEASUREMENT_IMPORT_COLUMNS) {
const cellIndex = headerIndexByLabel.get(column.label)
importRow[column.key] =
cellIndex === undefined
? ''
: normalizeWorkbookCellValue(row[cellIndex])
}
return importRow
})
.filter((row) =>
NON_INTEGRATED_SAMPLE_MEASUREMENT_IMPORT_COLUMNS.some(
(column) => row[column.key].trim().length > 0,
),
)
return {
rows,
sheetName,
}
}