furniture-3d-system-Wu/furniture-3d-system/scripts/sync-board-material-library.ts

577 lines
16 KiB
TypeScript

import "dotenv/config";
import { Prisma } from "@prisma/client";
import { prisma } from "../src/lib/prisma";
import {
BOARD_ALL_PANEL_FINISHES,
BOARD_LIBRARY_DEFAULTS,
BOARD_REGULAR_FINISHES,
BOARD_RUBBER_WOOD_FINISHES,
} from "../src/lib/board-substrate-rules";
const BOARD_IMPORT_SOURCE = "BOARD_LIBRARY_DEFAULTS_2026_04_20";
const SUBSTRATE_CATEGORY_SORT_ORDER: Record<string, number> = {
密度板: 10,
刨花板: 20,
生态板: 30,
三合板: 40,
橡胶木: 50,
};
const ENVIRONMENT_GRADE_SORT_ORDER: Record<string, number> = {
E0: 10,
E1级: 20,
E2: 30,
ENF级: 40,
};
const SURFACE_OPTION_SORT_ORDER: Record<string, number> = {
单贴: 10,
双贴: 20,
};
const MATERIAL_CATEGORY_SORT_ORDER: Record<string, number> = {
木纹贴面花色: 10,
纯色贴面花色: 20,
石纹贴面花色: 30,
实木花色: 40,
};
const WOODGRAIN_FINISHES = new Set([
"胡桃木色",
"北美原木纹色",
"经典北美黑胡桃色",
"榆木黑色",
"浅黄橡木色",
"黄兰橡木色",
"柚木色",
]);
const SOLID_FINISHES = new Set([
"大象灰色",
"荷花白色",
"烟墨黑色",
"石墨灰色",
"云雾灰色",
"珍珠白色",
"灰黑色",
]);
const FINISH_COLOR_MAP: Record<string, string> = {
胡桃木色: "#725338",
北美原木纹色: "#C79E6D",
经典北美黑胡桃色: "#4F3528",
大象灰色: "#A4A09B",
荷花白色: "#F6F3EB",
烟墨黑色: "#2F3033",
石墨灰色: "#686B70",
云雾灰色: "#D8D9DC",
珍珠白色: "#FAF8F1",
灰黑色: "#4B4B4B",
榆木黑色: "#40342F",
浅黄橡木色: "#D6B07A",
黄兰橡木色: "#C99C63",
柚木色: "#A67041",
冰川白石纹色: "#EDEAE3",
胡桃色: "#684731",
原木色: "#C89B6B",
黑色: "#1F1F1F",
};
type BoardRule = (typeof BOARD_LIBRARY_DEFAULTS)[number];
type ExpandedBoardRow = {
rowKey: string;
rule: BoardRule;
finishName: string;
};
type SyncSummary = {
materialCategoriesCreated: number;
substrateCategoriesCreated: number;
environmentGradesCreated: number;
surfaceOptionsCreated: number;
thicknessSpecsCreated: number;
finishMaterialsCreated: number;
finishMaterialsUpdated: number;
substratesDeleted: number;
substratesCreated: number;
};
function formatThickness(thicknessMm: number) {
return Number.isInteger(thicknessMm)
? String(thicknessMm)
: String(thicknessMm);
}
function getEnvironmentGradeName(rawName: string) {
if (rawName === "E1") return "E1级";
if (rawName === "ENF") return "ENF级";
return rawName;
}
function getSurfaceOptionName(veneerMode: "single" | "double") {
return veneerMode === "single" ? "单贴" : "双贴";
}
function getSystemName(rule: BoardRule) {
const thickness = formatThickness(rule.thicknessMm);
if (rule.materialName === "密度板") return `${thickness}密度板`;
if (rule.materialName === "刨花板") return `${thickness}E1级刨花板`;
if (rule.materialName === "生态板") return `${thickness}生态板A级`;
if (rule.materialName === "三合板") return `${thickness}E1级三合板`;
return `${thickness}ENF级橡胶木`;
}
function getFinishNames(rule: BoardRule) {
if (rule.finishScope === "REGULAR") {
return [...BOARD_REGULAR_FINISHES];
}
if (rule.finishScope === "ALL") {
return rule.materialName === "橡胶木"
? [...BOARD_RUBBER_WOOD_FINISHES]
: [...BOARD_ALL_PANEL_FINISHES];
}
return [...rule.finishNames];
}
function getFinishCategoryName(rule: BoardRule, finishName: string) {
if (rule.materialName === "橡胶木") return "实木花色";
if (finishName === "冰川白石纹色") return "石纹贴面花色";
if (SOLID_FINISHES.has(finishName)) return "纯色贴面花色";
if (WOODGRAIN_FINISHES.has(finishName)) return "木纹贴面花色";
return "木纹贴面花色";
}
function buildSubstrateName(rule: BoardRule, finishName: string) {
return `${formatThickness(rule.thicknessMm)} ${getEnvironmentGradeName(rule.environmentalGrade)} ${rule.materialName} ${getSurfaceOptionName(rule.veneerMode)} ${finishName}`;
}
function buildSubstrateDescription(rule: BoardRule, finishName: string) {
return `基础资料库标准板材:${getSystemName(rule)},贴面:${getSurfaceOptionName(rule.veneerMode)},花色:${finishName}。`;
}
function getMissingFields(rule: BoardRule) {
const missingFields: string[] = [];
if (rule.sheetWeightKg == null) {
missingFields.push("weightKg");
}
return missingFields;
}
function expandBoardRows(): ExpandedBoardRow[] {
return BOARD_LIBRARY_DEFAULTS.flatMap((rule) =>
getFinishNames(rule).map((finishName) => ({
rowKey: `${rule.code}::${finishName}`,
rule,
finishName,
})),
);
}
async function ensureMaterialCategory(name: string) {
const existing = await prisma.materialCategory.findUnique({
where: { name },
});
if (existing) {
await prisma.materialCategory.update({
where: { id: existing.id },
data: {
sortOrder: MATERIAL_CATEGORY_SORT_ORDER[name] ?? existing.sortOrder,
isActive: true,
},
});
return { category: existing, created: false };
}
const created = await prisma.materialCategory.create({
data: {
name,
sortOrder: MATERIAL_CATEGORY_SORT_ORDER[name] ?? 0,
isActive: true,
},
});
return { category: created, created: true };
}
async function ensureSubstrateCategory(name: string) {
const existing = await prisma.substrateCategory.findUnique({
where: { name },
});
if (existing) {
await prisma.substrateCategory.update({
where: { id: existing.id },
data: {
sortOrder: SUBSTRATE_CATEGORY_SORT_ORDER[name] ?? existing.sortOrder,
isActive: true,
},
});
return { category: existing, created: false };
}
const created = await prisma.substrateCategory.create({
data: {
name,
sortOrder: SUBSTRATE_CATEGORY_SORT_ORDER[name] ?? 0,
isActive: true,
},
});
return { category: created, created: true };
}
async function ensureEnvironmentGrade(name: string) {
const existing = await prisma.substrateEnvironmentGrade.findUnique({
where: { name },
});
if (existing) {
await prisma.substrateEnvironmentGrade.update({
where: { id: existing.id },
data: {
sortOrder: ENVIRONMENT_GRADE_SORT_ORDER[name] ?? existing.sortOrder,
isActive: true,
},
});
return { grade: existing, created: false };
}
const created = await prisma.substrateEnvironmentGrade.create({
data: {
name,
sortOrder: ENVIRONMENT_GRADE_SORT_ORDER[name] ?? 0,
isActive: true,
},
});
return { grade: created, created: true };
}
async function ensureSurfaceOption(name: string) {
const existing = await prisma.substrateSurfaceOption.findUnique({
where: { name },
});
if (existing) {
await prisma.substrateSurfaceOption.update({
where: { id: existing.id },
data: {
sortOrder: SURFACE_OPTION_SORT_ORDER[name] ?? existing.sortOrder,
isActive: true,
},
});
return { surface: existing, created: false };
}
const created = await prisma.substrateSurfaceOption.create({
data: {
name,
sortOrder: SURFACE_OPTION_SORT_ORDER[name] ?? 0,
isActive: true,
},
});
return { surface: created, created: true };
}
async function ensureThicknessSpec(thicknessMm: number) {
const existing = await prisma.substrateThicknessSpec.findFirst({
where: { thicknessMm },
orderBy: [{ isActive: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
});
if (existing) {
await prisma.substrateThicknessSpec.update({
where: { id: existing.id },
data: {
sortOrder: Math.round(thicknessMm * 10),
sheetLengthMm: existing.sheetLengthMm || 2440,
sheetWidthMm: existing.sheetWidthMm || 1220,
isActive: true,
},
});
return { thicknessSpec: existing, created: false };
}
const created = await prisma.substrateThicknessSpec.create({
data: {
name: `${formatThickness(thicknessMm)}MM`,
thicknessMm,
sheetLengthMm: 2440,
sheetWidthMm: 1220,
sortOrder: Math.round(thicknessMm * 10),
isActive: true,
},
});
return { thicknessSpec: created, created: true };
}
async function createCodeGenerator(options: {
prefix: string;
fetchLatest: () => Promise<string | null>;
exists: (code: string) => Promise<boolean>;
}) {
const latestCode = await options.fetchLatest();
let current = latestCode
? Number.parseInt(latestCode.slice(options.prefix.length), 10)
: 0;
if (!Number.isFinite(current)) current = 0;
return async () => {
while (true) {
current += 1;
const nextCode = `${options.prefix}${String(current).padStart(4, "0")}`;
if (!(await options.exists(nextCode))) {
return nextCode;
}
}
};
}
async function main() {
const summary: SyncSummary = {
materialCategoriesCreated: 0,
substrateCategoriesCreated: 0,
environmentGradesCreated: 0,
surfaceOptionsCreated: 0,
thicknessSpecsCreated: 0,
finishMaterialsCreated: 0,
finishMaterialsUpdated: 0,
substratesDeleted: 0,
substratesCreated: 0,
};
const expandedRows = expandBoardRows();
for (const categoryName of Object.keys(MATERIAL_CATEGORY_SORT_ORDER)) {
const result = await ensureMaterialCategory(categoryName);
if (result.created) {
summary.materialCategoriesCreated += 1;
}
}
const nextMaterialCode = await createCodeGenerator({
prefix: "BF-",
fetchLatest: async () => {
const latest = await prisma.material.findFirst({
where: { code: { startsWith: "BF-" } },
orderBy: { code: "desc" },
select: { code: true },
});
return latest?.code ?? null;
},
exists: async (code) =>
Boolean(
await prisma.material.findUnique({
where: { code },
select: { id: true },
}),
),
});
const nextSubstrateCode = await createCodeGenerator({
prefix: "BC-",
fetchLatest: async () => {
const latest = await prisma.substrate.findFirst({
where: { code: { startsWith: "BC-" } },
orderBy: { code: "desc" },
select: { code: true },
});
return latest?.code ?? null;
},
exists: async (code) =>
Boolean(
await prisma.substrate.findUnique({
where: { code },
select: { id: true },
}),
),
});
const existingImportedSubstrates = await prisma.substrate.findMany({
include: {
assetVariants: {
select: { id: true },
},
},
});
const importedSubstrates = existingImportedSubstrates.filter((substrate) => {
if (
!substrate.metadata ||
typeof substrate.metadata !== "object" ||
Array.isArray(substrate.metadata)
) {
return false;
}
return (
(substrate.metadata as Record<string, unknown>).source ===
BOARD_IMPORT_SOURCE
);
});
const linkedVariantCount = importedSubstrates.reduce(
(total, substrate) => total + substrate.assetVariants.length,
0,
);
if (linkedVariantCount > 0) {
throw new Error(
`已有 ${linkedVariantCount} 个资产引用了此前生成的基础资料,已停止自动删除,请先确认。`,
);
}
for (const substrate of importedSubstrates) {
await prisma.substrate.delete({
where: { id: substrate.id },
});
summary.substratesDeleted += 1;
}
for (const row of expandedRows) {
const { rule, finishName, rowKey } = row;
const substrateCategoryResult = await ensureSubstrateCategory(
rule.materialName,
);
if (substrateCategoryResult.created) {
summary.substrateCategoriesCreated += 1;
}
const environmentGradeName = getEnvironmentGradeName(
rule.environmentalGrade,
);
const environmentGradeResult =
await ensureEnvironmentGrade(environmentGradeName);
if (environmentGradeResult.created) {
summary.environmentGradesCreated += 1;
}
const surfaceOptionResult = await ensureSurfaceOption(
getSurfaceOptionName(rule.veneerMode),
);
if (surfaceOptionResult.created) {
summary.surfaceOptionsCreated += 1;
}
const thicknessSpecResult = await ensureThicknessSpec(rule.thicknessMm);
if (thicknessSpecResult.created) {
summary.thicknessSpecsCreated += 1;
}
const finishCategoryName = getFinishCategoryName(rule, finishName);
const finishCategoryResult =
await ensureMaterialCategory(finishCategoryName);
if (finishCategoryResult.created) {
summary.materialCategoriesCreated += 1;
}
const existingFinish = await prisma.material.findFirst({
where: {
name: finishName,
categoryId: finishCategoryResult.category.id,
},
select: { id: true },
});
const finishId = existingFinish
? existingFinish.id
: (
await prisma.material.create({
data: {
code: await nextMaterialCode(),
name: finishName,
category: { connect: { id: finishCategoryResult.category.id } },
thumbnailUrl: null,
textureUrl: null,
normalMapUrl: null,
color: FINISH_COLOR_MAP[finishName] ?? "#8A8A8A",
metalness: 0,
roughness: finishCategoryName === "石纹贴面花色" ? 0.28 : 0.55,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isActive: true,
},
select: { id: true },
})
).id;
if (existingFinish) {
await prisma.material.update({
where: { id: existingFinish.id },
data: {
color: FINISH_COLOR_MAP[finishName] ?? "#8A8A8A",
isActive: true,
},
});
summary.finishMaterialsUpdated += 1;
} else {
summary.finishMaterialsCreated += 1;
}
const missingFields = getMissingFields(rule);
const metadata = {
source: BOARD_IMPORT_SOURCE,
boardRowKey: rowKey,
boardRuleCode: rule.code,
boardFinishName: finishName,
systemName: getSystemName(rule),
finishScope: rule.finishScope,
finishNames: [finishName],
veneerMode: rule.veneerMode,
environmentalGrade: environmentGradeName,
materialName: rule.materialName,
sheetLengthMm: rule.sheetLengthMm,
sheetWidthMm: rule.sheetWidthMm,
sheetAreaSquareMeter: rule.sheetAreaSquareMeter,
sheetPrice: rule.sheetPrice,
sheetWeightKg: rule.sheetWeightKg,
pricePerSquareMeter: rule.pricePerSquareMeter,
weightPerSquareMeterKg: rule.weightPerSquareMeterKg,
missingFields,
importedAt: new Date().toISOString(),
} satisfies Prisma.JsonObject;
await prisma.substrate.create({
data: {
code: await nextSubstrateCode(),
name: buildSubstrateName(rule, finishName),
description: buildSubstrateDescription(rule, finishName),
categoryId: substrateCategoryResult.category.id,
environmentGradeId: environmentGradeResult.grade.id,
thicknessSpecId: thicknessSpecResult.thicknessSpec.id,
surfaceOptionId: surfaceOptionResult.surface.id,
suggestedPrice: new Prisma.Decimal(rule.sheetPrice.toFixed(2)),
weightKg: rule.sheetWeightKg,
metadata,
isActive: true,
finishes: {
create: [
{
finish: { connect: { id: finishId } },
},
],
},
},
});
summary.substratesCreated += 1;
}
console.log("板材基础资料库同步完成");
console.log(JSON.stringify(summary, null, 2));
}
main()
.catch((error) => {
console.error("板材基础资料库同步失败");
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});