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 = {}) { 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.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`, [ 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) { 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 = ?`, [id] ); 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) { 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 = ?', [input.part_number])) { 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 (${cols.join(', ')}, created_by, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`, [...cols.map((c) => input[c]), userId, ts, ts] ); audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number }); return getPart(result.lastInsertRowid); } function updatePart(id, body, userId) { const before = one('SELECT * FROM parts WHERE id = ?', [id]); if (!before) return null; const input = normalize({ ...before, ...body }); if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND id <> ?', [input.part_number, 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); } function deletePart(id, userId) { const before = one('SELECT * FROM parts WHERE id = ?', [id]); if (!before) return false; run('DELETE FROM parts WHERE id = ?', [id]); audit(userId, 'delete', 'part', id, before, null); return true; } // ---- Stock locations ------------------------------------------------------- function saveStock(partId, body, userId) { if (!one('SELECT id FROM parts WHERE id = ?', [partId])) 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); } function deleteStock(partId, stockId, userId) { 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); } // ---- Stock movements ------------------------------------------------------- function recordTransaction(partId, body, userId) { if (!one('SELECT id FROM parts WHERE id = ?', [partId])) 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); }); } // ---- Photos ---------------------------------------------------------------- function addPhoto(partId, body, userId) { if (!one('SELECT id FROM parts WHERE id = ?', [partId])) 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); } function deletePhoto(partId, photoId, userId) { 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); } module.exports = { STATUSES, TRANSACTION_TYPES, addPhoto, createPart, deletePart, deletePhoto, deleteStock, getPart, listParts, recordTransaction, saveStock, updatePart };