Merge supply-chain new product timing overview
This commit is contained in:
commit
75f7b3ac7f
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user