Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
213
apps/api/src/modules/orders/index.ts
Normal file
213
apps/api/src/modules/orders/index.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PoolConnection } from 'mysql2/promise';
|
||||
import { calculatePrice } from '@kc/platform/pricing';
|
||||
import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine';
|
||||
import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms';
|
||||
import { one, query, run, tx } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { enqueue } from '../../core/jobs.js';
|
||||
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { can, canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const j = (v: unknown, d: unknown = null) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
|
||||
async function nextNumber(c: PoolConnection, name: 'order' | 'contract', prefix: string): Promise<string> {
|
||||
await run('UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = ?', [name], c);
|
||||
return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
|
||||
}
|
||||
const orderView = (o: any, items: any[] = []) => ({
|
||||
id: o.id, number: o.number, orgId: o.org_id, orgName: o.org_name, status: o.status, placedVia: o.placed_via, approvalRequired: !!o.approval_required, note: o.note,
|
||||
failureNote: o.failure_note, failureAmbiguous: !!o.failure_ambiguous, createdAt: o.created_at, approvedAt: o.approved_at,
|
||||
items: items.map((i) => ({ id: i.id, quantity: i.quantity, discountBp: i.discount_bp, snapshot: j(i.snapshot_json), contractId: i.contract_id ?? null, contractNumber: i.contract_number ?? null })),
|
||||
});
|
||||
const contractView = (c: any) => ({
|
||||
id: c.id, number: c.number, orgId: c.org_id, orgName: c.org_name, status: c.status, productName: c.product_name, startedAt: c.started_at, termEnd: c.term_end, renewal: c.renewal, renewalTermMonths: c.renewal_term_months,
|
||||
noticeDays: c.notice_days, cancelRequestedAt: c.cancel_requested_at, cancelEffectiveAt: c.cancel_effective_at, cancelledAt: c.cancelled_at, resourceId: c.resource_id, price: j(c.price_snapshot_json), createdAt: c.created_at,
|
||||
});
|
||||
const ORDER_SQL = 'SELECT o.*, g.name AS org_name FROM orders o JOIN organizations g ON g.id = o.org_id';
|
||||
async function loadOrder(id: string) {
|
||||
const o = await one(`${ORDER_SQL} WHERE o.id = ?`, [id]);
|
||||
if (!o) return null;
|
||||
const items = await query('SELECT oi.*, c.id AS contract_id, c.number AS contract_number FROM order_items oi LEFT JOIN contracts c ON c.order_item_id = oi.id WHERE oi.order_id = ?', [id]);
|
||||
return { o, items };
|
||||
}
|
||||
const CONTRACT_SQL = `SELECT c.*, g.name AS org_name, JSON_VALUE(c.price_snapshot_json, '$.name') AS product_name FROM contracts c JOIN organizations g ON g.id = c.org_id`;
|
||||
|
||||
/** Wendet ein Ereignis des Bestell-Statusautomaten an (atomar über den erwarteten Ausgangszustand). */
|
||||
async function orderEvent(c: PoolConnection | undefined, id: string, from: string, ev: OrderEvent, extra: { sql?: string; params?: unknown[] } = {}): Promise<string> {
|
||||
const to = transition(ORDER_MACHINE, from as never, ev);
|
||||
const r = await run(`UPDATE orders SET status = ?${extra.sql ? ', ' + extra.sql : ''} WHERE id = ? AND status = ?`, [to, ...(extra.params ?? []), id, from], c);
|
||||
if (!r.affectedRows) throw conflict('Die Bestellung wurde inzwischen geändert. Bitte neu laden.', 'STALE_STATE');
|
||||
return to;
|
||||
}
|
||||
const startProvisioning = (orderId: string, key: string, correlationId: string) => enqueue('order.provision', { orderId }, { idempotencyKey: key, correlationId });
|
||||
const orderAccess = (a: AuthContext, orgId: string) => canInOrg(a.principal, orgId, 'orders.read', 'orders.read');
|
||||
|
||||
export const ordersModule: KcModule = {
|
||||
name: 'orders',
|
||||
permissions: {
|
||||
staff: {
|
||||
support: ['orders.read', 'contracts.read'], accounting: ['orders.read', 'contracts.read'],
|
||||
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'],
|
||||
},
|
||||
org: { owner: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], admin: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], member: ['orders.read', 'contracts.read'] },
|
||||
},
|
||||
register(app: FastifyInstance) {
|
||||
// ---- Bestellung anlegen ------------------------------------------------
|
||||
app.post('/orders', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0) })).min(1).max(20) }).parse(req.body);
|
||||
const staff = can(a.principal, 'orders.write');
|
||||
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
|
||||
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
|
||||
if (!org) throw notFound();
|
||||
if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann nichts bestellt werden', 'ORG_INACTIVE');
|
||||
|
||||
const lines: { p: any; it: { productId: string; quantity: number; discountBp: number }; snapshot: any }[] = [];
|
||||
for (const it of b.items) {
|
||||
const p = await one(`SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days
|
||||
FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?`, [it.productId]);
|
||||
if (!p || p.status !== 'active') throw badRequest('Produkt nicht verfügbar', 'PRODUCT_UNAVAILABLE');
|
||||
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
|
||||
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
|
||||
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
|
||||
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht
|
||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
|
||||
let terms = { termMonths: p.term_months as number, renewal: p.renewal as 'auto' | 'none', renewalTermMonths: p.renewal_term_months as number, noticeDays: p.notice_days as number };
|
||||
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
|
||||
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
|
||||
}
|
||||
const approvalRequired = !staff && lines.some((l) => !!l.p.requires_approval); // Personal bestellt = freigegeben
|
||||
const orderId = randomUUID();
|
||||
const number = await tx(async (c) => {
|
||||
const n = await nextNumber(c, 'order', 'B');
|
||||
await run('INSERT INTO orders (id, number, org_id, status, placed_by, placed_via, approval_required, approved_by, approved_at, note) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||
[orderId, n, b.orgId, approvalRequired ? 'pending_approval' : 'approved', a.user.id, staff ? 'staff' : 'customer', approvalRequired ? 1 : 0, approvalRequired ? null : a.user.id, approvalRequired ? null : new Date(), b.note ?? null], c);
|
||||
for (const l of lines) {
|
||||
const itemId = randomUUID();
|
||||
await run('INSERT INTO order_items (id, order_id, product_version_id, quantity, discount_bp, snapshot_json) VALUES (?,?,?,?,?,?)', [itemId, orderId, l.snapshot.productVersionId, l.it.quantity, l.it.discountBp, JSON.stringify(l.snapshot)], c);
|
||||
await run("INSERT INTO contracts (id, number, org_id, order_item_id, product_version_id, status, renewal, renewal_term_months, notice_days, price_snapshot_json) VALUES (?,?,?,?,?,'pending',?,?,?,?)",
|
||||
[randomUUID(), await nextNumber(c, 'contract', 'V'), b.orgId, itemId, l.snapshot.productVersionId, l.snapshot.terms.renewal, l.snapshot.terms.renewalTermMonths, l.snapshot.terms.noticeDays, JSON.stringify(l.snapshot)], c);
|
||||
}
|
||||
if (!approvalRequired) { await orderEvent(c, orderId, 'approved', 'start_provisioning'); await startProvisioning(orderId, `provision:${orderId}:1`, req.correlationId); }
|
||||
return n;
|
||||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'order.create', resourceType: 'order', resourceId: orderId, correlationId: req.correlationId, ip: clientIp(req), after: { number, approvalRequired, items: lines.map((l) => ({ sku: l.snapshot.sku, quantity: l.it.quantity, discountBp: l.it.discountBp })) } });
|
||||
return { id: orderId, number, status: approvalRequired ? 'pending_approval' : 'provisioning' };
|
||||
});
|
||||
|
||||
app.get('/orders', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
|
||||
const staff = can(a.principal, 'orders.read');
|
||||
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
|
||||
if (orgs && !orgs.length) return [];
|
||||
const where: string[] = []; const params: unknown[] = [];
|
||||
if (orgs) { where.push(`o.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
|
||||
if (q.status) { where.push('o.status = ?'); params.push(q.status); }
|
||||
const rows = await query(`${ORDER_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY o.created_at DESC LIMIT 300`, params);
|
||||
return rows.map((o) => orderView(o));
|
||||
});
|
||||
app.get('/orders/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadOrder(id);
|
||||
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
|
||||
return orderView(r.o, r.items);
|
||||
});
|
||||
|
||||
// ---- Freigabe, Ablehnung, Storno, Wiederholung -------------------------
|
||||
app.post('/admin/orders/:id/approve', async (req) => {
|
||||
const a = requirePermission(req, 'orders.approve');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadOrder(id); if (!r) throw notFound();
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'approve', { sql: 'approved_by = ?, approved_at = ?', params: [a.user.id, new Date()] }); await orderEvent(c, id, 'approved', 'start_provisioning'); await startProvisioning(id, `provision:${id}:1`, req.correlationId); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.approve', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'provisioning' };
|
||||
});
|
||||
app.post('/admin/orders/:id/reject', async (req) => {
|
||||
const a = requirePermission(req, 'orders.approve');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ reason: z.string().trim().max(500).optional() }).parse(req.body ?? {});
|
||||
const r = await loadOrder(id); if (!r) throw notFound();
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'reject', { sql: 'failure_note = ?', params: [b.reason ?? null] }); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.reject', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason } });
|
||||
return { status: 'rejected' };
|
||||
});
|
||||
app.post('/orders/:id/cancel', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadOrder(id);
|
||||
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
|
||||
// Kunden dürfen nur ihre noch nicht freigegebene Bestellung zurückziehen
|
||||
const allowed = can(a.principal, 'orders.write') || (r.o.status === 'pending_approval' && canInOrg(a.principal, r.o.org_id, 'orders.create', 'orders.write'));
|
||||
if (!allowed) throw forbidden();
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'cancel'); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.cancel', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'cancelled' };
|
||||
});
|
||||
/** Fehlgeschlagene Bereitstellung erneut starten. Bei unklarem Ausgang beim Anbieter ist eine ausdrückliche Bestätigung nötig (Doppelanlage!). */
|
||||
app.post('/admin/orders/:id/retry', async (req) => {
|
||||
const a = requirePermission(req, 'orders.approve');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ confirmChecked: z.boolean().default(false) }).parse(req.body ?? {});
|
||||
const r = await loadOrder(id); if (!r) throw notFound();
|
||||
if (r.o.failure_ambiguous && !b.confirmChecked) throw badRequest('Bitte bestätigen, dass beim Anbieter geprüft wurde, dass nichts angelegt wurde', 'CONFIRM_REQUIRED');
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'retry', { sql: 'failure_note = NULL, failure_ambiguous = 0' }); await startProvisioning(id, `provision:${id}:retry:${Date.now()}`, req.correlationId); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.retry', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { ambiguousConfirmed: b.confirmChecked } });
|
||||
return { status: 'provisioning' };
|
||||
});
|
||||
|
||||
// ---- Verträge ------------------------------------------------------------
|
||||
app.get('/contracts', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
|
||||
const staff = can(a.principal, 'contracts.read');
|
||||
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
|
||||
if (orgs && !orgs.length) return [];
|
||||
const where: string[] = []; const params: unknown[] = [];
|
||||
if (orgs) { where.push(`c.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
|
||||
if (q.status) { where.push('c.status = ?'); params.push(q.status); }
|
||||
return (await query(`${CONTRACT_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY c.created_at DESC LIMIT 300`, params)).map(contractView);
|
||||
});
|
||||
app.get('/contracts/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
|
||||
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
|
||||
return { ...contractView(c), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
|
||||
});
|
||||
/** Kündigung: zum nächstmöglichen Termin unter Beachtung von Laufzeit und Frist; sofort nur durch Personal. */
|
||||
app.post('/contracts/:id/cancel', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ immediate: z.boolean().default(false) }).parse(req.body ?? {});
|
||||
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
|
||||
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
|
||||
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
|
||||
if (b.immediate && !can(a.principal, 'contracts.write')) throw forbidden('Sofortige Beendigung nur durch das Personal', 'STAFF_ONLY');
|
||||
if (!['active', 'suspended'].includes(c.status)) throw badRequest('Dieser Vertrag kann nicht gekündigt werden', 'NOT_CANCELLABLE');
|
||||
if (c.cancel_requested_at) throw conflict('Die Kündigung wurde bereits eingereicht', 'ALREADY_CANCELLED');
|
||||
transition(CONTRACT_MACHINE, c.status, 'cancel'); // Prüfung, dass die Kündigung im Zustand erlaubt ist
|
||||
const now = new Date();
|
||||
const effective = b.immediate ? now : effectiveCancelDate(now, { termEnd: c.term_end ? new Date(c.term_end) : null, renewal: c.renewal, renewalTermMonths: Number(c.renewal_term_months), noticeDays: Number(c.notice_days) });
|
||||
await run('UPDATE contracts SET cancel_requested_at = ?, cancel_effective_at = ? WHERE id = ? AND cancel_requested_at IS NULL', [now, effective, id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.request', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { effectiveAt: effective.toISOString(), immediate: b.immediate } });
|
||||
return { cancelEffectiveAt: effective.toISOString() };
|
||||
});
|
||||
app.post('/contracts/:id/cancel/revoke', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
|
||||
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
|
||||
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
|
||||
if (!c.cancel_requested_at || new Date(c.cancel_effective_at) <= new Date()) throw badRequest('Keine widerrufbare Kündigung vorhanden', 'NOT_REVOCABLE');
|
||||
await run("UPDATE contracts SET cancel_requested_at = NULL, cancel_effective_at = NULL WHERE id = ? AND status IN ('active','suspended')", [id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue