109 lines
4.9 KiB
JavaScript
109 lines
4.9 KiB
JavaScript
/**
|
|
* 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] : []
|
|
}
|