feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
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]>
This commit is contained in:
1 parent
01eaca39dc
commit
fb9c2353c6
14 files changed
+772
-54
No files matched your search
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Fuehrt die Archiv-Automatik (lib/archive.ts) beim Oeffnen einer Admin-Liste
|
||||
* aus. Bewusst KEINE Server Action ("use server" fehlt absichtlich): sonst
|
||||
* waere das ein eigener, von aussen aufrufbarer POST-Endpunkt.
|
||||
*/
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { runAutoArchive, type ArchiveKind } from "@/lib/archive";
|
||||
|
||||
/**
|
||||
* Die Regeln haengen an "ein Monat" — minutengenau muss das nicht sein. Die
|
||||
* Sperre haelt die zwei UPDATEs aus jedem einzelnen Seitenaufruf heraus
|
||||
* (Filtern, Blaettern, Zurueck nach einer Aktion).
|
||||
*/
|
||||
const MIN_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const lastRun = new Map<ArchiveKind, number>();
|
||||
|
||||
const ENTITY_TYPE: Record<ArchiveKind, string> = {
|
||||
orders: "Order",
|
||||
returns: "ReturnRequest",
|
||||
products: "Product",
|
||||
};
|
||||
|
||||
export async function ensureAutoArchived(kind: ArchiveKind): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now - (lastRun.get(kind) ?? 0) < MIN_INTERVAL_MS) return;
|
||||
|
||||
// Das Admin-Layout prueft bereits; hier zusaetzlich, weil diese Funktion
|
||||
// schreibt und nie ohne angemeldeten Admin laufen soll.
|
||||
const admin = await getCurrentAdmin();
|
||||
if (!admin) return;
|
||||
lastRun.set(kind, now);
|
||||
|
||||
try {
|
||||
const result = await runAutoArchive(prisma, kind, new Date(now));
|
||||
if (result.archived > 0 || result.restored > 0) {
|
||||
await logAdminAction(admin, {
|
||||
action: "archive.auto",
|
||||
entityType: ENTITY_TYPE[kind],
|
||||
detail: `automatisch: ${result.archived} archiviert, ${result.restored} wiederhergestellt`,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Die Liste soll trotzdem erscheinen; beim naechsten Aufruf nach Ablauf
|
||||
// der Sperre wird es erneut versucht.
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
console.error(`[archive] Automatik fuer "${kind}" fehlgeschlagen: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
ARCHIVE_AFTER_DAYS,
|
||||
archiveCutoff,
|
||||
orderArchiveWhere,
|
||||
orderUnarchiveWhere,
|
||||
productArchiveWhere,
|
||||
productUnarchiveWhere,
|
||||
returnArchiveWhere,
|
||||
returnUnarchiveWhere,
|
||||
runAutoArchive,
|
||||
type ArchiveDb,
|
||||
} from "./archive";
|
||||
|
||||
const NOW = new Date("2026-09-25T12:00:00Z");
|
||||
|
||||
function fakeDb(counts: { archived: number; restored: number }) {
|
||||
const model = () => ({
|
||||
updateMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ count: counts.archived })
|
||||
.mockResolvedValueOnce({ count: counts.restored }),
|
||||
});
|
||||
return { order: model(), returnRequest: model(), product: model() };
|
||||
}
|
||||
|
||||
describe("archiveCutoff", () => {
|
||||
it("liegt genau einen Monat (30 Tage) vor jetzt", () => {
|
||||
expect(ARCHIVE_AFTER_DAYS).toBe(30);
|
||||
expect(archiveCutoff(NOW).toISOString()).toBe("2026-08-26T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderArchiveWhere", () => {
|
||||
const where = orderArchiveWhere(archiveCutoff(NOW));
|
||||
|
||||
it("archiviert nur versendete, abgeholte und stornierte Bestellungen", () => {
|
||||
expect(where.status).toEqual({ in: ["VERSENDET", "ABGEHOLT", "STORNIERT"] });
|
||||
});
|
||||
|
||||
it("verlangt, dass die letzte Bearbeitung mehr als einen Monat zurueckliegt", () => {
|
||||
expect(where.updatedAt).toEqual({ lte: archiveCutoff(NOW) });
|
||||
expect(where.archivedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("laesst Bestellungen mit offener oder frisch bearbeiteter Retoure aktiv", () => {
|
||||
expect(where.OR).toEqual([
|
||||
{ returnRequest: { is: null } },
|
||||
{
|
||||
returnRequest: {
|
||||
is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderUnarchiveWhere", () => {
|
||||
it("holt nur automatisch archivierte Bestellungen zurueck, wenn Status oder Retoure wieder offen sind", () => {
|
||||
expect(orderUnarchiveWhere()).toEqual({
|
||||
archivedAt: { not: null },
|
||||
archivedManually: false,
|
||||
OR: [
|
||||
{ status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
||||
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("returnArchiveWhere / returnUnarchiveWhere", () => {
|
||||
it("archiviert angenommene, abgelehnte und erstattete Retouren nach einem Monat ohne Bearbeitung", () => {
|
||||
expect(returnArchiveWhere(archiveCutoff(NOW))).toEqual({
|
||||
archivedAt: null,
|
||||
status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] },
|
||||
updatedAt: { lte: archiveCutoff(NOW) },
|
||||
});
|
||||
});
|
||||
|
||||
it("holt eine automatisch archivierte Retoure zurueck, sobald sie wieder offen ist", () => {
|
||||
expect(returnUnarchiveWhere()).toEqual({
|
||||
archivedAt: { not: null },
|
||||
archivedManually: false,
|
||||
status: { notIn: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("productArchiveWhere / productUnarchiveWhere", () => {
|
||||
it("archiviert Produkte, die seit ueber einem Monat Bestand 0 haben und nicht bearbeitet wurden", () => {
|
||||
const cutoff = archiveCutoff(NOW);
|
||||
expect(productArchiveWhere(cutoff)).toEqual({
|
||||
archivedAt: null,
|
||||
updatedAt: { lte: cutoff },
|
||||
OR: [{ inventory: { is: null } }, { inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } }],
|
||||
});
|
||||
});
|
||||
|
||||
it("holt ein automatisch archiviertes Produkt zurueck, sobald wieder Bestand da ist", () => {
|
||||
expect(productUnarchiveWhere()).toEqual({
|
||||
archivedAt: { not: null },
|
||||
archivedManually: false,
|
||||
inventory: { is: { quantity: { gt: 0 } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAutoArchive", () => {
|
||||
it("archiviert faellige Eintraege und holt wieder offene zurueck, jeweils nur fuer die angefragte Liste", async () => {
|
||||
const db = fakeDb({ archived: 3, restored: 1 });
|
||||
|
||||
const result = await runAutoArchive(db as unknown as ArchiveDb, "orders", NOW);
|
||||
|
||||
expect(result).toEqual({ archived: 3, restored: 1 });
|
||||
expect(db.order.updateMany).toHaveBeenNthCalledWith(1, {
|
||||
where: orderArchiveWhere(archiveCutoff(NOW)),
|
||||
data: { archivedAt: NOW, archivedManually: false },
|
||||
});
|
||||
expect(db.order.updateMany).toHaveBeenNthCalledWith(2, {
|
||||
where: orderUnarchiveWhere(),
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
expect(db.product.updateMany).not.toHaveBeenCalled();
|
||||
expect(db.returnRequest.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("nutzt fuer Retouren und Produkte die jeweils eigenen Regeln", async () => {
|
||||
const db = fakeDb({ archived: 0, restored: 0 });
|
||||
|
||||
await runAutoArchive(db as unknown as ArchiveDb, "returns", NOW);
|
||||
await runAutoArchive(db as unknown as ArchiveDb, "products", NOW);
|
||||
|
||||
expect(db.returnRequest.updateMany.mock.calls[0][0].where).toEqual(returnArchiveWhere(archiveCutoff(NOW)));
|
||||
expect(db.product.updateMany.mock.calls[0][0].where).toEqual(productArchiveWhere(archiveCutoff(NOW)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user