Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d5a565e9f
95 files changed
+12879
-546
No files matched your search
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user