Aufträge: Arbeitsblätter laden, normieren und als abgerechnet erkennen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-07 14:06:50 +00:00
1 parent 9cd5ea3038
commit 47a92811a3
2 files changed
+304

No files matched your search

@@ -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] : []
}
+196
View File
@@ -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, []), [])
})