// test/auftraegeArbeitsblaetter.test.js import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' // worksheets gehört dem Ticketsystem und hat im Nachbau kein Schema (jedes Feld ist erlaubt). const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { createProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') const A = await import('../server/services/auftraege/arbeitsblaetter.js') const WS = config.collections.worksheets const PR = config.collections.projektRechnungen beforeEach(async () => { for (const col of [WS, PR]) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) aw.resetListCalls() }) const UNTERSTUETZUNG = JSON.stringify([{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' }]) const ZWEI_UNTERSTUETZER = JSON.stringify([ { employeeId: 'e2', name: 'Justin Klein', task: 'Review' }, { employeeId: 'e3', name: 'Nico', task: 'Test' }, ]) const blatt = (id, felder = {}) => aw.seed(WS, { $id: id, workorderId: 't1', woid: '100101', wsid: id, startDate: '06.10.2026', endDate: '06.10.2026', totalTime: 60, details: 'Startseite überarbeitet', serviceType: 'REMOTE', employeeName: 'Kenso Grimm', countsToPlan: false, isComment: false, supporters: '', ...felder, }) const nachId = async (ticketIds = ['t1']) => Object.fromEntries((await A.ladeArbeitsblaetter(ticketIds)).map((b) => [b.id, b])) test('ladeArbeitsblaetter liefert die normierte Form je Blatt', async () => { blatt('w1') assert.deepEqual(await A.ladeArbeitsblaetter(['t1']), [ { id: 'w1', ticketId: 't1', woid: '100101', datum: '2026-10-06', minuten: 60, text: 'Startseite überarbeitet', aboZeit: false, kommentar: false }, ]) }) test('Datum eines Arbeitsblatts: TT.MM.JJJJ, ISO als Berliner Tag, leer fällt auf $createdAt, ein unmögliches Datum auch', async () => { const faelle = { d1: [{ startDate: '06.10.2026' }, '2026-10-06'], d2: [{ startDate: '', endDate: '07.10.2026' }, '2026-10-07'], d3: [{ startDate: '2026-10-06T21:59:59.000Z' }, '2026-10-06'], // 23:59:59 in Berlin d4: [{ startDate: '2026-10-06T22:30:00.000Z' }, '2026-10-07'], // 00:30 in Berlin am nächsten Tag d5: [{ startDate: '2026-10-06' }, '2026-10-06'], d6: [{ startDate: '', $createdAt: '2026-10-08T21:30:00.000+00:00' }, '2026-10-08'], d7: [{ startDate: '', $createdAt: '2026-10-08T22:00:00.000+00:00' }, '2026-10-09'], d8: [{ startDate: '31.02.2026', $createdAt: '2026-10-01T10:00:00.000+00:00' }, '2026-10-01'], d9: [{ startDate: 'gestern', $createdAt: '2026-10-02T10:00:00.000+00:00' }, '2026-10-02'], d10: [{ startDate: '12.13.2026', $createdAt: '2026-10-03T10:00:00.000+00:00' }, '2026-10-03'], // nie als Monatsangabe der US-Schreibweise lesen } for (const [id, [felder]] of Object.entries(faelle)) blatt(id, { endDate: '', ...felder }) const datum = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.datum])) assert.deepEqual(datum, Object.fromEntries(Object.entries(faelle).map(([id, [, tag]]) => [id, tag]))) }) test('Minuten: je Blatt inklusive Unterstützung auf 15 Minuten aufgerundet', async () => { const faelle = [ ['m1', { totalTime: 10 }, 15], ['m2', { totalTime: 15 }, 15], ['m3', { totalTime: 16 }, 30], ['m4', { totalTime: 0 }, 0], ['m5', { totalTime: 'abc' }, 0], ['m6', { totalTime: 80, supporters: UNTERSTUETZUNG }, 135], // 80 + 45 Unterstützung = 125 ['m7', { totalTime: 20, supporters: UNTERSTUETZUNG }, 45], // 20 + 15 = 35 ['m8', { totalTime: 60, supporters: ZWEI_UNTERSTUETZER }, 120], // 60 + 30 + 30 ] for (const [id, felder] of faelle) blatt(id, felder) const minuten = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.minuten])) assert.deepEqual(minuten, Object.fromEntries(faelle.map(([id, , erwartet]) => [id, erwartet]))) }) test('Abo-Zeit und Kommentar werden erkannt und sind nie abrechenbar, ebenso 0 Minuten', async () => { blatt('f1') blatt('f2', { countsToPlan: true }) blatt('f3', { isComment: true, totalTime: 0 }) blatt('f4', { totalTime: 0 }) const b = await nachId() assert.deepEqual([b.f1.aboZeit, b.f1.kommentar, A.abrechenbar(b.f1)], [false, false, true]) assert.deepEqual([b.f2.aboZeit, A.abrechenbar(b.f2)], [true, false]) assert.deepEqual([b.f3.kommentar, A.abrechenbar(b.f3)], [true, false]) assert.equal(A.abrechenbar(b.f4), false) const basis = { aboZeit: false, kommentar: false, minuten: 15 } assert.equal(A.abrechenbar(basis), true) assert.equal(A.abrechenbar({ ...basis, aboZeit: true }), false) assert.equal(A.abrechenbar({ ...basis, kommentar: true }), false) assert.equal(A.abrechenbar({ ...basis, minuten: 0 }), false) }) test('Text: wie in der Kundenansicht, eine Zeile, höchstens 200 Zeichen, Git ohne Kürzel, Link und Namen', async () => { blatt('x1', { details: ' Startseite\n\nKontaktformular überarbeitet ' }) blatt('x2', { details: 'ä'.repeat(300) }) blatt('x3', { serviceType: 'GIT', totalTime: 45, details: [ 'Git Push auf `knso/x` (Branch: main)', 'Commit: 88d3c37', '', '- [88d3c37](https://git.webklar.com/knso/x/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4): feat: Kontaktformular repariert (Kenso Grimm)', '- [1a2b3c4](https://git.webklar.com/knso/x/commit/1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b): fix: Footer', '', ].join('\n'), }) blatt('x4', { isComment: true, details: 'nur intern' }) blatt('x5', { details: '' }) const b = await nachId() assert.equal(b.x1.text, 'Startseite Kontaktformular überarbeitet') assert.equal(b.x2.text.length, 200) assert.ok(b.x2.text.endsWith('…')) assert.equal(b.x3.text, 'Kontaktformular repariert; Footer') assert.doesNotMatch(b.x3.text, /88d3c37|git\.webklar|Kenso/) assert.equal(b.x4.text, '') assert.equal(b.x5.text, '') }) test('ladeArbeitsblaetter: nur die genannten Tickets, nach Datum und ID sortiert, in Blöcken zu 100 Tickets', async () => { blatt('w3', { startDate: '07.10.2026' }) blatt('w2', { startDate: '06.10.2026' }) blatt('w1', { startDate: '06.10.2026' }) blatt('w9', { workorderId: 't2', startDate: '05.10.2026' }) blatt('w8', { workorderId: 't3' }) assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't2'])).map((b) => b.id), ['w9', 'w1', 'w2', 'w3']) assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't1', '', null, 5])).map((b) => b.id), ['w1', 'w2', 'w3']) aw.resetListCalls() for (const leer of [[], undefined, null, [''], 'kein-array']) assert.deepEqual(await A.ladeArbeitsblaetter(leer), [], String(leer)) assert.equal(aw.listCalls(WS).length, 0, 'ohne Ticket keine Abfrage') const viele = Array.from({ length: 250 }, (_, i) => `t_${i}`) viele[7] = 't1' assert.deepEqual((await A.ladeArbeitsblaetter(viele)).map((b) => b.id), ['w1', 'w2', 'w3']) const aufrufe = aw.listCalls(WS) assert.equal(aufrufe.length, 3) for (const aufruf of aufrufe) { assert.ok(aufruf.queries.find((q) => q.method === 'equal' && q.attribute === 'workorderId').values.length <= 100) } }) test('ladeArbeitsblaetterNachIds liefert nur vorhandene Blätter, sortiert wie ladeArbeitsblaetter', async () => { blatt('w1', { startDate: '07.10.2026' }) blatt('w2', { workorderId: 't2', startDate: '06.10.2026' }) assert.deepEqual((await A.ladeArbeitsblaetterNachIds(['w1', 'w2', 'gibtsnicht', 'w1'])).map((b) => [b.id, b.ticketId]), [['w2', 't2'], ['w1', 't1']]) assert.deepEqual(await A.ladeArbeitsblaetterNachIds([]), []) }) test('abgerechneteArbeitsblaetter: nur aktive Projektrechnungen dieses Kunden; ein Entwurf hat die Nummer leer', async () => { const daten = (extra) => ({ kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, ...extra }) await createProjektRechnung('in_gesendet', daten({ arbeitsblattIds: ['w1', 'w2'] })) await updateProjektRechnung('in_gesendet', { rechnungsnummer: 'WEBKLAR-0009' }) await createProjektRechnung('in_entwurf', daten({ arbeitsblattIds: ['w5'] })) await createProjektRechnung('in_storniert', daten({ arbeitsblattIds: ['w3'] })) await updateProjektRechnung('in_storniert', { aktiv: false }) await createProjektRechnung('in_fremd', daten({ kundenId: 'c2', arbeitsblattIds: ['w4'] })) const karte = await A.abgerechneteArbeitsblaetter('c1') assert.ok(karte instanceof Map) assert.deepEqual([...karte.entries()].sort(), [ ['w1', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }], ['w2', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }], ['w5', { invoiceId: 'in_entwurf', rechnungsnummer: '' }], ]) await assert.rejects(() => A.abgerechneteArbeitsblaetter(''), TypeError) }) test('offeneArbeitsblaetter: abrechenbar und noch nicht abgerechnet; ein stornierter Eintrag gibt das Blatt frei', async () => { blatt('w1') // abgerechnet (siehe unten) blatt('w6', { totalTime: 30 }) // offen blatt('w7', { countsToPlan: true }) // Abo-Zeit blatt('w8', { isComment: true, totalTime: 0 }) // Kommentar blatt('w9', { totalTime: 0 }) // 0 Minuten blatt('w10', { workorderId: 't2' }) // anderes Ticket await createProjektRechnung('in_a', { kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, arbeitsblattIds: ['w1'] }) assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w6']) await updateProjektRechnung('in_a', { aktiv: false }) assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w1', 'w6']) }) test('zeitquelleFuerProjekt: Vereinigung der vereinbarten ABs, sonst das Ticket des Projekts', () => { const ab = (status, zeitquelle) => ({ status, zeitquelle }) const projekt = { $id: 'p1', ticketId: 't0' } const alle = [ ab('versendet', ['t1', 't2']), ab('bestaetigt', ['t2', 't3']), ab('abgerechnet', ['t4']), ab('entwurf', ['t9']), ab('storniert', ['t8']), ab('ersetzt', ['t7']), ab('widerrufen', ['t6']), ] assert.deepEqual(A.zeitquelleFuerProjekt(projekt, alle), ['t1', 't2', 't3', 't4']) assert.deepEqual(A.zeitquelleFuerProjekt(projekt, []), ['t0']) assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('entwurf', ['t9'])]), ['t0']) assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('versendet', [])]), ['t0']) assert.deepEqual(A.zeitquelleFuerProjekt({ $id: 'p1' }, []), []) assert.deepEqual(A.zeitquelleFuerProjekt({ ticketId: '' }, undefined), []) assert.deepEqual(A.zeitquelleFuerProjekt(null, []), []) })