merge: sync asset center sku group fix with mainline

This commit is contained in:
WuYongJie 2026-06-15 08:42:08 +08:00
commit a611640927
70 changed files with 1770 additions and 559 deletions

View File

@ -0,0 +1,175 @@
-- Restore the AssetSkuGroup schema expected by the current asset-center code.
-- The previous retirement migration preserved group data in JSONB legacy fields;
-- this migration restores the relational table/column and backfills from that data.
CREATE TABLE IF NOT EXISTS "AssetSkuGroup" (
"id" TEXT NOT NULL,
"assetFamilyId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssetSkuGroup_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AssetVariant"
ADD COLUMN IF NOT EXISTS "skuGroupId" TEXT;
WITH legacy_family_groups AS (
SELECT
family.id AS "assetFamilyId",
NULLIF(BTRIM(group_item ->> 'code'), '') AS code,
NULLIF(BTRIM(group_item ->> 'name'), '') AS name,
NULLIF(BTRIM(group_item ->> 'description'), '') AS description,
CASE
WHEN NULLIF(BTRIM(group_item ->> 'sortOrder'), '') ~ '^-?[0-9]+$'
THEN NULLIF(BTRIM(group_item ->> 'sortOrder'), '')::INTEGER
ELSE 0
END AS "sortOrder",
CASE
WHEN jsonb_typeof(group_item -> 'metadata') = 'object' THEN group_item -> 'metadata'
ELSE NULL
END AS metadata,
0 AS source_rank
FROM "AssetFamily" AS family
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(family.metadata -> 'legacySkuGroups') = 'array'
THEN family.metadata -> 'legacySkuGroups'
ELSE '[]'::jsonb
END
) AS group_item
),
legacy_variant_groups AS (
SELECT
variant."assetFamilyId",
NULLIF(BTRIM(variant."businessData" -> 'legacySkuGroup' ->> 'code'), '') AS code,
NULLIF(BTRIM(variant."businessData" -> 'legacySkuGroup' ->> 'name'), '') AS name,
NULLIF(BTRIM(variant."businessData" -> 'legacySkuGroup' ->> 'description'), '') AS description,
CASE
WHEN NULLIF(BTRIM(variant."businessData" -> 'legacySkuGroup' ->> 'sortOrder'), '') ~ '^-?[0-9]+$'
THEN NULLIF(BTRIM(variant."businessData" -> 'legacySkuGroup' ->> 'sortOrder'), '')::INTEGER
ELSE 0
END AS "sortOrder",
CASE
WHEN jsonb_typeof(variant."businessData" -> 'legacySkuGroup' -> 'metadata') = 'object'
THEN variant."businessData" -> 'legacySkuGroup' -> 'metadata'
ELSE NULL
END AS metadata,
1 AS source_rank
FROM "AssetVariant" AS variant
WHERE jsonb_typeof(variant."businessData" -> 'legacySkuGroup') = 'object'
),
variant_code_groups AS (
SELECT
variant."assetFamilyId",
NULLIF(BTRIM(variant.code), '') AS code,
(ARRAY_AGG(COALESCE(NULLIF(BTRIM(variant.name), ''), variant.code) ORDER BY variant."createdAt" ASC, variant.id ASC))[1] AS name,
NULL::TEXT AS description,
ROW_NUMBER() OVER (
PARTITION BY variant."assetFamilyId"
ORDER BY MIN(variant."createdAt") ASC, NULLIF(BTRIM(variant.code), '') ASC
)::INTEGER AS "sortOrder",
jsonb_build_object(
'source',
'variant-backfill-after-sku-group-restore',
'sourceAssetVariantIds',
jsonb_agg(variant.id ORDER BY variant."createdAt" ASC, variant.id ASC)
) AS metadata,
2 AS source_rank
FROM "AssetVariant" AS variant
WHERE NULLIF(BTRIM(variant.code), '') IS NOT NULL
GROUP BY variant."assetFamilyId", NULLIF(BTRIM(variant.code), '')
),
candidate_groups AS (
SELECT * FROM legacy_family_groups WHERE code IS NOT NULL
UNION ALL
SELECT * FROM legacy_variant_groups WHERE code IS NOT NULL
UNION ALL
SELECT * FROM variant_code_groups WHERE code IS NOT NULL
),
deduped_groups AS (
SELECT DISTINCT ON ("assetFamilyId", code)
CONCAT('asg_', MD5("assetFamilyId" || ':' || code)) AS id,
"assetFamilyId",
code,
COALESCE(NULLIF(name, ''), code) AS name,
description,
"sortOrder",
metadata
FROM candidate_groups
ORDER BY "assetFamilyId", code, source_rank ASC, "sortOrder" ASC, name ASC
)
INSERT INTO "AssetSkuGroup" (
"id",
"assetFamilyId",
"code",
"name",
"description",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
id,
"assetFamilyId",
code,
name,
description,
"sortOrder",
metadata,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM deduped_groups
ON CONFLICT ("id") DO UPDATE
SET
"assetFamilyId" = EXCLUDED."assetFamilyId",
code = EXCLUDED.code,
name = EXCLUDED.name,
description = EXCLUDED.description,
"sortOrder" = EXCLUDED."sortOrder",
metadata = EXCLUDED.metadata,
"updatedAt" = CURRENT_TIMESTAMP;
UPDATE "AssetVariant" AS variant
SET "skuGroupId" = sku_group.id
FROM "AssetSkuGroup" AS sku_group
WHERE variant."skuGroupId" IS NULL
AND sku_group."assetFamilyId" = variant."assetFamilyId"
AND sku_group.code = COALESCE(
NULLIF(BTRIM(variant."businessData" -> 'legacySkuGroup' ->> 'code'), ''),
NULLIF(BTRIM(variant.code), '')
);
CREATE UNIQUE INDEX IF NOT EXISTS "AssetSkuGroup_assetFamilyId_code_key"
ON "AssetSkuGroup"("assetFamilyId", "code");
CREATE INDEX IF NOT EXISTS "AssetSkuGroup_assetFamilyId_idx" ON "AssetSkuGroup"("assetFamilyId");
CREATE INDEX IF NOT EXISTS "AssetSkuGroup_code_idx" ON "AssetSkuGroup"("code");
CREATE INDEX IF NOT EXISTS "AssetSkuGroup_sortOrder_idx" ON "AssetSkuGroup"("sortOrder");
CREATE INDEX IF NOT EXISTS "AssetVariant_skuGroupId_idx" ON "AssetVariant"("skuGroupId");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetSkuGroup_assetFamilyId_fkey'
) THEN
ALTER TABLE "AssetSkuGroup"
ADD CONSTRAINT "AssetSkuGroup_assetFamilyId_fkey"
FOREIGN KEY ("assetFamilyId") REFERENCES "AssetFamily"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetVariant_skuGroupId_fkey'
) THEN
ALTER TABLE "AssetVariant"
ADD CONSTRAINT "AssetVariant_skuGroupId_fkey"
FOREIGN KEY ("skuGroupId") REFERENCES "AssetSkuGroup"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;

View File

