This commit is contained in:
2026-03-14 23:03:17 -05:00
parent ce6dec316e
commit 8bf69c67e0
22 changed files with 1660 additions and 175 deletions

View File

@@ -0,0 +1,70 @@
-- CreateTable
CREATE TABLE "SalesQuote" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"expiresAt" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesQuoteLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"quoteId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitPrice" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuoteLine_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "SalesQuote" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesQuoteLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrderLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"orderId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitPrice" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrderLine_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "SalesQuote_documentNumber_key" ON "SalesQuote"("documentNumber");
-- CreateIndex
CREATE INDEX "SalesQuoteLine_quoteId_position_idx" ON "SalesQuoteLine"("quoteId", "position");
-- CreateIndex
CREATE UNIQUE INDEX "SalesOrder_documentNumber_key" ON "SalesOrder"("documentNumber");
-- CreateIndex
CREATE INDEX "SalesOrderLine_orderId_position_idx" ON "SalesOrderLine"("orderId", "position");

View File

@@ -119,6 +119,8 @@ model InventoryItem {
bomLines InventoryBomLine[] @relation("InventoryBomParent")
usedInBomLines InventoryBomLine[] @relation("InventoryBomComponent")
inventoryTransactions InventoryTransaction[]
salesQuoteLines SalesQuoteLine[]
salesOrderLines SalesOrderLine[]
}
model Warehouse {
@@ -163,6 +165,8 @@ model Customer {
contacts CrmContact[]
parentCustomer Customer? @relation("CustomerHierarchy", fields: [parentCustomerId], references: [id], onDelete: SetNull)
childCustomers Customer[] @relation("CustomerHierarchy")
salesQuotes SalesQuote[]
salesOrders SalesOrder[]
}
model InventoryBomLine {
@@ -277,3 +281,64 @@ model CrmContact {
customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
}
model SalesQuote {
id String @id @default(cuid())
documentNumber String @unique
customerId String
status String
issueDate DateTime
expiresAt DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesQuoteLine[]
}
model SalesQuoteLine {
id String @id @default(cuid())
quoteId String
itemId String
description String
quantity Int
unitOfMeasure String
unitPrice Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quote SalesQuote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([quoteId, position])
}
model SalesOrder {
id String @id @default(cuid())
documentNumber String @unique
customerId String
status String
issueDate DateTime
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesOrderLine[]
}
model SalesOrderLine {
id String @id @default(cuid())
orderId String
itemId String
description String
quantity Int
unitOfMeasure String
unitPrice Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([orderId, position])
}

View File

@@ -17,6 +17,7 @@ import { documentsRouter } from "./modules/documents/router.js";
import { filesRouter } from "./modules/files/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { salesRouter } from "./modules/sales/router.js";
import { settingsRouter } from "./modules/settings/router.js";
export function createApp() {
@@ -52,6 +53,7 @@ export function createApp() {
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/sales", salesRouter);
app.use("/api/v1/gantt", ganttRouter);
app.use("/api/v1/documents", documentsRouter);

View File

@@ -17,6 +17,7 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View gantt timelines",
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.shippingRead]: "View shipping data",
};

View File

@@ -0,0 +1,174 @@
import { permissions } from "@mrp/shared";
import { salesDocumentStatuses, type SalesDocumentType } from "@mrp/shared/dist/sales/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import {
createSalesDocument,
getSalesDocumentById,
listSalesCustomerOptions,
listSalesDocuments,
updateSalesDocument,
} from "./service.js";
const salesLineSchema = z.object({
itemId: z.string().trim().min(1),
description: z.string(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
unitPrice: z.number().nonnegative(),
position: z.number().int().nonnegative(),
});
const quoteSchema = z.object({
customerId: z.string().trim().min(1),
status: z.enum(salesDocumentStatuses),
issueDate: z.string().datetime(),
expiresAt: z.string().datetime().nullable(),
notes: z.string(),
lines: z.array(salesLineSchema),
});
const orderSchema = z.object({
customerId: z.string().trim().min(1),
status: z.enum(salesDocumentStatuses),
issueDate: z.string().datetime(),
notes: z.string(),
lines: z.array(salesLineSchema),
});
const salesListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(salesDocumentStatuses).optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const salesRouter = Router();
salesRouter.get("/customers/options", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await listSalesCustomerOptions());
});
salesRouter.get("/quotes", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote filters are invalid.");
}
return ok(response, await listSalesDocuments("QUOTE", parsed.data));
});
salesRouter.get("/quotes/:quoteId", requirePermissions([permissions.salesRead]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const quote = await getSalesDocumentById("QUOTE", quoteId);
if (!quote) {
return fail(response, 404, "QUOTE_NOT_FOUND", "Quote was not found.");
}
return ok(response, quote);
});
salesRouter.post("/quotes", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = quoteSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
}
const result = await createSalesDocument("QUOTE", parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
salesRouter.put("/quotes/:quoteId", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const parsed = quoteSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
}
const result = await updateSalesDocument("QUOTE", quoteId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.get("/orders", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order filters are invalid.");
}
return ok(response, await listSalesDocuments("ORDER", parsed.data));
});
salesRouter.get("/orders/:orderId", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const order = await getSalesDocumentById("ORDER", orderId);
if (!order) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, order);
});
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order payload is invalid.");
}
const result = await createSalesDocument("ORDER", {
...parsed.data,
expiresAt: null,
});
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
salesRouter.put("/orders/:orderId", requirePermissions([permissions.salesWrite]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order payload is invalid.");
}
const result = await updateSalesDocument("ORDER", orderId, {
...parsed.data,
expiresAt: null,
});
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});

