feat(intake): sync installation assets to drawing system by ID
This commit is contained in:
parent
97a173eb9c
commit
c066c60494
@ -0,0 +1,40 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { ErrorCode } from "@/server/core/errors";
|
||||
import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response";
|
||||
import { intakeInstallationDrawingUploadService } from "@/server/services/intake-installation-drawing-upload.service";
|
||||
|
||||
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,120}$/;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async ({ requestId, user }) => {
|
||||
const body = (await request.json().catch(() => null)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const productId =
|
||||
typeof body?.productId === "string" ? body.productId.trim() : "";
|
||||
const fileId = typeof body?.fileId === "string" ? body.fileId.trim() : "";
|
||||
const drawingId =
|
||||
typeof body?.drawingId === "string" ? body.drawingId.trim() : "";
|
||||
if (!productId || !fileId || !CODE_PATTERN.test(drawingId)) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"产品、文件或图纸 ID 不正确",
|
||||
400,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await intakeInstallationDrawingUploadService.upload({
|
||||
productId,
|
||||
fileId,
|
||||
drawingId,
|
||||
actor: user,
|
||||
});
|
||||
return jsonSuccess(result, 200, requestId);
|
||||
},
|
||||
{ requireAuth: true },
|
||||
);
|
||||
}
|
||||
@ -28,15 +28,11 @@ type ProductSetupState = ReturnType<
|
||||
type WorkflowDerivedState = ReturnType<
|
||||
typeof useNonIntegratedWorkflowDerivedController
|
||||
>;
|
||||
type FormActionState = ReturnType<
|
||||
typeof useNonIntegratedFormActionControllers
|
||||
>;
|
||||
type FormActionState = ReturnType<typeof useNonIntegratedFormActionControllers>;
|
||||
type DocumentActionState = ReturnType<
|
||||
typeof useNonIntegratedDocumentActionControllers
|
||||
>;
|
||||
type FileActionState = ReturnType<
|
||||
typeof useNonIntegratedFileActionControllers
|
||||
>;
|
||||
type FileActionState = ReturnType<typeof useNonIntegratedFileActionControllers>;
|
||||
type SkuReviewActionState = ReturnType<
|
||||
typeof useNonIntegratedSkuReviewActionControllers
|
||||
>;
|
||||
@ -163,57 +159,54 @@ export function buildNonIntegratedSecondaryWorkspaceRenderers({
|
||||
workflowState,
|
||||
workspaceNavigation,
|
||||
});
|
||||
const { renderFirstQualityWorkspace } =
|
||||
buildFirstQualityWorkspaceRenderers({
|
||||
factoryQuality: {
|
||||
allRows: workflowRows.productFirstQualityRows,
|
||||
displayedRows: workflowRows.displayedProductFirstQualityRows,
|
||||
files: documentDerived.factoryQualityFiles,
|
||||
inheritedSkuBatchTabs,
|
||||
isReadOnly: editability.isReadOnly,
|
||||
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
|
||||
onUpdateRow: productWorkflowActions.updateFirstQualityRow,
|
||||
onUploadFiles:
|
||||
workspaceUploadActions.handleUploadFirstQualityFiles,
|
||||
onDownloadFiles: documentDownloads.handleDownloadWorkspaceFiles,
|
||||
onPreviewImage: (src, alt) =>
|
||||
overlayState.setSkuImagePreview({ kind: "image", src, alt }),
|
||||
const { renderFirstQualityWorkspace } = buildFirstQualityWorkspaceRenderers({
|
||||
factoryQuality: {
|
||||
allRows: workflowRows.productFirstQualityRows,
|
||||
displayedRows: workflowRows.displayedProductFirstQualityRows,
|
||||
files: documentDerived.factoryQualityFiles,
|
||||
inheritedSkuBatchTabs,
|
||||
isReadOnly: editability.isReadOnly,
|
||||
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
|
||||
onUpdateRow: productWorkflowActions.updateFirstQualityRow,
|
||||
onUploadFiles: workspaceUploadActions.handleUploadFirstQualityFiles,
|
||||
onDownloadFiles: documentDownloads.handleDownloadWorkspaceFiles,
|
||||
onPreviewImage: (src, alt) =>
|
||||
overlayState.setSkuImagePreview({ kind: "image", src, alt }),
|
||||
},
|
||||
shell: {
|
||||
activePanel: artifactState.activeFirstQualityWorkspacePanel,
|
||||
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
|
||||
displayedFirstQualityIsConfirmed:
|
||||
workflowConfirmation.displayedFirstQualityIsConfirmed,
|
||||
displayedFirstQualityRowsComplete:
|
||||
workflowCompletion.displayedFirstQualityRowsComplete,
|
||||
displayedProductFirstQualityRowCount:
|
||||
workflowRows.displayedProductFirstQualityRows.length,
|
||||
documentWorkspaceUploadInputRef:
|
||||
inputRefs.documentWorkspaceUploadInputRef,
|
||||
effectiveSelectedWorkspaceFile:
|
||||
documentPresentation.effectiveSelectedWorkspaceFile,
|
||||
factoryQualityFiles: documentDerived.factoryQualityFiles,
|
||||
isCurrentUserPlainReadOnlyViewer:
|
||||
permissionState.isCurrentUserPlainReadOnlyViewer,
|
||||
isReadOnly: editability.isReadOnly,
|
||||
onConfirmFirstQualityRows:
|
||||
documentConfirmActions.handleConfirmFirstQualityRows,
|
||||
onDeleteUploadFile: documentUploadActions.handleDeleteUploadFile,
|
||||
onDownloadUploadFile: documentDownloads.handleDownloadUploadFile,
|
||||
onDownloadWorkspaceFiles: documentDownloads.handleDownloadWorkspaceFiles,
|
||||
onSelectPanel: (panel) => {
|
||||
artifactState.setActiveFirstQualityWorkspacePanel(panel);
|
||||
documentState.setSelectedWorkspaceFileId("");
|
||||
},
|
||||
shell: {
|
||||
activePanel: artifactState.activeFirstQualityWorkspacePanel,
|
||||
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
|
||||
displayedFirstQualityIsConfirmed:
|
||||
workflowConfirmation.displayedFirstQualityIsConfirmed,
|
||||
displayedFirstQualityRowsComplete:
|
||||
workflowCompletion.displayedFirstQualityRowsComplete,
|
||||
displayedProductFirstQualityRowCount:
|
||||
workflowRows.displayedProductFirstQualityRows.length,
|
||||
documentWorkspaceUploadInputRef:
|
||||
inputRefs.documentWorkspaceUploadInputRef,
|
||||
effectiveSelectedWorkspaceFile:
|
||||
documentPresentation.effectiveSelectedWorkspaceFile,
|
||||
factoryQualityFiles: documentDerived.factoryQualityFiles,
|
||||
isCurrentUserPlainReadOnlyViewer:
|
||||
permissionState.isCurrentUserPlainReadOnlyViewer,
|
||||
isReadOnly: editability.isReadOnly,
|
||||
onConfirmFirstQualityRows:
|
||||
documentConfirmActions.handleConfirmFirstQualityRows,
|
||||
onDeleteUploadFile: documentUploadActions.handleDeleteUploadFile,
|
||||
onDownloadUploadFile: documentDownloads.handleDownloadUploadFile,
|
||||
onDownloadWorkspaceFiles:
|
||||
documentDownloads.handleDownloadWorkspaceFiles,
|
||||
onSelectPanel: (panel) => {
|
||||
artifactState.setActiveFirstQualityWorkspacePanel(panel);
|
||||
documentState.setSelectedWorkspaceFileId("");
|
||||
},
|
||||
onUploadQualityStandardFiles:
|
||||
workspaceUploadActions.handleUploadDetectionQualityStandardFiles,
|
||||
qualityStandardFileAccept: QUALITY_STANDARD_FILE_ACCEPT,
|
||||
qualityStandardFiles: documentDerived.qualityStandardFiles,
|
||||
},
|
||||
renderQualityStandardWorkspace:
|
||||
documentWorkspaceRenderers.renderQualityStandardWorkspace,
|
||||
});
|
||||
onUploadQualityStandardFiles:
|
||||
workspaceUploadActions.handleUploadDetectionQualityStandardFiles,
|
||||
qualityStandardFileAccept: QUALITY_STANDARD_FILE_ACCEPT,
|
||||
qualityStandardFiles: documentDerived.qualityStandardFiles,
|
||||
},
|
||||
renderQualityStandardWorkspace:
|
||||
documentWorkspaceRenderers.renderQualityStandardWorkspace,
|
||||
});
|
||||
const styleAssetRenderers = buildStyleAssetWorkspaceRenderers({
|
||||
installation: {
|
||||
installationStyleEntries: documentPresentation.installationStyleEntries,
|
||||
@ -236,22 +229,21 @@ export function buildNonIntegratedSecondaryWorkspaceRenderers({
|
||||
documentState.activeDocumentWorkspaceCategory
|
||||
],
|
||||
isReadOnly: editability.isReadOnly,
|
||||
onPushDrawingSystemVersion:
|
||||
workspaceUploadActions.handlePushInstallationDrawingSystemVersion,
|
||||
selectedFileId:
|
||||
documentPresentation.effectiveSelectedWorkspaceFile?.id ?? "",
|
||||
archivePreviewPanel: renderArchivePreviewPanel(),
|
||||
onSelectStyle:
|
||||
documentSelectionActions.handleSelectInstallationStyle,
|
||||
onSelectStyle: documentSelectionActions.handleSelectInstallationStyle,
|
||||
onSelectAssetType:
|
||||
documentSelectionActions.handleSelectInstallationAssetType,
|
||||
onNoteChange:
|
||||
documentSelectionActions.handleDocumentWorkspaceNoteChange,
|
||||
onNoteChange: documentSelectionActions.handleDocumentWorkspaceNoteChange,
|
||||
onSelectFile: workspaceNavigation.handleSelectWorkspaceFile,
|
||||
},
|
||||
packaging: {
|
||||
actionGroupClassName: DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS,
|
||||
activePanel: artifactState.activePackagingWorkspacePanel,
|
||||
activePackagingStyleFiles:
|
||||
documentPresentation.activePackagingStyleFiles,
|
||||
activePackagingStyleFiles: documentPresentation.activePackagingStyleFiles,
|
||||
canDownloadWorkspaceFiles: permissionState.canDownloadWorkspaceFiles,
|
||||
confirmationAction: packagingConfirmationAction,
|
||||
isReadOnly: editability.isReadOnly,
|
||||
@ -259,16 +251,14 @@ export function buildNonIntegratedSecondaryWorkspaceRenderers({
|
||||
packagingWorkspaceUploadInputRef:
|
||||
inputRefs.packagingWorkspaceUploadInputRef,
|
||||
previewContent: renderDocumentWorkspacePreviewContent(),
|
||||
selectedPackagingStyleDetail:
|
||||
modelPreview.selectedPackagingStyleDetail,
|
||||
selectedPackagingStyleDetail: modelPreview.selectedPackagingStyleDetail,
|
||||
selectedPackagingStyleEntry:
|
||||
documentPresentation.selectedPackagingStyleEntry,
|
||||
packagingStyleEntries: documentPresentation.packagingStyleEntries,
|
||||
selectedWorkspaceFile:
|
||||
documentPresentation.effectiveSelectedWorkspaceFile,
|
||||
modelPreviewProps: {
|
||||
canEditPreviewModelMaterial:
|
||||
editability.canEditPreviewModelMaterial,
|
||||
canEditPreviewModelMaterial: editability.canEditPreviewModelMaterial,
|
||||
isPackagingModelPreviewActive:
|
||||
modelPreview.isPackagingModelPreviewActive,
|
||||
isReadOnly: editability.isReadOnly,
|
||||
@ -303,10 +293,8 @@ export function buildNonIntegratedSecondaryWorkspaceRenderers({
|
||||
packagingActions.handleChangePackagingOuterType,
|
||||
onDeleteFile: documentUploadActions.handleDeleteUploadFile,
|
||||
onDownloadFile: documentDownloads.handleDownloadUploadFile,
|
||||
onPackagingModel3dChange:
|
||||
packagingActions.handlePackagingModel3dChange,
|
||||
onPackagingUploadChange:
|
||||
packagingActions.handleUploadPackagingFiles,
|
||||
onPackagingModel3dChange: packagingActions.handlePackagingModel3dChange,
|
||||
onPackagingUploadChange: packagingActions.handleUploadPackagingFiles,
|
||||
onSelectPackagingStyle:
|
||||
documentSelectionActions.handleSelectPackagingStyle,
|
||||
onSelectPanel: workspaceNavigation.handleSelectPackagingWorkspacePanel,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -156,10 +156,7 @@ export function getNonIntegratedDocumentWorkspaceCategoryLabel(
|
||||
}
|
||||
|
||||
export type NonIntegratedProjectStatus =
|
||||
| "draft"
|
||||
| "revision"
|
||||
| "rejected"
|
||||
| "approved";
|
||||
"draft" | "revision" | "rejected" | "approved";
|
||||
|
||||
export type NonIntegratedProjectReview = {
|
||||
reviewDate: string;
|
||||
@ -199,13 +196,7 @@ export type NonIntegratedPatentApplication = {
|
||||
shouldApply: "" | "待判断" | "申请" | "不申请";
|
||||
patentName?: string;
|
||||
patentStatus?:
|
||||
| ""
|
||||
| "待确认"
|
||||
| "已有专利"
|
||||
| "申请中"
|
||||
| "申请失败"
|
||||
| "无专利"
|
||||
| "放弃维护";
|
||||
"" | "待确认" | "已有专利" | "申请中" | "申请失败" | "无专利" | "放弃维护";
|
||||
saRequired?: "" | "是" | "否";
|
||||
salesStatus?: "" | "在售" | "待上市" | "停售";
|
||||
designer?: string;
|
||||
@ -517,9 +508,14 @@ export function resolveNonIntegratedSkuProductTag(item: {
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSystemEnteredNonIntegratedPricingRow(row: {
|
||||
systemEnteredAt?: unknown;
|
||||
} | null | undefined) {
|
||||
export function hasSystemEnteredNonIntegratedPricingRow(
|
||||
row:
|
||||
| {
|
||||
systemEnteredAt?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return normalizeString(row?.systemEnteredAt).trim().length > 0;
|
||||
}
|
||||
|
||||
@ -661,9 +657,16 @@ export type NonIntegratedUploadFile = {
|
||||
productPhotoStyleKey?: string;
|
||||
productPhotoBarcode?: string;
|
||||
installStyleKey?: string;
|
||||
installDrawingId?: string;
|
||||
installAssetType?: string;
|
||||
installVersionLabel?: string;
|
||||
installIsCurrentVersion?: boolean;
|
||||
installDrawingSyncStatus?:
|
||||
"not_applicable" | "pending" | "processing" | "succeeded" | "failed";
|
||||
installDrawingSyncTaskId?: string;
|
||||
installDrawingSyncFileUrl?: string;
|
||||
installDrawingSyncError?: string;
|
||||
installDrawingSyncedAt?: string;
|
||||
packagingStyleKey?: string;
|
||||
packagingAssetType?: string;
|
||||
factoryQualityStyleKey?: string;
|
||||
@ -932,7 +935,9 @@ export function getSearchableNonIntegratedSpuKeywords(spu: string) {
|
||||
function hasSameNonIntegratedBaseCode(left: string, right: string) {
|
||||
const normalizedLeft = stripNonIntegratedBrandPrefix(left);
|
||||
const normalizedRight = stripNonIntegratedBrandPrefix(right);
|
||||
return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
|
||||
return Boolean(
|
||||
normalizedLeft && normalizedRight && normalizedLeft === normalizedRight,
|
||||
);
|
||||
}
|
||||
|
||||
function isOpeningTemporarySpu(
|
||||
@ -996,18 +1001,24 @@ export function resolveNonIntegratedPersistedSpu(
|
||||
const normalizedSpu = product.spu.trim().toUpperCase();
|
||||
if (normalizedSpu) return normalizedSpu;
|
||||
|
||||
const reference = normalizeNonIntegratedOpeningReference(product.openingReference);
|
||||
const reference = normalizeNonIntegratedOpeningReference(
|
||||
product.openingReference,
|
||||
);
|
||||
const temporaryCode = reference.temporaryCode.trim().toUpperCase();
|
||||
if (temporaryCode) return temporaryCode;
|
||||
|
||||
return product.id.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function getNonIntegratedFormalSpu(product: NonIntegratedFormalSpuSource) {
|
||||
export function getNonIntegratedFormalSpu(
|
||||
product: NonIntegratedFormalSpuSource,
|
||||
) {
|
||||
const normalizedSpu = product.spu.trim();
|
||||
if (!normalizedSpu) return "";
|
||||
|
||||
const reference = normalizeNonIntegratedOpeningReference(product.openingReference);
|
||||
const reference = normalizeNonIntegratedOpeningReference(
|
||||
product.openingReference,
|
||||
);
|
||||
const temporaryCode = reference.temporaryCode.trim();
|
||||
const productName = reference.productName.trim();
|
||||
const productId = product.id?.trim() ?? "";
|
||||
@ -1038,7 +1049,9 @@ export function getNonIntegratedProductDisplayName(
|
||||
product: Pick<NonIntegratedProductRecord, "id"> &
|
||||
NonIntegratedFormalSpuSource,
|
||||
) {
|
||||
const reference = normalizeNonIntegratedOpeningReference(product.openingReference);
|
||||
const reference = normalizeNonIntegratedOpeningReference(
|
||||
product.openingReference,
|
||||
);
|
||||
|
||||
return (
|
||||
getNonIntegratedFormalSpu(product) ||
|
||||
@ -1286,9 +1299,7 @@ function normalizeMetadataRecord(value: unknown) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizeMetadataValue = (
|
||||
item: unknown,
|
||||
): unknown | undefined => {
|
||||
const normalizeMetadataValue = (item: unknown): unknown | undefined => {
|
||||
if (
|
||||
item === null ||
|
||||
typeof item === "string" ||
|
||||
@ -1305,11 +1316,11 @@ function normalizeMetadataRecord(value: unknown) {
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
const normalizedEntries = Object.entries(item)
|
||||
.map(
|
||||
([key, entry]) =>
|
||||
[key, normalizeMetadataValue(entry)] as const,
|
||||
)
|
||||
.filter((entry): entry is readonly [string, unknown] => entry[1] !== undefined);
|
||||
.map(([key, entry]) => [key, normalizeMetadataValue(entry)] as const)
|
||||
.filter(
|
||||
(entry): entry is readonly [string, unknown] =>
|
||||
entry[1] !== undefined,
|
||||
);
|
||||
return normalizedEntries.length > 0
|
||||
? Object.fromEntries(normalizedEntries)
|
||||
: undefined;
|
||||
@ -1320,7 +1331,9 @@ function normalizeMetadataRecord(value: unknown) {
|
||||
|
||||
const normalizedEntries = Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, item]) => [key, normalizeMetadataValue(item)] as const)
|
||||
.filter((entry): entry is readonly [string, unknown] => entry[1] !== undefined);
|
||||
.filter(
|
||||
(entry): entry is readonly [string, unknown] => entry[1] !== undefined,
|
||||
);
|
||||
|
||||
return normalizedEntries.length > 0
|
||||
? Object.fromEntries(normalizedEntries)
|
||||
@ -1334,8 +1347,8 @@ function shouldDefaultModularProductTag(
|
||||
|
||||
return Boolean(
|
||||
typeof metadata.modularSpu === "string" ||
|
||||
metadata.source === "draft_group_modular_series" ||
|
||||
typeof metadata.componentDetailType === "string",
|
||||
metadata.source === "draft_group_modular_series" ||
|
||||
typeof metadata.componentDetailType === "string",
|
||||
);
|
||||
}
|
||||
|
||||
@ -1344,28 +1357,32 @@ function resolveStoredProductTag(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const pricingRows = Array.isArray(item.pricingRows) ? item.pricingRows : [];
|
||||
const hasProductTag = Object.prototype.hasOwnProperty.call(item, "productTag");
|
||||
const hasProductTag = Object.prototype.hasOwnProperty.call(
|
||||
item,
|
||||
"productTag",
|
||||
);
|
||||
const explicitProductTag = hasProductTag
|
||||
? resolveNonIntegratedSharedProductTagValue(item.productTag, pricingRows)
|
||||
: "";
|
||||
if (explicitProductTag) return explicitProductTag;
|
||||
|
||||
const uniquePricingTags = pricingRows.length > 0
|
||||
? Array.from(
|
||||
new Set(
|
||||
pricingRows
|
||||
.map((row) =>
|
||||
row && typeof row === "object"
|
||||
? resolveNonIntegratedSharedProductTagValue(
|
||||
(row as { productTag?: unknown }).productTag,
|
||||
pricingRows,
|
||||
)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const uniquePricingTags =
|
||||
pricingRows.length > 0
|
||||
? Array.from(
|
||||
new Set(
|
||||
pricingRows
|
||||
.map((row) =>
|
||||
row && typeof row === "object"
|
||||
? resolveNonIntegratedSharedProductTagValue(
|
||||
(row as { productTag?: unknown }).productTag,
|
||||
pricingRows,
|
||||
)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
if (uniquePricingTags.length === 1) {
|
||||
return uniquePricingTags[0];
|
||||
@ -1482,10 +1499,12 @@ function normalizeSkuBatchWorkflowConfirmations(value: unknown) {
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).flatMap(([batchKey, entry]) => {
|
||||
const normalizedEntry = normalizeDocumentWorkspaceConfirmations(entry);
|
||||
return batchKey.trim() ? [[batchKey, normalizedEntry]] : [];
|
||||
}),
|
||||
Object.entries(value as Record<string, unknown>).flatMap(
|
||||
([batchKey, entry]) => {
|
||||
const normalizedEntry = normalizeDocumentWorkspaceConfirmations(entry);
|
||||
return batchKey.trim() ? [[batchKey, normalizedEntry]] : [];
|
||||
},
|
||||
),
|
||||
) as NonIntegratedSkuBatchWorkflowConfirmationMap;
|
||||
}
|
||||
|
||||
@ -1495,10 +1514,12 @@ function normalizeSkuBatchWorkflowApprovedAt(value: unknown) {
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).flatMap(([batchKey, entry]) => {
|
||||
const normalizedEntry = normalizeDocumentWorkspaceApprovedAt(entry);
|
||||
return batchKey.trim() ? [[batchKey, normalizedEntry]] : [];
|
||||
}),
|
||||
Object.entries(value as Record<string, unknown>).flatMap(
|
||||
([batchKey, entry]) => {
|
||||
const normalizedEntry = normalizeDocumentWorkspaceApprovedAt(entry);
|
||||
return batchKey.trim() ? [[batchKey, normalizedEntry]] : [];
|
||||
},
|
||||
),
|
||||
) as NonIntegratedSkuBatchWorkflowApprovalMap;
|
||||
}
|
||||
|
||||
@ -1580,7 +1601,9 @@ export function normalizeNonIntegratedSkuComponentDetails(
|
||||
.filter((item): item is NonIntegratedSkuComponentDetail => item !== null);
|
||||
}
|
||||
|
||||
function normalizeNumberTuple3(value: unknown): [number, number, number] | null {
|
||||
function normalizeNumberTuple3(
|
||||
value: unknown,
|
||||
): [number, number, number] | null {
|
||||
if (!Array.isArray(value) || value.length < 3) return null;
|
||||
|
||||
const tuple = value.slice(0, 3);
|
||||
@ -1593,7 +1616,9 @@ function normalizeNumberTuple3(value: unknown): [number, number, number] | null
|
||||
return tuple as [number, number, number];
|
||||
}
|
||||
|
||||
function normalizeSkuHolePoint(value: unknown): NonIntegratedSkuHolePoint | null {
|
||||
function normalizeSkuHolePoint(
|
||||
value: unknown,
|
||||
): NonIntegratedSkuHolePoint | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
@ -1805,7 +1830,7 @@ function normalizePricingRow(
|
||||
? item.costPrice
|
||||
: typeof item.costPrice === "number" && Number.isFinite(item.costPrice)
|
||||
? String(item.costPrice)
|
||||
: "",
|
||||
: "",
|
||||
productTag: normalizeString(item.productTag),
|
||||
...(systemEnteredAt ? { systemEnteredAt } : {}),
|
||||
...(jstVirtualInventoryStatus ? { jstVirtualInventoryStatus } : {}),
|
||||
@ -1858,22 +1883,28 @@ function normalizePlanningLayoutRow(
|
||||
): NonIntegratedPlanningLayoutRow | null {
|
||||
if (typeof item.skuId !== "string") return null;
|
||||
const normalizedStyles = Array.from(
|
||||
new Set([
|
||||
...normalizeStringList(item.styles),
|
||||
normalizeString(item.style).trim(),
|
||||
].filter((value) => value.length > 0)),
|
||||
new Set(
|
||||
[
|
||||
...normalizeStringList(item.styles),
|
||||
normalizeString(item.style).trim(),
|
||||
].filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
const normalizedStyleTones = Array.from(
|
||||
new Set([
|
||||
...normalizeStringList(item.styleTones),
|
||||
normalizeString(item.styleTone).trim(),
|
||||
].filter((value) => value.length > 0)),
|
||||
new Set(
|
||||
[
|
||||
...normalizeStringList(item.styleTones),
|
||||
normalizeString(item.styleTone).trim(),
|
||||
].filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
const normalizedAudiences = Array.from(
|
||||
new Set([
|
||||
...normalizeStringList(item.audiences),
|
||||
normalizeString(item.audience).trim(),
|
||||
].filter((value) => value.length > 0)),
|
||||
new Set(
|
||||
[
|
||||
...normalizeStringList(item.audiences),
|
||||
normalizeString(item.audience).trim(),
|
||||
].filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
const normalizedBrands = [
|
||||
...(Array.isArray(item.brands) ? item.brands : []),
|
||||
@ -1993,19 +2024,37 @@ function normalizeUploadFile(
|
||||
productPhotoStyleKey: normalizeString(item.productPhotoStyleKey),
|
||||
productPhotoBarcode: normalizeString(item.productPhotoBarcode),
|
||||
installStyleKey: normalizeString(item.installStyleKey),
|
||||
installDrawingId: normalizeString(item.installDrawingId).toUpperCase(),
|
||||
installAssetType: normalizeString(item.installAssetType),
|
||||
installVersionLabel: normalizeString(item.installVersionLabel),
|
||||
installIsCurrentVersion:
|
||||
typeof item.installIsCurrentVersion === "boolean"
|
||||
? item.installIsCurrentVersion
|
||||
: false,
|
||||
installDrawingSyncStatus:
|
||||
item.installDrawingSyncStatus === "pending" ||
|
||||
item.installDrawingSyncStatus === "processing" ||
|
||||
item.installDrawingSyncStatus === "succeeded" ||
|
||||
item.installDrawingSyncStatus === "failed"
|
||||
? item.installDrawingSyncStatus
|
||||
: item.installDrawingSyncStatus === "not_applicable"
|
||||
? "not_applicable"
|
||||
: undefined,
|
||||
installDrawingSyncTaskId: normalizeString(item.installDrawingSyncTaskId),
|
||||
installDrawingSyncFileUrl: normalizeString(item.installDrawingSyncFileUrl),
|
||||
installDrawingSyncError: normalizeString(item.installDrawingSyncError),
|
||||
installDrawingSyncedAt: normalizeString(item.installDrawingSyncedAt),
|
||||
packagingStyleKey: normalizeString(item.packagingStyleKey),
|
||||
packagingAssetType: normalizeString(item.packagingAssetType),
|
||||
factoryQualityStyleKey: normalizeString(item.factoryQualityStyleKey),
|
||||
factoryQualityAssetType: normalizeString(item.factoryQualityAssetType),
|
||||
aiWhiteBackgroundSessionId: normalizeString(item.aiWhiteBackgroundSessionId),
|
||||
aiWhiteBackgroundSessionId: normalizeString(
|
||||
item.aiWhiteBackgroundSessionId,
|
||||
),
|
||||
aiWhiteBackgroundViewKey: normalizeString(item.aiWhiteBackgroundViewKey),
|
||||
aiWhiteBackgroundSourceSlot: normalizeString(item.aiWhiteBackgroundSourceSlot),
|
||||
aiWhiteBackgroundSourceSlot: normalizeString(
|
||||
item.aiWhiteBackgroundSourceSlot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -2074,8 +2123,10 @@ function normalizeBomRow(
|
||||
longEdgeCount: normalizeString(item.longEdgeCount),
|
||||
shortEdgeCount: normalizeString(item.shortEdgeCount),
|
||||
unit:
|
||||
normalizeNonIntegratedBomUnit(normalizedCategory, normalizeString(item.unit)) ||
|
||||
getDefaultNonIntegratedBomUnit(normalizedCategory),
|
||||
normalizeNonIntegratedBomUnit(
|
||||
normalizedCategory,
|
||||
normalizeString(item.unit),
|
||||
) || getDefaultNonIntegratedBomUnit(normalizedCategory),
|
||||
factor: normalizeString(item.factor) || "1",
|
||||
unitPrice: normalizeString(item.unitPrice),
|
||||
unitWeight: normalizeString(item.unitWeight),
|
||||
@ -2200,8 +2251,7 @@ function normalizeReviewImages(
|
||||
|
||||
return {
|
||||
id:
|
||||
normalizeString(item.id) ||
|
||||
`review-image-${Date.now()}-${index}`,
|
||||
normalizeString(item.id) || `review-image-${Date.now()}-${index}`,
|
||||
url,
|
||||
fileName: normalizeString(item.fileName),
|
||||
isPrimary: item.isPrimary === true,
|
||||
@ -2497,10 +2547,14 @@ function toProductRecord(
|
||||
patentApplication: normalizeNonIntegratedPatentApplication(
|
||||
item.patentApplication,
|
||||
),
|
||||
brand: normalizeNonIntegratedBrand(item.brand) || NON_INTEGRATED_DEFAULT_BRAND,
|
||||
brand:
|
||||
normalizeNonIntegratedBrand(item.brand) || NON_INTEGRATED_DEFAULT_BRAND,
|
||||
adaptableBrands: Array.isArray(item.adaptableBrands)
|
||||
? normalizeNonIntegratedBrandList(item.adaptableBrands)
|
||||
: [normalizeNonIntegratedBrand(item.brand) || NON_INTEGRATED_DEFAULT_BRAND],
|
||||
: [
|
||||
normalizeNonIntegratedBrand(item.brand) ||
|
||||
NON_INTEGRATED_DEFAULT_BRAND,
|
||||
],
|
||||
designer: item.designer,
|
||||
operator: normalizeString(item.operator),
|
||||
purchaser: normalizeString(item.purchaser),
|
||||
@ -2572,12 +2626,8 @@ function toProductRecord(
|
||||
skuItems,
|
||||
skuBatchCount: normalizedSkuBatchCount,
|
||||
skuBatchFlowNos: normalizeSkuBatchFlowNos(item.skuBatchFlowNos),
|
||||
skuBatchChangeTypes: normalizeSkuBatchChangeTypes(
|
||||
item.skuBatchChangeTypes,
|
||||
),
|
||||
skuBatchConfirmedAt: normalizeSkuBatchConfirmedAt(
|
||||
item.skuBatchConfirmedAt,
|
||||
),
|
||||
skuBatchChangeTypes: normalizeSkuBatchChangeTypes(item.skuBatchChangeTypes),
|
||||
skuBatchConfirmedAt: normalizeSkuBatchConfirmedAt(item.skuBatchConfirmedAt),
|
||||
factoryRows: Array.isArray(item.factoryRows)
|
||||
? item.factoryRows
|
||||
.map((entry) => normalizeFactoryRow(entry))
|
||||
@ -2751,7 +2801,8 @@ export function saveNonIntegratedProduct(
|
||||
normalizeString(product.previewImageName),
|
||||
);
|
||||
const normalizedBrand =
|
||||
normalizeNonIntegratedBrand(product.brand) || NON_INTEGRATED_DEFAULT_BRAND;
|
||||
normalizeNonIntegratedBrand(product.brand) ||
|
||||
NON_INTEGRATED_DEFAULT_BRAND;
|
||||
|
||||
return {
|
||||
...product,
|
||||
@ -2789,8 +2840,8 @@ export function saveNonIntegratedProduct(
|
||||
(item) =>
|
||||
item.id !== product.id &&
|
||||
resolveNonIntegratedPersistedSpu(item) === normalizedSpu &&
|
||||
(normalizeNonIntegratedBrand(item.brand) || NON_INTEGRATED_DEFAULT_BRAND) ===
|
||||
normalizedBrand,
|
||||
(normalizeNonIntegratedBrand(item.brand) ||
|
||||
NON_INTEGRATED_DEFAULT_BRAND) === normalizedBrand,
|
||||
);
|
||||
|
||||
if (normalizedSpu && duplicateProduct) {
|
||||
|
||||
@ -88,11 +88,10 @@ export function useNonIntegratedFileActionControllers({
|
||||
documentPresentation.activeInstallationAssetFiles,
|
||||
activeInstallationAssetType: documentState.activeInstallationAssetType,
|
||||
activeProductDataGroup: documentState.activeProductDataGroup,
|
||||
createUploadProgressHandler:
|
||||
transferProgress.createUploadProgressHandler,
|
||||
createUploadProgressHandler: transferProgress.createUploadProgressHandler,
|
||||
finishUploadProgress: transferProgress.finishUploadProgress,
|
||||
installationKnownStyleKeys:
|
||||
documentPresentation.installationKnownStyleKeys,
|
||||
installationDrawingIds: documentPresentation.installationKnownStyleKeys,
|
||||
installationKnownStyleKeys: documentPresentation.installationKnownStyleKeys,
|
||||
isReadOnly: editability.isReadOnly,
|
||||
selectedInstallationStyleEntry:
|
||||
documentPresentation.selectedInstallationStyleEntry,
|
||||
@ -129,18 +128,15 @@ export function useNonIntegratedFileActionControllers({
|
||||
inputRefs.pendingProductPhotoCurrentUploadTargetRef,
|
||||
pendingProductPhotoPanelUploadRef:
|
||||
inputRefs.pendingProductPhotoPanelUploadRef,
|
||||
productPhotoKnownStyleKeys:
|
||||
documentPresentation.productPhotoKnownStyleKeys,
|
||||
productPhotoWorkspaceData:
|
||||
documentPresentation.productPhotoWorkspaceData,
|
||||
productPhotoKnownStyleKeys: documentPresentation.productPhotoKnownStyleKeys,
|
||||
productPhotoWorkspaceData: documentPresentation.productPhotoWorkspaceData,
|
||||
selectedProductPhotoTarget: documentState.selectedProductPhotoTarget,
|
||||
setActiveDocumentWorkspaceCategory:
|
||||
documentState.setActiveDocumentWorkspaceCategory,
|
||||
setDocumentWorkspaceView: documentState.setDocumentWorkspaceView,
|
||||
setSaveMessage: overlayState.setSaveMessage,
|
||||
setSaveMessageType: overlayState.setSaveMessageType,
|
||||
setSelectedProductPhotoTarget:
|
||||
documentState.setSelectedProductPhotoTarget,
|
||||
setSelectedProductPhotoTarget: documentState.setSelectedProductPhotoTarget,
|
||||
setUploadFiles: documentState.setUploadFiles,
|
||||
setUploadProgress: transferProgress.setUploadProgress,
|
||||
sharedUploadProductId: editability.sharedUploadProductId,
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import type { NonIntegratedUploadFile } from "../nonIntegratedStorage";
|
||||
import { getUploadFileTypeLabel } from "./documents/uploadFilePreview";
|
||||
|
||||
type InstallationFileCardProps = {
|
||||
file: NonIntegratedUploadFile;
|
||||
drawingId: string;
|
||||
styleLabel: string;
|
||||
assetLabel: string;
|
||||
versionLabel: string;
|
||||
isActive: boolean;
|
||||
onSelect: (fileId: string) => void;
|
||||
};
|
||||
|
||||
export function InstallationFileCard({
|
||||
file,
|
||||
drawingId,
|
||||
styleLabel,
|
||||
assetLabel,
|
||||
versionLabel,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: InstallationFileCardProps) {
|
||||
const syncStatus = file.installDrawingSyncStatus;
|
||||
const fileDrawingId = file.installDrawingId || drawingId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-0 rounded-apple-xl border p-3 transition ${
|
||||
isActive
|
||||
? "border-apple-label-primary bg-white shadow-sm"
|
||||
: "border-apple-separator bg-white/80"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`installation-workspace-file-${file.id}`}
|
||||
onClick={() => onSelect(file.id)}
|
||||
className="flex w-full min-w-0 items-start gap-2 text-left"
|
||||
>
|
||||
<span className="shrink-0 rounded-md bg-apple-bg-secondary px-2 py-1 text-[10px] font-apple-semibold text-apple-label-secondary">
|
||||
{getUploadFileTypeLabel(file)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-apple-semibold text-apple-label-primary">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] text-apple-label-secondary">
|
||||
{styleLabel} · {assetLabel} · {versionLabel}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{fileDrawingId && syncStatus !== "not_applicable" ? (
|
||||
<div className="pt-2">
|
||||
<p
|
||||
className={`min-w-0 truncate text-[11px] ${
|
||||
syncStatus === "succeeded"
|
||||
? "text-emerald-700"
|
||||
: syncStatus === "failed"
|
||||
? "text-rose-600"
|
||||
: "text-apple-label-secondary"
|
||||
}`}
|
||||
title={file.installDrawingSyncError || undefined}
|
||||
data-testid={`installation-sync-status-${file.id}`}
|
||||
>
|
||||
{syncStatus === "succeeded"
|
||||
? "已推送图纸系统"
|
||||
: syncStatus === "failed"
|
||||
? `推送失败:${file.installDrawingSyncError || "未知原因"}`
|
||||
: syncStatus === "processing"
|
||||
? "正在推送图纸系统…"
|
||||
: "等待按顺序推送图纸系统"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type { NonIntegratedUploadFile } from "../nonIntegratedStorage";
|
||||
|
||||
type InstallationVersionGroupHeaderProps = {
|
||||
label: string;
|
||||
isCurrent: boolean;
|
||||
files: NonIntegratedUploadFile[];
|
||||
drawingId: string;
|
||||
isReadOnly: boolean;
|
||||
onPush: (
|
||||
files: NonIntegratedUploadFile[],
|
||||
drawingId: string,
|
||||
versionLabel: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function InstallationVersionGroupHeader({
|
||||
label,
|
||||
isCurrent,
|
||||
files,
|
||||
drawingId,
|
||||
isReadOnly,
|
||||
onPush,
|
||||
}: InstallationVersionGroupHeaderProps) {
|
||||
const pushableFiles = files.filter(
|
||||
(file) =>
|
||||
Boolean(file.installDrawingId || drawingId) &&
|
||||
file.installDrawingSyncStatus !== "not_applicable",
|
||||
);
|
||||
const syncedCount = pushableFiles.filter(
|
||||
(file) => file.installDrawingSyncStatus === "succeeded",
|
||||
).length;
|
||||
const hasProcessingFile = pushableFiles.some(
|
||||
(file) => file.installDrawingSyncStatus === "processing",
|
||||
);
|
||||
const hasFailedFile = pushableFiles.some(
|
||||
(file) => file.installDrawingSyncStatus === "failed",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-apple-semibold text-apple-label-primary">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] ${
|
||||
isCurrent
|
||||
? "bg-apple-label-primary text-white"
|
||||
: "bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
{isCurrent ? "当前版本" : "历史版本"}
|
||||
</span>
|
||||
{pushableFiles.length > 0 && !isReadOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
hasProcessingFile || syncedCount === pushableFiles.length
|
||||
}
|
||||
className="rounded-full bg-apple-label-primary px-3 py-1.5 text-[11px] font-apple-semibold text-white disabled:opacity-50"
|
||||
onClick={() => onPush(pushableFiles, drawingId, label)}
|
||||
data-testid={`installation-sync-push-version-${label}`}
|
||||
>
|
||||
{hasProcessingFile
|
||||
? "正在按顺序推送…"
|
||||
: hasFailedFile
|
||||
? `按顺序重试 · ${pushableFiles.length - syncedCount} 份`
|
||||
: `按顺序推送 · ${pushableFiles.length} 份`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,19 +2,24 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { NonIntegratedUploadFile } from "../nonIntegratedStorage";
|
||||
import { ArchiveFileTile } from "./shared/ArchivePreviewPanel";
|
||||
import { FieldLabel } from "../components/FormControls";
|
||||
import { InstallationFileCard } from "./InstallationFileCard";
|
||||
import { InstallationVersionGroupHeader } from "./InstallationVersionGroupHeader";
|
||||
import {
|
||||
isInstallationCurrentVersion,
|
||||
resolveInstallationAssetType,
|
||||
resolveInstallationVersionLabel,
|
||||
type InstallationAssetType,
|
||||
} from "../styleFileHelpers";
|
||||
import { INSTALLATION_ASSET_TYPE_OPTIONS } from "../workspacePanelConfig";
|
||||
import {
|
||||
INSTALLATION_ASSET_TYPE_OPTIONS,
|
||||
INSTALLATION_SHARED_STYLE_KEY,
|
||||
} from "../workspacePanelConfig";
|
||||
|
||||
type InstallationStyleEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
barcodes: string[];
|
||||
installMethod: string;
|
||||
installModeLabel: string;
|
||||
files: NonIntegratedUploadFile[];
|
||||
@ -43,6 +48,11 @@ type InstallationWorkspaceProps = {
|
||||
isReadOnly: boolean;
|
||||
selectedFileId: string;
|
||||
archivePreviewPanel: ReactNode;
|
||||
onPushDrawingSystemVersion: (
|
||||
files: NonIntegratedUploadFile[],
|
||||
drawingId: string,
|
||||
versionLabel: string,
|
||||
) => void;
|
||||
onSelectStyle: (styleKey: string) => void;
|
||||
onSelectAssetType: (assetType: InstallationAssetType) => void;
|
||||
onNoteChange: (value: string) => void;
|
||||
@ -63,6 +73,7 @@ export function InstallationWorkspace({
|
||||
isReadOnly,
|
||||
selectedFileId,
|
||||
archivePreviewPanel,
|
||||
onPushDrawingSystemVersion,
|
||||
onSelectStyle,
|
||||
onSelectAssetType,
|
||||
onNoteChange,
|
||||
@ -204,44 +215,39 @@ export function InstallationWorkspace({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 space-y-4">
|
||||
{activeInstallationVersionGroups.map((group) => (
|
||||
<div key={group.label} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-apple-semibold text-apple-label-primary">
|
||||
{group.label}
|
||||
</p>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] ${
|
||||
group.isCurrent
|
||||
? "bg-apple-label-primary text-white"
|
||||
: "bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
{group.isCurrent ? "当前版本" : "历史版本"}
|
||||
</span>
|
||||
</div>
|
||||
{activeInstallationVersionGroups.map((group) => {
|
||||
const styleKey = selectedInstallationStyleEntry?.key ?? "";
|
||||
const drawingId =
|
||||
styleKey === INSTALLATION_SHARED_STYLE_KEY ? "" : styleKey;
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
|
||||
{group.files.map((file) => {
|
||||
const isActive = selectedFileId === file.id;
|
||||
return (
|
||||
<div key={group.label} className="space-y-2">
|
||||
<InstallationVersionGroupHeader
|
||||
label={group.label}
|
||||
isCurrent={group.isCurrent}
|
||||
files={group.files}
|
||||
drawingId={drawingId}
|
||||
isReadOnly={isReadOnly}
|
||||
onPush={onPushDrawingSystemVersion}
|
||||
/>
|
||||
|
||||
return (
|
||||
<ArchiveFileTile
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-2">
|
||||
{group.files.map((file) => (
|
||||
<InstallationFileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
isActive={isActive}
|
||||
testId={`installation-workspace-file-${file.id}`}
|
||||
primaryMeta={
|
||||
selectedInstallationStyleEntry?.label ?? "当前款式"
|
||||
}
|
||||
secondaryMeta={`${activeInstallationAssetLabel} · ${group.label}`}
|
||||
onSelectFile={onSelectFile}
|
||||
drawingId={drawingId}
|
||||
styleLabel={selectedInstallationStyleEntry?.label ?? "当前款式"}
|
||||
assetLabel={activeInstallationAssetLabel}
|
||||
versionLabel={group.label}
|
||||
isActive={selectedFileId === file.id}
|
||||
onSelect={onSelectFile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -36,9 +36,11 @@ export type CreateUploadFileRecordOptions = {
|
||||
productPhotoStyleKey?: string;
|
||||
productPhotoBarcode?: string;
|
||||
installStyleKey?: string;
|
||||
installDrawingId?: string;
|
||||
installAssetType?: InstallationAssetType;
|
||||
installVersionLabel?: string;
|
||||
installIsCurrentVersion?: boolean;
|
||||
installDrawingSyncStatus?: "pending" | "processing" | "succeeded" | "failed";
|
||||
packagingStyleKey?: string;
|
||||
packagingAssetType?: PackagingAssetType;
|
||||
detectionAssetType?: DetectionWorkspaceAssetType;
|
||||
@ -73,6 +75,9 @@ export function buildUploadMetadataPayload(
|
||||
if (options.installStyleKey) {
|
||||
metadata.installStyleKey = options.installStyleKey;
|
||||
}
|
||||
if (options.installDrawingId) {
|
||||
metadata.installDrawingId = options.installDrawingId.trim().toUpperCase();
|
||||
}
|
||||
if (options.installAssetType) {
|
||||
metadata.installAssetType = options.installAssetType;
|
||||
}
|
||||
@ -82,6 +87,9 @@ export function buildUploadMetadataPayload(
|
||||
if (typeof options.installIsCurrentVersion === "boolean") {
|
||||
metadata.installIsCurrentVersion = options.installIsCurrentVersion;
|
||||
}
|
||||
if (options.installDrawingSyncStatus) {
|
||||
metadata.installDrawingSyncStatus = options.installDrawingSyncStatus;
|
||||
}
|
||||
if (options.packagingStyleKey) {
|
||||
metadata.packagingStyleKey = options.packagingStyleKey;
|
||||
}
|
||||
@ -117,6 +125,12 @@ export function applyUploadFileCategoryMetadata(
|
||||
productPhotoStyleKey: undefined,
|
||||
productPhotoBarcode: undefined,
|
||||
installStyleKey: undefined,
|
||||
installDrawingId: undefined,
|
||||
installDrawingSyncStatus: undefined,
|
||||
installDrawingSyncTaskId: undefined,
|
||||
installDrawingSyncFileUrl: undefined,
|
||||
installDrawingSyncError: undefined,
|
||||
installDrawingSyncedAt: undefined,
|
||||
installAssetType: undefined,
|
||||
installVersionLabel: undefined,
|
||||
installIsCurrentVersion: undefined,
|
||||
@ -134,8 +148,7 @@ export function applyUploadFileCategoryMetadata(
|
||||
group: options?.photoGroup,
|
||||
slot: options?.photoSlot,
|
||||
styleKey: options?.productPhotoStyleKey ?? file.productPhotoStyleKey,
|
||||
barcode:
|
||||
options?.productPhotoBarcode ?? file.productPhotoBarcode,
|
||||
barcode: options?.productPhotoBarcode ?? file.productPhotoBarcode,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -143,6 +156,12 @@ export function applyUploadFileCategoryMetadata(
|
||||
category,
|
||||
productDataGroup: undefined,
|
||||
installStyleKey: undefined,
|
||||
installDrawingId: undefined,
|
||||
installDrawingSyncStatus: undefined,
|
||||
installDrawingSyncTaskId: undefined,
|
||||
installDrawingSyncFileUrl: undefined,
|
||||
installDrawingSyncError: undefined,
|
||||
installDrawingSyncedAt: undefined,
|
||||
installAssetType: undefined,
|
||||
installVersionLabel: undefined,
|
||||
installIsCurrentVersion: undefined,
|
||||
@ -165,11 +184,16 @@ export function applyUploadFileCategoryMetadata(
|
||||
productPhotoStyleKey: undefined,
|
||||
productPhotoBarcode: undefined,
|
||||
installStyleKey: options?.installStyleKey ?? file.installStyleKey,
|
||||
installDrawingId:
|
||||
options?.installDrawingId?.trim().toUpperCase() ??
|
||||
file.installDrawingId,
|
||||
installAssetType: options?.installAssetType ?? file.installAssetType,
|
||||
installVersionLabel:
|
||||
options?.installVersionLabel ?? file.installVersionLabel,
|
||||
installIsCurrentVersion:
|
||||
options?.installIsCurrentVersion ?? file.installIsCurrentVersion,
|
||||
installDrawingSyncStatus:
|
||||
options?.installDrawingSyncStatus ?? file.installDrawingSyncStatus,
|
||||
packagingStyleKey: undefined,
|
||||
packagingAssetType: undefined,
|
||||
detectionAssetType: undefined,
|
||||
@ -188,6 +212,12 @@ export function applyUploadFileCategoryMetadata(
|
||||
productPhotoStyleKey: undefined,
|
||||
productPhotoBarcode: undefined,
|
||||
installStyleKey: undefined,
|
||||
installDrawingId: undefined,
|
||||
installDrawingSyncStatus: undefined,
|
||||
installDrawingSyncTaskId: undefined,
|
||||
installDrawingSyncFileUrl: undefined,
|
||||
installDrawingSyncError: undefined,
|
||||
installDrawingSyncedAt: undefined,
|
||||
installAssetType: undefined,
|
||||
installVersionLabel: undefined,
|
||||
installIsCurrentVersion: undefined,
|
||||
@ -210,6 +240,12 @@ export function applyUploadFileCategoryMetadata(
|
||||
productPhotoStyleKey: undefined,
|
||||
productPhotoBarcode: undefined,
|
||||
installStyleKey: undefined,
|
||||
installDrawingId: undefined,
|
||||
installDrawingSyncStatus: undefined,
|
||||
installDrawingSyncTaskId: undefined,
|
||||
installDrawingSyncFileUrl: undefined,
|
||||
installDrawingSyncError: undefined,
|
||||
installDrawingSyncedAt: undefined,
|
||||
installAssetType: undefined,
|
||||
installVersionLabel: undefined,
|
||||
installIsCurrentVersion: undefined,
|
||||
@ -232,6 +268,12 @@ export function applyUploadFileCategoryMetadata(
|
||||
productPhotoStyleKey: undefined,
|
||||
productPhotoBarcode: undefined,
|
||||
installStyleKey: undefined,
|
||||
installDrawingId: undefined,
|
||||
installDrawingSyncStatus: undefined,
|
||||
installDrawingSyncTaskId: undefined,
|
||||
installDrawingSyncFileUrl: undefined,
|
||||
installDrawingSyncError: undefined,
|
||||
installDrawingSyncedAt: undefined,
|
||||
installAssetType: undefined,
|
||||
installVersionLabel: undefined,
|
||||
installIsCurrentVersion: undefined,
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
import { useRef, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage";
|
||||
import { pushInstallationFileToDrawingSystem } from "../../core/nonIntegratedApi";
|
||||
import type { InstallationAssetType } from "../style-assets/styleFileHelpers";
|
||||
import { pushInstallationFilesInOrder } from "../style-assets/installationFileOrdering";
|
||||
|
||||
type SaveMessageType = "success" | "error";
|
||||
|
||||
export function useInstallationDrawingSystemPushController({
|
||||
activeInstallationAssetType,
|
||||
isReadOnly,
|
||||
setSaveMessage,
|
||||
setSaveMessageType,
|
||||
setUploadFiles,
|
||||
sharedUploadProductId,
|
||||
}: {
|
||||
activeInstallationAssetType: InstallationAssetType;
|
||||
isReadOnly: boolean;
|
||||
setSaveMessage: Dispatch<SetStateAction<string>>;
|
||||
setSaveMessageType: Dispatch<SetStateAction<SaveMessageType>>;
|
||||
setUploadFiles: Dispatch<SetStateAction<NonIntegratedUploadFile[]>>;
|
||||
sharedUploadProductId?: string;
|
||||
}) {
|
||||
const activePushGroupsRef = useRef(new Set<string>());
|
||||
|
||||
async function handlePushInstallationDrawingSystemVersion(
|
||||
files: NonIntegratedUploadFile[],
|
||||
requestedDrawingId: string,
|
||||
versionLabel: string,
|
||||
) {
|
||||
if (isReadOnly || !sharedUploadProductId) return;
|
||||
const drawingId =
|
||||
requestedDrawingId.trim().toUpperCase() ||
|
||||
files
|
||||
.map((file) => file.installDrawingId?.trim().toUpperCase() ?? "")
|
||||
.find(Boolean) ||
|
||||
"";
|
||||
if (!drawingId) return;
|
||||
|
||||
const pushGroupKey = `${drawingId}:${activeInstallationAssetType}:${versionLabel}`;
|
||||
if (activePushGroupsRef.current.has(pushGroupKey)) return;
|
||||
activePushGroupsRef.current.add(pushGroupKey);
|
||||
|
||||
try {
|
||||
const result = await pushInstallationFilesInOrder(files, {
|
||||
onProcessing: (file) => {
|
||||
setUploadFiles((current) =>
|
||||
current.map((item) =>
|
||||
item.id === file.id
|
||||
? {
|
||||
...item,
|
||||
installDrawingSyncStatus: "processing",
|
||||
installDrawingSyncError: "",
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
push: (file) =>
|
||||
pushInstallationFileToDrawingSystem({
|
||||
productId: sharedUploadProductId,
|
||||
fileId: file.id,
|
||||
drawingId:
|
||||
file.installDrawingId?.trim().toUpperCase() || drawingId,
|
||||
}),
|
||||
onSucceeded: (_file, response) => {
|
||||
setUploadFiles((current) =>
|
||||
current.map((item) =>
|
||||
item.id === response.file.id ? response.file : item,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFailed: (file, error) => {
|
||||
const message = error instanceof Error ? error.message : "推送失败";
|
||||
setUploadFiles((current) =>
|
||||
current.map((item) =>
|
||||
item.id === file.id
|
||||
? {
|
||||
...item,
|
||||
installDrawingSyncStatus: "failed",
|
||||
installDrawingSyncError: message,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (result.failedFile) {
|
||||
const reason =
|
||||
result.failedError instanceof Error
|
||||
? result.failedError.message
|
||||
: "图纸系统返回失败";
|
||||
setSaveMessage(
|
||||
`${versionLabel}已按顺序推送 ${result.succeededCount} 份;${result.failedFile.name}失败,后续 ${result.remainingCount} 份暂未推送,可重试继续。${reason}`,
|
||||
);
|
||||
setSaveMessageType("error");
|
||||
} else {
|
||||
setSaveMessage(
|
||||
`${versionLabel}已按顺序推送完成,共 ${result.succeededCount + result.skippedSucceededCount} 份(本次推送 ${result.succeededCount} 份)。`,
|
||||
);
|
||||
setSaveMessageType("success");
|
||||
}
|
||||
} finally {
|
||||
activePushGroupsRef.current.delete(pushGroupKey);
|
||||
}
|
||||
}
|
||||
|
||||
return { handlePushInstallationDrawingSystemVersion };
|
||||
}
|
||||
@ -19,13 +19,18 @@ import type {
|
||||
} from "./documentWorkspaceHelpers";
|
||||
import type { UploadProgressState } from "./progressHelpers";
|
||||
import { inferUploadCategory } from "./uploadCategoryHelpers";
|
||||
import { createUploadFileRecords } from "./uploadRecordHelpers";
|
||||
import {
|
||||
createUploadFileRecord,
|
||||
createUploadFileRecords,
|
||||
} from "./uploadRecordHelpers";
|
||||
import {
|
||||
buildNextInstallationVersionLabel,
|
||||
resolveInstallationAssetType,
|
||||
resolveInstallationFileDrawingId,
|
||||
resolveInstallationFileStyleKey,
|
||||
type InstallationAssetType,
|
||||
} from "../style-assets/styleFileHelpers";
|
||||
import { useInstallationDrawingSystemPushController } from "./useInstallationDrawingSystemPushController";
|
||||
import type { InstallationStyleEntry } from "../style-assets/styleWorkspaceEntries";
|
||||
import {
|
||||
INSTALLATION_ASSET_TYPE_LABELS,
|
||||
@ -47,6 +52,7 @@ type UseWorkspaceFileUploadControllerParams = {
|
||||
) => NonIntegratedUploadProgressHandler;
|
||||
finishUploadProgress: (fileName?: string) => void;
|
||||
installationKnownStyleKeys: string[];
|
||||
installationDrawingIds: string[];
|
||||
isReadOnly: boolean;
|
||||
selectedInstallationStyleEntry: InstallationStyleEntry | null;
|
||||
setActiveDetectionWorkspacePanel: Dispatch<
|
||||
@ -75,6 +81,7 @@ export function useWorkspaceFileUploadController({
|
||||
createUploadProgressHandler,
|
||||
finishUploadProgress,
|
||||
installationKnownStyleKeys,
|
||||
installationDrawingIds,
|
||||
isReadOnly,
|
||||
selectedInstallationStyleEntry,
|
||||
setActiveDetectionWorkspacePanel,
|
||||
@ -89,6 +96,16 @@ export function useWorkspaceFileUploadController({
|
||||
sharedUploadProductId,
|
||||
startUploadProgress,
|
||||
}: UseWorkspaceFileUploadControllerParams) {
|
||||
const { handlePushInstallationDrawingSystemVersion } =
|
||||
useInstallationDrawingSystemPushController({
|
||||
activeInstallationAssetType,
|
||||
isReadOnly,
|
||||
setSaveMessage,
|
||||
setSaveMessageType,
|
||||
setUploadFiles,
|
||||
sharedUploadProductId,
|
||||
});
|
||||
|
||||
async function handleUploadFilesChange(
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
forcedCategory?: DocumentWorkspaceCategory,
|
||||
@ -179,7 +196,16 @@ export function useWorkspaceFileUploadController({
|
||||
const targetStyleKey =
|
||||
selectedInstallationStyleEntry?.key === INSTALLATION_SHARED_STYLE_KEY
|
||||
? ""
|
||||
: (selectedInstallationStyleEntry?.key ?? "");
|
||||
: installationDrawingIds.includes(
|
||||
selectedInstallationStyleEntry?.key ?? "",
|
||||
)
|
||||
? ""
|
||||
: (selectedInstallationStyleEntry?.key ?? "");
|
||||
const targetDrawingId = installationDrawingIds.includes(
|
||||
selectedInstallationStyleEntry?.key ?? "",
|
||||
)
|
||||
? (selectedInstallationStyleEntry?.key ?? "")
|
||||
: "";
|
||||
const targetAssetType = activeInstallationAssetType;
|
||||
const targetStyleLabel =
|
||||
selectedInstallationStyleEntry?.label ?? "安装资料";
|
||||
@ -189,18 +215,24 @@ export function useWorkspaceFileUploadController({
|
||||
|
||||
try {
|
||||
startUploadProgress(files);
|
||||
const nextFiles = await createUploadFileRecords(
|
||||
files,
|
||||
INSTALLATION_WORKSPACE_CATEGORY,
|
||||
{
|
||||
productId: sharedUploadProductId,
|
||||
installStyleKey: targetStyleKey,
|
||||
installAssetType: targetAssetType,
|
||||
installVersionLabel: nextVersionLabel,
|
||||
installIsCurrentVersion: true,
|
||||
},
|
||||
createUploadProgressHandler,
|
||||
);
|
||||
const nextFiles: NonIntegratedUploadFile[] = [];
|
||||
for (const [index, file] of files.entries()) {
|
||||
const savedFile = await createUploadFileRecord(
|
||||
file,
|
||||
INSTALLATION_WORKSPACE_CATEGORY,
|
||||
{
|
||||
productId: sharedUploadProductId,
|
||||
installStyleKey: targetStyleKey,
|
||||
installDrawingId: targetDrawingId || undefined,
|
||||
installAssetType: targetAssetType,
|
||||
installVersionLabel: nextVersionLabel,
|
||||
installIsCurrentVersion: true,
|
||||
installDrawingSyncStatus: targetDrawingId ? "pending" : undefined,
|
||||
},
|
||||
createUploadProgressHandler(file.name, index, files.length),
|
||||
);
|
||||
nextFiles.push(savedFile);
|
||||
}
|
||||
|
||||
setUploadFiles((current) => {
|
||||
const retainedFiles = current.map((file) => {
|
||||
@ -208,14 +240,15 @@ export function useWorkspaceFileUploadController({
|
||||
return file;
|
||||
}
|
||||
|
||||
const fileStyleKey = resolveInstallationFileStyleKey(
|
||||
file,
|
||||
installationKnownStyleKeys,
|
||||
);
|
||||
const fileStyleKey = targetDrawingId
|
||||
? resolveInstallationFileDrawingId(file, installationDrawingIds)
|
||||
: resolveInstallationFileStyleKey(file, installationKnownStyleKeys);
|
||||
const isSameStyle =
|
||||
(targetStyleKey
|
||||
? fileStyleKey === targetStyleKey
|
||||
: !fileStyleKey) &&
|
||||
(targetDrawingId
|
||||
? fileStyleKey === targetDrawingId
|
||||
: targetStyleKey
|
||||
? fileStyleKey === targetStyleKey
|
||||
: !fileStyleKey) &&
|
||||
resolveInstallationAssetType(file) === targetAssetType;
|
||||
|
||||
if (!isSameStyle) {
|
||||
@ -232,11 +265,18 @@ export function useWorkspaceFileUploadController({
|
||||
});
|
||||
|
||||
setSelectedWorkspaceFileId(nextFiles[0]?.id ?? "");
|
||||
setSaveMessage(
|
||||
`${targetStyleLabel}${INSTALLATION_ASSET_TYPE_LABELS[targetAssetType]}已上传,当前版本 ${nextVersionLabel}。`,
|
||||
);
|
||||
setSaveMessageType("success");
|
||||
finishUploadProgress(nextFiles[nextFiles.length - 1]?.name);
|
||||
if (targetDrawingId && sharedUploadProductId) {
|
||||
setSaveMessage(
|
||||
`${targetStyleLabel}安装资料已保存到产品资料,请在文件卡片上点击“推送图纸系统”。`,
|
||||
);
|
||||
setSaveMessageType("success");
|
||||
} else {
|
||||
setSaveMessage(
|
||||
`${targetStyleLabel}${INSTALLATION_ASSET_TYPE_LABELS[targetAssetType]}已上传,当前版本 ${nextVersionLabel}。`,
|
||||
);
|
||||
setSaveMessageType("success");
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveMessage(
|
||||
error instanceof Error ? error.message : "安装资料上传失败",
|
||||
@ -293,5 +333,6 @@ export function useWorkspaceFileUploadController({
|
||||
handleUploadFilesChange,
|
||||
handleUploadFirstQualityFiles,
|
||||
handleUploadInstallationFiles,
|
||||
handlePushInstallationDrawingSystemVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import {
|
||||
pushInstallationFilesInOrder,
|
||||
sortInstallationFiles,
|
||||
} from "./installationFileOrdering";
|
||||
|
||||
describe("installation file ordering", () => {
|
||||
it("sorts numbered filenames naturally", () => {
|
||||
const files = ["10.jpg", "2.jpg", "1.jpg", "5.jpg", "3.jpg"];
|
||||
|
||||
expect(sortInstallationFiles(files.map((name) => ({ name })))).toEqual([
|
||||
{ name: "1.jpg" },
|
||||
{ name: "2.jpg" },
|
||||
{ name: "3.jpg" },
|
||||
{ name: "5.jpg" },
|
||||
{ name: "10.jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pushes in displayed order, skips successes, and stops at the first failure", async () => {
|
||||
const files = [
|
||||
{ name: "5.jpg", installDrawingSyncStatus: "pending" as const },
|
||||
{ name: "2.jpg", installDrawingSyncStatus: "succeeded" as const },
|
||||
{ name: "1.jpg", installDrawingSyncStatus: "pending" as const },
|
||||
{ name: "3.jpg", installDrawingSyncStatus: "pending" as const },
|
||||
{ name: "4.jpg", installDrawingSyncStatus: "pending" as const },
|
||||
];
|
||||
const pushed: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
const result = await pushInstallationFilesInOrder(files, {
|
||||
onProcessing: () => undefined,
|
||||
push: async (file) => {
|
||||
pushed.push(file.name);
|
||||
if (file.name === "3.jpg") throw new Error("network error");
|
||||
return file.name;
|
||||
},
|
||||
onSucceeded: () => undefined,
|
||||
onFailed: (file) => failed.push(file.name),
|
||||
});
|
||||
|
||||
expect(pushed).toEqual(["1.jpg", "3.jpg"]);
|
||||
expect(failed).toEqual(["3.jpg"]);
|
||||
expect(result).toMatchObject({
|
||||
succeededCount: 1,
|
||||
skippedSucceededCount: 1,
|
||||
failedFile: files[3],
|
||||
remainingCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,71 @@
|
||||
import type { NonIntegratedUploadFile } from "../../nonIntegratedStorage";
|
||||
|
||||
const installationFileNameCollator = new Intl.Collator("zh-CN", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
|
||||
export function sortInstallationFiles<T extends Pick<NonIntegratedUploadFile, "name">>(
|
||||
files: readonly T[],
|
||||
) {
|
||||
return [...files].sort((left, right) =>
|
||||
installationFileNameCollator.compare(left.name, right.name),
|
||||
);
|
||||
}
|
||||
|
||||
type InstallationSyncStatus = NonIntegratedUploadFile["installDrawingSyncStatus"];
|
||||
|
||||
export async function pushInstallationFilesInOrder<
|
||||
T extends {
|
||||
name: string;
|
||||
installDrawingSyncStatus?: InstallationSyncStatus;
|
||||
},
|
||||
TResult,
|
||||
>(
|
||||
files: readonly T[],
|
||||
handlers: {
|
||||
onProcessing: (file: T) => void;
|
||||
push: (file: T) => Promise<TResult>;
|
||||
onSucceeded: (file: T, result: TResult) => void;
|
||||
onFailed: (file: T, error: unknown) => void;
|
||||
},
|
||||
) {
|
||||
const orderedFiles = sortInstallationFiles(files);
|
||||
let skippedSucceededCount = 0;
|
||||
let succeededCount = 0;
|
||||
|
||||
for (let index = 0; index < orderedFiles.length; index += 1) {
|
||||
const file = orderedFiles[index];
|
||||
if (!file) continue;
|
||||
if (file.installDrawingSyncStatus === "succeeded") {
|
||||
skippedSucceededCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
handlers.onProcessing(file);
|
||||
try {
|
||||
const result = await handlers.push(file);
|
||||
handlers.onSucceeded(file, result);
|
||||
succeededCount += 1;
|
||||
} catch (error) {
|
||||
handlers.onFailed(file, error);
|
||||
return {
|
||||
succeededCount,
|
||||
skippedSucceededCount,
|
||||
failedFile: file,
|
||||
failedError: error,
|
||||
remainingCount: orderedFiles
|
||||
.slice(index + 1)
|
||||
.filter((remaining) => remaining.installDrawingSyncStatus !== "succeeded")
|
||||
.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
succeededCount,
|
||||
skippedSucceededCount,
|
||||
failedFile: null,
|
||||
remainingCount: 0,
|
||||
};
|
||||
}
|
||||
@ -80,7 +80,9 @@ export function getSkuBackedStyleLabel(
|
||||
);
|
||||
const modelLabel = matchedSku?.model.trim();
|
||||
|
||||
return modelLabel || getInstallationStyleLabel(normalizedStyleKey) || fallbackLabel;
|
||||
return (
|
||||
modelLabel || getInstallationStyleLabel(normalizedStyleKey) || fallbackLabel
|
||||
);
|
||||
}
|
||||
|
||||
export function getProductPhotoStyleLabel(
|
||||
@ -125,6 +127,9 @@ export function resolveInstallationFileStyleKey(
|
||||
) {
|
||||
if (!file) return "";
|
||||
|
||||
const drawingId = resolveInstallationFileDrawingId(file, knownStyleKeys);
|
||||
if (drawingId) return drawingId;
|
||||
|
||||
const explicitStyleKey = file.installStyleKey?.trim().toUpperCase() ?? "";
|
||||
if (explicitStyleKey && knownStyleKeys.includes(explicitStyleKey)) {
|
||||
return explicitStyleKey;
|
||||
@ -149,6 +154,36 @@ export function resolveInstallationFileStyleKey(
|
||||
return knownStyleKeys.length === 1 ? (knownStyleKeys[0] ?? "") : "";
|
||||
}
|
||||
|
||||
export function resolveInstallationFileDrawingId(
|
||||
file: NonIntegratedUploadFile | null,
|
||||
knownDrawingIds: string[],
|
||||
) {
|
||||
if (!file) return "";
|
||||
const normalizedIds = knownDrawingIds
|
||||
.map((drawingId) => drawingId.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
const explicitDrawingId = file.installDrawingId?.trim().toUpperCase() ?? "";
|
||||
if (explicitDrawingId && normalizedIds.includes(explicitDrawingId)) {
|
||||
return explicitDrawingId;
|
||||
}
|
||||
|
||||
const normalizedFileName = getFileNameWithoutExtension(file.name);
|
||||
const filenameMatches = normalizedIds.filter((drawingId) =>
|
||||
normalizedFileName.includes(drawingId),
|
||||
);
|
||||
if (filenameMatches.length === 1) return filenameMatches[0] ?? "";
|
||||
|
||||
const legacyStyleKey = file.installStyleKey?.trim().toUpperCase() ?? "";
|
||||
if (legacyStyleKey) {
|
||||
const legacyMatches = normalizedIds.filter((drawingId) =>
|
||||
drawingId.includes(legacyStyleKey),
|
||||
);
|
||||
if (legacyMatches.length === 1) return legacyMatches[0] ?? "";
|
||||
}
|
||||
|
||||
return normalizedIds.length === 1 ? (normalizedIds[0] ?? "") : "";
|
||||
}
|
||||
|
||||
export function resolveFactoryQualityAssetType(
|
||||
file: NonIntegratedUploadFile | null,
|
||||
): FactoryQualityAssetType {
|
||||
|
||||
@ -3,6 +3,8 @@ import type {
|
||||
NonIntegratedUploadFile,
|
||||
} from "../../nonIntegratedStorage";
|
||||
import {
|
||||
buildInstallationKnownStyleKeys,
|
||||
buildInstallationStyleEntries,
|
||||
buildProductPhotoStyleEntries,
|
||||
buildProductPhotoWorkspaceData,
|
||||
} from "./styleWorkspaceEntries";
|
||||
@ -61,6 +63,71 @@ function createProductPhotoFile(
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildInstallationStyleEntries", () => {
|
||||
it("groups by the full drawing ID and derives every matching barcode", () => {
|
||||
const entries = buildInstallationStyleEntries({
|
||||
activeFiles: [
|
||||
{
|
||||
id: "install-file-1",
|
||||
name: "instructions.pdf",
|
||||
category: "安装方式",
|
||||
dataUrl: "/api/storage/object?key=install-file-1",
|
||||
mimeType: "application/pdf",
|
||||
installDrawingId: "BJ0226K01AV01",
|
||||
installAssetType: "drawing",
|
||||
},
|
||||
],
|
||||
skuItems: [
|
||||
createSkuItem({
|
||||
id: "sku-1",
|
||||
model: "BJ0226K01",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
barcode: "BJ0226K01AV01",
|
||||
}),
|
||||
createSkuItem({
|
||||
id: "sku-2",
|
||||
skuSequence: 2,
|
||||
model: "BJ0226K01",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
barcode: "BJ0226K01AV01-BLACK",
|
||||
}),
|
||||
createSkuItem({
|
||||
id: "sku-3",
|
||||
skuSequence: 3,
|
||||
model: "BJ0226K02",
|
||||
drawingId: "BJ0226K02AV01",
|
||||
barcode: "BJ0226K02AV01",
|
||||
}),
|
||||
],
|
||||
styleDetails: {},
|
||||
styleKeys: ["K01", "K02"],
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
"BJ0226K01AV01",
|
||||
"BJ0226K02AV01",
|
||||
]);
|
||||
expect(entries[0]?.files.map((file) => file.id)).toEqual([
|
||||
"install-file-1",
|
||||
]);
|
||||
expect(entries[0]?.barcodes).toEqual([
|
||||
"BJ0226K01AV01",
|
||||
"BJ0226K01AV01-BLACK",
|
||||
]);
|
||||
expect(
|
||||
buildInstallationKnownStyleKeys({
|
||||
skuStyleKeys: ["K01", "K02"],
|
||||
skuItems: [
|
||||
createSkuItem({ drawingId: "BJ0226K01AV01" }),
|
||||
createSkuItem({ drawingId: "BJ0226K02AV01" }),
|
||||
],
|
||||
styleDetails: {},
|
||||
uploadFiles: [],
|
||||
}),
|
||||
).toEqual(["BJ0226K01AV01", "BJ0226K02AV01"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProductPhotoStyleEntries", () => {
|
||||
it("assigns styleless sku barcodes to the only product photo style", () => {
|
||||
const entries = buildProductPhotoStyleEntries({
|
||||
@ -124,15 +191,18 @@ describe("buildProductPhotoStyleEntries", () => {
|
||||
|
||||
expect(emptyBarcodeEntry?.hasOriginalImages).toBe(false);
|
||||
|
||||
const workspaceData = buildProductPhotoWorkspaceData(entries[0]?.files ?? [], {
|
||||
aiSourceSlotFiles: emptyBarcodeEntry?.aiSourceSlotFiles,
|
||||
productPhotoSlotFiles: emptyBarcodeEntry?.productPhotoSlotFiles,
|
||||
});
|
||||
const workspaceData = buildProductPhotoWorkspaceData(
|
||||
entries[0]?.files ?? [],
|
||||
{
|
||||
aiSourceSlotFiles: emptyBarcodeEntry?.aiSourceSlotFiles,
|
||||
productPhotoSlotFiles: emptyBarcodeEntry?.productPhotoSlotFiles,
|
||||
},
|
||||
);
|
||||
|
||||
expect(workspaceData.filledSlotCount).toBe(0);
|
||||
expect(Object.values(workspaceData.slotFiles).every((file) => file === null)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
Object.values(workspaceData.slotFiles).every((file) => file === null),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps shared positive source when the latest borrow session is partial", () => {
|
||||
|
||||
@ -20,10 +20,7 @@ import {
|
||||
type AiWhiteBackgroundViewKey,
|
||||
} from "../ai-white-background/nonIntegratedAiWhiteBackground";
|
||||
import { normalizeInstallationModeLabel } from "../basic-info/skuFieldConfig";
|
||||
import {
|
||||
EMPTY_STYLE_DETAIL,
|
||||
type StyleDetailForm,
|
||||
} from "./styleDetailState";
|
||||
import { EMPTY_STYLE_DETAIL, type StyleDetailForm } from "./styleDetailState";
|
||||
import {
|
||||
INSTALLATION_SHARED_STYLE_KEY,
|
||||
hasPackagingInnerItemContent,
|
||||
@ -36,6 +33,7 @@ import {
|
||||
resolveFactoryQualityAssetType,
|
||||
resolveFactoryQualityFileStyleKey,
|
||||
resolveInstallationAssetType,
|
||||
resolveInstallationFileDrawingId,
|
||||
resolveInstallationFileStyleKey,
|
||||
resolvePackagingFileStyleKey,
|
||||
sortStyleKeys,
|
||||
@ -44,6 +42,8 @@ import {
|
||||
export type InstallationStyleEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
modelStyleKey: string;
|
||||
barcodes: string[];
|
||||
installMethod: string;
|
||||
installModeLabel: string;
|
||||
files: NonIntegratedUploadFile[];
|
||||
@ -97,7 +97,10 @@ export type ProductPhotoBarcodeEntry = {
|
||||
ProductPhotoNineViewSlot,
|
||||
NonIntegratedUploadFile | null
|
||||
>;
|
||||
aiSourceSlotFiles: Record<AiWhiteBackgroundSourceSlot, NonIntegratedUploadFile | null>;
|
||||
aiSourceSlotFiles: Record<
|
||||
AiWhiteBackgroundSourceSlot,
|
||||
NonIntegratedUploadFile | null
|
||||
>;
|
||||
availableProductPhotoSlotCount: number;
|
||||
availableAiSourceSlotCount: number;
|
||||
files: NonIntegratedUploadFile[];
|
||||
@ -118,13 +121,24 @@ export function buildSkuStyleKeys(skuItems: NonIntegratedSkuItem[]) {
|
||||
|
||||
export function buildInstallationKnownStyleKeys({
|
||||
skuStyleKeys,
|
||||
skuItems = [],
|
||||
styleDetails,
|
||||
uploadFiles,
|
||||
}: {
|
||||
skuStyleKeys: string[];
|
||||
skuItems?: NonIntegratedSkuItem[];
|
||||
styleDetails: Record<string, StyleDetailForm>;
|
||||
uploadFiles: NonIntegratedUploadFile[];
|
||||
}) {
|
||||
const drawingIds = Array.from(
|
||||
new Set(
|
||||
skuItems
|
||||
.map((item) => item.drawingId.trim().toUpperCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
if (drawingIds.length > 0) return drawingIds.sort();
|
||||
|
||||
return buildKnownStyleKeysFromDetailsAndFiles({
|
||||
skuStyleKeys,
|
||||
styleDetails,
|
||||
@ -204,49 +218,81 @@ export function buildInstallationStyleEntries({
|
||||
}) {
|
||||
const entryMap = new Map<string, InstallationStyleEntry>();
|
||||
const sharedFiles: NonIntegratedUploadFile[] = [];
|
||||
const drawingIds = Array.from(
|
||||
new Set(
|
||||
skuItems
|
||||
.map((item) => item.drawingId.trim().toUpperCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const isDrawingIdGrouping = drawingIds.length > 0;
|
||||
|
||||
const ensureEntry = (styleKey: string) => {
|
||||
const normalizedStyleKey = styleKey.trim().toUpperCase();
|
||||
const existingEntry = entryMap.get(normalizedStyleKey);
|
||||
const ensureEntry = (key: string) => {
|
||||
const normalizedKey = key.trim().toUpperCase();
|
||||
const existingEntry = entryMap.get(normalizedKey);
|
||||
if (existingEntry) {
|
||||
return existingEntry;
|
||||
}
|
||||
|
||||
const representative = isDrawingIdGrouping
|
||||
? skuItems.find(
|
||||
(item) => item.drawingId.trim().toUpperCase() === normalizedKey,
|
||||
)
|
||||
: skuItems.find(
|
||||
(item) => getStyleKeyFromModel(item.model) === normalizedKey,
|
||||
);
|
||||
const modelStyleKey = representative
|
||||
? getStyleKeyFromModel(representative.model)
|
||||
: isDrawingIdGrouping
|
||||
? ""
|
||||
: normalizedKey;
|
||||
const installMethod =
|
||||
styleDetails[normalizedStyleKey]?.installMethod ??
|
||||
skuItems.find(
|
||||
(item) => getStyleKeyFromModel(item.model) === normalizedStyleKey,
|
||||
)?.installMethod ??
|
||||
styleDetails[modelStyleKey]?.installMethod ??
|
||||
representative?.installMethod ??
|
||||
"";
|
||||
const barcodes = isDrawingIdGrouping
|
||||
? Array.from(
|
||||
new Set(
|
||||
skuItems
|
||||
.filter(
|
||||
(item) => item.drawingId.trim().toUpperCase() === normalizedKey,
|
||||
)
|
||||
.map((item) => item.barcode.trim().toUpperCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort()
|
||||
: [];
|
||||
const nextEntry: InstallationStyleEntry = {
|
||||
key: normalizedStyleKey,
|
||||
label: getSkuBackedStyleLabel(
|
||||
normalizedStyleKey,
|
||||
skuItems,
|
||||
"安装资料",
|
||||
),
|
||||
key: normalizedKey,
|
||||
label: isDrawingIdGrouping
|
||||
? normalizedKey
|
||||
: getSkuBackedStyleLabel(normalizedKey, skuItems, "安装资料"),
|
||||
modelStyleKey,
|
||||
barcodes,
|
||||
installMethod,
|
||||
installModeLabel: normalizeInstallationModeLabel(installMethod),
|
||||
files: [],
|
||||
drawingCount: 0,
|
||||
videoCount: 0,
|
||||
};
|
||||
entryMap.set(normalizedStyleKey, nextEntry);
|
||||
entryMap.set(normalizedKey, nextEntry);
|
||||
return nextEntry;
|
||||
};
|
||||
|
||||
styleKeys.forEach((styleKey) => {
|
||||
ensureEntry(styleKey);
|
||||
(isDrawingIdGrouping ? drawingIds : styleKeys).forEach((key) => {
|
||||
ensureEntry(key);
|
||||
});
|
||||
|
||||
activeFiles.forEach((file) => {
|
||||
const resolvedStyleKey = resolveInstallationFileStyleKey(file, styleKeys);
|
||||
if (!resolvedStyleKey) {
|
||||
const resolvedKey = isDrawingIdGrouping
|
||||
? resolveInstallationFileDrawingId(file, drawingIds)
|
||||
: resolveInstallationFileStyleKey(file, styleKeys);
|
||||
if (!resolvedKey) {
|
||||
sharedFiles.push(file);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureEntry(resolvedStyleKey).files.push(file);
|
||||
ensureEntry(resolvedKey).files.push(file);
|
||||
});
|
||||
|
||||
const entries = Array.from(entryMap.values())
|
||||
@ -274,6 +320,8 @@ export function buildInstallationStyleEntries({
|
||||
entries.unshift({
|
||||
key: INSTALLATION_SHARED_STYLE_KEY,
|
||||
label: entries.length === 0 ? "安装资料" : "通用资料",
|
||||
modelStyleKey: "",
|
||||
barcodes: [],
|
||||
installMethod: "",
|
||||
installModeLabel: "未填写",
|
||||
files: sharedFiles,
|
||||
@ -309,11 +357,7 @@ export function buildFactoryQualityStyleEntries({
|
||||
|
||||
const nextEntry: FactoryQualityStyleEntry = {
|
||||
key: normalizedStyleKey,
|
||||
label: getSkuBackedStyleLabel(
|
||||
normalizedStyleKey,
|
||||
skuItems,
|
||||
"首批质检",
|
||||
),
|
||||
label: getSkuBackedStyleLabel(normalizedStyleKey, skuItems, "首批质检"),
|
||||
files: [],
|
||||
imageCount: 0,
|
||||
videoCount: 0,
|
||||
@ -378,8 +422,8 @@ export function buildPackagingStyleEntries({
|
||||
key: normalizedStyleKey,
|
||||
label: getPackagingStyleLabel(normalizedStyleKey, skuItems),
|
||||
outerPackagingType: styleDetail.outerPackagingType.trim(),
|
||||
innerPackagingItemCount: styleDetail.innerPackagingItems.filter(
|
||||
(item) => hasPackagingInnerItemContent(item),
|
||||
innerPackagingItemCount: styleDetail.innerPackagingItems.filter((item) =>
|
||||
hasPackagingInnerItemContent(item),
|
||||
).length,
|
||||
files: [],
|
||||
drawingCount: 0,
|
||||
@ -537,7 +581,8 @@ export function buildProductPhotoWorkspaceData(
|
||||
);
|
||||
const slotFiles = Object.fromEntries(
|
||||
PRODUCT_PHOTO_NINE_VIEW_SLOTS.map((slot) => {
|
||||
const productPhotoFile = options?.productPhotoSlotFiles?.[slot.key] ?? null;
|
||||
const productPhotoFile =
|
||||
options?.productPhotoSlotFiles?.[slot.key] ?? null;
|
||||
if (productPhotoFile) {
|
||||
return [slot.key, productPhotoFile];
|
||||
}
|
||||
@ -552,7 +597,7 @@ export function buildProductPhotoWorkspaceData(
|
||||
|
||||
return [
|
||||
slot.key,
|
||||
hasScopedSlotOptions ? null : slotBuckets[slot.key].at(-1) ?? null,
|
||||
hasScopedSlotOptions ? null : (slotBuckets[slot.key].at(-1) ?? null),
|
||||
];
|
||||
}),
|
||||
) as Record<ProductPhotoNineViewSlot, NonIntegratedUploadFile | null>;
|
||||
@ -580,7 +625,8 @@ function buildProductPhotoBarcodeEntries({
|
||||
fallbackStyleKey?: string;
|
||||
}) {
|
||||
const normalizedStyleKey = styleKey.trim().toUpperCase();
|
||||
const normalizedFallbackStyleKey = fallbackStyleKey?.trim().toUpperCase() ?? "";
|
||||
const normalizedFallbackStyleKey =
|
||||
fallbackStyleKey?.trim().toUpperCase() ?? "";
|
||||
const sharedStyleSlotFiles = buildLatestProductPhotoSlotFiles(
|
||||
activeFiles.filter(
|
||||
(file) =>
|
||||
@ -607,7 +653,8 @@ function buildProductPhotoBarcodeEntries({
|
||||
const barcodeScopedFiles = activeFiles.filter(
|
||||
(file) => getUploadFileProductPhotoBarcode(file) === barcode,
|
||||
);
|
||||
const scopedSlotFiles = buildLatestProductPhotoSlotFiles(barcodeScopedFiles);
|
||||
const scopedSlotFiles =
|
||||
buildLatestProductPhotoSlotFiles(barcodeScopedFiles);
|
||||
const sessionSourceSlotFiles = getLatestSessionSourceSlotFilesByBarcode(
|
||||
activeFiles,
|
||||
metadata,
|
||||
@ -655,7 +702,9 @@ function buildProductPhotoBarcodeEntries({
|
||||
ownScopedSourceCount > 0
|
||||
? buildAiSourceSlotFilesFromProductPhotoSlots(effectiveOwnSlotFiles)
|
||||
: hasCoverImage
|
||||
? buildAiSourceSlotFilesFromProductPhotoSlots(sharedStyleSlotFiles)
|
||||
? buildAiSourceSlotFilesFromProductPhotoSlots(
|
||||
sharedStyleSlotFiles,
|
||||
)
|
||||
: createEmptyAiSourceSlotFileMap(),
|
||||
availableProductPhotoSlotCount: countFilledProductPhotoSlots(
|
||||
effectiveProductPhotoSlotFiles,
|
||||
@ -663,7 +712,9 @@ function buildProductPhotoBarcodeEntries({
|
||||
availableAiSourceSlotCount:
|
||||
ownScopedSourceCount > 0
|
||||
? countFilledAiSourceSlots(
|
||||
buildAiSourceSlotFilesFromProductPhotoSlots(effectiveOwnSlotFiles),
|
||||
buildAiSourceSlotFilesFromProductPhotoSlots(
|
||||
effectiveOwnSlotFiles,
|
||||
),
|
||||
)
|
||||
: hasCoverImage
|
||||
? countFilledAiSourceSlots(
|
||||
@ -761,7 +812,9 @@ function countFilledProductPhotoSlots(
|
||||
}
|
||||
|
||||
function countFilledAiSourceSlots(
|
||||
slotFiles: Partial<Record<AiWhiteBackgroundSourceSlot, NonIntegratedUploadFile | null>>,
|
||||
slotFiles: Partial<
|
||||
Record<AiWhiteBackgroundSourceSlot, NonIntegratedUploadFile | null>
|
||||
>,
|
||||
) {
|
||||
return AI_WHITE_BACKGROUND_SOURCE_SLOTS.filter((slot) =>
|
||||
Boolean(slotFiles[slot]),
|
||||
@ -774,8 +827,8 @@ function getLatestSessionSourceSlotFilesByBarcode(
|
||||
styleKey: string,
|
||||
barcode: string,
|
||||
) {
|
||||
const sourceSessions = readNonIntegratedAiWhiteBackgroundMetadata(metadata).sessions
|
||||
.filter((session) => {
|
||||
const sourceSessions = readNonIntegratedAiWhiteBackgroundMetadata(metadata)
|
||||
.sessions.filter((session) => {
|
||||
const sessionStyleKey = session.styleKey.trim().toUpperCase();
|
||||
const sessionSourceBarcode = normalizeBarcode(
|
||||
session.sourceBarcode ?? session.barcode,
|
||||
@ -795,8 +848,7 @@ function getLatestSessionSourceSlotFilesByBarcode(
|
||||
if (!fileId) {
|
||||
return;
|
||||
}
|
||||
slotFiles[slot] =
|
||||
activeFiles.find((file) => file.id === fileId) ?? null;
|
||||
slotFiles[slot] = activeFiles.find((file) => file.id === fileId) ?? null;
|
||||
});
|
||||
});
|
||||
|
||||
@ -808,8 +860,8 @@ function getLatestGeneratedWhiteBackgroundCountByBarcode(
|
||||
styleKey: string,
|
||||
barcode: string,
|
||||
) {
|
||||
const latestSession = readNonIntegratedAiWhiteBackgroundMetadata(metadata).sessions
|
||||
.filter((session) => {
|
||||
const latestSession = readNonIntegratedAiWhiteBackgroundMetadata(metadata)
|
||||
.sessions.filter((session) => {
|
||||
const sessionStyleKey = session.styleKey.trim().toUpperCase();
|
||||
const sessionTargetBarcode = normalizeBarcode(
|
||||
session.targetBarcode ?? session.barcode,
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
resolveInstallationAssetType,
|
||||
resolveInstallationVersionLabel,
|
||||
} from "./styleFileHelpers";
|
||||
import { sortInstallationFiles } from "./installationFileOrdering";
|
||||
|
||||
export function useInstallationAssetGroups({
|
||||
activeInstallationAssetType,
|
||||
@ -54,7 +55,12 @@ export function useInstallationAssetGroups({
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groupMap.values()).sort((left, right) => {
|
||||
return Array.from(groupMap.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
files: sortInstallationFiles(group.files),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.isCurrent !== right.isCurrent) {
|
||||
return Number(right.isCurrent) - Number(left.isCurrent);
|
||||
}
|
||||
@ -62,7 +68,7 @@ export function useInstallationAssetGroups({
|
||||
return right.order - left.order;
|
||||
}
|
||||
return left.label.localeCompare(right.label, "zh-CN");
|
||||
});
|
||||
});
|
||||
}, [activeInstallationAssetFiles]);
|
||||
|
||||
return {
|
||||
|
||||
@ -55,10 +55,11 @@ export function useStyleWorkspaceEntries({
|
||||
() =>
|
||||
buildInstallationKnownStyleKeys({
|
||||
skuStyleKeys,
|
||||
skuItems: derivedSkuItems,
|
||||
styleDetails,
|
||||
uploadFiles,
|
||||
}),
|
||||
[skuStyleKeys, styleDetails, uploadFiles],
|
||||
[derivedSkuItems, skuStyleKeys, styleDetails, uploadFiles],
|
||||
);
|
||||
const factoryQualityKnownStyleKeys = useMemo(
|
||||
() =>
|
||||
@ -164,7 +165,8 @@ export function useStyleWorkspaceEntries({
|
||||
activeProductPhotoStyleFiles,
|
||||
activeProductPhotoBarcodeEntry
|
||||
? {
|
||||
aiSourceSlotFiles: activeProductPhotoBarcodeEntry.aiSourceSlotFiles,
|
||||
aiSourceSlotFiles:
|
||||
activeProductPhotoBarcodeEntry.aiSourceSlotFiles,
|
||||
productPhotoSlotFiles:
|
||||
activeProductPhotoBarcodeEntry.productPhotoSlotFiles,
|
||||
}
|
||||
|
||||
@ -0,0 +1,419 @@
|
||||
import type { AuthUser } from "@/server/core/types";
|
||||
import type { DrawingSystemClient } from "@/server/services/drawing-system.client";
|
||||
import { IntakeInstallationDrawingUploadService } from "../intake-installation-drawing-upload.service";
|
||||
import { operationLogService } from "../operation-log.service";
|
||||
import { intakeNonIntegratedProductRepository } from "@/server/repositories/intake-non-integrated-product.repository";
|
||||
import { intakeNonIntegratedProductService } from "../intake-non-integrated-product.service";
|
||||
|
||||
jest.mock("@/server/services/drawing-system.client", () => ({
|
||||
DrawingSystemClient: class {},
|
||||
DrawingSystemClientError: class DrawingSystemClientError extends Error {},
|
||||
getDrawingSystemClient: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock("@/server/services/intake-non-integrated-product.service", () => ({
|
||||
intakeNonIntegratedProductService: {
|
||||
assertCanWrite: jest.fn(),
|
||||
getById: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
"@/server/repositories/intake-non-integrated-product.repository",
|
||||
() => ({
|
||||
intakeNonIntegratedProductRepository: {
|
||||
findActiveById: jest.fn(),
|
||||
saveActiveWithLock: jest.fn(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock("@/server/services/operation-log.service", () => ({
|
||||
OperationLogAction: { INTAKE_FILE_UPLOAD: "INTAKE_FILE_UPLOAD" },
|
||||
OperationLogStatus: { SUCCESS: "SUCCESS", FAILED: "FAILED" },
|
||||
operationLogService: { record: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/storage", () => ({
|
||||
createObjectReadUrl: jest.fn(() => "http://object-storage.test/file"),
|
||||
resolveObjectStorageUrlForServerAccess: jest.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
jest.mock("@/app/intake/non-integrated/nonIntegratedAssetServer", () => ({
|
||||
buildNonIntegratedAssetObjectStorageKey: jest.fn(
|
||||
(value: string) => `non-integrated/${value}`,
|
||||
),
|
||||
}));
|
||||
|
||||
const actor = {
|
||||
id: "user-1",
|
||||
name: "采购测试员",
|
||||
role: "PURCHASER",
|
||||
} as AuthUser;
|
||||
|
||||
const baseProduct = {
|
||||
id: "product-1",
|
||||
spu: "BJ0226",
|
||||
previewImage: "",
|
||||
skuItems: [
|
||||
{
|
||||
id: "sku-1",
|
||||
model: "BJ0226K01",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
barcode: "BJ0226K01AV01",
|
||||
},
|
||||
{
|
||||
id: "sku-2",
|
||||
model: "BJ0226K01",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
barcode: "BJ0226K01AV01-BLACK",
|
||||
},
|
||||
{
|
||||
id: "sku-3",
|
||||
model: "BJ0226K02",
|
||||
drawingId: "BJ0226K02AV01",
|
||||
barcode: "BJ0226K02AV01",
|
||||
},
|
||||
],
|
||||
uploadFiles: [
|
||||
{
|
||||
id: "file-1",
|
||||
name: "install.pdf",
|
||||
category: "安装方式",
|
||||
dataUrl: "/api/storage/object?key=unused",
|
||||
mimeType: "application/pdf",
|
||||
storageKey: "product-1/安装方式/install.pdf",
|
||||
installDrawingId: "BJ0226K01AV01",
|
||||
installAssetType: "drawing",
|
||||
installDrawingSyncStatus: "pending",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
function createHarness(options: { assetType?: "drawing" | "video" } = {}) {
|
||||
const isVideo = options.assetType === "video";
|
||||
const file = {
|
||||
...baseProduct.uploadFiles[0],
|
||||
...(isVideo
|
||||
? {
|
||||
name: "install.mp4",
|
||||
mimeType: "video/mp4",
|
||||
installAssetType: "video" as const,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
let payload: Record<string, unknown> = { uploadFiles: [file] };
|
||||
const product = { ...baseProduct, uploadFiles: [file] };
|
||||
const repository = intakeNonIntegratedProductRepository as jest.Mocked<
|
||||
typeof intakeNonIntegratedProductRepository
|
||||
>;
|
||||
const productService = intakeNonIntegratedProductService as jest.Mocked<
|
||||
typeof intakeNonIntegratedProductService
|
||||
>;
|
||||
const logService = operationLogService as jest.Mocked<
|
||||
typeof operationLogService
|
||||
>;
|
||||
|
||||
repository.saveActiveWithLock.mockImplementation(async (_id, prepare) => {
|
||||
const prepared = await prepare({ payload } as never);
|
||||
if (prepared.data?.payload) {
|
||||
payload = prepared.data.payload as Record<string, unknown>;
|
||||
product.uploadFiles = (payload.uploadFiles ??
|
||||
[]) as typeof product.uploadFiles;
|
||||
}
|
||||
return {
|
||||
status: "saved",
|
||||
result: prepared.result,
|
||||
updatedAt: new Date(),
|
||||
} as never;
|
||||
});
|
||||
repository.findActiveById.mockImplementation(async () => ({ payload }) as never);
|
||||
productService.getById.mockImplementation(async () => product as never);
|
||||
productService.assertCanWrite.mockResolvedValue(undefined);
|
||||
logService.record.mockResolvedValue(undefined);
|
||||
|
||||
const client = {
|
||||
listTasksBySpu: jest.fn().mockResolvedValue([]),
|
||||
listTasksBySkuModel: jest.fn().mockResolvedValue([]),
|
||||
uploadProductPictureFromUrl: jest
|
||||
.fn()
|
||||
.mockResolvedValue("/remote/product.jpg"),
|
||||
createTask: jest.fn().mockResolvedValue(undefined),
|
||||
getTaskDetail: jest.fn().mockResolvedValue({
|
||||
pictureId: file.installDrawingId,
|
||||
skucodes: [],
|
||||
files: {
|
||||
[isVideo ? "video" : "instructions"]: [
|
||||
isVideo ? "/remote/install.mp4" : "/remote/install.pdf",
|
||||
],
|
||||
},
|
||||
}),
|
||||
uploadTaskFile: jest
|
||||
.fn()
|
||||
.mockResolvedValue(isVideo ? "/remote/install.mp4" : "/remote/install.pdf"),
|
||||
submitTaskFiles: jest.fn().mockResolvedValue(undefined),
|
||||
updateTaskMakeContent: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as DrawingSystemClient;
|
||||
const service = new IntakeInstallationDrawingUploadService({
|
||||
client,
|
||||
productService,
|
||||
repository,
|
||||
now: () => new Date("2026-09-23T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
file,
|
||||
logService,
|
||||
payload: () => payload,
|
||||
product,
|
||||
productService,
|
||||
repository,
|
||||
service,
|
||||
};
|
||||
}
|
||||
|
||||
describe("IntakeInstallationDrawingUploadService", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new TextEncoder().encode("saved file").buffer,
|
||||
}) as jest.Mock;
|
||||
});
|
||||
|
||||
it("creates a drawing task using only barcodes derived from the exact drawing ID", async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
const result = await harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "bj0226k01av01",
|
||||
actor,
|
||||
});
|
||||
|
||||
expect(result.barcodes).toEqual(["BJ0226K01AV01", "BJ0226K01AV01-BLACK"]);
|
||||
expect(harness.client.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spu: "BJ0226",
|
||||
skucodes: "BJ0226K01AV01,BJ0226K01AV01-BLACK",
|
||||
pictureId: "BJ0226K01AV01",
|
||||
skuModel: "BJ0226K01",
|
||||
makeContent: "3",
|
||||
}),
|
||||
);
|
||||
expect(harness.client.uploadTaskFile).toHaveBeenCalledTimes(1);
|
||||
expect(harness.client.submitTaskFiles).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ instructions: ["/remote/install.pdf"] },
|
||||
{ deduplicateUrls: true },
|
||||
);
|
||||
expect(result.file.installDrawingSyncStatus).toBe("succeeded");
|
||||
expect(harness.logService.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: "SUCCESS" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("pushes an installation video as type 4 and submits it to the video list", async () => {
|
||||
const harness = createHarness({ assetType: "video" });
|
||||
|
||||
const result = await harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
});
|
||||
|
||||
expect(harness.client.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ makeContent: "4" }),
|
||||
);
|
||||
expect(harness.client.uploadTaskFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"video",
|
||||
expect.objectContaining({ name: "install.mp4", type: "video/mp4" }),
|
||||
);
|
||||
expect(harness.client.submitTaskFiles).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ video: ["/remote/install.mp4"] },
|
||||
{ deduplicateUrls: true },
|
||||
);
|
||||
expect(result.file.installDrawingSyncStatus).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("reuses an existing drawing-ID task when the remote task has no barcode list", async () => {
|
||||
const harness = createHarness({ assetType: "video" });
|
||||
harness.client.listTasksBySkuModel.mockResolvedValue([
|
||||
{
|
||||
taskId: "1790154570617",
|
||||
spu: null,
|
||||
skucodes: "",
|
||||
pictureId: "BJ0226K01AV01",
|
||||
makeContent: "3",
|
||||
isComplete: "1",
|
||||
withLineArt: null,
|
||||
withVideo: null,
|
||||
},
|
||||
]);
|
||||
harness.client.getTaskDetail.mockResolvedValue({
|
||||
pictureId: "BJ0226K01AV01",
|
||||
skucodes: [],
|
||||
files: {
|
||||
instructions: ["/remote/install-1.jpg"],
|
||||
video: ["/remote/install.mp4"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
});
|
||||
|
||||
expect(harness.client.createTask).not.toHaveBeenCalled();
|
||||
expect(harness.client.updateTaskMakeContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ taskId: "1790154570617" }),
|
||||
"3,4",
|
||||
"BJ0226",
|
||||
);
|
||||
expect(harness.client.uploadTaskFile).toHaveBeenCalledWith(
|
||||
"1790154570617",
|
||||
"video",
|
||||
expect.objectContaining({ name: "install.mp4" }),
|
||||
);
|
||||
expect(result.file.installDrawingSyncStatus).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("still rejects an existing drawing-ID task with different remote barcodes", async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.listTasksBySkuModel.mockResolvedValue([
|
||||
{
|
||||
taskId: "remote-task",
|
||||
spu: null,
|
||||
skucodes: "",
|
||||
pictureId: "BJ0226K01AV01",
|
||||
makeContent: "3",
|
||||
isComplete: "1",
|
||||
withLineArt: null,
|
||||
withVideo: null,
|
||||
},
|
||||
]);
|
||||
harness.client.getTaskDetail.mockResolvedValue({
|
||||
pictureId: "BJ0226K01AV01",
|
||||
skucodes: ["OTHER-BARCODE"],
|
||||
files: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
}),
|
||||
).rejects.toThrow("图纸系统已有任务单绑定的条码与产品基础信息不一致");
|
||||
expect(harness.client.createTask).not.toHaveBeenCalled();
|
||||
expect(harness.client.updateTaskMakeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a file failed when submit succeeds but the remote list omits the file", async () => {
|
||||
const harness = createHarness({ assetType: "video" });
|
||||
harness.client.getTaskDetail.mockResolvedValueOnce({
|
||||
pictureId: "BJ0226K01AV01",
|
||||
skucodes: [],
|
||||
files: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
}),
|
||||
).rejects.toThrow("图纸系统未确认安装视频已写入任务单");
|
||||
expect(harness.product.uploadFiles[0]?.installDrawingSyncStatus).toBe(
|
||||
"failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds an attached installation file from the persisted payload if hydration omits it", async () => {
|
||||
const harness = createHarness();
|
||||
harness.productService.getById.mockResolvedValueOnce({
|
||||
...harness.product,
|
||||
uploadFiles: [],
|
||||
} as never);
|
||||
harness.repository.findActiveById.mockResolvedValueOnce({
|
||||
payload: { uploadFiles: [harness.file] },
|
||||
} as never);
|
||||
|
||||
const result = await harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
});
|
||||
|
||||
expect(result.file.installDrawingSyncStatus).toBe("succeeded");
|
||||
expect(harness.repository.findActiveById).toHaveBeenCalledWith("product-1");
|
||||
expect(harness.client.uploadTaskFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retains the task and uploaded remote URL after submit failure so retry does not upload twice", async () => {
|
||||
const harness = createHarness();
|
||||
jest
|
||||
.spyOn(harness.client, "submitTaskFiles")
|
||||
.mockRejectedValueOnce(new Error("temporary submit error"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
}),
|
||||
).rejects.toThrow("temporary submit error");
|
||||
|
||||
expect(harness.product.uploadFiles[0]?.installDrawingSyncStatus).toBe(
|
||||
"failed",
|
||||
);
|
||||
expect(harness.product.uploadFiles[0]?.installDrawingSyncTaskId).toBe(
|
||||
"1790121600000",
|
||||
);
|
||||
expect(harness.product.uploadFiles[0]?.installDrawingSyncFileUrl).toBe(
|
||||
"/remote/install.pdf",
|
||||
);
|
||||
|
||||
const retry = await harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
});
|
||||
|
||||
expect(retry.file.installDrawingSyncStatus).toBe("succeeded");
|
||||
expect(harness.client.createTask).toHaveBeenCalledTimes(1);
|
||||
expect(harness.client.uploadTaskFile).toHaveBeenCalledTimes(1);
|
||||
expect(harness.client.submitTaskFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("checks product write permission before making remote calls", async () => {
|
||||
const harness = createHarness();
|
||||
harness.productService.assertCanWrite.mockRejectedValueOnce(
|
||||
new Error("denied"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
harness.service.upload({
|
||||
productId: "product-1",
|
||||
fileId: "file-1",
|
||||
drawingId: "BJ0226K01AV01",
|
||||
actor,
|
||||
}),
|
||||
).rejects.toThrow("denied");
|
||||
|
||||
expect(harness.client.listTasksBySkuModel).not.toHaveBeenCalled();
|
||||
expect(harness.repository.saveActiveWithLock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { buildDrawingSystemSign, md5HexLower } from '@/lib/drawing-system-sign'
|
||||
import { buildDrawingSystemSign, md5HexLower } from "@/lib/drawing-system-sign";
|
||||
|
||||
/**
|
||||
* 简尚图纸管理系统接口客户端。
|
||||
@ -10,103 +10,115 @@ import { buildDrawingSystemSign, md5HexLower } from '@/lib/drawing-system-sign'
|
||||
*/
|
||||
|
||||
export type DrawingSystemConfig = {
|
||||
apiBase: string
|
||||
authBase: string
|
||||
desKey: string
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
apiBase: string;
|
||||
authBase: string;
|
||||
desKey: string;
|
||||
account: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type DrawingSystemTaskSummary = {
|
||||
taskId: string
|
||||
spu: string | null
|
||||
skucodes: string | null
|
||||
pictureId: string | null
|
||||
isComplete: string | null
|
||||
withLineArt: string | null
|
||||
withVideo: string | null
|
||||
}
|
||||
id?: string | null;
|
||||
taskId: string;
|
||||
spu: string | null;
|
||||
skucodes: string | null;
|
||||
pictureId: string | null;
|
||||
picture?: string | null;
|
||||
factoryModel?: string | null;
|
||||
skuModel: string | null;
|
||||
expressNo?: string | null;
|
||||
signTime?: string | null;
|
||||
makeContent?: string | null;
|
||||
remark?: string | null;
|
||||
isComplete: string | null;
|
||||
completeTime?: string | null;
|
||||
isDelete?: string | null;
|
||||
withLineArt: string | null;
|
||||
withVideo: string | null;
|
||||
userType?: string | null;
|
||||
purchasingAgent?: string | null;
|
||||
};
|
||||
|
||||
export type DrawingSystemTaskDetail = {
|
||||
/** 任务单绑定条码(详情接口口径,列表接口的 skucodes 不可靠)。 */
|
||||
skucodes: string[]
|
||||
pictureId: string | null
|
||||
files: Partial<Record<DrawingSystemFileCategory, string[]>>
|
||||
}
|
||||
skucodes: string[];
|
||||
pictureId: string | null;
|
||||
files: Partial<Record<DrawingSystemFileCategory, string[]>>;
|
||||
};
|
||||
|
||||
export type DrawingSystemFileCategory =
|
||||
| 'lineArt'
|
||||
| 'explodedDiagram'
|
||||
| 'instructions'
|
||||
| 'video'
|
||||
"lineArt" | "explodedDiagram" | "instructions" | "video";
|
||||
|
||||
/** 图纸系统 /api/File/PostFile 的 type 表单值:1 线稿图、2 爆炸图、3 安装图纸、4 安装视频。 */
|
||||
export const DRAWING_SYSTEM_CATEGORY_TYPE: Record<DrawingSystemFileCategory, number> = {
|
||||
export const DRAWING_SYSTEM_CATEGORY_TYPE: Record<
|
||||
DrawingSystemFileCategory,
|
||||
number
|
||||
> = {
|
||||
lineArt: 1,
|
||||
explodedDiagram: 2,
|
||||
instructions: 3,
|
||||
video: 4,
|
||||
}
|
||||
};
|
||||
|
||||
const ALL_CATEGORIES: DrawingSystemFileCategory[] = [
|
||||
'lineArt',
|
||||
'explodedDiagram',
|
||||
'instructions',
|
||||
'video',
|
||||
]
|
||||
"lineArt",
|
||||
"explodedDiagram",
|
||||
"instructions",
|
||||
"video",
|
||||
];
|
||||
|
||||
export class DrawingSystemClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number = 0,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'DrawingSystemClientError'
|
||||
super(message);
|
||||
this.name = "DrawingSystemClientError";
|
||||
}
|
||||
}
|
||||
|
||||
type DrawingSystemEnvelope<T> = {
|
||||
success?: boolean
|
||||
errMsg?: string
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
success?: boolean;
|
||||
errMsg?: string;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export function loadDrawingSystemConfigFromEnv(): DrawingSystemConfig {
|
||||
const apiBase = process.env.DRAWING_SYSTEM_API_BASE?.trim()
|
||||
const authBase = process.env.DRAWING_SYSTEM_AUTH_BASE?.trim()
|
||||
const desKey = process.env.DRAWING_SYSTEM_DES_KEY?.trim()
|
||||
const account = process.env.DRAWING_SYSTEM_ACCOUNT?.trim()
|
||||
const password = process.env.DRAWING_SYSTEM_PASSWORD
|
||||
const apiBase = process.env.DRAWING_SYSTEM_API_BASE?.trim();
|
||||
const authBase = process.env.DRAWING_SYSTEM_AUTH_BASE?.trim();
|
||||
const desKey = process.env.DRAWING_SYSTEM_DES_KEY?.trim();
|
||||
const account = process.env.DRAWING_SYSTEM_ACCOUNT?.trim();
|
||||
const password = process.env.DRAWING_SYSTEM_PASSWORD;
|
||||
if (!apiBase || !authBase || !desKey || !account || !password) {
|
||||
throw new DrawingSystemClientError(
|
||||
'图纸系统接口凭据缺失,请检查 DRAWING_SYSTEM_* 环境变量(见 LOCAL_SECRETS.md)',
|
||||
)
|
||||
"图纸系统接口凭据缺失,请检查 DRAWING_SYSTEM_* 环境变量(见 LOCAL_SECRETS.md)",
|
||||
);
|
||||
}
|
||||
return {
|
||||
apiBase: apiBase.replace(/\/+$/, ''),
|
||||
authBase: authBase.replace(/\/+$/, ''),
|
||||
apiBase: apiBase.replace(/\/+$/, ""),
|
||||
authBase: authBase.replace(/\/+$/, ""),
|
||||
desKey,
|
||||
account,
|
||||
password,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class DrawingSystemClient {
|
||||
private token: string | null = null
|
||||
private token: string | null = null;
|
||||
|
||||
constructor(private readonly config: DrawingSystemConfig) {}
|
||||
|
||||
private signHeaders(authenticated: boolean): Record<string, string> {
|
||||
const timespan = String(Math.floor(Date.now() / 1000))
|
||||
const timespan = String(Math.floor(Date.now() / 1000));
|
||||
const headers: Record<string, string> = {
|
||||
timespan,
|
||||
sign: buildDrawingSystemSign(timespan, this.config.desKey),
|
||||
}
|
||||
};
|
||||
if (authenticated && this.token) {
|
||||
headers.Authorization = `Bearer ${this.token}`
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
return headers
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async requestJson<T>(
|
||||
@ -114,101 +126,135 @@ export class DrawingSystemClient {
|
||||
init: RequestInit,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
let response: Response
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||
})
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new DrawingSystemClientError(
|
||||
`图纸系统接口请求失败:${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as DrawingSystemEnvelope<T> | null
|
||||
const payload = (await response
|
||||
.json()
|
||||
.catch(() => null)) as DrawingSystemEnvelope<T> | null;
|
||||
if (!response.ok) {
|
||||
throw new DrawingSystemClientError(
|
||||
payload?.errMsg || payload?.message || `图纸系统接口 HTTP ${response.status}`,
|
||||
payload?.errMsg ||
|
||||
payload?.message ||
|
||||
`图纸系统接口 HTTP ${response.status}`,
|
||||
response.status,
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!payload || payload.success !== true) {
|
||||
throw new DrawingSystemClientError(
|
||||
payload?.errMsg || payload?.message || '图纸系统接口返回失败',
|
||||
payload?.errMsg || payload?.message || "图纸系统接口返回失败",
|
||||
response.status,
|
||||
)
|
||||
);
|
||||
}
|
||||
return payload.data as T
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
private async ensureToken(): Promise<string> {
|
||||
if (this.token) return this.token
|
||||
if (this.token) return this.token;
|
||||
const data = await this.requestJson<{ token: string }>(
|
||||
`${this.config.authBase}/api/Login/Login`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.signHeaders(false) },
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.signHeaders(false),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account: this.config.account,
|
||||
password: md5HexLower(this.config.password),
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: 20_000 },
|
||||
)
|
||||
);
|
||||
if (!data?.token) {
|
||||
throw new DrawingSystemClientError('图纸系统登录成功但未返回 token')
|
||||
throw new DrawingSystemClientError("图纸系统登录成功但未返回 token");
|
||||
}
|
||||
this.token = data.token
|
||||
return this.token
|
||||
this.token = data.token;
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private invalidateToken() {
|
||||
this.token = null
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
/** 带一次「登录态失效重登」重试的请求入口。 */
|
||||
private async withAuthRetry<T>(action: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
await this.ensureToken()
|
||||
return await action()
|
||||
await this.ensureToken();
|
||||
return await action();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const expired =
|
||||
error instanceof DrawingSystemClientError &&
|
||||
(error.status === 401 || message.includes('登录过期'))
|
||||
if (!expired) throw error
|
||||
this.invalidateToken()
|
||||
await this.ensureToken()
|
||||
return action()
|
||||
(error.status === 401 || message.includes("登录过期"));
|
||||
if (!expired) throw error;
|
||||
this.invalidateToken();
|
||||
await this.ensureToken();
|
||||
return action();
|
||||
}
|
||||
}
|
||||
|
||||
async listTasksBySpu(spu: string): Promise<DrawingSystemTaskSummary[]> {
|
||||
return this.withAuthRetry(async () => {
|
||||
const query = new URLSearchParams({ SPU: spu, PageSize: '50', PageIndex: '1' })
|
||||
const query = new URLSearchParams({
|
||||
SPU: spu,
|
||||
PageSize: "50",
|
||||
PageIndex: "1",
|
||||
});
|
||||
const data = await this.requestJson<DrawingSystemTaskSummary[]>(
|
||||
`${this.config.apiBase}/api/MissionList/GetMissionListData?${query.toString()}`,
|
||||
{ headers: { ...this.signHeaders(true) } },
|
||||
)
|
||||
return (Array.isArray(data) ? data : []).filter((item) => Boolean(item?.taskId))
|
||||
})
|
||||
);
|
||||
return (Array.isArray(data) ? data : []).filter((item) =>
|
||||
Boolean(item?.taskId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 安装方式按图纸系统实际页面筛选口径查询 SKU 型号任务。 */
|
||||
async listTasksBySkuModel(skuModel: string): Promise<DrawingSystemTaskSummary[]> {
|
||||
return this.withAuthRetry(async () => {
|
||||
const query = new URLSearchParams({
|
||||
SkuModel: skuModel,
|
||||
PageSize: "50",
|
||||
PageIndex: "1",
|
||||
});
|
||||
const data = await this.requestJson<DrawingSystemTaskSummary[]>(
|
||||
`${this.config.apiBase}/api/MissionList/GetMissionListData?${query.toString()}`,
|
||||
{ headers: { ...this.signHeaders(true) } },
|
||||
);
|
||||
return (Array.isArray(data) ? data : []).filter((item) =>
|
||||
Boolean(item?.taskId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async createTask(input: {
|
||||
taskId: string
|
||||
spu: string
|
||||
skucodes: string
|
||||
pictureId: string
|
||||
skuModel: string
|
||||
picturePath: string
|
||||
makeContent: string
|
||||
taskId: string;
|
||||
spu: string;
|
||||
skucodes: string;
|
||||
pictureId: string;
|
||||
skuModel: string;
|
||||
picturePath: string;
|
||||
makeContent: string;
|
||||
}): Promise<void> {
|
||||
await this.withAuthRetry(async () => {
|
||||
await this.requestJson<string>(
|
||||
`${this.config.apiBase}/api/MissionList/AddMissionData`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.signHeaders(true) },
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.signHeaders(true),
|
||||
},
|
||||
// 口径对齐图纸系统「新建任务单」:taskId 由前端生成(毫秒时间戳)、pictureId 唯一、
|
||||
// makeContent 为 1/2/3/4 组合、picture 为产品图经 /api/File/PostFile(type=0)上传后的服务器路径。
|
||||
body: JSON.stringify({
|
||||
@ -219,90 +265,147 @@ export class DrawingSystemClient {
|
||||
skuModel: input.skuModel,
|
||||
picture: input.picturePath,
|
||||
makeContent: input.makeContent,
|
||||
isDelete: '0',
|
||||
isComplete: '0',
|
||||
isDelete: "0",
|
||||
isComplete: "0",
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: 30_000 },
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async updateTaskMakeContent(
|
||||
task: DrawingSystemTaskSummary,
|
||||
makeContent: string,
|
||||
fallbackSpu: string,
|
||||
): Promise<void> {
|
||||
await this.withAuthRetry(async () => {
|
||||
await this.requestJson<string>(
|
||||
`${this.config.apiBase}/api/MissionList/UpdateMissionData`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.signHeaders(true),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: task.id ?? undefined,
|
||||
taskId: task.taskId,
|
||||
spu: task.spu ?? fallbackSpu,
|
||||
skucodes: task.skucodes ?? "",
|
||||
pictureId: task.pictureId ?? "",
|
||||
skuModel: task.skuModel ?? fallbackSpu,
|
||||
picture: task.picture ?? "",
|
||||
factoryModel: task.factoryModel ?? "",
|
||||
expressNo: task.expressNo ?? null,
|
||||
signTime: task.signTime ?? null,
|
||||
makeContent,
|
||||
remark: task.remark ?? null,
|
||||
isDelete: task.isDelete ?? "0",
|
||||
isComplete: task.isComplete ?? "0",
|
||||
completeTime: task.completeTime ?? null,
|
||||
userType: task.userType ?? null,
|
||||
purchasingAgent: task.purchasingAgent ?? null,
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 抓取产品图 URL 并按 type=0 传到图纸系统,返回服务器路径(新建任务单的必填 picture)。 */
|
||||
async uploadProductPictureFromUrl(imageUrl: string, filename: string): Promise<string> {
|
||||
async uploadProductPictureFromUrl(
|
||||
imageUrl: string,
|
||||
filename: string,
|
||||
): Promise<string> {
|
||||
return this.withAuthRetry(async () => {
|
||||
let buffer: ArrayBuffer
|
||||
let contentType = 'image/jpeg'
|
||||
let buffer: ArrayBuffer;
|
||||
let contentType = "image/jpeg";
|
||||
if (imageUrl) {
|
||||
const response = await fetch(imageUrl, { signal: AbortSignal.timeout(30_000) })
|
||||
const response = await fetch(imageUrl, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new DrawingSystemClientError(`获取产品图失败:HTTP ${response.status}`)
|
||||
throw new DrawingSystemClientError(
|
||||
`获取产品图失败:HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
buffer = await response.arrayBuffer()
|
||||
contentType = response.headers.get('content-type') ?? contentType
|
||||
buffer = await response.arrayBuffer();
|
||||
contentType = response.headers.get("content-type") ?? contentType;
|
||||
} else {
|
||||
// 档案无图时用 1x1 占位图兜底,满足图纸系统必填约束。
|
||||
const placeholder = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
const arrayBuffer = new ArrayBuffer(placeholder.byteLength)
|
||||
new Uint8Array(arrayBuffer).set(placeholder)
|
||||
buffer = arrayBuffer
|
||||
contentType = 'image/png'
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
const arrayBuffer = new ArrayBuffer(placeholder.byteLength);
|
||||
new Uint8Array(arrayBuffer).set(placeholder);
|
||||
buffer = arrayBuffer;
|
||||
contentType = "image/png";
|
||||
}
|
||||
const form = new FormData()
|
||||
form.append('taskId', '')
|
||||
form.append('type', '0')
|
||||
form.append('file{0}', new File([buffer], filename, { type: contentType }))
|
||||
const data = await this.requestJson<Array<{ key?: string; url?: string }>>(
|
||||
const form = new FormData();
|
||||
form.append("taskId", "");
|
||||
form.append("type", "0");
|
||||
form.append(
|
||||
"file{0}",
|
||||
new File([buffer], filename, { type: contentType }),
|
||||
);
|
||||
const data = await this.requestJson<
|
||||
Array<{ key?: string; url?: string }>
|
||||
>(
|
||||
`${this.config.apiBase}/api/File/PostFile`,
|
||||
{ method: 'POST', headers: this.signHeaders(true), body: form },
|
||||
{ method: "POST", headers: this.signHeaders(true), body: form },
|
||||
{ timeoutMs: 60_000 },
|
||||
)
|
||||
const url = Array.isArray(data) ? data[0]?.url ?? '' : ''
|
||||
);
|
||||
const url = Array.isArray(data) ? (data[0]?.url ?? "") : "";
|
||||
if (!url) {
|
||||
throw new DrawingSystemClientError('产品图上传成功但未返回存储路径')
|
||||
throw new DrawingSystemClientError("产品图上传成功但未返回存储路径");
|
||||
}
|
||||
return url
|
||||
})
|
||||
return url;
|
||||
});
|
||||
}
|
||||
|
||||
async getTaskDetail(taskId: string): Promise<DrawingSystemTaskDetail> {
|
||||
return this.withAuthRetry(async () => {
|
||||
const query = new URLSearchParams({ taskId })
|
||||
const query = new URLSearchParams({ taskId });
|
||||
const data = await this.requestJson<
|
||||
(Partial<Record<DrawingSystemFileCategory, Array<{ url?: string | null }>>> & {
|
||||
missonTaskData?: { skucodes?: string | null; pictureId?: string | null } | null
|
||||
}) | null
|
||||
| (Partial<
|
||||
Record<DrawingSystemFileCategory, Array<{ url?: string | null }>>
|
||||
> & {
|
||||
missonTaskData?: {
|
||||
skucodes?: string | null;
|
||||
pictureId?: string | null;
|
||||
} | null;
|
||||
})
|
||||
| null
|
||||
>(
|
||||
`${this.config.apiBase}/api/MissionList/GetMissonFile?${query.toString()}`,
|
||||
{ headers: { ...this.signHeaders(true) } },
|
||||
)
|
||||
const files: Partial<Record<DrawingSystemFileCategory, string[]>> = {}
|
||||
);
|
||||
const files: Partial<Record<DrawingSystemFileCategory, string[]>> = {};
|
||||
for (const category of ALL_CATEGORIES) {
|
||||
const urls = (data?.[category] ?? [])
|
||||
.map((item) => item?.url ?? '')
|
||||
.filter((url) => url.length > 0)
|
||||
if (urls.length > 0) files[category] = urls
|
||||
.map((item) => item?.url ?? "")
|
||||
.filter((url) => url.length > 0);
|
||||
if (urls.length > 0) files[category] = urls;
|
||||
}
|
||||
const skucodes = (data?.missonTaskData?.skucodes ?? '')
|
||||
.split(',')
|
||||
const skucodes = (data?.missonTaskData?.skucodes ?? "")
|
||||
.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
skucodes,
|
||||
pictureId: data?.missonTaskData?.pictureId ?? null,
|
||||
files,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getTaskFiles(
|
||||
taskId: string,
|
||||
): Promise<Partial<Record<DrawingSystemFileCategory, string[]>>> {
|
||||
const detail = await this.getTaskDetail(taskId)
|
||||
return detail.files
|
||||
const detail = await this.getTaskDetail(taskId);
|
||||
return detail.files;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -315,26 +418,30 @@ export class DrawingSystemClient {
|
||||
file: File,
|
||||
): Promise<string> {
|
||||
return this.withAuthRetry(async () => {
|
||||
const form = new FormData()
|
||||
form.append('taskId', taskId)
|
||||
form.append('type', String(DRAWING_SYSTEM_CATEGORY_TYPE[category]))
|
||||
form.append('file{0}', file, file.name)
|
||||
const data = await this.requestJson<Array<{ key?: string; url?: string }>>(
|
||||
const form = new FormData();
|
||||
form.append("taskId", taskId);
|
||||
form.append("type", String(DRAWING_SYSTEM_CATEGORY_TYPE[category]));
|
||||
form.append("file{0}", file, file.name);
|
||||
const data = await this.requestJson<
|
||||
Array<{ key?: string; url?: string }>
|
||||
>(
|
||||
`${this.config.apiBase}/api/File/PostFile`,
|
||||
{
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: this.signHeaders(true),
|
||||
body: form,
|
||||
},
|
||||
// 视频文件较大,给足超时。
|
||||
{ timeoutMs: 600_000 },
|
||||
)
|
||||
const url = Array.isArray(data) ? data[0]?.url ?? '' : ''
|
||||
);
|
||||
const url = Array.isArray(data) ? (data[0]?.url ?? "") : "";
|
||||
if (!url) {
|
||||
throw new DrawingSystemClientError(`文件上传成功但未返回存储路径:${file.name}`)
|
||||
throw new DrawingSystemClientError(
|
||||
`文件上传成功但未返回存储路径:${file.name}`,
|
||||
);
|
||||
}
|
||||
return url
|
||||
})
|
||||
return url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -344,32 +451,42 @@ export class DrawingSystemClient {
|
||||
async submitTaskFiles(
|
||||
taskId: string,
|
||||
entries: Partial<Record<DrawingSystemFileCategory, string[]>>,
|
||||
options: { deduplicateUrls?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await this.withAuthRetry(async () => {
|
||||
const existing = await this.getTaskFiles(taskId)
|
||||
const body: Record<string, unknown> = { taskId }
|
||||
const existing = await this.getTaskFiles(taskId);
|
||||
const body: Record<string, unknown> = { taskId };
|
||||
for (const category of ALL_CATEGORIES) {
|
||||
const merged = [...(existing[category] ?? []), ...(entries[category] ?? [])]
|
||||
body[category] = merged.map((url) => ({ url }))
|
||||
const merged = [
|
||||
...(existing[category] ?? []),
|
||||
...(entries[category] ?? []),
|
||||
];
|
||||
const urls = options.deduplicateUrls
|
||||
? Array.from(new Set(merged))
|
||||
: merged;
|
||||
body[category] = urls.map((url) => ({ url }));
|
||||
}
|
||||
await this.requestJson<string>(
|
||||
`${this.config.apiBase}/api/MissionList/SubmitMissonFile`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.signHeaders(true) },
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.signHeaders(true),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs: 60_000 },
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let cachedClient: DrawingSystemClient | null = null
|
||||
let cachedClient: DrawingSystemClient | null = null;
|
||||
|
||||
export function getDrawingSystemClient(): DrawingSystemClient {
|
||||
if (!cachedClient) {
|
||||
cachedClient = new DrawingSystemClient(loadDrawingSystemConfigFromEnv())
|
||||
cachedClient = new DrawingSystemClient(loadDrawingSystemConfigFromEnv());
|
||||
}
|
||||
return cachedClient
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
@ -0,0 +1,563 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { INSTALLATION_WORKSPACE_CATEGORY } from "@/app/intake/non-integrated/workspaces/documents/documentWorkspaceConfig";
|
||||
import type {
|
||||
NonIntegratedProductRecord,
|
||||
NonIntegratedUploadFile,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import { buildNonIntegratedAssetObjectStorageKey } from "@/app/intake/non-integrated/nonIntegratedAssetServer";
|
||||
import {
|
||||
createObjectReadUrl,
|
||||
resolveObjectStorageUrlForServerAccess,
|
||||
} from "@/lib/storage";
|
||||
import { AppError, ErrorCode } from "@/server/core/errors";
|
||||
import type { AuthUser } from "@/server/core/types";
|
||||
import { intakeNonIntegratedProductRepository } from "@/server/repositories/intake-non-integrated-product.repository";
|
||||
import {
|
||||
DrawingSystemClient,
|
||||
DrawingSystemClientError,
|
||||
getDrawingSystemClient,
|
||||
type DrawingSystemTaskSummary,
|
||||
} from "./drawing-system.client";
|
||||
import { intakeNonIntegratedProductService } from "./intake-non-integrated-product.service";
|
||||
import {
|
||||
OperationLogAction,
|
||||
OperationLogStatus,
|
||||
operationLogService,
|
||||
} from "./operation-log.service";
|
||||
|
||||
type StoredUploadFile = NonIntegratedUploadFile & {
|
||||
storageKey?: string;
|
||||
installDrawingSyncTaskId?: string;
|
||||
installDrawingSyncFileUrl?: string;
|
||||
};
|
||||
|
||||
type SyncPatch = Pick<
|
||||
StoredUploadFile,
|
||||
| "installDrawingId"
|
||||
| "installDrawingSyncStatus"
|
||||
| "installDrawingSyncTaskId"
|
||||
| "installDrawingSyncFileUrl"
|
||||
| "installDrawingSyncError"
|
||||
| "installDrawingSyncedAt"
|
||||
>;
|
||||
|
||||
type Dependencies = {
|
||||
client: DrawingSystemClient;
|
||||
productService: typeof intakeNonIntegratedProductService;
|
||||
repository: typeof intakeNonIntegratedProductRepository;
|
||||
now: () => Date;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeCode(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().toUpperCase() : "";
|
||||
}
|
||||
|
||||
function mergeMakeContent(
|
||||
existing: string | null | undefined,
|
||||
required: string,
|
||||
) {
|
||||
const values = (existing ?? "")
|
||||
.split(/[\s,,、]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (!values.includes(required)) values.push(required);
|
||||
return values.join(",");
|
||||
}
|
||||
|
||||
function toSafeErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message.trim() : "";
|
||||
return (message || "图纸系统推送失败").slice(0, 500);
|
||||
}
|
||||
|
||||
export class IntakeInstallationDrawingUploadService {
|
||||
constructor(
|
||||
private readonly dependencies: Dependencies = {
|
||||
client: getDrawingSystemClient(),
|
||||
productService: intakeNonIntegratedProductService,
|
||||
repository: intakeNonIntegratedProductRepository,
|
||||
now: () => new Date(),
|
||||
},
|
||||
) {}
|
||||
|
||||
async upload(input: {
|
||||
productId: string;
|
||||
fileId: string;
|
||||
drawingId: string;
|
||||
actor: AuthUser;
|
||||
}) {
|
||||
const productId = input.productId.trim();
|
||||
const fileId = input.fileId.trim();
|
||||
const drawingId = normalizeCode(input.drawingId);
|
||||
if (!productId || !fileId || !drawingId) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_REQUIRED_FIELD,
|
||||
"缺少产品、文件或图纸 ID",
|
||||
);
|
||||
}
|
||||
|
||||
await this.dependencies.productService.assertCanWrite(
|
||||
productId,
|
||||
input.actor,
|
||||
INSTALLATION_WORKSPACE_CATEGORY,
|
||||
);
|
||||
const product = (await this.dependencies.productService.getById(
|
||||
productId,
|
||||
input.actor,
|
||||
)) as NonIntegratedProductRecord | null;
|
||||
if (!product) {
|
||||
throw new AppError(
|
||||
ErrorCode.PRODUCT_NOT_FOUND,
|
||||
"产品开发明细不存在或无权访问",
|
||||
);
|
||||
}
|
||||
|
||||
const matchingSkus = product.skuItems.filter(
|
||||
(sku) => normalizeCode(sku.drawingId) === drawingId,
|
||||
);
|
||||
const barcodes = Array.from(
|
||||
new Set(
|
||||
matchingSkus.map((sku) => normalizeCode(sku.barcode)).filter(Boolean),
|
||||
),
|
||||
).sort();
|
||||
if (matchingSkus.length === 0 || barcodes.length === 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"该图纸 ID 没有关联到有效 SKU 条码",
|
||||
);
|
||||
}
|
||||
|
||||
const hydratedFile = product.uploadFiles.find((item) => item.id === fileId);
|
||||
const rawRecord = hydratedFile
|
||||
? null
|
||||
: await this.dependencies.repository.findActiveById(productId);
|
||||
const rawPayload = isRecord(rawRecord?.payload) ? rawRecord.payload : null;
|
||||
const rawUploadFiles =
|
||||
rawPayload && Array.isArray(rawPayload.uploadFiles)
|
||||
? rawPayload.uploadFiles
|
||||
: [];
|
||||
const rawFile = rawUploadFiles.find(
|
||||
(candidate) => isRecord(candidate) && candidate.id === fileId,
|
||||
);
|
||||
const file = (hydratedFile ??
|
||||
(isRecord(rawFile) ? rawFile : undefined)) as StoredUploadFile | undefined;
|
||||
if (!file || file.category !== INSTALLATION_WORKSPACE_CATEGORY) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_NOT_FOUND,
|
||||
"没有找到此产品下的安装资料",
|
||||
);
|
||||
}
|
||||
if (!["drawing", "video"].includes(file.installAssetType ?? "")) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"当前安装资料类型不支持推送",
|
||||
);
|
||||
}
|
||||
if (file.installDrawingSyncStatus === "processing") {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
"该文件正在推送,请稍后再试",
|
||||
);
|
||||
}
|
||||
if (file.installDrawingSyncStatus === "succeeded") {
|
||||
return { file, drawingId, barcodes, alreadySynced: true };
|
||||
}
|
||||
|
||||
const selectedSku = matchingSkus[0];
|
||||
const skuModel =
|
||||
matchingSkus.map((sku) => sku.model.trim()).find(Boolean) || product.spu;
|
||||
const category =
|
||||
file.installAssetType === "video" ? "video" : "instructions";
|
||||
|
||||
let workingFile = file;
|
||||
let didClaim = false;
|
||||
try {
|
||||
const claimedFile = await this.updateFile(
|
||||
productId,
|
||||
fileId,
|
||||
input.actor.id,
|
||||
{
|
||||
installDrawingId: drawingId,
|
||||
installDrawingSyncStatus: "processing",
|
||||
installDrawingSyncTaskId: file.installDrawingSyncTaskId,
|
||||
installDrawingSyncFileUrl: file.installDrawingSyncFileUrl,
|
||||
installDrawingSyncError: "",
|
||||
installDrawingSyncedAt: "",
|
||||
},
|
||||
);
|
||||
workingFile = claimedFile;
|
||||
didClaim = true;
|
||||
if (claimedFile.installDrawingSyncStatus === "succeeded") {
|
||||
return { file: claimedFile, drawingId, barcodes, alreadySynced: true };
|
||||
}
|
||||
|
||||
let taskId = claimedFile.installDrawingSyncTaskId ?? "";
|
||||
if (!taskId) {
|
||||
taskId = await this.findOrCreateTask({
|
||||
product,
|
||||
drawingId,
|
||||
barcodes,
|
||||
skuModel,
|
||||
imageUrl: product.previewImage || selectedSku?.image || "",
|
||||
makeContent: category === "video" ? "4" : "3",
|
||||
});
|
||||
workingFile = await this.updateFile(
|
||||
productId,
|
||||
fileId,
|
||||
input.actor.id,
|
||||
{
|
||||
installDrawingId: drawingId,
|
||||
installDrawingSyncStatus: "processing",
|
||||
installDrawingSyncTaskId: taskId,
|
||||
installDrawingSyncFileUrl: claimedFile.installDrawingSyncFileUrl,
|
||||
installDrawingSyncError: "",
|
||||
installDrawingSyncedAt: "",
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
let remoteFileUrl = claimedFile.installDrawingSyncFileUrl ?? "";
|
||||
if (!remoteFileUrl) {
|
||||
const uploadFile = await this.readStoredFile(file, productId);
|
||||
remoteFileUrl = await this.dependencies.client.uploadTaskFile(
|
||||
taskId,
|
||||
category,
|
||||
uploadFile,
|
||||
);
|
||||
workingFile = await this.updateFile(
|
||||
productId,
|
||||
fileId,
|
||||
input.actor.id,
|
||||
{
|
||||
installDrawingId: drawingId,
|
||||
installDrawingSyncStatus: "processing",
|
||||
installDrawingSyncTaskId: taskId,
|
||||
installDrawingSyncFileUrl: remoteFileUrl,
|
||||
installDrawingSyncError: "",
|
||||
installDrawingSyncedAt: "",
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
await this.dependencies.client.submitTaskFiles(
|
||||
taskId,
|
||||
{ [category]: [remoteFileUrl] },
|
||||
{ deduplicateUrls: true },
|
||||
);
|
||||
const verifiedDetail = await this.dependencies.client.getTaskDetail(taskId);
|
||||
if (!(verifiedDetail.files[category] ?? []).includes(remoteFileUrl)) {
|
||||
throw new DrawingSystemClientError(
|
||||
`图纸系统未确认${category === "video" ? "安装视频" : "安装图纸"}已写入任务单`,
|
||||
);
|
||||
}
|
||||
const syncedAt = this.dependencies.now().toISOString();
|
||||
const savedFile = await this.updateFile(
|
||||
productId,
|
||||
fileId,
|
||||
input.actor.id,
|
||||
{
|
||||
installDrawingId: drawingId,
|
||||
installDrawingSyncStatus: "succeeded",
|
||||
installDrawingSyncTaskId: taskId,
|
||||
installDrawingSyncFileUrl: remoteFileUrl,
|
||||
installDrawingSyncError: "",
|
||||
installDrawingSyncedAt: syncedAt,
|
||||
},
|
||||
true,
|
||||
);
|
||||
await this.recordLog({
|
||||
product,
|
||||
file,
|
||||
drawingId,
|
||||
barcodes,
|
||||
taskId,
|
||||
actor: input.actor,
|
||||
succeeded: true,
|
||||
}).catch(() => undefined);
|
||||
return { file: savedFile, drawingId, barcodes, alreadySynced: false };
|
||||
} catch (error) {
|
||||
const safeMessage = toSafeErrorMessage(error);
|
||||
if (didClaim) {
|
||||
await this.updateFile(
|
||||
productId,
|
||||
fileId,
|
||||
input.actor.id,
|
||||
{
|
||||
installDrawingId: drawingId,
|
||||
installDrawingSyncStatus: "failed",
|
||||
installDrawingSyncTaskId: workingFile.installDrawingSyncTaskId,
|
||||
installDrawingSyncFileUrl: workingFile.installDrawingSyncFileUrl,
|
||||
installDrawingSyncError: safeMessage,
|
||||
installDrawingSyncedAt: "",
|
||||
},
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
await this.recordLog({
|
||||
product,
|
||||
file,
|
||||
drawingId,
|
||||
barcodes,
|
||||
taskId: workingFile.installDrawingSyncTaskId,
|
||||
actor: input.actor,
|
||||
succeeded: false,
|
||||
error: safeMessage,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(ErrorCode.FILE_UPLOAD_FAILED, safeMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrCreateTask(input: {
|
||||
product: NonIntegratedProductRecord;
|
||||
drawingId: string;
|
||||
barcodes: string[];
|
||||
skuModel: string;
|
||||
imageUrl: string;
|
||||
makeContent: string;
|
||||
}) {
|
||||
const tasks = await this.dependencies.client.listTasksBySkuModel(
|
||||
input.product.spu,
|
||||
);
|
||||
const existing = await this.validateExistingTask(
|
||||
tasks,
|
||||
input.drawingId,
|
||||
input.barcodes,
|
||||
);
|
||||
if (existing) {
|
||||
const nextMakeContent = mergeMakeContent(
|
||||
existing.makeContent,
|
||||
input.makeContent,
|
||||
);
|
||||
if (nextMakeContent !== (existing.makeContent ?? "")) {
|
||||
await this.dependencies.client.updateTaskMakeContent(
|
||||
existing,
|
||||
nextMakeContent,
|
||||
input.product.spu,
|
||||
);
|
||||
}
|
||||
return existing.taskId;
|
||||
}
|
||||
|
||||
const imageUrl = input.imageUrl.trim()
|
||||
? resolveObjectStorageUrlForServerAccess(input.imageUrl)
|
||||
: "";
|
||||
const picturePath =
|
||||
await this.dependencies.client.uploadProductPictureFromUrl(
|
||||
imageUrl,
|
||||
`${input.drawingId}.jpg`,
|
||||
);
|
||||
const taskId = String(this.dependencies.now().getTime());
|
||||
try {
|
||||
await this.dependencies.client.createTask({
|
||||
taskId,
|
||||
spu: input.product.spu,
|
||||
skucodes: input.barcodes.join(","),
|
||||
pictureId: input.drawingId,
|
||||
skuModel: input.skuModel,
|
||||
picturePath,
|
||||
makeContent: input.makeContent,
|
||||
});
|
||||
return taskId;
|
||||
} catch (error) {
|
||||
// If the remote system created the task but the response was lost, find it by
|
||||
// the stable drawing ID before allowing a retry to create a duplicate.
|
||||
if (error instanceof DrawingSystemClientError) {
|
||||
const recovered = await this.validateExistingTask(
|
||||
await this.dependencies.client.listTasksBySkuModel(input.product.spu),
|
||||
input.drawingId,
|
||||
input.barcodes,
|
||||
);
|
||||
if (recovered) return recovered.taskId;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async validateExistingTask(
|
||||
tasks: DrawingSystemTaskSummary[],
|
||||
drawingId: string,
|
||||
expectedBarcodes: string[],
|
||||
) {
|
||||
const matches = tasks.filter(
|
||||
(task) => normalizeCode(task.pictureId) === drawingId,
|
||||
);
|
||||
if (matches.length > 1) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
"图纸系统中存在多个同图纸 ID 任务单,请先核对",
|
||||
);
|
||||
}
|
||||
const task = matches[0];
|
||||
if (!task) return null;
|
||||
const detail = await this.dependencies.client.getTaskDetail(task.taskId);
|
||||
const detailDrawingId = normalizeCode(detail.pictureId);
|
||||
if (detailDrawingId && detailDrawingId !== drawingId) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
"图纸系统已有任务单的图纸 ID 与当前资料不一致",
|
||||
);
|
||||
}
|
||||
const actualBarcodes = Array.from(
|
||||
new Set(detail.skucodes.map(normalizeCode).filter(Boolean)),
|
||||
).sort();
|
||||
// 部分历史任务单的详情接口没有返回条码,但完整图纸 ID 已唯一命中;
|
||||
// 这种情况仍应复用任务单,把新资料追加到同一图纸 ID 下。
|
||||
if (actualBarcodes.length === 0) return task;
|
||||
if (actualBarcodes.join(",") !== [...expectedBarcodes].sort().join(",")) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
"图纸系统已有任务单绑定的条码与产品基础信息不一致",
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private async readStoredFile(file: StoredUploadFile, productId: string) {
|
||||
const relativeKey = file.storageKey?.trim() ?? "";
|
||||
const expectedPrefix = `${productId}/${INSTALLATION_WORKSPACE_CATEGORY}/`;
|
||||
if (!relativeKey.startsWith(expectedPrefix)) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_NOT_FOUND,
|
||||
"安装资料存储位置不属于当前产品,无法推送",
|
||||
);
|
||||
}
|
||||
const objectKey = buildNonIntegratedAssetObjectStorageKey(relativeKey);
|
||||
if (!objectKey) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_NOT_FOUND,
|
||||
"找不到已保存的本地安装资料,无法推送",
|
||||
);
|
||||
}
|
||||
const response = await fetch(createObjectReadUrl(objectKey), {
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_NOT_FOUND,
|
||||
`读取已保存安装资料失败(HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
return new File([bytes], file.name, {
|
||||
type: file.mimeType || "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
private async updateFile(
|
||||
productId: string,
|
||||
fileId: string,
|
||||
userId: string,
|
||||
patch: SyncPatch,
|
||||
allowProcessing = false,
|
||||
): Promise<StoredUploadFile> {
|
||||
const saved = await this.dependencies.repository.saveActiveWithLock(
|
||||
productId,
|
||||
(record) => {
|
||||
const payload = isRecord(record.payload) ? record.payload : null;
|
||||
const uploadFiles =
|
||||
payload && Array.isArray(payload.uploadFiles)
|
||||
? payload.uploadFiles
|
||||
: null;
|
||||
if (!payload || !uploadFiles) {
|
||||
throw new AppError(
|
||||
ErrorCode.SYSTEM_INTERNAL,
|
||||
"产品资料中的上传文件数据异常",
|
||||
);
|
||||
}
|
||||
const index = uploadFiles.findIndex(
|
||||
(candidate) => isRecord(candidate) && candidate.id === fileId,
|
||||
);
|
||||
if (index < 0 || !isRecord(uploadFiles[index])) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_NOT_FOUND,
|
||||
"产品资料中的安装文件已不存在",
|
||||
);
|
||||
}
|
||||
const currentFile = uploadFiles[index];
|
||||
if (currentFile.category !== INSTALLATION_WORKSPACE_CATEGORY) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_NOT_FOUND,
|
||||
"目标文件已不属于安装方式资料",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!allowProcessing &&
|
||||
currentFile.installDrawingSyncStatus === "processing"
|
||||
) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
"该文件正在推送,请稍后再试",
|
||||
);
|
||||
}
|
||||
const persistedPatch = Object.fromEntries(
|
||||
Object.entries(patch).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
const nextFile = {
|
||||
...currentFile,
|
||||
...persistedPatch,
|
||||
} as StoredUploadFile;
|
||||
const nextPayload = {
|
||||
...payload,
|
||||
uploadFiles: uploadFiles.map((candidate, candidateIndex) =>
|
||||
candidateIndex === index ? nextFile : candidate,
|
||||
),
|
||||
updatedAt: this.dependencies.now().toISOString(),
|
||||
};
|
||||
return {
|
||||
data: {
|
||||
payload: nextPayload as Prisma.InputJsonValue,
|
||||
updatedBy: userId,
|
||||
},
|
||||
result: nextFile,
|
||||
};
|
||||
},
|
||||
);
|
||||
if (saved.status !== "saved") {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, "产品开发明细不存在");
|
||||
}
|
||||
return saved.result;
|
||||
}
|
||||
|
||||
private async recordLog(input: {
|
||||
product: NonIntegratedProductRecord;
|
||||
file: StoredUploadFile;
|
||||
drawingId: string;
|
||||
barcodes: string[];
|
||||
taskId?: string;
|
||||
actor: AuthUser;
|
||||
succeeded: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
await operationLogService.record({
|
||||
action: OperationLogAction.INTAKE_FILE_UPLOAD,
|
||||
status: input.succeeded
|
||||
? OperationLogStatus.SUCCESS
|
||||
: OperationLogStatus.FAILED,
|
||||
actorId: input.actor.id,
|
||||
actorName: input.actor.name,
|
||||
productId: input.product.id,
|
||||
productSpu: input.product.spu,
|
||||
workspaceCategory: INSTALLATION_WORKSPACE_CATEGORY,
|
||||
errorCode: input.succeeded ? null : "DRAWING_SYSTEM_UPLOAD_FAILED",
|
||||
message: input.succeeded
|
||||
? `安装资料已推送图纸系统:${input.file.name}(图纸 ID ${input.drawingId})`
|
||||
: `安装资料推送图纸系统失败:${input.file.name}(${input.error ?? "未知错误"})`,
|
||||
details: {
|
||||
fileId: input.file.id,
|
||||
fileName: input.file.name,
|
||||
drawingId: input.drawingId,
|
||||
taskId: input.taskId ?? null,
|
||||
barcodes: input.barcodes,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const intakeInstallationDrawingUploadService =
|
||||
new IntakeInstallationDrawingUploadService();
|
||||
@ -264,9 +264,15 @@ type AttachUploadFileMetadata = Partial<
|
||||
| 'photoSlot'
|
||||
| 'productPhotoStyleKey'
|
||||
| 'installStyleKey'
|
||||
| 'installDrawingId'
|
||||
| 'installAssetType'
|
||||
| 'installVersionLabel'
|
||||
| 'installIsCurrentVersion'
|
||||
| 'installDrawingSyncStatus'
|
||||
| 'installDrawingSyncTaskId'
|
||||
| 'installDrawingSyncFileUrl'
|
||||
| 'installDrawingSyncError'
|
||||
| 'installDrawingSyncedAt'
|
||||
| 'packagingStyleKey'
|
||||
| 'packagingAssetType'
|
||||
| 'factoryQualityStyleKey'
|
||||
@ -4290,6 +4296,21 @@ function pickUploadFileMetadata(input: AttachUploadFileInput): AttachUploadFileM
|
||||
const installStyleKey = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installStyleKey']>
|
||||
>(input.installStyleKey)
|
||||
const installDrawingId = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installDrawingId']>
|
||||
>(input.installDrawingId)?.toUpperCase()
|
||||
const installDrawingSyncTaskId = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installDrawingSyncTaskId']>
|
||||
>(input.installDrawingSyncTaskId)
|
||||
const installDrawingSyncFileUrl = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installDrawingSyncFileUrl']>
|
||||
>(input.installDrawingSyncFileUrl)
|
||||
const installDrawingSyncError = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installDrawingSyncError']>
|
||||
>(input.installDrawingSyncError)
|
||||
const installDrawingSyncedAt = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installDrawingSyncedAt']>
|
||||
>(input.installDrawingSyncedAt)
|
||||
const installAssetType = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installAssetType']>
|
||||
>(input.installAssetType)
|
||||
@ -4325,6 +4346,14 @@ function pickUploadFileMetadata(input: AttachUploadFileInput): AttachUploadFileM
|
||||
...(photoSlot ? { photoSlot } : {}),
|
||||
...(productPhotoStyleKey ? { productPhotoStyleKey } : {}),
|
||||
...(installStyleKey ? { installStyleKey } : {}),
|
||||
...(installDrawingId ? { installDrawingId } : {}),
|
||||
...(input.installDrawingSyncStatus
|
||||
? { installDrawingSyncStatus: input.installDrawingSyncStatus }
|
||||
: {}),
|
||||
...(installDrawingSyncTaskId ? { installDrawingSyncTaskId } : {}),
|
||||
...(installDrawingSyncFileUrl ? { installDrawingSyncFileUrl } : {}),
|
||||
...(installDrawingSyncError ? { installDrawingSyncError } : {}),
|
||||
...(installDrawingSyncedAt ? { installDrawingSyncedAt } : {}),
|
||||
...(installAssetType ? { installAssetType } : {}),
|
||||
...(installVersionLabel ? { installVersionLabel } : {}),
|
||||
...(typeof input.installIsCurrentVersion === 'boolean'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user