Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+98 -12
View File
@@ -1,24 +1,31 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { aboMemo } from '../../services/aboStatus.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
import { withLocks } from '../../services/keyedLock.js'
import {
FEATURE_KEYS,
FEATURE_LABELS,
adminActivate,
assertOwnProject,
customerOf,
disable,
freischaltenNachAbnahme,
getFeatureDoc,
listCustomerFeatureDocs,
listCustomerFeatures,
ticketNachlegen,
} from '../../services/siteFeatures.js'
import { featureHaken } from '../../services/featureRegistry.js'
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
import { einschaltenZeiten } from '../../services/zeiten/einschalten.js'
import { ersatzHtml } from '../../services/zeiten/ersatz.js'
import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
import { freischaltHindernis } from '../../services/termine/freischaltung.js'
import { ladeStand } from '../../services/termine/kontext.js'
/**
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
@@ -42,13 +49,13 @@ export function composeAdminNote(previous, { by, action, note }, now = new Date(
return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry
}
async function ticketExists(ticketId) {
if (!ticketId) return false
/** Das Ticket oder null (keine ticketId, Ticket geloescht); jeder andere Lesefehler wird weitergereicht. */
async function readTicket(ticketId) {
if (!ticketId) return null
try {
await getDocument(config.collections.workorders, ticketId)
return true
return await getDocument(config.collections.workorders, ticketId)
} catch (err) {
if (err.status === 404) return false
if (err.status === 404) return null
throw err
}
}
@@ -76,16 +83,33 @@ async function zeitenZusatz(doc) {
}
}
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
/** Name des Kunden fuer die Anleitung; wie im Ticket (termineTicketDetails) die Kunden-ID, wenn er fehlt oder nicht lesbar ist (nur Kosmetik, kein Fehler). */
async function customerName(customerId) {
try {
return (await customerOf(customerId))?.name || customerId
} catch (err) {
console.error(`[funktionen-admin] Kunde ${customerId} fuer die Anleitung nicht lesbar:`, err.message)
return customerId
}
}
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet.
// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert
// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
async function adminView(customerId) {
const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs })
const abo = aboMemo()
for (const p of projects) {
for (const key of FEATURE_KEYS) {
const view = p.features[key]
const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key)
view.adminNote = doc?.adminNote || ''
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
view.hindernis = key === 'termine' && doc?.state === 'setup' ? await freischaltHindernis(doc, { abo }) : null
if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc))
}
}
@@ -129,6 +153,55 @@ funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => {
}
})
/**
* Einbauanleitung des Terminplaners eines Projekts fuer das WOMS-Panel (Task D8): die volle Fassung des Generators ohne `kurz` (die geht nur
* ins Ticket), nur lesend. Gilt in jedem Zustand und bei jedem Modus (kein TERMINE_ENABLED-Gate) und ist kein Kundenzugriff (kein
* customerViewLog, kein Pfad im Kundenrouter). Rechnung und Preis kommen live aus dem Dokument.
* state: Zustand des Dokuments. ticketWoid, ticketVersion: Ticket des Dokuments und der Stand, auf dem es beruht (null ohne Ticket oder
* ohne Standzeile). hindernis: nur in setup, was die Freischaltung noch hindert (siehe adminView), sonst null.
* Ein gespeicherter Stand, der neuer ist als das Ticket, haengt eine Warnung ans Ende von warnungen (driftWarnung); ein geloeschtes
* Ticket (404) ist keine Warnung und kein Fehler, jeder andere Lesefehler ist einer (keine stille Luecke).
*/
funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) => {
try {
const { customerId } = req.params
const { projectId } = req.query
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
const project = await assertOwnProject(customerId, projectId)
const doc = await getFeatureDoc(customerId, projectId, 'termine')
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.termine.keine}`), { status: 409 })
}
const stand = await ladeStand(doc.$id)
const ticket = await readTicket(doc.ticketId)
const ticketVersion = ticket ? ticketStand(ticket.details) : null
const { stand: anleitungsStand, warnungen, abschnitte, text } = einbauanleitung({
feature: doc,
project,
einstellung: stand?.einstellung ?? null,
fragebogen: stand?.fragebogen ?? null,
version: stand?.version ?? 0,
updatedAt: stand?.updatedAt ?? null,
kunde: { name: await customerName(customerId) },
now: new Date(),
})
const drift = driftWarnung(ticketVersion, anleitungsStand.version)
// Felder einzeln benannt: `kurz` (nur fuers Ticket) und alles, was der Generator spaeter dazubekommt, geht nie unbesehen nach aussen
res.json({
stand: anleitungsStand,
warnungen: drift ? [...warnungen, drift] : warnungen,
abschnitte,
text,
state: doc.state,
ticketWoid: ticket?.woid || null,
ticketVersion,
hindernis: doc.state === 'setup' ? await freischaltHindernis(doc) : null,
})
} catch (err) {
sendError(res, err)
}
})
/**
* Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team'
* und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
@@ -162,31 +235,44 @@ async function zeitenAbschalten({ customerId, projectId, by, note }) {
return nachAbschaltung(doc)
}
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu, /zeiten ueber den Gratis-Pfad; jeder andere Schluessel -> 404
// Je Funktion (/news, /termine, /zeiten; jeder andere Schluessel -> 404): activate (ohne Zahlung; bei /zeiten ueber den Gratis-Pfad),
// disable, abnahme (Freischalten nach Abnahme, in setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket
// nachlegen, in setup). abnahme und ticket gibt es nur fuer Funktionen mit ticketFreischaltung im Register.
const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket']
const TICKET_ACTIONS = ['abnahme', 'ticket']
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
try {
const { customerId, key } = req.params
if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' })
const { projectId, action } = req.body || {}
const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : ''
if (action !== 'activate' && action !== 'disable') {
if (!ACTIONS.includes(action)) {
return res.status(400).json({ error: 'Unbekannte Aktion' })
}
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Projekt fehlt' })
}
if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) {
return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' })
}
await assertOwnProject(customerId, projectId)
const by = adminName(req)
let ersatzTicket
if (key === 'zeiten') {
if (action === 'activate') await zeitenAktivieren({ customerId, projectId, by, note })
else ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
if (key === 'zeiten' && action === 'activate') {
await zeitenAktivieren({ customerId, projectId, by, note })
} else if (key === 'zeiten' && action === 'disable') {
ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
} else {
await withLocks([`feature-admin:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, key)
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
if (action === 'activate') {
await adminActivate(customerId, projectId, key, { by, adminNote })
} else if (action === 'abnahme') {
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
} else if (action === 'ticket') {
await ticketNachlegen({ customerId, projectId, key, by, adminNote })
} else {
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })