Files
2026-06-08 00:54:21 -05:00

321 lines
13 KiB
JavaScript

const { all, audit, now, one, run, tx } = require('../db');
const STATUSES = ['active', 'inactive', 'discontinued'];
// Stock movements. receive/issue/adjust change on-hand; reserve/unreserve change the
// reserved (committed) quantity. `adjust` sets the on-hand count to an absolute value.
const TRANSACTION_TYPES = ['receive', 'issue', 'adjust', 'reserve', 'unreserve'];
const COLUMNS = [
'part_number', 'name', 'description', 'category', 'manufacturer', 'manufacturer_part_number',
'barcode', 'unit_of_measure', 'unit_cost', 'reorder_point', 'reorder_quantity', 'measurements',
'supplier_contact_id', 'status', 'notes'
];
function round2(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function num(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function contactName(row, prefix) {
if (!row || !row[`${prefix}_id`]) return null;
const org = row[`${prefix}_org`];
const person = [row[`${prefix}_first`], row[`${prefix}_last`]].filter(Boolean).join(' ').trim();
return org || person || null;
}
function stockRows(partId) {
return all(
`SELECT s.*, c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last
FROM part_stock s LEFT JOIN contacts c ON c.id = s.location_contact_id
WHERE s.part_id = ? ORDER BY c.organization, s.aisle, s.bin, s.id`,
[partId]
).map((s) => ({
id: s.id,
part_id: s.part_id,
location_contact_id: s.location_contact_id,
location_name: s.location_org || [s.location_first, s.location_last].filter(Boolean).join(' ').trim() || 'Unassigned',
aisle: s.aisle,
bin: s.bin,
quantity: round2(s.quantity),
reserved: round2(s.reserved),
available: round2(s.quantity - s.reserved)
}));
}
function partPhotos(partId) {
return all('SELECT id, name, mime_type, data_base64 FROM part_photos WHERE part_id = ? ORDER BY id', [partId])
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 }));
}
function partTransactions(partId, limit = 100) {
return all(
`SELECT t.id, t.type, t.quantity, t.unit_cost, t.reference, t.notes, t.created_at, t.asset_id,
u.name AS created_by_name, a.asset_id AS asset_code,
c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last,
s.aisle, s.bin
FROM part_transactions t
LEFT JOIN users u ON u.id = t.created_by
LEFT JOIN assets a ON a.id = t.asset_id
LEFT JOIN part_stock s ON s.id = t.stock_id
LEFT JOIN contacts c ON c.id = s.location_contact_id
WHERE t.part_id = ? ORDER BY t.id DESC LIMIT ?`,
[partId, limit]
).map((t) => ({
id: t.id,
type: t.type,
quantity: round2(t.quantity),
unit_cost: t.unit_cost == null ? null : round2(t.unit_cost),
reference: t.reference,
notes: t.notes,
created_at: t.created_at,
created_by_name: t.created_by_name,
asset_code: t.asset_code,
location_name: t.location_org || [t.location_first, t.location_last].filter(Boolean).join(' ').trim() || null,
aisle: t.aisle,
bin: t.bin
}));
}
function totalsFor(stock) {
const on_hand = round2(stock.reduce((sum, s) => sum + s.quantity, 0));
const reserved = round2(stock.reduce((sum, s) => sum + s.reserved, 0));
return { on_hand, reserved, available: round2(on_hand - reserved), location_count: stock.length };
}
function decorate(row, totals) {
const onHand = totals.on_hand;
return {
...row,
supplier_name: contactName(row, 'supplier'),
on_hand: onHand,
reserved: totals.reserved,
available: totals.available,
location_count: totals.location_count,
stock_value: round2(onHand * num(row.unit_cost)),
low_stock: num(row.reorder_point) > 0 && onHand <= num(row.reorder_point)
};
}
function listParts(query = {}, companyId) {
const rows = all(
`SELECT p.*,
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last,
COALESCE(st.on_hand, 0) AS on_hand, COALESCE(st.reserved, 0) AS reserved, COALESCE(st.locations, 0) AS location_count
FROM parts p
LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id
LEFT JOIN (
SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
FROM part_stock GROUP BY part_id
) st ON st.part_id = p.id
WHERE (? IS NULL OR p.entity_id = ?)
AND (? IS NULL OR p.status = ?)
AND (? IS NULL OR p.category = ?)
AND (? IS NULL OR (
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
p.description LIKE '%' || ? || '%' OR p.barcode LIKE '%' || ? || '%' OR
p.manufacturer_part_number LIKE '%' || ? || '%'
))
ORDER BY p.name`,
[
companyId || null, companyId || null,
query.status || null, query.status || null,
query.category || null, query.category || null,
query.search || null, query.search || null, query.search || null,
query.search || null, query.search || null
]
).map((row) => {
const onHand = round2(row.on_hand);
const reserved = round2(row.reserved);
return decorate(row, { on_hand: onHand, reserved, available: round2(onHand - reserved), location_count: row.location_count });
});
if (query.low_stock === 'true' || query.low_stock === true) return rows.filter((r) => r.low_stock);
return rows;
}
function getPart(id, companyId) {
const row = one(
`SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ? AND (? IS NULL OR p.entity_id = ?)`,
[id, companyId || null, companyId || null]
);
if (!row) return null;
const stock = stockRows(id);
const part = decorate(row, totalsFor(stock));
part.stock = stock;
part.photos = partPhotos(id);
part.transactions = partTransactions(id);
return part;
}
function normalize(body) {
const input = {};
for (const column of COLUMNS) {
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
}
input.status = STATUSES.includes(input.status) ? input.status : 'active';
input.unit_of_measure = input.unit_of_measure || 'each';
for (const numeric of ['unit_cost', 'reorder_point', 'reorder_quantity']) {
if (input[numeric] !== undefined) input[numeric] = num(input[numeric]);
}
if (input.supplier_contact_id === '' || input.supplier_contact_id === undefined) input.supplier_contact_id = null;
return input;
}
function createPart(body, userId, companyId) {
const input = normalize(body);
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 });
if (one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ?', [input.part_number, companyId])) {
throw Object.assign(new Error('That part number already exists'), { status: 400 });
}
const ts = now();
const cols = COLUMNS.filter((c) => input[c] !== undefined);
const result = run(
`INSERT INTO parts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
);
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
return getPart(result.lastInsertRowid, companyId);
}
function updatePart(id, body, userId, companyId) {
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
if (!before || (companyId && before.entity_id !== companyId)) return null;
const input = normalize({ ...before, ...body });
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ? AND id <> ?', [input.part_number, before.entity_id, id])) {
throw Object.assign(new Error('That part number already exists'), { status: 400 });
}
run(
`UPDATE parts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
);
audit(userId, 'update', 'part', id, before, body);
return getPart(id, companyId);
}
function deletePart(id, userId, companyId) {
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
if (!before || (companyId && before.entity_id !== companyId)) return false;
run('DELETE FROM parts WHERE id = ?', [id]);
audit(userId, 'delete', 'part', id, before, null);
return true;
}
// ---- Stock locations -------------------------------------------------------
function partInCompany(partId, companyId) {
return Boolean(one('SELECT id FROM parts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [partId, companyId || null, companyId || null]));
}
function saveStock(partId, body, userId, companyId) {
if (!partInCompany(partId, companyId)) return null;
const ts = now();
const location = body.location_contact_id || null;
const aisle = body.aisle || null;
const bin = body.bin || null;
const quantity = num(body.quantity);
const reserved = Math.max(0, Math.min(quantity, num(body.reserved)));
if (body.id) {
const existing = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.id, partId]);
if (!existing) return null;
run(
'UPDATE part_stock SET location_contact_id = ?, aisle = ?, bin = ?, quantity = ?, reserved = ?, updated_at = ? WHERE id = ?',
[location, aisle, bin, quantity, reserved, ts, body.id]
);
audit(userId, 'update', 'part_stock', body.id, existing, body);
} else {
const result = run(
'INSERT INTO part_stock (part_id, location_contact_id, aisle, bin, quantity, reserved, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[partId, location, aisle, bin, quantity, reserved, ts, ts]
);
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
}
return getPart(partId, companyId);
}
function deleteStock(partId, stockId, userId, companyId) {
if (!partInCompany(partId, companyId)) return null;
const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
if (!result.changes) return null;
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
return getPart(partId, companyId);
}
// ---- Stock movements -------------------------------------------------------
function recordTransaction(partId, body, userId, companyId) {
if (!partInCompany(partId, companyId)) return null;
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive';
const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]);
if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 });
const qty = Math.abs(num(body.quantity));
if (!qty && type !== 'adjust') throw Object.assign(new Error('Quantity must be greater than zero'), { status: 400 });
let quantity = stock.quantity;
let reserved = stock.reserved;
switch (type) {
case 'receive': quantity += qty; break;
case 'issue': quantity = Math.max(0, quantity - qty); break;
case 'adjust': quantity = num(body.quantity); break; // absolute count correction
case 'reserve': reserved = Math.min(quantity, reserved + qty); break;
case 'unreserve': reserved = Math.max(0, reserved - qty); break;
default: break;
}
reserved = Math.min(reserved, quantity);
return tx(() => {
run('UPDATE part_stock SET quantity = ?, reserved = ?, updated_at = ? WHERE id = ?', [quantity, reserved, now(), stock.id]);
run(
`INSERT INTO part_transactions (part_id, stock_id, type, quantity, unit_cost, asset_id, reference, notes, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
partId, stock.id, type, type === 'adjust' ? num(body.quantity) : qty,
body.unit_cost != null && body.unit_cost !== '' ? num(body.unit_cost) : null,
body.asset_id || null, body.reference || null, body.notes || null, userId, now()
]
);
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
return getPart(partId, companyId);
});
}
// ---- Photos ----------------------------------------------------------------
function addPhoto(partId, body, userId, companyId) {
if (!partInCompany(partId, companyId)) return null;
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
run(
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)',
[partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
);
audit(userId, 'create', 'part_photo', partId, null, { name: body.name });
return getPart(partId, companyId);
}
function deletePhoto(partId, photoId, userId, companyId) {
if (!partInCompany(partId, companyId)) return null;
const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
if (!result.changes) return null;
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
return getPart(partId, companyId);
}
module.exports = {
STATUSES,
TRANSACTION_TYPES,
addPhoto,
createPart,
deletePart,
deletePhoto,
deleteStock,
getPart,
listParts,
recordTransaction,
saveStock,
updatePart
};