backfill from projects

This commit is contained in:
2026-03-18 11:54:22 -05:00
parent c18de77640
commit f12744f05d
15 changed files with 281 additions and 9 deletions

View File

@@ -0,0 +1,38 @@
ALTER TABLE "PurchaseOrder" ADD COLUMN "projectId" TEXT REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "PurchaseOrder_projectId_issueDate_idx" ON "PurchaseOrder"("projectId", "issueDate");
UPDATE "WorkOrder"
SET "projectId" = (
SELECT "Project"."id"
FROM "Project"
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
ORDER BY "Project"."createdAt" ASC
LIMIT 1
)
WHERE "projectId" IS NULL
AND "salesOrderId" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "Project"
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
);
UPDATE "PurchaseOrder"
SET "projectId" = (
SELECT "Project"."id"
FROM "PurchaseOrderLine"
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
ORDER BY "Project"."createdAt" ASC
LIMIT 1
)
WHERE "projectId" IS NULL
AND EXISTS (
SELECT 1
FROM "PurchaseOrderLine"
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
);

View File

@@ -618,6 +618,7 @@ model Project {
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
workOrders WorkOrder[]
purchaseOrders PurchaseOrder[]
milestones ProjectMilestone[]
@@index([customerId, createdAt])
@@ -864,6 +865,7 @@ model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique
vendorId String
projectId String?
status String
issueDate DateTime
taxPercent Float @default(0)
@@ -872,10 +874,13 @@ model PurchaseOrder {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
lines PurchaseOrderLine[]
receipts PurchaseReceipt[]
revisions PurchaseOrderRevision[]
capexEntries CapexEntry[]
@@index([projectId, issueDate])
}
model PurchaseOrderLine {

View File

@@ -868,15 +868,18 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
return { ok: false as const, reason: "Build item must have at least one station operation before a work order can be created." };
}
let projectSalesOrderId: string | null = null;
if (payload.projectId) {
const project = await prisma.project.findUnique({
where: { id: payload.projectId },
select: { id: true },
select: { id: true, salesOrderId: true },
});
if (!project) {
return { ok: false as const, reason: "Linked project was not found." };
}
projectSalesOrderId = project.salesOrderId ?? null;
}
if (payload.salesOrderId) {
@@ -888,6 +891,10 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
if (!salesOrder) {
return { ok: false as const, reason: "Linked sales order was not found." };
}
if (projectSalesOrderId && projectSalesOrderId !== payload.salesOrderId) {
return { ok: false as const, reason: "Linked project does not match the selected sales order." };
}
}
if (payload.salesOrderLineId) {
@@ -928,6 +935,28 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
return { ok: true as const };
}
async function deriveProjectIdForWorkOrder(payload: WorkOrderInput) {
if (payload.projectId) {
return payload.projectId;
}
if (!payload.salesOrderId) {
return null;
}
const project = await prisma.project.findFirst({
where: {
salesOrderId: payload.salesOrderId,
},
orderBy: [{ createdAt: "asc" }],
select: {
id: true,
},
});
return project?.id ?? null;
}
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
const items = await prisma.inventoryItem.findMany({
where: {
@@ -1158,13 +1187,14 @@ export async function createWorkOrder(payload: WorkOrderInput, actorId?: string
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
const derivedProjectId = await deriveProjectIdForWorkOrder(payload);
const workOrderNumber = await nextWorkOrderNumber();
const created = await workOrderModel.create({
data: {
workOrderNumber,
itemId: payload.itemId,
projectId: payload.projectId,
projectId: derivedProjectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId,
@@ -1223,12 +1253,13 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
const derivedProjectId = await deriveProjectIdForWorkOrder(payload);
await workOrderModel.update({
where: { id: workOrderId },
data: {
itemId: payload.itemId,
projectId: payload.projectId,
projectId: derivedProjectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId,

View File

@@ -29,6 +29,7 @@ const purchaseLineSchema = z.object({
const purchaseOrderSchema = z.object({
vendorId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable().optional(),
status: z.enum(purchaseOrderStatuses),
issueDate: z.string().datetime(),
taxPercent: z.number().min(0).max(100),

View File

@@ -126,6 +126,11 @@ type PurchaseOrderRevisionRecord = {
type PurchaseOrderRecord = {
id: string;
documentNumber: string;
project: {
id: string;
projectNumber: string;
name: string;
} | null;
status: string;
issueDate: Date;
taxPercent: number;
@@ -145,6 +150,17 @@ type PurchaseOrderRecord = {
revisions: PurchaseOrderRevisionRecord[];
};
type NormalizedPurchaseLine = {
itemId: string;
salesOrderId: string | null;
salesOrderLineId: string | null;
description: string;
quantity: number;
unitOfMeasure: PurchaseLineInput["unitOfMeasure"];
unitCost: number;
position: number;
};
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
@@ -322,6 +338,63 @@ async function validateLines(lines: PurchaseLineInput[]) {
return { ok: true as const, lines: normalized };
}
async function resolvePurchaseOrderProjectId(projectId: string | null | undefined, lines: NormalizedPurchaseLine[]) {
let explicitProjectId = projectId ?? null;
if (explicitProjectId) {
const project = await prisma.project.findUnique({
where: { id: explicitProjectId },
select: {
id: true,
salesOrderId: true,
},
});
if (!project) {
return { ok: false as const, reason: "Linked project was not found." };
}
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
if (linkedSalesOrderIds.length > 0 && project.salesOrderId && linkedSalesOrderIds.some((salesOrderId) => salesOrderId !== project.salesOrderId)) {
return { ok: false as const, reason: "Linked project does not match the sales-order demand attached to this purchase order." };
}
return { ok: true as const, projectId: project.id };
}
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
if (linkedSalesOrderIds.length === 0) {
return { ok: true as const, projectId: null };
}
const matchingProjects = await prisma.project.findMany({
where: {
salesOrderId: {
in: linkedSalesOrderIds,
},
},
select: {
id: true,
salesOrderId: true,
createdAt: true,
},
orderBy: [{ createdAt: "asc" }],
});
const projectBySalesOrderId = new Map<string, string>();
for (const project of matchingProjects) {
if (project.salesOrderId && !projectBySalesOrderId.has(project.salesOrderId)) {
projectBySalesOrderId.set(project.salesOrderId, project.id);
}
}
const derivedProjectIds = [...new Set(linkedSalesOrderIds.map((salesOrderId) => projectBySalesOrderId.get(salesOrderId)).filter((value): value is string => Boolean(value)))];
if (derivedProjectIds.length > 1) {
return { ok: false as const, reason: "Purchase orders can only auto-link to one project. Split the document or set the project intentionally." };
}
return { ok: true as const, projectId: derivedProjectIds[0] ?? null };
}
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
const receivedByLineId = new Map<string, number>();
@@ -370,6 +443,9 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
documentNumber: record.documentNumber,
vendorId: record.vendor.id,
vendorName: record.vendor.name,
projectId: record.project?.id ?? null,
projectNumber: record.project?.projectNumber ?? null,
projectName: record.project?.name ?? null,
vendorEmail: record.vendor.email,
paymentTerms: record.vendor.paymentTerms,
currencyCode: record.vendor.currencyCode,
@@ -487,6 +563,13 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
currencyCode: true,
},
},
project: {
select: {
id: true,
projectNumber: true,
name: true,
},
},
lines: {
include: {
item: {
@@ -715,6 +798,9 @@ export async function listPurchaseOrders(filters: { q?: string; status?: Purchas
documentNumber: detail.documentNumber,
vendorId: detail.vendorId,
vendorName: detail.vendorName,
projectId: detail.projectId,
projectNumber: detail.projectNumber,
projectName: detail.projectName,
status: detail.status,
subtotal: detail.subtotal,
taxPercent: detail.taxPercent,
@@ -762,6 +848,11 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
return { ok: false as const, reason: validatedLines.reason };
}
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
if (!resolvedProject.ok) {
return { ok: false as const, reason: resolvedProject.reason };
}
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
@@ -777,6 +868,7 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
data: {
documentNumber,
vendorId: payload.vendorId,
projectId: resolvedProject.projectId,
status: payload.status,
issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent,
@@ -824,6 +916,11 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
return { ok: false as const, reason: validatedLines.reason };
}
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
if (!resolvedProject.ok) {
return { ok: false as const, reason: resolvedProject.reason };
}
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
@@ -837,6 +934,7 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
where: { id: documentId },
data: {
vendorId: payload.vendorId,
projectId: resolvedProject.projectId,
status: payload.status,
issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent,

View File

@@ -97,6 +97,12 @@ type SalesDocumentRecord = {
firstName: string;
lastName: string;
} | null;
projects: Array<{
id: string;
projectNumber: string;
name: string;
createdAt: Date;
}>;
revisions: RevisionRecord[];
lines: SalesLineRecord[];
};
@@ -279,6 +285,9 @@ function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
linkedProjectId: record.projects[0]?.id ?? null,
linkedProjectNumber: record.projects[0]?.projectNumber ?? null,
linkedProjectName: record.projects[0]?.name ?? null,
lineCount: lines.length,
lines,
revisions,
@@ -383,6 +392,15 @@ function buildInclude() {
lastName: true,
},
},
projects: {
select: {
id: true,
projectNumber: true,
name: true,
createdAt: true,
},
orderBy: [{ createdAt: "asc" as const }],
},
revisions: {
include: {
createdBy: {
@@ -799,6 +817,16 @@ export async function convertQuoteToSalesOrder(quoteId: string, userId?: string)
select: { id: true },
});
await tx.project.updateMany({
where: {
salesQuoteId: quoteId,
salesOrderId: null,
},
data: {
salesOrderId: created.id,
},
});
return created.id;
});