diff --git a/README.md b/README.md
index c9fb1db..750bd3c 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Inven is a single-container inventory management system for Unraid-style deploym
- Sales orders can be created and shipped
- Purchase orders can be created and received
- Sales orders and purchase orders are created from relational inventory line selections
+- Shipping and receiving are posted from relational order-line quantity controls
- Sales orders support partial shipments
- Purchase orders support partial receipts
- Invoices are generated from shipped sales orders
@@ -75,6 +76,7 @@ Suggested Unraid mapping:
- Add parts and assemblies in the Parts module.
- Define assembly components in the Assemblies module.
- Create SOs and POs by selecting actual inventory items from the item master instead of typing free-form SKUs.
+- Fulfill SOs and POs by entering quantities against existing order lines instead of typing SKU text.
- Use purchase orders to restock and receive inventory.
- Use build transactions to convert component stock into assembly stock.
- Use sales orders and ship them fully or partially to reduce stock and generate invoices plus journal entries.
diff --git a/app/assemblies/page.tsx b/app/assemblies/page.tsx
index 6a0069d..68cbce5 100644
--- a/app/assemblies/page.tsx
+++ b/app/assemblies/page.tsx
@@ -1,7 +1,12 @@
import { addKitComponent, buildAssembly } from "@/lib/actions";
import { getAssembliesWithComponents, getParts } from "@/lib/repository";
-export default function AssembliesPage() {
+export default async function AssembliesPage({
+ searchParams
+}: {
+ searchParams?: Promise<{ error?: string; success?: string }>;
+}) {
+ const params = (await searchParams) ?? {};
const parts = getParts();
const assemblies = parts.filter((part) => part.kind === "assembly");
const components = parts.filter((part) => part.kind === "part");
@@ -9,6 +14,8 @@ export default function AssembliesPage() {
return (
+ {params.error ?
: null}
+ {params.success ?
: null}
Bill of Materials
@@ -16,26 +23,26 @@ export default function AssembliesPage() {
Build Assembly
- Consume component stock and create finished kit inventory in one transaction flow.
+ Consume component stock and create finished kit inventory in one transaction flow. This only works after the assembly has a BOM and enough component stock exists.
diff --git a/app/purchase-orders/page.tsx b/app/purchase-orders/page.tsx
index fedbb20..a490f53 100644
--- a/app/purchase-orders/page.tsx
+++ b/app/purchase-orders/page.tsx
@@ -1,12 +1,13 @@
-import { receivePurchaseOrder } from "@/lib/actions";
+import { PurchaseOrderFulfillmentForm } from "@/components/purchase-order-fulfillment-form";
import { PurchaseOrderForm } from "@/components/purchase-order-form";
import { formatCurrency, formatDate } from "@/lib/format";
-import { getLowStockParts, getOrderItemOptions, getPurchaseOrders, getVendors } from "@/lib/repository";
+import { getLowStockParts, getOrderItemOptions, getPurchaseOrderLineDetails, getPurchaseOrders, getVendors } from "@/lib/repository";
export default function PurchaseOrdersPage() {
const vendors = getVendors();
const items = getOrderItemOptions();
const orders = getPurchaseOrders();
+ const orderLines = getPurchaseOrderLineDetails();
const lowStockParts = getLowStockParts();
return (
@@ -19,7 +20,7 @@ export default function PurchaseOrdersPage() {
Receiving Flow
- Leave line quantities blank to receive the remaining balance, or enter `SKU,quantity` rows for a partial receipt.
+ Receive relational order lines directly by entering quantities against the remaining balance on each line.
Order Vendor Status Total Qty Progress Created Action
@@ -39,11 +40,10 @@ export default function PurchaseOrdersPage() {
{order.status === "received" ? (
Received
) : (
-
+ line.purchaseOrderId === order.id && line.remainingQuantity > 0)}
+ />
)}
diff --git a/app/sales-orders/page.tsx b/app/sales-orders/page.tsx
index 4d13a11..aad7bd1 100644
--- a/app/sales-orders/page.tsx
+++ b/app/sales-orders/page.tsx
@@ -1,12 +1,13 @@
-import { shipSalesOrder } from "@/lib/actions";
+import { SalesOrderFulfillmentForm } from "@/components/sales-order-fulfillment-form";
import { SalesOrderForm } from "@/components/sales-order-form";
import { formatCurrency, formatDate } from "@/lib/format";
-import { getCustomers, getOrderItemOptions, getSalesOrders } from "@/lib/repository";
+import { getCustomers, getOrderItemOptions, getSalesOrderLineDetails, getSalesOrders } from "@/lib/repository";
export default function SalesOrdersPage() {
const customers = getCustomers();
const items = getOrderItemOptions();
const orders = getSalesOrders();
+ const orderLines = getSalesOrderLineDetails();
return (
@@ -18,7 +19,7 @@ export default function SalesOrdersPage() {
Shipping Flow
- Leave line quantities blank to ship the remaining balance, or enter `SKU,quantity` rows for a partial shipment.
+ Ship relational order lines directly by choosing quantities from the remaining balance on each line.
Order Customer Status Total Qty Progress Created Action
@@ -38,11 +39,10 @@ export default function SalesOrdersPage() {
{order.status === "shipped" ? (
Shipped
) : (
-
-
-
- Ship
-
+ line.salesOrderId === order.id && line.remainingQuantity > 0)}
+ />
)}
diff --git a/components/purchase-order-fulfillment-form.tsx b/components/purchase-order-fulfillment-form.tsx
new file mode 100644
index 0000000..24954f2
--- /dev/null
+++ b/components/purchase-order-fulfillment-form.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { receivePurchaseOrder } from "@/lib/actions";
+import type { PurchaseOrderLineDetailRow } from "@/lib/types";
+
+type Props = {
+ orderId: number;
+ lines: PurchaseOrderLineDetailRow[];
+};
+
+export function PurchaseOrderFulfillmentForm({ orderId, lines }: Props) {
+ const [quantities, setQuantities] = useState>({});
+
+ const payload = useMemo(
+ () =>
+ JSON.stringify(
+ lines
+ .map((line) => ({
+ lineId: line.lineId,
+ quantity: Number(quantities[line.lineId] || 0)
+ }))
+ .filter((line) => line.quantity > 0)
+ ),
+ [lines, quantities]
+ );
+
+ return (
+
+
+
+
+
+ Receive Selected Quantities
+
+
+ );
+}
diff --git a/components/sales-order-fulfillment-form.tsx b/components/sales-order-fulfillment-form.tsx
new file mode 100644
index 0000000..1d3f5fe
--- /dev/null
+++ b/components/sales-order-fulfillment-form.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { shipSalesOrder } from "@/lib/actions";
+import type { SalesOrderLineDetailRow } from "@/lib/types";
+
+type Props = {
+ orderId: number;
+ lines: SalesOrderLineDetailRow[];
+};
+
+export function SalesOrderFulfillmentForm({ orderId, lines }: Props) {
+ const [quantities, setQuantities] = useState>({});
+
+ const payload = useMemo(
+ () =>
+ JSON.stringify(
+ lines
+ .map((line) => ({
+ lineId: line.lineId,
+ quantity: Number(quantities[line.lineId] || 0)
+ }))
+ .filter((line) => line.quantity > 0)
+ ),
+ [lines, quantities]
+ );
+
+ return (
+
+
+
+
+
+ Ship Selected Quantities
+
+
+ );
+}
diff --git a/lib/actions.ts b/lib/actions.ts
index ae839c3..dca23b1 100644
--- a/lib/actions.ts
+++ b/lib/actions.ts
@@ -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>;
+ 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;
diff --git a/lib/repository.ts b/lib/repository.ts
index 9241af0..ca8cd82 100644
--- a/lib/repository.ts
+++ b/lib/repository.ts
@@ -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(
diff --git a/lib/types.ts b/lib/types.ts
index 39d7be2..842c87c 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -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;
+};