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:
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] : []
|
||||
}
|
||||
@@ -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, []), [])
|
||||
})
|
||||
Reference in new issue
Block a user