initial release testing
This commit is contained in:
865
lib/actions.ts
Normal file
865
lib/actions.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authenticateUser, createSession, destroySession } from "@/lib/auth";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
type ParsedLine = {
|
||||
sku: string;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
type ParsedFulfillmentLine = {
|
||||
sku: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
function db() {
|
||||
return getDb();
|
||||
}
|
||||
|
||||
function getText(formData: FormData, key: string) {
|
||||
return String(formData.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
function getNumber(formData: FormData, key: string) {
|
||||
const value = Number(getText(formData, key));
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function parseLines(raw: string): ParsedLine[] {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sku, quantity, amount] = line.split(",").map((piece) => piece.trim());
|
||||
if (!sku || !quantity || !amount) {
|
||||
throw new Error(`Invalid line format: "${line}". Use SKU,quantity,amount.`);
|
||||
}
|
||||
|
||||
const parsedQuantity = Number(quantity);
|
||||
const parsedAmount = Number(amount);
|
||||
|
||||
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0 || !Number.isFinite(parsedAmount) || parsedAmount < 0) {
|
||||
throw new Error(`Invalid line values: "${line}".`);
|
||||
}
|
||||
|
||||
return {
|
||||
sku,
|
||||
quantity: parsedQuantity,
|
||||
amount: parsedAmount
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sku, quantity] = line.split(",").map((piece) => piece.trim());
|
||||
|
||||
if (!sku || !quantity) {
|
||||
throw new Error(`Invalid fulfillment line format: "${line}". Use SKU,quantity.`);
|
||||
}
|
||||
|
||||
const parsedQuantity = Number(quantity);
|
||||
|
||||
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0) {
|
||||
throw new Error(`Invalid fulfillment quantity: "${line}".`);
|
||||
}
|
||||
|
||||
return { sku, quantity: parsedQuantity };
|
||||
});
|
||||
}
|
||||
|
||||
function getPartIdBySku(sku: string) {
|
||||
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`Part with SKU "${sku}" 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")}`;
|
||||
}
|
||||
|
||||
function getDocumentNumber(prefix: string, table: "customer_invoices" | "vendor_bills") {
|
||||
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
|
||||
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
function createJournalEntry(
|
||||
entryType: string,
|
||||
referenceType: string,
|
||||
referenceId: number | null,
|
||||
description: string,
|
||||
lines: Array<{ accountCode: string; accountName: string; debit: number; credit: number }>
|
||||
) {
|
||||
const tx = db().transaction(() => {
|
||||
const result = db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO journal_entries (entry_type, reference_type, reference_id, description)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(entryType, referenceType, referenceId, description);
|
||||
|
||||
const journalEntryId = Number(result.lastInsertRowid);
|
||||
const insertLine = db().prepare(
|
||||
`
|
||||
INSERT INTO journal_lines (journal_entry_id, account_code, account_name, debit, credit)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
insertLine.run(journalEntryId, line.accountCode, line.accountName, line.debit, line.credit);
|
||||
}
|
||||
});
|
||||
|
||||
tx();
|
||||
}
|
||||
|
||||
function resolveAccount(code: string) {
|
||||
const row = db().prepare(`SELECT code, name FROM accounts WHERE code = ?`).get(code) as { code: string; name: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Account "${code}" does not exist.`);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function parseJournalLines(raw: string) {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [accountCode, debit, credit] = line.split(",").map((piece) => piece.trim());
|
||||
|
||||
if (!accountCode || debit === undefined || credit === undefined) {
|
||||
throw new Error(`Invalid journal line format: "${line}". Use account code,debit,credit.`);
|
||||
}
|
||||
|
||||
const parsedDebit = Number(debit);
|
||||
const parsedCredit = Number(credit);
|
||||
|
||||
if (!Number.isFinite(parsedDebit) || !Number.isFinite(parsedCredit) || parsedDebit < 0 || parsedCredit < 0) {
|
||||
throw new Error(`Invalid journal amounts: "${line}".`);
|
||||
}
|
||||
|
||||
if ((parsedDebit === 0 && parsedCredit === 0) || (parsedDebit > 0 && parsedCredit > 0)) {
|
||||
throw new Error(`Journal lines must have either a debit or a credit: "${line}".`);
|
||||
}
|
||||
|
||||
const account = resolveAccount(accountCode);
|
||||
|
||||
return {
|
||||
accountCode: account.code,
|
||||
accountName: account.name,
|
||||
debit: parsedDebit,
|
||||
credit: parsedCredit
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPart(formData: FormData) {
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO parts (sku, name, description, kind, unit_cost, sale_price, reorder_point, unit_of_measure)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
getText(formData, "sku"),
|
||||
getText(formData, "name"),
|
||||
getText(formData, "description"),
|
||||
getText(formData, "kind"),
|
||||
getNumber(formData, "unitCost"),
|
||||
getNumber(formData, "salePrice"),
|
||||
getNumber(formData, "reorderPoint"),
|
||||
getText(formData, "unitOfMeasure") || "ea"
|
||||
);
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/parts");
|
||||
revalidatePath("/assemblies");
|
||||
}
|
||||
|
||||
export async function addKitComponent(formData: FormData) {
|
||||
const assemblyId = getPartIdBySku(getText(formData, "assemblySku"));
|
||||
const componentId = getPartIdBySku(getText(formData, "componentSku"));
|
||||
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO kit_components (assembly_part_id, component_part_id, quantity)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (assembly_part_id, component_part_id)
|
||||
DO UPDATE SET quantity = excluded.quantity
|
||||
`
|
||||
)
|
||||
.run(assemblyId, componentId, getNumber(formData, "quantity"));
|
||||
|
||||
revalidatePath("/assemblies");
|
||||
}
|
||||
|
||||
export async function buildAssembly(formData: FormData) {
|
||||
const assemblySku = getText(formData, "assemblySku");
|
||||
const buildQuantity = getNumber(formData, "quantity");
|
||||
|
||||
if (buildQuantity <= 0) {
|
||||
throw new Error("Build quantity must be greater than zero.");
|
||||
}
|
||||
|
||||
const assemblyId = getPartIdBySku(assemblySku);
|
||||
|
||||
const components = db()
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
kc.component_part_id AS componentId,
|
||||
kc.quantity AS componentQuantity,
|
||||
p.sku,
|
||||
p.unit_cost AS unitCost,
|
||||
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
|
||||
FROM kit_components kc
|
||||
INNER JOIN parts p ON p.id = kc.component_part_id
|
||||
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
|
||||
WHERE kc.assembly_part_id = ?
|
||||
`
|
||||
)
|
||||
.all(assemblyId) as Array<{
|
||||
componentId: number;
|
||||
componentQuantity: number;
|
||||
sku: string;
|
||||
unitCost: number;
|
||||
quantityOnHand: number;
|
||||
}>;
|
||||
|
||||
if (components.length === 0) {
|
||||
throw new 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}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const buildCost = components.reduce((sum, component) => sum + component.unitCost * component.componentQuantity, 0);
|
||||
|
||||
const tx = db().transaction(() => {
|
||||
const insertInventory = db().prepare(
|
||||
`
|
||||
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
);
|
||||
const updateAssemblyCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
|
||||
|
||||
for (const component of components) {
|
||||
insertInventory.run(
|
||||
component.componentId,
|
||||
component.componentQuantity * buildQuantity * -1,
|
||||
component.unitCost,
|
||||
"assembly_consume",
|
||||
"assembly_build",
|
||||
assemblyId,
|
||||
`Consumed for ${assemblySku}`
|
||||
);
|
||||
}
|
||||
|
||||
insertInventory.run(
|
||||
assemblyId,
|
||||
buildQuantity,
|
||||
buildCost,
|
||||
"assembly_build",
|
||||
"assembly_build",
|
||||
assemblyId,
|
||||
`Built ${buildQuantity} of ${assemblySku}`
|
||||
);
|
||||
|
||||
updateAssemblyCost.run(buildCost, assemblyId);
|
||||
});
|
||||
|
||||
tx();
|
||||
revalidatePath("/");
|
||||
revalidatePath("/parts");
|
||||
revalidatePath("/assemblies");
|
||||
}
|
||||
|
||||
export async function recordAdjustment(formData: FormData) {
|
||||
const partId = getPartIdBySku(getText(formData, "sku"));
|
||||
const quantityDelta = getNumber(formData, "quantityDelta");
|
||||
const unitCost = getNumber(formData, "unitCost");
|
||||
const notes = getText(formData, "notes");
|
||||
const inventoryImpact = Math.abs(quantityDelta * unitCost);
|
||||
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, notes)
|
||||
VALUES (?, ?, ?, 'adjustment', 'manual_adjustment', ?)
|
||||
`
|
||||
)
|
||||
.run(partId, quantityDelta, unitCost, notes);
|
||||
|
||||
if (inventoryImpact > 0) {
|
||||
createJournalEntry("adjustment", "manual_adjustment", partId, notes || "Inventory adjustment posted", [
|
||||
{
|
||||
accountCode: quantityDelta >= 0 ? "1200" : "6100",
|
||||
accountName: quantityDelta >= 0 ? "Inventory" : "Inventory Adjustments",
|
||||
debit: quantityDelta >= 0 ? inventoryImpact : 0,
|
||||
credit: quantityDelta >= 0 ? 0 : inventoryImpact
|
||||
},
|
||||
{
|
||||
accountCode: quantityDelta >= 0 ? "3000" : "1200",
|
||||
accountName: quantityDelta >= 0 ? "Owner Equity" : "Inventory",
|
||||
debit: quantityDelta >= 0 ? 0 : inventoryImpact,
|
||||
credit: quantityDelta >= 0 ? inventoryImpact : 0
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/parts");
|
||||
revalidatePath("/accounting");
|
||||
}
|
||||
|
||||
export async function createCustomer(formData: FormData) {
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO customers (code, name, email, phone, billing_address, shipping_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
getText(formData, "code"),
|
||||
getText(formData, "name"),
|
||||
getText(formData, "email"),
|
||||
getText(formData, "phone"),
|
||||
getText(formData, "billingAddress"),
|
||||
getText(formData, "shippingAddress")
|
||||
);
|
||||
|
||||
revalidatePath("/customers");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function createAccount(formData: FormData) {
|
||||
const category = getText(formData, "category");
|
||||
|
||||
if (!["asset", "liability", "equity", "revenue", "expense"].includes(category)) {
|
||||
throw new Error("Invalid account category.");
|
||||
}
|
||||
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO accounts (code, name, category, is_system)
|
||||
VALUES (?, ?, ?, 0)
|
||||
`
|
||||
)
|
||||
.run(getText(formData, "code"), getText(formData, "name"), category);
|
||||
|
||||
revalidatePath("/accounting");
|
||||
}
|
||||
|
||||
export async function createVendor(formData: FormData) {
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO vendors (code, name, email, phone, address)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
getText(formData, "code"),
|
||||
getText(formData, "name"),
|
||||
getText(formData, "email"),
|
||||
getText(formData, "phone"),
|
||||
getText(formData, "address")
|
||||
);
|
||||
|
||||
revalidatePath("/vendors");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!customerRow) {
|
||||
throw new Error(`Customer "${customerCode}" does not exist.`);
|
||||
}
|
||||
|
||||
const tx = db().transaction(() => {
|
||||
const result = db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO sales_orders (order_number, customer_id, status, notes)
|
||||
VALUES (?, ?, 'open', ?)
|
||||
`
|
||||
)
|
||||
.run(getOrderNumber("SO", "sales_orders"), customerRow.id, getText(formData, "notes"));
|
||||
|
||||
const orderId = Number(result.lastInsertRowid);
|
||||
const insertLine = db().prepare(
|
||||
`
|
||||
INSERT INTO sales_order_lines (sales_order_id, part_id, quantity, unit_price)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
|
||||
}
|
||||
});
|
||||
|
||||
tx();
|
||||
revalidatePath("/");
|
||||
revalidatePath("/sales-orders");
|
||||
}
|
||||
|
||||
export async function shipSalesOrder(formData: FormData) {
|
||||
const orderId = Number(getText(formData, "orderId"));
|
||||
const order = db()
|
||||
.prepare(`SELECT customer_id AS customerId, status FROM sales_orders WHERE id = ?`)
|
||||
.get(orderId) as { customerId: number; status: string } | undefined;
|
||||
|
||||
if (!order) {
|
||||
throw new Error("Sales order not found.");
|
||||
}
|
||||
|
||||
if (order.status === "shipped") {
|
||||
throw new Error("Sales order has already been shipped.");
|
||||
}
|
||||
|
||||
const orderLines = db()
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
sol.id AS lineId,
|
||||
so.order_number AS orderNumber,
|
||||
p.id AS partId,
|
||||
p.sku,
|
||||
p.unit_cost AS unitCost,
|
||||
sol.quantity,
|
||||
sol.shipped_quantity AS shippedQuantity,
|
||||
sol.unit_price AS unitPrice,
|
||||
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
|
||||
FROM sales_order_lines sol
|
||||
INNER JOIN sales_orders so ON so.id = sol.sales_order_id
|
||||
INNER JOIN parts p ON p.id = sol.part_id
|
||||
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
|
||||
WHERE sol.sales_order_id = ?
|
||||
`
|
||||
)
|
||||
.all(orderId) as Array<{
|
||||
lineId: number;
|
||||
orderNumber: string;
|
||||
partId: number;
|
||||
sku: string;
|
||||
unitCost: number;
|
||||
quantity: number;
|
||||
shippedQuantity: number;
|
||||
unitPrice: number;
|
||||
quantityOnHand: number;
|
||||
}>;
|
||||
|
||||
if (orderLines.length === 0) {
|
||||
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);
|
||||
|
||||
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;
|
||||
})
|
||||
.filter((line): line is NonNullable<typeof line> => line !== null);
|
||||
|
||||
if (fulfilledLines.length === 0) {
|
||||
throw new Error("No shippable quantities were provided.");
|
||||
}
|
||||
|
||||
const revenue = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitPrice, 0);
|
||||
const cogs = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitCost, 0);
|
||||
const orderNumber = orderLines[0].orderNumber;
|
||||
|
||||
const tx = db().transaction(() => {
|
||||
const insertInventory = db().prepare(
|
||||
`
|
||||
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
|
||||
VALUES (?, ?, ?, 'sales_shipment', 'sales_order', ?, ?)
|
||||
`
|
||||
);
|
||||
const updateLine = db().prepare(`UPDATE sales_order_lines SET shipped_quantity = shipped_quantity + ? WHERE id = ?`);
|
||||
|
||||
for (const line of fulfilledLines) {
|
||||
insertInventory.run(line.partId, line.shipQuantity * -1, line.unitCost, orderId, `Shipment for ${orderNumber}`);
|
||||
updateLine.run(line.shipQuantity, line.lineId);
|
||||
}
|
||||
|
||||
const remainingCount = db()
|
||||
.prepare(`SELECT COUNT(*) AS count FROM sales_order_lines WHERE sales_order_id = ? AND shipped_quantity < quantity`)
|
||||
.get(orderId) as { count: number };
|
||||
const nextStatus = remainingCount.count > 0 ? "partial" : "shipped";
|
||||
|
||||
db().prepare(`UPDATE sales_orders SET status = ?, shipped_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO customer_invoices (invoice_number, sales_order_id, customer_id, status, due_date, total_amount, paid_amount)
|
||||
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
|
||||
`
|
||||
)
|
||||
.run(getDocumentNumber("INV", "customer_invoices"), orderId, order.customerId, revenue);
|
||||
});
|
||||
|
||||
tx();
|
||||
|
||||
createJournalEntry("shipment", "sales_order", orderId, `Shipment posted for ${orderNumber}`, [
|
||||
{ accountCode: "1100", accountName: "Accounts Receivable", debit: revenue, credit: 0 },
|
||||
{ accountCode: "4000", accountName: "Sales Revenue", debit: 0, credit: revenue },
|
||||
{ accountCode: "5000", accountName: "Cost of Goods Sold", debit: cogs, credit: 0 },
|
||||
{ accountCode: "1200", accountName: "Inventory", debit: 0, credit: cogs }
|
||||
]);
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/parts");
|
||||
revalidatePath("/sales-orders");
|
||||
revalidatePath("/accounting");
|
||||
revalidatePath("/invoices");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!vendorRow) {
|
||||
throw new Error(`Vendor "${vendorCode}" does not exist.`);
|
||||
}
|
||||
|
||||
const tx = db().transaction(() => {
|
||||
const result = db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO purchase_orders (order_number, vendor_id, status, notes)
|
||||
VALUES (?, ?, 'ordered', ?)
|
||||
`
|
||||
)
|
||||
.run(getOrderNumber("PO", "purchase_orders"), vendorRow.id, getText(formData, "notes"));
|
||||
|
||||
const orderId = Number(result.lastInsertRowid);
|
||||
const insertLine = db().prepare(
|
||||
`
|
||||
INSERT INTO purchase_order_lines (purchase_order_id, part_id, quantity, unit_cost)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
|
||||
}
|
||||
});
|
||||
|
||||
tx();
|
||||
revalidatePath("/");
|
||||
revalidatePath("/purchase-orders");
|
||||
}
|
||||
|
||||
export async function receivePurchaseOrder(formData: FormData) {
|
||||
const orderId = Number(getText(formData, "orderId"));
|
||||
const order = db()
|
||||
.prepare(`SELECT vendor_id AS vendorId, status FROM purchase_orders WHERE id = ?`)
|
||||
.get(orderId) as { vendorId: number; status: string } | undefined;
|
||||
|
||||
if (!order) {
|
||||
throw new Error("Purchase order not found.");
|
||||
}
|
||||
|
||||
if (order.status === "received") {
|
||||
throw new Error("Purchase order has already been received.");
|
||||
}
|
||||
|
||||
const lines = db()
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
pol.id AS lineId,
|
||||
po.order_number AS orderNumber,
|
||||
pol.part_id AS partId,
|
||||
pol.quantity,
|
||||
pol.received_quantity AS receivedQuantity,
|
||||
p.sku,
|
||||
pol.unit_cost AS unitCost
|
||||
FROM purchase_order_lines pol
|
||||
INNER JOIN purchase_orders po ON po.id = pol.purchase_order_id
|
||||
INNER JOIN parts p ON p.id = pol.part_id
|
||||
WHERE pol.purchase_order_id = ?
|
||||
`
|
||||
)
|
||||
.all(orderId) as Array<{
|
||||
lineId: number;
|
||||
orderNumber: string;
|
||||
partId: number;
|
||||
quantity: number;
|
||||
receivedQuantity: number;
|
||||
sku: string;
|
||||
unitCost: number;
|
||||
}>;
|
||||
|
||||
if (lines.length === 0) {
|
||||
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);
|
||||
|
||||
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;
|
||||
})
|
||||
.filter((line): line is NonNullable<typeof line> => line !== null);
|
||||
|
||||
if (fulfilledLines.length === 0) {
|
||||
throw new Error("No receivable quantities were provided.");
|
||||
}
|
||||
|
||||
const receiptValue = fulfilledLines.reduce((sum, line) => sum + line.receiveQuantity * line.unitCost, 0);
|
||||
const orderNumber = lines[0].orderNumber;
|
||||
|
||||
const tx = db().transaction(() => {
|
||||
const insertInventory = db().prepare(
|
||||
`
|
||||
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
|
||||
VALUES (?, ?, ?, 'purchase_receipt', 'purchase_order', ?, ?)
|
||||
`
|
||||
);
|
||||
|
||||
const updatePartCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
|
||||
const updateLine = db().prepare(`UPDATE purchase_order_lines SET received_quantity = received_quantity + ? WHERE id = ?`);
|
||||
|
||||
for (const line of fulfilledLines) {
|
||||
insertInventory.run(line.partId, line.receiveQuantity, line.unitCost, orderId, `Receipt for ${orderNumber}`);
|
||||
updatePartCost.run(line.unitCost, line.partId);
|
||||
updateLine.run(line.receiveQuantity, line.lineId);
|
||||
}
|
||||
|
||||
const remainingCount = db()
|
||||
.prepare(`SELECT COUNT(*) AS count FROM purchase_order_lines WHERE purchase_order_id = ? AND received_quantity < quantity`)
|
||||
.get(orderId) as { count: number };
|
||||
const nextStatus = remainingCount.count > 0 ? "partial" : "received";
|
||||
|
||||
db().prepare(`UPDATE purchase_orders SET status = ?, received_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
|
||||
db()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO vendor_bills (bill_number, purchase_order_id, vendor_id, status, due_date, total_amount, paid_amount)
|
||||
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
|
||||
`
|
||||
)
|
||||
.run(getDocumentNumber("BILL", "vendor_bills"), orderId, order.vendorId, receiptValue);
|
||||
});
|
||||
|
||||
tx();
|
||||
|
||||
createJournalEntry("receipt", "purchase_order", orderId, `Receipt posted for ${orderNumber}`, [
|
||||
{ accountCode: "1200", accountName: "Inventory", debit: receiptValue, credit: 0 },
|
||||
{ accountCode: "2000", accountName: "Accounts Payable", debit: 0, credit: receiptValue }
|
||||
]);
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/parts");
|
||||
revalidatePath("/purchase-orders");
|
||||
revalidatePath("/accounting");
|
||||
revalidatePath("/vendor-bills");
|
||||
}
|
||||
|
||||
export async function createManualJournalEntry(formData: FormData) {
|
||||
const description = getText(formData, "description");
|
||||
const lines = parseJournalLines(getText(formData, "lines"));
|
||||
const debitTotal = lines.reduce((sum, line) => sum + line.debit, 0);
|
||||
const creditTotal = lines.reduce((sum, line) => sum + line.credit, 0);
|
||||
|
||||
if (Math.abs(debitTotal - creditTotal) > 0.005) {
|
||||
throw new Error("Manual journal entry is not balanced.");
|
||||
}
|
||||
|
||||
createJournalEntry("manual", "manual_journal", null, description || "Manual journal entry", lines);
|
||||
|
||||
revalidatePath("/accounting");
|
||||
}
|
||||
|
||||
export async function loginAction(formData: FormData) {
|
||||
const email = getText(formData, "email");
|
||||
const password = getText(formData, "password");
|
||||
const user = authenticateUser(db(), email, password);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid email or password.");
|
||||
}
|
||||
|
||||
await createSession(user);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export async function logoutAction() {
|
||||
await destroySession();
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
export async function receiveCustomerPayment(formData: FormData) {
|
||||
const invoiceId = Number(getText(formData, "invoiceId"));
|
||||
const amount = getNumber(formData, "amount");
|
||||
const notes = getText(formData, "notes");
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new Error("Payment amount must be greater than zero.");
|
||||
}
|
||||
|
||||
const invoice = db()
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, invoice_number AS invoiceNumber, total_amount AS totalAmount, paid_amount AS paidAmount
|
||||
FROM customer_invoices
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.get(invoiceId) as { id: number; invoiceNumber: string; totalAmount: number; paidAmount: number } | undefined;
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice not found.");
|
||||
}
|
||||
|
||||
const balanceDue = invoice.totalAmount - invoice.paidAmount;
|
||||
|
||||
if (amount > balanceDue) {
|
||||
throw new Error("Payment cannot exceed invoice balance.");
|
||||
}
|
||||
|
||||
const newPaidAmount = invoice.paidAmount + amount;
|
||||
const newStatus = Math.abs(newPaidAmount - invoice.totalAmount) <= 0.005 ? "paid" : "partial";
|
||||
|
||||
const tx = db().transaction(() => {
|
||||
db()
|
||||
.prepare(`INSERT INTO customer_payments (invoice_id, amount, notes) VALUES (?, ?, ?)`)
|
||||
.run(invoiceId, amount, notes);
|
||||
db()
|
||||
.prepare(`UPDATE customer_invoices SET paid_amount = ?, status = ? WHERE id = ?`)
|
||||
.run(newPaidAmount, newStatus, invoiceId);
|
||||
});
|
||||
|
||||
tx();
|
||||
|
||||
createJournalEntry("customer_payment", "customer_invoice", invoiceId, `Payment received for ${invoice.invoiceNumber}`, [
|
||||
{ accountCode: "1000", accountName: "Cash", debit: amount, credit: 0 },
|
||||
{ accountCode: "1100", accountName: "Accounts Receivable", debit: 0, credit: amount }
|
||||
]);
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/accounting");
|
||||
revalidatePath("/invoices");
|
||||
}
|
||||
|
||||
export async function payVendorBill(formData: FormData) {
|
||||
const vendorBillId = Number(getText(formData, "vendorBillId"));
|
||||
const amount = getNumber(formData, "amount");
|
||||
const notes = getText(formData, "notes");
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new Error("Payment amount must be greater than zero.");
|
||||
}
|
||||
|
||||
const bill = db()
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, bill_number AS billNumber, total_amount AS totalAmount, paid_amount AS paidAmount
|
||||
FROM vendor_bills
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.get(vendorBillId) as { id: number; billNumber: string; totalAmount: number; paidAmount: number } | undefined;
|
||||
|
||||
if (!bill) {
|
||||
throw new Error("Vendor bill not found.");
|
||||
}
|
||||
|
||||
const balanceDue = bill.totalAmount - bill.paidAmount;
|
||||
|
||||
if (amount > balanceDue) {
|
||||
throw new Error("Payment cannot exceed vendor bill balance.");
|
||||
}
|
||||
|
||||
const newPaidAmount = bill.paidAmount + amount;
|
||||
const newStatus = Math.abs(newPaidAmount - bill.totalAmount) <= 0.005 ? "paid" : "partial";
|
||||
|
||||
const tx = db().transaction(() => {
|
||||
db()
|
||||
.prepare(`INSERT INTO vendor_payments (vendor_bill_id, amount, notes) VALUES (?, ?, ?)`)
|
||||
.run(vendorBillId, amount, notes);
|
||||
db()
|
||||
.prepare(`UPDATE vendor_bills SET paid_amount = ?, status = ? WHERE id = ?`)
|
||||
.run(newPaidAmount, newStatus, vendorBillId);
|
||||
});
|
||||
|
||||
tx();
|
||||
|
||||
createJournalEntry("vendor_payment", "vendor_bill", vendorBillId, `Vendor payment posted for ${bill.billNumber}`, [
|
||||
{ accountCode: "2000", accountName: "Accounts Payable", debit: amount, credit: 0 },
|
||||
{ accountCode: "1000", accountName: "Cash", debit: 0, credit: amount }
|
||||
]);
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/accounting");
|
||||
revalidatePath("/vendor-bills");
|
||||
}
|
||||
Reference in New Issue
Block a user