397 lines
11 KiB
TypeScript
397 lines
11 KiB
TypeScript
import type {
|
|
OrderSummary,
|
|
ProductionNode,
|
|
ProductionStatus,
|
|
RiskEvent,
|
|
RiskLevel,
|
|
SupplyType,
|
|
} from '@/types'
|
|
import { clamp, formatTime } from '@/lib/utils'
|
|
|
|
export const supplyMeta: Record<
|
|
SupplyType,
|
|
{ label: string; shortLabel: string; className: string }
|
|
> = {
|
|
self_made: { label: '自产', shortLabel: '自产', className: 'supply-self' },
|
|
outsourced: { label: '委外', shortLabel: '委外', className: 'supply-outsourced' },
|
|
purchased: { label: '外购', shortLabel: '外购', className: 'supply-purchased' },
|
|
}
|
|
|
|
export const statusMeta: Record<
|
|
ProductionStatus,
|
|
{ label: string; className: string }
|
|
> = {
|
|
waiting: { label: '待开始', className: 'status-waiting' },
|
|
in_progress: { label: '进行中', className: 'status-progress' },
|
|
done: { label: '已完成', className: 'status-done' },
|
|
delayed: { label: '已延期', className: 'status-delayed' },
|
|
blocked: { label: '阻塞', className: 'status-blocked' },
|
|
}
|
|
|
|
export const riskMeta: Record<RiskLevel, { label: string; className: string }> = {
|
|
normal: { label: '正常', className: 'risk-normal' },
|
|
warning: { label: '预警', className: 'risk-warning' },
|
|
critical: { label: '严重', className: 'risk-critical' },
|
|
}
|
|
|
|
export function flattenTree(node: ProductionNode): ProductionNode[] {
|
|
return [node, ...(node.children ?? []).flatMap(flattenTree)]
|
|
}
|
|
|
|
export interface OrderTreeStats {
|
|
totalNodes: number
|
|
partNodes: number
|
|
componentNodes: number
|
|
materialNodes: number
|
|
processNodes: number
|
|
maxDepth: number
|
|
}
|
|
|
|
export function calculateOrderTreeStats(root: ProductionNode): OrderTreeStats {
|
|
const stats: OrderTreeStats = {
|
|
totalNodes: 0,
|
|
partNodes: 0,
|
|
componentNodes: 0,
|
|
materialNodes: 0,
|
|
processNodes: 0,
|
|
maxDepth: 0,
|
|
}
|
|
|
|
const visit = (node: ProductionNode, depth: number) => {
|
|
stats.totalNodes += 1
|
|
stats.maxDepth = Math.max(stats.maxDepth, depth)
|
|
|
|
if (node.nodeType === 'part') stats.partNodes += 1
|
|
if (node.nodeType === 'component') stats.componentNodes += 1
|
|
if (node.nodeType === 'material') stats.materialNodes += 1
|
|
if (node.nodeType === 'process') stats.processNodes += 1
|
|
|
|
for (const child of node.children ?? []) {
|
|
visit(child, depth + 1)
|
|
}
|
|
}
|
|
|
|
visit(root, 0)
|
|
return stats
|
|
}
|
|
|
|
export function findNode(
|
|
root: ProductionNode | undefined,
|
|
nodeId: string | null,
|
|
): ProductionNode | undefined {
|
|
if (!root || !nodeId) {
|
|
return undefined
|
|
}
|
|
|
|
if (root.id === nodeId) {
|
|
return root
|
|
}
|
|
|
|
for (const child of root.children ?? []) {
|
|
const found = findNode(child, nodeId)
|
|
if (found) {
|
|
return found
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
export function findNodePath(
|
|
root: ProductionNode,
|
|
nodeId: string | null,
|
|
): ProductionNode[] {
|
|
if (!nodeId) {
|
|
return []
|
|
}
|
|
|
|
if (root.id === nodeId) {
|
|
return [root]
|
|
}
|
|
|
|
for (const child of root.children ?? []) {
|
|
const childPath = findNodePath(child, nodeId)
|
|
if (childPath.length > 0) {
|
|
return [root, ...childPath]
|
|
}
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
export function getPathEdgeIds(path: ProductionNode[]) {
|
|
return new Set(
|
|
path.slice(1).map((node, index) => `${path[index].id}-${node.id}`),
|
|
)
|
|
}
|
|
|
|
export function defaultExpandedIds(root: ProductionNode) {
|
|
const expanded = new Set<string>([root.id])
|
|
const priorityPart = root.children?.[1] ?? root.children?.[0]
|
|
if (priorityPart) {
|
|
expanded.add(priorityPart.id)
|
|
}
|
|
for (const component of priorityPart?.children?.slice(0, 2) ?? []) {
|
|
expanded.add(component.id)
|
|
}
|
|
return expanded
|
|
}
|
|
|
|
export function defaultSelectedNodeId(root: ProductionNode) {
|
|
const priorityPart = root.children?.[1] ?? root.children?.[0]
|
|
const priorityComponent = priorityPart?.children?.[1] ?? priorityPart?.children?.[0]
|
|
const priorityOperation = priorityComponent?.children?.[0]
|
|
|
|
return priorityOperation?.id ?? priorityComponent?.id ?? priorityPart?.id ?? root.id
|
|
}
|
|
|
|
export function recalculateTree(node: ProductionNode): ProductionNode {
|
|
const children = node.children?.map(recalculateTree) ?? []
|
|
const hasChildren = children.length > 0
|
|
|
|
if (!hasChildren) {
|
|
const progress = node.requiredQty <= 0
|
|
? 0
|
|
: clamp((node.completedQty / node.requiredQty) * 100, 0, 100)
|
|
const status = deriveLeafStatus(node, progress)
|
|
const riskLevel = deriveLeafRisk(node, progress, status)
|
|
|
|
return {
|
|
...node,
|
|
completedQty: Math.min(node.completedQty, node.requiredQty),
|
|
defectQty: Math.min(node.defectQty, Math.max(0, node.completedQty)),
|
|
progress,
|
|
status,
|
|
riskLevel,
|
|
}
|
|
}
|
|
|
|
const totalWeight = children.reduce((total, child) => total + child.requiredQty, 0)
|
|
const weightedProgress =
|
|
totalWeight === 0
|
|
? 0
|
|
: children.reduce(
|
|
(total, child) => total + child.progress * child.requiredQty,
|
|
0,
|
|
) / totalWeight
|
|
const delayDays = Math.max(node.delayDays, ...children.map((child) => child.delayDays))
|
|
const status = deriveParentStatus(children, weightedProgress)
|
|
const riskLevel = deriveParentRisk(children, delayDays, status)
|
|
|
|
return {
|
|
...node,
|
|
children,
|
|
completedQty: Math.round((node.requiredQty * weightedProgress) / 100),
|
|
defectQty: children.reduce((total, child) => total + child.defectQty, 0),
|
|
progress: weightedProgress,
|
|
status,
|
|
riskLevel,
|
|
delayDays,
|
|
}
|
|
}
|
|
|
|
export function recalculateOrder(order: OrderSummary): OrderSummary {
|
|
const root = recalculateTree(order.root)
|
|
const nodes = flattenTree(root)
|
|
const events = buildRiskEvents(order, nodes)
|
|
const riskCount = nodes.filter((node) => node.riskLevel !== 'normal').length
|
|
const plannedQty = Math.max(root.completedQty, Math.round(root.requiredQty * clamp(root.progress + 10, 0, 100) / 100))
|
|
const planAchievement = plannedQty === 0 ? 0 : clamp((root.completedQty / plannedQty) * 100, 0, 120)
|
|
|
|
return {
|
|
...order,
|
|
root,
|
|
requiredQty: root.requiredQty,
|
|
completedQty: root.completedQty,
|
|
progress: root.progress,
|
|
status: root.status,
|
|
riskLevel: root.riskLevel,
|
|
delayDays: root.delayDays,
|
|
planAchievement,
|
|
dailyDelta: clamp(order.dailyDelta + (Math.random() - 0.45) * 0.28, -9.9, 9.9),
|
|
events,
|
|
trend: [
|
|
...order.trend.slice(-11),
|
|
{
|
|
time: formatTime(),
|
|
completion: Math.round(root.progress),
|
|
risk: riskCount,
|
|
plannedQty,
|
|
actualQty: root.completedQty,
|
|
achievement: planAchievement,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
export function simulateOrdersTick(orders: OrderSummary[]): OrderSummary[] {
|
|
return orders.map((order, orderIndex) => {
|
|
const root = updateLeaves(order.root, orderIndex)
|
|
return recalculateOrder({ ...order, root })
|
|
})
|
|
}
|
|
|
|
function updateLeaves(node: ProductionNode, orderIndex: number): ProductionNode {
|
|
if (node.children?.length) {
|
|
return {
|
|
...node,
|
|
children: node.children.map((child) => updateLeaves(child, orderIndex)),
|
|
}
|
|
}
|
|
|
|
if (node.status === 'done') {
|
|
return node
|
|
}
|
|
|
|
const steadyBlocked = node.status === 'blocked' && Math.random() < 0.72
|
|
const criticalPause = node.riskLevel === 'critical' && Math.random() < 0.45
|
|
if (steadyBlocked || criticalPause) {
|
|
return node
|
|
}
|
|
|
|
const baseRate =
|
|
node.supplyType === 'self_made'
|
|
? 0.055
|
|
: node.supplyType === 'outsourced'
|
|
? 0.036
|
|
: 0.028
|
|
const momentum = 0.65 + Math.random() * 0.7 + orderIndex * 0.035
|
|
const increment = Math.max(1, Math.round(node.requiredQty * baseRate * momentum))
|
|
const completedQty = Math.min(node.requiredQty, node.completedQty + increment)
|
|
const progress = clamp((completedQty / node.requiredQty) * 100, 0, 100)
|
|
const defectBump =
|
|
node.riskLevel === 'critical'
|
|
? Math.random() < 0.28
|
|
: node.riskLevel === 'warning'
|
|
? Math.random() < 0.14
|
|
: Math.random() < 0.04
|
|
const delayDays =
|
|
progress < 100 && node.riskLevel !== 'normal' && Math.random() < 0.12
|
|
? Math.min(node.delayDays + 1, 9)
|
|
: node.delayDays
|
|
|
|
return {
|
|
...node,
|
|
completedQty,
|
|
defectQty: Math.min(completedQty, node.defectQty + (defectBump ? Math.max(1, Math.round(increment * 0.018)) : 0)),
|
|
progress,
|
|
delayDays,
|
|
actualStart: node.actualStart || node.plannedStart,
|
|
actualEnd: completedQty >= node.requiredQty ? formatDateOffset(0) : '',
|
|
}
|
|
}
|
|
|
|
function deriveLeafStatus(
|
|
node: ProductionNode,
|
|
progress: number,
|
|
): ProductionStatus {
|
|
if (progress >= 100) {
|
|
return 'done'
|
|
}
|
|
if (node.status === 'blocked') {
|
|
return 'blocked'
|
|
}
|
|
if (node.delayDays > 0 || node.riskLevel === 'critical') {
|
|
return 'delayed'
|
|
}
|
|
if (progress > 0) {
|
|
return 'in_progress'
|
|
}
|
|
return 'waiting'
|
|
}
|
|
|
|
function deriveLeafRisk(
|
|
node: ProductionNode,
|
|
progress: number,
|
|
status: ProductionStatus,
|
|
): RiskLevel {
|
|
if (status === 'blocked' || node.delayDays >= 3) {
|
|
return 'critical'
|
|
}
|
|
if (status === 'delayed' || node.delayDays > 0 || progress < 35) {
|
|
return 'warning'
|
|
}
|
|
return 'normal'
|
|
}
|
|
|
|
function deriveParentStatus(
|
|
children: ProductionNode[],
|
|
progress: number,
|
|
): ProductionStatus {
|
|
if (children.some((child) => child.status === 'blocked')) {
|
|
return 'blocked'
|
|
}
|
|
if (children.some((child) => child.status === 'delayed')) {
|
|
return 'delayed'
|
|
}
|
|
if (progress >= 100) {
|
|
return 'done'
|
|
}
|
|
if (progress > 0) {
|
|
return 'in_progress'
|
|
}
|
|
return 'waiting'
|
|
}
|
|
|
|
function deriveParentRisk(
|
|
children: ProductionNode[],
|
|
delayDays: number,
|
|
status: ProductionStatus,
|
|
): RiskLevel {
|
|
if (
|
|
delayDays >= 3 ||
|
|
status === 'blocked' ||
|
|
children.some((child) => child.riskLevel === 'critical')
|
|
) {
|
|
return 'critical'
|
|
}
|
|
if (
|
|
delayDays > 0 ||
|
|
status === 'delayed' ||
|
|
children.some((child) => child.riskLevel === 'warning')
|
|
) {
|
|
return 'warning'
|
|
}
|
|
return 'normal'
|
|
}
|
|
|
|
function buildRiskEvents(order: OrderSummary, nodes: ProductionNode[]): RiskEvent[] {
|
|
return nodes
|
|
.filter((node) => node.riskLevel !== 'normal' || node.delayDays > 0)
|
|
.sort((a, b) => {
|
|
const riskWeight = riskRank(b.riskLevel) - riskRank(a.riskLevel)
|
|
return riskWeight || b.delayDays - a.delayDays
|
|
})
|
|
.slice(0, 8)
|
|
.map((node, index) => ({
|
|
id: `${order.id}-${node.id}-${index}`,
|
|
orderId: order.id,
|
|
nodeId: node.id,
|
|
orderCode: order.code,
|
|
nodeName: node.name,
|
|
riskLevel: node.riskLevel,
|
|
message:
|
|
node.delayReason ||
|
|
(node.delayDays > 0
|
|
? `计划偏差 ${node.delayDays} 天,需要复核节拍`
|
|
: '进度低于当前节拍预测'),
|
|
time: formatTime(new Date(Date.now() - index * 1000 * 64)),
|
|
}))
|
|
}
|
|
|
|
function riskRank(riskLevel: RiskLevel) {
|
|
if (riskLevel === 'critical') {
|
|
return 3
|
|
}
|
|
if (riskLevel === 'warning') {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
|
|
function formatDateOffset(offset: number) {
|
|
const date = new Date()
|
|
date.setDate(date.getDate() + offset)
|
|
return date.toISOString().slice(0, 10)
|
|
}
|