/** * Archiv fuer Bestellungen, Retouren und Produkte im Admin-Bereich. * * Abgeschlossene Eintraege wandern nach einem Monat ohne Bearbeitung in den * Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist: * - Bestellungen: Status VERSENDET/ABGEHOLT/STORNIERT und keine offene Retoure * - Retouren: Status ANGENOMMEN/ABGELEHNT/ERSTATTET * - Produkte: Bestand 0 — Inventory.updatedAt ist dabei der Zeitpunkt der * letzten Bestandsaenderung, also der Moment, seit dem der Bestand 0 ist. * Nur Admin-Sicht: der Shop zeigt ein archiviertes Produkt unveraendert. * * Die Automatik laeuft beim Oeffnen der jeweiligen Admin-Liste (kein * CronJob) und holt automatisch archivierte Eintraege zurueck, sobald sie * wieder offen sind (Status geaendert, neue Retoure, Bestand aufgefuellt). * Von Hand archivierte Eintraege (archivedManually) fasst sie nicht an. * "Wiederherstellen" per Hand setzt ueber @updatedAt die Bearbeitungszeit neu * — der Eintrag bleibt dadurch einen weiteren Monat aktiv, statt beim * naechsten Seitenaufruf sofort wieder zu verschwinden. * * Die Datenbank wird hereingereicht statt importiert, damit sich die Regeln * ohne DATABASE_URL testen lassen (siehe archive.test.ts). */ import type { Prisma } from "@/generated/prisma/client"; export const ARCHIVE_AFTER_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; export const ORDER_DONE_STATUSES = ["VERSENDET", "ABGEHOLT", "STORNIERT"] as const; export const RETURN_DONE_STATUSES = ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] as const; export type ArchiveKind = "orders" | "returns" | "products"; /** URL-Parameter fuer den Archiv-Tab, z.B. /admin/bestellungen?ansicht=archiv */ export const ARCHIVE_VIEW_PARAM = "ansicht"; export const ARCHIVE_VIEW_VALUE = "archiv"; export function isArchiveView(value: unknown): boolean { return value === ARCHIVE_VIEW_VALUE; } export function archiveCutoff(now: Date): Date { return new Date(now.getTime() - ARCHIVE_AFTER_DAYS * DAY_MS); } export function orderArchiveWhere(cutoff: Date) { return { archivedAt: null, status: { in: [...ORDER_DONE_STATUSES] }, updatedAt: { lte: cutoff }, // Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert // Order.updatedAt nicht und muss deshalb eigens abgewartet werden. OR: [ { returnRequest: { is: null } }, { returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } }, ], } satisfies Prisma.OrderWhereInput; } export function orderUnarchiveWhere() { return { archivedAt: { not: null }, archivedManually: false, OR: [ { status: { notIn: [...ORDER_DONE_STATUSES] } }, { returnRequest: { is: { status: "ANGEFRAGT" } } }, ], } satisfies Prisma.OrderWhereInput; } export function returnArchiveWhere(cutoff: Date) { return { archivedAt: null, status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff }, } satisfies Prisma.ReturnRequestWhereInput; } export function returnUnarchiveWhere() { return { archivedAt: { not: null }, archivedManually: false, status: { notIn: [...RETURN_DONE_STATUSES] }, } satisfies Prisma.ReturnRequestWhereInput; } export function productArchiveWhere(cutoff: Date) { return { archivedAt: null, updatedAt: { lte: cutoff }, OR: [ // Ohne Inventory-Zeile gilt der Bestand als 0 (so zeigt ihn auch die Liste). { inventory: { is: null } }, { inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } }, ], } satisfies Prisma.ProductWhereInput; } export function productUnarchiveWhere() { return { archivedAt: { not: null }, archivedManually: false, inventory: { is: { quantity: { gt: 0 } } }, } satisfies Prisma.ProductWhereInput; } interface UpdateManyDelegate { updateMany(args: { where: Where; data: { archivedAt: Date | null; archivedManually?: boolean } }): Promise<{ count: number; }>; } /** Der Ausschnitt des Prisma-Clients, den die Automatik braucht. */ export interface ArchiveDb { order: UpdateManyDelegate; returnRequest: UpdateManyDelegate; product: UpdateManyDelegate; } export interface AutoArchiveResult { archived: number; restored: number; } export async function runAutoArchive(db: ArchiveDb, kind: ArchiveKind, now: Date = new Date()): Promise { const cutoff = archiveCutoff(now); const archiveData = { archivedAt: now, archivedManually: false }; const restoreData = { archivedAt: null }; // Jeweils ein einzelnes UPDATE ... WHERE: die Bedingung wird atomar gegen // den aktuellen Zeilenstand geprueft, ein gleichzeitiger Statuswechsel kann // also keinen gerade wieder offenen Eintrag ins Archiv schieben. switch (kind) { case "orders": { const archived = await db.order.updateMany({ where: orderArchiveWhere(cutoff), data: archiveData }); const restored = await db.order.updateMany({ where: orderUnarchiveWhere(), data: restoreData }); return { archived: archived.count, restored: restored.count }; } case "returns": { const archived = await db.returnRequest.updateMany({ where: returnArchiveWhere(cutoff), data: archiveData }); const restored = await db.returnRequest.updateMany({ where: returnUnarchiveWhere(), data: restoreData }); return { archived: archived.count, restored: restored.count }; } case "products": { const archived = await db.product.updateMany({ where: productArchiveWhere(cutoff), data: archiveData }); const restored = await db.product.updateMany({ where: productUnarchiveWhere(), data: restoreData }); return { archived: archived.count, restored: restored.count }; } } }