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
|
- Inventory is tracked through a transaction ledger
|
||||||
- Sales orders can be created and shipped
|
- Sales orders can be created and shipped
|
||||||
- Purchase orders can be created and received
|
- Purchase orders can be created and received
|
||||||
|
- Sales orders and purchase orders are created from relational inventory line selections
|
||||||
- Sales orders support partial shipments
|
- Sales orders support partial shipments
|
||||||
- Purchase orders support partial receipts
|
- Purchase orders support partial receipts
|
||||||
- Invoices are generated from shipped sales orders
|
- Invoices are generated from shipped sales orders
|
||||||
@@ -73,6 +74,7 @@ Suggested Unraid mapping:
|
|||||||
- Add customers and vendors before creating orders.
|
- Add customers and vendors before creating orders.
|
||||||
- Add parts and assemblies in the Parts module.
|
- Add parts and assemblies in the Parts module.
|
||||||
- Define assembly components in the Assemblies 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 purchase orders to restock and receive inventory.
|
||||||
- Use build transactions to convert component stock into assembly stock.
|
- 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.
|
- 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 { formatCurrency, formatDate } from "@/lib/format";
|
||||||
import { getLowStockParts, getPurchaseOrders, getVendors } from "@/lib/repository";
|
import { getLowStockParts, getOrderItemOptions, getPurchaseOrders, getVendors } from "@/lib/repository";
|
||||||
|
|
||||||
export default function PurchaseOrdersPage() {
|
export default function PurchaseOrdersPage() {
|
||||||
const vendors = getVendors();
|
const vendors = getVendors();
|
||||||
|
const items = getOrderItemOptions();
|
||||||
const orders = getPurchaseOrders();
|
const orders = getPurchaseOrders();
|
||||||
const lowStockParts = getLowStockParts();
|
const lowStockParts = getLowStockParts();
|
||||||
|
|
||||||
@@ -12,18 +14,8 @@ export default function PurchaseOrdersPage() {
|
|||||||
<section className="two-up">
|
<section className="two-up">
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Create Purchase Order</h2>
|
<h2 className="section-title">Create Purchase Order</h2>
|
||||||
<p className="section-copy">Enter one line per row as `SKU,quantity,unit cost`.</p>
|
<p className="section-copy">Build the PO from real inventory items so receipts, costing, and restocking stay relational.</p>
|
||||||
<form action={createPurchaseOrder} className="form-grid">
|
<PurchaseOrderCreateForm vendors={vendors} items={items} />
|
||||||
<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>
|
|
||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Receiving Flow</h2>
|
<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 { formatCurrency, formatDate } from "@/lib/format";
|
||||||
import { getCustomers, getSalesOrders } from "@/lib/repository";
|
import { getCustomers, getOrderItemOptions, getSalesOrders } from "@/lib/repository";
|
||||||
|
|
||||||
export default function SalesOrdersPage() {
|
export default function SalesOrdersPage() {
|
||||||
const customers = getCustomers();
|
const customers = getCustomers();
|
||||||
|
const items = getOrderItemOptions();
|
||||||
const orders = getSalesOrders();
|
const orders = getSalesOrders();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -11,18 +13,8 @@ export default function SalesOrdersPage() {
|
|||||||
<section className="two-up">
|
<section className="two-up">
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Create Sales Order</h2>
|
<h2 className="section-title">Create Sales Order</h2>
|
||||||
<p className="section-copy">Enter one line per row as `SKU,quantity,unit price`.</p>
|
<p className="section-copy">Build the order from real inventory records so each line references an actual item in the database.</p>
|
||||||
<form action={createSalesOrder} className="form-grid">
|
<SalesOrderCreateForm customers={customers} items={items} />
|
||||||
<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>
|
|
||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Shipping Flow</h2>
|
<h2 className="section-title">Shipping Flow</h2>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ type ParsedLine = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RelationalOrderLine = {
|
||||||
|
partId: number;
|
||||||
|
quantity: number;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
type ParsedFulfillmentLine = {
|
type ParsedFulfillmentLine = {
|
||||||
sku: string;
|
sku: string;
|
||||||
quantity: number;
|
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[] {
|
function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
|
||||||
return raw
|
return raw
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@@ -85,6 +129,18 @@ function getPartIdBySku(sku: string) {
|
|||||||
return row.id;
|
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") {
|
function getOrderNumber(prefix: string, table: "sales_orders" | "purchase_orders") {
|
||||||
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
|
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
|
||||||
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
|
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) {
|
export async function createSalesOrder(formData: FormData) {
|
||||||
const customerCode = getText(formData, "customerCode");
|
const customerId = Number(getText(formData, "customerId"));
|
||||||
const lines = parseLines(getText(formData, "lines"));
|
const relationalLinesPayload = getText(formData, "lineItems");
|
||||||
const customerRow = db().prepare(`SELECT id FROM customers WHERE code = ?`).get(customerCode) as { id: number } | undefined;
|
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) {
|
if (!customerRow) {
|
||||||
throw new Error(`Customer "${customerCode}" does not exist.`);
|
throw new Error("Selected customer does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tx = db().transaction(() => {
|
const tx = db().transaction(() => {
|
||||||
@@ -426,7 +489,7 @@ export async function createSalesOrder(formData: FormData) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const line of lines) {
|
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) {
|
export async function createPurchaseOrder(formData: FormData) {
|
||||||
const vendorCode = getText(formData, "vendorCode");
|
const vendorId = Number(getText(formData, "vendorId"));
|
||||||
const lines = parseLines(getText(formData, "lines"));
|
const relationalLinesPayload = getText(formData, "lineItems");
|
||||||
const vendorRow = db().prepare(`SELECT id FROM vendors WHERE code = ?`).get(vendorCode) as { id: number } | undefined;
|
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) {
|
if (!vendorRow) {
|
||||||
throw new Error(`Vendor "${vendorCode}" does not exist.`);
|
throw new Error("Selected vendor does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tx = db().transaction(() => {
|
const tx = db().transaction(() => {
|
||||||
@@ -594,7 +664,7 @@ export async function createPurchaseOrder(formData: FormData) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const line of lines) {
|
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,
|
JournalEntryRow,
|
||||||
KitRow,
|
KitRow,
|
||||||
LowStockRow,
|
LowStockRow,
|
||||||
|
OrderItemOption,
|
||||||
PartRow,
|
PartRow,
|
||||||
PurchaseOrderListRow,
|
PurchaseOrderListRow,
|
||||||
SalesOrderListRow,
|
SalesOrderListRow,
|
||||||
@@ -94,6 +95,28 @@ export function getParts(): PartRow[] {
|
|||||||
.all() as 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[] {
|
export function getAssembliesWithComponents(): KitRow[] {
|
||||||
return db()
|
return db()
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
11
lib/types.ts
11
lib/types.ts
@@ -41,6 +41,17 @@ export type ContactRow = {
|
|||||||
phone: string | null;
|
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 = {
|
export type SalesOrderListRow = {
|
||||||
id: number;
|
id: number;
|
||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user