Updated A LOT
This commit is contained in:
312
server/services/parts.js
Normal file
312
server/services/parts.js
Normal file
@@ -0,0 +1,312 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user