Add multi-line sale builder with pending-draft safeguard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
194
src/lib/server/invoices.js
Normal file
194
src/lib/server/invoices.js
Normal file
@ -0,0 +1,194 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user