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

View File

@ -1,56 +1,108 @@
import { AppError, ErrorCode } from '@/server/core/errors'
import { calculateSampleProgressAnomaly, 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'
import { AppError, ErrorCode } from "@/server/core/errors";
import {
calculateSampleProgressAnomaly,
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) {
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() }
function serialize(
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) {
const expected = openingCode.trim().toUpperCase()
return [item.openingCode, item.formSummary.openingCode]
.some((value) => value?.trim().toUpperCase() === expected)
function exactOpeningCode(
item: OaBoardProjectListItemDTO,
openingCode: string,
) {
const expected = openingCode.trim().toUpperCase();
return [item.openingCode, item.formSummary.openingCode].some(
(value) => value?.trim().toUpperCase() === expected,
);
}
export type SampleProgressOaPrefill = {
openingCode: string
brand: string
designer: string
imageUrl: string | null
oaReachedAt: string | null
purchaseOwner: string
merchandiserOwner: string
}
openingCode: string;
brand: string;
designer: string;
imageUrl: string | null;
oaReachedAt: string | null;
purchaseOwner: string;
merchandiserOwner: string;
};
async function readOaPrefill(openingCode: string): Promise<SampleProgressOaPrefill | null> {
const normalized = openingCode.trim()
if (!normalized) return null
const dashboard = await oaBoardService.getDashboard({ keyword: normalized })
const item = dashboard.items.find((candidate) => exactOpeningCode(candidate, normalized))
if (!item) return null
const linkedId = linkedProductId(item.formSummary.workflowLink || '')
async function readOaPrefill(
openingCode: string,
): Promise<SampleProgressOaPrefill | null> {
const normalized = openingCode.trim();
if (!normalized) return null;
const dashboard = await oaBoardService.getDashboard({ keyword: normalized });
const item = dashboard.items.find((candidate) =>
exactOpeningCode(candidate, normalized),
);
if (!item) return null;
const linkedId = linkedProductId(item.formSummary.workflowLink || "");
const linkedProduct = linkedId
? (await sampleProgressRepository.findDesignSellingPointImages([linkedId]))[0]
: null
? (
await sampleProgressRepository.findDesignSellingPointImages([linkedId])
)[0]
: null;
const arrivalTimes = item.nodeAssessments
.filter((node) => node.nodeName === '确认打样出货时间' && node.startedAtSource)
.filter(
(node) => node.nodeName === "确认打样出货时间" && node.startedAtSource,
)
.map((node) => node.startedAtSource as string)
.sort()
.sort();
return {
openingCode: item.formSummary.openingCode || item.openingCode || normalized,
brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '',
designer: linkedProduct?.profile?.structureDesigner?.trim() || item.formSummary.structureDesigner || '',
brand:
item.formSummary.plannedSalesBrands ||
item.formSummary.salesBrands ||
item.brand ||
"",
designer:
linkedProduct?.profile?.structureDesigner?.trim() ||
item.formSummary.structureDesigner ||
"",
imageUrl: item.styleImages[0]?.url || null,
oaReachedAt: arrivalTimes[0] || null,
purchaseOwner: item.formSummary.purchaseOwner || '',
merchandiserOwner: item.formSummary.merchandiserOwner || '',
}
purchaseOwner: item.formSummary.purchaseOwner || "",
merchandiserOwner: item.formSummary.merchandiserOwner || "",
};
}
async function mergeOaPrefill(input: SampleProgressInput) {
const prefill = await readOaPrefill(input.openingCode)
const prefill = await readOaPrefill(input.openingCode);
return {
...input,
openingCode: input.openingCode.trim().toUpperCase(),
@ -59,204 +111,409 @@ async function mergeOaPrefill(input: SampleProgressInput) {
imageUrl: prefill?.imageUrl || input.imageUrl,
oaReachedAt: prefill?.oaReachedAt ? new Date(prefill.oaReachedAt) : null,
purchaseOwner: input.purchaseOwner || prefill?.purchaseOwner || null,
merchandiserOwner: input.merchandiserOwner || prefill?.merchandiserOwner || null,
}
merchandiserOwner:
input.merchandiserOwner || prefill?.merchandiserOwner || null,
};
}
function sampleArrival(item: OaBoardProjectListItemDTO) {
return item.nodeAssessments
.filter((node) => node.nodeName === '确认打样出货时间' && node.startedAtSource)
.map((node) => node.startedAtSource as string)
.sort()[0] ?? null
return (
item.nodeAssessments
.filter(
(node) => node.nodeName === "确认打样出货时间" && node.startedAtSource,
)
.map((node) => node.startedAtSource as string)
.sort()[0] ?? null
);
}
function actualSampleCompletedAt(item: OaBoardProjectListItemDTO) {
return item.nodeAssessments
.filter((node) => node.nodeName === '填写样品快递单号' && node.finishedAtSource)
.map((node) => new Date(node.finishedAtSource as string))
.filter((value) => !Number.isNaN(value.getTime()))
.sort((left, right) => right.getTime() - left.getTime())[0] ?? null
return (
item.nodeAssessments
.filter(
(node) => node.nodeName === "填写样品快递单号" && node.finishedAtSource,
)
.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) {
const flowStatus = item.formSummary.totalFlowStatusLabel.trim()
if (flowStatus === '已完结') return 'COMPLETED'
if (flowStatus === '已撤销') return 'CANCELLED'
if (flowStatus === '已终止' || flowStatus === '已拒绝') return 'TERMINATED'
if (flowStatus === '进行中' && !sampleArrival(item)) return ''
if (flowStatus === '进行中' && !isCurrentSampleStage(item)) return 'COMPLETED'
return undefined
const flowStatus = item.formSummary.totalFlowStatusLabel.trim();
if (flowStatus === "已完结") return "COMPLETED";
if (flowStatus === "已撤销") return "CANCELLED";
if (flowStatus === "已终止" || flowStatus === "已拒绝") return "TERMINATED";
if (flowStatus === "进行中" && !sampleArrival(item)) return "";
if (flowStatus === "进行中" && !isCurrentSampleStage(item))
return "COMPLETED";
return undefined;
}
const SAMPLE_PROGRESS_SYNC_FLOW_STATUSES = [
'RUNNING',
'COMPLETED',
'TERMINATED',
'CANCELED',
'CANCELLED',
'REFUSED',
]
"RUNNING",
"COMPLETED",
"TERMINATED",
"CANCELED",
"CANCELLED",
"REFUSED",
];
function isCurrentSampleStage(item: OaBoardProjectListItemDTO) {
if (item.currentStageName?.trim() === '产品打样') return true
return String(item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '')
if (item.currentStageName?.trim() === "产品打样") return true;
return String(
item.formSummary.approvalStageDisplay ||
item.currentApprovalStageName ||
"",
)
.split(/[\s,,、;/]+/)
.includes('产品打样')
.includes("产品打样");
}
function linkedProductId(workflowLink: string) {
try {
const segments = new URL(workflowLink).pathname.split('/').filter(Boolean)
const index = segments.lastIndexOf('non-integrated')
return index >= 0 ? decodeURIComponent(segments[index + 1] || '') : ''
const segments = new URL(workflowLink).pathname.split("/").filter(Boolean);
const index = segments.lastIndexOf("non-integrated");
return index >= 0 ? decodeURIComponent(segments[index + 1] || "") : "";
} catch {
return ''
return "";
}
}
function designSellingPointImage(payload: unknown): string | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
const files = (payload as { uploadFiles?: unknown }).uploadFiles
if (!Array.isArray(files)) return null
if (!payload || typeof payload !== "object" || Array.isArray(payload))
return null;
const files = (payload as { uploadFiles?: unknown }).uploadFiles;
if (!Array.isArray(files)) return null;
const file = files.find((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as { category?: unknown; 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
if (!value || typeof value !== "object" || Array.isArray(value))
return false;
const record = value as {
category?: unknown;
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[]) {
const productIds = Array.from(new Set(items.map((item) => linkedProductId(item.formSummary.workflowLink)).filter(Boolean)))
const products = await sampleProgressRepository.findDesignSellingPointImages(productIds)
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)]),
)
products.map((product): [string, string | null] => [
product.id,
designSellingPointImage(product.payload),
]),
);
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 openingCode = (item.formSummary.openingCode || item.openingCode || '').trim()
const reachedAt = sampleArrival(item)
if (!openingCode || !reachedAt) return []
const orderDate = new Date(reachedAt)
if (Number.isNaN(orderDate.getTime())) return []
const sampleDueDate = new Date(orderDate)
sampleDueDate.setDate(sampleDueDate.getDate() + 10)
const workflowLink = item.formSummary.workflowLink || ''
const productId = linkedProductId(workflowLink)
const imageUrl = designImageByProductId.get(linkedProductId(workflowLink)) || item.styleImages[0]?.url || null
return [{
openingCode: openingCode.toUpperCase(),
orderDate,
sampleDueDate,
oaReachedAt: orderDate,
imageUrl,
productDevelopmentFlowUrl: workflowLink || null,
productSize: '',
color: '',
material: '',
sampleQuantity: null,
brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '',
supplier: '',
finalSampleFactory: null,
status: 'BARE_WOOD',
flowStatus: item.formSummary.totalFlowStatusLabel || '',
actualSampleCompletedAt: actualSampleCompletedAt(item),
designer: structureDesignerByProductId.get(productId) || item.formSummary.structureDesigner || '',
purchaseOwner: item.formSummary.purchaseOwner || null,
merchandiserOwner: item.formSummary.merchandiserOwner || null,
progressDescription: null,
autoGenerated: true,
}]
})
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,
})
}))
const openingCode = (
item.formSummary.openingCode ||
item.openingCode ||
""
).trim();
const reachedAt = sampleArrival(item);
if (!openingCode || !reachedAt) return [];
const orderDate = new Date(reachedAt);
if (Number.isNaN(orderDate.getTime())) return [];
const sampleDueDate = new Date(orderDate);
sampleDueDate.setDate(sampleDueDate.getDate() + 10);
const workflowLink = item.formSummary.workflowLink || "";
const productId = linkedProductId(workflowLink);
const imageUrl =
designImageByProductId.get(linkedProductId(workflowLink)) ||
item.styleImages[0]?.url ||
null;
return [
{
openingCode: openingCode.toUpperCase(),
orderDate,
sampleDueDate,
oaReachedAt: orderDate,
imageUrl,
productDevelopmentFlowUrl: workflowLink || null,
productSize: "",
color: "",
material: "",
sampleQuantity: null,
brand:
item.formSummary.plannedSalesBrands ||
item.formSummary.salesBrands ||
item.brand ||
"",
supplier: "",
finalSampleFactory: null,
status: "BARE_WOOD",
flowStatus: item.formSummary.totalFlowStatusLabel || "",
actualSampleCompletedAt: actualSampleCompletedAt(item),
designer:
structureDesignerByProductId.get(productId) ||
item.formSummary.structureDesigner ||
"",
purchaseOwner: item.formSummary.purchaseOwner || null,
merchandiserOwner: item.formSummary.merchandiserOwner || null,
progressDescription: null,
autoGenerated: true,
},
];
});
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> = {
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 }) {
const actorName = actor.name?.trim() || ''
if (!canEditSampleProgress(actorName, owners)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '仅采购负责人、跟单负责人或荆川可编辑打样记录')
function assertCanEdit(
actor: SampleProgressActor,
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) {
if (value instanceof Date) return value.toISOString()
return value === undefined ? null : value
if (value instanceof Date) return value.toISOString();
return value === undefined ? null : value;
}
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() {
const dashboard = await oaBoardService.getDashboard({
overallStatus: SAMPLE_PROGRESS_SYNC_FLOW_STATUSES,
})
await syncFromOaItems(dashboard.items)
});
await syncFromOaItems(dashboard.items);
},
async listCurrentSampleStageOpeningCodes() {
const dashboard = await oaBoardService.getDashboard({
overallStatus: SAMPLE_PROGRESS_SYNC_FLOW_STATUSES,
})
});
return dashboard.items
.filter(isCurrentSampleStage)
.map((item) => (item.formSummary.openingCode || item.openingCode || '').trim().toUpperCase())
.filter(Boolean)
.map((item) =>
(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() {
await this.syncFromOaDashboard().catch(() => undefined)
return this.listStored()
await this.syncFromOaDashboard().catch(() => undefined);
return this.listStored();
},
getOaPrefill: readOaPrefill,
async create(input: SampleProgressInput, actor: SampleProgressActor) {
const merged = await mergeOaPrefill(input)
assertCanEdit(actor, merged)
return serialize(await sampleProgressRepository.create({ ...merged, createdBy: actor.id, updatedBy: actor.id }))
const merged = await mergeOaPrefill(input);
assertCanEdit(actor, merged);
return serialize(
await sampleProgressRepository.create({
...merged,
createdBy: actor.id,
updatedBy: actor.id,
}),
);
},
async update(id: string, input: SampleProgressUpdateInput, actor: SampleProgressActor) {
const current = await sampleProgressRepository.findById(id)
if (!current) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在')
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, '变更打样交期必须填写交期变更原因')
async update(
id: string,
input: SampleProgressUpdateInput,
actor: SampleProgressActor,
) {
const current = await sampleProgressRepository.findById(id);
if (!current)
throw new AppError(ErrorCode.VALIDATION_FAILED, "打样记录不存在");
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 before = comparable(current[field as keyof typeof current])
const after = comparable(merged[field as keyof typeof merged])
return JSON.stringify(before) === JSON.stringify(after) ? [] : [{ field, label: FIELD_LABELS[field], before, after }]
})
if (changedFields.length === 0) return serialize(current)
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 || '未知用户' }))
const before = comparable(current[field as keyof typeof current]);
const after = comparable(merged[field as keyof typeof merged]);
return JSON.stringify(before) === JSON.stringify(after)
? []
: [{ field, label: FIELD_LABELS[field], before, after }];
});
if (changedFields.length === 0) return serialize(current);
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) {
if (!(await sampleProgressRepository.findById(id))) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在')
return (await sampleProgressRepository.listChangeLogs(id)).map((log) => ({ ...log, createdAt: log.createdAt.toISOString() }))
if (!(await sampleProgressRepository.findById(id)))
throw new AppError(ErrorCode.VALIDATION_FAILED, "打样记录不存在");
return (await sampleProgressRepository.listChangeLogs(id)).map((log) => ({
...log,
createdAt: log.createdAt.toISOString(),
}));
},
async delete(id: string, actor: SampleProgressActor) {
const current = await sampleProgressRepository.findById(id)
if (!current) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在')
if (actor.name?.trim() !== '荆川') throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '仅荆川可以删除打样记录')
await sampleProgressRepository.delete(id)
const current = await sampleProgressRepository.findById(id);
if (!current)
throw new AppError(ErrorCode.VALIDATION_FAILED, "打样记录不存在");
if (actor.name?.trim() !== "荆川")
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
"仅荆川可以删除打样记录",
);
await sampleProgressRepository.delete(id);
},
}
};