View File

@@ -0,0 +1,331 @@
import type {
SalesCustomerOptionDto,
SalesDocumentDetailDto,
SalesDocumentInput,
SalesDocumentStatus,
SalesDocumentSummaryDto,
SalesDocumentType,
SalesLineInput,
} from "@mrp/shared/dist/sales/types.js";
import { prisma } from "../../lib/prisma.js";
type SalesLineRecord = {
id: string;
description: string;
quantity: number;
unitOfMeasure: string;
unitPrice: number;
position: number;
item: {
id: string;
sku: string;
name: string;
};
};
type SalesDocumentRecord = {
id: string;
documentNumber: string;
status: string;
issueDate: Date;
expiresAt?: Date | null;
notes: string;
createdAt: Date;
updatedAt: Date;
customer: {
id: string;
name: string;
email: string;
};
lines: SalesLineRecord[];
};
const documentConfig = {
QUOTE: {
prefix: "Q",
findMany: (prisma as any).salesQuote.findMany.bind((prisma as any).salesQuote),
findUnique: (prisma as any).salesQuote.findUnique.bind((prisma as any).salesQuote),
create: (prisma as any).salesQuote.create.bind((prisma as any).salesQuote),
update: (prisma as any).salesQuote.update.bind((prisma as any).salesQuote),
count: (prisma as any).salesQuote.count.bind((prisma as any).salesQuote),
},
ORDER: {
prefix: "SO",
findMany: (prisma as any).salesOrder.findMany.bind((prisma as any).salesOrder),
findUnique: (prisma as any).salesOrder.findUnique.bind((prisma as any).salesOrder),
create: (prisma as any).salesOrder.create.bind((prisma as any).salesOrder),
update: (prisma as any).salesOrder.update.bind((prisma as any).salesOrder),
count: (prisma as any).salesOrder.count.bind((prisma as any).salesOrder),
},
} as const;
function normalizeLines(lines: SalesLineInput[]) {
return lines
.map((line, index) => ({
itemId: line.itemId,
description: line.description.trim(),
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
unitPrice: Number(line.unitPrice),
position: line.position ?? (index + 1) * 10,
}))
.filter((line) => line.itemId.trim().length > 0);
}
async function validateLines(lines: SalesLineInput[]) {
const normalized = normalizeLines(lines);
if (normalized.length === 0) {
return { ok: false as const, reason: "At least one line item is required." };
}
if (normalized.some((line) => !Number.isInteger(line.quantity) || line.quantity <= 0)) {
return { ok: false as const, reason: "Line quantity must be a whole number greater than zero." };
}
if (normalized.some((line) => Number.isNaN(line.unitPrice) || line.unitPrice < 0)) {
return { ok: false as const, reason: "Unit price must be zero or greater." };
}
const itemIds = [...new Set(normalized.map((line) => line.itemId))];
const items = await prisma.inventoryItem.findMany({
where: { id: { in: itemIds } },
select: { id: true },
});
if (items.length !== itemIds.length) {
return { ok: false as const, reason: "One or more sales lines reference an invalid inventory item." };
}
return { ok: true as const, lines: normalized };
}
function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
const lines = record.lines
.slice()
.sort((left, right) => left.position - right.position)
.map((line) => ({
id: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure as SalesDocumentDetailDto["lines"][number]["unitOfMeasure"],
unitPrice: line.unitPrice,
lineTotal: line.quantity * line.unitPrice,
position: line.position,
}));
return {
id: record.id,
documentNumber: record.documentNumber,
customerId: record.customer.id,
customerName: record.customer.name,
customerEmail: record.customer.email,
status: record.status as SalesDocumentStatus,
subtotal: lines.reduce((sum, line) => sum + line.lineTotal, 0),
issueDate: record.issueDate.toISOString(),
expiresAt: "expiresAt" in record && record.expiresAt ? record.expiresAt.toISOString() : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
lineCount: lines.length,
lines,
};
}
async function nextDocumentNumber(type: SalesDocumentType) {
const next = (await documentConfig[type].count()) + 1;
return `${documentConfig[type].prefix}-${String(next).padStart(5, "0")}`;
}
function buildInclude(type: SalesDocumentType) {
const base = {
customer: {
select: {
id: true,
name: true,
email: true,
},
},
};
if (type === "QUOTE") {
return {
...base,
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
};
}
return {
...base,
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
};
}
export async function listSalesCustomerOptions(): Promise<SalesCustomerOptionDto[]> {
const customers = await prisma.customer.findMany({
where: {
status: {
not: "INACTIVE",
},
},
select: {
id: true,
name: true,
email: true,
},
orderBy: [{ name: "asc" }],
});
return customers;
}
export async function listSalesDocuments(type: SalesDocumentType, filters: { q?: string; status?: SalesDocumentStatus } = {}) {
const query = filters.q?.trim();
const records = await documentConfig[type].findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(query
? {
OR: [
{ documentNumber: { contains: query } },
{ customer: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(type),
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return records.map((record: unknown) => {
const detail = mapDocument(record as SalesDocumentRecord);
const summary: SalesDocumentSummaryDto = {
id: detail.id,
documentNumber: detail.documentNumber,
customerId: detail.customerId,
customerName: detail.customerName,
status: detail.status,
subtotal: detail.subtotal,
issueDate: detail.issueDate,
updatedAt: detail.updatedAt,
lineCount: detail.lineCount,
};
return summary;
});
}
export async function getSalesDocumentById(type: SalesDocumentType, documentId: string) {
const record = await documentConfig[type].findUnique({
where: { id: documentId },
include: buildInclude(type),
});
return record ? mapDocument(record as SalesDocumentRecord) : null;
}
export async function createSalesDocument(type: SalesDocumentType, payload: SalesDocumentInput) {
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const customer = await prisma.customer.findUnique({
where: { id: payload.customerId },
select: { id: true },
});
if (!customer) {
return { ok: false as const, reason: "Customer was not found." };
}
const documentNumber = await nextDocumentNumber(type);
const created = await documentConfig[type].create({
data: {
documentNumber,
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
...(type === "QUOTE" ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}),
notes: payload.notes,
lines: {
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getSalesDocumentById(type, created.id);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved document." };
}
export async function updateSalesDocument(type: SalesDocumentType, documentId: string, payload: SalesDocumentInput) {
const existing = await documentConfig[type].findUnique({
where: { id: documentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Sales document was not found." };
}
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const customer = await prisma.customer.findUnique({
where: { id: payload.customerId },
select: { id: true },
});
if (!customer) {
return { ok: false as const, reason: "Customer was not found." };
}
await documentConfig[type].update({
where: { id: documentId },
data: {
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
...(type === "QUOTE" ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}),
notes: payload.notes,
lines: {
deleteMany: {},
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getSalesDocumentById(type, documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved document." };
}