195 lines
6.7 KiB
JavaScript
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));
|
|
}
|