project cockpit
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import type {
|
||||
ProjectCockpitDto,
|
||||
ProjectCockpitPurchaseOrderDto,
|
||||
ProjectCockpitReceiptDto,
|
||||
ProjectCockpitRiskLevel,
|
||||
ProjectCockpitVendorDto,
|
||||
ProjectCustomerOptionDto,
|
||||
ProjectDetailDto,
|
||||
ProjectDocumentOptionDto,
|
||||
@@ -13,9 +18,11 @@ import type {
|
||||
ProjectSummaryDto,
|
||||
WorkOrderStatus,
|
||||
} from "@mrp/shared";
|
||||
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { getSalesOrderPlanningById } from "../sales/planning.js";
|
||||
|
||||
const projectModel = (prisma as any).project;
|
||||
|
||||
@@ -70,6 +77,76 @@ type ProjectRecord = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type ProjectSalesDocumentRecord = {
|
||||
documentNumber: string;
|
||||
status: string;
|
||||
discountPercent: number;
|
||||
taxPercent: number;
|
||||
freightAmount: number;
|
||||
lines: Array<{
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ProjectShipmentRecord = {
|
||||
shipmentNumber: string;
|
||||
status: string;
|
||||
shipDate: Date | null;
|
||||
packageCount: number;
|
||||
trackingNumber: string;
|
||||
carrier: string;
|
||||
serviceLevel: string;
|
||||
};
|
||||
|
||||
type ProjectPurchaseOrderRecord = {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
status: string;
|
||||
issueDate: Date;
|
||||
vendor: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
lines: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
unitCost: number;
|
||||
receiptLines: Array<{
|
||||
quantity: number;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ProjectReceiptLineRecord = {
|
||||
quantity: number;
|
||||
purchaseReceipt: {
|
||||
id: string;
|
||||
receiptNumber: string;
|
||||
receivedAt: Date;
|
||||
purchaseOrder: {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
vendor: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function roundMoney(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function calculateSalesDocumentTotal(record: ProjectSalesDocumentRecord) {
|
||||
const subtotal = roundMoney(record.lines.reduce((sum, line) => sum + (line.quantity * line.unitPrice), 0));
|
||||
const discountAmount = roundMoney(subtotal * (record.discountPercent / 100));
|
||||
const taxableSubtotal = roundMoney(subtotal - discountAmount);
|
||||
const taxAmount = roundMoney(taxableSubtotal * (record.taxPercent / 100));
|
||||
|
||||
return roundMoney(taxableSubtotal + taxAmount + roundMoney(record.freightAmount));
|
||||
}
|
||||
|
||||
function getOwnerName(owner: ProjectRecord["owner"]) {
|
||||
return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null;
|
||||
}
|
||||
@@ -114,6 +191,297 @@ function buildProjectRollups(record: ProjectRecord): ProjectRollupDto {
|
||||
};
|
||||
}
|
||||
|
||||
function deriveProjectRiskLevel(score: number): ProjectCockpitRiskLevel {
|
||||
if (score >= 85) {
|
||||
return "LOW";
|
||||
}
|
||||
if (score >= 65) {
|
||||
return "MEDIUM";
|
||||
}
|
||||
return "HIGH";
|
||||
}
|
||||
|
||||
async function buildProjectCockpit(record: ProjectRecord, rollups: ProjectRollupDto): Promise<ProjectCockpitDto> {
|
||||
const blockedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "BLOCKED").length;
|
||||
|
||||
const [quote, salesOrder, shipment, purchaseOrders, receiptLines, planning] = await Promise.all([
|
||||
record.salesQuote
|
||||
? prisma.salesQuote.findUnique({
|
||||
where: { id: record.salesQuote.id },
|
||||
select: {
|
||||
documentNumber: true,
|
||||
status: true,
|
||||
discountPercent: true,
|
||||
taxPercent: true,
|
||||
freightAmount: true,
|
||||
lines: {
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
record.salesOrder
|
||||
? prisma.salesOrder.findUnique({
|
||||
where: { id: record.salesOrder.id },
|
||||
select: {
|
||||
documentNumber: true,
|
||||
status: true,
|
||||
discountPercent: true,
|
||||
taxPercent: true,
|
||||
freightAmount: true,
|
||||
lines: {
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
record.shipment
|
||||
? prisma.shipment.findUnique({
|
||||
where: { id: record.shipment.id },
|
||||
select: {
|
||||
shipmentNumber: true,
|
||||
status: true,
|
||||
shipDate: true,
|
||||
packageCount: true,
|
||||
trackingNumber: true,
|
||||
carrier: true,
|
||||
serviceLevel: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
record.salesOrder
|
||||
? prisma.purchaseOrder.findMany({
|
||||
where: {
|
||||
lines: {
|
||||
some: {
|
||||
salesOrderId: record.salesOrder.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
status: true,
|
||||
issueDate: true,
|
||||
vendor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
lines: {
|
||||
where: {
|
||||
salesOrderId: record.salesOrder.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
quantity: true,
|
||||
unitCost: true,
|
||||
receiptLines: {
|
||||
select: {
|
||||
quantity: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
record.salesOrder
|
||||
? prisma.purchaseReceiptLine.findMany({
|
||||
where: {
|
||||
purchaseOrderLine: {
|
||||
salesOrderId: record.salesOrder.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
quantity: true,
|
||||
purchaseReceipt: {
|
||||
select: {
|
||||
id: true,
|
||||
receiptNumber: true,
|
||||
receivedAt: true,
|
||||
purchaseOrder: {
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
vendor: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ purchaseReceipt: { receivedAt: "desc" } }, { createdAt: "desc" }],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
record.salesOrder ? getSalesOrderPlanningById(record.salesOrder.id) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const typedPurchaseOrders = purchaseOrders as ProjectPurchaseOrderRecord[];
|
||||
const typedReceiptLines = receiptLines as ProjectReceiptLineRecord[];
|
||||
const typedQuote = quote as ProjectSalesDocumentRecord | null;
|
||||
const typedSalesOrder = salesOrder as ProjectSalesDocumentRecord | null;
|
||||
const typedShipment = shipment as ProjectShipmentRecord | null;
|
||||
const typedPlanning = planning as SalesOrderPlanningDto | null;
|
||||
|
||||
const purchaseOrdersSummary: ProjectCockpitPurchaseOrderDto[] = typedPurchaseOrders.map((purchaseOrder) => {
|
||||
const linkedLineCount = purchaseOrder.lines.length;
|
||||
const linkedLineValue = roundMoney(
|
||||
purchaseOrder.lines.reduce((sum, line) => sum + (line.quantity * line.unitCost), 0)
|
||||
);
|
||||
const totalOrderedQuantity = purchaseOrder.lines.reduce((sum, line) => sum + line.quantity, 0);
|
||||
const totalReceivedQuantity = purchaseOrder.lines.reduce(
|
||||
(sum, line) => sum + line.receiptLines.reduce((lineSum, receiptLine) => lineSum + receiptLine.quantity, 0),
|
||||
0
|
||||
);
|
||||
const totalOutstandingQuantity = Math.max(0, totalOrderedQuantity - totalReceivedQuantity);
|
||||
|
||||
return {
|
||||
id: purchaseOrder.id,
|
||||
documentNumber: purchaseOrder.documentNumber,
|
||||
vendorId: purchaseOrder.vendor.id,
|
||||
vendorName: purchaseOrder.vendor.name,
|
||||
status: purchaseOrder.status,
|
||||
issueDate: purchaseOrder.issueDate.toISOString(),
|
||||
linkedLineCount,
|
||||
linkedLineValue,
|
||||
totalOrderedQuantity,
|
||||
totalReceivedQuantity,
|
||||
totalOutstandingQuantity,
|
||||
receiptCount: purchaseOrder.lines.reduce((sum, line) => sum + line.receiptLines.length, 0),
|
||||
};
|
||||
});
|
||||
|
||||
const vendorMap = new Map<string, ProjectCockpitVendorDto>();
|
||||
for (const purchaseOrder of purchaseOrdersSummary) {
|
||||
const existing = vendorMap.get(purchaseOrder.vendorId);
|
||||
if (existing) {
|
||||
existing.orderCount += 1;
|
||||
existing.linkedLineValue = roundMoney(existing.linkedLineValue + purchaseOrder.linkedLineValue);
|
||||
existing.outstandingQuantity += purchaseOrder.totalOutstandingQuantity;
|
||||
continue;
|
||||
}
|
||||
|
||||
vendorMap.set(purchaseOrder.vendorId, {
|
||||
vendorId: purchaseOrder.vendorId,
|
||||
vendorName: purchaseOrder.vendorName,
|
||||
orderCount: 1,
|
||||
linkedLineValue: purchaseOrder.linkedLineValue,
|
||||
outstandingQuantity: purchaseOrder.totalOutstandingQuantity,
|
||||
});
|
||||
}
|
||||
|
||||
const receiptMap = new Map<string, ProjectCockpitReceiptDto>();
|
||||
for (const receiptLine of typedReceiptLines) {
|
||||
const receipt = receiptLine.purchaseReceipt;
|
||||
const existing = receiptMap.get(receipt.id);
|
||||
if (existing) {
|
||||
existing.totalQuantity += receiptLine.quantity;
|
||||
continue;
|
||||
}
|
||||
|
||||
receiptMap.set(receipt.id, {
|
||||
receiptId: receipt.id,
|
||||
receiptNumber: receipt.receiptNumber,
|
||||
purchaseOrderId: receipt.purchaseOrder.id,
|
||||
purchaseOrderNumber: receipt.purchaseOrder.documentNumber,
|
||||
vendorName: receipt.purchaseOrder.vendor.name,
|
||||
receivedAt: receipt.receivedAt.toISOString(),
|
||||
totalQuantity: receiptLine.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
const totalOrderedQuantity = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.totalOrderedQuantity, 0);
|
||||
const totalReceivedQuantity = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.totalReceivedQuantity, 0);
|
||||
const totalOutstandingQuantity = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.totalOutstandingQuantity, 0);
|
||||
const linkedLineCount = purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.linkedLineCount, 0);
|
||||
const linkedLineValue = roundMoney(
|
||||
purchaseOrdersSummary.reduce((sum, purchaseOrder) => sum + purchaseOrder.linkedLineValue, 0)
|
||||
);
|
||||
const outstandingPurchaseOrderCount = purchaseOrdersSummary.filter((purchaseOrder) => purchaseOrder.totalOutstandingQuantity > 0).length;
|
||||
const shortageItemCount = typedPlanning?.summary.uncoveredItemCount ?? 0;
|
||||
const totalUncoveredQuantity = typedPlanning?.summary.totalUncoveredQuantity ?? 0;
|
||||
const purchaseOutstandingPenalty = totalOrderedQuantity > 0
|
||||
? Math.min(12, Math.round((totalOutstandingQuantity / totalOrderedQuantity) * 12))
|
||||
: 0;
|
||||
const readinessScore = Math.max(
|
||||
0,
|
||||
100
|
||||
- Math.min(20, blockedMilestoneCount * 10)
|
||||
- Math.min(18, rollups.overdueMilestoneCount * 9)
|
||||
- Math.min(18, rollups.overdueWorkOrderCount * 9)
|
||||
- Math.min(24, shortageItemCount * 8)
|
||||
- purchaseOutstandingPenalty
|
||||
);
|
||||
|
||||
const commercialQuoteTotal = typedQuote ? calculateSalesDocumentTotal(typedQuote) : null;
|
||||
const commercialOrderTotal = typedSalesOrder ? calculateSalesDocumentTotal(typedSalesOrder) : null;
|
||||
|
||||
return {
|
||||
commercial: {
|
||||
quoteTotal: commercialQuoteTotal,
|
||||
quoteStatus: typedQuote?.status ?? null,
|
||||
orderTotal: commercialOrderTotal,
|
||||
orderStatus: typedSalesOrder?.status ?? null,
|
||||
activeDocumentType: typedSalesOrder ? "ORDER" : typedQuote ? "QUOTE" : null,
|
||||
activeDocumentNumber: typedSalesOrder?.documentNumber ?? typedQuote?.documentNumber ?? null,
|
||||
activeDocumentStatus: typedSalesOrder?.status ?? typedQuote?.status ?? null,
|
||||
activeDocumentTotal: commercialOrderTotal ?? commercialQuoteTotal,
|
||||
},
|
||||
purchasing: {
|
||||
linkedPurchaseOrderCount: purchaseOrdersSummary.length,
|
||||
openPurchaseOrderCount: purchaseOrdersSummary.filter((purchaseOrder) => purchaseOrder.status !== "CLOSED").length,
|
||||
vendorCount: vendorMap.size,
|
||||
linkedLineCount,
|
||||
linkedLineValue,
|
||||
totalOrderedQuantity,
|
||||
totalReceivedQuantity,
|
||||
totalOutstandingQuantity,
|
||||
purchaseOrders: purchaseOrdersSummary,
|
||||
vendors: [...vendorMap.values()].sort((left, right) => {
|
||||
if (right.outstandingQuantity !== left.outstandingQuantity) {
|
||||
return right.outstandingQuantity - left.outstandingQuantity;
|
||||
}
|
||||
return right.linkedLineValue - left.linkedLineValue;
|
||||
}),
|
||||
recentReceipts: [...receiptMap.values()]
|
||||
.sort((left, right) => new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime())
|
||||
.slice(0, 5),
|
||||
},
|
||||
delivery: {
|
||||
shipmentNumber: typedShipment?.shipmentNumber ?? null,
|
||||
shipmentStatus: typedShipment?.status ?? null,
|
||||
shipDate: typedShipment?.shipDate ? typedShipment.shipDate.toISOString() : null,
|
||||
packageCount: typedShipment?.packageCount ?? 0,
|
||||
trackingNumber: typedShipment?.trackingNumber ?? null,
|
||||
carrier: typedShipment?.carrier ?? null,
|
||||
serviceLevel: typedShipment?.serviceLevel ?? null,
|
||||
},
|
||||
risk: {
|
||||
readinessScore,
|
||||
riskLevel: deriveProjectRiskLevel(readinessScore),
|
||||
blockedMilestoneCount,
|
||||
overdueMilestoneCount: rollups.overdueMilestoneCount,
|
||||
overdueWorkOrderCount: rollups.overdueWorkOrderCount,
|
||||
shortageItemCount,
|
||||
totalUncoveredQuantity,
|
||||
outstandingPurchaseOrderCount,
|
||||
outstandingReceiptQuantity: totalOutstandingQuantity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -131,7 +499,7 @@ function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
|
||||
};
|
||||
}
|
||||
|
||||
function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
|
||||
function mapProjectDetail(record: ProjectRecord, cockpit: ProjectCockpitDto): ProjectDetailDto {
|
||||
return {
|
||||
...mapProjectSummary(record),
|
||||
notes: record.notes,
|
||||
@@ -145,6 +513,7 @@ function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
|
||||
customerEmail: record.customer.email,
|
||||
customerPhone: record.customer.phone,
|
||||
milestones: record.milestones.map(mapProjectMilestone),
|
||||
cockpit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,7 +880,13 @@ export async function getProjectById(projectId: string) {
|
||||
include: buildInclude(),
|
||||
});
|
||||
|
||||
return project ? mapProjectDetail(project as ProjectRecord) : null;
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mappedProject = project as ProjectRecord;
|
||||
const cockpit = await buildProjectCockpit(mappedProject, buildProjectRollups(mappedProject));
|
||||
return mapProjectDetail(mappedProject, cockpit);
|
||||
}
|
||||
|
||||
export async function createProject(payload: ProjectInput, actorId?: string | null) {
|
||||
|
||||
Reference in New Issue
Block a user