shared catalog in Bestellungen, catalog picker in line items, Ersatzbeschaffung flag, vendor detail flash fix

This commit is contained in:
Matthias Hochmeister
2026-03-27 14:50:31 +01:00
parent c704e2c173
commit 29d66e37a1
16 changed files with 506 additions and 32 deletions

View File

@@ -460,6 +460,29 @@ class AusruestungsanfrageController {
}
}
async updatePositionZurueckgegeben(req: Request, res: Response): Promise<void> {
try {
const positionId = Number(req.params.positionId);
const { altes_geraet_zurueckgegeben } = req.body as { altes_geraet_zurueckgegeben?: boolean };
if (typeof altes_geraet_zurueckgegeben !== 'boolean') {
res.status(400).json({ success: false, message: 'altes_geraet_zurueckgegeben (boolean) ist erforderlich' });
return;
}
const position = await ausruestungsanfrageService.updatePositionZurueckgegeben(positionId, altes_geraet_zurueckgegeben);
if (!position) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: position });
} catch (error) {
logger.error('AusruestungsanfrageController.updatePositionZurueckgegeben error', { error });
res.status(500).json({ success: false, message: 'Rückgabestatus konnte nicht aktualisiert werden' });
}
}
// -------------------------------------------------------------------------
// Overview
// -------------------------------------------------------------------------

View File

