feat(ausruestungsanfrage): add personal item tracking, catalog enforcement, and detail pages
This commit is contained in:
@@ -267,7 +267,7 @@ class AusruestungsanfrageController {
|
||||
async createRequest(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name } = req.body as {
|
||||
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
|
||||
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[]; persoenlich_id?: string; neuer_zustand?: string }[];
|
||||
notizen?: string;
|
||||
bezeichnung?: string;
|
||||
fuer_benutzer_id?: string;
|
||||
@@ -280,7 +280,7 @@ class AusruestungsanfrageController {
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.bezeichnung || item.bezeichnung.trim().length === 0) {
|
||||
if (!item.persoenlich_id && (!item.bezeichnung || item.bezeichnung.trim().length === 0)) {
|
||||
res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' });
|
||||
return;
|
||||
}
|
||||
@@ -616,6 +616,40 @@ class AusruestungsanfrageController {
|
||||
res.status(500).json({ success: false, message: 'Widget-Daten konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unassigned positions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getUnassignedPositions(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await ausruestungsanfrageService.getUnassignedPositions();
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('AusruestungsanfrageController.getUnassignedPositions error', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updatePositionArtikelId(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const positionId = Number(req.params.positionId);
|
||||
const artikelId = Number(req.body.artikel_id);
|
||||
if (isNaN(positionId) || isNaN(artikelId) || artikelId <= 0) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige IDs' });
|
||||
return;
|
||||
}
|
||||
const updated = await ausruestungsanfrageService.updatePositionArtikelId(positionId, artikelId);
|
||||
if (!updated) {
|
||||
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: updated });
|
||||
} catch (error) {
|
||||
logger.error('AusruestungsanfrageController.updatePositionArtikelId error', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AusruestungsanfrageController();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Migration: 085_personal_equipment_eigenschaften
|
||||
-- Adds eigenschaften (characteristics) storage for persoenliche_ausruestung
|
||||
-- and extends ausruestung_anfrage_positionen for status change requests.
|
||||
|
||||
-- 1. Characteristics table for personal equipment items
|
||||
CREATE TABLE IF NOT EXISTS persoenliche_ausruestung_eigenschaften (
|
||||
id SERIAL PRIMARY KEY,
|
||||
persoenlich_id UUID NOT NULL REFERENCES persoenliche_ausruestung(id) ON DELETE CASCADE,
|
||||
eigenschaft_id INT REFERENCES ausruestung_artikel_eigenschaften(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
wert TEXT NOT NULL,
|
||||
UNIQUE(persoenlich_id, eigenschaft_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_eigenschaften_persoenlich
|
||||
ON persoenliche_ausruestung_eigenschaften(persoenlich_id);
|
||||
|
||||
-- 2. Add status-change request columns to anfrage positions
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS aktueller_zustand TEXT;
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS neuer_zustand TEXT CHECK (neuer_zustand IN ('gut','beschaedigt','abgaengig','verloren'));
|
||||
@@ -57,10 +57,17 @@ router.patch('/requests/:id', authenticate, ausruestungsanfrageController.update
|
||||
router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController));
|
||||
router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unassigned positions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/nicht-zugewiesen', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getUnassignedPositions.bind(ausruestungsanfrageController));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Position delivery tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.patch('/positionen/:positionId/artikel', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionArtikelId.bind(ausruestungsanfrageController));
|
||||
router.patch('/positionen/:positionId/geliefert', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionGeliefert.bind(ausruestungsanfrageController));
|
||||
router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionZurueckgegeben.bind(ausruestungsanfrageController));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user