// Per-PERIOD lexoffice CSV export state for the settlement pages (Amazon // settlement periods / eBay payouts) — the client of // server/api/accounting/settlement-exports (data/settlement-exports.db). // // The C_Order marker (isLexofficeCsvExported) only covers matched order rows. // Fees, Ads invoices, adjustments, unmatched orders and the payout line have no // order to carry a marker, yet they MUST go to the lexoffice clearing account // for the period to reconcile. This log records per period whether its // positions (complete or filtered) and its payout row already went out, so // - the period list shows a clearance badge (exportiert / teilweise / offen), // - every grid row gets a CSV status (order marker where one exists, period // state otherwise) and the "noch nicht als CSV exportiert" filter is honest, // - the export warns before a period is sent to lexoffice twice. import type { LexofficeExportVariant } from '~/composables/useLexofficeSettlementExport' export type SettlementMarketplace = 'amazon' | 'ebay' export interface PeriodExportRecord { periodId: string periodLabel: string payoutDate: string amount: number | null currency: string positionsExportedAt: string | null positionsComplete: boolean positionsRowCount: number payoutExportedAt: string | null balanced: boolean | null lastVariant: string | null lastFile: string | null lastUser: string | null updatedAt: string } export type PeriodExportState = 'none' | 'partial' | 'complete' export interface PeriodExportBadge { state: PeriodExportState label: string color: 'success' | 'warning' | 'neutral' title: string } // One period as handed to record(): the page maps its own payload into this. export interface PeriodExportEntry { periodId: string periodLabel?: string payoutDate?: string amount?: number | null currency?: string positions: boolean complete: boolean rowCount?: number payout: boolean net?: number | null } const fmtDate = (s?: string | null) => { if (!s) return '' const d = new Date(s) return isNaN(d.getTime()) ? String(s) : d.toLocaleDateString('de-DE') } export const useSettlementExportLog = (marketplace: SettlementMarketplace, orderSourceId: Ref) => { const periods = ref>({}) const available = ref(true) const loading = ref(false) const busy = ref(false) // Bumps on every change so pages can re-decorate their rows in a watch. const version = ref(0) const headers = () => useRequestHeaders(['cookie']) const sourceId = () => Number(orderSourceId.value || 0) const load = async () => { periods.value = {} version.value++ if (!sourceId()) return loading.value = true try { const res: any = await $fetch('/api/accounting/settlement-exports', { headers: headers(), query: { marketplace, orderSourceId: sourceId() } }) if (Number(res?.status) === 200) { available.value = res.available !== false periods.value = res.periods || {} } } catch (e) { // Fail-soft: without the log the pages still work, just without badges. available.value = false } finally { loading.value = false version.value++ } } const merge = (updated: Record | undefined, removed: string[] = []) => { const next = { ...periods.value } for (const id of removed) delete next[id] for (const [id, rec] of Object.entries(updated || {})) next[id] = rec periods.value = next version.value++ } // Records one produced CSV file. Returns the server message on failure, '' on success. const record = async (variant: LexofficeExportVariant, entries: PeriodExportEntry[], meta: { fileName?: string; rowCount?: number } = {}): Promise => { if (!sourceId() || !entries.length) return '' busy.value = true try { const res: any = await $fetch('/api/accounting/settlement-exports', { method: 'POST', headers: headers(), body: { marketplace, orderSourceId: sourceId(), variant, fileName: meta.fileName, rowCount: meta.rowCount, periods: entries } }) if (Number(res?.status) !== 200) { if (Number(res?.status) === 503) available.value = false return res?.message || 'Export-Protokoll konnte nicht gespeichert werden' } merge(res.periods) return '' } catch (e: any) { return e?.data?.message || e?.message || 'Export-Protokoll konnte nicht gespeichert werden' } finally { busy.value = false } } const reset = async (periodIds: string[], scope: 'all' | 'positions' | 'payout' = 'all'): Promise => { const ids = [...new Set(periodIds.filter(Boolean))] if (!sourceId() || !ids.length) return '' busy.value = true try { const res: any = await $fetch('/api/accounting/settlement-exports/reset', { method: 'POST', headers: headers(), body: { marketplace, orderSourceId: sourceId(), periodIds: ids, scope } }) if (Number(res?.status) !== 200) return res?.message || 'Zurücksetzen fehlgeschlagen' merge(res.periods, scope === 'all' ? ids : []) return '' } catch (e: any) { return e?.data?.message || e?.message || 'Zurücksetzen fehlgeschlagen' } finally { busy.value = false } } const get = (periodId: string | null | undefined): PeriodExportRecord | null => (periodId && periods.value[periodId]) || null // Clearance state of a period. `needsPayout` = the period has a payout line // to export at all (false for Failed / amount-0 periods). const stateOf = (periodId: string, needsPayout = true): PeriodExportState => { const r = get(periodId) if (!r) return 'none' const positionsDone = !!r.positionsExportedAt && r.positionsComplete const payoutDone = !needsPayout || !!r.payoutExportedAt if (positionsDone && payoutDone) return 'complete' if (r.positionsExportedAt || r.payoutExportedAt) return 'partial' return 'none' } const badgeOf = (periodId: string, needsPayout = true, exportable = true): PeriodExportBadge | null => { const r = get(periodId) const state = stateOf(periodId, needsPayout) if (state === 'none') { if (!exportable) return null return { state, label: 'offen', color: 'neutral', title: 'Noch nicht als Lexoffice-CSV exportiert' } } const parts: string[] = [] if (r?.positionsExportedAt) parts.push(`Positionen ${r.positionsComplete ? 'vollständig' : 'gefiltert (unvollständig)'} am ${fmtDate(r.positionsExportedAt)}${r.positionsRowCount ? ` (${r.positionsRowCount} Zeilen)` : ''}`) else parts.push('Positionen noch nicht exportiert') if (needsPayout) { parts.push(r?.payoutExportedAt ? `Auszahlungszeile am ${fmtDate(r.payoutExportedAt)}` : 'Auszahlungszeile noch nicht exportiert') } if (r?.balanced === true) parts.push('Saldo 0,00 ✓') else if (r?.balanced === false) parts.push('Saldo ≠ 0,00 — bitte prüfen') if (r?.lastFile) parts.push(`Datei ${r.lastFile}`) if (r?.lastUser) parts.push(`von ${r.lastUser}`) const title = parts.join(' · ') if (state === 'complete') { const when = fmtDate(r?.payoutExportedAt || r?.positionsExportedAt) return { state, label: `exportiert${when ? ' ' + when : ''}`, color: r?.balanced === false ? 'warning' : 'success', title } } return { state, label: 'teilweise exportiert', color: 'warning', title } } // CSV status of one grid row that has NO order marker of its own. const rowStatus = (periodId: string | null | undefined, isPayoutRow: boolean) => { const r = get(periodId) if (!r) return { exported: false, date: '', complete: false } if (isPayoutRow) return { exported: !!r.payoutExportedAt, date: r.payoutExportedAt || '', complete: true } return { exported: !!r.positionsExportedAt, date: r.positionsExportedAt || '', complete: r.positionsComplete } } // Periods (of the given ids) that the given variant would send to lexoffice a // second time — for the duplicate-import confirm before an export starts. const alreadyExported = (periodIds: string[], variant: LexofficeExportVariant) => periodIds.filter(id => { const r = get(id) if (!r) return false const positions = variant !== 'payouts' && !!r.positionsExportedAt const payout = variant !== 'positions' && !!r.payoutExportedAt return positions || payout }) return { periods, available, loading, busy, version, load, record, reset, get, stateOf, badgeOf, rowStatus, alreadyExported } }