@ -2,6 +2,10 @@
import { useState } from 'react'
import Link from 'next/link'
import type {
AssetFamilyDTO as SharedAssetFamilyDTO,
AssetSeriesDTO,
} from '@/server/services'
import {
Layers3,
Shapes,
@ -19,19 +23,7 @@ import {
type AssetTypeFilter = 'BOARD' | 'COMPONENT' | 'HARDWARE' | 'PACKAGING'
interface AssetSeriesDTO {
id: string
name: string
code?: string
}
interface AssetFamilyDTO {
id: string
name: string
code?: string
type: AssetTypeFilter
assetSeriesId: string | null
metadata?: unknown
type AssetFamilyDTO = SharedAssetFamilyDTO & {
_count?: { variants: number; skuGroups: number }
skuGroups?: Array<{ id: string }>
variants?: Array<{ id: string }>
@ -44,11 +36,6 @@ interface VariantDTO {
modelUrl?: string | null
}
interface SeriesComponentDetailDTO {
id: string
assetSeriesId: string
}
const TYPE_TABS: { type: AssetTypeFilter; label: string; Icon: typeof Layers3 }[] = [
{ type: 'BOARD', label: '板材', Icon: Layers3 },
{ type: 'COMPONENT', label: '组件', Icon: Shapes },
@ -65,7 +52,7 @@ function getVisibleFamiliesForTab(families: AssetFamilyDTO[], activeTab: AssetTy
/* ─── Component ─── */
export interface AssetCenterMobileViewProps {
families: AssetFamilyDTO[]
families: SharedAssetFamilyDTO[]
assetSeries: AssetSeriesDTO[]
selectedSeriesId: string | null
onSeriesChange: (id: string) => void

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

@ -63,12 +63,12 @@ export interface HomeMobileViewProps {
/* 删除产品 */
deletingProduct: ProductWithSkus | null
onDeleteProduct: () => Promise<void>
onDeleteProduct: () => void | Promise<void>
onCloseDeleteProduct: () => void
/* 删除 SKU */
deletingSku: { product: ProductWithSkus; sku: { skuCode: string } } | null
onDeleteSKU: () => Promise<void>
onDeleteSKU: () => void | Promise<void>
onCloseDeleteSKU: () => void
/* 添加 SKU */
@ -406,7 +406,9 @@ export default function HomeMobileView({
<DeleteConfirmDialog
isOpen={!!deletingProduct}
onClose={onCloseDeleteProduct}
onConfirm={onDeleteProduct}
onConfirm={async () => {
await onDeleteProduct()
}}
title="删除产品"
description={
deletingProduct
@ -428,7 +430,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

@ -185,7 +185,7 @@ export function PlatformSplitLayout({
>
{leftContent}
</aside>
<div className="min-w-0 flex-1 overflow-y-auto rounded-xl p-3 max-lg:p-2"
<div className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto rounded-xl p-3 max-lg:p-2"
style={{
backgroundColor: 'var(--color-surface-container-lowest)',
boxShadow: '0px 4px 20px rgba(45,51,53,0.06)',
@ -195,7 +195,7 @@ export function PlatformSplitLayout({
</div>
</>
) : (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-y-auto rounded-xl p-3 max-lg:p-2"
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto rounded-xl p-3 max-lg:p-2"
style={{
backgroundColor: 'var(--color-surface-container-lowest)',
boxShadow: '0px 4px 20px rgba(45,51,53,0.06)',

View File

@ -1,6 +1,12 @@
import { render } from '@testing-library/react'
import { PlatformSplitLayout } from '../PlatformSplitLayout'
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
}),
}))
describe('PlatformSplitLayout', () => {
it('默认使用统一的右栏宽度变量', () => {
const { container } = render(
@ -16,6 +22,20 @@ describe('PlatformSplitLayout', () => {
expect(rightPanel).toHaveClass('w-[var(--layout-right-panel-width)]')
expect(rightPanel).toHaveClass('max-w-[var(--layout-right-panel-max-width)]')
})
it('收住页面级横向溢出', () => {
const { container } = render(
<PlatformSplitLayout
leftContent={<div className="w-[200vw]">wide content</div>}
rightContent={null}
/>,
)
const contentPanel = container.querySelector('section > div')
expect(contentPanel).toHaveClass('overflow-x-hidden')
})
it('允许页面级覆盖右栏宽度', () => {
const { container } = render(
<PlatformSplitLayout

View File

@ -0,0 +1,60 @@
import { act, render, screen } from '@testing-library/react'
import { renderToString } from 'react-dom/server'
import { useMediaQuery } from './useMediaQuery'
function MediaQueryProbe({ query = '(max-width: 767px)' }: { query?: string }) {
const matches = useMediaQuery(query)
return <output data-testid="media-query">{String(matches)}</output>
}
function installMatchMedia(initialMatches: boolean) {
let matches = initialMatches
const listeners = new Set<(event: MediaQueryListEvent) => void>()
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
if (event === 'change') listeners.add(listener)
}),
removeEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
if (event === 'change') listeners.delete(listener)
}),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
}))
return {
setMatches(nextMatches: boolean) {
matches = nextMatches
act(() => {
listeners.forEach((listener) =>
listener({ matches: nextMatches, media: '(max-width: 767px)' } as MediaQueryListEvent),
)
})
},
}
}
describe('useMediaQuery', () => {
it('uses a stable server snapshot for hydration', () => {
installMatchMedia(true)
expect(renderToString(<MediaQueryProbe />)).toContain('false')
})
it('subscribes to client media query changes', () => {
const media = installMatchMedia(false)
render(<MediaQueryProbe />)
expect(screen.getByTestId('media-query')).toHaveTextContent('false')
media.setMatches(true)
expect(screen.getByTestId('media-query')).toHaveTextContent('true')
})
})

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useSyncExternalStore } from 'react'
/**
* 媒体查询 Hook
@ -13,30 +13,32 @@ import { useCallback, useEffect, useState } from 'react'
* const isDesktop = useMediaQuery('(min-width: 1024px)')
*/
export function useMediaQuery(query: string): boolean {
const getMatches = useCallback((): boolean => {
const subscribe = useCallback(
(onStoreChange: () => void) => {
if (typeof window === 'undefined') return () => {}
const mql = window.matchMedia(query)
mql.addEventListener('change', onStoreChange)
return () => {
mql.removeEventListener('change', onStoreChange)
}
},
[query],
)
const getSnapshot = useCallback(() => {
if (typeof window === 'undefined') return false
return window.matchMedia(query).matches
}, [query])
const [matches, setMatches] = useState<boolean>(getMatches)
const getServerSnapshot = useCallback(() => false, [])
useEffect(() => {
if (typeof window === 'undefined') return
const mql = window.matchMedia(query)
// 立即同步一次
setMatches(mql.matches)
function handleChange(event: MediaQueryListEvent) {
setMatches(event.matches)
}
mql.addEventListener('change', handleChange)
return () => mql.removeEventListener('change', handleChange)
}, [query])
return matches
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
)
}
/**

View File

@ -1,6 +1,10 @@
export type StorageProvider = 's3' | 'qiniu'
const PRIVATE_OBJECT_ROUTE_PATH = '/api/storage/object'
const LEGACY_OBJECT_PATH_PREFIXES = [
'/materials/textures/',
'/materials/thumbnails/',
] as const
function trimToNull(value: string | undefined | null): string | null {
const normalized = value?.trim()
@ -102,6 +106,37 @@ function readPrivateObjectAccessKey(value: string): string | null {
}
}
function readLegacyObjectPathKey(value: string): string | null {
const normalized = trimToNull(value)
if (!normalized) return null
let parsed: URL
try {
parsed = new URL(normalized, 'http://storage.local')
} catch {
return null
}
const pathname = parsed.pathname.startsWith('/')
? parsed.pathname
: `/${parsed.pathname}`
if (!LEGACY_OBJECT_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return null
}
const rawKey = pathname.replace(/^\/+/, '')
if (!rawKey) {
return null
}
try {
return normalizeObjectKey(rawKey)
} catch {
return null
}
}
function buildS3PublicBaseUrl(): string | null {
const configured =
trimToNull(process.env.S3_PUBLIC_DOMAIN) ||
@ -236,7 +271,9 @@ export function buildStableObjectAccessUrl(key: string): string {
export function readObjectStorageKeyFromUrl(value: string): string | null {
const objectKey =
readPrivateObjectAccessKey(value) ?? readPublicObjectAccessKey(value)
readPrivateObjectAccessKey(value) ??
readPublicObjectAccessKey(value) ??
readLegacyObjectPathKey(value)
if (!objectKey) {
return null
}

View File

@ -136,6 +136,11 @@ describe('storage object provider', () => {
'https://cdn.example.com/furniture/prod-assets/models/table.glb',
),
).toBe('prod-assets/models/table.glb')
expect(
storage.normalizeObjectStorageUrlForPersistence(
'https://cdn.example.com/furniture/prod-assets/models/table.glb',
),
).toBe('/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb')
})
it('rejects unsafe object keys at the storage boundary', async () => {
@ -305,6 +310,38 @@ describe('storage object provider', () => {
'/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb',
),
).toContain('https://qnycdn.example.com/prod-assets/models/table.glb?e=')
expect(
storage.normalizeObjectStorageUrlForPersistence(
'https://qnycdn.example.com/prod-assets/models/table.glb?e=123&token=abc',
),
).toBe('/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb')
})
it('returns direct HTTPS Qiniu read URLs for legacy material routes', async () => {
process.env.STORAGE_PROVIDER = 'qiniu'
process.env.QINIU_ACCESS_KEY = 'qiniu-access'
process.env.QINIU_SECRET_KEY = 'qiniu-secret'
process.env.QINIU_BUCKET = 'qiniu-bucket'
process.env.QINIU_PUBLIC_DOMAIN = 'https://qnycdn.example.com'
process.env.QINIU_FILE_PATH = 'prod-assets'
process.env.QINIU_PRIVATE_BUCKET = 'true'
const storage = await import('./storage')
expect(
storage.resolveObjectStorageUrlForClientAccess(
'/materials/textures/material-library/oak.webp',
),
).toContain(
'https://qnycdn.example.com/prod-assets/materials/textures/material-library/oak.webp?e=',
)
expect(
storage.resolveObjectStorageUrlForClientAccess(
'/materials/thumbnails/material-library/oak.webp',
),
).toContain(
'https://qnycdn.example.com/prod-assets/materials/thumbnails/material-library/oak.webp?e=',
)
})
it('keeps protected non-integrated assets on the stable route even when direct HTTPS reads are available', async () => {

View File

@ -118,6 +118,11 @@ export function buildStableObjectAccessUrl(key: string): string {
return buildStableObjectAccessUrlImpl(key)
}
export function normalizeObjectStorageUrlForPersistence(url: string): string {
const objectKey = readObjectStorageKeyFromUrl(url)
return objectKey ? buildStableObjectAccessUrl(objectKey) : url
}
function getPublicObjectUrlBaseCandidates(): string[] {
return []
}

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

@ -5,7 +5,10 @@ import {
} from '@/lib/open-product/asset-variant-soft-delete'
import { PACKAGING_PLACEHOLDER_MODEL_URL } from '@/lib/packaging-materials/constants'
import { prisma } from '@/lib/prisma'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import { assetCenterService } from '../asset-center.service'
import { assetVariantThumbnailService } from '../asset-variant-thumbnail.service'
import { intakeNonIntegratedProductService } from '../intake-non-integrated-product.service'
@ -133,6 +136,7 @@ jest.mock('@/lib/storage', () => {
const actual = jest.requireActual('@/lib/storage')
return {
...actual,
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) => value),
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => value),
}
})
@ -157,6 +161,9 @@ describe('assetCenterService', () => {
const mockedResolveObjectStorageUrlForClientAccess = jest.mocked(
resolveObjectStorageUrlForClientAccess,
)
const mockedNormalizeObjectStorageUrlForPersistence = jest.mocked(
normalizeObjectStorageUrlForPersistence,
)
const defaultAssetSeries = {
id: 'series-single-board-desk',
code: 'DBZ',
@ -203,7 +210,20 @@ describe('assetCenterService', () => {
beforeEach(() => {
jest.clearAllMocks()
const normalized = {
'https://qnycdn.example.com/dev/models/new.glb':
'/api/storage/object?key=dev%2Fmodels%2Fnew.glb',
'https://qnycdn.example.com/dev/models/board-available.glb':
'/api/storage/object?key=dev%2Fmodels%2Fboard-available.glb',
'https://qnycdn.example.com/dev/thumbnails/new.png':
'/api/storage/object?key=dev%2Fthumbnails%2Fnew.png',
'https://qnycdn.example.com/dev/thumbnails/generated.png':
'/api/storage/object?key=dev%2Fthumbnails%2Fgenerated.png',
} as const
mockedResolveObjectStorageUrlForClientAccess.mockImplementation((value: string) => {
return normalized[value as keyof typeof normalized] ?? value
})
mockedNormalizeObjectStorageUrlForPersistence.mockImplementation((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/models/new.glb':
'/api/storage/object?key=dev%2Fmodels%2Fnew.glb',

View File

@ -1,4 +1,7 @@
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import {
commonComponentCategoryRepository,
commonComponentRepository,
@ -33,6 +36,7 @@ jest.mock('@/lib/storage', () => {
const actual = jest.requireActual('@/lib/storage')
return {
...actual,
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) => value),
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => value),
}
})
@ -43,10 +47,22 @@ describe('commonComponentService', () => {
const mockedResolveObjectStorageUrlForClientAccess = jest.mocked(
resolveObjectStorageUrlForClientAccess,
)
const mockedNormalizeObjectStorageUrlForPersistence = jest.mocked(
normalizeObjectStorageUrlForPersistence,
)
beforeEach(() => {
jest.clearAllMocks()
const normalized = {
'https://qnycdn.example.com/dev/components/drawer.glb':
'/api/storage/object?key=dev%2Fcomponents%2Fdrawer.glb',
'https://qnycdn.example.com/dev/components/drawer.png':
'/api/storage/object?key=dev%2Fcomponents%2Fdrawer.png',
} as const
mockedResolveObjectStorageUrlForClientAccess.mockImplementation((value: string) => {
return normalized[value as keyof typeof normalized] ?? value
})
mockedNormalizeObjectStorageUrlForPersistence.mockImplementation((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/components/drawer.glb':
'/api/storage/object?key=dev%2Fcomponents%2Fdrawer.glb',

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

@ -5,6 +5,20 @@ jest.mock('../../repositories', () => ({
}))
jest.mock('@/lib/storage', () => ({
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/hardware/existing.glb':
'/api/storage/object?key=dev%2Fhardware%2Fexisting.glb',
'https://qnycdn.example.com/dev/hardware/existing.png':
'/api/storage/object?key=dev%2Fhardware%2Fexisting.png',
'https://qnycdn.example.com/dev/hardware/new.glb':
'/api/storage/object?key=dev%2Fhardware%2Fnew.glb',
'https://qnycdn.example.com/dev/hardware/new.png':
'/api/storage/object?key=dev%2Fhardware%2Fnew.png',
} as const
return normalized[value as keyof typeof normalized] ?? value
}),
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/hardware/existing.glb':

View File

@ -1,4 +1,7 @@
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import {
hardwareCategoryRepository,
hardwareRepository,
@ -46,6 +49,7 @@ jest.mock('@/lib/storage', () => {
const actual = jest.requireActual('@/lib/storage')
return {
...actual,
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) => value),
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => value),
}
})
@ -56,10 +60,22 @@ describe('hardwareService', () => {
const mockedResolveObjectStorageUrlForClientAccess = jest.mocked(
resolveObjectStorageUrlForClientAccess,
)
const mockedNormalizeObjectStorageUrlForPersistence = jest.mocked(
normalizeObjectStorageUrlForPersistence,
)
beforeEach(() => {
jest.clearAllMocks()
const normalized = {
'https://qnycdn.example.com/dev/hardware/hinge.glb':
'/api/storage/object?key=dev%2Fhardware%2Fhinge.glb',
'https://qnycdn.example.com/dev/hardware/hinge.png':
'/api/storage/object?key=dev%2Fhardware%2Fhinge.png',
} as const
mockedResolveObjectStorageUrlForClientAccess.mockImplementation((value: string) => {
return normalized[value as keyof typeof normalized] ?? value
})
mockedNormalizeObjectStorageUrlForPersistence.mockImplementation((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/hardware/hinge.glb':
'/api/storage/object?key=dev%2Fhardware%2Fhinge.glb',

View File

@ -29,13 +29,21 @@ jest.mock('@/lib/3d/material-pbr-presets', () => ({
jest.mock('@/lib/material-asset-path', () => ({
normalizeMaterialAssetPath: jest.fn((value) => value),
isDefaultMaterialThumbnailPath: jest.fn((value) =>
value === '/materials/thumbnails/default.jpg' || value === '/materials/thumbnails/default.svg'
isDefaultMaterialThumbnailPath: jest.fn(
(value) =>
value === '/materials/thumbnails/default.jpg' ||
value === '/materials/thumbnails/default.svg',
),
}))
jest.mock('@/lib/material-thumbnail', () => ({
buildMaterialThumbnailPreviewUrl: jest.fn(() => '/api/materials/thumbnail?name=test'),
buildMaterialThumbnailPreviewUrl: jest.fn(
() => '/api/materials/thumbnail?name=test',
),
}))
jest.mock('@/lib/storage', () => ({
resolveObjectStorageUrlForClientAccess: jest.fn((value) => value),
}))
import {
@ -43,6 +51,7 @@ import {
materialRepository,
} from '@/server/repositories/material.repository'
import { normalizeMaterialAssetPath } from '@/lib/material-asset-path'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import { materialService } from '../material.service'
const mockMaterialRepository = materialRepository as unknown as {
@ -69,15 +78,44 @@ const mockMaterialCategoryRepository = materialCategoryRepository as unknown as
delete: jest.Mock
}
const mockNormalizeMaterialAssetPath = normalizeMaterialAssetPath as jest.Mock
const mockNormalizeMaterialAssetPath =
normalizeMaterialAssetPath as jest.Mock
const mockResolveObjectStorageUrlForClientAccess =
resolveObjectStorageUrlForClientAccess as jest.Mock
function buildMaterialRecord(overrides: Record<string, unknown> = {}) {
return {
id: 'finish-oak',
code: 'HS-0010',
name: 'oak finish',
categoryId: 'category-wood',
category: { id: 'category-wood', name: 'wood', icon: null },
thumbnailUrl: null,
textureUrl: null,
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
...overrides,
}
}
describe('materialService', () => {
beforeEach(() => {
jest.clearAllMocks()
mockNormalizeMaterialAssetPath.mockImplementation((value) => value)
mockResolveObjectStorageUrlForClientAccess.mockImplementation(
(value) => value,
)
mockMaterialCategoryRepository.findById.mockResolvedValue({
id: 'category-wood',
name: '木纹',
name: 'wood',
icon: null,
})
mockMaterialCategoryRepository.getMaterialCount.mockResolvedValue(0)
@ -87,50 +125,28 @@ describe('materialService', () => {
mockMaterialRepository.getUsageCount.mockResolvedValue(0)
})
it('创建花色时会自动生成 HS 编码', async () => {
it('creates a material with the next generated code', async () => {
mockMaterialRepository.findLatestGeneratedCode.mockResolvedValue('HS-0009')
mockMaterialRepository.findByCode.mockResolvedValue(null)
mockMaterialRepository.create.mockResolvedValue({
id: 'finish-oak',
code: 'HS-0010',
name: '北美橡木',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '木纹', icon: null },
thumbnailUrl: '/materials/thumbnails/default.jpg',
textureUrl: null,
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue({
id: 'finish-oak',
code: 'HS-0010',
name: '北美橡木',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '木纹', icon: null },
thumbnailUrl: '/materials/thumbnails/default.jpg',
textureUrl: null,
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.create.mockResolvedValue(
buildMaterialRecord({
id: 'finish-oak',
code: 'HS-0010',
name: 'north-american oak',
thumbnailUrl: '/materials/thumbnails/default.jpg',
}),
)
mockMaterialRepository.findByIdWithCategory.mockResolvedValue(
buildMaterialRecord({
id: 'finish-oak',
code: 'HS-0010',
name: 'north-american oak',
thumbnailUrl: '/materials/thumbnails/default.jpg',
}),
)
const result = await materialService.create({
name: '北美橡木',
name: 'north-american oak',
categoryId: 'category-wood',
color: '#ffffff',
metalness: 0,
@ -140,7 +156,7 @@ describe('materialService', () => {
expect(mockMaterialRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
name: '北美橡木',
name: 'north-american oak',
code: 'HS-0010',
thumbnailUrl: null,
}),
@ -150,49 +166,25 @@ describe('materialService', () => {
expect(result.previewUrl).toBe('/api/materials/thumbnail?name=test')
})
it('创建花色时会按统一编码池归一化人工编码', async () => {
it('normalizes manual material code before create', async () => {
mockMaterialRepository.findByCode.mockResolvedValue(null)
mockMaterialRepository.create.mockResolvedValue({
id: 'finish-custom',
code: 'HS-0020',
name: '胡桃木',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '木纹', icon: null },
thumbnailUrl: null,
textureUrl: null,
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue({
id: 'finish-custom',
code: 'HS-0020',
name: '胡桃木',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '木纹', icon: null },
thumbnailUrl: null,
textureUrl: null,
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.create.mockResolvedValue(
buildMaterialRecord({
id: 'finish-custom',
code: 'HS-0020',
name: 'oak custom',
}),
)
mockMaterialRepository.findByIdWithCategory.mockResolvedValue(
buildMaterialRecord({
id: 'finish-custom',
code: 'HS-0020',
name: 'oak custom',
}),
)
await materialService.create({
name: ' 胡桃木 ',
name: ' oak custom ',
code: ' hs-0020 ',
categoryId: 'category-wood',
color: '#ffffff',
@ -202,94 +194,104 @@ describe('materialService', () => {
})
expect(mockMaterialRepository.findByCode).toHaveBeenCalledWith('HS-0020')
expect(mockMaterialRepository.nameExistsInCategory).toHaveBeenCalledWith('胡桃木', 'category-wood')
expect(mockMaterialRepository.nameExistsInCategory).toHaveBeenCalledWith(
'oak custom',
'category-wood',
)
expect(mockMaterialRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
code: 'HS-0020',
name: '胡桃木',
name: 'oak custom',
}),
)
})
it('历史默认占位缩略图会在 DTO 层归一化为 null', async () => {
mockMaterialRepository.findByIdWithCategory.mockResolvedValue({
id: 'finish-stone',
code: 'HS-0014',
name: '雪山白石',
categoryId: 'category-stone',
category: { id: 'category-stone', name: '石纹', icon: null },
thumbnailUrl: '/materials/thumbnails/default.jpg',
textureUrl: '/materials/pbr/polyhaven/marble_01/marble_01_diff_1k.jpg',
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.22,
opacity: 1,
textureRepeat: 1.4,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
it('maps the legacy default thumbnail placeholder to null in DTO', async () => {
mockMaterialRepository.findByIdWithCategory.mockResolvedValue(
buildMaterialRecord({
id: 'finish-stone',
code: 'HS-0014',
name: 'snow mountain stone',
categoryId: 'category-stone',
category: { id: 'category-stone', name: 'stone', icon: null },
thumbnailUrl: '/materials/thumbnails/default.jpg',
textureUrl:
'/materials/pbr/polyhaven/marble_01/marble_01_diff_1k.jpg',
roughness: 0.22,
textureRepeat: 1.4,
}),
)
const result = await materialService.findById('finish-stone')
expect(result.thumbnailUrl).toBeNull()
expect(result.textureUrl).toBe('/materials/pbr/polyhaven/marble_01/marble_01_diff_1k.jpg')
expect(result.textureUrl).toBe(
'/materials/pbr/polyhaven/marble_01/marble_01_diff_1k.jpg',
)
expect(result.previewUrl).toBe('/api/materials/thumbnail?name=test')
})
it('删除材质时会拦截系统预设', async () => {
it('blocks deleting preset materials', async () => {
mockMaterialRepository.findById.mockResolvedValue({
id: 'preset-finish',
isPreset: true,
isActive: true,
})
await expect(materialService.delete('preset-finish')).rejects.toThrow('系统预设材质不允许删除')
await expect(materialService.delete('preset-finish')).rejects.toThrow()
expect(mockMaterialRepository.deactivate).not.toHaveBeenCalled()
})
it('删除分类时只按启用材质拦截并停用分类', async () => {
it('deactivates a category when it has no active materials', async () => {
await materialService.deleteCategory('category-wood')
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenCalledWith('category-wood', true)
expect(mockMaterialCategoryRepository.deactivate).toHaveBeenCalledWith('category-wood')
expect(
mockMaterialCategoryRepository.getMaterialCount,
).toHaveBeenCalledWith('category-wood', true)
expect(mockMaterialCategoryRepository.deactivate).toHaveBeenCalledWith(
'category-wood',
)
expect(mockMaterialCategoryRepository.delete).not.toHaveBeenCalled()
})
it('彻底删除分类时会继续拦截历史隐藏材质', async () => {
it('blocks hard delete when historical hidden materials still exist', async () => {
mockMaterialCategoryRepository.getMaterialCount
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(1)
await expect(materialService.deleteCategory('category-wood', true)).rejects.toThrow(
'该分类下还有 1 个历史材质,无法彻底删除',
)
await expect(
materialService.deleteCategory('category-wood', true),
).rejects.toThrow()
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenNthCalledWith(1, 'category-wood', true)
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenNthCalledWith(2, 'category-wood', false)
expect(
mockMaterialCategoryRepository.getMaterialCount,
).toHaveBeenNthCalledWith(1, 'category-wood', true)
expect(
mockMaterialCategoryRepository.getMaterialCount,
).toHaveBeenNthCalledWith(2, 'category-wood', false)
expect(mockMaterialCategoryRepository.delete).not.toHaveBeenCalled()
expect(mockMaterialCategoryRepository.deactivate).not.toHaveBeenCalled()
})
it('创建分类时会归一化名称图标并拒绝负排序', async () => {
it('normalizes category data and rejects negative sort order', async () => {
mockMaterialCategoryRepository.create.mockResolvedValue({
id: 'category-metal',
name: '金属',
name: 'metal',
icon: null,
sortOrder: 3,
isActive: true,
})
await materialService.createCategory({
name: ' 金属 ',
name: ' metal ',
icon: ' ',
})
expect(mockMaterialCategoryRepository.nameExists).toHaveBeenCalledWith('金属')
expect(mockMaterialCategoryRepository.nameExists).toHaveBeenCalledWith(
'metal',
)
expect(mockMaterialCategoryRepository.create).toHaveBeenCalledWith({
name: '金属',
name: 'metal',
icon: null,
sortOrder: 3,
isActive: true,
@ -297,56 +299,48 @@ describe('materialService', () => {
await expect(
materialService.createCategory({
name: '玻璃',
name: 'glass',
sortOrder: -1,
}),
).rejects.toThrow('分类排序必须是非负整数')
).rejects.toThrow()
})
it('更新材质名称时会先去空格再查重和保存', async () => {
it('trims material name before duplicate check and persistence on update', async () => {
mockMaterialRepository.findById.mockResolvedValue({
id: 'finish-oak',
name: '旧橡木',
name: 'oak-old',
categoryId: 'category-wood',
thumbnailUrl: null,
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue({
id: 'finish-oak',
code: 'HS-0010',
name: '北美橡木',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '木纹', icon: null },
thumbnailUrl: null,
textureUrl: null,
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue(
buildMaterialRecord({
id: 'finish-oak',
code: 'HS-0010',
name: 'north-american oak',
}),
)
await materialService.update('finish-oak', {
name: ' 北美橡木 ',
name: ' north-american oak ',
})
expect(mockMaterialRepository.nameExistsInCategory).toHaveBeenCalledWith(
'北美橡木',
'north-american oak',
'category-wood',
'finish-oak',
)
expect(mockMaterialRepository.update).toHaveBeenCalledWith(
'finish-oak',
expect.objectContaining({ name: '北美橡木' }),
expect.objectContaining({ name: 'north-american oak' }),
)
})
it('normalizes material asset urls before create persists them', async () => {
mockNormalizeMaterialAssetPath.mockImplementation((value) => {
if (value === 'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp') {
if (
value ===
'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp'
) {
return '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp'
}
if (value === '/materials/textures/wood/oak-normal.webp') {
@ -356,49 +350,34 @@ describe('materialService', () => {
})
mockMaterialRepository.findLatestGeneratedCode.mockResolvedValue('HS-0020')
mockMaterialRepository.findByCode.mockResolvedValue(null)
mockMaterialRepository.create.mockResolvedValue({
id: 'finish-oak-normalized',
code: 'HS-0021',
name: '鍖楃編姗℃湪',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '鏈ㄧ汗', icon: null },
thumbnailUrl: null,
textureUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp',
normalMapUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-normal.webp',
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue({
id: 'finish-oak-normalized',
code: 'HS-0021',
name: '鍖楃編姗℃湪',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '鏈ㄧ汗', icon: null },
thumbnailUrl: null,
textureUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp',
normalMapUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-normal.webp',
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.create.mockResolvedValue(
buildMaterialRecord({
id: 'finish-oak-normalized',
code: 'HS-0021',
name: 'oak normalized',
textureUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp',
normalMapUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-normal.webp',
}),
)
mockMaterialRepository.findByIdWithCategory.mockResolvedValue(
buildMaterialRecord({
id: 'finish-oak-normalized',
code: 'HS-0021',
name: 'oak normalized',
textureUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp',
normalMapUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-normal.webp',
}),
)
await materialService.create({
name: '鍖楃編姗℃湪',
name: 'oak normalized',
categoryId: 'category-wood',
textureUrl: 'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp',
textureUrl:
'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp',
normalMapUrl: '/materials/textures/wood/oak-normal.webp',
color: '#ffffff',
metalness: 0,
@ -408,15 +387,20 @@ describe('materialService', () => {
expect(mockMaterialRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
textureUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp',
normalMapUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-normal.webp',
textureUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp',
normalMapUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-normal.webp',
}),
)
})
it('normalizes material asset urls before update persists them', async () => {
mockNormalizeMaterialAssetPath.mockImplementation((value) => {
if (value === 'https://qnycdn.example.com/prod/materials/textures/wood/oak-v2.webp') {
if (
value ===
'https://qnycdn.example.com/prod/materials/textures/wood/oak-v2.webp'
) {
return '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-v2.webp'
}
return value
@ -427,35 +411,84 @@ describe('materialService', () => {
categoryId: 'category-wood',
thumbnailUrl: null,
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue({
id: 'finish-oak',
code: 'HS-0010',
name: 'oak-old',
categoryId: 'category-wood',
category: { id: 'category-wood', name: '鏈ㄧ汗', icon: null },
thumbnailUrl: null,
textureUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-v2.webp',
normalMapUrl: null,
color: '#ffffff',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue(
buildMaterialRecord({
id: 'finish-oak',
code: 'HS-0010',
name: 'oak-old',
textureUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-v2.webp',
}),
)
await materialService.update('finish-oak', {
textureUrl: 'https://qnycdn.example.com/prod/materials/textures/wood/oak-v2.webp',
textureUrl:
'https://qnycdn.example.com/prod/materials/textures/wood/oak-v2.webp',
})
expect(mockMaterialRepository.update).toHaveBeenCalledWith(
'finish-oak',
expect.objectContaining({
textureUrl: '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-v2.webp',
textureUrl:
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak-v2.webp',
}),
)
})
it('resolves material dto assets to client-accessible urls', async () => {
mockNormalizeMaterialAssetPath.mockImplementation((value) => {
if (
value ===
'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp'
) {
return '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp'
}
if (value === '/materials/textures/wood/oak-normal.webp') {
return '/materials/textures/wood/oak-normal.webp'
}
if (value === '/materials/thumbnails/wood/oak-thumb.webp') {
return '/materials/thumbnails/wood/oak-thumb.webp'
}
return value
})
mockResolveObjectStorageUrlForClientAccess.mockImplementation((value) => {
if (
value ===
'/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Fwood%2Foak.webp'
) {
return 'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp?e=1&token=abc'
}
if (value === '/materials/textures/wood/oak-normal.webp') {
return 'https://qnycdn.example.com/prod/materials/textures/wood/oak-normal.webp?e=1&token=abc'
}
if (value === '/materials/thumbnails/wood/oak-thumb.webp') {
return 'https://qnycdn.example.com/prod/materials/thumbnails/wood/oak-thumb.webp?e=1&token=abc'
}
return value
})
mockMaterialRepository.findByIdWithCategory.mockResolvedValue(
buildMaterialRecord({
id: 'finish-oak',
code: 'HS-0010',
name: 'oak-old',
thumbnailUrl: '/materials/thumbnails/wood/oak-thumb.webp',
textureUrl:
'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp',
normalMapUrl: '/materials/textures/wood/oak-normal.webp',
}),
)
const result = await materialService.findById('finish-oak')
expect(result.thumbnailUrl).toBe(
'https://qnycdn.example.com/prod/materials/thumbnails/wood/oak-thumb.webp?e=1&token=abc',
)
expect(result.textureUrl).toBe(
'https://qnycdn.example.com/prod/materials/textures/wood/oak.webp?e=1&token=abc',
)
expect(result.normalMapUrl).toBe(
'https://qnycdn.example.com/prod/materials/textures/wood/oak-normal.webp?e=1&token=abc',
)
expect(result.previewUrl).toBe('/api/materials/thumbnail?name=test')
})
})

View File

@ -1,4 +1,7 @@
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import { ProductWriteService } from '../product-write.service'
jest.mock('../../repositories/category.repository', () => ({
@ -16,6 +19,7 @@ jest.mock('@/lib/storage', () => {
const actual = jest.requireActual('@/lib/storage')
return {
...actual,
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) => value),
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => value),
}
})
@ -54,10 +58,24 @@ describe('ProductWriteService storage normalization', () => {
const mockedResolveObjectStorageUrlForClientAccess = jest.mocked(
resolveObjectStorageUrlForClientAccess,
)
const mockedNormalizeObjectStorageUrlForPersistence = jest.mocked(
normalizeObjectStorageUrlForPersistence,
)
beforeEach(() => {
jest.clearAllMocks()
const normalized = {
'https://qnycdn.example.com/dev/products/table.glb':
'/api/storage/object?key=dev%2Fproducts%2Ftable.glb',
'https://qnycdn.example.com/dev/products/table.png':
'/api/storage/object?key=dev%2Fproducts%2Ftable.png',
'https://qnycdn.example.com/dev/products/cad.glb':
'/api/storage/object?key=dev%2Fproducts%2Fcad.glb',
} as const
mockedResolveObjectStorageUrlForClientAccess.mockImplementation((value: string) => {
return normalized[value as keyof typeof normalized] ?? value
})
mockedNormalizeObjectStorageUrlForPersistence.mockImplementation((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/products/table.glb':
'/api/storage/object?key=dev%2Fproducts%2Ftable.glb',

View File

@ -1,7 +1,10 @@
import { SKUCommandService } from '../sku-command.service'
import { createSkuWithImportedHolesTx } from '../sku-cad-import.helper'
import { releaseSkuCodeFromDeletedRecords } from '../sku-soft-delete.helper'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import { productRepository } from '../../repositories/product.repository'
jest.mock('../../repositories/product.repository', () => ({
@ -73,6 +76,7 @@ jest.mock('@/lib/storage', () => {
const actual = jest.requireActual('@/lib/storage')
return {
...actual,
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) => value),
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => value),
}
})
@ -95,6 +99,9 @@ describe('SKUCommandService.updateSKU', () => {
const mockedResolveObjectStorageUrlForClientAccess = jest.mocked(
resolveObjectStorageUrlForClientAccess,
)
const mockedNormalizeObjectStorageUrlForPersistence = jest.mocked(
normalizeObjectStorageUrlForPersistence,
)
const ctx = {
user: {
id: 'user-1',
@ -114,7 +121,22 @@ describe('SKUCommandService.updateSKU', () => {
beforeEach(() => {
jest.clearAllMocks()
const normalized = {
'https://qnycdn.example.com/dev/skus/sku-1.glb':
'/api/storage/object?key=dev%2Fskus%2Fsku-1.glb',
'https://qnycdn.example.com/dev/skus/sku-1-main.png':
'/api/storage/object?key=dev%2Fskus%2Fsku-1-main.png',
'https://qnycdn.example.com/dev/skus/sku-1-detail.png':
'/api/storage/object?key=dev%2Fskus%2Fsku-1-detail.png',
'https://qnycdn.example.com/dev/skus/sku-cad.glb':
'/api/storage/object?key=dev%2Fskus%2Fsku-cad.glb',
'https://qnycdn.example.com/dev/skus/sku-cad-main.png':
'/api/storage/object?key=dev%2Fskus%2Fsku-cad-main.png',
} as const
mockedResolveObjectStorageUrlForClientAccess.mockImplementation((value: string) => {
return normalized[value as keyof typeof normalized] ?? value
})
mockedNormalizeObjectStorageUrlForPersistence.mockImplementation((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/skus/sku-1.glb':
'/api/storage/object?key=dev%2Fskus%2Fsku-1.glb',

View File

@ -13,6 +13,20 @@ jest.mock('../../repositories/sku-quality-inspection.repository', () => ({
}))
jest.mock('@/lib/storage', () => ({
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/quality/product.png':
'/api/storage/object?key=dev%2Fquality%2Fproduct.png',
'https://qnycdn.example.com/dev/quality/sku.png':
'/api/storage/object?key=dev%2Fquality%2Fsku.png',
'https://qnycdn.example.com/dev/quality/model.glb':
'/api/storage/object?key=dev%2Fquality%2Fmodel.glb',
'https://qnycdn.example.com/dev/quality/attachment.png':
'/api/storage/object?key=dev%2Fquality%2Fattachment.png',
} as const
return normalized[value as keyof typeof normalized] ?? value
}),
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => {
const normalized = {
'https://qnycdn.example.com/dev/quality/product.png':

View File

@ -137,7 +137,10 @@ import { assetSkuSyncService, type AssetSkuSyncReason } from './asset-sku-sync.s
import { intakeNonIntegratedProductService } from './intake-non-integrated-product.service'
import { materialService } from './material.service'
import type { AssetOperationProgressStep } from './asset-operation-progress.service'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import {
createLegacyAssetSkuGroupSnapshot,
readLegacyAssetSkuGroupFromBusinessData,
@ -184,14 +187,14 @@ function trimOrNull(value: string | null | undefined): string | null {
function normalizeIncomingVariantAssetUrl(value: string): string {
const next = value.trim()
return next ? resolveObjectStorageUrlForClientAccess(next) : ''
return next ? normalizeObjectStorageUrlForPersistence(next) : ''
}
function normalizeNullableIncomingVariantAssetUrl(
value: string | null | undefined,
): string | null {
const next = trimOrNull(value)
return next ? resolveObjectStorageUrlForClientAccess(next) : null
return next ? normalizeObjectStorageUrlForPersistence(next) : null
}
function normalizeDimensionRecord(value: unknown): Record<string, number> {

View File

@ -1,5 +1,5 @@
import { deleteUploadedFile } from '@/lib/file-cleanup'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
commonComponentCategoryRepository,
@ -40,14 +40,14 @@ function normalizeRequiredText(value: string | null | undefined, fieldName: stri
function normalizeIncomingCommonComponentAssetUrl(value: string): string {
const next = value.trim()
return next ? resolveObjectStorageUrlForClientAccess(next) : ''
return next ? normalizeObjectStorageUrlForPersistence(next) : ''
}
function normalizeNullableCommonComponentAssetUrl(
value: string | null | undefined,
): string | null {
const next = value?.trim() ?? ''
return next ? resolveObjectStorageUrlForClientAccess(next) : null
return next ? normalizeObjectStorageUrlForPersistence(next) : null
}
class CommonComponentService {

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[]> {

View File

@ -3,7 +3,10 @@ import { Prisma } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors'
import { calculateGLBGeometry } from '@/lib/glb-geometry'
import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding'
import { resolveObjectStorageUrlForClientAccess, uploadBufferToObjectStorage } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
uploadBufferToObjectStorage,
} from '@/lib/storage'
import { hardwareRepository } from '../repositories'
import type { SyncHardwareInput, SyncHardwareResult } from './hardware.service.types'
import { toNullableJsonInput } from './hardware-dto.mapper'
@ -28,14 +31,14 @@ function normalizeOptionalText(value: string | null | undefined): string | null
function normalizeIncomingHardwareAssetUrl(value: string): string {
const next = value.trim()
return next ? resolveObjectStorageUrlForClientAccess(next) : ''
return next ? normalizeObjectStorageUrlForPersistence(next) : ''
}
function normalizeNullableHardwareAssetUrl(
value: string | null | undefined,
): string | null {
const next = value?.trim() ?? ''
return next ? resolveObjectStorageUrlForClientAccess(next) : null
return next ? normalizeObjectStorageUrlForPersistence(next) : null
}
export async function uploadHardwareModelFile(

View File

@ -10,7 +10,7 @@
*/
import { AppError, ErrorCode } from '@/server/core/errors'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import {
hardwareCategoryRepository,
hardwareMappingRepository,
@ -59,14 +59,14 @@ function normalizeOptionalText(value: string | null | undefined): string | null
function normalizeIncomingHardwareAssetUrl(value: string): string {
const next = value.trim()
return next ? resolveObjectStorageUrlForClientAccess(next) : ''
return next ? normalizeObjectStorageUrlForPersistence(next) : ''
}
function normalizeNullableHardwareAssetUrl(
value: string | null | undefined,
): string | null {
const next = value?.trim() ?? ''
return next ? resolveObjectStorageUrlForClientAccess(next) : null
return next ? normalizeObjectStorageUrlForPersistence(next) : null
}
function assertPositiveFiniteNumber(value: number, fieldName: string): void {

View File

@ -19,6 +19,7 @@ import { mergeMaterialWithPreset } from '@/lib/3d/material-pbr-presets'
import { MATERIAL_CODE_PREFIX, formatMaterialCode, parseMaterialCode } from '@/lib/material-code'
import { isDefaultMaterialThumbnailPath, normalizeMaterialAssetPath } from '@/lib/material-asset-path'
import { buildMaterialThumbnailPreviewUrl } from '@/lib/material-thumbnail'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
// ============ 类型定义 ============
@ -182,6 +183,12 @@ class MaterialService {
return normalizeMaterialAssetPath(rawPath)
}
private resolveMaterialClientAssetUrl(rawPath: string | null | undefined): string | null {
const normalized = normalizeMaterialAssetPath(rawPath)
if (!normalized) return null
return resolveObjectStorageUrlForClientAccess(normalized)
}
private normalizeIncomingThumbnailUrl(rawPath: string | null | undefined): string | null {
const normalized = normalizeMaterialAssetPath(rawPath)
if (!normalized || isDefaultMaterialThumbnailPath(normalized)) {
@ -680,10 +687,13 @@ class MaterialService {
icon: string | null
}
}): MaterialDTO {
const thumbnailUrl = this.normalizeStoredThumbnailUrl(material.thumbnailUrl)
const normalizedThumbnailUrl = this.normalizeStoredThumbnailUrl(material.thumbnailUrl)
const thumbnailUrl = normalizedThumbnailUrl
? resolveObjectStorageUrlForClientAccess(normalizedThumbnailUrl)
: null
const textureUrl = normalizeMaterialAssetPath(material.textureUrl)
const previewUrl = this.buildPreviewUrl({
thumbnailUrl,
thumbnailUrl: normalizedThumbnailUrl,
textureUrl,
color: material.color,
name: material.name,
@ -699,8 +709,8 @@ class MaterialService {
categoryIcon: material.category?.icon,
thumbnailUrl,
previewUrl,
textureUrl,
normalMapUrl: normalizeMaterialAssetPath(material.normalMapUrl),
textureUrl: this.resolveMaterialClientAssetUrl(material.textureUrl),
normalMapUrl: this.resolveMaterialClientAssetUrl(material.normalMapUrl),
color: material.color,
metalness: material.metalness,
roughness: material.roughness,

View File

@ -1,5 +1,5 @@
import { deleteProductFiles } from '@/lib/file-cleanup'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import { canManageCollaborators } from '@/lib/permissions'
import type { CacheService } from '../cache'
import { CacheKeys } from '../cache/keys'
@ -43,7 +43,7 @@ function normalizeCollaboratorRole(value: string): 'editor' | 'viewer' {
function normalizeNullableProductAssetUrl(value: string | null | undefined): string | null {
const normalized = value?.trim()
return normalized ? resolveObjectStorageUrlForClientAccess(normalized) : null
return normalized ? normalizeObjectStorageUrlForPersistence(normalized) : null
}
class ProductWriteService {

View File

@ -15,7 +15,7 @@ import type { SKURepositoryClass } from '../repositories/sku.repository'
import { productRepository } from '../repositories/product.repository'
import { userRepository } from '../repositories/user.repository'
import { deleteSKUFiles } from '@/lib/file-cleanup'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import { canTransferSKU } from '@/lib/permissions'
import { Prisma } from '@prisma/client'
import {
@ -52,7 +52,7 @@ function normalizeSkuIdentityCode(value: string): string {
function normalizeNullableSkuAssetUrl(value: string | null | undefined): string | null {
const normalized = value?.trim()
return normalized ? resolveObjectStorageUrlForClientAccess(normalized) : null
return normalized ? normalizeObjectStorageUrlForPersistence(normalized) : null
}
function normalizeSkuAssetUrls(values: string[] | null | undefined): string[] {

View File

@ -1,7 +1,10 @@
import { Prisma, ProductSkuQualityInspection } from '@prisma/client'
import { hasBusinessRole } from '@/lib/role-scope'
import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import {
normalizeObjectStorageUrlForPersistence,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
@ -50,21 +53,36 @@ function optionalText(value: unknown) {
return text || null
}
function normalizeStorageAssetUrl(value: unknown) {
function normalizeClientStorageAssetUrl(value: unknown) {
const text = trimText(value)
return text ? resolveObjectStorageUrlForClientAccess(text) : ''
}
function normalizePersistentStorageAssetUrl(value: unknown) {
const text = trimText(value)
return text ? normalizeObjectStorageUrlForPersistence(text) : ''
}
function normalizeNullableStorageAssetUrl(value: unknown) {
const normalized = normalizeStorageAssetUrl(value)
const normalized = normalizePersistentStorageAssetUrl(value)
return normalized || null
}
function normalizeStorageAssetUrls(values: Array<unknown>) {
function normalizeClientStorageAssetUrls(values: Array<unknown>) {
return Array.from(
new Set(
values
.map((value) => normalizeStorageAssetUrl(value))
.map((value) => normalizeClientStorageAssetUrl(value))
.filter((value): value is string => Boolean(value)),
),
)
}
function normalizePersistentStorageAssetUrls(values: Array<unknown>) {
return Array.from(
new Set(
values
.map((value) => normalizePersistentStorageAssetUrl(value))
.filter((value): value is string => Boolean(value)),
),
)
@ -163,7 +181,7 @@ function normalizeAttachments(value: unknown): SkuQualityInspectionAttachmentDTO
const id = trimText(item.id)
const name = trimText(item.name)
const url = normalizeStorageAssetUrl(item.url)
const url = normalizeClientStorageAssetUrl(item.url)
if (!id || !name || !url) return []
return [
@ -222,7 +240,7 @@ function getSourceProductName(product: FirstQualitySourceProduct, fallback: stri
function getSourceProductImage(product: FirstQualitySourceProduct) {
const openingReference = getOpeningReference(product)
return normalizeStorageAssetUrl(
return normalizePersistentStorageAssetUrl(
readNestedText(openingReference, 'productImage') ||
trimText(product.previewImage),
)
@ -344,8 +362,8 @@ function toArchiveDTO(record: SkuQualityArchiveRecord): SkuQualityArchiveDTO {
const latestWarehouseRecord =
records.find((item) => item.sourceType === WAREHOUSE_SOURCE) ?? null
const imageUrl =
normalizeStorageAssetUrl(record.assetProfile?.imageUrl) ||
normalizeStorageAssetUrl(record.images[0])
normalizeClientStorageAssetUrl(record.assetProfile?.imageUrl) ||
normalizeClientStorageAssetUrl(record.images[0])
return {
skuId: record.id,
@ -468,7 +486,9 @@ function getFirstQualityAttachments(product: FirstQualitySourceProduct, skuItemI
const id = trimText(file.id)
const name = trimText(file.name)
const url = normalizeStorageAssetUrl(file.fileUrl) || normalizeStorageAssetUrl(file.dataUrl)
const url =
normalizePersistentStorageAssetUrl(file.fileUrl) ||
normalizePersistentStorageAssetUrl(file.dataUrl)
if (!id || !name || !url) return []
return [
@ -506,8 +526,8 @@ class SkuQualityInspectionService {
createdBy: input.ownerId,
})
const skuImage = normalizeStorageAssetUrl(input.skuItem.image)
const images = normalizeStorageAssetUrls([skuImage, productImage])
const skuImage = normalizePersistentStorageAssetUrl(input.skuItem.image)
const images = normalizePersistentStorageAssetUrls([skuImage, productImage])
const imageUrl = images[0] ?? ''
const modelUrl =
normalizeNullableStorageAssetUrl(input.skuItem.model3dDataUrl) || productModelUrl