Sistemato magazzino

This commit is contained in:
Lorenzo Sanesi
2026-08-01 03:12:23 +02:00
parent de921abcd6
commit 0fdf395733
128 changed files with 9307 additions and 3076 deletions
@@ -4,6 +4,18 @@ CREATE TYPE "stato_materiale" AS ENUM ('proposto', 'approvato', 'rifiutato');
-- CreateEnum
CREATE TYPE "stato_magazzino_voce" AS ENUM ('buono', 'da_riparare', 'mancante');
-- CreateEnum
CREATE TYPE "stato_lista" AS ENUM ('bozza', 'privato', 'gruppo', 'pubblico');
-- CreateEnum
CREATE TYPE "stato_moderazione_lista" AS ENUM ('proposto', 'approvato', 'rifiutato');
-- CreateEnum
CREATE TYPE "stato_categoria" AS ENUM ('confermata', 'da_approvare');
-- CreateEnum
CREATE TYPE "tipo_notifica" AS ENUM ('MATERIALE_PROPOSTO', 'CATEGORIA_PROPOSTA', 'TIPO_EVENTO_PROPOSTO', 'LISTA_PROPOSTA');
-- CreateTable
CREATE TABLE "materiale" (
"id" TEXT NOT NULL,
@@ -21,35 +33,35 @@ CREATE TABLE "materiale" (
CREATE TABLE "tipo_evento" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"stato" "stato_categoria" NOT NULL DEFAULT 'confermata',
"creato_da_org_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tipo_evento_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello" (
CREATE TABLE "categoria" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"tipo_evento_id" TEXT NOT NULL,
"pubblica" BOOLEAN NOT NULL DEFAULT true,
"stato" "stato_categoria" NOT NULL DEFAULT 'confermata',
"creato_da_org_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lista_modello_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello_voce" (
"lista_modello_id" TEXT NOT NULL,
"materiale_id" TEXT NOT NULL,
"quantita" INTEGER NOT NULL,
CONSTRAINT "lista_modello_voce_pkey" PRIMARY KEY ("lista_modello_id","materiale_id")
CONSTRAINT "categoria_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"org_id" TEXT,
"creata_da_user_id" TEXT,
"stato" "stato_lista" NOT NULL DEFAULT 'bozza',
"creata_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"stato_moderazione" "stato_moderazione_lista",
"tipo_evento_id" TEXT,
"parent_id" TEXT,
CONSTRAINT "lista_pkey" PRIMARY KEY ("id")
);
@@ -63,6 +75,14 @@ CREATE TABLE "lista_voce" (
CONSTRAINT "lista_voce_pkey" PRIMARY KEY ("lista_id","materiale_id")
);
-- CreateTable
CREATE TABLE "lista_sotto_lista" (
"lista_id" TEXT NOT NULL,
"sotto_lista_id" TEXT NOT NULL,
CONSTRAINT "lista_sotto_lista_pkey" PRIMARY KEY ("lista_id","sotto_lista_id")
);
-- CreateTable
CREATE TABLE "magazzino_voce" (
"id" TEXT NOT NULL,
@@ -72,10 +92,22 @@ CREATE TABLE "magazzino_voce" (
"stato" "stato_magazzino_voce" NOT NULL,
"posizione" TEXT,
"note" TEXT,
"gruppo_id" TEXT,
CONSTRAINT "magazzino_voce_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "gruppo_magazzino" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"parent_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gruppo_magazzino_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evento" (
"id" TEXT NOT NULL,
@@ -97,14 +129,41 @@ CREATE TABLE "evento_check" (
CONSTRAINT "evento_check_pkey" PRIMARY KEY ("evento_id","materiale_id")
);
-- AddForeignKey
ALTER TABLE "lista_modello" ADD CONSTRAINT "lista_modello_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "notifica" (
"id" SERIAL NOT NULL,
"tipo" "tipo_notifica" NOT NULL,
"messaggio" TEXT NOT NULL,
"link" TEXT,
"letta" BOOLEAN NOT NULL DEFAULT false,
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifica_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "lista_stato_stato_moderazione_idx" ON "lista"("stato", "stato_moderazione");
-- CreateIndex
CREATE INDEX "lista_tipo_evento_id_idx" ON "lista"("tipo_evento_id");
-- CreateIndex
CREATE INDEX "lista_parent_id_idx" ON "lista"("parent_id");
-- CreateIndex
CREATE INDEX "gruppo_magazzino_org_id_idx" ON "gruppo_magazzino"("org_id");
-- CreateIndex
CREATE INDEX "gruppo_magazzino_parent_id_idx" ON "gruppo_magazzino"("parent_id");
-- CreateIndex
CREATE INDEX "magazzino_voce_gruppo_id_idx" ON "magazzino_voce"("gruppo_id");
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_lista_modello_id_fkey" FOREIGN KEY ("lista_modello_id") REFERENCES "lista_modello"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "lista" ADD CONSTRAINT "lista_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "lista" ADD CONSTRAINT "lista_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "lista"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -112,9 +171,21 @@ ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY (
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_sotto_lista_id_fkey" FOREIGN KEY ("sotto_lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_gruppo_id_fkey" FOREIGN KEY ("gruppo_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "gruppo_magazzino" ADD CONSTRAINT "gruppo_magazzino_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evento" ADD CONSTRAINT "evento_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+143 -36
View File
@@ -23,6 +23,68 @@ enum StatoMagazzinoVoce {
@@map("stato_magazzino_voce")
}
enum StatoLista {
bozza
privato
gruppo
pubblico
@@map("stato_lista")
}
enum StatoModerazioneLista {
proposto
approvato
rifiutato
@@map("stato_moderazione_lista")
}
enum StatoCategoria {
confermata
da_approvare
@@map("stato_categoria")
}
enum TipoNotifica {
MATERIALE_PROPOSTO
CATEGORIA_PROPOSTA
TIPO_EVENTO_PROPOSTO
LISTA_PROPOSTA
@@map("tipo_notifica")
}
// Coda di notifiche di moderazione (nuove proposte di materiale/categoria/tipo
// evento/lista in attesa di revisione): visibile solo a chi ha ruolo 'moderatore',
// non ci sono ancora notifiche personali nel dominio magazzino.
model Notifica {
id Int @id @default(autoincrement())
tipo TipoNotifica
messaggio String
link String?
letta Boolean @default(false)
dataCreazione DateTime @default(now()) @map("data_creazione")
@@map("notifica")
}
// Tassonomia delle categorie di materiale, gestita dal moderatore (CRUD diretto,
// sempre creata "confermata") con un flusso di proposta aperto a qualsiasi org
// autenticata (crea in "da_approvare", poi approvata/rifiutata dal moderatore).
// Materiale.categoria resta per ora un campo stringa libero, non collegato a questa
// tabella: introdurla come FK è un passo successivo, fuori scope qui.
model Categoria {
id String @id @default(uuid())
nome String
stato StatoCategoria @default(confermata)
creatoDaOrgId String? @map("creato_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
@@map("categoria")
}
model Materiale {
id String @id @default(uuid())
nome String
@@ -32,56 +94,67 @@ model Materiale {
propostoDaOrgId String @map("proposto_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
listaModelloVoci ListaModelloVoce[]
listaVoci ListaVoce[]
magazzinoVoci MagazzinoVoce[]
eventoCheck EventoCheck[]
listaVoci ListaVoce[]
magazzinoVoci MagazzinoVoce[]
eventoCheck EventoCheck[]
@@map("materiale")
}
model TipoEvento {
id String @id @default(uuid())
nome String
id String @id @default(uuid())
nome String
// Stessa semantica/enum di Categoria.stato: un tipo evento creato dal moderatore
// nasce 'confermata', uno proposto da un'organizzazione nasce 'da_approvare'.
stato StatoCategoria @default(confermata)
creatoDaOrgId String? @map("creato_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
listeModello ListaModello[]
liste Lista[]
@@map("tipo_evento")
}
model ListaModello {
id String @id @default(uuid())
nome String
tipoEventoId String @map("tipo_evento_id")
pubblica Boolean @default(true)
tipoEvento TipoEvento @relation(fields: [tipoEventoId], references: [id])
voci ListaModelloVoce[]
@@map("lista_modello")
}
model ListaModelloVoce {
listaModelloId String @map("lista_modello_id")
materialeId String @map("materiale_id")
quantita Int
listaModello ListaModello @relation(fields: [listaModelloId], references: [id])
materiale Materiale @relation(fields: [materialeId], references: [id])
@@id([listaModelloId, materialeId])
@@map("lista_modello_voce")
}
model Lista {
id String @id @default(uuid())
nome String
orgId String @map("org_id")
creataIl DateTime @default(now()) @map("creata_il")
id String @id @default(uuid())
nome String
// Valorizzato solo per le liste in stato 'gruppo' (condivise con l'organizzazione):
// le liste 'bozza'/'privato'/'pubblico' sono personali e non hanno alcuna org, anche
// per un utente che ha un'organizzazione attiva sul token al momento della creazione.
orgId String? @map("org_id")
// Nullable perché le liste create prima di questo campo non hanno un autore noto;
// da qui in poi è sempre valorizzato da req.auth.userId alla creazione.
creataDaUserId String? @map("creata_da_user_id")
stato StatoLista @default(bozza)
creataIl DateTime @default(now()) @map("creata_il")
// Moderazione, ortogonale a `stato`: nullo per bozza/privato/gruppo, valorizzato
// solo quando stato = pubblico (proposto/approvato/rifiutato, vedi liste.service.ts).
statoModerazione StatoModerazioneLista? @map("stato_moderazione")
// Ex campo obbligatorio di ListaModello, ora opzionale su tutte le liste: serve solo
// a filtrare il catalogo pubblico per tipo evento.
tipoEventoId String? @map("tipo_evento_id")
tipoEvento TipoEvento? @relation(fields: [tipoEventoId], references: [id])
// Lineage self-referenziato: da quale lista pubblica approvata è stata forkata questa
// (fork = "usa come base"). Best-effort/storico: cancellare l'origine non deve
// bloccare né trascinare i figli, quindi SetNull.
parentId String? @map("parent_id")
parent Lista? @relation("ListaParent", fields: [parentId], references: [id], onDelete: SetNull)
figlie Lista[] @relation("ListaParent")
voci ListaVoce[]
eventi Evento[]
// Sotto-liste agganciate a questa lista personale: un solo livello di nesting
// (una sotto-lista non può avere a sua volta sotto-liste), validato in liste.service.ts.
sottoListe ListaSottoLista[] @relation("SottoListePadre")
usataComeSottoListaIn ListaSottoLista[] @relation("SottoListaFiglia")
@@index([stato, statoModerazione])
@@index([tipoEventoId])
@@index([parentId])
@@map("lista")
}
@@ -97,6 +170,35 @@ model ListaVoce {
@@map("lista_voce")
}
model ListaSottoLista {
listaId String @map("lista_id")
sottoListaId String @map("sotto_lista_id")
lista Lista @relation("SottoListePadre", fields: [listaId], references: [id])
sottoLista Lista @relation("SottoListaFiglia", fields: [sottoListaId], references: [id])
@@id([listaId, sottoListaId])
@@map("lista_sotto_lista")
}
model GruppoMagazzino {
id String @id @default(uuid())
orgId String @map("org_id")
nome String
// Un solo livello di nesting (uno scaffale contiene kit, un kit non contiene
// a sua volta altri gruppi), validato in gruppiMagazzino.service.ts.
parentId String? @map("parent_id")
creatoIl DateTime @default(now()) @map("creato_il")
parent GruppoMagazzino? @relation("GruppoMagazzinoParent", fields: [parentId], references: [id], onDelete: SetNull)
figli GruppoMagazzino[] @relation("GruppoMagazzinoParent")
voci MagazzinoVoce[]
@@index([orgId])
@@index([parentId])
@@map("gruppo_magazzino")
}
model MagazzinoVoce {
id String @id @default(uuid())
orgId String @map("org_id")
@@ -105,9 +207,14 @@ model MagazzinoVoce {
stato StatoMagazzinoVoce
posizione String?
note String?
// Nullo finché la giacenza non viene organizzata in un gruppo (scaffale/kit):
// eliminare il gruppo non elimina la voce, la riporta solo tra le "non organizzate".
gruppoId String? @map("gruppo_id")
materiale Materiale @relation(fields: [materialeId], references: [id])
materiale Materiale @relation(fields: [materialeId], references: [id])
gruppo GruppoMagazzino? @relation(fields: [gruppoId], references: [id], onDelete: SetNull)
@@index([gruppoId])
@@map("magazzino_voce")
}
+204
View File
@@ -0,0 +1,204 @@
import { PrismaClient, StatoMateriale } from "@prisma/client";
const prisma = new PrismaClient();
// Placeholder di orgId per il materiale di catalogo seedato manualmente (non riconducibile
// a nessuna organizzazione reale), analogo a UTENTE_MODIFICA="MANUALE" nel seed di attivita-be.
const ORG_SISTEMA = "SISTEMA";
async function main() {
const materiali = [
{ id: "materiale-corda", nome: "Corda", categoria: "Attrezzatura", unitaMisura: "pz" },
{ id: "materiale-torcia", nome: "Torcia", categoria: "Attrezzatura", unitaMisura: "pz" },
{
id: "materiale-kit-primo-soccorso",
nome: "Kit primo soccorso",
categoria: "Sicurezza",
unitaMisura: "kit",
},
{ id: "materiale-tenda", nome: "Tenda", categoria: "Campeggio", unitaMisura: "pz" },
{
id: "materiale-fornello-campo",
nome: "Fornello da campo",
categoria: "Campeggio",
unitaMisura: "pz",
},
{
id: "materiale-zaino",
nome: "Zaino",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{
id: "materiale-sacco-a-pelo",
nome: "Sacco a pelo",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{
id: "materiale-borraccia",
nome: "Borraccia",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{ id: "materiale-garze", nome: "Garze", categoria: "Sicurezza", unitaMisura: "pz" },
{ id: "materiale-cerotti", nome: "Cerotti", categoria: "Sicurezza", unitaMisura: "pz" },
{
id: "materiale-disinfettante",
nome: "Disinfettante",
categoria: "Sicurezza",
unitaMisura: "flacone",
},
];
await Promise.all(
materiali.map((materiale) =>
prisma.materiale.upsert({
where: { id: materiale.id },
update: {
...materiale,
stato: StatoMateriale.approvato,
propostoDaOrgId: ORG_SISTEMA,
},
create: {
...materiale,
stato: StatoMateriale.approvato,
propostoDaOrgId: ORG_SISTEMA,
},
})
)
);
const tipiEvento = [
{ id: "tipo-evento-uscita-giorno", nome: "Uscita di un giorno" },
{ id: "tipo-evento-campo-estivo", nome: "Campo estivo" },
{ id: "tipo-evento-riunione-settimanale", nome: "Riunione settimanale" },
];
await Promise.all(
tipiEvento.map((tipoEvento) =>
prisma.tipoEvento.upsert({
where: { id: tipoEvento.id },
update: { nome: tipoEvento.nome },
create: tipoEvento,
})
)
);
// Le ex "liste modello" sono ora liste unificate: pubbliche, già approvate dalla
// moderazione, senza autore/org (scritte direttamente dal seed, come farebbe un
// moderatore in bypass della coda di proposte).
const listeModello = [
{
id: "lista-modello-uscita-giorno",
nome: "Materiale base uscita giornaliera",
tipoEventoId: "tipo-evento-uscita-giorno",
},
{
id: "lista-modello-campo-estivo",
nome: "Materiale base campo estivo",
tipoEventoId: "tipo-evento-campo-estivo",
},
// Sotto-lista "foglia" (nessuna sotto-lista propria): allegata come sotto-lista sia a
// "uscita di un giorno" sia a "campo estivo", al posto del vecchio materiale generico
// "materiale-kit-primo-soccorso", per verificare il nesting a un livello.
{
id: "lista-modello-kit-primo-soccorso",
nome: "Kit di pronto soccorso",
tipoEventoId: "tipo-evento-uscita-giorno",
},
];
for (const listaModello of listeModello) {
await prisma.lista.upsert({
where: { id: listaModello.id },
update: { ...listaModello, stato: "pubblico", statoModerazione: "approvato" },
create: {
...listaModello,
orgId: null,
creataDaUserId: null,
stato: "pubblico",
statoModerazione: "approvato",
},
});
}
const listeModelloVoci = [
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-zaino", quantita: 1 },
{
listaId: "lista-modello-uscita-giorno",
materialeId: "materiale-borraccia",
quantita: 1,
},
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-torcia", quantita: 1 },
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-tenda", quantita: 1 },
{
listaId: "lista-modello-campo-estivo",
materialeId: "materiale-sacco-a-pelo",
quantita: 1,
},
{
listaId: "lista-modello-campo-estivo",
materialeId: "materiale-fornello-campo",
quantita: 1,
},
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-corda", quantita: 2 },
// Voci della sotto-lista "Kit di pronto soccorso" (foglia, allegata sotto a due liste diverse).
{ listaId: "lista-modello-kit-primo-soccorso", materialeId: "materiale-garze", quantita: 3 },
{
listaId: "lista-modello-kit-primo-soccorso",
materialeId: "materiale-cerotti",
quantita: 10,
},
{
listaId: "lista-modello-kit-primo-soccorso",
materialeId: "materiale-disinfettante",
quantita: 1,
},
];
// Rimuove il vecchio materiale generico "kit primo soccorso" piazzato direttamente nelle due
// liste padre: da questo seed in poi il kit è la sotto-lista dedicata, non più una singola voce.
await prisma.listaVoce.deleteMany({
where: {
materialeId: "materiale-kit-primo-soccorso",
listaId: { in: ["lista-modello-uscita-giorno", "lista-modello-campo-estivo"] },
},
});
for (const voce of listeModelloVoci) {
await prisma.listaVoce.upsert({
where: {
listaId_materialeId: {
listaId: voce.listaId,
materialeId: voce.materialeId,
},
},
update: { quantita: voce.quantita },
create: voce,
});
}
const sottoListe = [
{ listaId: "lista-modello-uscita-giorno", sottoListaId: "lista-modello-kit-primo-soccorso" },
{ listaId: "lista-modello-campo-estivo", sottoListaId: "lista-modello-kit-primo-soccorso" },
];
for (const sottoLista of sottoListe) {
await prisma.listaSottoLista.upsert({
where: {
listaId_sottoListaId: {
listaId: sottoLista.listaId,
sottoListaId: sottoLista.sottoListaId,
},
},
update: {},
create: sottoLista,
});
}
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});