From 0ab3cbbaa3bb34695e33f4b7d372e0b99f12f6da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:04:01 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge:=20Appwrite-Schema=20der=20Collec?= =?UTF-8?q?tions=20auftraege=20und=20projektRechnungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/auftraege/schema.js | 60 +++++++++++++++++++++++++ test/auftraegeSchema.test.js | 70 +++++++++++++++++++++++++++++ test/support/schema.js | 13 ++++-- 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 server/services/auftraege/schema.js create mode 100644 test/auftraegeSchema.test.js diff --git a/server/services/auftraege/schema.js b/server/services/auftraege/schema.js new file mode 100644 index 0000000..912a428 --- /dev/null +++ b/server/services/auftraege/schema.js @@ -0,0 +1,60 @@ +/** + * Aufträge und Rechnungen: Appwrite-Schema der Collections auftraege und projektRechnungen sowie der Zusatzfelder + * (Übersicht V1). Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor + * useTestEnv(), ein früh geladenes config.js würde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs + * stehen in config.collections.; scripts/auftraege-setup.mjs legt alles an. + * + * Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false } + * Index: { key, type: 'key', attributes: [...] } + * Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurück, daher immer mit new Date() lesen. + */ + +const str = (key, size) => ({ key, type: 'string', size, required: false }) +const int = (key) => ({ key, type: 'integer', required: false }) +const bool = (key) => ({ key, type: 'boolean', required: false }) +const dt = (key) => ({ key, type: 'datetime', required: false }) +const index = (key, ...attributes) => ({ key, type: 'key', attributes }) + +export const AUFTRAEGE_COLLECTIONS = { + auftraege: { + attributes: [ + str('nummer', 20), int('jahr'), int('laufnummer'), str('status', 20), + str('kundenId', 64), str('kundeJson', 2000), str('projektId', 64), str('titel', 200), + str('zeitquelleJson', 1000), str('art', 20), str('positionenJson', 20000), + int('stundensatzCents'), int('schaetzungMinuten'), int('summeCents'), + str('zahlungsplanJson', 1000), int('zahlungszielTage'), + str('auftragVom', 10), str('auftragWeg', 20), str('startAm', 10), str('fertigBis', 10), + str('texteFassung', 20), str('agbFassung', 20), str('pdfDatei', 300), str('pdfSha256', 64), + dt('gesendetAm'), str('gesendetAn', 255), str('mailStatus', 20), str('mailFehler', 500), + str('tokenHash', 64), dt('tokenGueltigBis'), + dt('bestaetigtAm'), str('bestaetigtUeber', 10), str('bestaetigtPdfSha256', 64), + bool('fruehbeginn'), str('fruehbeginnFassung', 20), + str('ersetztId', 64), str('ersetztDurchId', 64), dt('storniertAm'), dt('widerrufenAm'), + str('erstelltVon', 255), dt('erstelltAm'), dt('geaendertAm'), + ], + // idx_nummer bewusst kein Unique-Index: Entwürfe haben nummer ''. Die Eindeutigkeit stellt vergibNummer unter + // SPERRE_NUMMER her (L3). + indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_jahr', 'jahr'), + index('idx_token', 'tokenHash'), index('idx_nummer', 'nummer')], + }, + // Dokument-ID = Stripe-Rechnungs-ID (muss /^in_\w{1,33}$/ erfüllen; der Stripe-Nachbau vergibt in_test_) + projektRechnungen: { + attributes: [ + str('invoiceId', 64), str('kundenId', 64), str('projektId', 64), str('auftragId', 64), str('art', 20), + int('betragCents'), str('arbeitsblattIdsJson', 20000), str('rechnungsnummer', 64), bool('aktiv'), + str('erstelltVon', 255), dt('erstelltAm'), + ], + indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_auftrag', 'auftragId')], + }, +} + +// Zusätzliche Felder bestehender Collections (Schlüssel = Name in config.collections) +export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = { + customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat' + financeSettings: [str('steuernummer', 40)], +} + +// Datumsfelder je Collection, für startAppwriteMock({ datetimes }) (Appwrite liefert sie mit "+00:00") +export const AUFTRAEGE_DATETIMES = Object.fromEntries( + Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)]), +) diff --git a/test/auftraegeSchema.test.js b/test/auftraegeSchema.test.js new file mode 100644 index 0000000..a8b6524 --- /dev/null +++ b/test/auftraegeSchema.test.js @@ -0,0 +1,70 @@ +// test/auftraegeSchema.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_DATETIMES, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js' +import { schemaKeys } from './support/schema.js' + +// Spalten aus Übersicht V1 in Kurzschrift: sN = Text mit Größe N, i = ganze Zahl, b = Wahrheitswert, d = Datum und Zeit +const SPALTEN = { + auftraege: `nummer:s20 jahr:i laufnummer:i status:s20 kundenId:s64 kundeJson:s2000 projektId:s64 titel:s200 + zeitquelleJson:s1000 art:s20 positionenJson:s20000 stundensatzCents:i schaetzungMinuten:i summeCents:i + zahlungsplanJson:s1000 zahlungszielTage:i auftragVom:s10 auftragWeg:s20 startAm:s10 fertigBis:s10 + texteFassung:s20 agbFassung:s20 pdfDatei:s300 pdfSha256:s64 gesendetAm:d gesendetAn:s255 mailStatus:s20 + mailFehler:s500 tokenHash:s64 tokenGueltigBis:d bestaetigtAm:d bestaetigtUeber:s10 bestaetigtPdfSha256:s64 + fruehbeginn:b fruehbeginnFassung:s20 ersetztId:s64 ersetztDurchId:s64 storniertAm:d widerrufenAm:d + erstelltVon:s255 erstelltAm:d geaendertAm:d`, + projektRechnungen: `invoiceId:s64 kundenId:s64 projektId:s64 auftragId:s64 art:s20 betragCents:i + arbeitsblattIdsJson:s20000 rechnungsnummer:s64 aktiv:b erstelltVon:s255 erstelltAm:d`, +} +function attribut(kurz) { + const [key, art] = kurz.split(':') + if (art === 'i') return { key, type: 'integer', required: false } + if (art === 'b') return { key, type: 'boolean', required: false } + if (art === 'd') return { key, type: 'datetime', required: false } + return { key, type: 'string', size: Number(art.slice(1)), required: false } +} +const erwartet = (name) => SPALTEN[name].trim().split(/\s+/).map(attribut) + +test('zwei Collections mit den Spalten aus Übersicht V1', () => { + assert.deepEqual(Object.keys(AUFTRAEGE_COLLECTIONS), ['auftraege', 'projektRechnungen']) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.attributes, erwartet('auftraege')) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes, erwartet('projektRechnungen')) + assert.equal(AUFTRAEGE_COLLECTIONS.auftraege.attributes.length, 42) + assert.equal(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.length, 11) +}) + +test('Indizes und Zusatzfelder wie in Übersicht V1', () => { + assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.indexes, [ + { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] }, + { key: 'idx_projekt', type: 'key', attributes: ['projektId'] }, + { key: 'idx_jahr', type: 'key', attributes: ['jahr'] }, + { key: 'idx_token', type: 'key', attributes: ['tokenHash'] }, + { key: 'idx_nummer', type: 'key', attributes: ['nummer'] }, + ]) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.indexes, [ + { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] }, + { key: 'idx_projekt', type: 'key', attributes: ['projektId'] }, + { key: 'idx_auftrag', type: 'key', attributes: ['auftragId'] }, + ]) + assert.deepEqual(AUFTRAEGE_ZUSATZ_ATTRIBUTE, { + customers: [{ key: 'kundenart', type: 'string', size: 20, required: false }], + financeSettings: [{ key: 'steuernummer', type: 'string', size: 40, required: false }], + }) +}) + +test('AUFTRAEGE_DATETIMES nennt je Collection genau die Datumsfelder', () => { + assert.deepEqual(AUFTRAEGE_DATETIMES, { + auftraege: ['gesendetAm', 'tokenGueltigBis', 'bestaetigtAm', 'storniertAm', 'widerrufenAm', 'erstelltAm', 'geaendertAm'], + projektRechnungen: ['erstelltAm'], + }) +}) + +test('schemaKeys kennt die neuen Collections und die Steuernummer; Bestehendes bleibt', () => { + assert.deepEqual(schemaKeys('auftraege'), AUFTRAEGE_COLLECTIONS.auftraege.attributes.map((a) => a.key)) + assert.deepEqual(schemaKeys('projektRechnungen'), AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.map((a) => a.key)) + const einstellungen = schemaKeys('financeSettings') + for (const key of ['steuernummer', 'bankDetailsText', 'taxReservePercent']) assert.ok(einstellungen.includes(key), key) + assert.ok(!schemaKeys('financeFixedCosts').includes('steuernummer')) + assert.ok(schemaKeys('portalFeatures').includes('unlockedBy') && schemaKeys('termineConfig').includes('einstellung')) + assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/) +}) diff --git a/test/support/schema.js b/test/support/schema.js index 7c9c06b..58c743a 100644 --- a/test/support/schema.js +++ b/test/support/schema.js @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs' import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js' import { SHOP_COLLECTIONS } from '../../server/services/shop/schema.js' import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../../server/services/zeiten/schema.js' +import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../../server/services/auftraege/schema.js' import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs' // Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],) @@ -16,9 +17,12 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs'] * die Zeiten-Collections aus server/services/zeiten/schema.js (scripts/zeiten-setup.mjs); * portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES) und die * Zeiten-Felder aus server/services/zeiten/schema.js (ZEITEN_FEATURE_ATTRIBUTES, angelegt von scripts/zeiten-setup.mjs); - * featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES). + * featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES); + * die Aufträge-Collections kommen aus server/services/auftraege/schema.js (scripts/auftraege-setup.mjs), und ihre + * Zusatzfelder bestehender Collections (AUFTRAEGE_ZUSATZ_ATTRIBUTE, z. B. financeSettings.steuernummer) werden angehängt. */ export function schemaKeys(collection) { + if (Object.hasOwn(AUFTRAEGE_COLLECTIONS, collection)) return AUFTRAEGE_COLLECTIONS[collection].attributes.map((a) => a.key) if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key) if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key) if (Object.hasOwn(ZEITEN_COLLECTIONS, collection)) return ZEITEN_COLLECTIONS[collection].attributes.map((a) => a.key) @@ -29,8 +33,11 @@ export function schemaKeys(collection) { if (start < 0) continue const block = src.slice(start, src.indexOf('\n ],', start)) const keys = [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) - return collection === 'portalFeatures' - ? [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), ...ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)] + if (collection === 'portalFeatures') { + return [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), ...ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)] + } + return Object.hasOwn(AUFTRAEGE_ZUSATZ_ATTRIBUTE, collection) + ? [...keys, ...AUFTRAEGE_ZUSATZ_ATTRIBUTE[collection].map((a) => a.key)] : keys } throw new Error(`Collection ${collection} fehlt im Setup-Skript`)