This commit is contained in:
2026-03-14 23:48:27 -05:00
parent ff37ad6f06
commit 7b85d14ff6
21 changed files with 1072 additions and 1 deletions

View File

@@ -0,0 +1,18 @@
CREATE TABLE "Shipment" (
"id" TEXT NOT NULL PRIMARY KEY,
"shipmentNumber" TEXT NOT NULL,
"salesOrderId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"shipDate" DATETIME,
"carrier" TEXT NOT NULL,
"serviceLevel" TEXT NOT NULL,
"trackingNumber" TEXT NOT NULL,
"packageCount" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Shipment_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Shipment_shipmentNumber_key" ON "Shipment"("shipmentNumber");
CREATE INDEX "Shipment_salesOrderId_createdAt_idx" ON "Shipment"("salesOrderId", "createdAt");

View File

@@ -331,6 +331,7 @@ model SalesOrder {
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesOrderLine[]
shipments Shipment[]
}
model SalesOrderLine {
@@ -349,3 +350,21 @@ model SalesOrderLine {
@@index([orderId, position])
}
model Shipment {
id String @id @default(cuid())
shipmentNumber String @unique
salesOrderId String
status String
shipDate DateTime?
carrier String
serviceLevel String
trackingNumber String
packageCount Int @default(1)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
@@index([salesOrderId, createdAt])
}

View File

@@ -18,6 +18,7 @@ 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 { shippingRouter } from "./modules/shipping/router.js";
import { settingsRouter } from "./modules/settings/router.js";
export function createApp() {
@@ -54,6 +55,7 @@ export function createApp() {
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/sales", salesRouter);
app.use("/api/v1/shipping", shippingRouter);
app.use("/api/v1/gantt", ganttRouter);
app.use("/api/v1/documents", documentsRouter);

View File

@@ -19,6 +19,7 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.shippingRead]: "View shipping data",
[permissions.shippingWrite]: "Manage shipments",
};
export async function bootstrapAppData() {

View File

@@ -12,6 +12,7 @@ import {
getSalesDocumentById,
listSalesCustomerOptions,
listSalesDocuments,
listSalesOrderOptions,
updateSalesDocumentStatus,
updateSalesDocument,
} from "./service.js";
@@ -67,6 +68,10 @@ salesRouter.get("/customers/options", requirePermissions([permissions.salesRead]
return ok(response, await listSalesCustomerOptions());
});
salesRouter.get("/orders/options", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await listSalesOrderOptions());
});
salesRouter.get("/quotes", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {

View File

@@ -246,6 +246,38 @@ export async function listSalesCustomerOptions(): Promise<SalesCustomerOptionDto
return customers;
}
export async function listSalesOrderOptions() {
const orders = await prisma.salesOrder.findMany({
include: {
customer: {
select: {
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
total: calculateTotals(
order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0),
order.discountPercent,
order.taxPercent,
order.freightAmount
).total,
}));
}
export async function listSalesDocuments(type: SalesDocumentType, filters: { q?: string; status?: SalesDocumentStatus } = {}) {
const query = filters.q?.trim();
const records = await documentConfig[type].findMany({

View File

@@ -0,0 +1,114 @@
import { permissions } from "@mrp/shared";
import { shipmentStatuses } from "@mrp/shared/dist/shipping/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, updateShipment, updateShipmentStatus } from "./service.js";
const shipmentSchema = z.object({
salesOrderId: z.string().trim().min(1),
status: z.enum(shipmentStatuses),
shipDate: z.string().datetime().nullable(),
carrier: z.string(),
serviceLevel: z.string(),
trackingNumber: z.string(),
packageCount: z.number().int().positive(),
notes: z.string(),
});
const shipmentListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(shipmentStatuses).optional(),
salesOrderId: z.string().optional(),
});
const shipmentStatusUpdateSchema = z.object({
status: z.enum(shipmentStatuses),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const shippingRouter = Router();
shippingRouter.get("/orders/options", requirePermissions([permissions.shippingRead]), async (_request, response) => {
return ok(response, await listShipmentOrderOptions());
});
shippingRouter.get("/shipments", requirePermissions([permissions.shippingRead]), async (request, response) => {
const parsed = shipmentListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment filters are invalid.");
}
return ok(response, await listShipments(parsed.data));
});
shippingRouter.get("/shipments/:shipmentId", requirePermissions([permissions.shippingRead]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const shipment = await getShipmentById(shipmentId);
if (!shipment) {
return fail(response, 404, "SHIPMENT_NOT_FOUND", "Shipment was not found.");
}
return ok(response, shipment);
});
shippingRouter.post("/shipments", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const parsed = shipmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
}
const result = await createShipment(parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment, 201);
});
shippingRouter.put("/shipments/:shipmentId", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const parsed = shipmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
}
const result = await updateShipment(shipmentId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment);
});
shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const parsed = shipmentStatusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment status payload is invalid.");
}
const result = await updateShipmentStatus(shipmentId, parsed.data.status);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment);
});

