merge: sync asset center sku group fix with mainline
This commit is contained in:
commit
a611640927
@ -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 $$;
|
||||
@ -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
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
41
furniture-3d-system/src/app/intake/loading.tsx
Normal file
41
furniture-3d-system/src/app/intake/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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>
|
||||
{[
|
||||
"寄样",
|
||||
|
||||
@ -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>
|
||||
{[
|
||||
"批次",
|
||||
|
||||
@ -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>
|
||||
{[
|
||||
"产品图片",
|
||||
|
||||
@ -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>
|
||||
{[
|
||||
"型号",
|
||||
|
||||
@ -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>
|
||||
{[
|
||||
"型号",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
]);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 后再定厂。
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -153,7 +153,7 @@ export function getSampleMeasurementStatusMessage({
|
||||
}
|
||||
|
||||
return {
|
||||
message: "样包测量已完成",
|
||||
message: "资料已填完核对无误请确认。",
|
||||
type: "success" as const,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -214,6 +214,7 @@ export function useIntakeClientViewModel(
|
||||
pendingEntryTab,
|
||||
skuList,
|
||||
nonIntegratedProducts,
|
||||
initialNonIntegratedPageInfo: props.initialNonIntegratedPageInfo,
|
||||
canDeleteNonIntegratedProducts,
|
||||
canCreateNonIntegratedProducts,
|
||||
canUseDesignControlledProductData,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
44
furniture-3d-system/src/app/warehouse-quality/loading.tsx
Normal file
44
furniture-3d-system/src/app/warehouse-quality/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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
|
||||
|
||||
60
furniture-3d-system/src/hooks/useMediaQuery.test.tsx
Normal file
60
furniture-3d-system/src/hooks/useMediaQuery.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 []
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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([
|
||||
{
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user