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 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user