From 47a92811a3c6f049f16b87ab2403133daac96ebf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:06:50 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge:=20Arbeitsbl=C3=A4tter=20laden,?= =?UTF-8?q?=20normieren=20und=20als=20abgerechnet=20erkennen?= 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/arbeitsblaetter.js | 108 ++++++++++ test/auftraegeArbeitsblaetter.test.js | 196 +++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 server/services/auftraege/arbeitsblaetter.js create mode 100644 test/auftraegeArbeitsblaetter.test.js diff --git a/server/services/auftraege/arbeitsblaetter.js b/server/services/auftraege/arbeitsblaetter.js new file mode 100644 index 0000000..473d5bf --- /dev/null +++ b/server/services/auftraege/arbeitsblaetter.js @@ -0,0 +1,108 @@ +/** + * Arbeitsblätter (Collection worksheets, gehört dem Ticketsystem) für die Projektabrechnung: laden, normieren + * (Berliner Tag, auf 15 Minuten aufgerundete Minuten, kundentauglicher Text) und als abgerechnet erkennen + * (Übersicht V3/V4). „Abgerechnet“ gibt es nur über aktive Projektrechnungen: ein stornierter Eintrag gibt seine + * Blätter frei, ein Entwurf sperrt sie. Abo-Zeit und Kommentare sind nie abrechenbar. + */ +import { config } from '../../config.js' +import { listAllDocuments, Query } from '../appwriteAdmin.js' +import { customerWorkEntries } from '../customerProjectView.js' +import { berlinDate } from '../finance/dates.js' +import { worksheetMinutes } from '../worksheetMinutes.js' +import { VEREINBARTE_STATUS, VIERTELSTUNDE } from './konstanten.js' +import { listProjektRechnungen } from './projektRechnungenStore.js' + +const BLOCK = 100 // Werte je equal-Abfrage (Appwrite begrenzt die Werteliste) +const MAX_TEXT = 200 +const DE_DATUM = /^(\d{2})\.(\d{2})\.(\d{4})$/ + +/** Berliner Kalendertag eines Arbeitsblatts; nie ein Wurf. Regel wie parseSheetDate in hourLedger.js. */ +function blattDatum(doc) { + const roh = String(doc.startDate || doc.endDate || '').trim() + const de = DE_DATUM.exec(roh) + if (de) { + const [tag, monat, jahr] = [Number(de[1]), Number(de[2]), Number(de[3])] + const pruefung = new Date(Date.UTC(jahr, monat - 1, tag)) + if (pruefung.getUTCFullYear() === jahr && pruefung.getUTCMonth() === monat - 1 && pruefung.getUTCDate() === tag) return `${de[3]}-${de[2]}-${de[1]}` + // ein unmögliches Datum (31.02.) ist nie ein Kandidat für new Date: V8 läse „12.13.2026“ als Dezember + } + const kandidaten = [de ? '' : roh, doc.createdAt, doc.$createdAt] + for (const kandidat of kandidaten) { + if (!kandidat) continue + const datum = new Date(kandidat) + if (!Number.isNaN(datum.getTime())) return berlinDate(datum) + } + return '' +} + +/** Text wie in der Kundenansicht (customerWorkEntries): eine Zeile, höchstens 200 Zeichen. */ +function blattText(doc) { + const [eintrag] = customerWorkEntries([doc], { includeUpdates: true }) + if (!eintrag) return '' + const roh = Array.isArray(eintrag.changes) ? eintrag.changes.join('; ') : String(eintrag.text || '') + const text = roh.replace(/\s+/g, ' ').trim() + return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1).trimEnd()}…` : text +} + +function docToBlatt(doc) { + return { + id: doc.$id, + ticketId: String(doc.workorderId ?? ''), + woid: String(doc.woid ?? ''), + datum: blattDatum(doc), + minuten: Math.ceil(worksheetMinutes(doc) / VIERTELSTUNDE) * VIERTELSTUNDE, + text: blattText(doc), + aboZeit: doc.countsToPlan === true, + kommentar: doc.isComment === true, + } +} + +const sortiert = (blaetter) => blaetter.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)) + +async function ladeNach(attribut, werte) { + const liste = Array.isArray(werte) ? [...new Set(werte.filter((wert) => typeof wert === 'string' && wert !== ''))] : [] + const blaetter = [] + for (let i = 0; i < liste.length; i += BLOCK) { + const docs = await listAllDocuments(config.collections.worksheets, [Query.equal(attribut, liste.slice(i, i + BLOCK))]) + blaetter.push(...docs.map(docToBlatt)) + } + return sortiert(blaetter) +} + +export const ladeArbeitsblaetter = (ticketIds) => ladeNach('workorderId', ticketIds) + +/** Liefert nur gefundene Blätter; der Aufrufer vergleicht die Anzahl. */ +export const ladeArbeitsblaetterNachIds = (ids) => ladeNach('$id', ids) + +export const abrechenbar = (blatt) => !blatt.aboZeit && !blatt.kommentar && blatt.minuten > 0 + +/** Karte Arbeitsblatt-ID → { invoiceId, rechnungsnummer } aus aktiven Projektrechnungen (Entwurf: Nummer ''). */ +export async function abgerechneteArbeitsblaetter(kundenId) { + const karte = new Map() + for (const rechnung of await listProjektRechnungen({ kundenId })) { + for (const id of rechnung.arbeitsblattIds) { + if (!karte.has(id)) karte.set(id, { invoiceId: rechnung.invoiceId, rechnungsnummer: rechnung.rechnungsnummer }) + } + } + return karte +} + +export async function offeneArbeitsblaetter(ticketIds, kundenId) { + const abgerechnet = await abgerechneteArbeitsblaetter(kundenId) + return (await ladeArbeitsblaetter(ticketIds)).filter((blatt) => abrechenbar(blatt) && !abgerechnet.has(blatt.id)) +} + +/** + * Zeitquelle eines Projekts: Vereinigung der zeitquelle aller vereinbarten ABs (VEREINBARTE_STATUS), sonst das + * Ticket des Projekts. Der Aufrufer übergibt nur die ABs dieses Projekts; hier wird nicht nach Projekt gefiltert. + */ +export function zeitquelleFuerProjekt(projekt, auftraege) { + const quelle = new Set() + for (const auftrag of Array.isArray(auftraege) ? auftraege : []) { + if (!VEREINBARTE_STATUS.includes(auftrag?.status)) continue + for (const id of auftrag.zeitquelle || []) if (typeof id === 'string' && id !== '') quelle.add(id) + } + if (quelle.size) return [...quelle] + const ticketId = projekt?.ticketId + return typeof ticketId === 'string' && ticketId !== '' ? [ticketId] : [] +} diff --git a/test/auftraegeArbeitsblaetter.test.js b/test/auftraegeArbeitsblaetter.test.js new file mode 100644 index 0000000..386a61d --- /dev/null +++ b/test/auftraegeArbeitsblaetter.test.js @@ -0,0 +1,196 @@ +// 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, []), []) +})