diff --git a/server/services/shop/ticketDetails.js b/server/services/shop/ticketDetails.js index d5a5d76..5317335 100644 --- a/server/services/shop/ticketDetails.js +++ b/server/services/shop/ticketDetails.js @@ -54,7 +54,8 @@ export async function shopTicketDetails(ctx) { `5. Vorschau prüfen: https://project.webklar.com/shop/${siteKey}`, '6. Ticket schließen: das schaltet die Funktion frei.', '', - 'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.', + // Abo-Modell (einrichtungAbrechnung abo oder projekt): die Abrechnung steht allein in der Schlusszeile des Tickets (createFeatureTicket) + ...(doc?.einrichtungAbrechnung === 'abo' || doc?.einrichtungAbrechnung === 'projekt' ? [] : ['Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.']), `Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`, ] return kuerze(zeilen.join('\n').toWellFormed(), TICKET_DETAILS_MAX, { anZeile: true }) diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js index cb879b8..98c794e 100644 --- a/server/services/termine/einbauanleitung.js +++ b/server/services/termine/einbauanleitung.js @@ -488,6 +488,19 @@ const DATENSCHUTZ_REGEL = 'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; ' + 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt' +/** + * Abrechnung am Dokument (einrichtungAbrechnung, Abo-Modell der Kundenansicht neu): 'abo' (aufs Abo-Stundenkonto) oder 'projekt' (im Projekt + * enthalten); alles andere (Kauf-Bestand, Pilot, Kulanz) ist '' und behaelt die bisherigen Texte Zeichen fuer Zeichen. + */ +const abrechnungVon = (feature) => (feature?.einrichtungAbrechnung === 'abo' || feature?.einrichtungAbrechnung === 'projekt' ? feature.einrichtungAbrechnung : '') + +/** DATENSCHUTZ_REGEL je Abrechnung: ohne Marker die Konstante wörtlich, im Abo-Modell ohne "nicht angerechnet". */ +function datenschutzRegel(feature) { + const abrechnung = abrechnungVon(feature) + if (!abrechnung) return DATENSCHUTZ_REGEL + return DATENSCHUTZ_REGEL.replace('wird nicht auf das Stundenkontingent angerechnet', abrechnung === 'abo' ? 'zählt mit zum Stundenkonto' : 'ist im Projekt enthalten') +} + function abschnittCheckliste(d) { const { e } = d const widerruf = zeigtWiderruf(e) @@ -501,7 +514,7 @@ function abschnittCheckliste(d) { 'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.', ...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []), 'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.', - `Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${DATENSCHUTZ_REGEL}).`, + `Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${datenschutzRegel(d.feature)}).`, `URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`, `„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`, // am sichtbaren Widget @@ -531,6 +544,16 @@ function einrichtungsPreis() { } function abschnittAbrechnung(d) { + const abrechnung = abrechnungVon(d.feature) + if (abrechnung) { + // Abo-Modell: Einrichtung ohne Zahlung, die Zeit gehoert aufs Abo-Stundenkonto bzw. ins Projekt + return neuerAbschnitt('abrechnung', 'Abrechnung', [ + abrechnung === 'abo' + ? 'Abrechnung: Einrichtung aus dem Abo, Zeit im Arbeitsblatt aufs Abo-Stundenkonto anrechnen (Vorschuss möglich).' + : 'Abrechnung: im Projekt enthalten, Zeit im Arbeitsblatt, nicht aufs Abo anrechnen.', + 'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.', + ].join('\n')) + } if (d.feature?.unlockedByPurchase !== true) { return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.') } @@ -663,14 +686,15 @@ function kurzUmfang(k) { function kurzSchritte(k) { const form = !k.hatEinstellung ? 'Sie- oder Du-Form nach der Anrede der Gäste' : k.anrede === 'du' ? 'Du-Form' : 'Sie-Form' const bezahlt = k.feature?.unlockedByPurchase === true ? ` (Einrichtung bezahlt, ${einrichtungsPreis()})` : '' + const abrechnung = abrechnungVon(k.feature) return [ 'Schritte:', '1. WOMS-Panel öffnen (Kunde → Reiter Funktionen → Anleitung des Projekts): dort stehen alle Einstellungen, die Kopierknöpfe und die Abnahme-Checkliste.', '2. Einbau-Code einfügen: den Code oben an der Stelle einsetzen, die der Kunde nennt (siehe Kundenwunsch), das Skript einmal pro Seite.', '3. Gestaltung und Kontrast: das CSS aus der Anleitung (Abschnitt „Aussehen (CSS)“) in die Website übernehmen und den Kontrast prüfen.', - `4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${DATENSCHUTZ_REGEL}).`, + `4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${datenschutzRegel(k.feature)}).`, '5. „Freischalten (Abnahme)“ im WOMS auslösen, danach eine Probebuchung durchspielen und den Testtermin wieder stornieren (Checkliste in der Anleitung).', - `6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`, + abrechnung ? '6. Ticket erledigen und die Zeit im Arbeitsblatt buchen.' : `6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`, ].join('\n') } diff --git a/server/services/termine/ticketDetails.js b/server/services/termine/ticketDetails.js index 39082fb..2aa4c10 100644 --- a/server/services/termine/ticketDetails.js +++ b/server/services/termine/ticketDetails.js @@ -36,13 +36,15 @@ export async function termineTicketDetails(ctx) { const ANLEITUNG_FEHLT = 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen (Verweis oben).' const KONTINGENT = 'Nicht auf das Stundenkontingent anrechnen.' +// Abo-Modell (Kundenansicht neu, P6): die Abrechnung steht allein in der Schlusszeile des Tickets (createFeatureTicket, ABRECHNUNGS_ZEILEN) +const imAboModell = (doc) => doc?.einrichtungAbrechnung === 'abo' || doc?.einrichtungAbrechnung === 'projekt' /** Kopfzeilen und Verweis (minimaleTicketDetails, Funktion wie im Kopf der Kurzfassung) plus Hinweis; wirft nie, auch nicht bei einem unbrauchbaren ctx. */ function notfallDetails(ctx) { try { const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Terminplaner' } }) - return kuerze(`${kopf}\n${ANLEITUNG_FEHLT}\n${KONTINGENT}`.toWellFormed(), TICKET_DETAILS_MAX) + return kuerze(`${kopf}\n${ANLEITUNG_FEHLT}${imAboModell(ctx?.doc) ? '' : `\n${KONTINGENT}`}`.toWellFormed(), TICKET_DETAILS_MAX) } catch { - return `Funktion: Terminplaner\nAnleitung konnte nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).\n${KONTINGENT}` + return `Funktion: Terminplaner\nAnleitung konnte nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).${imAboModell(ctx?.doc) ? '' : `\n${KONTINGENT}`}` } } diff --git a/server/services/unterkunft/ticketDetails.js b/server/services/unterkunft/ticketDetails.js index 2370d2d..d2241f3 100644 --- a/server/services/unterkunft/ticketDetails.js +++ b/server/services/unterkunft/ticketDetails.js @@ -46,7 +46,8 @@ export async function unterkunftTicketDetails(ctx) { '5. Testanfrage über den Kalender stellen und im Portal (Buchungen) prüfen.', '6. Ticket schließen: das schaltet die Funktion frei (sobald der Kunde Unterkunft, Preise, Stornobedingungen und Firmendaten hinterlegt hat).', '', - 'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.', + // Abo-Modell (einrichtungAbrechnung abo oder projekt): die Abrechnung steht allein in der Schlusszeile des Tickets (createFeatureTicket) + ...(doc?.einrichtungAbrechnung === 'abo' || doc?.einrichtungAbrechnung === 'projekt' ? [] : ['Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.']), 'Einbauanleitung: docs/funktionen/unterkunft-einbau.md', `Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`, ] diff --git a/test/einrichtungsTicketTexte.test.js b/test/einrichtungsTicketTexte.test.js new file mode 100644 index 0000000..0cf981e --- /dev/null +++ b/test/einrichtungsTicketTexte.test.js @@ -0,0 +1,45 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Ticket-Texte zum Stundenkonto je Abrechnung (P6 Task 14): Dokumente des Abo-Modells (einrichtungAbrechnung abo oder projekt) sagen im +// Ticket nirgends "nicht anrechnen"; die Abrechnung steht allein in der Schlusszeile aus createFeatureTicket (ABRECHNUNGS_ZEILEN). +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +after(() => Promise.all([aw.close(), st.close()])) +const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js') +const { shopTicketDetails } = await import('../server/services/shop/ticketDetails.js') +const { unterkunftTicketDetails } = await import('../server/services/unterkunft/ticketDetails.js') +const { einbauanleitung } = await import('../server/services/termine/einbauanleitung.js') +const { ausVorlage } = await import('../server/services/termine/vorlagen.js') + +const NOW = new Date('2026-10-17T08:00:00.000Z') +// Salon-Vorlage als gespeicherte Einstellung: erst damit erzeugt der Generator alle Abschnitte (Checkliste, Abrechnung), ohne Einstellung nur das Snippet +const EINSTELLUNG = () => ausVorlage('salon', { name: 'Salon Muster', email: 'inhaber@salon-muster.example', telefon: '0631 123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', kleinunternehmer: false, impressumUrl: 'https://salon-muster.example/impressum', datenschutzUrl: 'https://salon-muster.example/datenschutz', bundesland: 'RP' }, NOW) +const ALT = /nicht auf (das )?Stundenkontingent|nicht auf Abo-Stunden|angerechnet/i +const ctx = (key, doc) => ({ feature: { key }, project: { projectName: 'Bäckerei', subdomain: 'baeckerei' }, customer: { name: 'Kunde' }, doc: { $id: `f_${key}`, siteKey: 'abcdefghijklmnop', customerId: 'c1', projectId: 'p1', featureKey: key, ...doc } }) +const BAUER = { termine: termineTicketDetails, shop: shopTicketDetails, unterkunft: unterkunftTicketDetails } +for (const [key, bau] of Object.entries(BAUER)) { + test(`${key}: mit Abrechnung abo und projekt keine „nicht anrechnen“-Aussage im Ticket`, async () => { + for (const einrichtungAbrechnung of ['abo', 'projekt']) assert.doesNotMatch(await bau(ctx(key, { einrichtungAbrechnung, unlockedBy: einrichtungAbrechnung })), ALT, `${key} ${einrichtungAbrechnung}`) + }) + test(`${key}: Kauf-Bestand und Altdokumente behalten ihren Wortlaut`, async () => { assert.match(await bau(ctx(key, key === 'termine' ? { unlockedByPurchase: true } : {})), ALT) }) +} +test('Terminplaner-Anleitung: Text und Kurzfassung je Abrechnung; Notfalltext ohne Kontingent-Satz', async (t) => { + t.mock.method(console, 'error', () => {}) + const gen = (feature) => einbauanleitung({ feature: { $id: 'f1', siteKey: 'abcdefghijklmnop', customerId: 'c1', projectId: 'p1', ...feature }, project: { projectName: 'B' }, einstellung: EINSTELLUNG(), fragebogen: null, version: 1, updatedAt: null, kunde: { name: 'K' }, now: NOW }) + for (const a of ['abo', 'projekt']) { const g = gen({ einrichtungAbrechnung: a, unlockedBy: a }); assert.doesNotMatch(g.text, ALT, a); assert.doesNotMatch(g.kurz, ALT, a) } + assert.match(gen({ einrichtungAbrechnung: 'abo', unlockedBy: 'abo' }).text, /Einrichtung aus dem Abo, Zeit im Arbeitsblatt aufs Abo-Stundenkonto anrechnen \(Vorschuss möglich\)\./) + assert.match(gen({ einrichtungAbrechnung: 'projekt', unlockedBy: 'projekt' }).text, /im Projekt enthalten, Zeit im Arbeitsblatt, nicht aufs Abo anrechnen\./) + assert.match(gen({ unlockedByPurchase: true }).kurz, /Nicht auf das Stundenkontingent anrechnen/) + // Ersatztexte der Datenschutz-Regel und Schritt 6 + assert.match(gen({ einrichtungAbrechnung: 'abo', unlockedBy: 'abo' }).kurz, /das gehört zur Einrichtung und zählt mit zum Stundenkonto/) + assert.match(gen({ einrichtungAbrechnung: 'projekt', unlockedBy: 'projekt' }).kurz, /das gehört zur Einrichtung und ist im Projekt enthalten/) + assert.match(gen({ einrichtungAbrechnung: 'abo', unlockedBy: 'abo' }).kurz, /6\. Ticket erledigen und die Zeit im Arbeitsblatt buchen\.(\n|$)/) + // Notfalltext (Anleitung nicht erzeugbar, hier: leere $id, ladeStand wirft): im Abo-Modell ohne Kontingent-Satz, ohne Marker wie heute + assert.doesNotMatch(await termineTicketDetails(ctx('termine', { einrichtungAbrechnung: 'abo', unlockedBy: 'abo', $id: '' })), ALT) + assert.match(await termineTicketDetails(ctx('termine', { $id: '' })), /Nicht auf das Stundenkontingent anrechnen\./) +})