Files
avtoambor/src/lib/server/invoices.js
David Beccue 00ee9fb1fe Add multi-line sale builder with pending-draft safeguard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:57:49 +05:00

195 lines
6.7 KiB
JavaScript

import { getDb } from './db.js';
import { recordMovement } from './movements.js';
export function getPendingInvoice() {
const db = getDb();
const invoice = db.prepare(`
SELECT * FROM invoices WHERE status = 'pending' LIMIT 1
`).get();
if (!invoice) return null;
return { invoice, lines: linesFor(invoice.id) };
}
export function getOrCreatePendingInvoice() {
const db = getDb();
const existing = db.prepare(`SELECT * FROM invoices WHERE status = 'pending' LIMIT 1`).get();
if (existing) return { invoice: existing, lines: linesFor(existing.id) };
const id = db.prepare(`INSERT INTO invoices (status) VALUES ('pending')`).run().lastInsertRowid;
const invoice = db.prepare(`SELECT * FROM invoices WHERE id = ?`).get(id);
return { invoice, lines: [] };
}
export function getInvoice(id) {
const db = getDb();
const invoice = db.prepare(`SELECT * FROM invoices WHERE id = ?`).get(Number(id));
if (!invoice) return null;
return { invoice, lines: linesFor(invoice.id) };
}
function linesFor(invoiceId) {
return getDb().prepare(`
SELECT
l.*,
p.sku AS part_sku,
p.name_en AS part_name_en,
p.name_tg AS part_name_tg,
p.quantity_on_hand AS part_on_hand
FROM invoice_lines l
LEFT JOIN parts p ON p.id = l.part_id
WHERE l.invoice_id = ?
ORDER BY l.sort_order, l.id
`).all(invoiceId);
}
function nextSortOrder(invoiceId) {
const row = getDb().prepare(`
SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM invoice_lines WHERE invoice_id = ?
`).get(invoiceId);
return row.n;
}
/**
* Add a parts-based line to an invoice. If a line for the same part_id
* already exists on this invoice, increments that line's quantity rather
* than inserting a new row (per-edit merge). The line's unit price is left
* alone in the merge case so a user-edited price isn't clobbered.
*/
export function addLine({ invoice_id, part_id, quantity, unit_price_dirams }) {
const db = getDb();
const invoiceId = Number(invoice_id);
const partId = Number(part_id);
const qty = Math.floor(Number(quantity));
if (!invoiceId) throw new Error('invoice_id required');
if (!partId) throw new Error('part_id required');
if (!Number.isInteger(qty) || qty <= 0) throw new Error('quantity must be a positive integer');
return db.transaction(() => {
const part = db.prepare(`SELECT id, sale_price FROM parts WHERE id = ?`).get(partId);
if (!part) throw new Error(`part ${partId} not found`);
const price = (unit_price_dirams === '' || unit_price_dirams == null)
? Number(part.sale_price || 0)
: Math.round(Number(unit_price_dirams));
const existing = db.prepare(`
SELECT id, quantity FROM invoice_lines
WHERE invoice_id = ? AND part_id = ? AND affects_inventory = 1
LIMIT 1
`).get(invoiceId, partId);
if (existing) {
db.prepare(`UPDATE invoice_lines SET quantity = ? WHERE id = ?`)
.run(existing.quantity + qty, existing.id);
return existing.id;
}
return db.prepare(`
INSERT INTO invoice_lines
(invoice_id, part_id, label, quantity, unit_price_dirams, affects_inventory, sort_order)
VALUES (?, ?, NULL, ?, ?, 1, ?)
`).run(invoiceId, partId, qty, price, nextSortOrder(invoiceId)).lastInsertRowid;
})();
}
export function addCustomLine({ invoice_id, label, quantity, unit_price_dirams }) {
const db = getDb();
const invoiceId = Number(invoice_id);
const cleanLabel = (label || '').trim();
const qty = Math.floor(Number(quantity));
const price = Math.round(Number(unit_price_dirams || 0));
if (!invoiceId) throw new Error('invoice_id required');
if (!cleanLabel) throw new Error('label required');
if (!Number.isInteger(qty) || qty <= 0) throw new Error('quantity must be a positive integer');
return db.prepare(`
INSERT INTO invoice_lines
(invoice_id, part_id, label, quantity, unit_price_dirams, affects_inventory, sort_order)
VALUES (?, NULL, ?, ?, ?, 0, ?)
`).run(invoiceId, cleanLabel, qty, price, nextSortOrder(invoiceId)).lastInsertRowid;
}
export function updateLine(line_id, { quantity, unit_price_dirams, label }) {
const db = getDb();
const id = Number(line_id);
if (!id) throw new Error('line_id required');
const current = db.prepare(`SELECT * FROM invoice_lines WHERE id = ?`).get(id);
if (!current) throw new Error(`line ${id} not found`);
const qty = quantity == null || quantity === ''
? current.quantity
: Math.floor(Number(quantity));
if (!Number.isInteger(qty) || qty <= 0) throw new Error('quantity must be a positive integer');
const price = unit_price_dirams == null || unit_price_dirams === ''
? current.unit_price_dirams
: Math.round(Number(unit_price_dirams));
const newLabel = current.affects_inventory === 0
? (label == null ? current.label : String(label).trim() || current.label)
: current.label;
db.prepare(`
UPDATE invoice_lines SET quantity = ?, unit_price_dirams = ?, label = ?
WHERE id = ?
`).run(qty, price, newLabel, id);
}
export function removeLine(line_id) {
getDb().prepare(`DELETE FROM invoice_lines WHERE id = ?`).run(Number(line_id));
}
/**
* Commit the invoice: for each inventoried line, record an 'out' stock
* movement (which decrements parts.quantity_on_hand atomically and rejects
* if there's not enough stock). All movements + the status flip happen in
* one transaction — if any line fails, nothing is decremented and the
* draft stays pending.
*/
export function saveInvoice(invoice_id) {
const db = getDb();
const id = Number(invoice_id);
return db.transaction(() => {
const invoice = db.prepare(`SELECT * FROM invoices WHERE id = ?`).get(id);
if (!invoice) throw new Error(`invoice ${id} not found`);
if (invoice.status !== 'pending') throw new Error('invoice already saved');
const lines = db.prepare(`
SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY sort_order, id
`).all(id);
if (lines.length === 0) throw new Error('cannot save empty invoice');
const reference = `INV-${id}`;
for (const line of lines) {
if (line.affects_inventory !== 1) continue;
recordMovement({
part_id: line.part_id,
movement_type: 'out',
quantity: line.quantity,
unit_price: line.unit_price_dirams / 100,
reference
});
}
const total = lines.reduce(
(sum, l) => sum + (l.quantity * l.unit_price_dirams),
0
);
db.prepare(`
UPDATE invoices
SET status = 'saved', saved_at = datetime('now'), total_dirams = ?
WHERE id = ?
`).run(total, id);
return id;
})();
}
export function cancelInvoice(invoice_id) {
getDb().prepare(`
DELETE FROM invoices WHERE id = ? AND status = 'pending'
`).run(Number(invoice_id));
}