Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
Abgeschlossene Eintraege wandern automatisch in einen Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist: - Bestellungen: versendet/abgeholt/storniert, keine offene Retoure, seit einem Monat nicht bearbeitet - Retouren: angenommen/abgelehnt/erstattet, seit einem Monat nicht bearbeitet - Produkte: seit einem Monat Bestand 0 (nur Admin-Sicht, Shop unveraendert) Die Automatik laeuft beim Oeffnen der jeweiligen Liste (hoechstens alle 5 Minuten) und holt Eintraege zurueck, sobald sie wieder offen sind. Zusaetzlich "Archivieren"/"Wiederherstellen" per Hand; von Hand archivierte Eintraege fasst die Automatik nicht an. Alle Verschiebungen landen im Audit-Log. Neue Felder archivedAt/archivedManually samt Index an orders, return_requests und products (Migration 20260925010000_archive_fields). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
150 lines
5.7 KiB
TypeScript
150 lines
5.7 KiB
TypeScript
/**
|
|
* 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<Where> {
|
|
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<Prisma.OrderWhereInput>;
|
|
returnRequest: UpdateManyDelegate<Prisma.ReturnRequestWhereInput>;
|
|
product: UpdateManyDelegate<Prisma.ProductWhereInput>;
|
|
}
|
|
|
|
export interface AutoArchiveResult {
|
|
archived: number;
|
|
restored: number;
|
|
}
|
|
|
|
export async function runAutoArchive(db: ArchiveDb, kind: ArchiveKind, now: Date = new Date()): Promise<AutoArchiveResult> {
|
|
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 };
|
|
}
|
|
}
|
|
}
|