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