This commit is contained in:
2026-03-23 17:12:35 -05:00
parent 92328713f4
commit d59a0a563d
9 changed files with 346 additions and 40 deletions

View File

@@ -22,6 +22,11 @@ type ParsedFulfillmentLine = {
quantity: number;
};
type RelationalFulfillmentLine = {
lineId: number;
quantity: number;
};
function db() {
return getDb();
}
@@ -121,6 +126,32 @@ function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
});
}
function parseRelationalFulfillmentLines(raw: string): RelationalFulfillmentLine[] {
try {
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error("No fulfillment lines selected.");
}
return parsed.map((line) => {
const lineId = Number(line.lineId);
const quantity = Number(line.quantity);
if (!Number.isInteger(lineId) || lineId <= 0) {
throw new Error("Invalid line selection.");
}
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error("Invalid fulfillment quantity.");
}
return { lineId, quantity };
});
} catch {
throw new Error("Invalid relational fulfillment payload.");
}
}
function getPartIdBySku(sku: string) {
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
if (!row) {
@@ -274,8 +305,12 @@ export async function buildAssembly(formData: FormData) {
const assemblySku = getText(formData, "assemblySku");
const buildQuantity = getNumber(formData, "quantity");
if (!assemblySku) {
redirect("/assemblies?error=Select an assembly before building.");
}
if (buildQuantity <= 0) {
throw new Error("Build quantity must be greater than zero.");
redirect("/assemblies?error=Build quantity must be greater than zero.");
}
const assemblyId = getPartIdBySku(assemblySku);
@@ -304,13 +339,13 @@ export async function buildAssembly(formData: FormData) {
}>;
if (components.length === 0) {
throw new Error("Assembly has no bill of materials defined.");
redirect("/assemblies?error=Assembly has no bill of materials defined.");
}
for (const component of components) {
const needed = component.componentQuantity * buildQuantity;
if (component.quantityOnHand < needed) {
throw new Error(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`);
redirect(`/assemblies?error=${encodeURIComponent(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`)}`);
}
}
@@ -354,6 +389,7 @@ export async function buildAssembly(formData: FormData) {
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/assemblies");
redirect(`/assemblies?success=${encodeURIComponent(`Built ${buildQuantity} of ${assemblySku}.`)}`);
}
export async function recordAdjustment(formData: FormData) {
@@ -548,18 +584,18 @@ export async function shipSalesOrder(formData: FormData) {
throw new Error("Sales order has no lines.");
}
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
const fulfilledLines = requestedLines.length
? requestedLines.map((request) => {
const matchingLine = orderLines.find((line) => line.sku === request.sku);
const relationalPayload = getText(formData, "fulfillmentLines");
const fulfilledLines = relationalPayload
? parseRelationalFulfillmentLines(relationalPayload).map((request) => {
const matchingLine = orderLines.find((line) => line.lineId === request.lineId);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this sales order.`);
throw new Error("Selected sales order line is invalid.");
}
const remaining = matchingLine.quantity - matchingLine.shippedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot ship ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
throw new Error(`Cannot ship ${request.quantity} of ${matchingLine.sku}; only ${remaining} remain.`);
}
if (matchingLine.quantityOnHand < request.quantity) {
@@ -568,7 +604,26 @@ export async function shipSalesOrder(formData: FormData) {
return { ...matchingLine, shipQuantity: request.quantity };
})
: orderLines
: parseFulfillmentLines(getText(formData, "lines")).length
? parseFulfillmentLines(getText(formData, "lines")).map((request) => {
const matchingLine = orderLines.find((line) => line.sku === request.sku);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this sales order.`);
}
const remaining = matchingLine.quantity - matchingLine.shippedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot ship ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
}
if (matchingLine.quantityOnHand < request.quantity) {
throw new Error(`Insufficient stock for ${matchingLine.sku}. Need ${request.quantity}, have ${matchingLine.quantityOnHand}.`);
}
return { ...matchingLine, shipQuantity: request.quantity };
})
: orderLines
.map((line) => {
const remaining = line.quantity - line.shippedQuantity;
return remaining > 0 ? { ...line, shipQuantity: remaining } : null;
@@ -718,23 +773,38 @@ export async function receivePurchaseOrder(formData: FormData) {
throw new Error("Purchase order has no lines.");
}
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
const fulfilledLines = requestedLines.length
? requestedLines.map((request) => {
const matchingLine = lines.find((line) => line.sku === request.sku);
const relationalPayload = getText(formData, "fulfillmentLines");
const fulfilledLines = relationalPayload
? parseRelationalFulfillmentLines(relationalPayload).map((request) => {
const matchingLine = lines.find((line) => line.lineId === request.lineId);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this purchase order.`);
throw new Error("Selected purchase order line is invalid.");
}
const remaining = matchingLine.quantity - matchingLine.receivedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot receive ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
throw new Error(`Cannot receive ${request.quantity} of ${matchingLine.sku}; only ${remaining} remain.`);
}
return { ...matchingLine, receiveQuantity: request.quantity };
})
: lines
: parseFulfillmentLines(getText(formData, "lines")).length
? parseFulfillmentLines(getText(formData, "lines")).map((request) => {
const matchingLine = lines.find((line) => line.sku === request.sku);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this purchase order.`);
}
const remaining = matchingLine.quantity - matchingLine.receivedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot receive ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
}
return { ...matchingLine, receiveQuantity: request.quantity };
})
: lines
.map((line) => {
const remaining = line.quantity - line.receivedQuantity;
return remaining > 0 ? { ...line, receiveQuantity: remaining } : null;

View File

@@ -11,7 +11,9 @@ import type {
OrderItemOption,
PartRow,
PurchaseOrderListRow,
PurchaseOrderLineDetailRow,
SalesOrderListRow,
SalesOrderLineDetailRow,
VendorBillRow
} from "@/lib/types";
@@ -190,6 +192,54 @@ export function getPurchaseOrders(): PurchaseOrderListRow[] {
.all() as PurchaseOrderListRow[];
}
export function getSalesOrderLineDetails(): SalesOrderLineDetailRow[] {
return db()
.prepare(
`
SELECT
sol.id AS lineId,
sol.sales_order_id AS salesOrderId,
sol.part_id AS partId,
p.sku,
p.name AS partName,
sol.quantity,
sol.shipped_quantity AS fulfilledQuantity,
sol.quantity - sol.shipped_quantity AS remainingQuantity,
sol.unit_price AS unitPrice,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand,
p.unit_of_measure AS unitOfMeasure
FROM sales_order_lines sol
INNER JOIN parts p ON p.id = sol.part_id
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
ORDER BY sol.sales_order_id DESC, sol.id ASC
`
)
.all() as SalesOrderLineDetailRow[];
}
export function getPurchaseOrderLineDetails(): PurchaseOrderLineDetailRow[] {
return db()
.prepare(
`
SELECT
pol.id AS lineId,
pol.purchase_order_id AS purchaseOrderId,
pol.part_id AS partId,
p.sku,
p.name AS partName,
pol.quantity,
pol.received_quantity AS fulfilledQuantity,
pol.quantity - pol.received_quantity AS remainingQuantity,
pol.unit_cost AS unitCost,
p.unit_of_measure AS unitOfMeasure
FROM purchase_order_lines pol
INNER JOIN parts p ON p.id = pol.part_id
ORDER BY pol.purchase_order_id DESC, pol.id ASC
`
)
.all() as PurchaseOrderLineDetailRow[];
}
export function getJournalEntries(): JournalEntryRow[] {
const entries = db()
.prepare(

View File

@@ -141,3 +141,30 @@ export type VendorBillRow = {
paidAmount: number;
balanceDue: number;
};
export type SalesOrderLineDetailRow = {
lineId: number;
salesOrderId: number;
partId: number;
sku: string;
partName: string;
quantity: number;
fulfilledQuantity: number;
remainingQuantity: number;
unitPrice: number;
quantityOnHand: number;
unitOfMeasure: string;
};
export type PurchaseOrderLineDetailRow = {
lineId: number;
purchaseOrderId: number;
partId: number;
sku: string;
partName: string;
quantity: number;
fulfilledQuantity: number;
remainingQuantity: number;
unitCost: number;
unitOfMeasure: string;
};