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

@@ -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." };
}