pick orders
This commit is contained in:
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user