From 4a75d7aba09e86f6f9dfbb9d9d073c94369f3f8a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:50:21 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P10:=20Skript=20setzt=20d?= =?UTF-8?q?ie=20Kundenart=20der=20Bestandseintr=C3=A4ge=20auf=20Unternehme?= =?UTF-8?q?n=20(E18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/kundenart-bestand.mjs | 123 ++++++++++++++++++++++++++++++++++ test/kundenartBestand.test.js | 61 +++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 scripts/kundenart-bestand.mjs create mode 100644 test/kundenartBestand.test.js diff --git a/scripts/kundenart-bestand.mjs b/scripts/kundenart-bestand.mjs new file mode 100644 index 0000000..4d1a029 --- /dev/null +++ b/scripts/kundenart-bestand.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Einmalig (Kundenansicht neu, P10 Umschalten, E18): die Kundenart der Bestandseintraege. Kunden, Leads und verlorene Leads, die vor + * der neuen Ansicht angelegt wurden, haben kein Feld customers.kundenart; leer gilt ueberall als privat (kundenartWirksam) und + * sperrte ihnen die Funktionen fuer Betriebe. Alle bisherigen Eintraege sind Betriebe: leere Felder werden "unternehmen". + * + * Geschrieben wird nur dort, wo das Feld leer ist (fehlt, "" oder nur Leerzeichen). Ein gueltiger Wert ("unternehmen", "privat") und + * jeder unbekannte Wert bleiben stehen. Geschrieben wird erst mit der Bestaetigungszeile "Kundenart unternehmen setzen: ", + * die genau zur Zahl der leeren Eintraege passt (der Probelauf nennt sie). Ausgaben nennen nur IDs und Zahlen, nie Namen oder Adressen. + * + * node scripts/kundenart-bestand.mjs --pruefen (zaehlt und listet, schreibt nichts) + * node scripts/kundenart-bestand.mjs --ausfuehren --bestaetigung="Kundenart unternehmen setzen: " + * Im Container als Datei: docker exec -w /app node scripts/kundenart-bestand.mjs --pruefen + * Die Protokolldatei entsteht per tee ausserhalb des Containers. Exit 0 bei Erfolg, 1 bei Schreibfehlern (Skript erneut ausfuehren), + * 2 bei falschem Aufruf oder falscher Bestaetigung. + * NICHT gegen Produktion ausfuehren, ohne dass der Probelauf geprueft und die Freigabe da ist. + */ +import 'dotenv/config' +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { getDocument, listAllDocuments, updateDocument } from '../server/services/appwriteAdmin.js' +import { KUNDENARTEN } from '../server/services/kundenart.js' + +const NEUE_ART = 'unternehmen' +const AUFRUF = 'Aufruf: node scripts/kundenart-bestand.mjs --pruefen | --ausfuehren --bestaetigung="Kundenart unternehmen setzen: "' + +/** Falscher Aufruf oder falsche Bestaetigung (Exit 2) */ +class AufrufFehler extends Error {} + +/** Aufrufargumente: genau --pruefen, oder --ausfuehren zusammen mit --bestaetigung=; alles andere wirft. */ +export function parseArgs(argv = []) { + if (argv.length === 1 && argv[0] === '--pruefen') return { modus: 'pruefen' } + if (argv.length === 2 && argv.includes('--ausfuehren')) { + const bestaetigung = argv.find((a) => a.startsWith('--bestaetigung='))?.slice('--bestaetigung='.length) + if (bestaetigung) return { modus: 'ausfuehren', bestaetigung } + } + throw new AufrufFehler(AUFRUF) +} + +export const bestaetigungsZeile = (anzahl) => `Kundenart unternehmen setzen: ${anzahl}` + +/** Leer = fehlt, "" oder nur Leerzeichen. Nur dann wird geschrieben. */ +const istLeer = (doc) => String(doc?.kundenart ?? '').trim() === '' + +/** Der alte Wert fuer das Protokoll: nie der Rohwert, nur fehlt oder leer (Leerzeichen zaehlen als leer). */ +const vorherText = (doc) => (doc?.kundenart === undefined || doc?.kundenart === null ? '(fehlt)' : '(leer)') + +/** + * Liest alle Kundeneintraege und sortiert sie ein. Schreibt nichts, nennt nur IDs und Zahlen. + * @returns {Promise<{ gesamt: number, mitKundenart: { unternehmen: number, privat: number }, ohne: string[], + * ohneNachStatus: Record, unbekannt: string[] }>} + */ +export async function pruefen({ log = console.log } = {}) { + const docs = await listAllDocuments(config.collections.customers) + const mitKundenart = Object.fromEntries(KUNDENARTEN.map((art) => [art, 0])) + const ohne = [] + const ohneNachStatus = {} + const unbekannt = [] + for (const doc of docs) { + if (istLeer(doc)) { + ohne.push(doc.$id) + const status = String(doc.customerStatus ?? '').trim() || '(ohne Status)' + ;(ohneNachStatus[status] ??= []).push(doc.$id) + } else if (KUNDENARTEN.includes(doc.kundenart)) mitKundenart[doc.kundenart] += 1 + else unbekannt.push(doc.$id) + } + log(`Einträge gesamt: ${docs.length}`) + log(`Schon gesetzt: ${KUNDENARTEN.map((art) => `${art}=${mitKundenart[art]}`).join(' ')}`) + log(`Ohne Kundenart: ${ohne.length}`) + log(`Unbekannte Werte: ${unbekannt.length}`) + if (unbekannt.length) log(`Unbekannte Kundenart bei: ${unbekannt.join(', ')}`) + for (const [status, ids] of Object.entries(ohneNachStatus)) log(`${status}: ${ids.join(', ')}`) + log(bestaetigungsZeile(ohne.length)) + return { gesamt: docs.length, mitKundenart, ohne, ohneNachStatus, unbekannt } +} + +/** + * Setzt kundenart auf "unternehmen" bei allen leeren Eintraegen, aber nur mit passender Bestaetigungszeile (vor jedem Schreiben geprueft). + * Jeder Eintrag wird vor dem Schreiben neu gelesen; ein inzwischen gesetzter Wert bleibt. Ein Schreibfehler stoppt die uebrigen nicht. + * @returns {Promise<{ gesetzt: number, uebersprungen: number, fehler: { id: string, grund: string }[] }>} + */ +export async function ausfuehren({ bestaetigung, log = console.log } = {}) { + const { ohne } = await pruefen({ log }) + const ergebnis = { gesetzt: 0, uebersprungen: 0, fehler: [] } + if (ohne.length === 0) { + log('Nichts zu setzen.') + return ergebnis + } + const erwartet = bestaetigungsZeile(ohne.length) + if (bestaetigung !== erwartet) throw new AufrufFehler(`Bestätigung fehlt oder stimmt nicht. Erwartet: "${erwartet}"`) + + log(`Schreibe ${ohne.length} Einträge …`) + for (const id of ohne) { + const zeit = new Date().toISOString() + try { + const aktuell = await getDocument(config.collections.customers, id) + if (!istLeer(aktuell)) { + ergebnis.uebersprungen += 1 + log(`${zeit}\t${id}\t(inzwischen gesetzt)\t->\tunverändert`) + continue + } + await updateDocument(config.collections.customers, id, { kundenart: NEUE_ART }) + ergebnis.gesetzt += 1 + log(`${zeit}\t${id}\t${vorherText(aktuell)}\t->\t${NEUE_ART}`) + } catch (err) { + ergebnis.fehler.push({ id, grund: err.message }) + log(`${zeit}\t${id}\tFEHLER\t${err.message}`) + } + } + log(`Summe gesetzt=${ergebnis.gesetzt} uebersprungen=${ergebnis.uebersprungen} fehler=${ergebnis.fehler.length}`) + return ergebnis +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + try { + const args = parseArgs(process.argv.slice(2)) + const ergebnis = args.modus === 'pruefen' ? await pruefen({}) : await ausfuehren(args) + if (ergebnis.fehler?.length) process.exitCode = 1 + } catch (err) { + console.error('Fehler:', err.message) + process.exit(err instanceof AufrufFehler ? 2 : 1) + } +} diff --git a/test/kundenartBestand.test.js b/test/kundenartBestand.test.js new file mode 100644 index 0000000..bac6b98 --- /dev/null +++ b/test/kundenartBestand.test.js @@ -0,0 +1,61 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' + +// Einmal-Skript scripts/kundenart-bestand.mjs (P10, E18): Kundenart "unternehmen" nur dort setzen, wo das Feld leer ist, und nur mit +// passender Bestaetigungszeile. Nur Nachbauten, nie Produktion. +const aw = await startAppwriteMock({ schema: { customers: ['name', 'email', 'companyName', 'customerStatus', 'kundenart'] } }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { parseArgs, bestaetigungsZeile, pruefen, ausfuehren } = await import('../scripts/kundenart-bestand.mjs') +const kunde = (id, customerStatus, kundenart) => + aw.seed('customers', { $id: id, name: `Name ${id}`, email: `${id}@example.invalid`, companyName: `Firma ${id}`, customerStatus, ...(kundenart === undefined ? {} : { kundenart }) }) +kunde('k_leer', 'customer', ''); kunde('k_fehlt', 'lead'); kunde('k_blank', 'lost', ' '); kunde('k_archiv', 'archived', '') +kunde('k_unt', 'customer', 'unternehmen'); kunde('k_priv', 'lead', 'privat'); kunde('k_fremd', 'customer', 'firma') +const OHNE = ['k_archiv', 'k_blank', 'k_fehlt', 'k_leer'] +const stand = () => JSON.stringify([...aw.docs('customers')]) +const sammler = () => { const zeilen = []; return { log: (z) => zeilen.push(z), text: () => zeilen.join('\n') } } + +test('parseArgs: nur --pruefen oder --ausfuehren mit Bestätigung, alles andere wirft', () => { + assert.deepEqual(parseArgs(['--pruefen']), { modus: 'pruefen' }) + assert.deepEqual(parseArgs(['--ausfuehren', '--bestaetigung=Kundenart unternehmen setzen: 4']), { modus: 'ausfuehren', bestaetigung: 'Kundenart unternehmen setzen: 4' }) + for (const schlecht of [[], ['--ausfuehren'], ['--pruefen', '--ausfuehren'], ['--force'], ['--pruefen', '--bestaetigung=x']]) assert.throws(() => parseArgs(schlecht), /Aufruf/) + assert.equal(bestaetigungsZeile(42), 'Kundenart unternehmen setzen: 42') +}) +test('pruefen zählt und nennt IDs, schreibt nichts, gibt keine Personendaten aus', async () => { + const vorher = stand(); const out = sammler() + const e = await pruefen({ log: out.log }) + assert.deepEqual([e.gesamt, e.mitKundenart, [...e.ohne].sort(), e.unbekannt], [7, { unternehmen: 1, privat: 1 }, OHNE, ['k_fremd']]) + assert.deepEqual(e.ohneNachStatus, { customer: ['k_leer'], lead: ['k_fehlt'], lost: ['k_blank'], archived: ['k_archiv'] }) + assert.equal(stand(), vorher) + assert.match(out.text(), /Ohne Kundenart: 4/); assert.match(out.text(), /Unbekannte Werte: 1/); assert.match(out.text(), /Kundenart unternehmen setzen: 4/) + assert.doesNotMatch(out.text(), /Name |Firma |@example/) +}) +test('ausfuehren ohne passende Bestätigungszeile schreibt nichts', async () => { + const vorher = stand() + for (const b of ['', 'ja', 'Kundenart unternehmen setzen: 3', 'Kundenart unternehmen setzen: 5', 'kundenart unternehmen setzen: 4']) await assert.rejects(ausfuehren({ bestaetigung: b, log: () => {} }), /Kundenart unternehmen setzen: 4/, b) + assert.equal(stand(), vorher) +}) +test('ausfuehren setzt nur leere Felder, lässt alles andere stehen und ist wiederholbar', async () => { + const out = sammler() + const e = await ausfuehren({ bestaetigung: bestaetigungsZeile(4), log: out.log }) + assert.deepEqual([e.gesetzt, e.uebersprungen, e.fehler], [4, 0, []]) + const art = (id) => aw.docs('customers').get(id).kundenart + assert.deepEqual(OHNE.map(art), Array(4).fill('unternehmen')); assert.deepEqual(['k_unt', 'k_priv', 'k_fremd'].map(art), ['unternehmen', 'privat', 'firma']) + assert.equal(aw.docs('customers').get('k_leer').name, 'Name k_leer') + for (const id of OHNE) assert.match(out.text(), new RegExp(`\\t${id}\\t`)); assert.doesNotMatch(out.text(), /Name |Firma |@example/) + assert.equal((await ausfuehren({ bestaetigung: bestaetigungsZeile(0), log: () => {} })).gesetzt, 0) +}) +test('Wert, der zwischen Prüfen und Schreiben gesetzt wurde, bleibt', async () => { + kunde('k_r1', 'lead', ''); kunde('k_r2', 'lead', '') + const e = await ausfuehren({ bestaetigung: bestaetigungsZeile(2), log: (z) => { if (/^Schreibe 2 /.test(z)) kunde('k_r1', 'lead', 'privat') } }) + assert.deepEqual([e.gesetzt, e.uebersprungen], [1, 1]) + assert.deepEqual(['k_r1', 'k_r2'].map((id) => aw.docs('customers').get(id).kundenart), ['privat', 'unternehmen']) +}) +test('Schreibfehler stehen im Ergebnis, die übrigen Einträge werden trotzdem gesetzt', async () => { + kunde('k_e1', 'lead', ''); kunde('k_e2', 'lead', '') + aw.failNext({ collection: 'customers', method: 'PATCH', status: 500, message: 'kaputt' }) + const e = await ausfuehren({ bestaetigung: bestaetigungsZeile(2), log: () => {} }) + assert.deepEqual([e.gesetzt, e.fehler.length], [1, 1]); assert.match(e.fehler[0].grund, /kaputt/) +})