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] : []
}