Merge supply-chain new product timing overview

This commit is contained in:
jingchuan 2026-08-18 15:01:20 +08:00
commit 75f7b3ac7f
3 changed files with 1335 additions and 254 deletions

View File

@ -1,49 +1,55 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
import NewProductManagementDashboard from '@/app/intake/new-product-management/NewProductManagementDashboard' import NewProductManagementDashboard from "@/app/intake/new-product-management/NewProductManagementDashboard";
import { requireSession } from '@/lib/server-session' import { requireSession } from "@/lib/server-session";
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants' import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from "@/server/services/oa-board.constants";
import { import {
createEmptyOaBoardDashboard, createEmptyOaBoardDashboard,
oaBoardService, oaBoardService,
} from '@/server/services/oa-board.service' } from "@/server/services/oa-board.service";
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' import { supplyChainPermissionSettingsService } from "@/server/services/supply-chain-permission-settings.service";
import { sampleProgressService } from "@/server/services/sample-progress.service";
export const dynamic = 'force-dynamic' export const dynamic = "force-dynamic";
export const metadata = { title: '新品经营总览 · 供应链中心' } export const metadata = { title: "新品经营总览 · 供应链中心" };
export default async function NewProductOverviewPage() { export default async function NewProductOverviewPage() {
const { authUser } = await requireSession('/login') const { authUser } = await requireSession("/login");
const access = await supplyChainPermissionSettingsService.getUserAccess( const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser, authUser,
'newProductOverview', "newProductOverview",
) );
if (!access.view) redirect('/supply-chain') if (!access.view) redirect("/supply-chain");
const result = await oaBoardService const result = await oaBoardService
.getDashboard({ .getDashboard({
overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES], overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
}) })
.then((dashboard) => ({ .then(async (dashboard) => ({
dashboard, dashboard,
preferredImageUrls: await sampleProgressService
.preferredImageUrlsByOpeningCode(dashboard.items)
.catch(() => ({}) as Record<string, string>),
loadErrorMessage: null as string | null, loadErrorMessage: null as string | null,
})) }))
.catch((error) => { .catch((error) => {
console.error('Failed to load supply chain new product overview', error) console.error("Failed to load supply chain new product overview", error);
return { return {
dashboard: createEmptyOaBoardDashboard(), dashboard: createEmptyOaBoardDashboard(),
preferredImageUrls: {} as Record<string, string>,
loadErrorMessage: loadErrorMessage:
error instanceof Error error instanceof Error
? `新品管理数据读取失败:${error.message}` ? `新品管理数据读取失败:${error.message}`
: '新品管理数据读取失败,请稍后重试。', : "新品管理数据读取失败,请稍后重试。",
} };
}) });
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<NewProductManagementDashboard <NewProductManagementDashboard
dashboard={result.dashboard} dashboard={result.dashboard}
preferredImageUrls={result.preferredImageUrls}
loadErrorMessage={result.loadErrorMessage} loadErrorMessage={result.loadErrorMessage}
/> />
</div> </div>
) );
} }

View File

