feat(offers): ciclo di vita dei servizi ricorrenti (v2.4 Phase 13)
Un retainer, una volta assegnato, non si poteva fermare: project_offers aveva solo start_date e il forecast sommava il canone a ogni mese dell'orizzonte da li in poi, per sempre. Un cliente che disdiceva continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento attivo nel portale. - migration 0016 (gia applicata a prod): project_offers.status (attivo|sospeso|cessato, CHECK) + end_date. Additiva pura, default 'attivo' cosi le righe esistenti conservano il comportamento di prima - forecast: i retainer si fermano a end_date, sospesi e cessati escono. getOffersSoldBreakdown NON filtra per stato: e uno storico di vendita, escludere le cessate riscriverebbe il passato - offersAcceptedTotal esclude le cessate (default del piano pagamenti) - admin: comandi Sospendi/Riattiva/Cessa + data fine nella tab Offerte, solo per i ricorrenti. setProjectOfferLifecycle valida con Zod e filtra anche per project_id, cosi un id arbitrario non tocca altri progetti - portale: "Attivo dal", "fino al", badge In pausa, "Canone mensile" invece di "Prezzo finale"; le cessate non arrivano al client - fix: un retainer sospeso continuava a intestare i pagamenti "Totale Pagamento Mensile" e a sovrascrivere l'importo Igiene nello stesso giro: - STATUS.md riscritto: era fermo al 22 giugno e diceva che node/docker non sono disponibili sul server e che le migrazioni si applicano da locale con uno script postgres.js — il contrario della procedura reale - rimossi ChatSection/CommentList/CommentForm, senza importatori (308 righe) - overrides postcss>=8.5.18 e sharp>=0.35.0: 3 CVE high transitive di Next senza fix upstream. npm audit ora pulito, build verde Verifica: forecast controllato sui dati veri in 5 scenari (baseline invariata, end_date, sospeso, cessato, ripristino); portale verificato nei 4 stati; tab admin verificata con Playwright sul build di produzione (i comandi non compaiono sulle una tantum). Dati di test ripuliti, tabelle protette invariate 4/5/11/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
clientTranscripts,
|
||||
client_emails,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||
import { eq, ne, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||
import { getPool } from "@/lib/taxonomy";
|
||||
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||
import type {
|
||||
@@ -238,6 +238,9 @@ export type ProjectOfferWithMicro = {
|
||||
macro_offer_type: string;
|
||||
start_date: Date;
|
||||
accepted_total: string | null;
|
||||
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
|
||||
status: string;
|
||||
end_date: Date | null;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
@@ -684,6 +687,8 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
macro_offer_type: offer_macros.offer_type,
|
||||
start_date: project_offers.start_date,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
status: project_offers.status,
|
||||
end_date: project_offers.end_date,
|
||||
created_at: project_offers.created_at,
|
||||
})
|
||||
.from(project_offers)
|
||||
@@ -759,8 +764,13 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
return true;
|
||||
});
|
||||
|
||||
// Le offerte cessate non entrano nel totale: serve come default del piano
|
||||
// pagamenti, e un abbonamento chiuso non è denaro da incassare.
|
||||
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
|
||||
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
||||
(sum, o) =>
|
||||
o.status === "cessato"
|
||||
? sum
|
||||
: sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
||||
0
|
||||
);
|
||||
|
||||
@@ -863,6 +873,7 @@ export type ClientActiveOfferSummary = {
|
||||
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
|
||||
offer_type: string; // una_tantum | retainer (fallback for category)
|
||||
accepted_total: string | null;
|
||||
status: string; // attivo | sospeso — le cessate non compaiono qui
|
||||
};
|
||||
|
||||
export async function getClientActiveOffers(clientId: string): Promise<ClientActiveOfferSummary[]> {
|
||||
@@ -887,11 +898,18 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
||||
category: offer_macros.category,
|
||||
offer_type: offer_macros.offer_type,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
status: project_offers.status,
|
||||
})
|
||||
.from(project_offers)
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.where(inArray(project_offers.project_id, projectIds))
|
||||
// La sezione si chiama "Offerte Attive": le cessate non ci vanno.
|
||||
.where(
|
||||
and(
|
||||
inArray(project_offers.project_id, projectIds),
|
||||
ne(project_offers.status, "cessato")
|
||||
)
|
||||
)
|
||||
.orderBy(asc(project_offers.created_at));
|
||||
|
||||
return rows.map((r) => ({
|
||||
@@ -904,6 +922,7 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
||||
category: r.category,
|
||||
offer_type: r.offer_type,
|
||||
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
|
||||
status: r.status,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
|
||||
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
|
||||
|
||||
@@ -59,6 +59,9 @@ export interface ClientView {
|
||||
offer_type: string; // una_tantum | retainer
|
||||
cumulative_price: string;
|
||||
accepted_total: string | null;
|
||||
status: string; // attivo | sospeso — le cessate non arrivano al cliente
|
||||
start_date: string; // ISO
|
||||
end_date: string | null; // ISO — null = continuativo
|
||||
services: Array<{ name: string; description: string | null }>;
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
@@ -132,6 +135,9 @@ export interface ProjectView {
|
||||
offer_type: string; // una_tantum | retainer
|
||||
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
|
||||
accepted_total: string | null;
|
||||
status: string; // attivo | sospeso — le cessate non arrivano al cliente
|
||||
start_date: string; // ISO
|
||||
end_date: string | null; // ISO — null = continuativo
|
||||
services: Array<{ name: string; description: string | null }>; // included services list
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
@@ -331,7 +337,10 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
.where(eq(notes.project_id, projectId))
|
||||
.orderBy(asc(notes.created_at));
|
||||
|
||||
// Fetch active offers for this project (client-safe fields only — never internal_name)
|
||||
// Fetch active offers for this project (client-safe fields only — never internal_name).
|
||||
// Le offerte cessate sono escluse: un abbonamento chiuso non è un servizio
|
||||
// attivo e il cliente non deve vederselo ancora addosso. Le sospese restano,
|
||||
// marcate come tali — sapere che è in pausa è diverso dal vederlo sparire.
|
||||
const projectOfferRows = await db
|
||||
.select({
|
||||
id: project_offers.id,
|
||||
@@ -339,12 +348,20 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
|
||||
offer_type: offer_macros.offer_type, // una_tantum | retainer
|
||||
accepted_total: project_offers.accepted_total,
|
||||
status: project_offers.status, // attivo | sospeso
|
||||
start_date: project_offers.start_date,
|
||||
end_date: project_offers.end_date,
|
||||
micro_id: project_offers.micro_id,
|
||||
})
|
||||
.from(project_offers)
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.where(eq(project_offers.project_id, projectId));
|
||||
.where(
|
||||
and(
|
||||
eq(project_offers.project_id, projectId),
|
||||
ne(project_offers.status, "cessato")
|
||||
)
|
||||
);
|
||||
|
||||
// Cumulative price + service list per micro.
|
||||
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
|
||||
@@ -420,6 +437,9 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
offer_type: o.offer_type,
|
||||
cumulative_price,
|
||||
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
||||
status: o.status,
|
||||
start_date: o.start_date.toISOString(),
|
||||
end_date: o.end_date ? o.end_date.toISOString() : null,
|
||||
services: servicesList,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@/db";
|
||||
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
|
||||
import { eq, sql, desc } from "drizzle-orm";
|
||||
import { eq, ne, and, sql, desc } from "drizzle-orm";
|
||||
|
||||
export type ForecastMonth = {
|
||||
year: number;
|
||||
@@ -21,6 +21,11 @@ export type OffersSoldBreakdown = {
|
||||
};
|
||||
|
||||
// Quante offerte sono state vendute (assegnate a un progetto), per offerta + tier.
|
||||
//
|
||||
// NON filtrare per project_offers.status: questo è un dato storico di vendita.
|
||||
// Escludere le offerte cessate riscriverebbe il passato ogni volta che un
|
||||
// cliente disdice, e "quante ne ho vendute" smetterebbe di essere una risposta
|
||||
// stabile. Il ciclo di vita riguarda il forecast, non lo storico.
|
||||
export async function getOffersSoldBreakdown(): Promise<OffersSoldBreakdown> {
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -49,9 +54,15 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||
// Active offers only (non-archived projects). offer_type drives the math:
|
||||
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
|
||||
// - una_tantum: accepted_total è il prezzo TOTALE → spalmato su duration_months
|
||||
//
|
||||
// Le offerte cessate escono dalla query: un abbonamento chiuso non è ricavo
|
||||
// futuro. Prima della 0016 non esisteva modo di chiuderne uno, e un retainer
|
||||
// disdetto restava nel forecast per sempre.
|
||||
const offers = await db
|
||||
.select({
|
||||
start_date: project_offers.start_date,
|
||||
end_date: project_offers.end_date,
|
||||
status: project_offers.status,
|
||||
duration_months: offer_micros.duration_months,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
offer_type: offer_macros.offer_type,
|
||||
@@ -60,7 +71,7 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.innerJoin(projects, eq(project_offers.project_id, projects.id))
|
||||
.where(eq(projects.archived, false));
|
||||
.where(and(eq(projects.archived, false), ne(project_offers.status, "cessato")));
|
||||
|
||||
// Build 12-month bucket array starting from current month
|
||||
const now = new Date();
|
||||
@@ -83,11 +94,23 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||
const startMonthKey = start.getFullYear() * 12 + start.getMonth();
|
||||
|
||||
if (offer.offer_type === "retainer") {
|
||||
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in poi,
|
||||
// senza limite di duration_months (un retainer è continuativo).
|
||||
// Un retainer sospeso non produce ricavo prevedibile: senza una data di
|
||||
// ripresa non si può sapere quando ricomincia, quindi ai fini del forecast
|
||||
// vale come chiuso. Scelta di prodotto, non conseguenza tecnica.
|
||||
if (offer.status !== "attivo") continue;
|
||||
|
||||
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in
|
||||
// poi, senza limite di duration_months (un retainer è continuativo) —
|
||||
// ma ora si ferma a end_date se valorizzata. NULL = continuativo.
|
||||
const endMonthKey = offer.end_date
|
||||
? offer.end_date.getFullYear() * 12 + offer.end_date.getMonth()
|
||||
: null;
|
||||
|
||||
for (const b of buckets) {
|
||||
const bucketMonthKey = b.year * 12 + (b.month - 1);
|
||||
if (bucketMonthKey >= startMonthKey) b.total += total;
|
||||
if (bucketMonthKey < startMonthKey) continue;
|
||||
if (endMonthKey !== null && bucketMonthKey > endMonthKey) continue;
|
||||
b.total += total;
|
||||
}
|
||||
} else {
|
||||
// Una tantum: prezzo totale ripartito su duration_months a partire da start.
|
||||
|
||||
Reference in New Issue
Block a user