Rechnungen: Positionen direkt aus Produkt oder Vertrag übernehmen

This commit is contained in:
Kundencenter 2026-09-27 16:51:19 +02:00
parent a1472617b9
commit 6f9efb91dc
3 changed files with 61 additions and 3 deletions

View file

@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise'; import type { PoolConnection } from 'mysql2/promise';
import { calculatePrice } from '@kc/platform/pricing';
import { one, query, run, tx } from '../../core/db.js'; import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js'; import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js'; import { enqueue } from '../../core/jobs.js';
@ -136,11 +137,24 @@ export const invoicesModule: KcModule = {
}); });
/** Aus einem Vertrag die letzte Preisangabe als Positionsvorschlag übernehmen (nichts wird automatisch gespeichert). */ /** Aus einem Vertrag die letzte Preisangabe als Positionsvorschlag übernehmen (nichts wird automatisch gespeichert). */
/** Vorschlag für eine Rechnungsposition aus dem eingefrorenen Preis-Snapshot eines Vertrags (die laufende Periode). */
app.get('/invoices/suggest-from-contract/:contractId', async (req) => { app.get('/invoices/suggest-from-contract/:contractId', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { contractId } = z.object({ contractId: z.string().uuid() }).parse(req.params); const a = requirePermission(req, 'invoices.write'); const { contractId } = z.object({ contractId: z.string().uuid() }).parse(req.params);
const c = await one('SELECT * FROM contracts WHERE id = ?', [contractId]); if (!c) throw notFound(); const c = await one('SELECT * FROM contracts WHERE id = ?', [contractId]); if (!c) throw notFound();
const snap = JSON.parse(c.price_snapshot_json); const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json;
return { orgId: c.org_id, description: snap.name, quantity: 1, unitPriceNetCents: snap.recurring?.net ?? 0, taxBp: snap.taxBp ?? 1900, contractId }; return { orgId: c.org_id, items: [{ description: snap.name, quantity: 1, unitPriceNetCents: snap.recurring?.net ?? 0, taxBp: snap.taxBp ?? 1900, contractId }] };
});
/** Vorschlag aus dem aktuellen Katalogpreis eines Produkts (Einrichtung und/oder wiederkehrender Preis als eigene Positionen). */
app.get('/invoices/suggest-from-product/:productId', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { productId } = z.object({ productId: z.string().uuid() }).parse(req.params);
const p = await one('SELECT p.*, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
if (!p) throw notFound();
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
const items = [];
if (price.setup.net > 0) items.push({ description: `Einrichtung: ${p.name}`, quantity: 1, unitPriceNetCents: price.setup.net, taxBp: p.tax_bp });
if (price.recurring.net > 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
if (items.length === 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
return { items };
}); });
app.post('/invoices/:id/issue', async (req) => { app.post('/invoices/:id/issue', async (req) => {

View file

@ -67,3 +67,28 @@ describe('Rechnungen', () => {
const staffOpenB = (await call(app, acc, 'GET', `/invoices?org=${B.org}`)).json(); expect(staffOpenB).toEqual([]); const staffOpenB = (await call(app, acc, 'GET', `/invoices?org=${B.org}`)).json(); expect(staffOpenB).toEqual([]);
}); });
}); });
describe('Rechnungspositionen aus Produkt/Vertrag übernehmen', () => {
it('Vorschlag aus einem Produkt (Einrichtung + wiederkehrend als eigene Positionen) und aus einem Vertrag (laufende Periode)', async () => {
const admin = await staff('sugg-admin@example.com', 'superadmin'); const acc = await staff('sugg-acc@example.com', 'accounting');
const A = await customer(admin, 'Kunde A', 'sugg-a@example.com');
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const prod = await call(app, admin, 'POST', '/admin/products', {
sku: 'HOST-M', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
version: { name: 'Hosting Paket M', taxRateId: t19, setupCents: 1000, recurringCents: 2500, billingInterval: 'monthly', termMonths: 12, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} },
});
const suggProd = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}`)).json();
expect(suggProd.items).toEqual([{ description: 'Einrichtung: Hosting Paket M', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }, { description: 'Hosting Paket M', quantity: 1, unitPriceNetCents: 2500, taxBp: 1900 }]);
const order = (await call(app, admin, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id }] })).json();
const contractId = (await call(app, admin, 'GET', `/orders/${order.id}`)).json().items[0].contractId;
const suggContract = (await call(app, acc, 'GET', `/invoices/suggest-from-contract/${contractId}`)).json();
expect(suggContract.orgId).toBe(A.org); expect(suggContract.items).toEqual([{ description: 'Hosting Paket M', quantity: 1, unitPriceNetCents: 2500, taxBp: 1900, contractId }]);
// Die Vorschläge lassen sich als Positionen übernehmen
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: suggProd.items })).statusCode).toBe(200);
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(3500);
});
});

View file

@ -10,15 +10,30 @@ interface Item { id: string; description: string; quantity: number; unitPriceNet
interface Inv { id: string; number: string | null; orgId: string; orgName: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; cancelsInvoiceId: string | null; cancelledByInvoiceId: string | null; items: Item[]; canWrite: boolean } interface Inv { id: string; number: string | null; orgId: string; orgName: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; cancelsInvoiceId: string | null; cancelledByInvoiceId: string | null; items: Item[]; canWrite: boolean }
type Row = { description: string; quantity: string; unitPrice: string; taxBp: string }; type Row = { description: string; quantity: string; unitPrice: string; taxBp: string };
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' }); const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number }
const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) });
interface Prod { id: string; status: string; current: { name: string } | null }
interface Contract { id: string; number: string; productName: string | null }
export default function RechnungDetail() { export default function RechnungDetail() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read'); const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read');
const [inv, setInv] = useState<Inv | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [inv, setInv] = useState<Inv | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
const [rows, setRows] = useState<Row[]>([emptyRow()]); const [busy, setBusy] = useState(false); const [rows, setRows] = useState<Row[]>([emptyRow()]); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<Inv>('GET', `/invoices/${id}`).then((v) => { setInv(v); if (v.status === 'draft') setRows(v.items.length ? v.items.map((it) => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) })) : [emptyRow()]); }).catch((e) => setErr(errMsg(e))), [id]); const [products, setProducts] = useState<Prod[]>([]); const [contracts, setContracts] = useState<Contract[]>([]);
const load = useCallback(() => api<Inv>('GET', `/invoices/${id}`).then((v) => { setInv(v); if (v.status === 'draft') setRows(v.items.length ? v.items.map((it) => toRow(it)) : [emptyRow()]); }).catch((e) => setErr(errMsg(e))), [id]);
useEffect(() => { void load(); }, [load]); useEffect(() => { void load(); }, [load]);
useEffect(() => {
if (!inv || inv.status !== 'draft' || !inv.canWrite) return;
api<Prod[]>('GET', '/admin/products').then((l) => setProducts(l.filter((p) => p.status === 'active' && p.current))).catch(() => undefined);
api<Contract[]>('GET', `/contracts?org=${inv.orgId}&status=active`).then(setContracts).catch(() => undefined);
}, [inv]);
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } }; const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
function appendSuggested(items: Suggested[]) { setRows((rs) => [...rs.filter((r) => r.description.trim() || r.unitPrice.trim()), ...items.map(toRow)]); }
async function takeFrom(kind: 'product' | 'contract', pickId: string) {
if (!pickId) return; setErr('');
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
}
async function saveItems(e: FormEvent<HTMLFormElement>) { async function saveItems(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) })); const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) }));
@ -37,6 +52,10 @@ export default function RechnungDetail() {
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>} {err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2> {isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
{(products.length > 0 || contracts.length > 0) && <div className="cols" style={{ marginBottom: 16 }}>
{products.length > 0 && <Field id="fromProduct" label="Position aus Produkt übernehmen" hint="Fügt Einrichtung und/oder wiederkehrenden Preis als eigene Zeilen hinzu."><select id="fromProduct" value="" onChange={(e) => void takeFrom('product', e.target.value)}><option value="">Produkt wählen …</option>{products.map((p) => <option key={p.id} value={p.id}>{p.current!.name}</option>)}</select></Field>}
{contracts.length > 0 && <Field id="fromContract" label="Position aus laufendem Vertrag übernehmen" hint="Übernimmt den wiederkehrenden Preis der aktuellen Periode."><select id="fromContract" value="" onChange={(e) => void takeFrom('contract', e.target.value)}><option value="">Vertrag wählen …</option>{contracts.map((c) => <option key={c.id} value={c.id}>{c.number} · {c.productName ?? '–'}</option>)}</select></Field>}
</div>}
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr auto', alignItems: 'end' }}> {rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr auto', alignItems: 'end' }}>
<Field id={`d${i}`} label="Beschreibung"><input id={`d${i}`} value={r.description} onChange={(e) => setRow(i, 'description', e.target.value)} /></Field> <Field id={`d${i}`} label="Beschreibung"><input id={`d${i}`} value={r.description} onChange={(e) => setRow(i, 'description', e.target.value)} /></Field>
<Field id={`q${i}`} label="Menge"><input id={`q${i}`} value={r.quantity} onChange={(e) => setRow(i, 'quantity', e.target.value)} /></Field> <Field id={`q${i}`} label="Menge"><input id={`q${i}`} value={r.quantity} onChange={(e) => setRow(i, 'quantity', e.target.value)} /></Field>