feat(ausruestungsanfrage): add personal item tracking, catalog enforcement, and detail pages

This commit is contained in:
Matthias Hochmeister
2026-04-14 16:49:20 +02:00
parent e6b6639fe9
commit 633a75cb0b
15 changed files with 1031 additions and 26 deletions

View File

@@ -428,7 +428,7 @@ async function getRequestById(id: number) {
async function createRequest(
userId: string,
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[],
items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[]; persoenlich_id?: string; neuer_zustand?: string }[],
notizen?: string,
bezeichnung?: string,
fuerBenutzerName?: string,
@@ -471,7 +471,26 @@ async function createRequest(
for (const item of items) {
let itemBezeichnung = item.bezeichnung;
if (item.artikel_id) {
let itemArtikelId = item.artikel_id || null;
let itemPersoenlichId: string | null = null;
let itemAktuellerZustand: string | null = null;
let itemNeuerZustand: string | null = null;
if (item.persoenlich_id) {
// Load personal item details
const persResult = await client.query(
'SELECT bezeichnung, zustand, artikel_id FROM persoenliche_ausruestung WHERE id = $1',
[item.persoenlich_id],
);
if (persResult.rows.length > 0) {
const pers = persResult.rows[0];
itemBezeichnung = pers.bezeichnung;
itemArtikelId = pers.artikel_id || itemArtikelId;
itemPersoenlichId = item.persoenlich_id;
itemAktuellerZustand = pers.zustand || null;
itemNeuerZustand = item.neuer_zustand || null;
}
} else if (item.artikel_id) {
const artikelResult = await client.query(
'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1',
[item.artikel_id],
@@ -482,9 +501,9 @@ async function createRequest(
}
await client.query(
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz)
VALUES ($1, $2, $3, $4, $5, $6)`,
[anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false],
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz, persoenlich_id, aktueller_zustand, neuer_zustand)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[anfrage.id, itemArtikelId, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false, itemPersoenlichId, itemAktuellerZustand, itemNeuerZustand],
);
// NOTE: eigenschaft values are NOT saved in the transaction to avoid
@@ -710,6 +729,19 @@ async function updateRequestStatus(
[id],
);
} catch { /* column may not exist yet */ }
// Apply personal equipment status changes
const statusChangePositionen = await pool.query(
`SELECT persoenlich_id, neuer_zustand FROM ausruestung_anfrage_positionen
WHERE anfrage_id = $1 AND persoenlich_id IS NOT NULL AND neuer_zustand IS NOT NULL`,
[id],
);
for (const row of statusChangePositionen.rows) {
await pool.query(
`UPDATE persoenliche_ausruestung SET zustand = $1 WHERE id = $2`,
[row.neuer_zustand, row.persoenlich_id],
);
}
}
return updated;
@@ -1006,6 +1038,22 @@ async function assignDeliveredItems(
);
const newId = insertResult.rows[0].id;
// Copy position eigenschaften to persoenliche_ausruestung_eigenschaften
const posEigResult = await client.query(
`SELECT poe.eigenschaft_id, aae.name, poe.wert
FROM ausruestung_position_eigenschaften poe
JOIN ausruestung_artikel_eigenschaften aae ON aae.id = poe.eigenschaft_id
WHERE poe.position_id = $1`,
[a.positionId],
);
for (const row of posEigResult.rows) {
await client.query(
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
[newId, row.eigenschaft_id, row.name, row.wert],
);
}
await client.query(
`UPDATE ausruestung_anfrage_positionen
SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1
@@ -1034,6 +1082,38 @@ async function assignDeliveredItems(
}
}
// ---------------------------------------------------------------------------
// Unassigned positions + position artikel update
// ---------------------------------------------------------------------------
async function getUnassignedPositions() {
const result = await pool.query(`
SELECT
p.id, p.bezeichnung, p.menge, p.artikel_id,
a.id AS anfrage_id,
a.bezeichnung AS anfrage_bezeichnung,
a.bestell_nummer,
a.bestell_jahr,
COALESCE(a.fuer_benutzer_name,
NULLIF(TRIM(COALESCE(u.given_name,'') || ' ' || COALESCE(u.family_name,'')), ''),
u.name) AS fuer_wen
FROM ausruestung_anfrage_positionen p
JOIN ausruestung_anfragen a ON a.id = p.anfrage_id
LEFT JOIN users u ON u.id = a.anfrager_id
WHERE p.geliefert = true AND p.zuweisung_typ IS NULL
ORDER BY a.bestell_jahr DESC NULLS LAST, a.bestell_nummer DESC NULLS LAST, p.bezeichnung
`);
return result.rows;
}
async function updatePositionArtikelId(positionId: number, artikelId: number) {
const result = await pool.query(
`UPDATE ausruestung_anfrage_positionen SET artikel_id = $1 WHERE id = $2 RETURNING *`,
[artikelId, positionId],
);
return result.rows[0] || null;
}
export default {
getAllUsers,
getKategorien,
@@ -1065,4 +1145,6 @@ export default {
getOverview,
getWidgetOverview,
assignDeliveredItems,
getUnassignedPositions,
updatePositionArtikelId,
};

View File

@@ -19,6 +19,7 @@ interface CreatePersonalEquipmentData {
anschaffung_datum?: string;
zustand?: string;
notizen?: string;
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
}
interface UpdatePersonalEquipmentData {
@@ -33,6 +34,7 @@ interface UpdatePersonalEquipmentData {
anschaffung_datum?: string | null;
zustand?: string;
notizen?: string | null;
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null;
}
const BASE_SELECT = `
@@ -45,6 +47,23 @@ const BASE_SELECT = `
WHERE pa.geloescht_am IS NULL
`;
async function loadEigenschaften(ids: string[]) {
if (ids.length === 0) return new Map<string, { id: number; persoenlich_id: string; eigenschaft_id: number | null; name: string; wert: string }[]>();
const result = await pool.query(
`SELECT pae.id, pae.persoenlich_id, pae.eigenschaft_id, pae.name, pae.wert
FROM persoenliche_ausruestung_eigenschaften pae
WHERE pae.persoenlich_id = ANY($1)`,
[ids],
);
const map = new Map<string, typeof result.rows>();
for (const row of result.rows) {
const arr = map.get(row.persoenlich_id) || [];
arr.push(row);
map.set(row.persoenlich_id, arr);
}
return map;
}
async function getAll(filters: PersonalEquipmentFilters = {}) {
try {
const conditions: string[] = [];
@@ -68,6 +87,13 @@ async function getAll(filters: PersonalEquipmentFilters = {}) {
`${BASE_SELECT}${where} ORDER BY pa.bezeichnung`,
params,
);
const ids = result.rows.map((r: { id: string }) => r.id);
const eigMap = await loadEigenschaften(ids);
for (const row of result.rows) {
row.eigenschaften = eigMap.get(row.id) || [];
}
return result.rows;
} catch (error) {
logger.error('personalEquipmentService.getAll failed', { error });
@@ -81,6 +107,13 @@ async function getByUserId(userId: string) {
`${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`,
[userId],
);
const ids = result.rows.map((r: { id: string }) => r.id);
const eigMap = await loadEigenschaften(ids);
for (const row of result.rows) {
row.eigenschaften = eigMap.get(row.id) || [];
}
return result.rows;
} catch (error) {
logger.error('personalEquipmentService.getByUserId failed', { error, userId });
@@ -94,7 +127,17 @@ async function getById(id: string) {
`${BASE_SELECT} AND pa.id = $1`,
[id],
);
return result.rows[0] || null;
if (!result.rows[0]) return null;
const eigResult = await pool.query(
`SELECT pae.id, pae.persoenlich_id, pae.eigenschaft_id, pae.name, pae.wert
FROM persoenliche_ausruestung_eigenschaften pae
WHERE pae.persoenlich_id = $1`,
[id],
);
result.rows[0].eigenschaften = eigResult.rows;
return result.rows[0];
} catch (error) {
logger.error('personalEquipmentService.getById failed', { error, id });
throw new Error('Failed to fetch personal equipment item');
@@ -125,8 +168,20 @@ async function create(data: CreatePersonalEquipmentData, requestingUserId: strin
requestingUserId,
],
);
logger.info('Personal equipment created', { id: result.rows[0].id, by: requestingUserId });
return result.rows[0];
const created = result.rows[0];
logger.info('Personal equipment created', { id: created.id, by: requestingUserId });
if (data.eigenschaften && data.eigenschaften.length > 0) {
for (const e of data.eigenschaften) {
await pool.query(
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
VALUES ($1, $2, $3, $4)`,
[created.id, e.eigenschaft_id ?? null, e.name, e.wert],
);
}
}
return created;
} catch (error) {
logger.error('personalEquipmentService.create failed', { error });
throw new Error('Failed to create personal equipment');
@@ -156,19 +211,46 @@ async function update(id: string, data: UpdatePersonalEquipmentData) {
if (data.zustand !== undefined) addField('zustand', data.zustand);
if (data.notizen !== undefined) addField('notizen', data.notizen);
if (fields.length === 0) {
if (fields.length === 0 && data.eigenschaften === undefined) {
throw new Error('No fields to update');
}
values.push(id);
const result = await pool.query(
`UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`,
values,
);
let updated = null;
if (fields.length > 0) {
values.push(id);
const result = await pool.query(
`UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`,
values,
);
if (result.rows.length === 0) return null;
updated = result.rows[0];
}
if (data.eigenschaften !== undefined) {
await pool.query('DELETE FROM persoenliche_ausruestung_eigenschaften WHERE persoenlich_id = $1', [id]);
if (data.eigenschaften && data.eigenschaften.length > 0) {
for (const e of data.eigenschaften) {
await pool.query(
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
VALUES ($1, $2, $3, $4)`,
[id, e.eigenschaft_id ?? null, e.name, e.wert],
);
}
}
}
if (!updated) {
// Only eigenschaften were updated, fetch the row
const result = await pool.query(
`SELECT * FROM persoenliche_ausruestung WHERE id = $1 AND geloescht_am IS NULL`,
[id],
);
if (result.rows.length === 0) return null;
updated = result.rows[0];
}
if (result.rows.length === 0) return null;
logger.info('Personal equipment updated', { id });
return result.rows[0];
return updated;
} catch (error) {
logger.error('personalEquipmentService.update failed', { error, id });
throw error;