merge: isolate oversized board packing

This commit is contained in:
WuYongJie 2026-08-11 05:41:14 +08:00
commit 7b95e5af33
9 changed files with 607 additions and 64 deletions

View File

@ -1,5 +1,6 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { canViewOaBoardDailyReport } from '@/server/services/oa-board-daily-report-access'
import { createEmptyOaBoardDashboard, oaBoardService } from '@/server/services/oa-board.service'
export const dynamic = 'force-dynamic'
@ -31,34 +32,48 @@ export async function GET(request: NextRequest) {
const nodeName = searchParams.get('nodeName') ?? ''
const timeoutOnly = searchParams.get('timeoutOnly') === '1'
const timeoutStatus = searchParams.get('timeoutStatus')
const reportDate = searchParams.get('reportDate') ?? ''
const canViewDailyReport = canViewOaBoardDailyReport(ctx.user)
try {
const dashboard = await oaBoardService.getDashboard({
keyword: keyword || undefined,
approvalNumber: approvalNumber || undefined,
overallStatus: overallStatus.length > 0 ? overallStatus : undefined,
createdDateStart: createdDateStart || undefined,
createdDateEnd: createdDateEnd || undefined,
productType: productType || undefined,
approvalStage: approvalStage || undefined,
developmentPlan: developmentPlan || undefined,
productPlanner: productPlanner || undefined,
structureDesigner: structureDesigner || undefined,
appearanceDesigner: appearanceDesigner || undefined,
purchaseOwner: purchaseOwner || undefined,
spuOwner: spuOwner || undefined,
owner: owner || undefined,
nodeName: nodeName || undefined,
timeoutOnly,
timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined,
})
const dashboard = await oaBoardService.getDashboard(
{
keyword: keyword || undefined,
approvalNumber: approvalNumber || undefined,
overallStatus: overallStatus.length > 0 ? overallStatus : undefined,
createdDateStart: createdDateStart || undefined,
createdDateEnd: createdDateEnd || undefined,
productType: productType || undefined,
approvalStage: approvalStage || undefined,
developmentPlan: developmentPlan || undefined,
productPlanner: productPlanner || undefined,
structureDesigner: structureDesigner || undefined,
appearanceDesigner: appearanceDesigner || undefined,
purchaseOwner: purchaseOwner || undefined,
spuOwner: spuOwner || undefined,
owner: owner || undefined,
nodeName: nodeName || undefined,
timeoutOnly,
timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined,
},
canViewDailyReport ? reportDate || undefined : undefined,
)
return jsonSuccess(dashboard, 200, ctx.requestId)
} catch (error) {
console.error('Failed to load OA board dashboard', error)
return jsonSuccess(
{
...createEmptyOaBoardDashboard(),
...dashboard,
dailyReport: canViewDailyReport ? dashboard.dailyReport : undefined,
},
200,
ctx.requestId,
)
} catch (error) {
console.error('Failed to load OA board dashboard', error)
const emptyDashboard = createEmptyOaBoardDashboard()
return jsonSuccess(
{
...emptyDashboard,
dailyReport: canViewDailyReport ? emptyDashboard.dailyReport : undefined,
notice: '当前看板表尚未完成迁移或同步,先返回空列表占位。',
},
200,

View File

@ -4,8 +4,10 @@ import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import {
AlertTriangle,
CalendarDays,
ChevronDown,
CheckCircle2,
ClipboardCheck,
Clock3,
Download,
ExternalLink,
@ -27,6 +29,7 @@ import {
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
import type {
OaBoardDashboardDTO,
OaBoardDailyReportDTO,
OaBoardProjectListItemDTO,
OaBoardStageGraphDTO,
OaBoardStageGraphNodeDTO,
@ -59,7 +62,14 @@ type FilterOption = {
label: string
}
type OaBoardViewTab = 'summary' | 'detail' | 'brand-planning' | 'timeout-analysis' | 'shipment' | 'listing'
type OaBoardViewTab =
| 'summary'
| 'daily'
| 'detail'
| 'brand-planning'
| 'timeout-analysis'
| 'shipment'
| 'listing'
type BrandPlanningFilterState = {
keyword: string
@ -165,6 +175,7 @@ const DEFAULT_STATUS_SELECT_VALUE = '__default__'
function parseOaBoardViewTab(value: string | null): OaBoardViewTab {
if (
value === 'daily' ||
value === 'detail' ||
value === 'brand-planning' ||
value === 'timeout-analysis' ||
@ -2711,6 +2722,126 @@ function BrandPlanningBoardPanel() {
)
}
function DailyReportPanel({
report,
onReportDateChange,
}: {
report: OaBoardDailyReportDTO
onReportDateChange: (value: string) => void
}) {
const completionMetrics = [
{
label: '新增新品流程',
value: report.newProjectCount,
detail: '按立项评审创建日期',
icon: CalendarDays,
accentClass: 'text-sky-700',
},
{
label: '完成新品流程',
value: report.completedProjectCount,
detail: '按总流程最终完成日期',
icon: CheckCircle2,
accentClass: 'text-emerald-700',
},
{
label: '首单下单完成',
value: report.firstOrderCompletedCount,
detail: '按首单子流程结束日期',
icon: ClipboardCheck,
accentClass: 'text-amber-700',
},
{
label: '美术设计完成',
value: report.artCompletedCount,
detail: '按美术设计申请结束日期',
icon: Sparkles,
accentClass: 'text-rose-700',
},
{
label: '限价流程完成',
value: report.pricingCompletedCount,
detail: '按新品限价流程结束日期',
icon: CheckCircle2,
accentClass: 'text-violet-700',
},
]
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<section className="overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-apple-separator px-4 py-3">
<div>
<h2 className="text-base font-apple-semibold tracking-apple-tight text-apple-label-primary">
流程日报
</h2>
<p className="mt-1 text-[11px] text-apple-label-secondary">按 OA 流程创建和结束时间统计</p>
</div>
<label className="inline-flex h-8 items-center gap-2 rounded-apple-md border border-apple-separator bg-apple-bg-secondary px-2.5 text-xs text-apple-label-secondary">
<CalendarDays className="h-3.5 w-3.5" />
<span>统计日期</span>
<input
type="date"
value={report.reportDate}
onChange={(event) => onReportDateChange(event.target.value)}
className="min-w-[124px] bg-transparent text-xs text-apple-label-primary outline-none"
/>
</label>
</div>
<div className="grid gap-2 px-3 py-3 sm:grid-cols-2 xl:grid-cols-5">
{completionMetrics.map((metric) => {
const Icon = metric.icon
return (
<div
key={metric.label}
className="flex h-[104px] min-w-0 flex-col justify-between rounded-md border border-black/8 bg-white px-3.5 py-3 shadow-apple-xs"
>
<div className="flex items-start justify-between gap-2">
<p className="text-xs text-apple-label-secondary">{metric.label}</p>
<Icon className={`h-4 w-4 shrink-0 ${metric.accentClass}`} />
</div>
<div>
<p className="text-[28px] font-apple-semibold leading-none text-apple-label-primary">
{metric.value}
</p>
<p className="mt-1.5 truncate text-[11px] text-apple-label-tertiary">{metric.detail}</p>
</div>
</div>
)
})}
</div>
</section>
<section className="grid gap-3 lg:grid-cols-2">
<div className="flex min-h-[132px] items-center justify-between rounded-lg border border-black/8 bg-white px-5 py-4 shadow-apple-xs">
<div>
<p className="text-sm font-apple-medium text-apple-label-primary">当前在途流程</p>
<p className="mt-1 text-xs text-apple-label-secondary">截至本次 OA 数据同步仍在推进</p>
</div>
<div className="text-right">
<p className="text-[32px] font-apple-semibold leading-none text-apple-label-primary">
{report.currentInProgressCount}
</p>
<p className="mt-1 text-[11px] text-apple-label-tertiary">条流程</p>
</div>
</div>
<div className="flex min-h-[132px] items-center justify-between rounded-lg border border-amber-200 bg-[#fffdf6] px-5 py-4 shadow-apple-xs">
<div>
<p className="text-sm font-apple-medium text-apple-label-primary">当前总流程超时</p>
<p className="mt-1 text-xs text-apple-label-secondary">仅按总实际时效与总目标时效判断</p>
</div>
<div className="text-right">
<p className="text-[32px] font-apple-semibold leading-none text-amber-700">
{report.currentOverallOvertimeCount}
</p>
<p className="mt-1 text-[11px] text-amber-700/70">条流程</p>
</div>
</div>
</section>
</div>
)
}
export default function OaBoardClient({
dashboard,
loadErrorMessage,
@ -2718,7 +2849,8 @@ export default function OaBoardClient({
dashboard: OaBoardDashboardDTO
loadErrorMessage?: string | null
}) {
const { items: allItems, stageGraph } = dashboard
const { items: allItems, stageGraph, dailyReport } = dashboard
const canViewDailyReport = Boolean(dailyReport)
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
@ -2775,13 +2907,17 @@ export default function OaBoardClient({
[filterSearchParamsKey],
)
const [filters, setFilters] = useState<FilterState>(appliedFilters)
const [activeTab, setActiveTab] = useState<OaBoardViewTab>(() =>
parseOaBoardViewTab(searchParams.get('tab')),
)
const [activeTab, setActiveTab] = useState<OaBoardViewTab>(() => {
const requestedTab = parseOaBoardViewTab(searchParams.get('tab'))
return requestedTab === 'daily' && !canViewDailyReport ? 'summary' : requestedTab
})
const showingDailyReport = canViewDailyReport && activeTab === 'daily'
const activeTabLabel =
activeTab === 'summary'
? '新品进度汇总'
: activeTab === 'detail'
: showingDailyReport
? '新品流程日报'
: activeTab === 'detail'
? '新品流程明细'
: activeTab === 'brand-planning'
? '新品品牌策划看板'
@ -2801,6 +2937,15 @@ export default function OaBoardClient({
disabled: false,
onClick: () => switchTab('summary'),
},
...(canViewDailyReport
? [{
key: 'daily' as const,
label: '新品流程日报',
active: showingDailyReport,
disabled: false,
onClick: () => switchTab('daily'),
}]
: []),
{
key: 'detail',
label: '新品流程明细',
@ -2847,6 +2992,7 @@ export default function OaBoardClient({
}
const switchTab = (nextTab: OaBoardViewTab) => {
if (nextTab === 'daily' && !canViewDailyReport) return
if (nextTab === activeTab) return
const params = new URLSearchParams(searchParams.toString())
@ -2861,6 +3007,16 @@ export default function OaBoardClient({
window.history.replaceState(window.history.state, '', query ? `${pathname}?${query}` : pathname)
}
const changeReportDate = (nextDate: string) => {
if (!dailyReport || !nextDate || nextDate === dailyReport.reportDate) return
const params = preserveActiveTab(new URLSearchParams(searchParams.toString()))
params.set('reportDate', nextDate)
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`)
})
}
useEffect(() => {
setFilters(appliedFilters)
setCurrentPage(1)
@ -3287,7 +3443,7 @@ export default function OaBoardClient({
<>
<div
className={
activeTab === 'summary'
activeTab === 'summary' || showingDailyReport
? 'min-h-full bg-[#f5f7f8]'
: 'flex h-[calc(100vh-72px)] min-h-0 flex-col overflow-hidden bg-[#f5f7f8]'
}
@ -3312,13 +3468,17 @@ export default function OaBoardClient({
<LoaderCircle className={`h-3.5 w-3.5 ${isPending ? 'animate-spin' : ''}`} />
{activeTab === 'summary'
? `${overviewItems.length} 条流程`
: showingDailyReport && dailyReport
? `${dailyReport.reportDate} 日报`
: activeTab === 'brand-planning'
? 'SKU 维度'
: `${items.length} 条命中`}
</span>
</header>
{activeTab === 'summary' ? (
{showingDailyReport && dailyReport ? (
<DailyReportPanel report={dailyReport} onReportDateChange={changeReportDate} />
) : activeTab === 'summary' ? (
<>
<section className="shrink-0 overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">
<div className="flex items-center justify-between border-b border-apple-separator px-4 py-3">

View File

@ -5,6 +5,7 @@ import {
oaBoardService,
} from '@/server/services/oa-board.service'
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
import { canViewOaBoardDailyReport } from '@/server/services/oa-board-daily-report-access'
import { readOaBoardLocalPreviewDashboard } from '@/server/services/oa-board-local-preview.service'
import OaBoardClient from './OaBoardClient'
@ -16,10 +17,8 @@ export default async function OaBoardPage({
searchParams?: Promise<Record<string, string | string[] | undefined>>
}) {
const allowLocalPreview = process.env.OA_BOARD_LOCAL_PREVIEW === '1'
if (!allowLocalPreview) {
await requireSession('/login')
}
const session = allowLocalPreview ? null : await requireSession('/login')
const canViewDailyReport = canViewOaBoardDailyReport(session?.authUser)
const params = searchParams ? await searchParams : {}
const readSingleParam = (value: string | string[] | undefined) =>
@ -47,30 +46,34 @@ export default async function OaBoardPage({
const nodeName = readSingleParam(params.nodeName)
const timeoutOnly = params.timeoutOnly === '1'
const timeoutStatus = readSingleParam(params.timeoutStatus)
const reportDate = readSingleParam(params.reportDate)
const loadLiveDashboard = () =>
oaBoardService.getDashboard({
keyword: keyword || undefined,
approvalNumber: approvalNumber || undefined,
overallStatus:
overallStatus.length > 0
? overallStatus
: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
createdDateStart: createdDateStart || undefined,
createdDateEnd: createdDateEnd || undefined,
productType: productType || undefined,
approvalStage: approvalStage || undefined,
developmentPlan: developmentPlan || undefined,
productPlanner: productPlanner || undefined,
structureDesigner: structureDesigner || undefined,
appearanceDesigner: appearanceDesigner || undefined,
purchaseOwner: purchaseOwner || undefined,
spuOwner: spuOwner || undefined,
owner: owner || undefined,
nodeName: nodeName || undefined,
timeoutOnly,
timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined,
})
oaBoardService.getDashboard(
{
keyword: keyword || undefined,
approvalNumber: approvalNumber || undefined,
overallStatus:
overallStatus.length > 0
? overallStatus
: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
createdDateStart: createdDateStart || undefined,
createdDateEnd: createdDateEnd || undefined,
productType: productType || undefined,
approvalStage: approvalStage || undefined,
developmentPlan: developmentPlan || undefined,
productPlanner: productPlanner || undefined,
structureDesigner: structureDesigner || undefined,
appearanceDesigner: appearanceDesigner || undefined,
purchaseOwner: purchaseOwner || undefined,
spuOwner: spuOwner || undefined,
owner: owner || undefined,
nodeName: nodeName || undefined,
timeoutOnly,
timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined,
},
canViewDailyReport ? reportDate || undefined : undefined,
)
const resolveDashboard = () =>
loadLiveDashboard()
@ -116,12 +119,16 @@ export default async function OaBoardPage({
nodeName,
timeoutOnly,
timeoutStatus,
reportDate: canViewDailyReport ? reportDate : '',
})
const dashboard = canViewDailyReport
? result.dashboard
: { ...result.dashboard, dailyReport: undefined }
return (
<OaBoardClient
key={pageStateKey}
dashboard={result.dashboard}
dashboard={dashboard}
loadErrorMessage={result.loadErrorMessage}
/>
)

View File

@ -1,7 +1,9 @@
import {
buildOaBoardDailyReport,
hasOaBoardProjectOvertime,
type OaBoardProjectListItemDTO,
} from '../oa-board.service'
import { canViewOaBoardDailyReport } from '../oa-board-daily-report-access'
function buildItem({
overallTimeoutFlag = false,
@ -47,3 +49,99 @@ describe('OA 看板总超时口径', () => {
expect(hasOaBoardProjectOvertime(buildItem({ overallTimeoutFlag: true }))).toBe(true)
})
})
describe('OA 看板流程日报', () => {
it('按主流程和各子流程的实际事件时间分别统计', () => {
const report = buildOaBoardDailyReport(
[
{
boardKey: 'OPENING:XP001',
overallStatus: 'COMPLETED',
overallTimeoutFlag: false,
flowChips: [
{
flowType: 'review',
processInstanceId: 'review-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-06T01:00:00.000Z',
finishedAtSource: '2026-08-06T02:00:00.000Z',
},
{
flowType: 'order',
processInstanceId: 'order-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-06T02:00:00.000Z',
finishedAtSource: '2026-08-06T04:00:00.000Z',
},
{
flowType: 'art',
processInstanceId: 'art-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-06T02:00:00.000Z',
finishedAtSource: '2026-08-06T05:00:00.000Z',
},
{
flowType: 'pricing',
processInstanceId: 'pricing-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-06T05:00:00.000Z',
finishedAtSource: '2026-08-06T06:00:00.000Z',
},
],
},
{
boardKey: 'OPENING:XP002',
overallStatus: 'RUNNING',
overallTimeoutFlag: true,
flowChips: [
{
flowType: 'review',
processInstanceId: 'review-002',
status: 'RUNNING',
result: null,
createdAtSource: '2026-08-06T07:00:00.000Z',
finishedAtSource: null,
},
],
},
{
boardKey: 'OPENING:XP003',
overallStatus: 'TERMINATED',
overallTimeoutFlag: false,
flowChips: [
{
flowType: 'order',
processInstanceId: 'order-003',
status: 'COMPLETED',
result: 'REFUSE',
createdAtSource: '2026-08-06T01:00:00.000Z',
finishedAtSource: '2026-08-06T03:00:00.000Z',
},
],
},
] as OaBoardProjectListItemDTO[],
'2026-08-06',
)
expect(report).toMatchObject({
reportDate: '2026-08-06',
newProjectCount: 2,
completedProjectCount: 1,
firstOrderCompletedCount: 1,
artCompletedCount: 1,
pricingCompletedCount: 1,
currentInProgressCount: 1,
currentOverallOvertimeCount: 1,
})
})
it('仅授权荆川查看日报', () => {
expect(canViewOaBoardDailyReport({ name: '荆川' })).toBe(true)
expect(canViewOaBoardDailyReport({ name: '荆川 ' })).toBe(true)
expect(canViewOaBoardDailyReport({ name: '其他用户' })).toBe(false)
})
})

View File

@ -363,6 +363,79 @@ describe('BomCostingScenarioService', () => {
})
})
it('单个超尺寸板件不会让同一 SKU 的其他板件全部退出批量裁切', async () => {
const bomLines = [
line({
id: 'fit-board',
category: 'BOARD',
partName: '可排版层板',
materialName: '橡胶木',
materialCode: 'RUBBERWOOD',
materialSpec: '明确标注板厚 18mm',
unit: '块',
quantity: 4,
lengthMm: 600,
widthMm: 600,
}),
line({
id: 'oversized-board',
category: 'BOARD',
partName: '超尺寸背板',
materialName: '橡胶木',
materialCode: 'RUBBERWOOD',
materialSpec: '明确标注板厚 18mm',
unit: '块',
quantity: 1,
lengthMm: 2_500,
widthMm: 1_000,
}),
]
mockBase({ bomLines })
jest.mocked(bomCostingScenarioRepository.listSubstrateSheetCandidates).mockResolvedValue([{
id: 'substrate-rubberwood-18',
code: 'RW-18',
name: '橡胶木指接板 18mm',
categoryName: '实木板',
systemName: '橡胶木',
environmentGrade: 'E0',
aliases: ['RW-18', '橡胶木', '橡胶木指接板'],
thicknessMm: 18,
sheetLengthMm: 2_440,
sheetWidthMm: 1_220,
fullSheetPrice: 200,
updatedAt: now,
}])
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockImplementation(async (input) => [
candidate({
id: `historical-${input.partName}`,
category: 'BOARD',
partName: input.partName,
materialName: '橡胶木',
unit: '块',
unitPrice: input.partName === '超尺寸背板' ? 180 : 50,
}),
])
const service = new BomCostingScenarioService()
jest.spyOn(service, 'getCaseDynamicCosting').mockResolvedValue({} as never)
await service.calculate({
caseId: 'case-1',
skuId: 'sku-1',
targetMaterialId: 'material-rubberwood',
})
const saved = jest.mocked(bomCostingScenarioRepository.saveScenarioPair).mock.calls[0]![0]
const sameLines = new Map(saved.sameMaterial.lines.map((item) => [item.sourceLineId, item]))
expect(sameLines.get('fit-board')).toMatchObject({
calculationRule: 'board-sheet-maxrects/v2-batch',
priceSource: expect.stringContaining('批量裁切'),
})
expect(sameLines.get('oversized-board')).toMatchObject({
calculationRule: expect.not.stringContaining('board-sheet-maxrects'),
subtotal: 180,
})
})
it('小板件主成本采用同款批量排版,单件整板成本只保留在区间上限', async () => {
const bomLines = [line({
id: 'small-board',

View File

@ -505,11 +505,29 @@ function readNonNegativeEnvironmentNumber(name: string, fallback: number) {
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback
}
function boardPartFitsSheet(input: {
lengthMm: number
widthMm: number
candidate: BomCostingSubstrateSheetCandidate
kerfMm: number
edgeMarginMm: number
}) {
const usableLength = input.candidate.sheetLengthMm - input.edgeMarginMm * 2 + input.kerfMm
const usableWidth = input.candidate.sheetWidthMm - input.edgeMarginMm * 2 + input.kerfMm
if (usableLength <= 0 || usableWidth <= 0) return false
return (
(input.lengthMm + input.kerfMm <= usableLength && input.widthMm + input.kerfMm <= usableWidth) ||
(input.widthMm + input.kerfMm <= usableLength && input.lengthMm + input.kerfMm <= usableWidth)
)
}
function buildBoardSheetPriceQuotes(input: {
lines: BomCostingLine[]
candidates: BomCostingSubstrateSheetCandidate[]
materialNamesForLine: (line: BomCostingLine) => string[]
}) {
const kerfMm = readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_KERF_MM', 4)
const edgeMarginMm = readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 5)
const assigned = input.lines.flatMap((line) => {
if (line.category !== 'BOARD') return []
const lengthMm = positiveFinite(line.lengthMm)
@ -521,7 +539,34 @@ function buildBoardSheetPriceQuotes(input: {
input.candidates,
input.materialNamesForLine(line),
)
return selection ? [{ line, selection, candidate: selection.candidate, lengthMm, widthMm, quantity }] : []
if (!selection) return []
const fittingCandidates = selection.compatibleCandidates.filter((candidate) => boardPartFitsSheet({
lengthMm,
widthMm,
candidate,
kerfMm,
edgeMarginMm,
}))
if (fittingCandidates.length === 0) return []
const candidate = [...fittingCandidates].sort((left, right) => (
Math.abs(left.fullSheetPrice - selection.candidate.fullSheetPrice) -
Math.abs(right.fullSheetPrice - selection.candidate.fullSheetPrice) ||
Math.abs(left.thicknessMm - selection.candidate.thicknessMm) -
Math.abs(right.thicknessMm - selection.candidate.thicknessMm) ||
right.updatedAt.getTime() - left.updatedAt.getTime()
))[0]!
return [{
line,
selection: {
...selection,
candidate,
compatibleCandidates: fittingCandidates,
},
candidate,
lengthMm,
widthMm,
quantity,
}]
})
const groups = new Map<string, typeof assigned>()
for (const entry of assigned) {
@ -538,12 +583,12 @@ function buildBoardSheetPriceQuotes(input: {
sheetLengthMm: candidate.sheetLengthMm,
sheetWidthMm: candidate.sheetWidthMm,
fullSheetPrice: candidate.fullSheetPrice,
kerfMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_KERF_MM', 4),
kerfMm,
// Five millimetres on each edge keeps a real trimming allowance while
// still allowing two 600 mm parts on a common 1220 mm sheet with kerf.
// The former 10 mm-per-edge default falsely doubled sheet counts for
// this very common layout and surfaced the trim as excessive loss.
edgeMarginMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 5),
edgeMarginMm,
}, group.map(({ line, lengthMm, widthMm, quantity }) => ({
lineId: line.id,
lengthMm,

View File

@ -0,0 +1,9 @@
import 'server-only'
import type { AuthUser } from '@/server/core/types'
const OA_BOARD_DAILY_REPORT_VIEWER_NAMES = new Set(['荆川'])
export function canViewOaBoardDailyReport(user: Pick<AuthUser, 'name'> | null | undefined) {
return OA_BOARD_DAILY_REPORT_VIEWER_NAMES.has(String(user?.name ?? '').trim())
}

View File

@ -625,4 +625,9 @@ export const OA_BOARD_MANUAL_OVERALL_STATUS_OVERRIDES = {
XP2026072300000345: 'TERMINATED',
XP2026070300000255: 'TERMINATED',
XP2026070400000261: 'TERMINATED',
XP2026070100000242: 'TERMINATED',
XP2026071700000329: 'TERMINATED',
XP2026071800000332: 'TERMINATED',
XP2026080100000354: 'TERMINATED',
XP2026080300000367: 'TERMINATED',
} as const

View File

@ -163,6 +163,17 @@ export interface OaBoardSummaryDTO {
productTypes: string[]
}
export interface OaBoardDailyReportDTO {
reportDate: string
newProjectCount: number
completedProjectCount: number
firstOrderCompletedCount: number
artCompletedCount: number
pricingCompletedCount: number
currentInProgressCount: number
currentOverallOvertimeCount: number
}
export interface OaBoardStageGraphFlowCountDTO {
flowType: string
flowLabel: string
@ -211,6 +222,117 @@ function parseDateSortValue(value: string | null | undefined) {
return Number.isFinite(dateValue) ? dateValue : 0
}
function getShanghaiDateKey(value: Date | string) {
const date = typeof value === 'string' ? new Date(value) : value
if (!Number.isFinite(date.getTime())) return null
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date)
const partValue = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? ''
const year = partValue('year')
const month = partValue('month')
const day = partValue('day')
return year && month && day ? `${year}-${month}-${day}` : null
}
function isValidDateKey(value: string) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!match) return false
const [, yearText, monthText, dayText] = match
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
const date = new Date(Date.UTC(year, month - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
)
}
export function normalizeOaBoardDailyReportDate(value?: string, referenceDate = new Date()) {
const normalized = String(value ?? '').trim()
if (isValidDateKey(normalized)) return normalized
return getShanghaiDateKey(referenceDate) ?? '1970-01-01'
}
function isSuccessfulCompletedFlow(flow: OaBoardFlowChipDTO) {
const status = flow.status.trim().toUpperCase()
const result = String(flow.result ?? '').trim().toUpperCase()
return status === 'COMPLETED' && result !== 'REFUSE' && result !== 'REFUSED'
}
function latestFinishedDateKey(item: OaBoardProjectListItemDTO) {
const timestamps = item.flowChips
.map((flow) => (flow.finishedAtSource ? new Date(flow.finishedAtSource).getTime() : Number.NaN))
.filter((value) => Number.isFinite(value))
if (timestamps.length === 0) return null
return getShanghaiDateKey(new Date(Math.max(...timestamps)))
}
function countCompletedSubflows(
items: OaBoardProjectListItemDTO[],
reportDate: string,
flowType: string,
) {
const completedInstances = new Set<string>()
items.forEach((item) => {
item.flowChips.forEach((flow) => {
if (
flow.flowType === flowType &&
isSuccessfulCompletedFlow(flow) &&
flow.finishedAtSource &&
getShanghaiDateKey(flow.finishedAtSource) === reportDate
) {
completedInstances.add(flow.processInstanceId || `${item.boardKey}:${flowType}`)
}
})
})
return completedInstances.size
}
export function buildOaBoardDailyReport(
items: OaBoardProjectListItemDTO[],
requestedDate?: string,
): OaBoardDailyReportDTO {
const reportDate = normalizeOaBoardDailyReportDate(requestedDate)
const newProjects = new Set<string>()
const completedProjects = new Set<string>()
items.forEach((item) => {
const reviewFlow = item.flowChips.find((flow) => flow.flowType === 'review')
if (reviewFlow?.createdAtSource && getShanghaiDateKey(reviewFlow.createdAtSource) === reportDate) {
newProjects.add(item.boardKey)
}
if (item.overallStatus === 'COMPLETED' && latestFinishedDateKey(item) === reportDate) {
completedProjects.add(item.boardKey)
}
})
return {
reportDate,
newProjectCount: newProjects.size,
completedProjectCount: completedProjects.size,
firstOrderCompletedCount: countCompletedSubflows(items, reportDate, 'order'),
artCompletedCount: countCompletedSubflows(items, reportDate, 'art'),
pricingCompletedCount: countCompletedSubflows(items, reportDate, 'pricing'),
currentInProgressCount: items.filter((item) => item.overallStatus === 'RUNNING').length,
currentOverallOvertimeCount: items.filter(hasOaBoardProjectOvertime).length,
}
}
function splitApprovalStages(value: string | null | undefined) {
return String(value ?? '')
.split(';')
@ -309,6 +431,7 @@ export interface OaBoardStageMatrixRowDTO {
export interface OaBoardDashboardDTO {
items: OaBoardProjectListItemDTO[]
summary: OaBoardSummaryDTO
dailyReport?: OaBoardDailyReportDTO
stageMatrix: OaBoardStageMatrixRowDTO[]
stageGraph: OaBoardStageGraphDTO
}
@ -406,8 +529,10 @@ export interface OaBoardProjectGraphInput {
}
export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoardDashboardDTO {
const items: OaBoardProjectListItemDTO[] = []
return {
items: [],
items,
summary: {
totalProjects: 0,
timeoutProjects: 0,
@ -415,8 +540,9 @@ export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoar
completedProjects: 0,
productTypes,
},
dailyReport: buildOaBoardDailyReport(items),
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph([]),
stageGraph: buildStageGraph(items),
}
}
@ -718,6 +844,7 @@ export function createMockOaBoardDashboard(): OaBoardDashboardDTO {
completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length,
productTypes,
},
dailyReport: buildOaBoardDailyReport(items),
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}
@ -1228,7 +1355,10 @@ export function filterOaBoardProjectItems(
}
export class OaBoardService {
async getDashboard(filter: OaBoardProjectListFilter = {}): Promise<OaBoardDashboardDTO> {
async getDashboard(
filter: OaBoardProjectListFilter = {},
requestedReportDate?: string,
): Promise<OaBoardDashboardDTO> {
const snapshotMaxAgeMinutesRaw = process.env.OA_BOARD_SNAPSHOT_MAX_AGE_MINUTES?.trim()
const snapshotMaxAgeMinutes = snapshotMaxAgeMinutesRaw ? Number(snapshotMaxAgeMinutesRaw) : 60
const snapshotMaxAgeMs =
@ -1291,6 +1421,7 @@ export class OaBoardService {
completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length,
productTypes,
},
dailyReport: buildOaBoardDailyReport(allItems, requestedReportDate),
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}