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:
parent
58966bba20
commit
adfecb4c67
@ -38,12 +38,14 @@ describe("buildNonIntegratedDetailChrome", () => {
|
||||
productContext: {},
|
||||
productFactoryActions: {},
|
||||
productLimitWorkbook: {},
|
||||
productPricingWorkbook: {},
|
||||
productPhotoUploadActions: {},
|
||||
productWorkflowActions: {},
|
||||
qualityInspectionActions: {},
|
||||
reviewDerived: {},
|
||||
reviewSheetAssets: {},
|
||||
reviewSheetFields: {},
|
||||
sampleMeasurementWorkbook: {},
|
||||
sampleWorkflowActions: {},
|
||||
secondaryRenderers: { workspaceImageDialog: {} },
|
||||
seriesDerived: {},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -239,6 +239,7 @@ export function useNonIntegratedDetailDomainControllers(
|
||||
});
|
||||
const skuActions = useNonIntegratedSkuReviewActionControllers({
|
||||
artifactState,
|
||||
workflowCompletion: workflow.workflowCompletion,
|
||||
basicInfoController: formActions.basicInfoController,
|
||||
basicInfoDerived: setup.basicInfoDerived,
|
||||
bomLibrary,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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('上传资料缺少必要表头')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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('上传资料缺少必要表头')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user