pick orders

This commit is contained in:
2026-03-18 07:27:33 -05:00
parent e00639bb8b
commit 02e14319ac
10 changed files with 763 additions and 40 deletions

View File

@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "ShipmentPick" (
"id" TEXT NOT NULL PRIMARY KEY,
"shipmentId" TEXT NOT NULL,
"salesOrderLineId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ShipmentPick_shipmentId_fkey" FOREIGN KEY ("shipmentId") REFERENCES "Shipment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_salesOrderLineId_fkey" FOREIGN KEY ("salesOrderLineId") REFERENCES "SalesOrderLine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ShipmentPick_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "ShipmentPick_shipmentId_createdAt_idx" ON "ShipmentPick"("shipmentId", "createdAt");
-- CreateIndex
CREATE INDEX "ShipmentPick_salesOrderLineId_createdAt_idx" ON "ShipmentPick"("salesOrderLineId", "createdAt");
-- CreateIndex
CREATE INDEX "ShipmentPick_warehouseId_locationId_createdAt_idx" ON "ShipmentPick"("warehouseId", "locationId", "createdAt");

View File

@@ -28,6 +28,7 @@ model User {
workOrderCompletions WorkOrderCompletion[]
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
assignedWorkOrderOperations WorkOrderOperation[]
shipmentPicks ShipmentPick[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
@@ -164,6 +165,7 @@ model InventoryItem {
purchaseOrderLines PurchaseOrderLine[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
shipmentPicks ShipmentPick[]
operations InventoryItemOperation[]
reservations InventoryReservation[]
transfers InventoryTransfer[]
@@ -224,6 +226,7 @@ model Warehouse {
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
shipmentPicks ShipmentPick[]
reservations InventoryReservation[]
transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse")
transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse")
@@ -295,6 +298,7 @@ model WarehouseLocation {
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
shipmentPicks ShipmentPick[]
reservations InventoryReservation[]
transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation")
transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation")
@@ -509,6 +513,7 @@ model SalesOrderLine {
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
shipmentPicks ShipmentPick[]
@@index([orderId, position])
}
@@ -560,10 +565,35 @@ model Shipment {
updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
projects Project[]
picks ShipmentPick[]
@@index([salesOrderId, createdAt])
}
model ShipmentPick {
id String @id @default(cuid())
shipmentId String
salesOrderLineId String
itemId String
warehouseId String
locationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shipment Shipment @relation(fields: [shipmentId], references: [id], onDelete: Cascade)
salesOrderLine SalesOrderLine @relation(fields: [salesOrderLineId], references: [id], onDelete: Restrict)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([shipmentId, createdAt])
@@index([salesOrderLineId, createdAt])
@@index([warehouseId, locationId, createdAt])
}
model Project {
id String @id @default(cuid())
projectNumber String @unique

View File

@@ -5,7 +5,7 @@ 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";
import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, postShipmentPick, updateShipment, updateShipmentStatus } from "./service.js";
const shipmentSchema = z.object({
salesOrderId: z.string().trim().min(1),
@@ -28,6 +28,14 @@ const shipmentStatusUpdateSchema = z.object({
status: z.enum(shipmentStatuses),
});
const shipmentPickSchema = z.object({
salesOrderLineId: z.string().trim().min(1),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
quantity: z.number().positive(),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -112,3 +120,22 @@ shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permis
return ok(response, result.shipment);
});
shippingRouter.post("/shipments/:shipmentId/picks", 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 = shipmentPickSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment pick payload is invalid.");
}
const result = await postShipmentPick(shipmentId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment, 201);
});

View File

@@ -1,6 +1,7 @@
import type {
ShipmentDetailDto,
ShipmentInput,
ShipmentPickInput,
ShipmentOrderOptionDto,
ShipmentStatus,
ShipmentSummaryDto,
@@ -61,10 +62,83 @@ type ShipmentRecord = {
customer: {
name: string;
};
lines: Array<{
id: string;
description: string;
quantity: number;
unitOfMeasure: string;
item: {
id: string;
sku: string;
name: string;
};
}>;
};
picks: Array<{
id: string;
salesOrderLineId: string;
quantity: number;
notes: string;
createdAt: Date;
item: {
id: string;
sku: string;
name: string;
};
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
};
function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
function mapShipmentSummary(record: {
id: string;
shipmentNumber: string;
status: string;
shipDate: Date | null;
carrier: string;
trackingNumber: string;
packageCount: number;
updatedAt: Date;
salesOrder: {
id: string;
documentNumber: string;
customer: {
name: string;
};
};
}): ShipmentSummaryDto {
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,
trackingNumber: record.trackingNumber,
packageCount: record.packageCount,
shipDate: record.shipDate ? record.shipDate.toISOString() : null,
updatedAt: record.updatedAt.toISOString(),
};
}
function mapShipmentDetail(record: ShipmentRecord): ShipmentDetailDto {
const pickedByLineId = new Map<string, number>();
for (const pick of record.picks) {
pickedByLineId.set(pick.salesOrderLineId, (pickedByLineId.get(pick.salesOrderLineId) ?? 0) + pick.quantity);
}
return {
id: record.id,
shipmentNumber: record.shipmentNumber,
@@ -80,9 +154,58 @@ function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
lines: record.salesOrder.lines.map((line) => {
const pickedQuantity = pickedByLineId.get(line.id) ?? 0;
return {
salesOrderLineId: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
orderedQuantity: line.quantity,
pickedQuantity,
remainingQuantity: Math.max(line.quantity - pickedQuantity, 0),
unitOfMeasure: line.unitOfMeasure,
};
}),
picks: record.picks.map((pick) => ({
id: pick.id,
salesOrderLineId: pick.salesOrderLineId,
itemId: pick.item.id,
itemSku: pick.item.sku,
itemName: pick.item.name,
quantity: pick.quantity,
warehouseId: pick.warehouse.id,
warehouseCode: pick.warehouse.code,
warehouseName: pick.warehouse.name,
locationId: pick.location.id,
locationCode: pick.location.code,
locationName: pick.location.name,
notes: pick.notes,
createdAt: pick.createdAt.toISOString(),
createdByName: pick.createdBy ? `${pick.createdBy.firstName} ${pick.createdBy.lastName}`.trim() : "System",
})),
};
}
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({
where: {
itemId,
warehouseId,
locationId,
},
select: {
transactionType: true,
quantity: true,
},
});
return transactions.reduce((total, transaction) => {
return total + (transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity);
}, 0);
}
async function nextShipmentNumber() {
const next = (await prisma.shipment.count()) + 1;
return `SHP-${String(next).padStart(5, "0")}`;
@@ -147,7 +270,7 @@ export async function listShipments(filters: { q?: string; status?: ShipmentStat
orderBy: [{ createdAt: "desc" }],
});
return shipments.map((shipment) => mapShipment(shipment));
return shipments.map((shipment) => mapShipmentSummary(shipment));
}
export async function getShipmentById(shipmentId: string) {
@@ -161,12 +284,56 @@ export async function getShipmentById(shipmentId: string) {
name: true,
},
},
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
},
picks: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
},
});
return shipment ? mapShipment(shipment) : null;
return shipment ? mapShipmentDetail(shipment) : null;
}
export async function createShipment(payload: ShipmentInput, actorId?: string | null) {
@@ -300,6 +467,126 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
}
export async function postShipmentPick(shipmentId: string, payload: ShipmentPickInput, actorId?: string | null) {
const shipment = await prisma.shipment.findUnique({
where: { id: shipmentId },
include: {
salesOrder: {
include: {
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
},
},
},
picks: {
select: {
salesOrderLineId: true,
quantity: true,
},
},
},
});
if (!shipment) {
return { ok: false as const, reason: "Shipment was not found." };
}
const line = shipment.salesOrder.lines.find((entry) => entry.id === payload.salesOrderLineId);
if (!line) {
return { ok: false as const, reason: "Shipment pick must target a line on the linked sales order." };
}
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
id: true,
warehouseId: true,
},
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
const pickedQuantity = shipment.picks
.filter((pick) => pick.salesOrderLineId === payload.salesOrderLineId)
.reduce((sum, pick) => sum + pick.quantity, 0);
const remainingQuantity = Math.max(line.quantity - pickedQuantity, 0);
if (payload.quantity > remainingQuantity) {
return { ok: false as const, reason: "Pick quantity exceeds the remaining unpicked sales-order quantity for this shipment line." };
}
const onHand = await getItemLocationOnHand(line.item.id, payload.warehouseId, payload.locationId);
if (onHand < payload.quantity) {
return { ok: false as const, reason: "Shipment pick would drive the selected stock location below zero on-hand." };
}
await prisma.$transaction(async (tx) => {
await tx.shipmentPick.create({
data: {
shipmentId,
salesOrderLineId: payload.salesOrderLineId,
itemId: line.item.id,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
quantity: payload.quantity,
notes: payload.notes,
createdById: actorId ?? null,
},
});
await tx.inventoryTransaction.create({
data: {
itemId: line.item.id,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
transactionType: "ISSUE",
quantity: payload.quantity,
reference: `${shipment.shipmentNumber} shipment pick`,
notes: payload.notes || `Shipment pick for ${shipment.shipmentNumber}`,
createdById: actorId ?? null,
},
});
if (shipment.status === "DRAFT") {
await tx.shipment.update({
where: { id: shipmentId },
data: {
status: "PICKING",
},
});
}
});
const detail = await getShipmentById(shipmentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipmentId,
action: "pick.posted",
summary: `Posted shipment pick for ${detail.shipmentNumber}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
salesOrderLineId: payload.salesOrderLineId,
itemId: line.item.id,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
quantity: payload.quantity,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
}
export async function getShipmentPackingSlipData(shipmentId: string): Promise<ShipmentPackingSlipData | null> {
const shipment = await getShipmentDocumentData(shipmentId);