Projektkonto: Anzeigelogik für Zeilen, Rechnungsspalten und Abzeichen „abgerechnet“ mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99ab4bd47b
commit
241f7cdec6
2 files changed
+314
No files matched your search
@@ -0,0 +1,154 @@
|
||||
// Anzeigelogik des Projektkontos (rein, ohne React und Appwrite; unter Node testbar).
|
||||
// Aufgaben: Zeilen der Projektliste im Kundenreiter „Aufträge und Rechnungen“, die Spalten „Projekt“ und „Art“ der
|
||||
// Rechnungstabelle und das Abzeichen „abgerechnet“ der Arbeitsblätter. Alle Beträge sind Cent; Ausgaben sind Zeichenketten.
|
||||
import { euro, euroWhole } from './financeFormat'
|
||||
|
||||
const PLATZHALTER = '–'
|
||||
|
||||
export const STRIPE_HINWEIS = 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.'
|
||||
export const KEINE_PROJEKTE_HINWEIS = 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.'
|
||||
|
||||
const ART_LABEL = {
|
||||
frei: 'Ohne Projekt',
|
||||
festbetrag: 'Festbetrag',
|
||||
aufwand: 'Aufwand',
|
||||
anzahlung: 'Anzahlung',
|
||||
schluss: 'Schlussrechnung',
|
||||
abo: 'Abo',
|
||||
}
|
||||
|
||||
function istZahl(v) {
|
||||
return typeof v === 'number' && Number.isFinite(v)
|
||||
}
|
||||
|
||||
function eigen(obj, key) {
|
||||
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key)
|
||||
}
|
||||
|
||||
/** 750 -> '12,5 Std.' (Dezimalstunden, höchstens zwei Nachkommastellen, ohne überflüssige Nullen). */
|
||||
export function stundenDe(minuten) {
|
||||
const min = istZahl(minuten) ? minuten : 0
|
||||
const h = Math.round((min / 60) * 100) / 100
|
||||
return `${String(h).replace('.', ',')} Std.`
|
||||
}
|
||||
|
||||
/** 10000 -> '100 €/Std.', 8550 -> '85,50 €/Std.', keine Zahl -> '–'. */
|
||||
export function satzText(cents) {
|
||||
if (!istZahl(cents)) return PLATZHALTER
|
||||
const ganz = Math.round(cents) % 100 === 0
|
||||
return `${ganz ? euroWhole(cents) : euro(cents)}/Std.`
|
||||
}
|
||||
|
||||
/** [{ woid: '100131' }, …] -> '#100131, #100140'; Tickets ohne Nummer fallen weg. */
|
||||
export function ticketsText(tickets) {
|
||||
if (!Array.isArray(tickets)) return ''
|
||||
return tickets
|
||||
.map((t) => String(t?.woid ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.map((w) => `#${w}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/** Hinweis nur, wenn Stripe ausdrücklich nicht erreichbar war. */
|
||||
export function stripeHinweis(stripeOk) {
|
||||
return stripeOk === false ? STRIPE_HINWEIS : ''
|
||||
}
|
||||
|
||||
/** Text der Spalte „Vereinbart“: Festpreis, Aufwand mit Satz und Schätzung, beides oder „keine AB“. */
|
||||
export function vereinbartText(vereinbart) {
|
||||
const teile = []
|
||||
if (istZahl(vereinbart?.festpreisCents)) teile.push(euro(vereinbart.festpreisCents))
|
||||
const aufwand = vereinbart?.aufwand
|
||||
if (aufwand) {
|
||||
let t = `nach Aufwand, ${satzText(aufwand.stundensatzCents)}`
|
||||
if (istZahl(aufwand.schaetzungMinuten) && aufwand.schaetzungMinuten > 0) t += `, Schätzung ${stundenDe(aufwand.schaetzungMinuten)}`
|
||||
teile.push(t)
|
||||
}
|
||||
return teile.length ? teile.join(' und ') : 'keine AB'
|
||||
}
|
||||
|
||||
/** Eine Zeile der Projektliste. `stripeOk: false` ersetzt Berechnet, bezahlt und (Festpreis) Noch offen durch „–“. */
|
||||
export function kontoZeile(konto, { stripeOk = true } = {}) {
|
||||
const stripe = stripeOk !== false
|
||||
const k = konto || {}
|
||||
const zeit = k.zeit || {}
|
||||
const vereinbart = k.vereinbart || {}
|
||||
const name = String(k.name ?? '').trim() || 'Projekt'
|
||||
const meta = [String(k.projektTyp ?? '').trim(), ticketsText(k.tickets)].filter(Boolean).join(' · ')
|
||||
let offen = PLATZHALTER
|
||||
if (istZahl(vereinbart.festpreisCents)) offen = stripe ? euro(k.offenCents) : PLATZHALTER
|
||||
else if (vereinbart.aufwand) offen = euro(zeit.offenerWertCents)
|
||||
return {
|
||||
key: k.projektId,
|
||||
projektId: k.projektId,
|
||||
name,
|
||||
meta,
|
||||
vereinbart: vereinbartText(vereinbart),
|
||||
berechnet: stripe ? euro(k.berechnetCents) : PLATZHALTER,
|
||||
bezahlt: stripe ? `bezahlt ${euro(k.bezahltCents)}` : '',
|
||||
offen,
|
||||
zeit: {
|
||||
stunden: stundenDe(zeit.minuten),
|
||||
wert: `Wert ${euro(zeit.wertCents)}`,
|
||||
abo: istZahl(zeit.aboMinuten) && zeit.aboMinuten > 0 ? `über Abo: ${stundenDe(zeit.aboMinuten)}` : '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Die feste Zeile „Ohne Projekt“ am Ende der Liste. */
|
||||
export function ohneProjektZeile(ohne, { stripeOk = true } = {}) {
|
||||
const stripe = stripeOk !== false
|
||||
const anzahl = istZahl(ohne?.anzahl) ? ohne.anzahl : 0
|
||||
const meta = anzahl <= 0 ? 'keine Rechnungen' : anzahl === 1 ? '1 Rechnung' : `${anzahl} Rechnungen`
|
||||
return {
|
||||
key: 'ohne-projekt',
|
||||
projektId: '',
|
||||
name: 'Ohne Projekt',
|
||||
meta,
|
||||
vereinbart: PLATZHALTER,
|
||||
berechnet: stripe ? euro(ohne?.berechnetCents ?? 0) : PLATZHALTER,
|
||||
bezahlt: stripe ? `bezahlt ${euro(ohne?.bezahltCents ?? 0)}` : '',
|
||||
offen: PLATZHALTER,
|
||||
zeit: { stunden: PLATZHALTER, wert: '', abo: '' },
|
||||
}
|
||||
}
|
||||
|
||||
/** Spalte „Art“ der Rechnungstabelle. */
|
||||
export function artLabel(art) {
|
||||
return typeof art === 'string' && eigen(ART_LABEL, art) ? ART_LABEL[art] : PLATZHALTER
|
||||
}
|
||||
|
||||
/** Spalte „Projekt“ der Rechnungstabelle. */
|
||||
export function projektSpalte(invoice) {
|
||||
return String(invoice?.projektName ?? '').trim() || PLATZHALTER
|
||||
}
|
||||
|
||||
function abgerechnetWert(abgerechnet, worksheetId) {
|
||||
if (!eigen(abgerechnet, worksheetId)) return null
|
||||
const wert = abgerechnet[worksheetId]
|
||||
return typeof wert === 'string' && wert.trim() ? wert.trim() : null
|
||||
}
|
||||
|
||||
/** Abzeichen am Arbeitsblatt: 'abgerechnet (WEBKLAR-0009)' oder 'abgerechnet (Entwurf)'; sonst null. */
|
||||
export function abgerechnetText(abgerechnet, worksheetId) {
|
||||
const wert = abgerechnetWert(abgerechnet, worksheetId)
|
||||
return wert ? `abgerechnet (${wert})` : null
|
||||
}
|
||||
|
||||
/** Warnung beim Ändern der Zeit eines abgerechneten Arbeitsblatts; sonst null. */
|
||||
export function abgerechnetWarnung(abgerechnet, worksheetId) {
|
||||
const wert = abgerechnetWert(abgerechnet, worksheetId)
|
||||
return wert ? `Dieses Arbeitsblatt ist abgerechnet (${wert}). Eine Änderung ändert die Rechnung nicht.` : null
|
||||
}
|
||||
|
||||
/** Eindeutige Ticket-Kennungen der Arbeitsblätter in Reihenfolge des ersten Auftretens, höchstens fünf. */
|
||||
export function ticketIdsOf(worksheets) {
|
||||
if (!Array.isArray(worksheets)) return []
|
||||
const ids = []
|
||||
for (const w of worksheets) {
|
||||
const id = String(w?.workorderId ?? '').trim()
|
||||
if (id && !ids.includes(id)) ids.push(id)
|
||||
if (ids.length >= 5) break
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
STRIPE_HINWEIS, KEINE_PROJEKTE_HINWEIS,
|
||||
stundenDe, satzText, ticketsText, stripeHinweis, vereinbartText, kontoZeile, ohneProjektZeile,
|
||||
artLabel, projektSpalte, abgerechnetText, abgerechnetWarnung, ticketIdsOf,
|
||||
} = await loadModule('src/lib/projektkontoView.js')
|
||||
|
||||
const konto = (over = {}) => ({
|
||||
projektId: 'p1',
|
||||
name: 'Website Relaunch',
|
||||
projektTyp: 'Website',
|
||||
tickets: [{ id: 't1', woid: '100131' }],
|
||||
zeitquelle: ['t1'],
|
||||
vereinbart: { festpreisCents: 120000, aufwand: null },
|
||||
berechnetCents: 50000,
|
||||
bezahltCents: 20000,
|
||||
offenCents: 70000,
|
||||
zeit: { minuten: 750, aboMinuten: 0, offeneMinuten: 750, satzCents: 10000, wertCents: 125000, offenerWertCents: 125000 },
|
||||
...over,
|
||||
})
|
||||
const AUFWAND = { festpreisCents: null, aufwand: { stundensatzCents: 10000, schaetzungMinuten: 600 } }
|
||||
|
||||
test('stundenDe: Dezimalstunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
assert.equal(stundenDe(750), '12,5 Std.')
|
||||
assert.equal(stundenDe(135), '2,25 Std.')
|
||||
assert.equal(stundenDe(60), '1 Std.')
|
||||
assert.equal(stundenDe(20), '0,33 Std.')
|
||||
assert.equal(stundenDe(0), '0 Std.')
|
||||
assert.equal(stundenDe(null), '0 Std.')
|
||||
assert.equal(stundenDe('abc'), '0 Std.')
|
||||
})
|
||||
|
||||
test('satzText: ganze Euro ohne Cent, sonst mit Cent', () => {
|
||||
assert.equal(satzText(10000), '100 €/Std.')
|
||||
assert.equal(satzText(8550), '85,50 €/Std.')
|
||||
assert.equal(satzText(null), '–')
|
||||
})
|
||||
|
||||
test('ticketsText: Ticketnummern mit Raute, ohne Nummer fällt der Eintrag weg', () => {
|
||||
assert.equal(ticketsText([{ id: 'a', woid: '100131' }]), '#100131')
|
||||
assert.equal(ticketsText([{ id: 'a', woid: '100131' }, { id: 'b', woid: '100140' }]), '#100131, #100140')
|
||||
assert.equal(ticketsText([{ id: 'a' }, null]), '')
|
||||
assert.equal(ticketsText(undefined), '')
|
||||
})
|
||||
|
||||
test('vereinbartText: Festpreis, Aufwand mit Schätzung, beides, keine AB', () => {
|
||||
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: null }), '1.200,00 €')
|
||||
assert.equal(vereinbartText(AUFWAND), 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: null, aufwand: { stundensatzCents: 8550, schaetzungMinuten: null } }), 'nach Aufwand, 85,50 €/Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: { stundensatzCents: 10000, schaetzungMinuten: null } }), '1.200,00 € und nach Aufwand, 100 €/Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: null, aufwand: null }), 'keine AB')
|
||||
assert.equal(vereinbartText(undefined), 'keine AB')
|
||||
})
|
||||
|
||||
test('kontoZeile: Festpreis-AB mit Berechnet, bezahlt, Noch offen und Zeit', () => {
|
||||
assert.deepEqual(kontoZeile(konto()), {
|
||||
key: 'p1',
|
||||
projektId: 'p1',
|
||||
name: 'Website Relaunch',
|
||||
meta: 'Website · #100131',
|
||||
vereinbart: '1.200,00 €',
|
||||
berechnet: '500,00 €',
|
||||
bezahlt: 'bezahlt 200,00 €',
|
||||
offen: '700,00 €',
|
||||
zeit: { stunden: '12,5 Std.', wert: 'Wert 1.250,00 €', abo: '' },
|
||||
})
|
||||
// ohne Art und ohne Ticket bleibt nur der Name
|
||||
assert.equal(kontoZeile(konto({ projektTyp: '', tickets: [] })).meta, '')
|
||||
})
|
||||
|
||||
test('kontoZeile: Aufwand-AB (Noch offen = Wert der offenen Zeit) und Abo-Zeit getrennt', () => {
|
||||
const z = kontoZeile(konto({
|
||||
vereinbart: AUFWAND,
|
||||
offenCents: null,
|
||||
zeit: { minuten: 300, aboMinuten: 90, offeneMinuten: 180, satzCents: 10000, wertCents: 50000, offenerWertCents: 30000 },
|
||||
}))
|
||||
assert.equal(z.vereinbart, 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||
assert.equal(z.offen, '300,00 €')
|
||||
assert.deepEqual(z.zeit, { stunden: '5 Std.', wert: 'Wert 500,00 €', abo: 'über Abo: 1,5 Std.' })
|
||||
})
|
||||
|
||||
test('kontoZeile: ohne AB steht „keine AB“ und Noch offen ist „–“', () => {
|
||||
const z = kontoZeile(konto({ vereinbart: { festpreisCents: null, aufwand: null }, offenCents: null }))
|
||||
assert.equal(z.vereinbart, 'keine AB')
|
||||
assert.equal(z.offen, '–')
|
||||
})
|
||||
|
||||
test('kontoZeile: Stripe nicht erreichbar zeigt „–“ statt Nullen, Zeit und Vereinbartes bleiben', () => {
|
||||
const z = kontoZeile(konto({ berechnetCents: 0, bezahltCents: 0, offenCents: null }), { stripeOk: false })
|
||||
assert.deepEqual([z.vereinbart, z.berechnet, z.bezahlt, z.offen], ['1.200,00 €', '–', '', '–'])
|
||||
assert.equal(z.zeit.wert, 'Wert 1.250,00 €')
|
||||
// Aufwand: der Wert der offenen Zeit braucht kein Stripe
|
||||
const a = kontoZeile(konto({ vereinbart: AUFWAND, offenCents: null }), { stripeOk: false })
|
||||
assert.equal(a.offen, '1.250,00 €')
|
||||
assert.equal(stripeHinweis(false), STRIPE_HINWEIS)
|
||||
assert.equal(stripeHinweis(true), '')
|
||||
assert.equal(stripeHinweis(undefined), '')
|
||||
assert.equal(STRIPE_HINWEIS, 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.')
|
||||
})
|
||||
|
||||
test('ohneProjektZeile: Rechnungen ohne Projekt, ohne Vereinbart und Zeit', () => {
|
||||
assert.deepEqual(ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }), {
|
||||
key: 'ohne-projekt',
|
||||
projektId: '',
|
||||
name: 'Ohne Projekt',
|
||||
meta: '3 Rechnungen',
|
||||
vereinbart: '–',
|
||||
berechnet: '300,00 €',
|
||||
bezahlt: 'bezahlt 100,00 €',
|
||||
offen: '–',
|
||||
zeit: { stunden: '–', wert: '', abo: '' },
|
||||
})
|
||||
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 1 }).meta, '1 Rechnung')
|
||||
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }).meta, 'keine Rechnungen')
|
||||
assert.equal(ohneProjektZeile(undefined).meta, 'keine Rechnungen')
|
||||
const s = ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }, { stripeOk: false })
|
||||
assert.deepEqual([s.berechnet, s.bezahlt], ['–', ''])
|
||||
})
|
||||
|
||||
test('Hinweis für Kunden ohne Projekte', () => {
|
||||
assert.equal(KEINE_PROJEKTE_HINWEIS, 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.')
|
||||
})
|
||||
|
||||
test('artLabel: Spalte Art der Rechnungstabelle', () => {
|
||||
assert.deepEqual(
|
||||
['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss', 'abo'].map((a) => artLabel(a)),
|
||||
['Ohne Projekt', 'Festbetrag', 'Aufwand', 'Anzahlung', 'Schlussrechnung', 'Abo']
|
||||
)
|
||||
assert.equal(artLabel(undefined), '–')
|
||||
assert.equal(artLabel('unbekannt'), '–')
|
||||
assert.equal(artLabel('constructor'), '–')
|
||||
})
|
||||
|
||||
test('projektSpalte: Projektname der Rechnung oder „–“', () => {
|
||||
assert.equal(projektSpalte({ projektName: ' Website Relaunch ' }), 'Website Relaunch')
|
||||
assert.equal(projektSpalte({ projektName: '' }), '–')
|
||||
assert.equal(projektSpalte({}), '–')
|
||||
assert.equal(projektSpalte(null), '–')
|
||||
})
|
||||
|
||||
test('abgerechnetText und abgerechnetWarnung: Rechnungsnummer oder Entwurf', () => {
|
||||
const map = { w1: 'WEBKLAR-0009', w2: 'Entwurf' }
|
||||
assert.equal(abgerechnetText(map, 'w1'), 'abgerechnet (WEBKLAR-0009)')
|
||||
assert.equal(abgerechnetText(map, 'w2'), 'abgerechnet (Entwurf)')
|
||||
assert.equal(abgerechnetText(map, 'w3'), null)
|
||||
assert.equal(abgerechnetText(map, 'constructor'), null)
|
||||
assert.equal(abgerechnetText(null, 'w1'), null)
|
||||
assert.equal(abgerechnetWarnung(map, 'w1'), 'Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert die Rechnung nicht.')
|
||||
assert.equal(abgerechnetWarnung(map, 'w2'), 'Dieses Arbeitsblatt ist abgerechnet (Entwurf). Eine Änderung ändert die Rechnung nicht.')
|
||||
assert.equal(abgerechnetWarnung(map, 'w3'), null)
|
||||
})
|
||||
|
||||
test('ticketIdsOf: eindeutige Ticket-Kennungen der Arbeitsblätter, höchstens fünf', () => {
|
||||
assert.deepEqual(ticketIdsOf([{ workorderId: 't1' }, { workorderId: 't1' }, { workorderId: 't2' }, {}, null]), ['t1', 't2'])
|
||||
assert.deepEqual(ticketIdsOf(undefined), [])
|
||||
assert.equal(ticketIdsOf(Array.from({ length: 7 }, (_, i) => ({ workorderId: `t${i}` }))).length, 5)
|
||||
})
|
||||
Reference in new issue
Block a user