fixed
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
11
lib/types.ts
11
lib/types.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user