View File

@@ -0,0 +1,223 @@
import type {
ShipmentDetailDto,
ShipmentInput,
ShipmentOrderOptionDto,
ShipmentStatus,
ShipmentSummaryDto,
} from "@mrp/shared/dist/shipping/types.js";
import { prisma } from "../../lib/prisma.js";
type ShipmentRecord = {
id: string;
shipmentNumber: string;
status: string;
shipDate: Date | null;
carrier: string;
serviceLevel: string;
trackingNumber: string;
packageCount: number;
notes: string;
createdAt: Date;
updatedAt: Date;
salesOrder: {
id: string;
documentNumber: string;
customer: {
name: string;
};
};
};
function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
return {
id: record.id,
shipmentNumber: record.shipmentNumber,
salesOrderId: record.salesOrder.id,
salesOrderNumber: record.salesOrder.documentNumber,
customerName: record.salesOrder.customer.name,
status: record.status as ShipmentStatus,
carrier: record.carrier,
serviceLevel: record.serviceLevel,
trackingNumber: record.trackingNumber,
packageCount: record.packageCount,
shipDate: record.shipDate ? record.shipDate.toISOString() : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function nextShipmentNumber() {
const next = (await prisma.shipment.count()) + 1;
return `SHP-${String(next).padStart(5, "0")}`;
}
export async function listShipmentOrderOptions(): Promise<ShipmentOrderOptionDto[]> {
const orders = await prisma.salesOrder.findMany({
include: {
customer: {
select: {
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
total: order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0),
}));
}
export async function listShipments(filters: { q?: string; status?: ShipmentStatus; salesOrderId?: string } = {}) {
const query = filters.q?.trim();
const shipments = await prisma.shipment.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.salesOrderId ? { salesOrderId: filters.salesOrderId } : {}),
...(query
? {
OR: [
{ shipmentNumber: { contains: query } },
{ trackingNumber: { contains: query } },
{ carrier: { contains: query } },
{ salesOrder: { documentNumber: { contains: query } } },
{ salesOrder: { customer: { name: { contains: query } } } },
],
}
: {}),
},
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }],
});
return shipments.map((shipment) => mapShipment(shipment));
}
export async function getShipmentById(shipmentId: string) {
const shipment = await prisma.shipment.findUnique({
where: { id: shipmentId },
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
});
return shipment ? mapShipment(shipment) : null;
}
export async function createShipment(payload: ShipmentInput) {
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
const shipmentNumber = await nextShipmentNumber();
const shipment = await prisma.shipment.create({
data: {
shipmentNumber,
salesOrderId: payload.salesOrderId,
status: payload.status,
shipDate: payload.shipDate ? new Date(payload.shipDate) : null,
carrier: payload.carrier.trim(),
serviceLevel: payload.serviceLevel.trim(),
trackingNumber: payload.trackingNumber.trim(),
packageCount: payload.packageCount,
notes: payload.notes,
},
select: { id: true },
});
const detail = await getShipmentById(shipment.id);
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
}
export async function updateShipment(shipmentId: string, payload: ShipmentInput) {
const existing = await prisma.shipment.findUnique({
where: { id: shipmentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Shipment was not found." };
}
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
await prisma.shipment.update({
where: { id: shipmentId },
data: {
salesOrderId: payload.salesOrderId,
status: payload.status,
shipDate: payload.shipDate ? new Date(payload.shipDate) : null,
carrier: payload.carrier.trim(),
serviceLevel: payload.serviceLevel.trim(),
trackingNumber: payload.trackingNumber.trim(),
packageCount: payload.packageCount,
notes: payload.notes,
},
select: { id: true },
});
const detail = await getShipmentById(shipmentId);
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
}
export async function updateShipmentStatus(shipmentId: string, status: ShipmentStatus) {
const existing = await prisma.shipment.findUnique({
where: { id: shipmentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Shipment was not found." };
}
await prisma.shipment.update({
where: { id: shipmentId },
data: { status },
select: { id: true },
});
const detail = await getShipmentById(shipmentId);
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
}