backfill from projects
This commit is contained in:
@@ -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
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user