merge: sync xuxiake mainline before platform material fix

# Conflicts:
#	furniture-3d-system/src/app/assisted-assembly/mobile/AssistedAssemblyMobileView.tsx
#	furniture-3d-system/src/app/mobile/HomeMobileView.tsx
#	furniture-3d-system/src/server/services/asset-center.service.ts
This commit is contained in:
WuYongJie 2026-06-15 11:03:11 +08:00
commit f802d65490
51 changed files with 1583 additions and 443 deletions

View File

@ -939,7 +939,6 @@ model AssetFamily {
deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull)
variants AssetVariant[]
skuGroups AssetSkuGroup[]
compatibleSeries AssetFamilySeriesCompatibility[]
createdAt DateTime @default(now())
@ -958,26 +957,6 @@ model AssetFamily {
@@index([deletedById])
}
model AssetSkuGroup {
id String @id @default(cuid())
assetFamilyId String
assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade)
code String
name String
description String? @db.Text
sortOrder Int @default(0)
metadata Json?
variants AssetVariant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([assetFamilyId, code])
@@index([assetFamilyId])
@@index([code])
@@index([sortOrder])
}
model AssetSeries {
id String @id @default(cuid())
code String @unique
@ -1025,8 +1004,6 @@ model AssetVariant {
id String @id @default(cuid())
assetFamilyId String
assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade)
skuGroupId String?
skuGroup AssetSkuGroup? @relation(fields: [skuGroupId], references: [id], onDelete: SetNull)
code String @unique
name String
identityKey String
@ -1084,7 +1061,6 @@ model AssetVariant {
@@unique([assetFamilyId, identityKey])
@@index([assetFamilyId])
@@index([skuGroupId])
@@index([status])
@@index([barcode])
@@index([substrateId])

View File

@ -1450,6 +1450,15 @@
animation: slideInRight 0.35s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes nav-progress {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(260%);
}
}
/* 滑块 — Precision Atelier 配色 */
.apple-slider {
width: 100%;

View File

@ -18,6 +18,7 @@ import type {
WarehouseLocationDTO,
} from "@/server/services";
import type { PlatformMetricItem } from "@/components/platform/types";
import type { NonIntegratedProductPageInfo } from "./non-integrated/core/nonIntegratedApi";
import type { NonIntegratedProductRecord } from "./non-integrated/nonIntegratedStorage";
type IntakeClientUser = {
@ -45,6 +46,11 @@ type CommonIntakeClientProps = {
materialLibraryCategories: MaterialCategoryDTO[];
materialLibraryDictionaries: SubstrateDictionaryBundleDTO;
initialNonIntegratedProducts: NonIntegratedProductRecord[];
initialNonIntegratedPageInfo?: {
product?: NonIntegratedProductPageInfo;
stock?: NonIntegratedProductPageInfo;
series?: NonIntegratedProductPageInfo;
};
initialNodePermissionOverrides?: IntakeNodePermissionOverrideMap;
initialActiveSection?: IntakeSection;
initialActiveRawMaterialLibrary?: RawMaterialLibraryKey;

View File

@ -118,6 +118,7 @@ export function buildIntakePrimaryDomainProps(params: {
pendingEntryTab: IntakeEntryTab;
skuList: IntegratedPendingSeriesRow[];
nonIntegratedProducts: NonIntegratedProductRecord[];
initialNonIntegratedPageInfo: IntakeClientProps["initialNonIntegratedPageInfo"];
canDeleteNonIntegratedProducts: boolean;
canCreateNonIntegratedProducts: boolean;
canUseDesignControlledProductData: boolean;
@ -162,6 +163,7 @@ export function buildIntakePrimaryDomainProps(params: {
pendingEntryTab: params.pendingEntryTab,
integratedRows: params.skuList,
nonIntegratedProducts: params.nonIntegratedProducts,
initialNonIntegratedPageInfo: params.initialNonIntegratedPageInfo,
canDeleteProducts: params.canDeleteNonIntegratedProducts,
canCreateProducts: params.canCreateNonIntegratedProducts,
canUseDesignControlledProductData:

View File

@ -0,0 +1,41 @@
const ROWS = Array.from({ length: 8 }, (_, index) => index)
const COLUMNS = Array.from({ length: 8 }, (_, index) => index)
export default function IntakeLoading() {
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-apple-bg-secondary p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="space-y-2">
<div className="h-4 w-32 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-3 w-52 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
</div>
<div className="h-8 w-24 animate-pulse rounded-apple-lg bg-apple-bg-tertiary" />
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
<div className="flex gap-2 border-b border-apple-separator p-3">
<div className="h-8 w-20 animate-pulse rounded-apple-lg bg-apple-bg-tertiary" />
<div className="h-8 w-20 animate-pulse rounded-apple-lg bg-apple-bg-tertiary" />
<div className="h-8 w-20 animate-pulse rounded-apple-lg bg-apple-bg-tertiary" />
</div>
<div className="min-h-0 flex-1 overflow-hidden p-3">
<div className="overflow-hidden rounded-apple-lg border border-apple-separator">
<div className="grid grid-cols-8 gap-px bg-apple-separator">
{COLUMNS.map((column) => (
<div key={column} className="bg-apple-bg-secondary p-3">
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
</div>
))}
{ROWS.flatMap((row) =>
COLUMNS.map((column) => (
<div key={`${row}-${column}`} className="bg-apple-bg-primary p-3">
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
</div>
)),
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -34,6 +34,7 @@ import {
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
replaceNonIntegratedProducts,
saveNonIntegratedProduct,
type NonIntegratedFactoryRow,
type NonIntegratedSkuItem,
type NonIntegratedProductRecord,
} from "./nonIntegratedStorage";
@ -454,6 +455,38 @@ function createSkuItem(
};
}
function createFactoryRow(
overrides: Partial<NonIntegratedFactoryRow> = {},
): NonIntegratedFactoryRow {
return {
skuId: "sku-test-1",
confirmed: false,
model: "SZ0230K01",
skuCode: "SZ023001",
setCount: "1",
barcode: "SZ023001N1280V01",
specName: "款式一 140/60/75",
size: "140/60/75",
color: "胡桃色",
material: "橡胶木",
installMethod: "拆装",
supplierName: "兰考华兰家具",
supplierCode: "N1280",
warehouse: "郑州仓",
shippingMethod: "入仓",
minimumOrderQuantity: "50",
taxIncludedUnitPrice: "",
leadTime: "30天",
assemblyMode: "",
productionType: "OEM",
installationService: "送货",
supplierCategory: "主力供应商",
purchaseOrderMethod: "订单式",
executeStandard: "GB/T 3324",
...overrides,
};
}
function createMaterialFinish(
overrides: Partial<MaterialDTO> & Pick<MaterialDTO, "id" | "name">,
): MaterialDTO {
@ -4446,7 +4479,7 @@ describe("NonIntegratedDetailClient", () => {
});
});
it("shares product factory selection fields by model without changing SKU content", async () => {
it("shares product factory selection fields by SPU without changing SKU content", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
@ -4573,12 +4606,12 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([
"郑州仓",
"郑州仓",
"",
"郑州仓",
]);
expect(savedProduct?.factoryRows.map((row) => row.shippingMethod)).toEqual([
"代发",
"代发",
"",
"代发",
]);
expect(
savedProduct?.factoryRows.map((row) => row.minimumOrderQuantity),
@ -4586,7 +4619,7 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([
"OEM",
"OEM",
"",
"OEM",
]);
expect(
savedProduct?.factoryRows.map((row) => row.installationService),
@ -4594,11 +4627,11 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProduct?.factoryRows.map((row) => row.supplierCategory)).toEqual([
"主力供应商",
"主力供应商",
"",
"主力供应商",
]);
expect(
savedProduct?.factoryRows.map((row) => row.purchaseOrderMethod),
).toEqual(["订单式", "订单式", ""]);
).toEqual(["订单式", "订单式", "订单式"]);
expect(savedProduct?.skuItems.map((item) => item.manufacturer)).toEqual([
"曹县嘉欢",
"曹县嘉欢",
@ -4640,17 +4673,17 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProduct?.factoryRows.map((row) => row.warehouse)).toEqual([
"杭州仓",
"杭州仓",
"",
"杭州仓",
]);
expect(savedProduct?.factoryRows.map((row) => row.shippingMethod)).toEqual([
"入仓",
"入仓",
"",
"入仓",
]);
expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([
"ODM",
"ODM",
"",
"ODM",
]);
expect(savedProduct?.skuItems.map((item) => item.manufacturer)).toEqual([
"曹县嘉欢",
@ -4679,16 +4712,82 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProduct?.factoryRows.map((row) => row.shippingMethod)).toEqual([
"入仓",
"入仓",
"",
"入仓",
]);
expect(savedProduct?.factoryRows.map((row) => row.productionType)).toEqual([
"ODM",
"ODM",
"",
"ODM",
]);
});
});
it("requires product factory rows to be manually confirmed after all fields are filled", async () => {
const actor = userEvent.setup();
const productId = "SZ0230-product-factory-manual-confirm";
saveNonIntegratedProduct(
createProductRecord({
id: productId,
spu: "SZ0230",
skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT,
skuItems: [
createSkuItem({
id: "sku-factory-confirm-1",
model: "SZ0230K01",
skuCode: "SZ023001",
barcode: "SZ023001N1280V01",
manufacturer: "兰考华兰家具",
manufacturerCode: "N1280",
installMethod: "拆装",
executeStandard: "GB/T 3324",
}),
],
factoryRows: [
createFactoryRow({
skuId: "sku-factory-confirm-1",
confirmed: false,
model: "SZ0230K01",
skuCode: "SZ023001",
barcode: "SZ023001N1280V01",
}),
],
documentWorkspaceConfirmations:
createDefaultDocumentWorkspaceConfirmations(false),
}),
);
render(
<NonIntegratedDetailClient mode="edit" productId={productId} />,
);
await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂"));
const confirmButton = screen.getByTestId("product-factory-confirm");
expect(confirmButton).toHaveTextContent("资料已填完核对无误请确认。");
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(productId);
expect(savedProduct?.factoryRows?.[0]?.confirmed).toBe(false);
expect(savedProduct?.documentWorkspaceConfirmations?.产品定厂).not.toBe(
true,
);
expect(savedProduct?.documentWorkspaceApprovedAt?.产品定厂).toBeUndefined();
});
await actor.click(confirmButton);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(productId);
expect(savedProduct?.factoryRows?.[0]?.confirmed).toBe(true);
expect(savedProduct?.documentWorkspaceConfirmations?.产品定厂).toBe(true);
expect(savedProduct?.documentWorkspaceApprovedAt?.产品定厂).toEqual(
expect.any(String),
);
});
expect(confirmButton).toHaveTextContent("已确认");
});
it("persists the SKU national standard compliance selection", async () => {
const actor = userEvent.setup();
@ -6447,8 +6546,16 @@ describe("NonIntegratedDetailClient", () => {
weightKg: "18.5",
},
]);
expect(savedProduct?.documentWorkspaceConfirmations?.样包测量).not.toBe(
true,
);
expect(savedProduct?.documentWorkspaceApprovedAt?.样包测量).toBeUndefined();
});
const confirmButton = screen.getByTestId("sample-measurement-confirm");
expect(confirmButton).toHaveTextContent("资料已填完核对无误请确认。");
await actor.click(confirmButton);
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"SZ0230-sample-measurement",
@ -6458,6 +6565,7 @@ describe("NonIntegratedDetailClient", () => {
expect.any(String),
);
});
expect(confirmButton).toHaveTextContent("已确认");
expect(
screen.getByTestId("sample-measurement-lengthCm-sku-sample-1"),

View File

@ -1370,7 +1370,9 @@ export default function NonIntegratedDetailClient({
isReadOnly || isSeriesSkuComponentAggregateMode;
const {
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsConfirmed,
allProductFactoryRowsComplete,
allSampleMeasurementRowsConfirmedByPricing,
allSampleMeasurementRowsComplete,
confirmedProductFactoryRowCount,
displayedFinishedSampleShipmentRowsComplete,
@ -1383,6 +1385,7 @@ export default function NonIntegratedDetailClient({
displayedProductPricingRowsAllSystemEntered,
displayedProductPricingRowsComplete,
displayedProductPricingRowsReadyForPush,
displayedSampleMeasurementRowsConfirmedByPricing,
displayedSampleMeasurementRowsComplete,
isSampleMeasurementSkuLocked,
planningLayoutHasContent,
@ -1460,6 +1463,9 @@ export default function NonIntegratedDetailClient({
const activeDocumentWorkspaceHasContent =
(documentWorkspaceCountByCategory[activeDocumentWorkspaceCategory] ?? 0) >
0;
const displayedSampleMeasurementHasPendingRows =
displayedProductSampleMeasurementRows.length > 0 &&
!displayedSampleMeasurementRowsComplete;
const {
basicInfoDisplayCount,
basicInfoIsApproved,
@ -1476,19 +1482,19 @@ export default function NonIntegratedDetailClient({
isWorkflowNodeConfirmed,
} = useNonIntegratedWorkflowConfirmationState({
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsComplete,
allSampleMeasurementRowsComplete,
allProductFactoryRowsConfirmed,
allSampleMeasurementRowsConfirmedByPricing,
basicInfoHasContent,
displayedFinishedSampleShipmentRowsComplete,
displayedFirstQualityRowsComplete,
displayedProductFactoryIsConfirmed,
displayedProductLimitRowsComplete,
displayedProductSampleMeasurementRows,
displayedSampleMeasurementRowsConfirmedByPricing,
displayedSampleMeasurementRowsComplete,
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
documentWorkspaceRevisionSignatures,
isSampleMeasurementSkuLocked,
previousDocumentWorkspaceRevisionSignatures:
documentWorkspaceRevisionSignatureRef.current,
productPricingCanBeConfirmed,
@ -1498,20 +1504,13 @@ export default function NonIntegratedDetailClient({
});
useNonIntegratedWorkflowAutoConfirmationEffects({
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsComplete,
allSampleMeasurementRowsComplete,
existingProductCreatedAt,
factoryRows,
finishedSampleShipmentRows,
isReadOnly,
mode,
productFactoryRows,
productId,
sampleMeasurementRows,
setDocumentWorkspaceApprovedAt,
setDocumentWorkspaceConfirmations,
setFactoryRows,
supplierCodeByName,
});
useNonIntegratedWorkflowRevisionResetEffect({
documentWorkspaceRevisionSignatureRef,
@ -1803,6 +1802,7 @@ export default function NonIntegratedDetailClient({
documentWorkspaceConfirmations;
const nextDocumentWorkspaceApprovedAt =
overrides?.documentWorkspaceApprovedAt ?? documentWorkspaceApprovedAt;
const nextFactoryRows = overrides?.factoryRows ?? productFactoryRows;
const nextReviewRounds = overrides?.reviewRounds ?? reviewRounds;
const nextFinishedSampleShipmentRows =
overrides?.finishedSampleShipmentRows ?? productFinishedSampleShipmentRows;
@ -1810,6 +1810,8 @@ export default function NonIntegratedDetailClient({
overrides?.openingReference ?? openingReferenceRef.current;
const nextSkuItems = overrides?.skuItems ?? skuItems;
const nextPricingRows = overrides?.pricingRows ?? productPricingRows;
const nextSampleMeasurementRows =
overrides?.sampleMeasurementRows ?? productSampleMeasurementRows;
const nextSkuBatchCount = overrides?.skuBatchCount ?? skuBatchCount;
const nextSkuBatchConfirmedAt =
overrides?.skuBatchConfirmedAt ?? skuBatchConfirmedAt;
@ -1874,11 +1876,11 @@ export default function NonIntegratedDetailClient({
bomDetails,
nextReviewRounds,
qualityInspectionSheet,
productFactoryRows,
nextFactoryRows,
nextPricingRows,
nextPlanningLayoutRows,
productLimitRows,
productSampleMeasurementRows,
nextSampleMeasurementRows,
nextFinishedSampleShipmentRows,
productFirstQualityRows,
uploadFiles,
@ -2585,13 +2587,16 @@ export default function NonIntegratedDetailClient({
derivedSkuItems,
displayedProductFactoryHasPendingRows,
displayedProductFactoryHasSystemEnteredPricingRows,
displayedProductFactoryIsConfirmed,
displayedProductFactoryRows,
displayedProductFactoryRowsComplete,
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
documentWorkspaceRevisionSignatureRef,
isCurrentUserAdmin,
isReadOnly,
isSkuItemFactoryConfirmed,
persistEditedProduct,
productFactoryModelAutofillRef,
productFactoryReopenField,
productFactoryRows,
@ -2631,7 +2636,7 @@ export default function NonIntegratedDetailClient({
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
documentWorkspaceRevisionSignatureRef,
isProductFactorySkuComplete,
isProductFactorySkuConfirmed: isSkuItemFactoryConfirmed,
isProductLimitNodeLocked,
isProductPricingNodeLocked,
isReadOnly,
@ -2643,6 +2648,8 @@ export default function NonIntegratedDetailClient({
planningLayoutRowsRef,
pricingRows,
productId,
productSampleMeasurementRows,
sampleMeasurementRows,
scheduleAutoSave,
setDocumentWorkspaceApprovedAt,
setDocumentWorkspaceConfirmations,
@ -2879,11 +2886,20 @@ export default function NonIntegratedDetailClient({
handleFinishedSampleShipmentStatusClick,
handleSampleMeasurementStatusClick,
} = useSampleWorkflowStatusController({
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
documentWorkspaceRevisionSignatureRef,
displayedFinishedSampleShipmentRows,
displayedFinishedSampleShipmentRowsComplete,
displayedProductFactoryIsConfirmed,
displayedProductSampleMeasurementRows,
displayedSampleMeasurementIsConfirmed,
displayedSampleMeasurementRowsComplete,
isReadOnly,
persistEditedProduct,
productSampleMeasurementRows,
setDocumentWorkspaceApprovedAt,
setDocumentWorkspaceConfirmations,
setSaveMessage,
setSaveMessageType,
});
@ -3820,7 +3836,7 @@ export default function NonIntegratedDetailClient({
allRows: productSampleMeasurementRows,
displayedRows: displayedProductSampleMeasurementRows,
isReadOnly,
isProductFactorySkuComplete,
isProductFactorySkuConfirmed: isSkuItemFactoryConfirmed,
isSampleMeasurementSkuLocked,
onUpdateRow: updateSampleMeasurementRow,
},
@ -4320,9 +4336,12 @@ export default function NonIntegratedDetailClient({
documentWorkspaceCountByCategory
}
documentWorkspaceView={documentWorkspaceView}
isProductFactoryRowComplete={isProductFactoryRowComplete}
isSampleMeasurementConfirmed={
displayedSampleMeasurementIsConfirmed
}
isWorkflowNodeConfirmed={isWorkflowNodeConfirmed}
productFactoryRows={productFactoryRows}
sampleMeasurementRows={productSampleMeasurementRows}
visibleGroupedUploadFiles={visibleGroupedUploadFiles}
onOpenBasicInfo={() => setDocumentWorkspaceView("detail")}
onOpenDocumentWorkspace={handleOpenDocumentWorkspace}
@ -4781,6 +4800,7 @@ export default function NonIntegratedDetailClient({
displayedProductPricingRowsReadyForPush,
displayedProductSampleMeasurementRowCount:
displayedProductSampleMeasurementRows.length,
displayedSampleMeasurementHasPendingRows,
displayedSampleMeasurementIsConfirmed,
documentWorkspaceConfirmationAction:
renderDocumentWorkspaceConfirmationAction(),

View File

@ -2,6 +2,7 @@ import type { MaterialDTO } from "@/server/services";
import type {
NonIntegratedDocumentWorkspaceApprovalMap,
NonIntegratedDocumentWorkspaceConfirmationMap,
NonIntegratedFactoryRow,
NonIntegratedFinishedSampleShipmentRow,
NonIntegratedOpeningReference,
NonIntegratedProductRecord,
@ -11,6 +12,7 @@ import type {
NonIntegratedReviewIssue,
NonIntegratedReviewRound,
NonIntegratedReviewSheet,
NonIntegratedSampleMeasurementRow,
NonIntegratedSkuItem,
} from "./core/nonIntegratedStorage";
import type {
@ -58,6 +60,8 @@ export type AutoSaveNoticeType = "saving" | "success" | "error";
export type PersistEditedProductOverrides = {
documentWorkspaceConfirmations?: NonIntegratedDocumentWorkspaceConfirmationMap;
documentWorkspaceApprovedAt?: NonIntegratedDocumentWorkspaceApprovalMap;
factoryRows?: NonIntegratedFactoryRow[];
sampleMeasurementRows?: NonIntegratedSampleMeasurementRow[];
reviewRounds?: NonIntegratedReviewRound[];
finishedSampleShipmentRows?: NonIntegratedFinishedSampleShipmentRow[];
openingReference?: NonIntegratedOpeningReference;

View File

@ -1,89 +1,41 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import {
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
} from "./workspaces/documents/documentWorkspaceConfig";
import type { DocumentWorkspaceCategory } from "./workspaces/documents/documentWorkspaceHelpers";
import {
isProductFactoryRowComplete,
normalizeFactoryRowsForPayload,
} from "./workspaces/basic-info/productFactoryHelpers";
import type {
NonIntegratedDocumentWorkspaceApprovalMap,
NonIntegratedDocumentWorkspaceConfirmationMap,
NonIntegratedFactoryRow,
NonIntegratedFinishedSampleShipmentRow,
NonIntegratedSampleMeasurementRow,
} from "./core/nonIntegratedStorage";
type DetailMode = "create" | "edit" | "view";
type UseNonIntegratedWorkflowAutoConfirmationEffectsParams = {
allFinishedSampleShipmentRowsComplete: boolean;
allProductFactoryRowsComplete: boolean;
allSampleMeasurementRowsComplete: boolean;
existingProductCreatedAt: string;
factoryRows: NonIntegratedFactoryRow[];
finishedSampleShipmentRows: NonIntegratedFinishedSampleShipmentRow[];
isReadOnly: boolean;
mode: DetailMode;
productFactoryRows: NonIntegratedFactoryRow[];
productId?: string;
sampleMeasurementRows: NonIntegratedSampleMeasurementRow[];
setDocumentWorkspaceApprovedAt: Dispatch<
SetStateAction<NonIntegratedDocumentWorkspaceApprovalMap>
>;
setDocumentWorkspaceConfirmations: Dispatch<
SetStateAction<NonIntegratedDocumentWorkspaceConfirmationMap>
>;
setFactoryRows: Dispatch<SetStateAction<NonIntegratedFactoryRow[]>>;
supplierCodeByName: Map<string, string>;
};
export function useNonIntegratedWorkflowAutoConfirmationEffects({
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsComplete,
allSampleMeasurementRowsComplete,
existingProductCreatedAt,
factoryRows,
finishedSampleShipmentRows,
isReadOnly,
mode,
productFactoryRows,
productId,
sampleMeasurementRows,
setDocumentWorkspaceApprovedAt,
setDocumentWorkspaceConfirmations,
setFactoryRows,
supplierCodeByName,
}: UseNonIntegratedWorkflowAutoConfirmationEffectsParams) {
useEffect(() => {
if (isReadOnly || factoryRows.length === 0) return;
const normalizedRows = normalizeFactoryRowsForPayload(
productFactoryRows,
supplierCodeByName,
);
const hasConfirmationChange = normalizedRows.some(
(row) => row.confirmed !== isProductFactoryRowComplete(row),
);
if (!hasConfirmationChange) return;
setFactoryRows(
normalizedRows.map((row) => ({
...row,
confirmed: isProductFactoryRowComplete(row),
})),
);
}, [
factoryRows.length,
isReadOnly,
productFactoryRows,
setFactoryRows,
supplierCodeByName,
]);
useEffect(() => {
if (
mode !== "edit" ||
@ -99,17 +51,6 @@ export function useNonIntegratedWorkflowAutoConfirmationEffects({
touched: boolean;
complete: boolean;
}> = [
{
category: PRODUCT_FACTORY_WORKSPACE_CATEGORY,
touched: factoryRows.length > 0,
complete: allProductFactoryRowsComplete,
},
{
category: SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
touched: sampleMeasurementRows.length > 0,
complete:
allProductFactoryRowsComplete && allSampleMeasurementRowsComplete,
},
{
category: FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
touched: finishedSampleShipmentRows.length > 0,
@ -149,15 +90,11 @@ export function useNonIntegratedWorkflowAutoConfirmationEffects({
});
}, [
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsComplete,
allSampleMeasurementRowsComplete,
existingProductCreatedAt,
factoryRows.length,
finishedSampleShipmentRows.length,
isReadOnly,
mode,
productId,
sampleMeasurementRows.length,
setDocumentWorkspaceApprovedAt,
setDocumentWorkspaceConfirmations,
]);

View File

@ -56,7 +56,7 @@ export function useNonIntegratedWorkflowCompletion({
productSampleMeasurementRows: NonIntegratedSampleMeasurementRow[];
}) {
const confirmedProductFactoryRowCount = useMemo(
() => productFactoryRows.filter(isProductFactoryRowComplete).length,
() => productFactoryRows.filter((row) => row.confirmed).length,
[productFactoryRows],
);
const populatedProductPricingRowCount = useMemo(
@ -109,7 +109,10 @@ export function useNonIntegratedWorkflowCompletion({
displayedProductFactoryRows.some((row) => !isProductFactoryRowComplete(row));
const displayedProductFactoryIsConfirmed =
displayedProductFactoryRows.length > 0 &&
!displayedProductFactoryHasPendingRows;
displayedProductFactoryRows.every((row) => row.confirmed);
const allProductFactoryRowsConfirmed =
productFactoryRows.length > 0 &&
productFactoryRows.every((row) => row.confirmed);
const displayedProductFactoryRowsComplete =
displayedProductFactoryRows.length > 0 &&
displayedProductFactoryRows.every(isProductFactoryRowComplete);
@ -162,6 +165,16 @@ export function useNonIntegratedWorkflowCompletion({
),
[productPricingRows],
);
const allSampleMeasurementRowsConfirmedByPricing =
productSampleMeasurementRows.length > 0 &&
productSampleMeasurementRows.every((row) =>
productPricingSystemEnteredSkuIdSet.has(row.skuId),
);
const displayedSampleMeasurementRowsConfirmedByPricing =
displayedProductSampleMeasurementRows.length > 0 &&
displayedProductSampleMeasurementRows.every((row) =>
productPricingSystemEnteredSkuIdSet.has(row.skuId),
);
const displayedProductFactoryHasSystemEnteredPricingRows =
displayedProductFactoryRows.some((row) =>
productPricingSystemEnteredSkuIdSet.has(row.skuId),
@ -179,7 +192,10 @@ export function useNonIntegratedWorkflowCompletion({
return {
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsConfirmed,
allProductFactoryRowsComplete,
allProductPricingRowsSystemEntered,
allSampleMeasurementRowsConfirmedByPricing,
allSampleMeasurementRowsComplete,
confirmedProductFactoryRowCount,
displayedFinishedSampleShipmentRowsComplete,
@ -192,6 +208,7 @@ export function useNonIntegratedWorkflowCompletion({
displayedProductPricingRowsAllSystemEntered,
displayedProductPricingRowsComplete,
displayedProductPricingRowsReadyForPush,
displayedSampleMeasurementRowsConfirmedByPricing,
displayedSampleMeasurementRowsComplete,
isSampleMeasurementSkuLocked,
planningLayoutHasContent,

View File

@ -25,19 +25,19 @@ import {
type UseNonIntegratedWorkflowConfirmationStateParams = {
allFinishedSampleShipmentRowsComplete: boolean;
allProductFactoryRowsComplete: boolean;
allSampleMeasurementRowsComplete: boolean;
allProductFactoryRowsConfirmed: boolean;
allSampleMeasurementRowsConfirmedByPricing: boolean;
basicInfoHasContent: boolean;
displayedFinishedSampleShipmentRowsComplete: boolean;
displayedFirstQualityRowsComplete: boolean;
displayedProductFactoryIsConfirmed: boolean;
displayedProductLimitRowsComplete: boolean;
displayedProductSampleMeasurementRows: Array<{ skuId: string }>;
displayedSampleMeasurementRowsConfirmedByPricing: boolean;
displayedSampleMeasurementRowsComplete: boolean;
documentWorkspaceApprovedAt: NonIntegratedDocumentWorkspaceApprovalMap;
documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap;
documentWorkspaceRevisionSignatures: Record<WorkflowNodeCategory, string>;
isSampleMeasurementSkuLocked: (skuId: string) => boolean;
previousDocumentWorkspaceRevisionSignatures:
| Record<WorkflowNodeCategory, string>
| null
@ -54,19 +54,19 @@ type UseNonIntegratedWorkflowConfirmationStateParams = {
export function useNonIntegratedWorkflowConfirmationState({
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsComplete,
allSampleMeasurementRowsComplete,
allProductFactoryRowsConfirmed,
allSampleMeasurementRowsConfirmedByPricing,
basicInfoHasContent,
displayedFinishedSampleShipmentRowsComplete,
displayedFirstQualityRowsComplete,
displayedProductFactoryIsConfirmed,
displayedProductLimitRowsComplete,
displayedProductSampleMeasurementRows,
displayedSampleMeasurementRowsConfirmedByPricing,
displayedSampleMeasurementRowsComplete,
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
documentWorkspaceRevisionSignatures,
isSampleMeasurementSkuLocked,
previousDocumentWorkspaceRevisionSignatures,
productPricingCanBeConfirmed,
projectStatus,
@ -92,13 +92,15 @@ export function useNonIntegratedWorkflowConfirmationState({
const isWorkflowNodeConfirmed = useCallback(
(category: WorkflowNodeCategory, hasContent: boolean) => {
if (category === PRODUCT_FACTORY_WORKSPACE_CATEGORY) {
return hasContent && allProductFactoryRowsComplete;
return hasContent && allProductFactoryRowsConfirmed;
}
if (category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY) {
if (!hasContent || !allProductFactoryRowsConfirmed) return false;
if (allSampleMeasurementRowsConfirmedByPricing) return true;
return (
hasContent &&
allProductFactoryRowsComplete &&
allSampleMeasurementRowsComplete
documentWorkspaceConfirmations[category] === true &&
Boolean(documentWorkspaceApprovedAt[category]) &&
!dirtyWorkflowNodeCategories.has(category)
);
}
if (category === FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY) {
@ -110,12 +112,6 @@ export function useNonIntegratedWorkflowConfirmationState({
) {
return false;
}
if (
category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY &&
!displayedProductFactoryIsConfirmed
) {
return false;
}
return (
!shouldRequireWorkflowConfirmation(category) ||
@ -127,10 +123,9 @@ export function useNonIntegratedWorkflowConfirmationState({
},
[
allFinishedSampleShipmentRowsComplete,
allProductFactoryRowsComplete,
allSampleMeasurementRowsComplete,
allProductFactoryRowsConfirmed,
allSampleMeasurementRowsConfirmedByPricing,
dirtyWorkflowNodeCategories,
displayedProductFactoryIsConfirmed,
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
productPricingCanBeConfirmed,
@ -142,11 +137,6 @@ export function useNonIntegratedWorkflowConfirmationState({
Boolean(documentWorkspaceApprovedAt[category]),
[documentWorkspaceApprovedAt, documentWorkspaceConfirmations],
);
const isSampleMeasurementNodeLocked =
displayedProductSampleMeasurementRows.length > 0 &&
displayedProductSampleMeasurementRows.every((row) =>
isSampleMeasurementSkuLocked(row.skuId),
);
const isFinishedSampleShipmentNodeLocked = isDocumentWorkspaceNodeLocked(
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
);
@ -156,12 +146,20 @@ export function useNonIntegratedWorkflowConfirmationState({
const isProductLimitNodeLocked = isDocumentWorkspaceNodeLocked(
PRODUCT_LIMIT_WORKSPACE_CATEGORY,
);
const displayedSampleMeasurementIsConfirmed = isWorkflowNodeConfirmed(
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
displayedSampleMeasurementRowsComplete,
);
const displayedSampleMeasurementHasContent =
displayedProductSampleMeasurementRows.length > 0;
const displayedSampleMeasurementStoredConfirmed =
documentWorkspaceConfirmations[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY] ===
true &&
Boolean(documentWorkspaceApprovedAt[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]) &&
!dirtyWorkflowNodeCategories.has(SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY);
const displayedSampleMeasurementIsConfirmed =
displayedSampleMeasurementHasContent &&
displayedProductFactoryIsConfirmed &&
(displayedSampleMeasurementRowsConfirmedByPricing ||
displayedSampleMeasurementStoredConfirmed);
const isSampleMeasurementNodeApproved =
isSampleMeasurementNodeLocked &&
displayedSampleMeasurementIsConfirmed &&
!dirtyWorkflowNodeCategories.has(SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY);
const displayedFinishedSampleShipmentIsConfirmed = isWorkflowNodeConfirmed(
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,

View File

@ -128,7 +128,7 @@ export function useNonIntegratedWorkflowRows({
() =>
new Set(
productFactoryRows
.filter((row) => row.confirmed || isProductFactoryRowComplete(row))
.filter((row) => row.confirmed)
.map((row) => row.skuId),
),
[productFactoryRows],

View File

@ -124,7 +124,7 @@ export function FinishedSampleShipmentWorkspace({
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-3">
<div className="min-h-0 min-w-0 flex-1 overflow-auto p-3">
{allRows.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-apple-xl border border-dashed border-apple-separator text-sm text-apple-label-secondary">
当前还没有 SKU 信息,先维护 SKU 后再进行成品寄样。
@ -134,9 +134,9 @@ export function FinishedSampleShipmentWorkspace({
当前批次还没有 SKU 信息。
</div>
) : (
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator" style={{ overflowY: 'visible' }}>
<table className="min-w-[1760px] w-full border-collapse text-xs">
<thead className="bg-apple-bg-secondary/40 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{[
"寄样",

View File

@ -89,7 +89,7 @@ export function FirstQualityWorkspace({
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-3">
<div className="min-h-0 min-w-0 flex-1 overflow-auto p-3">
{allRows.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-apple-xl border border-dashed border-apple-separator text-sm text-apple-label-secondary">
当前还没有 SKU 信息,先维护 SKU 后再进行首批质检。
@ -99,9 +99,9 @@ export function FirstQualityWorkspace({
当前批次还没有 SKU 信息。
</div>
) : (
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator" style={{ overflowY: 'visible' }}>
<table className="min-w-[2120px] w-full border-collapse text-xs">
<thead className="bg-apple-bg-secondary/40 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{[
"批次",

View File

@ -96,7 +96,7 @@ export function ProductLimitWorkspace({
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-3">
<div className="min-h-0 min-w-0 flex-1 overflow-auto p-3">
{allRows.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-apple-xl border border-dashed border-apple-separator text-sm text-apple-label-secondary">
当前还没有 SKU 信息,先维护 SKU 后再填写产品限价。
@ -106,9 +106,9 @@ export function ProductLimitWorkspace({
当前批次还没有 SKU 信息。
</div>
) : (
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator" style={{ overflowY: 'visible' }}>
<table className="min-w-[1480px] w-full border-collapse text-xs">
<thead className="bg-apple-bg-secondary/40 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{[
"产品图片",

View File

@ -113,7 +113,7 @@ export function ProductPricingWorkspace({
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-3">
<div className="min-h-0 min-w-0 flex-1 overflow-auto p-3">
{allRows.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-apple-xl border border-dashed border-apple-separator text-sm text-apple-label-secondary">
当前还没有 SKU 信息,先维护 SKU 后再核价。
@ -123,9 +123,9 @@ export function ProductPricingWorkspace({
当前批次还没有 SKU 信息。
</div>
) : (
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator" style={{ overflowY: 'visible' }}>
<table className="min-w-[1280px] w-full border-collapse text-xs">
<thead className="bg-apple-bg-secondary/40 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{[
"型号",

View File

@ -15,7 +15,7 @@ type SampleMeasurementWorkspaceProps = {
displayedRows: ProductSampleMeasurementDisplayRow[];
inheritedSkuBatchTabs: ReactNode;
isReadOnly: boolean;
isProductFactorySkuComplete: (skuId: string) => boolean;
isProductFactorySkuConfirmed: (skuId: string) => boolean;
isSampleMeasurementSkuLocked: (skuId: string) => boolean;
onUpdateRow: (
skuId: string,
@ -50,7 +50,7 @@ export function SampleMeasurementWorkspace({
displayedRows,
inheritedSkuBatchTabs,
isReadOnly,
isProductFactorySkuComplete,
isProductFactorySkuConfirmed,
isSampleMeasurementSkuLocked,
onUpdateRow,
}: SampleMeasurementWorkspaceProps) {
@ -64,7 +64,7 @@ export function SampleMeasurementWorkspace({
placeholder={placeholder}
readOnly={
isReadOnly ||
!isProductFactorySkuComplete(row.skuId) ||
!isProductFactorySkuConfirmed(row.skuId) ||
isSampleMeasurementSkuLocked(row.skuId)
}
mode="singleLineResizable"
@ -85,7 +85,7 @@ export function SampleMeasurementWorkspace({
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-3">
<div className="min-h-0 min-w-0 flex-1 overflow-auto p-3">
{allRows.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-apple-xl border border-dashed border-apple-separator text-sm text-apple-label-secondary">
当前还没有 SKU 信息,先维护 SKU 后再进行样包测量。
@ -95,9 +95,9 @@ export function SampleMeasurementWorkspace({
当前批次还没有 SKU 信息。
</div>
) : (
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator" style={{ overflowY: 'visible' }}>
<table className="min-w-[2120px] w-full border-collapse text-xs">
<thead className="bg-apple-bg-secondary/40 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{[
"型号",

View File

@ -67,10 +67,9 @@ export function ComponentSkuTable({
onSelectComponentCode: (code: string) => void;
}) {
return (
<div className="overflow-hidden rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll overflow-x-auto">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll max-h-[60vh] max-w-full rounded-apple-lg border border-apple-separator">
<table className="min-w-[1660px] w-full text-left text-xs">
<thead className="bg-apple-bg-secondary/60 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{COMPONENT_SKU_TABLE_HEADERS.map((label) => (
<th
@ -165,7 +164,6 @@ export function ComponentSkuTable({
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -50,14 +50,13 @@ export function SkuInfoTableShell({
(showActionColumn ? 1 : 0);
return (
<div className="overflow-hidden rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll overflow-x-auto">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll max-h-[60vh] max-w-full rounded-apple-lg border border-apple-separator">
<table
className={`${
isSeriesComponentDetailProduct ? "min-w-[2260px]" : "min-w-[2180px]"
} w-full text-left text-xs`}
>
<thead className="bg-apple-bg-secondary/60 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{headers.map((label) => (
<th
@ -83,7 +82,6 @@ export function SkuInfoTableShell({
{children}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -11,6 +11,7 @@ import {
MODEL_SHARED_PRODUCT_FACTORY_KEYS,
PACKAGE_SHARED_PRODUCT_FACTORY_PURCHASING_KEYS,
PRODUCT_FACTORY_REQUIRED_FIELDS,
SPU_SHARED_PRODUCT_FACTORY_KEYS,
} from "./skuFieldConfig";
import { buildAutoBarcode, shouldKeepCustomBarcode } from "./skuAutoHelpers";
import { SUPPLIER_CODE_BY_NAME } from "./supplierDirectory";
@ -84,6 +85,8 @@ export function normalizeModelSharedProductFactoryRows(
});
return rows.map((row) => {
if (row.confirmed) return row;
const modelKey = getSkuFactoryModelKey(row);
if (!modelKey) return row;
@ -101,6 +104,40 @@ export function normalizeModelSharedProductFactoryRows(
});
}
export function normalizeSpuSharedProductFactoryRows(
rows: NonIntegratedFactoryRow[],
) {
const sharedValueByKey = new Map<string, string>();
SPU_SHARED_PRODUCT_FACTORY_KEYS.forEach((key) => {
const sharedValue = rows.find((row) => row[key].trim())?.[key].trim() ?? "";
sharedValueByKey.set(key, sharedValue);
});
return rows.map((row) => {
if (row.confirmed) return row;
let nextRow = row;
SPU_SHARED_PRODUCT_FACTORY_KEYS.forEach((key) => {
const sharedValue = sharedValueByKey.get(key);
if (sharedValue === undefined || nextRow[key] === sharedValue) return;
nextRow = {
...nextRow,
[key]: sharedValue,
};
});
return nextRow;
});
}
export function normalizeSharedProductFactoryRows(
rows: NonIntegratedFactoryRow[],
) {
return normalizeSpuSharedProductFactoryRows(
normalizeModelSharedProductFactoryRows(rows),
);
}
export function getProductFactoryMissingFieldLabels(
row: NonIntegratedFactoryRow,
) {
@ -123,7 +160,7 @@ export function getProductFactoryStatusMessage({
if (rows.length === 0) return null;
if (isRowsComplete) {
return {
message: "产品定厂已完成",
message: "资料已填完核对无误请确认。",
type: "success" as const,
};
}
@ -249,7 +286,7 @@ export function buildProductFactoryRows(
);
});
return normalizeModelSharedProductFactoryRows(rows);
return normalizeSharedProductFactoryRows(rows);
}
export function normalizeFactoryRowsForPayload(
@ -294,5 +331,5 @@ export function normalizeFactoryRowsForPayload(
};
});
return normalizeModelSharedProductFactoryRows(normalizedRows);
return normalizeSharedProductFactoryRows(normalizedRows);
}

View File

@ -161,10 +161,13 @@ export const PACKAGE_SHARED_PRODUCT_FACTORY_PURCHASING_KEYS =
"assemblyMode",
]);
export const MODEL_SHARED_PRODUCT_FACTORY_KEYS = new Set<ProductFactoryEditableKey>([
"installationService",
]);
export const SPU_SHARED_PRODUCT_FACTORY_KEYS = new Set<ProductFactoryEditableKey>([
"warehouse",
"shippingMethod",
"installMethod",
"productionType",
"installationService",
"supplierCategory",
"purchaseOrderMethod",
]);

View File

@ -28,7 +28,7 @@ export function BomRowsTableShell({
className="w-full min-w-full text-left text-xs"
style={{ minWidth: `${tableMinWidth}px` }}
>
<thead className="bg-apple-bg-secondary/60 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{columns.map((column) => (
<th

View File

@ -28,8 +28,6 @@ export const PRODUCT_LIMIT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
export const PLANNING_LAYOUT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY;
export const AUTO_COMPLETED_WORKFLOW_CATEGORIES = new Set<WorkflowNodeCategory>([
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
]);
export const FACTORY_QUALITY_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =

View File

@ -10,6 +10,7 @@ type ProductFactoryHeaderActionsProps = {
isReadOnly: boolean;
rowCount: number;
hasPendingRows: boolean;
isConfirmed: boolean;
hasSystemEnteredPricingRows: boolean;
isReopenPickerOpen: boolean;
reopenField: ProductFactoryEditableKey;
@ -26,6 +27,7 @@ export function ProductFactoryHeaderActions({
isReadOnly,
rowCount,
hasPendingRows,
isConfirmed,
hasSystemEnteredPricingRows,
isReopenPickerOpen,
reopenField,
@ -98,10 +100,15 @@ export function ProductFactoryHeaderActions({
) : null}
<Button
size="small"
data-testid="product-factory-confirm"
onClick={onStatusClick}
disabled={rowCount === 0}
>
{hasPendingRows ? "当前资料未填完" : "已完成"}
{hasPendingRows
? "当前资料未填完"
: isConfirmed
? "已确认"
: "资料已填完核对无误请确认。"}
</Button>
</div>
);

View File

@ -96,9 +96,9 @@ export function ProductFactoryInfoTable({
};
return (
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator" style={{ overflowY: 'visible' }}>
<table className="min-w-[2160px] w-full border-collapse text-xs">
<thead className="bg-apple-bg-secondary/40 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{[
"定厂",
@ -141,14 +141,18 @@ export function ProductFactoryInfoTable({
<td className="px-2 py-2">
<span
className={`inline-flex whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] ${
isProductFactoryRowComplete(row)
row.confirmed
? "bg-[#dff7e8] text-[#137a38]"
: "bg-[#fff3df] text-[#9b6511]"
: isProductFactoryRowComplete(row)
? "bg-[#e7f1ff] text-[#1764ad]"
: "bg-[#fff3df] text-[#9b6511]"
}`}
>
{isProductFactoryRowComplete(row)
? "已完成"
: "当前资料未填完"}
{row.confirmed
? "已确认"
: isProductFactoryRowComplete(row)
? "资料已填完"
: "当前资料未填完"}
</span>
</td>
<td className="px-2 py-2 text-apple-label-secondary">

View File

@ -8,9 +8,9 @@ export function ProductFactorySkuInfoTable({
rows: NonIntegratedFactoryRow[];
}) {
return (
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator">
<div className="non-integrated-table-scroll non-integrated-workspace-table-scroll h-full max-w-full rounded-apple-lg border border-apple-separator" style={{ overflowY: 'visible' }}>
<table className="min-w-[980px] w-full border-collapse text-xs">
<thead className="bg-apple-bg-secondary/40 text-apple-label-secondary">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr>
{[
"定厂",
@ -40,14 +40,18 @@ export function ProductFactorySkuInfoTable({
<td className="px-3 py-2">
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[11px] ${
isProductFactoryRowComplete(row)
row.confirmed
? "bg-[#dff7e8] text-[#137a38]"
: "bg-[#fff3df] text-[#9b6511]"
: isProductFactoryRowComplete(row)
? "bg-[#e7f1ff] text-[#1764ad]"
: "bg-[#fff3df] text-[#9b6511]"
}`}
>
{isProductFactoryRowComplete(row)
? "已完成"
: "当前资料未填完"}
{row.confirmed
? "已确认"
: isProductFactoryRowComplete(row)
? "资料已填完"
: "当前资料未填完"}
</span>
</td>
<td className="px-3 py-2">{row.model || "-"}</td>

View File

@ -91,7 +91,7 @@ export function ProductFactoryWorkspace({
/>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-3">
<div className="min-h-0 min-w-0 flex-1 overflow-auto p-3">
{allRows.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-apple-xl border border-dashed border-apple-separator text-sm text-apple-label-secondary">
当前还没有 SKU 信息,先维护 SKU 后再定厂。

View File

@ -6,6 +6,7 @@ import {
MODEL_AUTOFILL_PRODUCT_FACTORY_KEYS,
MODEL_SHARED_PRODUCT_FACTORY_KEYS,
PACKAGE_SHARED_PRODUCT_FACTORY_KEYS,
SPU_SHARED_PRODUCT_FACTORY_KEYS,
type ProductFactoryEditableKey,
type ProductFactoryModelAutofillEntry,
} from "../basic-info/skuFieldConfig";
@ -48,6 +49,10 @@ export function getProductFactoryTargetSkuIds(
const targetItem = items.find((item) => item.id === targetId);
if (!targetItem) return new Set([targetId]);
if (SPU_SHARED_PRODUCT_FACTORY_KEYS.has(key)) {
return new Set(items.map((item) => item.id));
}
if (MODEL_SHARED_PRODUCT_FACTORY_KEYS.has(key)) {
const targetModelKey = getSkuFactoryModelKey(targetItem);
if (!targetModelKey) return new Set([targetId]);

View File

@ -4,6 +4,7 @@ import type {
SetStateAction,
} from "react";
import type { PersistEditedProductOverrides } from "../../nonIntegratedDetailTypes";
import type {
NonIntegratedDocumentWorkspaceApprovalMap,
NonIntegratedDocumentWorkspaceConfirmationMap,
@ -16,10 +17,15 @@ import {
type ProductFactoryEditableKey,
type ProductFactoryModelAutofillEntry,
} from "../basic-info/skuFieldConfig";
import { normalizeFactorySupplier, getProductFactoryStatusMessage } from "../basic-info/productFactoryHelpers";
import {
getProductFactoryStatusMessage,
normalizeFactoryRowsForPayload,
normalizeFactorySupplier,
} from "../basic-info/productFactoryHelpers";
import { buildSkuItemsAfterSetCountCommit } from "../basic-info/skuSetCountCommitHelpers";
import { getStyleKeyFromModel } from "../style-assets/styleFileHelpers";
import { PRODUCT_FACTORY_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig";
import type { WorkflowNodeCategory } from "../documents/documentWorkspaceHelpers";
import {
confirmProductFactorySupplierRowsForSku,
reopenProductFactoryRows,
@ -35,18 +41,30 @@ type ApplySkuSupplierName = (
value: string,
) => NonIntegratedSkuItem;
type PersistEditedProduct = (params: {
successMessage: string;
errorFallbackMessage: string;
overrides?: PersistEditedProductOverrides;
suppressSuccessMessage?: boolean;
}) => Promise<boolean>;
type UseProductFactoryRowControllerParams = {
applySkuSupplierName: ApplySkuSupplierName;
derivedSkuItems: NonIntegratedSkuItem[];
displayedProductFactoryHasPendingRows: boolean;
displayedProductFactoryHasSystemEnteredPricingRows: boolean;
displayedProductFactoryIsConfirmed: boolean;
displayedProductFactoryRows: NonIntegratedFactoryRow[];
displayedProductFactoryRowsComplete: boolean;
documentWorkspaceApprovedAt: NonIntegratedDocumentWorkspaceApprovalMap;
documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap;
documentWorkspaceRevisionSignatureRef: MutableRefObject<
Record<WorkflowNodeCategory, string> | null
>;
isCurrentUserAdmin: boolean;
isReadOnly: boolean;
isSkuItemFactoryConfirmed: (item: NonIntegratedSkuItem) => boolean;
persistEditedProduct: PersistEditedProduct;
productFactoryModelAutofillRef: MutableRefObject<
Map<string, ProductFactoryModelAutofillEntry>
>;
@ -73,13 +91,16 @@ export function useProductFactoryRowController({
derivedSkuItems,
displayedProductFactoryHasPendingRows,
displayedProductFactoryHasSystemEnteredPricingRows,
displayedProductFactoryIsConfirmed,
displayedProductFactoryRows,
displayedProductFactoryRowsComplete,
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
documentWorkspaceRevisionSignatureRef,
isCurrentUserAdmin,
isReadOnly,
isSkuItemFactoryConfirmed,
persistEditedProduct,
productFactoryModelAutofillRef,
productFactoryReopenField,
productFactoryRows,
@ -156,32 +177,32 @@ export function useProductFactoryRowController({
if (key === "installMethod") {
setSkuItems((current) => {
const targetItem = workflowSkuItems.find((item) => item.id === skuId);
if (!targetItem) return current;
const targetSkuIds = getProductFactoryTargetSkuIds(
workflowSkuItems,
skuId,
key,
);
const targetStyleKeys = new Set(
workflowSkuItems
.filter((item) => targetSkuIds.has(item.id))
.map((item) => getStyleKeyFromModel(item.model))
.filter(Boolean),
);
const targetStyleKey = getStyleKeyFromModel(targetItem.model);
if (targetStyleKey) {
const targetSkuIds = new Set(
workflowSkuItems
.filter((item) => getStyleKeyFromModel(item.model) === targetStyleKey)
.map((item) => item.id),
);
setStyleDetails((currentDetails) =>
updateStyleDetailEntry(currentDetails, targetStyleKey, {
installMethod: value,
}),
);
return current.map((item) =>
targetSkuIds.has(item.id)
? { ...item, installMethod: value }
: item,
);
if (targetStyleKeys.size > 0) {
setStyleDetails((currentDetails) => {
let nextDetails = currentDetails;
targetStyleKeys.forEach((styleKey) => {
nextDetails = updateStyleDetailEntry(nextDetails, styleKey, {
installMethod: value,
});
});
return nextDetails;
});
}
return current.map((item) =>
item.id === skuId ? { ...item, installMethod: value } : item,
targetSkuIds.has(item.id) ? { ...item, installMethod: value } : item,
);
});
}
@ -235,13 +256,65 @@ export function useProductFactoryRowController({
setSaveMessageType("success");
}
function handleProductFactoryStatusClick() {
async function handleProductFactoryStatusClick() {
const statusMessage = getProductFactoryStatusMessage({
isRowsComplete: displayedProductFactoryRowsComplete,
rows: displayedProductFactoryRows,
});
if (!statusMessage) return;
if (
!isReadOnly &&
displayedProductFactoryRowsComplete &&
!displayedProductFactoryIsConfirmed
) {
const targetSkuIds = new Set(
displayedProductFactoryRows.map((row) => row.skuId),
);
const normalizedRows = normalizeFactoryRowsForPayload(
productFactoryRows,
supplierCodeByName,
);
const nextRows = normalizedRows.map((row) =>
targetSkuIds.has(row.skuId) ? { ...row, confirmed: true } : row,
);
const allRowsConfirmed =
nextRows.length > 0 && nextRows.every((row) => row.confirmed);
const approvedAt = new Date().toISOString();
const nextConfirmations = {
...documentWorkspaceConfirmations,
[PRODUCT_FACTORY_WORKSPACE_CATEGORY]: allRowsConfirmed,
};
const nextApprovedAt = { ...documentWorkspaceApprovedAt };
if (allRowsConfirmed) {
nextApprovedAt[PRODUCT_FACTORY_WORKSPACE_CATEGORY] = approvedAt;
} else {
delete nextApprovedAt[PRODUCT_FACTORY_WORKSPACE_CATEGORY];
}
setFactoryRows(nextRows);
setDocumentWorkspaceConfirmations(nextConfirmations);
setDocumentWorkspaceApprovedAt(nextApprovedAt);
if (documentWorkspaceRevisionSignatureRef.current) {
documentWorkspaceRevisionSignatureRef.current = {
...documentWorkspaceRevisionSignatureRef.current,
[PRODUCT_FACTORY_WORKSPACE_CATEGORY]: JSON.stringify(nextRows),
};
}
await persistEditedProduct({
successMessage: allRowsConfirmed
? "产品定厂已确认"
: "当前批次产品定厂已确认",
errorFallbackMessage: "产品定厂确认保存失败",
overrides: {
factoryRows: nextRows,
documentWorkspaceConfirmations: nextConfirmations,
documentWorkspaceApprovedAt: nextApprovedAt,
},
});
return;
}
setSaveMessage(statusMessage.message);
setSaveMessageType(statusMessage.type);
}

View File

@ -20,7 +20,10 @@ import type {
NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import type { FinishedSampleShipmentEditableKey } from "../basic-info/skuFieldConfig";
import { PRODUCT_PRICING_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig";
import {
PRODUCT_PRICING_WORKSPACE_CATEGORY,
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
} from "../documents/documentWorkspaceConfig";
import type { WorkflowNodeCategory } from "../documents/documentWorkspaceHelpers";
import {
updateFirstQualityRowsForSku,
@ -77,7 +80,7 @@ type UseProductWorkflowRowControllerParams = {
documentWorkspaceRevisionSignatureRef: MutableRefObject<
Record<WorkflowNodeCategory, string> | null
>;
isProductFactorySkuComplete: (skuId: string) => boolean;
isProductFactorySkuConfirmed: (skuId: string) => boolean;
isProductLimitNodeLocked: boolean;
isProductPricingNodeLocked: boolean;
isReadOnly: boolean;
@ -89,6 +92,8 @@ type UseProductWorkflowRowControllerParams = {
planningLayoutRowsRef: MutableRefObject<NonIntegratedPlanningLayoutRow[]>;
pricingRows: NonIntegratedPricingRow[];
productId?: string;
productSampleMeasurementRows: NonIntegratedSampleMeasurementRow[];
sampleMeasurementRows: NonIntegratedSampleMeasurementRow[];
scheduleAutoSave: (overrides?: PersistEditedProductOverrides) => void;
setDocumentWorkspaceApprovedAt: Dispatch<
SetStateAction<NonIntegratedDocumentWorkspaceApprovalMap>
@ -128,7 +133,7 @@ export function useProductWorkflowRowController({
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
documentWorkspaceRevisionSignatureRef,
isProductFactorySkuComplete,
isProductFactorySkuConfirmed,
isProductLimitNodeLocked,
isProductPricingNodeLocked,
isReadOnly,
@ -140,6 +145,8 @@ export function useProductWorkflowRowController({
planningLayoutRowsRef,
pricingRows,
productId,
productSampleMeasurementRows,
sampleMeasurementRows,
scheduleAutoSave,
setDocumentWorkspaceApprovedAt,
setDocumentWorkspaceConfirmations,
@ -233,16 +240,25 @@ export function useProductWorkflowRowController({
});
const nextProductPricingCanBeConfirmed =
canConfirmProductPricingRows(nextPricingRows);
const nextSampleMeasurementCanBeConfirmedByPricing =
nextProductPricingCanBeConfirmed &&
productSampleMeasurementRows.length > 0;
const nextApprovedAt = nextProductPricingCanBeConfirmed
? {
...documentWorkspaceApprovedAt,
[PRODUCT_PRICING_WORKSPACE_CATEGORY]: pushedAt,
...(nextSampleMeasurementCanBeConfirmedByPricing
? { [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: pushedAt }
: {}),
}
: documentWorkspaceApprovedAt;
const nextConfirmations = nextProductPricingCanBeConfirmed
? {
...documentWorkspaceConfirmations,
[PRODUCT_PRICING_WORKSPACE_CATEGORY]: true,
...(nextSampleMeasurementCanBeConfirmedByPricing
? { [SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: true }
: {}),
}
: documentWorkspaceConfirmations;
const pushSuccessMessage =
@ -257,6 +273,12 @@ export function useProductWorkflowRowController({
...documentWorkspaceRevisionSignatureRef.current,
[PRODUCT_PRICING_WORKSPACE_CATEGORY]:
JSON.stringify(nextPricingRows),
...(nextSampleMeasurementCanBeConfirmedByPricing
? {
[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]:
JSON.stringify(sampleMeasurementRows),
}
: {}),
};
}
}
@ -456,7 +478,7 @@ export function useProductWorkflowRowController({
) {
if (
isReadOnly ||
!isProductFactorySkuComplete(skuId) ||
!isProductFactorySkuConfirmed(skuId) ||
isSampleMeasurementSkuLocked(skuId)
)
return;

View File

@ -4,6 +4,7 @@ type SampleMeasurementHeaderActionsProps = {
actionGroupClassName: string;
rowCount: number;
isProductFactoryConfirmed: boolean;
hasPendingRows: boolean;
isSampleMeasurementConfirmed: boolean;
onStatusClick: () => void;
};
@ -12,6 +13,7 @@ export function SampleMeasurementHeaderActions({
actionGroupClassName,
rowCount,
isProductFactoryConfirmed,
hasPendingRows,
isSampleMeasurementConfirmed,
onStatusClick,
}: SampleMeasurementHeaderActionsProps) {
@ -19,6 +21,7 @@ export function SampleMeasurementHeaderActions({
<div className={actionGroupClassName}>
<Button
size="small"
data-testid="sample-measurement-confirm"
onClick={onStatusClick}
disabled={rowCount === 0}
title={
@ -28,8 +31,10 @@ export function SampleMeasurementHeaderActions({
{!isProductFactoryConfirmed
? "定厂未完成"
: isSampleMeasurementConfirmed
? "已完成"
: "当前资料未填完"}
? "已确认"
: hasPendingRows
? "当前资料未填完"
: "资料已填完核对无误请确认。"}
</Button>
</div>
);

View File

@ -153,7 +153,7 @@ export function getSampleMeasurementStatusMessage({
}
return {
message: "样包测量已完成",
message: "资料已填完核对无误请确认。",
type: "success" as const,
};
}

View File

@ -1,37 +1,77 @@
import type {
Dispatch,
MutableRefObject,
SetStateAction,
} from "react";
import type { PersistEditedProductOverrides } from "../../nonIntegratedDetailTypes";
import type {
NonIntegratedDocumentWorkspaceApprovalMap,
NonIntegratedDocumentWorkspaceConfirmationMap,
} from "../../nonIntegratedStorage";
import { SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig";
import type { WorkflowNodeCategory } from "../documents/documentWorkspaceHelpers";
import {
getFinishedSampleShipmentStatusMessage,
getSampleMeasurementStatusMessage,
normalizeSampleMeasurementRowsForPayload,
type ProductFinishedSampleShipmentDisplayRow,
type ProductSampleMeasurementDisplayRow,
} from "./sampleWorkflowHelpers";
type SaveMessageType = "success" | "error";
type PersistEditedProduct = (params: {
successMessage: string;
errorFallbackMessage: string;
overrides?: PersistEditedProductOverrides;
suppressSuccessMessage?: boolean;
}) => Promise<boolean>;
type UseSampleWorkflowStatusControllerParams = {
documentWorkspaceApprovedAt: NonIntegratedDocumentWorkspaceApprovalMap;
documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap;
displayedFinishedSampleShipmentRows: ProductFinishedSampleShipmentDisplayRow[];
displayedFinishedSampleShipmentRowsComplete: boolean;
displayedProductFactoryIsConfirmed: boolean;
displayedProductSampleMeasurementRows: ProductSampleMeasurementDisplayRow[];
displayedSampleMeasurementIsConfirmed: boolean;
displayedSampleMeasurementRowsComplete: boolean;
documentWorkspaceRevisionSignatureRef: MutableRefObject<
Record<WorkflowNodeCategory, string> | null
>;
isReadOnly: boolean;
persistEditedProduct: PersistEditedProduct;
productSampleMeasurementRows: ProductSampleMeasurementDisplayRow[];
setDocumentWorkspaceApprovedAt: Dispatch<
SetStateAction<NonIntegratedDocumentWorkspaceApprovalMap>
>;
setDocumentWorkspaceConfirmations: Dispatch<
SetStateAction<NonIntegratedDocumentWorkspaceConfirmationMap>
>;
setSaveMessage: Dispatch<SetStateAction<string>>;
setSaveMessageType: Dispatch<SetStateAction<SaveMessageType>>;
};
export function useSampleWorkflowStatusController({
documentWorkspaceApprovedAt,
documentWorkspaceConfirmations,
displayedFinishedSampleShipmentRows,
displayedFinishedSampleShipmentRowsComplete,
displayedProductFactoryIsConfirmed,
displayedProductSampleMeasurementRows,
displayedSampleMeasurementIsConfirmed,
displayedSampleMeasurementRowsComplete,
documentWorkspaceRevisionSignatureRef,
isReadOnly,
persistEditedProduct,
productSampleMeasurementRows,
setDocumentWorkspaceApprovedAt,
setDocumentWorkspaceConfirmations,
setSaveMessage,
setSaveMessageType,
}: UseSampleWorkflowStatusControllerParams) {
function handleSampleMeasurementStatusClick() {
async function handleSampleMeasurementStatusClick() {
const statusMessage = getSampleMeasurementStatusMessage({
isFactoryConfirmed: displayedProductFactoryIsConfirmed,
isRowsComplete: displayedSampleMeasurementRowsComplete,
@ -39,6 +79,47 @@ export function useSampleWorkflowStatusController({
});
if (!statusMessage) return;
if (
!isReadOnly &&
displayedProductFactoryIsConfirmed &&
displayedSampleMeasurementRowsComplete &&
!displayedSampleMeasurementIsConfirmed
) {
const approvedAt = new Date().toISOString();
const nextConfirmations = {
...documentWorkspaceConfirmations,
[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: true,
};
const nextApprovedAt = {
...documentWorkspaceApprovedAt,
[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: approvedAt,
};
const nextSampleMeasurementRows = normalizeSampleMeasurementRowsForPayload(
productSampleMeasurementRows,
);
setDocumentWorkspaceConfirmations(nextConfirmations);
setDocumentWorkspaceApprovedAt(nextApprovedAt);
if (documentWorkspaceRevisionSignatureRef.current) {
documentWorkspaceRevisionSignatureRef.current = {
...documentWorkspaceRevisionSignatureRef.current,
[SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY]: JSON.stringify(
nextSampleMeasurementRows,
),
};
}
await persistEditedProduct({
successMessage: "样包测量已确认",
errorFallbackMessage: "样包测量确认保存失败",
overrides: {
sampleMeasurementRows: nextSampleMeasurementRows,
documentWorkspaceConfirmations: nextConfirmations,
documentWorkspaceApprovedAt: nextApprovedAt,
},
});
return;
}
setSaveMessage(statusMessage.message);
setSaveMessageType(statusMessage.type);
}

View File

@ -54,6 +54,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
displayedProductPricingRowsComplete,
displayedProductPricingRowsReadyForPush,
displayedProductSampleMeasurementRowCount,
displayedSampleMeasurementHasPendingRows,
displayedSampleMeasurementIsConfirmed,
documentWorkspaceConfirmationAction,
documentWorkspaceUploadInputRef,
@ -157,6 +158,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
displayedProductPricingRowsComplete: boolean;
displayedProductPricingRowsReadyForPush: boolean;
displayedProductSampleMeasurementRowCount: number;
displayedSampleMeasurementHasPendingRows: boolean;
displayedSampleMeasurementIsConfirmed: boolean;
documentWorkspaceConfirmationAction: ReactNode;
documentWorkspaceUploadInputRef: RefObject<HTMLInputElement | null>;
@ -262,6 +264,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
isReadOnly={isReadOnly}
rowCount={displayedProductFactoryRowCount}
hasPendingRows={displayedProductFactoryHasPendingRows}
isConfirmed={displayedProductFactoryIsConfirmed}
hasSystemEnteredPricingRows={
displayedProductFactoryHasSystemEnteredPricingRows
}
@ -282,6 +285,7 @@ export function DocumentWorkspaceHeaderActionsSlot({
actionGroupClassName={actionGroupClassName}
rowCount={displayedProductSampleMeasurementRowCount}
isProductFactoryConfirmed={displayedProductFactoryIsConfirmed}
hasPendingRows={displayedSampleMeasurementHasPendingRows}
isSampleMeasurementConfirmed={displayedSampleMeasurementIsConfirmed}
onStatusClick={onSampleMeasurementStatusClick}
/>

View File

@ -10,8 +10,10 @@ import {
import { DocumentWorkspaceSidebarStatusIcon } from "../../components/DocumentWorkspaceControls";
import type {
NonIntegratedFactoryRow,
NonIntegratedSampleMeasurementRow,
NonIntegratedUploadFile,
} from "../../nonIntegratedStorage";
import { SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY } from "../documents/documentWorkspaceConfig";
type DocumentWorkspaceView = "detail" | "document";
@ -27,9 +29,10 @@ export function DocumentWorkspaceSidebar({
basicInfoNeedsConfirmation,
documentWorkspaceCountByCategory,
documentWorkspaceView,
isProductFactoryRowComplete,
isWorkflowNodeConfirmed,
productFactoryRows,
sampleMeasurementRows,
isSampleMeasurementConfirmed,
visibleGroupedUploadFiles,
onOpenBasicInfo,
onOpenDocumentWorkspace,
@ -42,12 +45,13 @@ export function DocumentWorkspaceSidebar({
Record<DocumentWorkspaceCategory, number>
>;
documentWorkspaceView: DocumentWorkspaceView;
isProductFactoryRowComplete: (row: NonIntegratedFactoryRow) => boolean;
isWorkflowNodeConfirmed: (
category: DocumentWorkspaceCategory,
hasContent: boolean,
) => boolean;
productFactoryRows: NonIntegratedFactoryRow[];
sampleMeasurementRows: NonIntegratedSampleMeasurementRow[];
isSampleMeasurementConfirmed: boolean;
visibleGroupedUploadFiles: GroupedWorkspaceFiles[];
onOpenBasicInfo: () => void;
onOpenDocumentWorkspace: (category: DocumentWorkspaceCategory) => void;
@ -74,13 +78,14 @@ export function DocumentWorkspaceSidebar({
const categoryHasContent =
category === PRODUCT_FACTORY_WORKSPACE_CATEGORY
? productFactoryRows.length > 0
: category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY
? sampleMeasurementRows.length > 0
: hasFiles;
const categoryNeedsConfirmation =
category === PRODUCT_FACTORY_WORKSPACE_CATEGORY
? !(
productFactoryRows.length > 0 &&
productFactoryRows.every(isProductFactoryRowComplete)
)
? !productFactoryRows.every((row) => row.confirmed)
: category === SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY
? !isSampleMeasurementConfirmed
: needsConfirmation;
const shouldInsertBasicInfoTrigger = index === basicInfoInsertIndex;
const basicInfoIsActive = documentWorkspaceView === "detail";

View File

@ -231,7 +231,8 @@ export default async function IntakePage({
materialLibraryFinishes,
materialLibraryCategories,
materialLibraryDictionaries,
initialNonIntegratedProductPage,
initialProductNonIntegratedProductPage,
initialStockNonIntegratedProductPage,
initialSeriesNonIntegratedProductPage,
nodePermissionSettings,
initialOpenProduct,
@ -247,10 +248,17 @@ export default async function IntakePage({
page: 1,
pageSize: 50,
productDevelopmentKind: 'product',
productKind: 'new',
}),
intakeNonIntegratedProductService.listPage(authUser, {
page: 1,
pageSize: 500,
pageSize: 50,
productDevelopmentKind: 'product',
productKind: 'stock',
}),
intakeNonIntegratedProductService.listPage(authUser, {
page: 1,
pageSize: 50,
productDevelopmentKind: 'series',
}),
intakeNodePermissionSettingsService.getSettings(),
@ -263,11 +271,17 @@ export default async function IntakePage({
])
const initialNonIntegratedProducts = appendInitialOpenProduct(
mergeInitialNonIntegratedProducts(
initialNonIntegratedProductPage.products,
initialProductNonIntegratedProductPage.products,
initialStockNonIntegratedProductPage.products,
initialSeriesNonIntegratedProductPage.products,
),
initialOpenProduct,
)
const initialNonIntegratedPageInfo = {
product: initialProductNonIntegratedProductPage.pageInfo,
stock: initialStockNonIntegratedProductPage.pageInfo,
series: initialSeriesNonIntegratedProductPage.pageInfo,
}
const initialOpenProductWorkspace = initialOpenProduct
? {
product: initialOpenProduct,
@ -292,43 +306,50 @@ export default async function IntakePage({
? selectedDetail
: null
const eligibleDraftGroupDetails = await Promise.all(
eligibleDraftGroups.map((group) => draftGroupService.getById(group.id)),
const eligibleDraftGroupById = new Map(
eligibleDraftGroups.map((group) => [group.id, group]),
)
const eligiblePreSkus = await draftGroupService.listPreSkusByDraftGroupIds(
eligibleDraftGroups.map((group) => group.id),
)
const eligiblePreSkuSequenceByDraftGroup = new Map<string, number>()
const eligibleSkuList = eligibleDraftGroupDetails.flatMap((detail) =>
detail.preSkus.map((preSku, index) => {
const skuFields = readPreSkuSkuFields(preSku)
return {
id: preSku.id,
draftGroupId: detail.draftGroup.id,
spuCode:
readPreSkuTextField(preSku, 'spuCode') ||
readPreSkuTextField(preSku, 'spu') ||
detail.draftGroup.code ||
`SPU-${String(index + 1).padStart(3, '0')}`,
code:
skuFields.skuCode ||
preSku.customName ||
preSku.defaultName,
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, 'seriesName') ||
detail.draftGroup.assetSeriesName ||
detail.draftGroup.name,
draftGroupName: detail.draftGroup.name,
status: mapWorkbenchStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
packagingPlan: preSku.packagingPlan,
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields,
model3dPreviewNodes: preSku.previewNodes.length
? preSku.previewNodes
: preSku.assemblyWorkspace?.nodes ?? [],
}
}),
)
const eligibleSkuList = eligiblePreSkus.flatMap((preSku) => {
const draftGroup = eligibleDraftGroupById.get(preSku.draftGroupId)
if (!draftGroup) return []
const index = eligiblePreSkuSequenceByDraftGroup.get(draftGroup.id) ?? 0
eligiblePreSkuSequenceByDraftGroup.set(draftGroup.id, index + 1)
const skuFields = readPreSkuSkuFields(preSku)
return {
id: preSku.id,
draftGroupId: draftGroup.id,
spuCode:
readPreSkuTextField(preSku, 'spuCode') ||
readPreSkuTextField(preSku, 'spu') ||
draftGroup.code ||
`SPU-${String(index + 1).padStart(3, '0')}`,
code:
skuFields.skuCode ||
preSku.customName ||
preSku.defaultName,
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, 'seriesName') ||
draftGroup.assetSeriesName ||
draftGroup.name,
draftGroupName: draftGroup.name,
status: mapWorkbenchStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
componentDetails: readPreSkuComponentDetails(preSku),
packagingPlan: preSku.packagingPlan,
holePoints: buildSkuHolePointsFromVariantSelection(preSku),
skuFields,
model3dPreviewNodes: preSku.previewNodes.length
? preSku.previewNodes
: preSku.assemblyWorkspace?.nodes ?? [],
}
})
const sourceWorkspace =
eligibleSelectedDetail?.preSkus.find((preSku) => preSku.assemblyWorkspace)
@ -363,6 +384,7 @@ export default async function IntakePage({
materialLibraryCategories={materialLibraryCategories}
materialLibraryDictionaries={materialLibraryDictionaries}
initialNonIntegratedProducts={initialNonIntegratedProducts}
initialNonIntegratedPageInfo={initialNonIntegratedPageInfo}
initialNodePermissionOverrides={nodePermissionSettings.overrides}
initialActiveSection={initialActiveSection}
initialActiveRawMaterialLibrary={initialActiveRawMaterialLibrary}
@ -390,6 +412,7 @@ export default async function IntakePage({
materialLibraryCategories={materialLibraryCategories}
materialLibraryDictionaries={materialLibraryDictionaries}
initialNonIntegratedProducts={initialNonIntegratedProducts}
initialNonIntegratedPageInfo={initialNonIntegratedPageInfo}
initialNodePermissionOverrides={nodePermissionSettings.overrides}
initialActiveSection={initialActiveSection}
initialActiveRawMaterialLibrary={initialActiveRawMaterialLibrary}

View File

@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import Button from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import {
CopyProductDialog,
ImagePreviewDialog,
@ -33,6 +32,7 @@ import {
import type { ProductDevelopmentKind, WorkspaceProductMode } from "@/app/intake/intakeWorkspaceTypes";
import {
buildNonIntegratedListStateStorageKey,
createDefaultNonIntegratedListQuery,
getUniqueSortedStrings,
normalizeNonIntegratedListQuery,
normalizePersistedPage,
@ -102,8 +102,39 @@ const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-95
const INTAKE_PRIMARY_BUTTON_CLASS =
"!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400";
function isDefaultInitialListRequest(params: {
activeQuickScope: NonIntegratedQuickScope;
appliedQuery: NonIntegratedListQuery;
currentPage: number;
effectiveProductKind: NonIntegratedProductKindFilter;
initialPageInfo?: NonIntegratedProductPageInfo | null;
initialProductKind: NonIntegratedProductKindFilter;
}) {
const {
activeQuickScope,
appliedQuery,
currentPage,
effectiveProductKind,
initialPageInfo,
initialProductKind,
} = params;
if (!initialPageInfo) return false;
if (activeQuickScope !== "all") return false;
if (currentPage !== initialPageInfo.page) return false;
if (initialPageInfo.pageSize !== PENDING_PAGE_SIZE) return false;
if (effectiveProductKind !== initialProductKind) return false;
const defaultQuery = createDefaultNonIntegratedListQuery(initialProductKind);
return Object.entries(defaultQuery).every(
([key, value]) =>
appliedQuery[key as keyof NonIntegratedListQuery] === value,
);
}
export function NonIntegratedPendingDetail({
initialProducts,
initialPageInfo,
canDeleteProducts,
currentUser,
productDevelopmentKind = "product",
@ -117,6 +148,7 @@ export function NonIntegratedPendingDetail({
onOpenSecondaryNewNonIntegratedProductWorkspace,
}: {
initialProducts: NonIntegratedProductRecord[];
initialPageInfo?: NonIntegratedProductPageInfo | null;
canDeleteProducts: boolean;
currentUser: NonIntegratedListPermissionUser;
productDevelopmentKind?: ProductDevelopmentKind;
@ -151,7 +183,7 @@ export function NonIntegratedPendingDetail({
String(normalizePersistedPage(initialPersistedListState?.currentPage)),
);
const [pageInfo, setPageInfo] =
useState<NonIntegratedProductPageInfo | null>(null);
useState<NonIntegratedProductPageInfo | null>(initialPageInfo ?? null);
const [isListLoading, setIsListLoading] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState("");
const [previewProduct, setPreviewProduct] = useState<{
@ -187,6 +219,7 @@ export function NonIntegratedPendingDetail({
useState(false);
const listStateStorageKeyRef = useRef(listStateStorageKey);
const skipNextListStatePersistRef = useRef(false);
const initialListRequestHandledRef = useRef(false);
const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test";
const effectiveProductKind = fixedProductKind ?? appliedQuery.productKind;
const hasCreateAction = Boolean(
@ -310,6 +343,22 @@ export function NonIntegratedPendingDetail({
useEffect(() => {
if (!shouldUseSharedNonIntegratedApi) return;
if (
!initialListRequestHandledRef.current &&
isDefaultInitialListRequest({
activeQuickScope,
appliedQuery,
currentPage,
effectiveProductKind,
initialPageInfo,
initialProductKind,
})
) {
initialListRequestHandledRef.current = true;
return;
}
initialListRequestHandledRef.current = true;
let cancelled = false;
setIsListLoading(true);
setListErrorMessage("");
@ -351,6 +400,8 @@ export function NonIntegratedPendingDetail({
appliedQuery,
currentPage,
effectiveProductKind,
initialPageInfo,
initialProductKind,
productDevelopmentKind,
shouldUseSharedNonIntegratedApi,
]);
@ -847,8 +898,8 @@ export function NonIntegratedPendingDetail({
(isSeriesDevelopmentList ? 1 : 0);
return (
<div className="flex h-full flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="border-b border-apple-separator px-4 py-3">
<div className="flex h-full min-w-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="min-w-0 border-b border-apple-separator px-4 py-3">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-1.5">
{quickScopes.map((scope) => (
@ -871,14 +922,14 @@ export function NonIntegratedPendingDetail({
))}
</div>
<div
className={`flex items-end gap-2 overflow-x-auto pb-1 md:grid md:overflow-visible md:pb-0 ${filterGridColumnClass}`}
className={`flex min-w-0 max-w-full items-end gap-2 overflow-x-auto pb-1 md:grid md:overflow-visible md:pb-0 ${filterGridColumnClass}`}
>
{filterFields.map((filter) => (
<label
key={filter.key}
className="min-w-[112px] space-y-1.5 text-xs text-apple-label-secondary max-md:space-y-0 md:min-w-0"
>
<span className="max-md:sr-only">{filter.label}</span>
<span className="max-md:hidden">{filter.label}</span>
{filter.options ? (
<select
aria-label={filter.ariaLabel}
@ -954,9 +1005,9 @@ export function NonIntegratedPendingDetail({
</div>
</div>
<div className="min-h-0 flex-1 p-4">
<div className="flex h-full min-h-0 flex-col rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="min-h-0 flex-1 overflow-auto pb-3">
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="min-h-0 min-w-0 max-w-full flex-1 overflow-x-auto overflow-y-auto pb-3">
<table
className={`w-full text-left text-xs ${
isSeriesDevelopmentList ? "min-w-[3140px]" : "min-w-[3000px]"

View File

@ -2,6 +2,7 @@
import type { ComponentProps, ReactNode } from "react";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import type { NonIntegratedProductPageInfo } from "@/app/intake/non-integrated/core/nonIntegratedApi";
import { NonIntegratedPendingDetail } from "@/app/intake/product-development/NonIntegratedPendingDetail";
import { ProductDevelopmentProgressDashboard } from "@/app/intake/product-development/progress-dashboard/ProductDevelopmentProgressDashboard";
import type {
@ -26,6 +27,11 @@ type ProductDevelopmentDomainProps = {
pendingEntryTab: IntakeEntryTab;
integratedRows: IntegratedPendingSeriesRow[];
nonIntegratedProducts: NonIntegratedProductRecord[];
initialNonIntegratedPageInfo?: {
product?: NonIntegratedProductPageInfo;
stock?: NonIntegratedProductPageInfo;
series?: NonIntegratedProductPageInfo;
};
canDeleteProducts: boolean;
canCreateProducts: boolean;
canUseDesignControlledProductData: boolean;
@ -53,6 +59,7 @@ export function ProductDevelopmentDomain({
pendingEntryTab,
integratedRows,
nonIntegratedProducts,
initialNonIntegratedPageInfo,
canDeleteProducts,
canCreateProducts,
canUseDesignControlledProductData,
@ -89,6 +96,7 @@ export function ProductDevelopmentDomain({
return (
<NonIntegratedPendingDetail
initialProducts={seriesDevelopmentProducts}
initialPageInfo={initialNonIntegratedPageInfo?.series}
canDeleteProducts={canDeleteProducts}
currentUser={currentUser}
productDevelopmentKind="series"
@ -109,6 +117,7 @@ export function ProductDevelopmentDomain({
return (
<NonIntegratedPendingDetail
initialProducts={stockDevelopmentProducts}
initialPageInfo={initialNonIntegratedPageInfo?.stock}
canDeleteProducts={canDeleteProducts}
currentUser={currentUser}
productDevelopmentKind="product"
@ -126,6 +135,7 @@ export function ProductDevelopmentDomain({
return (
<NonIntegratedPendingDetail
initialProducts={productDevelopmentProducts}
initialPageInfo={initialNonIntegratedPageInfo?.product}
canDeleteProducts={canDeleteProducts}
currentUser={currentUser}
productDevelopmentKind="product"

View File

@ -214,6 +214,7 @@ export function useIntakeClientViewModel(
pendingEntryTab,
skuList,
nonIntegratedProducts,
initialNonIntegratedPageInfo: props.initialNonIntegratedPageInfo,
canDeleteNonIntegratedProducts,
canCreateNonIntegratedProducts,
canUseDesignControlledProductData,

View File

@ -409,7 +409,9 @@ export default function HomeMobileView({
<DeleteConfirmDialog
isOpen={!!deletingProduct}
onClose={onCloseDeleteProduct}
onConfirm={onDeleteProduct}
onConfirm={async () => {
await onDeleteProduct()
}}
title="删除产品"
description={
deletingProduct
@ -431,7 +433,9 @@ export default function HomeMobileView({
<DeleteConfirmDialog
isOpen={!!deletingSku}
onClose={onCloseDeleteSKU}
onConfirm={onDeleteSKU}
onConfirm={async () => {
await onDeleteSKU()
}}
title="删除 SKU"
description={
deletingSku

View File

@ -46,6 +46,7 @@ type SpuQualityGroup = {
}
const CONCLUSION_OPTIONS = ['待检', '合格', '让步接收', '返工', '不合格'] as const
const INITIAL_SPU_GROUP_RENDER_LIMIT = 80
function createEmptyForm(inspectorName: string): QualityFormState {
return {
@ -256,6 +257,7 @@ export default function WarehouseQualityClient({
const [isSaving, setIsSaving] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [message, setMessage] = useState('')
const [showAllGroups, setShowAllGroups] = useState(false)
const filteredArchives = useMemo(() => {
const keyword = search.trim().toLowerCase()
@ -278,11 +280,32 @@ export default function WarehouseQualityClient({
selectedGroup?.skuArchives[0] ??
null
const visibleSpuGroups = useMemo(() => {
if (showAllGroups || spuGroups.length <= INITIAL_SPU_GROUP_RENDER_LIMIT) {
return spuGroups
}
const initialGroups = spuGroups.slice(0, INITIAL_SPU_GROUP_RENDER_LIMIT)
if (
selectedGroup &&
!initialGroups.some((group) => group.key === selectedGroup.key)
) {
return [...initialGroups, selectedGroup]
}
return initialGroups
}, [selectedGroup, showAllGroups, spuGroups])
const hiddenSpuGroupCount = Math.max(0, spuGroups.length - visibleSpuGroups.length)
const records = useMemo(
() => sortRecordsForDisplay(selectedArchive?.records ?? []),
[selectedArchive],
)
useEffect(() => {
setShowAllGroups(false)
}, [search])
useEffect(() => {
if (!selectedGroup && spuGroups[0]) {
setSelectedGroupKey(spuGroups[0].key)
@ -420,7 +443,7 @@ export default function WarehouseQualityClient({
</div>
) : (
<div className="space-y-1.5">
{spuGroups.map((group) => {
{visibleSpuGroups.map((group) => {
const isActive = group.key === selectedGroup?.key
return (
<button
@ -456,6 +479,15 @@ export default function WarehouseQualityClient({
</button>
)
})}
{hiddenSpuGroupCount > 0 ? (
<button
type="button"
onClick={() => setShowAllGroups(true)}
className="w-full rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-primary px-2.5 py-2 text-center text-[11px] text-apple-label-secondary transition hover:bg-apple-bg-secondary"
>
显示全部 {spuGroups.length} 个 SPU
</button>
) : null}
</div>
)}
</div>

View File

@ -0,0 +1,44 @@
const GROUPS = Array.from({ length: 9 }, (_, index) => index)
const ROWS = Array.from({ length: 6 }, (_, index) => index)
export default function WarehouseQualityLoading() {
return (
<div className="flex h-full min-h-0 gap-3 overflow-hidden bg-apple-bg-secondary p-4">
<aside className="flex w-[270px] shrink-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
<div className="space-y-2 border-b border-apple-separator p-3">
<div className="h-4 w-24 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-8 animate-pulse rounded-apple-lg bg-apple-bg-tertiary" />
</div>
<div className="space-y-2 p-2">
{GROUPS.map((group) => (
<div key={group} className="rounded-apple-lg border border-apple-separator p-2.5">
<div className="h-3 w-24 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="mt-2 h-3 w-36 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
</div>
))}
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
<div className="space-y-2 border-b border-apple-separator p-4">
<div className="h-5 w-36 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-3 w-56 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-hidden p-4">
<div className="h-24 animate-pulse rounded-apple-lg bg-apple-bg-tertiary" />
<div className="overflow-hidden rounded-apple-lg border border-apple-separator">
{ROWS.map((row) => (
<div key={row} className="grid grid-cols-6 gap-3 border-b border-apple-separator p-3 last:border-b-0">
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
<div className="h-3 animate-pulse rounded-apple-sm bg-apple-bg-tertiary" />
</div>
))}
</div>
</div>
</main>
</div>
)
}

View File

@ -57,6 +57,7 @@ interface AppTopNavProps {
export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
const pathname = usePathname() ?? '/'
const [mobileWorkbenchNavOpen, setMobileWorkbenchNavOpen] = useState(false)
const [pendingHref, setPendingHref] = useState<string | null>(null)
if (variant === 'workbench') {
const shouldRestrictNavigation =
@ -68,6 +69,21 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
const activeNavItem =
visibleNavItems.find((item) => matchesWorkbenchHref(pathname, item.href)) ??
visibleNavItems[0]
const visiblePendingHref =
pendingHref && !matchesWorkbenchHref(pathname, pendingHref)
? pendingHref
: null
const pendingNavItem = visiblePendingHref
? visibleNavItems.find((item) => item.href === visiblePendingHref)
: null
function handleNavIntent(href: string) {
if (matchesWorkbenchHref(pathname, href)) {
setPendingHref(null)
return
}
setPendingHref(href)
}
return (
<nav className="fixed left-0 right-0 top-0 z-50 flex h-16 items-center border-b border-black/[0.04] bg-white/[0.92] px-3 font-[Manrope,sans-serif] tracking-tight backdrop-blur-2xl sm:px-8">
@ -102,10 +118,15 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
<Link
key={item.href}
href={item.href}
onClick={() => setMobileWorkbenchNavOpen(false)}
onClick={() => {
handleNavIntent(item.href)
setMobileWorkbenchNavOpen(false)
}}
className={[
'flex min-h-[44px] items-center gap-2 rounded-[10px] px-3 text-[13px] font-semibold transition',
active
visiblePendingHref === item.href
? 'bg-black/[0.07] text-[#2d3335]'
: active
? 'bg-[#050505] text-white'
: 'text-[#2d3335] hover:bg-black/[0.04]',
].join(' ')}
@ -139,20 +160,30 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
{visibleNavItems.map((item) => {
const Icon = item.icon
const active = matchesWorkbenchHref(pathname, item.href)
const pending = visiblePendingHref === item.href
return (
<Link
key={item.href}
href={item.href}
onClick={() => handleNavIntent(item.href)}
className={[
'flex h-10 items-center gap-2 rounded-[6px] px-3 text-[13px] font-semibold transition',
active
'relative flex h-10 items-center gap-2 overflow-hidden rounded-[6px] px-3 text-[13px] font-semibold transition',
pending
? 'bg-black/[0.055] text-[#2d3335]'
: active
? 'bg-black/[0.04] text-[#2d3335] shadow-sm'
: 'text-[#5a6062]/70 hover:bg-black/[0.025] hover:text-[#2d3335]',
].join(' ')}
aria-busy={pending || undefined}
>
<Icon size={15} strokeWidth={1.8} />
<span>{item.label}</span>
{pending ? (
<span className="absolute inset-x-2 bottom-1 h-0.5 overflow-hidden rounded-full bg-black/[0.08]">
<span className="block h-full w-1/2 animate-[nav-progress_1s_ease-in-out_infinite] rounded-full bg-[#2d3335]/70" />
</span>
) : null}
</Link>
)
})}
@ -165,6 +196,11 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
</button>
<UserMenu user={user ?? null} />
</div>
{pendingNavItem ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] overflow-hidden bg-black/[0.04]">
<div className="h-full w-1/3 animate-[nav-progress_1s_ease-in-out_infinite] rounded-full bg-[#2d3335]/70" />
</div>
) : null}
</nav>
)
}

View File

@ -0,0 +1,178 @@
export const LEGACY_ASSET_SKU_GROUP_KEY = 'legacySkuGroup'
export const LEGACY_ASSET_SKU_GROUPS_KEY = 'legacySkuGroups'
export interface LegacyAssetSkuGroupSnapshot {
id: string
assetFamilyId: string | null
code: string
name: string
description: string | null
sortOrder: number
metadata: Record<string, unknown> | null
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function readNullableText(value: unknown): string | null {
const text = readText(value)
return text || null
}
function readSortOrder(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value)
if (typeof value === 'string' && value.trim()) {
const parsed = Number.parseInt(value.trim(), 10)
if (Number.isFinite(parsed)) return parsed
}
return fallback
}
export function buildLegacyAssetSkuGroupId(assetFamilyId: string, code: string): string {
const normalizedFamilyId = assetFamilyId.trim()
const normalizedCode = code.trim().toUpperCase()
return `legacy-sku-group:${normalizedFamilyId}:${normalizedCode}`
}
export function normalizeLegacyAssetSkuGroupSnapshot(
value: unknown,
fallback: {
assetFamilyId?: string | null
id?: string | null
code?: string | null
name?: string | null
sortOrder?: number
metadata?: Record<string, unknown> | null
} = {},
): LegacyAssetSkuGroupSnapshot | null {
const record = isPlainRecord(value) ? value : {}
const assetFamilyId = readText(record.assetFamilyId) || fallback.assetFamilyId?.trim() || null
const code = (readText(record.code) || fallback.code?.trim() || '').toUpperCase()
if (!code) return null
const id =
readText(record.id) ||
fallback.id?.trim() ||
(assetFamilyId ? buildLegacyAssetSkuGroupId(assetFamilyId, code) : `legacy-sku-group:${code}`)
const name = readText(record.name) || fallback.name?.trim() || code
const metadata = isPlainRecord(record.metadata)
? { ...record.metadata }
: fallback.metadata
? { ...fallback.metadata }
: null
return {
id,
assetFamilyId,
code,
name,
description: readNullableText(record.description),
sortOrder: readSortOrder(record.sortOrder, fallback.sortOrder ?? Number.MAX_SAFE_INTEGER),
metadata,
}
}
export function createLegacyAssetSkuGroupSnapshot(input: {
assetFamilyId: string
code: string
name?: string | null
description?: string | null
sortOrder?: number
metadata?: Record<string, unknown> | null
id?: string | null
}): LegacyAssetSkuGroupSnapshot {
const assetFamilyId = input.assetFamilyId.trim()
const code = input.code.trim().toUpperCase()
const id = input.id?.trim() || buildLegacyAssetSkuGroupId(assetFamilyId, code)
return {
id,
assetFamilyId,
code,
name: input.name?.trim() || code,
description: input.description?.trim() || null,
sortOrder: input.sortOrder ?? Number.MAX_SAFE_INTEGER,
metadata: input.metadata ? { ...input.metadata } : null,
}
}
export function readLegacyAssetSkuGroupFromBusinessData(
businessData: unknown,
fallback: Parameters<typeof normalizeLegacyAssetSkuGroupSnapshot>[1] = {},
): LegacyAssetSkuGroupSnapshot | null {
if (!isPlainRecord(businessData)) return null
const rawSnapshot = businessData[LEGACY_ASSET_SKU_GROUP_KEY]
if (!isPlainRecord(rawSnapshot)) return null
return normalizeLegacyAssetSkuGroupSnapshot(rawSnapshot, fallback)
}
export function readLegacyAssetSkuGroupsFromFamilyMetadata(
metadata: unknown,
assetFamilyId?: string | null,
): LegacyAssetSkuGroupSnapshot[] {
if (!isPlainRecord(metadata) || !Array.isArray(metadata[LEGACY_ASSET_SKU_GROUPS_KEY])) {
return []
}
return metadata[LEGACY_ASSET_SKU_GROUPS_KEY]
.map((item, index) =>
normalizeLegacyAssetSkuGroupSnapshot(item, {
assetFamilyId,
sortOrder: index,
}),
)
.filter((item): item is LegacyAssetSkuGroupSnapshot => item !== null)
}
export function serializeLegacyAssetSkuGroupSnapshot(
snapshot: LegacyAssetSkuGroupSnapshot,
): Record<string, unknown> {
return {
id: snapshot.id,
assetFamilyId: snapshot.assetFamilyId,
code: snapshot.code,
name: snapshot.name,
description: snapshot.description,
sortOrder: snapshot.sortOrder,
metadata: snapshot.metadata,
source: 'AssetSkuGroup.retired',
}
}
export function writeLegacyAssetSkuGroupToBusinessData(
businessData: unknown,
snapshot: LegacyAssetSkuGroupSnapshot,
): Record<string, unknown> {
return {
...(isPlainRecord(businessData) ? businessData : {}),
[LEGACY_ASSET_SKU_GROUP_KEY]: serializeLegacyAssetSkuGroupSnapshot(snapshot),
}
}
export function writeLegacyAssetSkuGroupsToFamilyMetadata(
metadata: unknown,
snapshots: LegacyAssetSkuGroupSnapshot[],
): Record<string, unknown> | null {
const next = { ...(isPlainRecord(metadata) ? metadata : {}) }
if (snapshots.length > 0) {
const serializedSnapshots = snapshots
.map((snapshot, index) => ({
...serializeLegacyAssetSkuGroupSnapshot(snapshot),
sortOrder: snapshot.sortOrder === Number.MAX_SAFE_INTEGER ? index : snapshot.sortOrder,
}))
.sort((left, right) => {
const leftSort = typeof left.sortOrder === 'number' ? left.sortOrder : Number.MAX_SAFE_INTEGER
const rightSort = typeof right.sortOrder === 'number' ? right.sortOrder : Number.MAX_SAFE_INTEGER
return leftSort - rightSort || String(left['code']).localeCompare(String(right['code']), 'zh-CN', { numeric: true })
})
next[LEGACY_ASSET_SKU_GROUPS_KEY] = serializedSnapshots
} else {
delete next[LEGACY_ASSET_SKU_GROUPS_KEY]
}
return Object.keys(next).length > 0 ? next : null
}

View File

@ -1,5 +1,6 @@
import { AssetFamily, AssetSeries, AssetSkuGroup, AssetVariant, Prisma, Substrate } from '@prisma/client'
import { AssetFamily, AssetSeries, AssetVariant, Prisma, Substrate } from '@prisma/client'
import { BaseRepository, FindManyOptions } from './base.repository'
import { prisma as prismaClient } from '@/lib/prisma'
import {
buildDeletedAssetVariantUniqueValue,
buildSoftDeletedAssetVariantBusinessData,
@ -9,6 +10,15 @@ import {
getRestorableAssetVariantIdentityKey,
} from '@/lib/open-product/asset-variant-soft-delete'
import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding'
import {
LegacyAssetSkuGroupSnapshot,
createLegacyAssetSkuGroupSnapshot,
isPlainRecord,
readLegacyAssetSkuGroupFromBusinessData,
readLegacyAssetSkuGroupsFromFamilyMetadata,
writeLegacyAssetSkuGroupToBusinessData,
writeLegacyAssetSkuGroupsToFamilyMetadata,
} from '@/lib/open-product/asset-sku-group-legacy'
export interface AssetCenterFinishRelation {
id: string
@ -76,7 +86,10 @@ export interface AssetSkuGroupFilter {
assetFamilyId?: string
}
export interface AssetSkuGroupWithRelations extends AssetSkuGroup {
export interface AssetSkuGroupWithRelations extends LegacyAssetSkuGroupSnapshot {
assetFamilyId: string
createdAt: Date
updatedAt: Date
assetFamily: {
id: string
code: string
@ -99,7 +112,8 @@ export interface AssetVariantWithRelations extends AssetVariant {
deletedAt: Date | null
assetSeries: { id: string; code: string; name: string } | null
}
skuGroup: {
skuGroupId?: string | null
skuGroup?: {
id: string
assetFamilyId: string
code: string
@ -181,17 +195,6 @@ export const assetVariantRelationInclude = {
assetSeries: { select: { id: true, code: true, name: true, metadata: true } },
},
},
skuGroup: {
select: {
id: true,
assetFamilyId: true,
code: true,
name: true,
description: true,
sortOrder: true,
metadata: true,
},
},
substrate: { select: { id: true, code: true, name: true } },
finish: {
select: {
@ -221,21 +224,6 @@ export const assetVariantRelationInclude = {
lastModifier: { select: { id: true, name: true } },
} satisfies Prisma.AssetVariantInclude
export const assetSkuGroupRelationInclude = {
assetFamily: {
select: {
id: true,
code: true,
name: true,
type: true,
assetSeriesId: true,
deletedAt: true,
assetSeries: { select: { id: true, code: true, name: true, metadata: true } },
},
},
_count: { select: { variants: true } },
} satisfies Prisma.AssetSkuGroupInclude
export const substrateRelationInclude = {
category: { select: { id: true, name: true } },
environmentGrade: { select: { id: true, name: true } },
@ -449,107 +437,306 @@ class AssetSeriesRepositoryClass extends BaseRepository<
}
}
class AssetSkuGroupRepositoryClass extends BaseRepository<
AssetSkuGroup,
Prisma.AssetSkuGroupCreateInput,
Prisma.AssetSkuGroupUpdateInput,
AssetSkuGroupFilter
> {
protected modelName = 'assetSkuGroup'
class AssetSkuGroupRepositoryClass {
private prisma = prismaClient
private toRecord(input: {
family: AssetFamilyWithRelations
snapshot: LegacyAssetSkuGroupSnapshot
variantCount?: number
}): AssetSkuGroupWithRelations {
return {
...input.snapshot,
assetFamilyId: input.family.id,
assetFamily: {
id: input.family.id,
code: input.family.code,
name: input.family.name,
type: input.family.type,
assetSeriesId: input.family.assetSeriesId,
deletedAt: input.family.deletedAt,
assetSeries: input.family.assetSeries
? {
id: input.family.assetSeries.id,
code: input.family.assetSeries.code,
name: input.family.assetSeries.name,
}
: null,
},
_count: { variants: input.variantCount ?? 0 },
createdAt: input.family.createdAt,
updatedAt: input.family.updatedAt,
}
}
private sortRecords(records: AssetSkuGroupWithRelations[]): AssetSkuGroupWithRelations[] {
return [...records].sort(
(left, right) =>
left.assetFamilyId.localeCompare(right.assetFamilyId) ||
left.sortOrder - right.sortOrder ||
left.code.localeCompare(right.code, 'zh-CN', { numeric: true }),
)
}
private getVariantSnapshot(record: AssetVariantWithRelations): LegacyAssetSkuGroupSnapshot | null {
return readLegacyAssetSkuGroupFromBusinessData(record.businessData, {
assetFamilyId: record.assetFamilyId,
code: record.code,
name: record.name,
id: `variant:${record.id}`,
sortOrder: Number.MAX_SAFE_INTEGER,
})
}
async findByIdWithRelations(id: string): Promise<AssetSkuGroupWithRelations | null> {
return this.prisma.assetSkuGroup.findUnique({
where: { id },
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations | null>
const families = await this.prisma.assetFamily.findMany({
where: { deletedAt: null },
include: assetFamilyRelationInclude,
}) as AssetFamilyWithRelations[]
for (const family of families) {
const snapshot = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id)
.find((group) => group.id === id) ?? null
if (!snapshot) continue
const variantCount = await this.prisma.assetVariant.count({
where: {
assetFamilyId: family.id,
deletedAt: null,
businessData: {
path: ['legacySkuGroup', 'id'],
equals: id,
} as Prisma.JsonFilter,
},
})
return this.toRecord({ family, snapshot, variantCount })
}
const variant = await this.prisma.assetVariant.findFirst({
where: {
deletedAt: null,
businessData: {
path: ['legacySkuGroup', 'id'],
equals: id,
} as Prisma.JsonFilter,
assetFamily: { is: { deletedAt: null } },
},
include: assetVariantRelationInclude,
}) as AssetVariantWithRelations | null
if (!variant) return null
const snapshot = this.getVariantSnapshot(variant)
return snapshot
? this.toRecord({ family: variant.assetFamily as unknown as AssetFamilyWithRelations, snapshot, variantCount: 1 })
: null
}
async findByFamilyAndCode(
assetFamilyId: string,
code: string,
): Promise<AssetSkuGroupWithRelations | null> {
return this.prisma.assetSkuGroup.findUnique({
where: {
assetFamilyId_code: {
assetFamilyId,
code: normalizeCoreIdentity(code),
},
},
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations | null>
const normalizedCode = normalizeCoreIdentity(code)
if (!assetFamilyId || !normalizedCode) return null
const groups = await this.findManyByFamilyIds([assetFamilyId])
return groups.find((group) => normalizeCoreIdentity(group.code) === normalizedCode) ?? null
}
async findManyByFamilyIds(assetFamilyIds: string[]): Promise<AssetSkuGroupWithRelations[]> {
if (assetFamilyIds.length === 0) return []
const ids = Array.from(new Set(assetFamilyIds.map((id) => id.trim()).filter(Boolean)))
if (ids.length === 0) return []
return this.prisma.assetSkuGroup.findMany({
where: {
assetFamilyId: { in: assetFamilyIds },
assetFamily: { is: { deletedAt: null } },
},
orderBy: [
{ assetFamilyId: 'asc' },
{ sortOrder: 'asc' },
{ code: 'asc' },
],
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations[]>
const [families, variants] = await Promise.all([
this.prisma.assetFamily.findMany({
where: { id: { in: ids }, deletedAt: null },
include: assetFamilyRelationInclude,
}) as Promise<AssetFamilyWithRelations[]>,
this.prisma.assetVariant.findMany({
where: {
assetFamilyId: { in: ids },
deletedAt: null,
assetFamily: { is: { deletedAt: null } },
},
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations[]>,
])
const familyById = new Map(families.map((family) => [family.id, family]))
const variantCountByGroupId = new Map<string, number>()
const snapshotById = new Map<string, LegacyAssetSkuGroupSnapshot>()
variants.forEach((variant) => {
const snapshot = this.getVariantSnapshot(variant)
if (!snapshot) return
snapshotById.set(snapshot.id, snapshot)
variantCountByGroupId.set(snapshot.id, (variantCountByGroupId.get(snapshot.id) ?? 0) + 1)
})
families.forEach((family) => {
readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id).forEach((snapshot) => {
snapshotById.set(snapshot.id, snapshot)
})
})
const records = Array.from(snapshotById.values())
.map((snapshot) => {
const familyId = snapshot.assetFamilyId ?? ''
const family = familyById.get(familyId)
return family
? this.toRecord({
family,
snapshot,
variantCount: variantCountByGroupId.get(snapshot.id) ?? 0,
})
: null
})
.filter((item): item is AssetSkuGroupWithRelations => item !== null)
return this.sortRecords(records)
}
async findManyBySeriesCode(seriesCode: string): Promise<AssetSkuGroupWithRelations[]> {
const normalizedSeriesCode = normalizeCoreIdentity(seriesCode)
if (!normalizedSeriesCode) return []
return this.prisma.assetSkuGroup.findMany({
const families = await this.prisma.assetFamily.findMany({
where: {
assetFamily: {
is: {
deletedAt: null,
assetSeries: {
is: { code: normalizedSeriesCode },
},
},
},
deletedAt: null,
assetSeries: { is: { code: normalizedSeriesCode } },
},
orderBy: [
{ assetFamilyId: 'asc' },
{ sortOrder: 'asc' },
{ code: 'asc' },
],
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations[]>
select: { id: true },
})
return this.findManyByFamilyIds(families.map((family) => family.id))
}
async createWithRelations(data: Prisma.AssetSkuGroupCreateInput): Promise<AssetSkuGroupWithRelations> {
return this.prisma.assetSkuGroup.create({
data,
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations>
async createWithRelations(data: {
assetFamily: { connect: { id: string } }
code: string
name: string
description?: string | null
sortOrder?: number
metadata?: Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | null
}): Promise<AssetSkuGroupWithRelations> {
const familyId = data.assetFamily.connect.id
const family = await assetFamilyRepository.findWithRelationsById(familyId)
if (!family) {
throw new Error('Asset family not found')
}
const snapshot = createLegacyAssetSkuGroupSnapshot({
assetFamilyId: family.id,
code: data.code,
name: data.name,
description: data.description,
sortOrder: data.sortOrder,
metadata: isPlainRecord(data.metadata) ? data.metadata : null,
})
const existing = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id)
.filter((group) => group.id !== snapshot.id)
const metadata = writeLegacyAssetSkuGroupsToFamilyMetadata(family.metadata, [...existing, snapshot])
const updatedFamily = await this.prisma.assetFamily.update({
where: { id: family.id },
data: { metadata: metadata === null ? Prisma.JsonNull : metadata as Prisma.InputJsonValue },
include: assetFamilyRelationInclude,
}) as AssetFamilyWithRelations
return this.toRecord({ family: updatedFamily, snapshot })
}
async updateWithRelations(
id: string,
data: Prisma.AssetSkuGroupUpdateInput,
data: {
code?: string
name?: string
description?: string | null
sortOrder?: number
metadata?: Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | null
},
): Promise<AssetSkuGroupWithRelations> {
return this.prisma.assetSkuGroup.update({
where: { id },
data,
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations>
const existing = await this.findByIdWithRelations(id)
if (!existing) {
throw new Error('Asset SKU group not found')
}
const family = await assetFamilyRepository.findWithRelationsById(existing.assetFamilyId)
if (!family) {
throw new Error('Asset family not found')
}
const nextSnapshot = createLegacyAssetSkuGroupSnapshot({
assetFamilyId: family.id,
id: existing.id,
code: data.code ?? existing.code,
name: data.name ?? existing.name,
description: data.description === undefined ? existing.description : data.description,
sortOrder: data.sortOrder ?? existing.sortOrder,
metadata: data.metadata === undefined
? existing.metadata
: isPlainRecord(data.metadata)
? data.metadata
: null,
})
const groups = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id)
.filter((group) => group.id !== id)
const metadata = writeLegacyAssetSkuGroupsToFamilyMetadata(family.metadata, [...groups, nextSnapshot])
const variants = await this.prisma.assetVariant.findMany({
where: {
assetFamilyId: family.id,
deletedAt: null,
businessData: {
path: ['legacySkuGroup', 'id'],
equals: id,
} as Prisma.JsonFilter,
},
select: {
id: true,
businessData: true,
},
})
const updatedFamily = await this.prisma.$transaction(async (tx) => {
const nextFamily = await tx.assetFamily.update({
where: { id: family.id },
data: { metadata: metadata === null ? Prisma.JsonNull : metadata as Prisma.InputJsonValue },
include: assetFamilyRelationInclude,
})
await Promise.all(
variants.map((variant) =>
tx.assetVariant.update({
where: { id: variant.id },
data: {
businessData: writeLegacyAssetSkuGroupToBusinessData(
variant.businessData,
nextSnapshot,
) as Prisma.InputJsonValue,
},
}),
),
)
return nextFamily
}) as AssetFamilyWithRelations
return this.toRecord({ family: updatedFamily, snapshot: nextSnapshot })
}
async deleteById(id: string): Promise<void> {
await this.prisma.assetSkuGroup.delete({ where: { id } })
}
const existing = await this.findByIdWithRelations(id)
if (!existing) return
const family = await assetFamilyRepository.findWithRelationsById(existing.assetFamilyId)
if (!family) return
protected buildWhereClause(filter?: AssetSkuGroupFilter): Prisma.AssetSkuGroupWhereInput {
return {
assetFamilyId: filter?.assetFamilyId,
assetFamily: { is: { deletedAt: null } },
}
const groups = readLegacyAssetSkuGroupsFromFamilyMetadata(family.metadata, family.id)
.filter((group) => group.id !== id)
const metadata = writeLegacyAssetSkuGroupsToFamilyMetadata(family.metadata, groups)
await this.prisma.assetFamily.update({
where: { id: family.id },
data: { metadata: metadata === null ? Prisma.JsonNull : metadata as Prisma.InputJsonValue },
})
}
}
class AssetVariantRepositoryClass extends BaseRepository<
AssetVariant,
Prisma.AssetVariantCreateInput,
@ -558,6 +745,42 @@ class AssetVariantRepositoryClass extends BaseRepository<
> {
protected modelName = 'assetVariant'
private attachLegacySkuGroup(record: AssetVariantWithRelations): AssetVariantWithRelations {
const snapshot = readLegacyAssetSkuGroupFromBusinessData(record.businessData, {
assetFamilyId: record.assetFamilyId,
code: record.code,
name: record.name,
id: `variant:${record.id}`,
sortOrder: Number.MAX_SAFE_INTEGER,
})
if (!snapshot) {
return {
...record,
skuGroupId: null,
skuGroup: null,
}
}
return {
...record,
skuGroupId: snapshot.id,
skuGroup: {
id: snapshot.id,
assetFamilyId: snapshot.assetFamilyId ?? record.assetFamilyId,
code: snapshot.code,
name: snapshot.name,
description: snapshot.description,
sortOrder: snapshot.sortOrder,
metadata: snapshot.metadata as Prisma.JsonValue | null,
},
}
}
private attachLegacySkuGroups(records: AssetVariantWithRelations[]): AssetVariantWithRelations[] {
return records.map((record) => this.attachLegacySkuGroup(record))
}
async findByCode(code: string): Promise<AssetVariant | null> {
return this.prisma.assetVariant.findFirst({
where: {
@ -668,16 +891,17 @@ class AssetVariantRepositoryClass extends BaseRepository<
}
async findByIdWithRelations(id: string): Promise<AssetVariantWithRelations | null> {
return this.prisma.assetVariant.findUnique({
const record = await this.prisma.assetVariant.findUnique({
where: { id },
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations | null>
}) as AssetVariantWithRelations | null
return record ? this.attachLegacySkuGroup(record) : null
}
async findByIdsWithRelations(ids: string[]): Promise<AssetVariantWithRelations[]> {
if (ids.length === 0) return []
return this.prisma.assetVariant.findMany({
const records = await this.prisma.assetVariant.findMany({
where: {
id: { in: ids },
deletedAt: null,
@ -685,19 +909,21 @@ class AssetVariantRepositoryClass extends BaseRepository<
},
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations[]>
}) as AssetVariantWithRelations[]
return this.attachLegacySkuGroups(records)
}
async findByIdsWithRelationsIncludingDeleted(ids: string[]): Promise<AssetVariantWithRelations[]> {
if (ids.length === 0) return []
return this.prisma.assetVariant.findMany({
const records = await this.prisma.assetVariant.findMany({
where: {
id: { in: ids },
},
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations[]>
}) as AssetVariantWithRelations[]
return this.attachLegacySkuGroups(records)
}
async findManyByFamilyIds(
@ -706,7 +932,7 @@ class AssetVariantRepositoryClass extends BaseRepository<
): Promise<AssetVariantWithRelations[]> {
if (assetFamilyIds.length === 0) return []
return this.prisma.assetVariant.findMany({
const records = await this.prisma.assetVariant.findMany({
where: {
assetFamilyId: { in: assetFamilyIds },
status: filter?.status,
@ -715,13 +941,14 @@ class AssetVariantRepositoryClass extends BaseRepository<
},
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations[]>
}) as AssetVariantWithRelations[]
return this.attachLegacySkuGroups(records)
}
async findAvailableByFamilyIds(assetFamilyIds: string[]): Promise<AssetVariantWithRelations[]> {
if (assetFamilyIds.length === 0) return []
return this.prisma.assetVariant.findMany({
const records = await this.prisma.assetVariant.findMany({
where: {
assetFamilyId: { in: assetFamilyIds },
status: 'AVAILABLE',
@ -730,14 +957,15 @@ class AssetVariantRepositoryClass extends BaseRepository<
},
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations[]>
}) as AssetVariantWithRelations[]
return this.attachLegacySkuGroups(records)
}
async findActiveBySeriesCode(seriesCode: string): Promise<AssetVariantWithRelations[]> {
const normalizedSeriesCode = seriesCode.trim().toUpperCase()
if (!normalizedSeriesCode) return []
return this.prisma.assetVariant.findMany({
const records = await this.prisma.assetVariant.findMany({
where: {
deletedAt: null,
assetFamily: {
@ -751,25 +979,28 @@ class AssetVariantRepositoryClass extends BaseRepository<
},
orderBy: [{ code: 'asc' }, { updatedAt: 'asc' }],
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations[]>
}) as AssetVariantWithRelations[]
return this.attachLegacySkuGroups(records)
}
async createWithRelations(data: Prisma.AssetVariantCreateInput): Promise<AssetVariantWithRelations> {
return this.prisma.assetVariant.create({
const record = await this.prisma.assetVariant.create({
data,
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations>
}) as AssetVariantWithRelations
return this.attachLegacySkuGroup(record)
}
async updateWithRelations(
id: string,
data: Prisma.AssetVariantUpdateInput,
): Promise<AssetVariantWithRelations> {
return this.prisma.assetVariant.update({
const record = await this.prisma.assetVariant.update({
where: { id },
data,
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations>
}) as AssetVariantWithRelations
return this.attachLegacySkuGroup(record)
}
async deleteById(id: string): Promise<void> {
@ -777,14 +1008,15 @@ class AssetVariantRepositoryClass extends BaseRepository<
}
async restoreById(id: string): Promise<AssetVariantWithRelations> {
return this.prisma.assetVariant.update({
const record = await this.prisma.assetVariant.update({
where: { id },
data: {
deletedAt: null,
deleter: { disconnect: true },
},
include: assetVariantRelationInclude,
}) as Promise<AssetVariantWithRelations>
}) as AssetVariantWithRelations
return this.attachLegacySkuGroup(record)
}
protected buildWhereClause(filter?: AssetVariantFilter): Prisma.AssetVariantWhereInput {

View File

@ -150,6 +150,27 @@ class PreSkuRepositoryClass extends BaseRepository<
}) as Promise<PreSkuWithRelations[]>
}
async findByDraftGroupIds(draftGroupIds: string[]): Promise<PreSkuWithRelations[]> {
if (draftGroupIds.length === 0) return []
return this.prisma.preSku.findMany({
where: {
draftGroupId: {
in: draftGroupIds,
},
},
orderBy: [{ draftGroupId: 'asc' }, { sequence: 'asc' }],
include: {
basicInfo: true,
packagingPlan: {
include: {
packagedItems: true,
},
},
},
}) as Promise<PreSkuWithRelations[]>
}
async findActiveByAssetSeriesId(
assetSeriesId: string,
excludeDraftGroupId?: string,

View File

@ -116,7 +116,6 @@ export {
assetFamilyRepository,
assetSeriesRepository,
assetSkuGroupRepository,
assetSkuGroupRelationInclude,
assetVariantRelationInclude,
assetVariantRepository,
substrateRelationInclude,

View File

@ -866,15 +866,17 @@ describe('assetCenterService', () => {
lossFactor: 1,
})
expect(mockedAssetSkuGroupRepository.createWithRelations).toHaveBeenCalledWith(
expect.objectContaining({
code: 'DBZ-SPJ03',
}),
)
expect(mockedAssetSkuGroupRepository.createWithRelations).not.toHaveBeenCalled()
expect(mockedAssetVariantRepository.createWithRelations).toHaveBeenCalledWith(
expect.objectContaining({
code: 'DBZ-SPJ03模型1',
skuGroup: { connect: { id: 'sku-group-DBZ-SPJ03' } },
businessData: expect.objectContaining({
legacySkuGroup: expect.objectContaining({
id: 'legacy-sku-group:family-board-top:DBZ-SPJ03',
assetFamilyId: 'family-board-top',
code: 'DBZ-SPJ03',
}),
}),
}),
)
expect(variant.code).toBe('DBZ-SPJ03模型1')
@ -986,7 +988,13 @@ describe('assetCenterService', () => {
expect(mockedAssetVariantRepository.createWithRelations).toHaveBeenCalledWith(
expect.objectContaining({
code: 'DBZ-SPJ02模型3',
skuGroup: { connect: { id: 'sku-group-02' } },
businessData: expect.objectContaining({
legacySkuGroup: expect.objectContaining({
id: 'sku-group-02',
assetFamilyId: 'family-board-top',
code: 'DBZ-SPJ02',
}),
}),
}),
)
expect(variant.code).toBe('DBZ-SPJ02模型3')
@ -2422,7 +2430,7 @@ describe('assetCenterService', () => {
1,
'variant-board-top-uv',
expect.objectContaining({
businessData: {
businessData: expect.objectContaining({
renderConfig: {
uv: {
version: 1,
@ -2435,7 +2443,7 @@ describe('assetCenterService', () => {
},
},
},
},
}),
}),
)
expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith(
@ -2537,11 +2545,11 @@ describe('assetCenterService', () => {
expect(mockedAssetVariantRepository.updateWithRelations).toHaveBeenCalledWith(
'variant-board-top-uv-fast',
expect.objectContaining({
businessData: {
businessData: expect.objectContaining({
renderConfig: {
uv: uvConfig,
},
},
}),
}),
)
expect(mockedAssetVariantThumbnailService.generateForVariant).not.toHaveBeenCalled()

View File

@ -51,6 +51,7 @@ jest.mock('../../repositories', () => ({
createManyForDraftGroup: jest.fn(),
findActiveByAssetSeriesId: jest.fn(),
findByDraftGroupId: jest.fn(),
findByDraftGroupIds: jest.fn(),
findByIdWithRelations: jest.fn(),
updateDisplaySummary: jest.fn(),
upsertBasicInfo: jest.fn(),
@ -376,6 +377,7 @@ describe('draftGroupService', () => {
mockedPreSkuRepository.findByBasicInfoField.mockResolvedValue([])
mockedPreSkuRepository.findByBasicInfoFields.mockResolvedValue([])
mockedPreSkuRepository.findActiveByAssetSeriesId.mockResolvedValue([])
mockedPreSkuRepository.findByDraftGroupIds.mockResolvedValue([])
mockedIntakeNonIntegratedProductRepository.findActiveSeriesProductByNames.mockResolvedValue(null)
mockedSkuRepository.findBySkuCode.mockResolvedValue(null)
mockedSkuRepository.findByAssetProfileBarcode.mockResolvedValue(null)
@ -1469,6 +1471,84 @@ describe('draftGroupService', () => {
)
})
it('批量读取草稿组准 SKU 时也会把资产货号对应条码补到部件快照', async () => {
mockedPreSkuRepository.findByDraftGroupIds.mockResolvedValue([
{
id: 'pre-sku-batch-1',
draftGroupId: 'draft-group-1',
sequence: 1,
defaultName: '单板桌',
customName: null,
status: 'READY_FOR_INTAKE',
sourceCandidateKey: 'candidate-1',
familySelection: {},
variantSelection: {
slots: [
{
slotKey: 'desktop',
role: 'desktop',
assetFamilyId: 'family-desktop-a',
variantId: 'variant-desktop-a-white',
code: 'DBZ-SPJ01',
name: '桌面板',
assetType: 'BOARD',
dimensions: { length: 1400, width: 600, thickness: 25 },
weightKg: 14.5,
costPrice: 210,
modelUrl: '/models/top-a-1200.glb',
mountPoints: [],
assemblyBasis: {},
},
],
hardwareSelections: [],
},
recommendedHardware: [],
previewNodes: [],
displaySummary: null,
bomPreview: [],
weightPreview: null,
componentCodes: ['DBZ-SPJ01'],
installStepPreview: [],
notes: null,
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
packagingPlan: null,
},
] as never)
mockedAssetVariantRepository.findByIdsWithRelations.mockResolvedValue([
{
id: 'variant-desktop-a-white',
barcode: 'fallback-barcode',
skuGroup: {
metadata: {
skuContentFields: {
correspondingBarcode: 'DNZ038401N1440V01',
},
},
},
},
] as never)
const result = await draftGroupService.listPreSkusByDraftGroupIds([
'draft-group-1',
'draft-group-1',
' ',
])
expect(mockedPreSkuRepository.findByDraftGroupIds).toHaveBeenCalledWith([
'draft-group-1',
])
expect(mockedAssetVariantRepository.findByIdsWithRelations).toHaveBeenCalledWith([
'variant-desktop-a-white',
])
expect(result[0].variantSelection.slots[0]).toEqual(
expect.objectContaining({
barcode: 'FALLBACK-BARCODE',
correspondingBarcode: 'DNZ038401N1440V01',
}),
)
})
it('读取草稿组详情时优先返回 displaySummary 中的装配状态和 warnings', async () => {
mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([
{

View File

@ -141,6 +141,12 @@ import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import {
createLegacyAssetSkuGroupSnapshot,
readLegacyAssetSkuGroupFromBusinessData,
writeLegacyAssetSkuGroupToBusinessData,
type LegacyAssetSkuGroupSnapshot,
} from '@/lib/open-product/asset-sku-group-legacy'
interface VariantThumbnailRenderOptions {
renderBaseUrl?: string | null
@ -2111,6 +2117,13 @@ function toMaterialLikeDTO(record: AssetCenterFinishRelation): MaterialLikeDTO {
function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
const businessData = isRecord(record.businessData) ? record.businessData : null
const legacySkuGroup = readLegacyAssetSkuGroupFromBusinessData(businessData, {
assetFamilyId: record.assetFamilyId,
code: record.code,
name: record.name,
id: `variant:${record.id}`,
sortOrder: Number.MAX_SAFE_INTEGER,
})
const uvConfig = readAssetVariantUvConfig(businessData)
const assemblyBasis = recoverAssemblyBasis(
isRecord(record.assemblyBasis) ? record.assemblyBasis : null,
@ -2122,11 +2135,11 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
assetFamilyId: record.assetFamilyId,
assetFamilyCode: record.assetFamily.code,
assetFamilyName: record.assetFamily.name,
skuGroupId: record.skuGroupId,
skuGroupCode: record.skuGroup?.code ?? null,
skuGroupName: record.skuGroup?.name ?? null,
skuGroupSortOrder: record.skuGroup?.sortOrder ?? null,
skuGroupMetadata: isRecord(record.skuGroup?.metadata) ? record.skuGroup.metadata : null,
skuGroupId: legacySkuGroup?.id ?? record.skuGroupId ?? null,
skuGroupCode: legacySkuGroup?.code ?? record.skuGroup?.code ?? null,
skuGroupName: legacySkuGroup?.name ?? record.skuGroup?.name ?? null,
skuGroupSortOrder: legacySkuGroup?.sortOrder ?? record.skuGroup?.sortOrder ?? null,
skuGroupMetadata: legacySkuGroup?.metadata ?? (isRecord(record.skuGroup?.metadata) ? record.skuGroup.metadata : null),
assetType: record.assetType,
code: record.code,
name: record.name,
@ -2409,7 +2422,7 @@ async function ensureAssetSkuGroupForVariant(input: {
skuGroupId?: string | null
defaultCode: string
defaultName: string
}): Promise<AssetSkuGroupWithRelations> {
}): Promise<LegacyAssetSkuGroupSnapshot> {
const requestedId = input.skuGroupId?.trim()
if (requestedId) {
const group = await assetSkuGroupRepository.findByIdWithRelations(requestedId)
@ -2419,26 +2432,42 @@ async function ensureAssetSkuGroupForVariant(input: {
if (group.assetFamilyId !== input.assetFamilyId) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组必须属于当前型号')
}
return group
return createLegacyAssetSkuGroupSnapshot({
assetFamilyId: group.assetFamilyId,
id: group.id,
code: group.code,
name: group.name,
description: group.description,
sortOrder: group.sortOrder,
metadata: group.metadata,
})
}
const code = normalizeAssetSkuGroupCode(input.defaultCode)
const existing = await assetSkuGroupRepository.findByFamilyAndCode(input.assetFamilyId, code)
if (existing) return existing
if (existing) {
return createLegacyAssetSkuGroupSnapshot({
assetFamilyId: existing.assetFamilyId,
id: existing.id,
code: existing.code,
name: existing.name,
description: existing.description,
sortOrder: existing.sortOrder,
metadata: existing.metadata,
})
}
const maxSortOrder = await prisma.assetSkuGroup.aggregate({
where: { assetFamilyId: input.assetFamilyId },
_max: { sortOrder: true },
})
const existingGroups = await assetSkuGroupRepository.findManyByFamilyIds([input.assetFamilyId])
const maxSortOrder = Math.max(0, ...existingGroups.map((group) => group.sortOrder))
return assetSkuGroupRepository.createWithRelations({
assetFamily: { connect: { id: input.assetFamilyId } },
return createLegacyAssetSkuGroupSnapshot({
assetFamilyId: input.assetFamilyId,
code,
name: input.defaultName.trim() || code,
sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1,
sortOrder: maxSortOrder + 1,
metadata: {
source: 'variant-default',
} as Prisma.InputJsonValue,
},
})
}
@ -2452,7 +2481,15 @@ async function ensureVariantBarcodeAvailable(
if (
existing &&
existing.id !== currentId &&
(!allowedSkuGroupId || existing.skuGroupId !== allowedSkuGroupId)
(
!allowedSkuGroupId ||
readLegacyAssetSkuGroupFromBusinessData(existing.businessData, {
assetFamilyId: existing.assetFamilyId,
code: existing.code,
name: existing.name,
id: `variant:${existing.id}`,
})?.id !== allowedSkuGroupId
)
) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '条码已存在')
}
@ -2912,7 +2949,7 @@ class AssetCenterService {
private async generateNextVariantModelCodeForSkuGroup(input: {
assetFamilyId: string
skuGroup: AssetSkuGroupWithRelations
skuGroup: Pick<LegacyAssetSkuGroupSnapshot, 'id' | 'code'>
currentVariantId?: string | null
}): Promise<string> {
const variants = await assetVariantRepository.findManyByFamilyIds([input.assetFamilyId])
@ -3736,11 +3773,8 @@ class AssetCenterService {
})
const sortOrder = input.sortOrder ?? await (async () => {
const maxSortOrder = await prisma.assetSkuGroup.aggregate({
where: { assetFamilyId: family.id },
_max: { sortOrder: true },
})
return (maxSortOrder._max.sortOrder ?? 0) + 1
const existingGroups = await assetSkuGroupRepository.findManyByFamilyIds([family.id])
return Math.max(0, ...existingGroups.map((group) => group.sortOrder)) + 1
})()
const created = await assetSkuGroupRepository.createWithRelations({
@ -3806,8 +3840,11 @@ class AssetCenterService {
}
const activeVariantCount = await prisma.assetVariant.count({
where: {
skuGroupId: id,
deletedAt: null,
businessData: {
path: ['legacySkuGroup', 'id'],
equals: id,
} as Prisma.JsonFilter,
},
})
if (activeVariantCount > 0) {
@ -4455,7 +4492,7 @@ class AssetCenterService {
}
const requestedCode = input.code?.trim().toUpperCase() ?? ''
let skuGroup: AssetSkuGroupWithRelations
let skuGroup: LegacyAssetSkuGroupSnapshot
let code: string
if (family.type === 'PACKAGING') {
code = requestedCode || await this.generateNextSkuGroupCodeForFamily(family)
@ -4554,11 +4591,15 @@ class AssetCenterService {
dimensions: resolvedDimensions,
lossFactor: resolvedLossFactor,
})
const resolvedBusinessData = resolveBusinessDataForVariantCreate({
const rawResolvedBusinessData = resolveBusinessDataForVariantCreate({
businessData: input.businessData,
uvConfig: input.uvConfig,
inheritedBusinessData,
})
const resolvedBusinessData = writeLegacyAssetSkuGroupToBusinessData(
rawResolvedBusinessData ?? {},
skuGroup,
)
const resolvedAssemblyBasis = resolveInheritedJsonRecord(
input.assemblyBasis,
inheritedVariant?.assemblyBasis,
@ -4582,7 +4623,6 @@ class AssetCenterService {
const created = await assetVariantRepository.createWithRelations({
assetFamily: { connect: { id: input.assetFamilyId } },
skuGroup: { connect: { id: skuGroup.id } },
code,
name,
identityKey,
@ -4729,9 +4769,14 @@ class AssetCenterService {
input.skuGroupId !== undefined
? input.skuGroupId
: nextFamilyId === existing.assetFamilyId
? existing.skuGroupId
? readLegacyAssetSkuGroupFromBusinessData(existing.businessData, {
assetFamilyId: existing.assetFamilyId,
code: existing.code,
name: existing.name,
id: `variant:${existing.id}`,
})?.id ?? null
: null
let nextSkuGroup: AssetSkuGroupWithRelations
let nextSkuGroup: LegacyAssetSkuGroupSnapshot
let nextCode: string
if (nextFamily.type === 'PACKAGING') {
@ -4835,6 +4880,10 @@ class AssetCenterService {
uvConfig: input.uvConfig,
})
: undefined
const persistedNextBusinessData = writeLegacyAssetSkuGroupToBusinessData(
nextBusinessData ?? existingBusinessData ?? {},
nextSkuGroup,
)
const existingUvConfig = readAssetVariantUvConfig(existingBusinessData)
const nextUvConfig =
input.uvConfig !== undefined
@ -4931,7 +4980,6 @@ class AssetCenterService {
input.assetFamilyId === undefined
? undefined
: { connect: { id: input.assetFamilyId } },
skuGroup: { connect: { id: nextSkuGroup.id } },
code: nextCode,
name: nextName,
identityKey,
@ -4976,7 +5024,7 @@ class AssetCenterService {
namingFields:
input.namingFields !== undefined ? toJsonValue(input.namingFields) : undefined,
businessData:
nextBusinessData !== undefined ? toJsonValue(nextBusinessData) : undefined,
toJsonValue(persistedNextBusinessData),
weightKg: input.weightKg === undefined ? undefined : input.weightKg,
costPrice: input.costPrice === undefined ? undefined : input.costPrice,
lossFactor: input.lossFactor === undefined ? undefined : input.lossFactor,

View File

@ -555,6 +555,17 @@ class DraftGroupService {
}
}
async listPreSkusByDraftGroupIds(draftGroupIds: string[]): Promise<DraftGroupPreSkuDTO[]> {
const uniqueDraftGroupIds = Array.from(
new Set(draftGroupIds.map((id) => id.trim()).filter(Boolean)),
)
if (uniqueDraftGroupIds.length === 0) return []
const preSkus = await preSkuRepository.findByDraftGroupIds(uniqueDraftGroupIds)
const preSkuDTOs = preSkus.map((item) => toDraftGroupPreSkuDTO(item))
return this.enrichSelectedVariantBarcodes(preSkuDTOs)
}
private async enrichSelectedVariantBarcodes(
preSkus: DraftGroupPreSkuDTO[],
): Promise<DraftGroupPreSkuDTO[]> {