This commit is contained in:
2026-03-23 16:56:23 -05:00
parent 1f0986a94d
commit ee26ffe75c
6 changed files with 128 additions and 38 deletions

View File

@@ -9,6 +9,7 @@ Inven is a single-container inventory management system for Unraid-style deploym
- Inventory is tracked through a transaction ledger
- 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
- Sales orders support partial shipments
- Purchase orders support partial receipts
- Invoices are generated from shipped sales orders
@@ -73,6 +74,7 @@ Suggested Unraid mapping:
- Add customers and vendors before creating orders.
- 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.
- 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.

View File

@@ -1,9 +1,11 @@
import { createPurchaseOrder, receivePurchaseOrder } from "@/lib/actions";
import { receivePurchaseOrder } from "@/lib/actions";
import { PurchaseOrderCreateForm } from "@/components/purchase-order-create-form";
import { formatCurrency, formatDate } from "@/lib/format";
import { getLowStockParts, getPurchaseOrders, getVendors } from "@/lib/repository";
import { getLowStockParts, getOrderItemOptions, getPurchaseOrders, getVendors } from "@/lib/repository";
export default function PurchaseOrdersPage() {
const vendors = getVendors();
const items = getOrderItemOptions();
const orders = getPurchaseOrders();
const lowStockParts = getLowStockParts();
@@ -12,18 +14,8 @@ export default function PurchaseOrdersPage() {
<section className="two-up">
<article className="panel">
<h2 className="section-title">Create Purchase Order</h2>
<p className="section-copy">Enter one line per row as `SKU,quantity,unit cost`.</p>
<form action={createPurchaseOrder} className="form-grid">
<div className="form-row">
<label htmlFor="vendorCode">Vendor Code</label>
<select className="select" id="vendorCode" name="vendorCode">
{vendors.map((vendor) => <option key={vendor.id} value={vendor.code}>{vendor.code} - {vendor.name}</option>)}
</select>
</div>
<div className="form-row"><label htmlFor="purchase-lines">Line Items</label><textarea className="textarea" id="purchase-lines" name="lines" placeholder={"PART-001,10,31.50\nPART-002,24,6.25"} required /></div>
<div className="form-row"><label htmlFor="purchase-notes">Notes</label><textarea className="textarea" id="purchase-notes" name="notes" /></div>
<button className="button" type="submit">Save Purchase Order</button>
</form>
<p className="section-copy">Build the PO from real inventory items so receipts, costing, and restocking stay relational.</p>
<PurchaseOrderCreateForm vendors={vendors} items={items} />
</article>
<article className="panel">
<h2 className="section-title">Receiving Flow</h2>

View File

@@ -1,9 +1,11 @@
import { createSalesOrder, shipSalesOrder } from "@/lib/actions";
import { shipSalesOrder } from "@/lib/actions";
import { SalesOrderCreateForm } from "@/components/sales-order-create-form";
import { formatCurrency, formatDate } from "@/lib/format";
import { getCustomers, getSalesOrders } from "@/lib/repository";
import { getCustomers, getOrderItemOptions, getSalesOrders } from "@/lib/repository";
export default function SalesOrdersPage() {
const customers = getCustomers();
const items = getOrderItemOptions();
const orders = getSalesOrders();
return (
@@ -11,18 +13,8 @@ export default function SalesOrdersPage() {
<section className="two-up">
<article className="panel">
<h2 className="section-title">Create Sales Order</h2>
<p className="section-copy">Enter one line per row as `SKU,quantity,unit price`.</p>
<form action={createSalesOrder} className="form-grid">
<div className="form-row">
<label htmlFor="customerCode">Customer Code</label>
<select className="select" id="customerCode" name="customerCode">
{customers.map((customer) => <option key={customer.id} value={customer.code}>{customer.code} - {customer.name}</option>)}
</select>
</div>
<div className="form-row"><label htmlFor="sales-lines">Line Items</label><textarea className="textarea" id="sales-lines" name="lines" placeholder={"PART-001,2,79.99\nKIT-100,1,249.00"} required /></div>
<div className="form-row"><label htmlFor="sales-notes">Notes</label><textarea className="textarea" id="sales-notes" name="notes" /></div>
<button className="button" type="submit">Save Sales Order</button>
</form>
<p className="section-copy">Build the order from real inventory records so each line references an actual item in the database.</p>
<SalesOrderCreateForm customers={customers} items={items} />
</article>
<article className="panel">
<h2 className="section-title">Shipping Flow</h2>

View File

@@ -11,6 +11,12 @@ type ParsedLine = {
amount: number;
};
type RelationalOrderLine = {
partId: number;
quantity: number;
amount: number;
};
type ParsedFulfillmentLine = {
sku: string;
quantity: number;
@@ -55,6 +61,44 @@ function parseLines(raw: string): ParsedLine[] {
});
}
function parseRelationalOrderLines(raw: string): RelationalOrderLine[] {
try {
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error("Order must contain at least one line.");
}
const lines = parsed.map((line) => {
const partId = Number(line.partId);
const quantity = Number(line.quantity);
const amount = Number(line.amount);
if (!Number.isInteger(partId) || partId <= 0) {
throw new Error("Invalid item selection.");
}
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error("Invalid line quantity.");
}
if (!Number.isFinite(amount) || amount < 0) {
throw new Error("Invalid line amount.");
}
return { partId, quantity, amount };
});
const uniquePartIds = new Set(lines.map((line) => line.partId));
if (uniquePartIds.size !== lines.length) {
throw new Error("Each inventory item can only appear once per order.");
}
return lines;
} catch {
throw new Error("Invalid relational order payload.");
}
}
function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
return raw
.split(/\r?\n/)
@@ -85,6 +129,18 @@ function getPartIdBySku(sku: string) {
return row.id;
}
function getExistingPart(partId: number) {
const row = db()
.prepare(`SELECT id FROM parts WHERE id = ?`)
.get(partId) as { id: number } | undefined;
if (!row) {
throw new Error(`Selected inventory item ${partId} does not exist.`);
}
return row.id;
}
function getOrderNumber(prefix: string, table: "sales_orders" | "purchase_orders") {
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
@@ -399,12 +455,19 @@ export async function createVendor(formData: FormData) {
}
export async function createSalesOrder(formData: FormData) {
const customerCode = getText(formData, "customerCode");
const lines = parseLines(getText(formData, "lines"));
const customerRow = db().prepare(`SELECT id FROM customers WHERE code = ?`).get(customerCode) as { id: number } | undefined;
const customerId = Number(getText(formData, "customerId"));
const relationalLinesPayload = getText(formData, "lineItems");
const lines = relationalLinesPayload
? parseRelationalOrderLines(relationalLinesPayload)
: parseLines(getText(formData, "lines")).map((line) => ({
partId: getPartIdBySku(line.sku),
quantity: line.quantity,
amount: line.amount
}));
const customerRow = db().prepare(`SELECT id FROM customers WHERE id = ?`).get(customerId) as { id: number } | undefined;
if (!customerRow) {
throw new Error(`Customer "${customerCode}" does not exist.`);
throw new Error("Selected customer does not exist.");
}
const tx = db().transaction(() => {
@@ -426,7 +489,7 @@ export async function createSalesOrder(formData: FormData) {
);
for (const line of lines) {
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
insertLine.run(orderId, getExistingPart(line.partId), line.quantity, line.amount);
}
});
@@ -567,12 +630,19 @@ export async function shipSalesOrder(formData: FormData) {
}
export async function createPurchaseOrder(formData: FormData) {
const vendorCode = getText(formData, "vendorCode");
const lines = parseLines(getText(formData, "lines"));
const vendorRow = db().prepare(`SELECT id FROM vendors WHERE code = ?`).get(vendorCode) as { id: number } | undefined;
const vendorId = Number(getText(formData, "vendorId"));
const relationalLinesPayload = getText(formData, "lineItems");
const lines = relationalLinesPayload
? parseRelationalOrderLines(relationalLinesPayload)
: parseLines(getText(formData, "lines")).map((line) => ({
partId: getPartIdBySku(line.sku),
quantity: line.quantity,
amount: line.amount
}));
const vendorRow = db().prepare(`SELECT id FROM vendors WHERE id = ?`).get(vendorId) as { id: number } | undefined;
if (!vendorRow) {
throw new Error(`Vendor "${vendorCode}" does not exist.`);
throw new Error("Selected vendor does not exist.");
}
const tx = db().transaction(() => {
@@ -594,7 +664,7 @@ export async function createPurchaseOrder(formData: FormData) {
);
for (const line of lines) {
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
insertLine.run(orderId, getExistingPart(line.partId), line.quantity, line.amount);
}
});

View File

@@ -8,6 +8,7 @@ import type {
JournalEntryRow,
KitRow,
LowStockRow,
OrderItemOption,
PartRow,
PurchaseOrderListRow,
SalesOrderListRow,
@@ -94,6 +95,28 @@ export function getParts(): PartRow[] {
.all() as PartRow[];
}
export function getOrderItemOptions(): OrderItemOption[] {
return db()
.prepare(
`
SELECT
p.id,
p.sku,
p.name,
p.kind,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand,
p.sale_price AS salePrice,
p.unit_cost AS unitCost,
p.unit_of_measure AS unitOfMeasure
FROM parts p
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
WHERE p.is_active = 1
ORDER BY p.kind DESC, p.sku ASC
`
)
.all() as OrderItemOption[];
}
export function getAssembliesWithComponents(): KitRow[] {
return db()
.prepare(

View File

@@ -41,6 +41,17 @@ export type ContactRow = {
phone: string | null;
};
export type OrderItemOption = {
id: number;
sku: string;
name: string;
kind: "part" | "assembly";
quantityOnHand: number;
salePrice: number;
unitCost: number;
unitOfMeasure: string;
};
export type SalesOrderListRow = {
id: number;
orderNumber: string;