@@ -8,6 +8,51 @@ import fs from 'fs';
const param = (req: Request, key: string): string => req.params[key] as string;
class BestellungController {
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel)
// ---------------------------------------------------------------------------
async listKatalogItems(req: Request, res: Response): Promise<void> {
try {
const search = req.query.search as string | undefined;
const kategorie = req.query.kategorie as string | undefined;
const items = await bestellungService.getKatalogItems({ search, kategorie });
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('BestellungController.listKatalogItems error', { error });
res.status(500).json({ success: false, message: 'Katalogartikel konnten nicht geladen werden' });
}
}
async getKatalogItem(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'itemId'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const item = await bestellungService.getKatalogItem(id);
if (!item) {
res.status(404).json({ success: false, message: 'Katalogartikel nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('BestellungController.getKatalogItem error', { error });
res.status(500).json({ success: false, message: 'Katalogartikel konnte nicht geladen werden' });
}
}
async listKatalogKategorien(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await bestellungService.getKatalogKategorien();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('BestellungController.listKatalogKategorien error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
// ---------------------------------------------------------------------------
// Vendors
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,9 @@
-- Migration 065: Shared catalog + request type fields
-- Link bestellpositionen to ausruestung_artikel (shared catalog)
ALTER TABLE bestellpositionen
ADD COLUMN IF NOT EXISTS artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL;
-- Add replacement/return fields to ausruestung_anfrage_positionen
ALTER TABLE ausruestung_anfrage_positionen
ADD COLUMN IF NOT EXISTS ist_ersatz BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS altes_geraet_zurueckgegeben BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -62,6 +62,7 @@ router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfr
// ---------------------------------------------------------------------------
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));
// ---------------------------------------------------------------------------
// Linking requests to orders

View File

@@ -52,6 +52,31 @@ router.delete(
bestellungController.deleteVendor.bind(bestellungController)
);
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel, accessed via bestellungen context)
// ---------------------------------------------------------------------------
router.get(
'/katalog/items',
authenticate,
requirePermission('bestellungen:view'),
bestellungController.listKatalogItems.bind(bestellungController)
);
router.get(
'/katalog/items/:itemId',
authenticate,
requirePermission('bestellungen:view'),
bestellungController.getKatalogItem.bind(bestellungController)
);
router.get(
'/katalog/kategorien',
authenticate,
requirePermission('bestellungen:view'),
bestellungController.listKatalogKategorien.bind(bestellungController)
);
// ---------------------------------------------------------------------------
// Orders (Bestellungen)
// ---------------------------------------------------------------------------

View File

@@ -422,7 +422,7 @@ async function getRequestById(id: number) {
async function createRequest(
userId: string,
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; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[],
notizen?: string,
bezeichnung?: string,
fuerBenutzerName?: string,
@@ -476,9 +476,9 @@ async function createRequest(
}
await client.query(
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
VALUES ($1, $2, $3, $4, $5)`,
[anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null],
`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],
);
// NOTE: eigenschaft values are NOT saved in the transaction to avoid
@@ -527,7 +527,7 @@ async function updateRequest(
data: {
bezeichnung?: string;
notizen?: string;
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; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
},
) {
const client = await pool.connect();
@@ -581,9 +581,9 @@ async function updateRequest(
}
const prevGeliefert = geliefertMap.get(`${item.artikel_id ?? 0}:${itemBezeichnung}`) ?? false;
await client.query(
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert)
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert],
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert, ist_ersatz)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert, item.ist_ersatz ?? false],
);
}
}
@@ -652,6 +652,14 @@ async function updatePositionGeliefert(positionId: number, geliefert: boolean) {
return position;
}
async function updatePositionZurueckgegeben(positionId: number, zurueckgegeben: boolean) {
const result = await pool.query(
`UPDATE ausruestung_anfrage_positionen SET altes_geraet_zurueckgegeben = $1 WHERE id = $2 RETURNING *`,
[zurueckgegeben, positionId],
);
return result.rows[0] || null;
}
async function updateRequestStatus(
id: number,
status: string,
@@ -768,10 +776,35 @@ async function createOrdersFromRequest(
const bestellung = bestellungResult.rows[0];
for (const pos of orderData.positionen) {
// Look up the anfrage position to get artikel_id and eigenschaften
let artikelId: number | null = null;
let spezifikationen: string[] = [];
if (pos.position_id) {
const posResult = await client.query(
`SELECT p.artikel_id FROM ausruestung_anfrage_positionen p WHERE p.id = $1`,
[pos.position_id]
);
if (posResult.rows.length > 0) {
artikelId = posResult.rows[0].artikel_id || null;
}
// Load eigenschaften and map to spezifikationen strings
try {
const eigResult = await client.query(
`SELECT ae.name AS eigenschaft_name, pe.wert
FROM ausruestung_position_eigenschaften pe
JOIN ausruestung_artikel_eigenschaften ae ON ae.id = pe.eigenschaft_id
WHERE pe.position_id = $1
ORDER BY ae.sort_order, ae.id`,
[pos.position_id]
);
spezifikationen = eigResult.rows.map((e: { eigenschaft_name: string; wert: string }) => `${e.eigenschaft_name}: ${e.wert}`);
} catch { /* table may not exist */ }
}
await client.query(
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen)
VALUES ($1, $2, $3, $4, $5)`,
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null]
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen, artikel_id, spezifikationen)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null, artikelId, JSON.stringify(spezifikationen)]
);
}
@@ -875,6 +908,7 @@ export default {
getMyRequests,
getRequestById,
updatePositionGeliefert,
updatePositionZurueckgegeben,
createRequest,
updateRequest,
updateRequestStatus,

View File

@@ -7,6 +7,38 @@ import logger from '../utils/logger';
import fs from 'fs';
import notificationService from './notification.service';
import { permissionService } from './permission.service';
import ausruestungsanfrageService from './ausruestungsanfrage.service';
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel via ausruestungsanfrageService)
// ---------------------------------------------------------------------------
async function getKatalogItems(filters?: { search?: string; kategorie?: string }) {
try {
return await ausruestungsanfrageService.getItems({ search: filters?.search, kategorie: filters?.kategorie, aktiv: true });
} catch (error) {
logger.error('BestellungService.getKatalogItems failed', { error });
throw new Error('Katalogartikel konnten nicht geladen werden');
}
}
async function getKatalogItem(id: number) {
try {
return await ausruestungsanfrageService.getItemById(id);
} catch (error) {
logger.error('BestellungService.getKatalogItem failed', { error, id });
throw new Error('Katalogartikel konnte nicht geladen werden');
}
}
async function getKatalogKategorien() {
try {
return await ausruestungsanfrageService.getKategorien();
} catch (error) {
logger.error('BestellungService.getKatalogKategorien failed', { error });
throw new Error('Katalogkategorien konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Vendors (Lieferanten)
@@ -168,7 +200,13 @@ async function getOrderById(id: number) {
if (orderResult.rows.length === 0) return null;
const [positionen, dateien, erinnerungen, historie] = await Promise.all([
pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]),
pool.query(
`SELECT bp.*, aa.bezeichnung AS artikel_bezeichnung
FROM bestellpositionen bp
LEFT JOIN ausruestung_artikel aa ON aa.id = bp.artikel_id
WHERE bp.bestellung_id = $1 ORDER BY bp.id`,
[id]
),
pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]),
pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]),
pool.query(`SELECT h.*, COALESCE(u.name, u.preferred_username, u.email) AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]),
@@ -425,13 +463,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
// Line Items (Bestellpositionen)
// ---------------------------------------------------------------------------
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[] }, userId: string) {
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[]; artikel_id?: number }, userId: string) {
try {
const result = await pool.query(
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen, artikel_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
RETURNING *`,
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || [])]
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || []), data.artikel_id || null]
);
await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId);
return result.rows[0];
@@ -693,6 +731,10 @@ async function getHistory(bestellungId: number) {
}
export default {
// Catalog
getKatalogItems,
getKatalogItem,
getKatalogKategorien,
// Vendors
getVendors,
getVendorById,