feat(intake): sync installation assets to drawing system by ID

This commit is contained in:
WuYongJie 2026-09-24 13:35:04 +08:00
parent 97a173eb9c
commit c066c60494
22 changed files with 2871 additions and 919 deletions

View File

@ -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 },
);
}

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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,

View File

@ -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 };
}

View File

@ -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,
};
}

View File

@ -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,
});
});
});

View File

@ -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,
};
}

View File

@ -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 {

View File

@ -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", () => {

View File

@ -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,

View File

@ -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 {

View File

@ -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,
}

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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();

View File

@ -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'