577 lines
16 KiB
TypeScript
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();
|
|
});
|