@ -1,56 +1,108 @@
import { AppError, ErrorCode } from '@/server/core/errors' import { AppError, ErrorCode } from "@/server/core/errors";
import { calculateSampleProgressAnomaly, canEditSampleProgress, type SampleProgressInput, type SampleProgressUpdateInput } from '@/lib/sample-progress' import {
import { sampleProgressRepository } from '@/server/repositories/sample-progress.repository' calculateSampleProgressAnomaly,
import { oaBoardService, type OaBoardProjectListItemDTO } from '@/server/services/oa-board.service' canEditSampleProgress,
type SampleProgressInput,
type SampleProgressUpdateInput,
} from "@/lib/sample-progress";
import { sampleProgressRepository } from "@/server/repositories/sample-progress.repository";
import {
oaBoardService,
type OaBoardProjectListItemDTO,
} from "@/server/services/oa-board.service";
function serialize(item: Awaited<ReturnType<typeof sampleProgressRepository.findById>> extends infer T ? NonNullable<T> : never) { function serialize(
return { ...item, anomaly: calculateSampleProgressAnomaly(item), orderDate: item.orderDate.toISOString(), sampleDueDate: item.sampleDueDate.toISOString(), actualSampleCompletedAt: item.actualSampleCompletedAt?.toISOString() ?? null, oaReachedAt: item.oaReachedAt?.toISOString() ?? null, requestedCost: item.requestedCost?.toNumber() ?? null, actualCost: item.actualCost?.toNumber() ?? null, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString() } item: Awaited<
ReturnType<typeof sampleProgressRepository.findById>
> extends infer T
? NonNullable<T>
: never,
) {
return {
...item,
anomaly: calculateSampleProgressAnomaly(item),
orderDate: item.orderDate.toISOString(),
sampleDueDate: item.sampleDueDate.toISOString(),
actualSampleCompletedAt:
item.actualSampleCompletedAt?.toISOString() ?? null,
oaReachedAt: item.oaReachedAt?.toISOString() ?? null,
requestedCost: item.requestedCost?.toNumber() ?? null,
actualCost: item.actualCost?.toNumber() ?? null,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
} }
export type SampleProgressDTO = Omit<ReturnType<typeof serialize>, 'autoGenerated' | 'flowStatus' | 'actualSampleCompletedAt'> & { autoGenerated?: boolean; flowStatus?: string; actualSampleCompletedAt?: string | null } export type SampleProgressDTO = Omit<
ReturnType<typeof serialize>,
"autoGenerated" | "flowStatus" | "actualSampleCompletedAt"
> & {
autoGenerated?: boolean;
flowStatus?: string;
actualSampleCompletedAt?: string | null;
};
function exactOpeningCode(item: OaBoardProjectListItemDTO, openingCode: string) { function exactOpeningCode(
const expected = openingCode.trim().toUpperCase() item: OaBoardProjectListItemDTO,
return [item.openingCode, item.formSummary.openingCode] openingCode: string,
.some((value) => value?.trim().toUpperCase() === expected) ) {
const expected = openingCode.trim().toUpperCase();
return [item.openingCode, item.formSummary.openingCode].some(
(value) => value?.trim().toUpperCase() === expected,
);
} }
export type SampleProgressOaPrefill = { export type SampleProgressOaPrefill = {
openingCode: string openingCode: string;
brand: string brand: string;
designer: string designer: string;
imageUrl: string | null imageUrl: string | null;
oaReachedAt: string | null oaReachedAt: string | null;
purchaseOwner: string purchaseOwner: string;
merchandiserOwner: string merchandiserOwner: string;
} };
async function readOaPrefill(openingCode: string): Promise<SampleProgressOaPrefill | null> { async function readOaPrefill(
const normalized = openingCode.trim() openingCode: string,
if (!normalized) return null ): Promise<SampleProgressOaPrefill | null> {
const dashboard = await oaBoardService.getDashboard({ keyword: normalized }) const normalized = openingCode.trim();
const item = dashboard.items.find((candidate) => exactOpeningCode(candidate, normalized)) if (!normalized) return null;
if (!item) return null const dashboard = await oaBoardService.getDashboard({ keyword: normalized });
const linkedId = linkedProductId(item.formSummary.workflowLink || '') const item = dashboard.items.find((candidate) =>
exactOpeningCode(candidate, normalized),
);
if (!item) return null;
const linkedId = linkedProductId(item.formSummary.workflowLink || "");
const linkedProduct = linkedId const linkedProduct = linkedId
? (await sampleProgressRepository.findDesignSellingPointImages([linkedId]))[0] ? (
: null await sampleProgressRepository.findDesignSellingPointImages([linkedId])
)[0]
: null;
const arrivalTimes = item.nodeAssessments const arrivalTimes = item.nodeAssessments
.filter((node) => node.nodeName === '确认打样出货时间' && node.startedAtSource) .filter(
(node) => node.nodeName === "确认打样出货时间" && node.startedAtSource,
)
.map((node) => node.startedAtSource as string) .map((node) => node.startedAtSource as string)
.sort() .sort();
return { return {
openingCode: item.formSummary.openingCode || item.openingCode || normalized, openingCode: item.formSummary.openingCode || item.openingCode || normalized,
brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '', brand:
designer: linkedProduct?.profile?.structureDesigner?.trim() || item.formSummary.structureDesigner || '', item.formSummary.plannedSalesBrands ||
item.formSummary.salesBrands ||
item.brand ||
"",
designer:
linkedProduct?.profile?.structureDesigner?.trim() ||
item.formSummary.structureDesigner ||
"",
imageUrl: item.styleImages[0]?.url || null, imageUrl: item.styleImages[0]?.url || null,
oaReachedAt: arrivalTimes[0] || null, oaReachedAt: arrivalTimes[0] || null,
purchaseOwner: item.formSummary.purchaseOwner || '', purchaseOwner: item.formSummary.purchaseOwner || "",
merchandiserOwner: item.formSummary.merchandiserOwner || '', merchandiserOwner: item.formSummary.merchandiserOwner || "",
} };
} }
async function mergeOaPrefill(input: SampleProgressInput) { async function mergeOaPrefill(input: SampleProgressInput) {
const prefill = await readOaPrefill(input.openingCode) const prefill = await readOaPrefill(input.openingCode);
return { return {
...input, ...input,
openingCode: input.openingCode.trim().toUpperCase(), openingCode: input.openingCode.trim().toUpperCase(),
@ -59,204 +111,409 @@ async function mergeOaPrefill(input: SampleProgressInput) {
imageUrl: prefill?.imageUrl || input.imageUrl, imageUrl: prefill?.imageUrl || input.imageUrl,
oaReachedAt: prefill?.oaReachedAt ? new Date(prefill.oaReachedAt) : null, oaReachedAt: prefill?.oaReachedAt ? new Date(prefill.oaReachedAt) : null,
purchaseOwner: input.purchaseOwner || prefill?.purchaseOwner || null, purchaseOwner: input.purchaseOwner || prefill?.purchaseOwner || null,
merchandiserOwner: input.merchandiserOwner || prefill?.merchandiserOwner || null, merchandiserOwner:
} input.merchandiserOwner || prefill?.merchandiserOwner || null,
};
} }
function sampleArrival(item: OaBoardProjectListItemDTO) { function sampleArrival(item: OaBoardProjectListItemDTO) {
return item.nodeAssessments return (
.filter((node) => node.nodeName === '确认打样出货时间' && node.startedAtSource) item.nodeAssessments
.map((node) => node.startedAtSource as string) .filter(
.sort()[0] ?? null (node) => node.nodeName === "确认打样出货时间" && node.startedAtSource,
)
.map((node) => node.startedAtSource as string)
.sort()[0] ?? null
);
} }
function actualSampleCompletedAt(item: OaBoardProjectListItemDTO) { function actualSampleCompletedAt(item: OaBoardProjectListItemDTO) {
return item.nodeAssessments return (
.filter((node) => node.nodeName === '填写样品快递单号' && node.finishedAtSource) item.nodeAssessments
.map((node) => new Date(node.finishedAtSource as string)) .filter(
.filter((value) => !Number.isNaN(value.getTime())) (node) => node.nodeName === "填写样品快递单号" && node.finishedAtSource,
.sort((left, right) => right.getTime() - left.getTime())[0] ?? null )
.map((node) => new Date(node.finishedAtSource as string))
.filter((value) => !Number.isNaN(value.getTime()))
.sort((left, right) => right.getTime() - left.getTime())[0] ?? null
);
} }
function sampleStatusFromFlow(item: OaBoardProjectListItemDTO) { function sampleStatusFromFlow(item: OaBoardProjectListItemDTO) {
const flowStatus = item.formSummary.totalFlowStatusLabel.trim() const flowStatus = item.formSummary.totalFlowStatusLabel.trim();
if (flowStatus === '已完结') return 'COMPLETED' if (flowStatus === "已完结") return "COMPLETED";
if (flowStatus === '已撤销') return 'CANCELLED' if (flowStatus === "已撤销") return "CANCELLED";
if (flowStatus === '已终止' || flowStatus === '已拒绝') return 'TERMINATED' if (flowStatus === "已终止" || flowStatus === "已拒绝") return "TERMINATED";
if (flowStatus === '进行中' && !sampleArrival(item)) return '' if (flowStatus === "进行中" && !sampleArrival(item)) return "";
if (flowStatus === '进行中' && !isCurrentSampleStage(item)) return 'COMPLETED' if (flowStatus === "进行中" && !isCurrentSampleStage(item))
return undefined return "COMPLETED";
return undefined;
} }
const SAMPLE_PROGRESS_SYNC_FLOW_STATUSES = [ const SAMPLE_PROGRESS_SYNC_FLOW_STATUSES = [
'RUNNING', "RUNNING",
'COMPLETED', "COMPLETED",
'TERMINATED', "TERMINATED",
'CANCELED', "CANCELED",
'CANCELLED', "CANCELLED",
'REFUSED', "REFUSED",
] ];
function isCurrentSampleStage(item: OaBoardProjectListItemDTO) { function isCurrentSampleStage(item: OaBoardProjectListItemDTO) {
if (item.currentStageName?.trim() === '产品打样') return true if (item.currentStageName?.trim() === "产品打样") return true;
return String(item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '') return String(
item.formSummary.approvalStageDisplay ||
item.currentApprovalStageName ||
"",
)
.split(/[\s,,、;/]+/) .split(/[\s,,、;/]+/)
.includes('产品打样') .includes("产品打样");
} }
function linkedProductId(workflowLink: string) { function linkedProductId(workflowLink: string) {
try { try {
const segments = new URL(workflowLink).pathname.split('/').filter(Boolean) const segments = new URL(workflowLink).pathname.split("/").filter(Boolean);
const index = segments.lastIndexOf('non-integrated') const index = segments.lastIndexOf("non-integrated");
return index >= 0 ? decodeURIComponent(segments[index + 1] || '') : '' return index >= 0 ? decodeURIComponent(segments[index + 1] || "") : "";
} catch { } catch {
return '' return "";
} }
} }
function designSellingPointImage(payload: unknown): string | null { function designSellingPointImage(payload: unknown): string | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null if (!payload || typeof payload !== "object" || Array.isArray(payload))
const files = (payload as { uploadFiles?: unknown }).uploadFiles return null;
if (!Array.isArray(files)) return null const files = (payload as { uploadFiles?: unknown }).uploadFiles;
if (!Array.isArray(files)) return null;
const file = files.find((value) => { const file = files.find((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false if (!value || typeof value !== "object" || Array.isArray(value))
const record = value as { category?: unknown; mimeType?: unknown; dataUrl?: unknown } return false;
return record.category === '设计卖点' && String(record.mimeType || '').startsWith('image/') && typeof record.dataUrl === 'string' && record.dataUrl.trim() const record = value as {
}) as { dataUrl?: string } | undefined category?: unknown;
return file?.dataUrl?.trim() || null mimeType?: unknown;
dataUrl?: unknown;
};
return (
record.category === "设计卖点" &&
String(record.mimeType || "").startsWith("image/") &&
typeof record.dataUrl === "string" &&
record.dataUrl.trim()
);
}) as { dataUrl?: string } | undefined;
return file?.dataUrl?.trim() || null;
} }
async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) { async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
const productIds = Array.from(new Set(items.map((item) => linkedProductId(item.formSummary.workflowLink)).filter(Boolean))) const productIds = Array.from(
const products = await sampleProgressRepository.findDesignSellingPointImages(productIds) new Set(
items
.map((item) => linkedProductId(item.formSummary.workflowLink))
.filter(Boolean),
),
);
const products =
await sampleProgressRepository.findDesignSellingPointImages(productIds);
const designImageByProductId = new Map<string, string | null>( const designImageByProductId = new Map<string, string | null>(
products.map((product): [string, string | null] => [product.id, designSellingPointImage(product.payload)]), products.map((product): [string, string | null] => [
) product.id,
designSellingPointImage(product.payload),
]),
);
const structureDesignerByProductId = new Map( const structureDesignerByProductId = new Map(
products.map((product): [string, string] => [product.id, product.profile?.structureDesigner?.trim() || '']), products.map((product): [string, string] => [
) product.id,
product.profile?.structureDesigner?.trim() || "",
]),
);
const rows = items.flatMap((item) => { const rows = items.flatMap((item) => {
const openingCode = (item.formSummary.openingCode || item.openingCode || '').trim() const openingCode = (
const reachedAt = sampleArrival(item) item.formSummary.openingCode ||
if (!openingCode || !reachedAt) return [] item.openingCode ||
const orderDate = new Date(reachedAt) ""
if (Number.isNaN(orderDate.getTime())) return [] ).trim();
const sampleDueDate = new Date(orderDate) const reachedAt = sampleArrival(item);
sampleDueDate.setDate(sampleDueDate.getDate() + 10) if (!openingCode || !reachedAt) return [];
const workflowLink = item.formSummary.workflowLink || '' const orderDate = new Date(reachedAt);
const productId = linkedProductId(workflowLink) if (Number.isNaN(orderDate.getTime())) return [];
const imageUrl = designImageByProductId.get(linkedProductId(workflowLink)) || item.styleImages[0]?.url || null const sampleDueDate = new Date(orderDate);
return [{ sampleDueDate.setDate(sampleDueDate.getDate() + 10);
openingCode: openingCode.toUpperCase(), const workflowLink = item.formSummary.workflowLink || "";
orderDate, const productId = linkedProductId(workflowLink);
sampleDueDate, const imageUrl =
oaReachedAt: orderDate, designImageByProductId.get(linkedProductId(workflowLink)) ||
imageUrl, item.styleImages[0]?.url ||
productDevelopmentFlowUrl: workflowLink || null, null;
productSize: '', return [
color: '', {
material: '', openingCode: openingCode.toUpperCase(),
sampleQuantity: null, orderDate,
brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '', sampleDueDate,
supplier: '', oaReachedAt: orderDate,
finalSampleFactory: null, imageUrl,
status: 'BARE_WOOD', productDevelopmentFlowUrl: workflowLink || null,
flowStatus: item.formSummary.totalFlowStatusLabel || '', productSize: "",
actualSampleCompletedAt: actualSampleCompletedAt(item), color: "",
designer: structureDesignerByProductId.get(productId) || item.formSummary.structureDesigner || '', material: "",
purchaseOwner: item.formSummary.purchaseOwner || null, sampleQuantity: null,
merchandiserOwner: item.formSummary.merchandiserOwner || null, brand:
progressDescription: null, item.formSummary.plannedSalesBrands ||
autoGenerated: true, item.formSummary.salesBrands ||
}] item.brand ||
}) "",
await sampleProgressRepository.createMany(rows) supplier: "",
await Promise.all(items.map((item) => { finalSampleFactory: null,
const openingCode = (item.formSummary.openingCode || item.openingCode || '').trim().toUpperCase() status: "BARE_WOOD",
if (!openingCode) return Promise.resolve({ count: 0 }) flowStatus: item.formSummary.totalFlowStatusLabel || "",
const workflowLink = item.formSummary.workflowLink || '' actualSampleCompletedAt: actualSampleCompletedAt(item),
const productId = linkedProductId(workflowLink) designer:
return sampleProgressRepository.updateOaFields({ structureDesignerByProductId.get(productId) ||
openingCode, item.formSummary.structureDesigner ||
designer: structureDesignerByProductId.get(productId) || item.formSummary.structureDesigner || '', "",
flowStatus: item.formSummary.totalFlowStatusLabel || '', purchaseOwner: item.formSummary.purchaseOwner || null,
status: sampleStatusFromFlow(item), merchandiserOwner: item.formSummary.merchandiserOwner || null,
actualSampleCompletedAt: actualSampleCompletedAt(item), progressDescription: null,
imageUrl: designImageByProductId.get(linkedProductId(workflowLink)) || item.styleImages[0]?.url || null, autoGenerated: true,
productDevelopmentFlowUrl: workflowLink || null, },
}) ];
})) });
await sampleProgressRepository.createMany(rows);
await Promise.all(
items.map((item) => {
const openingCode = (
item.formSummary.openingCode ||
item.openingCode ||
""
)
.trim()
.toUpperCase();
if (!openingCode) return Promise.resolve({ count: 0 });
const workflowLink = item.formSummary.workflowLink || "";
const productId = linkedProductId(workflowLink);
return sampleProgressRepository.updateOaFields({
openingCode,
designer:
structureDesignerByProductId.get(productId) ||
item.formSummary.structureDesigner ||
"",
flowStatus: item.formSummary.totalFlowStatusLabel || "",
status: sampleStatusFromFlow(item),
actualSampleCompletedAt: actualSampleCompletedAt(item),
imageUrl:
designImageByProductId.get(linkedProductId(workflowLink)) ||
item.styleImages[0]?.url ||
null,
productDevelopmentFlowUrl: workflowLink || null,
});
}),
);
} }
type SampleProgressActor = { id?: string; name?: string } async function preferredImageUrlsByOpeningCode(
items: OaBoardProjectListItemDTO[],
) {
const productIds = Array.from(
new Set(
items
.map((item) => linkedProductId(item.formSummary.workflowLink))
.filter(Boolean),
),
);
const products =
await sampleProgressRepository.findDesignSellingPointImages(productIds);
const designImageByProductId = new Map<string, string | null>(
products.map((product): [string, string | null] => [
product.id,
designSellingPointImage(product.payload),
]),
);
const imageByOpeningCode = new Map<string, string>();
items.forEach((item) => {
const openingCode = (item.formSummary.openingCode || item.openingCode || "")
.trim()
.toUpperCase();
if (!openingCode) return;
const workflowLink = item.formSummary.workflowLink || "";
const designImage =
designImageByProductId.get(linkedProductId(workflowLink)) || null;
const fallbackImage =
item.styleImages.find((image) => image.url)?.url || null;
const imageUrl = designImage || fallbackImage;
if (imageUrl && (!imageByOpeningCode.has(openingCode) || designImage)) {
imageByOpeningCode.set(openingCode, imageUrl);
}
});
return Object.fromEntries(imageByOpeningCode);
}
type SampleProgressActor = { id?: string; name?: string };
const FIELD_LABELS: Record<string, string> = { const FIELD_LABELS: Record<string, string> = {
imageUrl: '图片', brand: '计划售卖品牌', designer: '结构设计师', oaReachedAt: 'OA流程到达时间', orderDate: '下单时间', productDevelopmentFlowUrl: '由壹点链接', productSize: '产品尺寸', color: '颜色', material: '材质', sampleQuantity: '打样数量', sampleDueDate: '打样交期', supplier: '供应商', finalSampleFactory: '最终出样厂家', status: '打样状态', progressDescription: '进度说明', requestedCost: '需求成本', actualCost: '实际成本', followUpOwner: '跟进人', purchaseOwner: '采购负责人', merchandiserOwner: '跟单负责人', imageUrl: "图片",
} brand: "计划售卖品牌",
designer: "结构设计师",
oaReachedAt: "OA流程到达时间",
orderDate: "下单时间",
productDevelopmentFlowUrl: "由壹点链接",
productSize: "产品尺寸",
color: "颜色",
material: "材质",
sampleQuantity: "打样数量",
sampleDueDate: "打样交期",
supplier: "供应商",
finalSampleFactory: "最终出样厂家",
status: "打样状态",
progressDescription: "进度说明",
requestedCost: "需求成本",
actualCost: "实际成本",
followUpOwner: "跟进人",
purchaseOwner: "采购负责人",
merchandiserOwner: "跟单负责人",
};
function assertCanEdit(actor: SampleProgressActor, owners: { purchaseOwner?: string | null; merchandiserOwner?: string | null }) { function assertCanEdit(
const actorName = actor.name?.trim() || '' actor: SampleProgressActor,
if (!canEditSampleProgress(actorName, owners)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '仅采购负责人、跟单负责人或荆川可编辑打样记录') owners: { purchaseOwner?: string | null; merchandiserOwner?: string | null },
) {
const actorName = actor.name?.trim() || "";
if (!canEditSampleProgress(actorName, owners))
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
"仅采购负责人、跟单负责人或荆川可编辑打样记录",
);
} }
function comparable(value: unknown) { function comparable(value: unknown) {
if (value instanceof Date) return value.toISOString() if (value instanceof Date) return value.toISOString();
return value === undefined ? null : value return value === undefined ? null : value;
} }
export const sampleProgressService = { export const sampleProgressService = {
async syncFromOaItems(items: OaBoardProjectListItemDTO[]) { await syncFromOaItems(items) }, async syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
await syncFromOaItems(items);
},
async preferredImageUrlsByOpeningCode(items: OaBoardProjectListItemDTO[]) {
return preferredImageUrlsByOpeningCode(items);
},
async syncFromOaDashboard() { async syncFromOaDashboard() {
const dashboard = await oaBoardService.getDashboard({ const dashboard = await oaBoardService.getDashboard({
overallStatus: SAMPLE_PROGRESS_SYNC_FLOW_STATUSES, overallStatus: SAMPLE_PROGRESS_SYNC_FLOW_STATUSES,
}) });
await syncFromOaItems(dashboard.items) await syncFromOaItems(dashboard.items);
}, },
async listCurrentSampleStageOpeningCodes() { async listCurrentSampleStageOpeningCodes() {
const dashboard = await oaBoardService.getDashboard({ const dashboard = await oaBoardService.getDashboard({
overallStatus: SAMPLE_PROGRESS_SYNC_FLOW_STATUSES, overallStatus: SAMPLE_PROGRESS_SYNC_FLOW_STATUSES,
}) });
return dashboard.items return dashboard.items
.filter(isCurrentSampleStage) .filter(isCurrentSampleStage)
.map((item) => (item.formSummary.openingCode || item.openingCode || '').trim().toUpperCase()) .map((item) =>
.filter(Boolean) (item.formSummary.openingCode || item.openingCode || "")
.trim()
.toUpperCase(),
)
.filter(Boolean);
},
async listStored() {
return (await sampleProgressRepository.list()).map(serialize);
}, },
async listStored() { return (await sampleProgressRepository.list()).map(serialize) },
async list() { async list() {
await this.syncFromOaDashboard().catch(() => undefined) await this.syncFromOaDashboard().catch(() => undefined);
return this.listStored() return this.listStored();
}, },
getOaPrefill: readOaPrefill, getOaPrefill: readOaPrefill,
async create(input: SampleProgressInput, actor: SampleProgressActor) { async create(input: SampleProgressInput, actor: SampleProgressActor) {
const merged = await mergeOaPrefill(input) const merged = await mergeOaPrefill(input);
assertCanEdit(actor, merged) assertCanEdit(actor, merged);
return serialize(await sampleProgressRepository.create({ ...merged, createdBy: actor.id, updatedBy: actor.id })) return serialize(
await sampleProgressRepository.create({
...merged,
createdBy: actor.id,
updatedBy: actor.id,
}),
);
}, },
async update(id: string, input: SampleProgressUpdateInput, actor: SampleProgressActor) { async update(
const current = await sampleProgressRepository.findById(id) id: string,
if (!current) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在') input: SampleProgressUpdateInput,
assertCanEdit(actor, current) actor: SampleProgressActor,
const { deliveryDateChangeReason, ...editableInput } = input ) {
const mergedFromOa = await mergeOaPrefill({ ...editableInput, openingCode: current.openingCode }) const current = await sampleProgressRepository.findById(id);
const merged = { ...mergedFromOa, purchaseOwner: editableInput.purchaseOwner || null, merchandiserOwner: editableInput.merchandiserOwner || null } if (!current)
const dueDateChanged = comparable(current.sampleDueDate) !== comparable(merged.sampleDueDate) throw new AppError(ErrorCode.VALIDATION_FAILED, "打样记录不存在");
if (dueDateChanged && !deliveryDateChangeReason?.trim()) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '变更打样交期必须填写交期变更原因') assertCanEdit(actor, current);
const { deliveryDateChangeReason, ...editableInput } = input;
const mergedFromOa = await mergeOaPrefill({
...editableInput,
openingCode: current.openingCode,
});
const merged = {
...mergedFromOa,
purchaseOwner: editableInput.purchaseOwner || null,
merchandiserOwner: editableInput.merchandiserOwner || null,
};
const dueDateChanged =
comparable(current.sampleDueDate) !== comparable(merged.sampleDueDate);
if (dueDateChanged && !deliveryDateChangeReason?.trim())
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
"变更打样交期必须填写交期变更原因",
);
const changedFields = Object.keys(FIELD_LABELS).flatMap((field) => { const changedFields = Object.keys(FIELD_LABELS).flatMap((field) => {
const before = comparable(current[field as keyof typeof current]) const before = comparable(current[field as keyof typeof current]);
const after = comparable(merged[field as keyof typeof merged]) const after = comparable(merged[field as keyof typeof merged]);
return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ field, label: FIELD_LABELS[field], before, after }] return JSON.stringify(before) === JSON.stringify(after)
}) ? []
if (changedFields.length === 0) return serialize(current) : [{ field, label: FIELD_LABELS[field], before, after }];
const beforeSnapshot = Object.fromEntries(Object.keys(FIELD_LABELS).map((field) => [field, comparable(current[field as keyof typeof current])])) });
const afterSnapshot = { ...beforeSnapshot, ...Object.fromEntries(Object.keys(FIELD_LABELS).map((field) => [field, comparable(merged[field as keyof typeof merged])])) } if (changedFields.length === 0) return serialize(current);
return serialize(await sampleProgressRepository.updateWithChangeLog({ id, data: { ...merged, openingCode: current.openingCode, autoGenerated: false, updatedBy: actor.id }, changedFields, beforeSnapshot, afterSnapshot, changeReason: dueDateChanged ? deliveryDateChangeReason?.trim() : null, changedById: actor.id, changedByName: actor.name || '未知用户' })) const beforeSnapshot = Object.fromEntries(
Object.keys(FIELD_LABELS).map((field) => [
field,
comparable(current[field as keyof typeof current]),
]),
);
const afterSnapshot = {
...beforeSnapshot,
...Object.fromEntries(
Object.keys(FIELD_LABELS).map((field) => [
field,
comparable(merged[field as keyof typeof merged]),
]),
),
};
return serialize(
await sampleProgressRepository.updateWithChangeLog({
id,
data: {
...merged,
openingCode: current.openingCode,
autoGenerated: false,
updatedBy: actor.id,
},
changedFields,
beforeSnapshot,
afterSnapshot,
changeReason: dueDateChanged ? deliveryDateChangeReason?.trim() : null,
changedById: actor.id,
changedByName: actor.name || "未知用户",
}),
);
}, },
async listChangeLogs(id: string) { async listChangeLogs(id: string) {
if (!(await sampleProgressRepository.findById(id))) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在') if (!(await sampleProgressRepository.findById(id)))
return (await sampleProgressRepository.listChangeLogs(id)).map((log) => ({ ...log, createdAt: log.createdAt.toISOString() })) throw new AppError(ErrorCode.VALIDATION_FAILED, "打样记录不存在");
return (await sampleProgressRepository.listChangeLogs(id)).map((log) => ({
...log,
createdAt: log.createdAt.toISOString(),
}));
}, },
async delete(id: string, actor: SampleProgressActor) { async delete(id: string, actor: SampleProgressActor) {
const current = await sampleProgressRepository.findById(id) const current = await sampleProgressRepository.findById(id);
if (!current) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在') if (!current)
if (actor.name?.trim() !== '荆川') throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '仅荆川可以删除打样记录') throw new AppError(ErrorCode.VALIDATION_FAILED, "打样记录不存在");
await sampleProgressRepository.delete(id) if (actor.name?.trim() !== "荆川")
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
"仅荆川可以删除打样记录",
);
await sampleProgressRepository.delete(id);
}, },
} };