From c5b1333953f5430dd1f1d11599d1f38b80f8d0eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:47:04 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20P5:=20Setup-Skript=20f=C3=BCr?= =?UTF-8?q?=20Kundenart=20und=20Einrichtungs-Marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/kundenansicht-setup.mjs | 88 +++++++++++++++++++++++++++++++++ test/featureSetup.test.js | 4 +- test/kundenansichtSetup.test.js | 75 ++++++++++++++++++++++++++++ test/support/schema.js | 8 ++- 4 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 scripts/kundenansicht-setup.mjs create mode 100644 test/kundenansichtSetup.test.js diff --git a/scripts/kundenansicht-setup.mjs b/scripts/kundenansicht-setup.mjs new file mode 100644 index 0000000..b245b4b --- /dev/null +++ b/scripts/kundenansicht-setup.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Kundenansicht neu (Paket P5, Abo-Modell Kern): legt die neuen Attribute an. Der Code schreibt sie nur, wenn ein + * Aufrufer die zugehoerigen Parameter uebergibt; ohne dieses Skript bleibt die alte Ansicht unberuehrt. + * - featureConsents.kundenart: Kundenart zum Klickzeitpunkt im Nachweis ('privat' | 'unternehmen') + * - workorders.einrichtungFuer: Schluessel der Funktion, die das Einrichtungs-Ticket betrifft (laengster: 'unterkunft') + * - workorders.einrichtungAbrechnung: 'abo' | 'projekt' | '' (Abrechnung der Einrichtung am Ticket) + * - portalFeatures.einrichtungAbrechnung: dieselbe Abrechnung am Funktionsdokument (Nachlegen und Heilen setzen + * den Marker am Ticket erneut) + * - portalFeatures.unlockedBy: 'kauf' | 'abo' | 'team' | 'projekt' (steht schon im Zeiten-Schema; fehlt es, legt + * dieses Skript es an) + * Idempotent: Vorhandenes wird uebersprungen. Das Skript loescht nichts und laeuft in Entwicklung und Tests nie + * gegen Produktion; dort nur auf Anweisung des Controllers nach OK des Inhabers, vorher immer mit --pruefen. + * scripts/setup-abo-collections.mjs, feature-setup.mjs und zeiten-setup.mjs bleiben unveraendert. + * Usage: + * node scripts/kundenansicht-setup.mjs --pruefen (Alias --check; nur melden, nichts anlegen; Exit 1 bei Luecken) + * node scripts/kundenansicht-setup.mjs (Fehlendes anlegen) + * + * config.js und der Appwrite-Client werden erst in main() geladen: test/support/schema.js importiert die + * Attributlisten, bevor die Tests ihre Umgebung (useTestEnv) setzen koennen. + */ +import { pathToFileURL } from 'node:url' +import { ZEITEN_FEATURE_ATTRIBUTES } from '../server/services/zeiten/schema.js' + +const str = (key, size) => ({ key, type: 'string', size, required: false }) +const unlockedBy = ZEITEN_FEATURE_ATTRIBUTES.find((a) => a.key === 'unlockedBy') + +export const KUNDENANSICHT_ATTRIBUTE = { + featureConsents: [str('kundenart', 12)], + workorders: [str('einrichtungFuer', 16), str('einrichtungAbrechnung', 8)], + portalFeatures: [unlockedBy, str('einrichtungAbrechnung', 8)], +} + +export async function main({ pruefen = false, client, log = console.log } = {}) { + const { config } = await import('../server/config.js') + let api = client + if (!api) { + const { createAppwriteClient } = await import('./analyse-setup.mjs') + api = createAppwriteClient() + } + const missing = [] + const plan = [] + + // Erst alles pruefen, dann (ohne --pruefen) anlegen, damit --pruefen garantiert nichts veraendert + for (const [name, attributes] of Object.entries(KUNDENANSICHT_ATTRIBUTE)) { + const id = config.collections[name] + const exists = await api.collectionExists(id) + if (!exists && !pruefen) throw new Error(`Collection ${id} fehlt – zuerst das jeweilige Setup-Skript ausfuehren`) + if (!exists) log(`- ${id} FEHLT`) + const have = exists ? new Set((await api.listAttributes(id)).map((a) => a.key)) : new Set() + for (const attr of attributes) { + if (have.has(attr.key)) log(`= ${id}.${attr.key} (existiert)`) + else if (pruefen) log(`- ${id}.${attr.key} FEHLT`) + } + const absent = attributes.filter((a) => !have.has(a.key)) + absent.forEach((a) => missing.push({ ...a, collection: id })) + plan.push({ id, absent }) + } + + const result = { missing } + if (pruefen) { + log(missing.length ? `${missing.length} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.') + return result + } + + for (const { id, absent } of plan) { + for (const attr of absent) { + await api.createAttribute(id, attr) + log(`+ Attribut ${id}.${attr.key}`) + } + } + for (const { id, absent } of plan) { + for (const attr of absent) await api.waitForAttribute(id, attr.key) + } + log('Fertig.') + return result +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const pruefen = process.argv.includes('--pruefen') || process.argv.includes('--check') + main({ pruefen }) + .then(({ missing }) => process.exit(pruefen && missing.length ? 1 : 0)) + .catch((e) => { + console.error(e.message) + process.exit(1) + }) +} diff --git a/test/featureSetup.test.js b/test/featureSetup.test.js index cf3f931..23846e9 100644 --- a/test/featureSetup.test.js +++ b/test/featureSetup.test.js @@ -188,8 +188,8 @@ test('Schema featureConsents: drei Indizes mit den Attributen aus der Vorgabe (i }) test('schemaKeys(featureConsents) liefert dieselben Schluessel, der Nachbau nimmt sie an und lehnt andere ab', async () => { - assert.deepEqual(schemaKeys('featureConsents'), CONSENT_KEYS) - assert.deepEqual(schemaKeys('featureConsents'), FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)) + assert.deepEqual(schemaKeys('featureConsents'), [...CONSENT_KEYS, 'kundenart']) + assert.deepEqual(schemaKeys('featureConsents'), [...FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key), 'kundenart']) schema.featureConsents = schemaKeys('featureConsents') const { createDocument } = await import('../server/services/appwriteAdmin.js') const doc = await createDocument(config.collections.featureConsents, { diff --git a/test/kundenansichtSetup.test.js b/test/kundenansichtSetup.test.js new file mode 100644 index 0000000..a336b75 --- /dev/null +++ b/test/kundenansichtSetup.test.js @@ -0,0 +1,75 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { KUNDENANSICHT_ATTRIBUTE, main } from '../scripts/kundenansicht-setup.mjs' + +// Setup-Skript der Kundenansicht neu (Paket P5): Kundenart im Nachweis, Einrichtungs-Marker an Ticket und Funktion. +// Das Skript laeuft hier nur gegen Mock-Clients, nie gegen Produktion. +const aw = await startAppwriteMock({ schema: {} }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') + +const client = (vorhanden = {}) => { + const calls = { attrs: [], waits: [] } + return { + calls, + collectionExists: async () => true, + listAttributes: async (id) => (vorhanden[id] ?? []).map((key) => ({ key })), + createAttribute: async (id, a) => calls.attrs.push(`${id}.${a.key}`), + waitForAttribute: async (id, k) => calls.waits.push(`${id}.${k}`), + } +} +const ID = () => config.collections +const ALLE = () => [ + `${ID().featureConsents}.kundenart`, + `${ID().portalFeatures}.einrichtungAbrechnung`, + `${ID().portalFeatures}.unlockedBy`, + `${ID().workorders}.einrichtungAbrechnung`, + `${ID().workorders}.einrichtungFuer`, +] + +test('Schema: Schlüssel, Größen, nur optionale Strings; längste Werte passen hinein', () => { + const kurz = Object.fromEntries(Object.entries(KUNDENANSICHT_ATTRIBUTE).map(([c, l]) => [c, l.map((a) => `${a.key}:${a.size}`)])) + assert.deepEqual(kurz, { + featureConsents: ['kundenart:12'], + workorders: ['einrichtungFuer:16', 'einrichtungAbrechnung:8'], + portalFeatures: ['unlockedBy:8', 'einrichtungAbrechnung:8'], + }) + assert.ok(Object.values(KUNDENANSICHT_ATTRIBUTE).flat().every((a) => a.type === 'string' && a.required === false)) + assert.ok('unternehmen'.length <= 12 && 'projekt'.length <= 8 && 'unterkunft'.length <= 16) +}) + +test('--pruefen meldet alle fünf Lücken und legt nichts an', async () => { + const c = client() + const log = [] + const r = await main({ pruefen: true, client: c, log: (z) => log.push(z) }) + assert.deepEqual(r.missing.map((a) => `${a.collection}.${a.key}`).sort(), ALLE()) + assert.deepEqual(c.calls, { attrs: [], waits: [] }) + assert.ok(log.some((z) => z === `- ${ID().featureConsents}.kundenart FEHLT`)) +}) + +test('ohne --pruefen: legt an und wartet; der zweite Lauf ist idempotent', async () => { + const c = client() + await main({ client: c, log: () => {} }) + assert.deepEqual([...c.calls.attrs].sort(), ALLE()) + assert.deepEqual([...c.calls.waits].sort(), ALLE()) + const voll = client({ + [ID().featureConsents]: ['kundenart'], + [ID().workorders]: ['einrichtungFuer', 'einrichtungAbrechnung'], + [ID().portalFeatures]: ['unlockedBy', 'einrichtungAbrechnung'], + }) + assert.deepEqual((await main({ client: voll, log: () => {} })).missing, []) + assert.deepEqual(voll.calls.attrs, []) +}) + +test('schemaKeys liest das Skript; das Skript löscht nichts', () => { + assert.equal(schemaKeys('featureConsents').at(-1), 'kundenart') + const f = schemaKeys('portalFeatures') + assert.ok(f.includes('einrichtungAbrechnung')) + assert.equal(new Set(f).size, f.length) + assert.doesNotMatch(readFileSync(new URL('../scripts/kundenansicht-setup.mjs', import.meta.url), 'utf8'), /delete/i) +}) diff --git a/test/support/schema.js b/test/support/schema.js index a0be61c..c267ff1 100644 --- a/test/support/schema.js +++ b/test/support/schema.js @@ -5,6 +5,7 @@ import { UNTERKUNFT_COLLECTIONS } from '../../server/services/unterkunft/schema. 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' +import { KUNDENANSICHT_ATTRIBUTE } from '../../scripts/kundenansicht-setup.mjs' // Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],) const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs'] @@ -20,6 +21,8 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.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); + * scripts/kundenansicht-setup.mjs (KUNDENANSICHT_ATTRIBUTE) ergaenzt featureConsents um kundenart und portalFeatures um + * unlockedBy und einrichtungAbrechnung; * 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. */ @@ -29,7 +32,7 @@ export function schemaKeys(collection) { if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key) if (Object.hasOwn(UNTERKUNFT_COLLECTIONS, collection)) return UNTERKUNFT_COLLECTIONS[collection].attributes.map((a) => a.key) if (Object.hasOwn(ZEITEN_COLLECTIONS, collection)) return ZEITEN_COLLECTIONS[collection].attributes.map((a) => a.key) - if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key) + if (collection === 'featureConsents') return [...FEATURE_CONSENT_ATTRIBUTES, ...KUNDENANSICHT_ATTRIBUTE.featureConsents].map((a) => a.key) for (const script of SETUP_SCRIPTS) { const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8') const start = src.indexOf(`'${collection}',`) @@ -37,7 +40,8 @@ export function schemaKeys(collection) { const block = src.slice(start, src.indexOf('\n ],', start)) const keys = [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) if (collection === 'portalFeatures') { - return [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), ...ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)] + const zusatz = [...PORTAL_FEATURE_ATTRIBUTES, ...ZEITEN_FEATURE_ATTRIBUTES, ...KUNDENANSICHT_ATTRIBUTE.portalFeatures] + return [...new Set([...keys, ...zusatz.map((a) => a.key)])] } return Object.hasOwn(AUFTRAEGE_ZUSATZ_ATTRIBUTE, collection) ? [...keys, ...AUFTRAEGE_ZUSATZ_ATTRIBUTE[collection].map((a) => a.key)]