# PromoGest v2, fase 1: fondamenta e logica di dominio

> **Fase chiusa, in produzione.** Registro storico: cosa è stato deciso e perché.


> **Per chi esegue:** usare la skill `superpowers:subagent-driven-development` (consigliata) oppure
> `superpowers:executing-plans` per implementare un task alla volta. Gli step usano caselle
> `- [ ]` per il tracciamento.

**Obiettivo:** avere il progetto SvelteKit in piedi e tutta la logica di dominio (date, parsing
Excel, raggruppamento, fusione conflitti, normalizzazione insegne) scritta come funzioni pure con i
test che oggi non esistono.

**Architettura:** questa fase non tocca né database né interfaccia. Produce moduli TypeScript puri
sotto `src/lib/` con la loro suite Vitest. È la fase che protegge il rifacimento: la logica qui
dentro è quella dove sono stati trovati tutti i bug costosi della versione PHP, e va portata con i
suoi comportamenti esatti, non riscritta a intuito.

**Stack:** SvelteKit 2, Svelte 5, TypeScript, Tailwind 4, Vitest, SheetJS.

**Riferimenti:** la spec è in `docs/design.md`. Il progetto gemello da cui si copiano le convenzioni
è `/Users/paolo/Server/Siti Web/Lavoro/Inlumia/Alleanza/app`. Il codice PHP da cui si migra è in
`/Users/paolo/Server/Siti Web/_private/valentina/promogest/`.

## Vincoli globali

Valgono per ogni task, non si ripetono.

- **Le date civili sono stringhe `YYYY-MM-DD`, mai oggetti `Date`.** I confronti si fanno
  lessicograficamente (`inizio <= giorno && fine >= giorno`). Un `Date` si costruisce solo per
  leggere il calendario di sistema o per interpretare una cella Excel, e in quel caso si legge
  subito con `ymd()`.
- **Mai `toISOString()` su una Date che rappresenta la mezzanotte locale.** È il bug che ha fatto
  slittare di un giorno ogni data dell'app PHP per cinque mesi. L'unica eccezione legittima è una
  Date costruita da un epoch UTC (ramo del seriale Excel) o un timestamp vero.
- **Ogni file sorgente ha il commento in cima che dice cosa fa e perché.** In italiano, come in
  Alleanza.
- **I test si scrivono prima dell'implementazione** e devono fallire per il motivo giusto prima di
  passare.
- **Nomi in italiano** per le funzioni di dominio (`risolviInsegna`, `fondiConflitti`), in inglese
  per ciò che è convenzione del framework (`load`, `handle`, `GET`).
- Un commit per task, messaggio in italiano all'imperativo.

---

### Task 1: Scaffolding del progetto e modulo delle date

Il modulo delle date viene per primo perché tutto il resto ne dipende, e perché il suo test è quello
che vale più di tutti gli altri messi insieme.

**File:**
- Creare: `package.json`, `svelte.config.js`, `vite.config.ts`, `vitest.config.ts`, `tsconfig.json`,
  `.env.example`, `src/app.html`, `src/app.css`, `src/app.d.ts`, `src/routes/+layout.svelte`
- Creare: `src/lib/date.ts`
- Test: `src/lib/date.test.ts`

**Interfacce prodotte:**
```ts
type IsoDate = string;
function ymd(d: Date): IsoDate
function oggi(): IsoDate
function aggiungiGiorni(d: IsoDate, n: number): IsoDate
function giorniDiDifferenza(a: IsoDate, b: IsoDate): number
function siSovrappongono(aInizio: IsoDate, aFine: IsoDate, bInizio: IsoDate, bFine: IsoDate): boolean
function attivoIl(inizio: IsoDate, fine: IsoDate, giorno: IsoDate): boolean
function formattaData(d: IsoDate): string
function nomeMese(mese0: number): string
function offsetPrimoGiorno(anno: number, mese0: number): number
function giorniNelMese(anno: number, mese0: number): number
function eIsoDate(v: unknown): v is IsoDate
```

- [ ] **Step 1: Creare il progetto SvelteKit**

Dalla cartella `promogest-v2` (che contiene già `.git`, `README.md`, `docs/`):

```bash
npx sv create .
```

Il comando è interattivo. Rispondere: template **SvelteKit minimal**, type checking
**TypeScript**, add-ons **nessuno** (Tailwind lo aggiungiamo a mano allo step dopo, perché la
versione 4 si configura via plugin Vite), package manager **npm**.

Quando avverte che la cartella non è vuota, confermare. `.git`, `.gitignore`, `README.md` e `docs/`
vanno conservati: verificarlo dopo con `git status`, che deve mostrare solo file aggiunti e nessuna
cancellazione.

- [ ] **Step 2: Installare le dipendenze**

```bash
npm i -D @sveltejs/adapter-vercel @tailwindcss/vite tailwindcss vitest
```

SheetJS **non** va preso da npm: il pacchetto `xlsx` lì è fermo alla 0.18.5 del 2022 e ha due
advisory `high` senza correzione. Va installato dal tarball ufficiale. Prima controllare qual è la
versione corrente su `https://cdn.sheetjs.com/`, poi:

```bash
npm i https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
```

sostituendo `0.20.3` con la versione trovata. Verificare con `node -e "console.log(require('xlsx').version)"`
che sia almeno 0.20.2, che è quella già in uso nella versione PHP e sulla quale il comportamento
delle date è stato verificato.

- [ ] **Step 3: Scrivere i file di configurazione**

**Niente `svelte.config.js`.** Da SvelteKit 2.70 il plugin accetta la configurazione come
argomento (`export async function sveltekit(config)` in
`node_modules/@sveltejs/kit/src/exports/vite/index.js:150`), e `sv create` non genera più quel file.
Il progetto Alleanza ce l'ha perché è stato creato con una versione precedente: non copiarlo da lì.

Verificato che la configurazione arrivi davvero a destinazione: dopo `npm run build`,
`.vercel/output/functions/*/.vc-config.json` contiene `"runtime": "nodejs22.x"` e
`"regions": ["fra1"]`.

`vite.config.ts`:

```ts
import adapter from '@sveltejs/adapter-vercel';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [
		tailwindcss(),
		sveltekit({
			// Francoforte: su Vercel Hobby la regione è una sola e il default è la Virginia,
			// che aggiunge un giro Italia-USA-Italia a ogni chiamata dal telefono.
			adapter: adapter({ runtime: 'nodejs22.x', regions: ['fra1'] })
		})
	]
});
```

`$lib` non va dichiarato: è già l'alias predefinito di SvelteKit.

`vitest.config.ts`:

```ts
import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
	resolve: {
		alias: {
			$lib: path.resolve(__dirname, 'src/lib'),
			// I moduli runtime di SvelteKit non esistono fuori da SvelteKit.
			'$env/dynamic/private': path.resolve(__dirname, 'src/test-utils/env-stub.ts')
		}
	},
	test: {
		include: ['src/**/*.test.ts'],
		environment: 'node'
	}
});
```

`src/test-utils/env-stub.ts`:

```ts
// Sostituto di $env/dynamic/private nei test: le funzioni pure non lo usano,
// chi lo usa va mockato esplicitamente nel proprio test.
export const env: Record<string, string | undefined> = {};
```

`tsconfig.json`: lasciare quello generato da `sv create`, aggiungendo `"strict": true` se assente.

`.env.example`:

```
# In sviluppo punta a un file locale, in produzione a Turso.
DATABASE_URL=file:./local.db
DATABASE_AUTH_TOKEN=
# Segreto di firma del JWT di sessione. In produzione va generato con:
#   node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
JWT_SECRET=cambiami
```

In `package.json`, sezione `scripts`, aggiungere:

```json
"test": "vitest run",
"test:fusi": "TZ=Europe/Rome vitest run && TZ=UTC vitest run && TZ=Pacific/Auckland vitest run"
```

`src/app.html`, con `viewport-fit=cover` che serve alle safe area dell'iPhone:

```html
<!doctype html>
<html lang="it">
	<head>
		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
		<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
		<title>PromoGest</title>
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover" class="min-h-screen bg-slate-50 text-slate-900">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>
```

`src/app.css`, con i colori delle tre aziende presi da `data/companies.json` della versione PHP:

```css
@import 'tailwindcss';

@theme {
	--color-caseifici-500: #2196f3;
	--color-salumifici-500: #4caf50;
	--color-parmacotto-500: #ff9800;
}

html,
body {
	/* Disattiva il pull-to-refresh: in modalità standalone quel gesto ricarica
	   l'app e fa perdere lo stato di navigazione. */
	overscroll-behavior: none;
}
```

`src/routes/+layout.svelte`:

```svelte
<script lang="ts">
	import '../app.css';
	let { children } = $props();
</script>

{@render children()}
```

- [ ] **Step 4: Verificare che il progetto si avvii**

```bash
npm run dev
```

Atteso: il server parte su `http://localhost:5173` e la pagina si apre senza errori in console.
Fermare con Ctrl+C.

- [ ] **Step 5: Scrivere il test delle date (deve fallire)**

`src/lib/date.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import {
	ymd,
	aggiungiGiorni,
	giorniDiDifferenza,
	siSovrappongono,
	attivoIl,
	formattaData,
	offsetPrimoGiorno,
	giorniNelMese,
	eIsoDate
} from './date';

describe('ymd', () => {
	// Questo è IL test del progetto. Il bug che ha fatto slittare di un giorno ogni
	// data dell'app PHP per cinque mesi si manifestava solo a est di Greenwich: in UTC
	// il codice sbagliato dava per caso la risposta giusta. Girare la suite anche con
	// TZ=UTC e TZ=Pacific/Auckland (npm run test:fusi) è ciò che impedisce il ritorno.
	it('legge i componenti locali, non quelli UTC', () => {
		expect(ymd(new Date(2026, 2, 13))).toBe('2026-03-13');
		expect(ymd(new Date(2026, 0, 1))).toBe('2026-01-01');
		expect(ymd(new Date(2026, 11, 31))).toBe('2026-12-31');
	});

	it('non sbaglia sull ultimo giorno del mese', () => {
		expect(ymd(new Date(2026, 3, 30))).toBe('2026-04-30');
		expect(ymd(new Date(2026, 4, 31))).toBe('2026-05-31');
		expect(ymd(new Date(2026, 1, 28))).toBe('2026-02-28');
	});

	it('non sbaglia ai cambi di ora legale', () => {
		expect(ymd(new Date(2026, 2, 29))).toBe('2026-03-29');
		expect(ymd(new Date(2026, 9, 25))).toBe('2026-10-25');
	});
});

describe('aggiungiGiorni', () => {
	it('somma e sottrae giorni', () => {
		expect(aggiungiGiorni('2026-04-22', 1)).toBe('2026-04-23');
		expect(aggiungiGiorni('2026-04-30', 1)).toBe('2026-05-01');
		expect(aggiungiGiorni('2026-01-01', -1)).toBe('2025-12-31');
	});

	it('attraversa il cambio di ora legale senza saltare giorni', () => {
		expect(aggiungiGiorni('2026-03-28', 1)).toBe('2026-03-29');
		expect(aggiungiGiorni('2026-03-29', 1)).toBe('2026-03-30');
		expect(aggiungiGiorni('2026-10-24', 1)).toBe('2026-10-25');
		expect(aggiungiGiorni('2026-10-25', 1)).toBe('2026-10-26');
	});

	it('gestisce gli anni bisestili', () => {
		expect(aggiungiGiorni('2028-02-28', 1)).toBe('2028-02-29');
		expect(aggiungiGiorni('2026-02-28', 1)).toBe('2026-03-01');
	});
});

describe('giorniDiDifferenza', () => {
	it('è simmetrica e non risente del cambio d ora', () => {
		expect(giorniDiDifferenza('2026-04-22', '2026-04-24')).toBe(2);
		expect(giorniDiDifferenza('2026-04-24', '2026-04-22')).toBe(2);
		// Nella versione PHP questa coppia dava 2.0416 e il conflitto non veniva fuso.
		expect(giorniDiDifferenza('2026-10-24', '2026-10-26')).toBe(2);
		expect(giorniDiDifferenza('2026-03-28', '2026-03-30')).toBe(2);
	});
});

describe('siSovrappongono', () => {
	// Semantica della vista Lista: "cosa è attivo in questa finestra", non
	// "cosa inizia e finisce dentro la finestra". Il rifacimento ingenuo
	// (inizio >= da && fine <= a) farebbe sparire metà degli eventi.
	it('include gli eventi a cavallo della finestra', () => {
		expect(siSovrappongono('2026-04-22', '2026-05-05', '2026-05-01', '2026-05-31')).toBe(true);
		expect(siSovrappongono('2026-04-22', '2026-05-05', '2026-04-01', '2026-04-30')).toBe(true);
	});

	it('esclude solo chi sta tutto fuori', () => {
		expect(siSovrappongono('2026-04-22', '2026-05-05', '2026-06-01', '2026-06-30')).toBe(false);
		expect(siSovrappongono('2026-04-22', '2026-05-05', '2026-01-01', '2026-02-01')).toBe(false);
	});

	it('considera sovrapposizione anche il contatto su un solo giorno', () => {
		expect(siSovrappongono('2026-04-22', '2026-05-05', '2026-05-05', '2026-05-31')).toBe(true);
	});
});

describe('attivoIl', () => {
	it('include gli estremi del periodo', () => {
		expect(attivoIl('2026-04-22', '2026-05-05', '2026-04-22')).toBe(true);
		expect(attivoIl('2026-04-22', '2026-05-05', '2026-05-05')).toBe(true);
		expect(attivoIl('2026-04-22', '2026-05-05', '2026-04-30')).toBe(true);
	});

	it('esclude i giorni fuori', () => {
		expect(attivoIl('2026-04-22', '2026-05-05', '2026-04-21')).toBe(false);
		expect(attivoIl('2026-04-22', '2026-05-05', '2026-05-06')).toBe(false);
	});
});

describe('formattaData', () => {
	it('scrive nel formato italiano', () => {
		expect(formattaData('2026-04-22')).toBe('22/04/2026');
		expect(formattaData('2026-12-01')).toBe('01/12/2026');
	});
});

describe('griglia del calendario', () => {
	it('offsetPrimoGiorno tratta lunedì come primo giorno', () => {
		// marzo 2026 inizia di domenica: sei celle vuote prima
		expect(offsetPrimoGiorno(2026, 2)).toBe(6);
		// giugno 2026 inizia di lunedì: nessuna cella vuota
		expect(offsetPrimoGiorno(2026, 5)).toBe(0);
	});

	it('giorniNelMese conta bene anche febbraio', () => {
		expect(giorniNelMese(2026, 1)).toBe(28);
		expect(giorniNelMese(2028, 1)).toBe(29);
		expect(giorniNelMese(2026, 3)).toBe(30);
		expect(giorniNelMese(2026, 0)).toBe(31);
	});
});

describe('eIsoDate', () => {
	it('accetta solo il formato esatto', () => {
		expect(eIsoDate('2026-04-22')).toBe(true);
		expect(eIsoDate('2026-4-22')).toBe(false);
		expect(eIsoDate('22/04/2026')).toBe(false);
		expect(eIsoDate('')).toBe(false);
		expect(eIsoDate(null)).toBe(false);
		expect(eIsoDate(20260422)).toBe(false);
	});

	it('rifiuta le date che nel calendario non esistono', () => {
		// Non basta contare le cifre: '2026-02-31' ha la forma giusta e non esiste.
		// Siccome tutto il dominio confronta date come stringhe, una data impossibile
		// non farebbe rumore da nessuna parte e finirebbe nel database.
		expect(eIsoDate('2026-02-31')).toBe(false);
		expect(eIsoDate('2026-13-01')).toBe(false);
		expect(eIsoDate('2026-01-32')).toBe(false);
		expect(eIsoDate('2026-00-10')).toBe(false);
		expect(eIsoDate('2026-01-00')).toBe(false);
		expect(eIsoDate('2026-02-29')).toBe(false); // il 2026 non è bisestile
	});

	it('accetta il 29 febbraio negli anni bisestili', () => {
		expect(eIsoDate('2028-02-29')).toBe(true);
		expect(eIsoDate('2024-02-29')).toBe(true);
	});
});
```

- [ ] **Step 6: Eseguire il test e verificare che fallisca**

```bash
npm test
```

Atteso: FALLISCE con `Failed to resolve import "./date"` o equivalente. Il modulo non esiste ancora.

- [ ] **Step 7: Scrivere `src/lib/date.ts`**

```ts
/**
 * Date civili.
 *
 * In questa applicazione una data (sellout_start, sellout_end) è una data del
 * calendario, non un istante nel tempo. Si rappresenta come stringa 'YYYY-MM-DD'
 * e si confronta lessicograficamente. Costruire un Date per confrontare o
 * formattare una data civile è il modo in cui nella versione PHP ogni data è
 * slittata di un giorno per cinque mesi.
 *
 * L'unico timestamp vero del dominio è imported_at, che non passa da qui.
 */

/** Data civile nel formato 'YYYY-MM-DD'. */
export type IsoDate = string;

const FORMATO = /^\d{4}-\d{2}-\d{2}$/;

/**
 * Vera se la stringa è una data del calendario, non solo se ha la forma giusta.
 *
 * Il controllo sul calendario non è pignoleria: senza, '2026-02-31' e '2026-13-01'
 * passano, e siccome tutto il dominio confronta date come stringhe finirebbero nel
 * database e nel calendario senza che niente protesti. Costruendo la data in UTC,
 * JavaScript normalizza i valori che traboccano (il 31 febbraio diventa il 3 marzo),
 * quindi basta verificare che i componenti tornino identici.
 */
export function eIsoDate(v: unknown): v is IsoDate {
	if (typeof v !== 'string' || !FORMATO.test(v)) return false;
	const [anno, mese, giorno] = v.split('-').map(Number);
	const t = new Date(Date.UTC(anno, mese - 1, giorno));
	return (
		t.getUTCFullYear() === anno && t.getUTCMonth() === mese - 1 && t.getUTCDate() === giorno
	);
}

/**
 * Converte una Date nella sua data civile leggendo i componenti LOCALI.
 * Non usare mai toISOString() per questo: una Date a mezzanotte locale in Italia
 * è il giorno prima alle 22:00 o 23:00 UTC.
 */
export function ymd(d: Date): IsoDate {
	const m = String(d.getMonth() + 1).padStart(2, '0');
	const g = String(d.getDate()).padStart(2, '0');
	return `${d.getFullYear()}-${m}-${g}`;
}

/** Oggi secondo l'orologio del dispositivo. Va richiamata, mai memorizzata all'avvio. */
export function oggi(): IsoDate {
	return ymd(new Date());
}

function aUtc(d: IsoDate): number {
	const [y, m, g] = d.split('-').map(Number);
	return Date.UTC(y, m - 1, g);
}

/** Somma (o sottrae) giorni. Aritmetica UTC, quindi immune al cambio di ora legale. */
export function aggiungiGiorni(d: IsoDate, n: number): IsoDate {
	const [y, m, g] = d.split('-').map(Number);
	return new Date(Date.UTC(y, m - 1, g + n)).toISOString().slice(0, 10);
}

/** Distanza in giorni fra due date civili, sempre positiva. */
export function giorniDiDifferenza(a: IsoDate, b: IsoDate): number {
	return Math.abs(aUtc(a) - aUtc(b)) / 86_400_000;
}

/**
 * Due periodi hanno almeno un giorno in comune.
 * È la semantica del filtro della Lista: Valentina chiede cosa è attivo in una
 * finestra, non cosa inizia e finisce dentro di essa.
 */
export function siSovrappongono(
	aInizio: IsoDate,
	aFine: IsoDate,
	bInizio: IsoDate,
	bFine: IsoDate
): boolean {
	return !(aFine < bInizio || aInizio > bFine);
}

/** Il periodo è attivo in quel giorno, estremi inclusi. */
export function attivoIl(inizio: IsoDate, fine: IsoDate, giorno: IsoDate): boolean {
	return inizio <= giorno && fine >= giorno;
}

const MESI = [
	'Gennaio',
	'Febbraio',
	'Marzo',
	'Aprile',
	'Maggio',
	'Giugno',
	'Luglio',
	'Agosto',
	'Settembre',
	'Ottobre',
	'Novembre',
	'Dicembre'
];

/** Nome del mese, indice 0-based. Array letterale e non Intl, per non dipendere
 *  dal locale del dispositivo e avere la maiuscola iniziale. */
export function nomeMese(mese0: number): string {
	return MESI[mese0];
}

/** '22/04/2026' */
export function formattaData(d: IsoDate): string {
	const [y, m, g] = d.split('-');
	return `${g}/${m}/${y}`;
}

/** Quante celle vuote prima del primo del mese, con la settimana che inizia di lunedì. */
export function offsetPrimoGiorno(anno: number, mese0: number): number {
	return (new Date(anno, mese0, 1).getDay() + 6) % 7;
}

export function giorniNelMese(anno: number, mese0: number): number {
	return new Date(anno, mese0 + 1, 0).getDate();
}
```

- [ ] **Step 8: Eseguire i test e verificare che passino, in tre fusi**

```bash
npm test
npm run test:fusi
```

Atteso: tutti verdi in tutte e tre le esecuzioni. Se `test:fusi` fallisce in UTC o Auckland mentre
`test` passa, c'è una dipendenza dal fuso nascosta: risolverla prima di proseguire.

- [ ] **Step 9: Commit**

```bash
git add -A
git commit -m "Imposta lo scaffolding SvelteKit e il modulo delle date civili

Le date del dominio sono stringhe YYYY-MM-DD confrontate lessicograficamente.
Il test gira su tre fusi orari (npm run test:fusi) perché il bug di slittamento
della versione PHP si manifestava solo a est di Greenwich."
```

---

### Task 2: parseDate, lettura delle celle Excel

**File:**
- Creare: `src/lib/server/excel.ts`
- Test: `src/lib/server/excel.test.ts`

**Interfacce consumate:** `ymd`, `IsoDate` da `$lib/date`.

**Interfacce prodotte:**
```ts
function parseDate(val: unknown): IsoDate | null
```

- [ ] **Step 1: Scrivere il test (deve fallire)**

`src/lib/server/excel.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import { parseDate } from './excel';

describe('parseDate', () => {
	// SheetJS con cellDates:true costruisce le date a mezzanotte LOCALE.
	// Leggerle con toISOString() le riporta al giorno prima: è esattamente
	// il bug che ha slittato tutte le date della versione PHP.
	it('legge una Date di SheetJS come data civile locale', () => {
		expect(parseDate(new Date(2026, 3, 23))).toBe('2026-04-23');
		expect(parseDate(new Date(2026, 2, 13))).toBe('2026-03-13');
		expect(parseDate(new Date(2026, 11, 31))).toBe('2026-12-31');
	});

	it('legge un seriale Excel', () => {
		// 45000 = 2023-03-15 nel sistema data 1900
		expect(parseDate(45000)).toBe('2023-03-15');
		expect(parseDate(46135)).toBe('2026-04-23');
	});

	it('legge le stringhe nei formati che i fornitori usano davvero', () => {
		expect(parseDate('2026-04-23')).toBe('2026-04-23');
		expect(parseDate('23/04/2026')).toBe('2026-04-23');
		expect(parseDate('3/4/2026')).toBe('2026-04-03');
		expect(parseDate('23-04-2026')).toBe('2026-04-23');
		expect(parseDate('  2026-04-23  ')).toBe('2026-04-23');
	});

	it('interpreta le stringhe all italiana, giorno prima del mese', () => {
		// new Date('03/04/2026') in JS è il 4 marzo all'americana: sarebbe un
		// errore di un mese su una data perfettamente plausibile.
		expect(parseDate('03/04/2026')).toBe('2026-04-03');
	});

	it('rifiuta ciò che non è una data', () => {
		expect(parseDate('')).toBeNull();
		expect(parseDate(null)).toBeNull();
		expect(parseDate(undefined)).toBeNull();
		expect(parseDate('APRILE')).toBeNull();
		expect(parseDate('n.d.')).toBeNull();
		expect(parseDate(new Date('non una data'))).toBeNull();
	});

	it('rifiuta i numeri che non sono date plausibili', () => {
		// Nella versione PHP il seriale 0.25 (un margine) diventava una data del 1899
		// e la riga passava tutte le validazioni. È così che a giugno 2026 sono finiti
		// in archivio eventi con anno 1952 e 2242.
		expect(parseDate(0.25)).toBeNull();
		expect(parseDate(1)).toBeNull();
		expect(parseDate(0)).toBeNull();
		expect(parseDate(22000)).toBeNull();
		expect(parseDate(102000)).toBeNull();
		expect(parseDate(-5)).toBeNull();
	});

	it('rifiuta le date fuori dall intervallo plausibile', () => {
		expect(parseDate('01/01/1850')).toBeNull();
		expect(parseDate('2242-03-27')).toBeNull();
	});
});
```

- [ ] **Step 2: Eseguire e verificare il fallimento**

```bash
npm test -- src/lib/server/excel.test.ts
```

Atteso: FALLISCE, il modulo non esiste.

- [ ] **Step 3: Scrivere `src/lib/server/excel.ts`**

```ts
/**
 * Lettura dei file Excel dei piani promo.
 *
 * Gira solo lato server. Nella versione PHP questo codice stava nel browser e
 * si portava dietro 923 KB di SheetJS caricati a ogni apertura dell'app, anche
 * per guardare soltanto il calendario.
 */
import { ymd, eIsoDate, type IsoDate } from '$lib/date';

/** Intervallo di anni accettabile per una data di piano promo. Fuori da qui
 *  non è una data: è un prezzo, una quantità o una cella sbagliata. */
const ANNO_MIN = 2015;
const ANNO_MAX = 2035;

function plausibile(d: IsoDate | null): IsoDate | null {
	if (!d) return null;
	const anno = Number(d.slice(0, 4));
	return anno >= ANNO_MIN && anno <= ANNO_MAX ? d : null;
}

/**
 * Interpreta una cella come data civile. Restituisce null se non lo è.
 *
 * Tre casi, e il terzo è quello che ha fatto danni:
 *  - Date: SheetJS con cellDates:true la costruisce a mezzanogiorno LOCALE,
 *    quindi si legge con ymd() e mai con toISOString().
 *  - number: seriale Excel, che nasce da un epoch UTC, quindi lì toISOString()
 *    è la lettura giusta. Ma va filtrato per plausibilità, altrimenti una
 *    quantità (22000) o un margine (0.25) diventa una data valida.
 *  - string: solo i formati che i tre fornitori usano davvero. Mai new Date(s),
 *    che interpreta '03/04/2026' come 4 marzo all'americana.
 */
export function parseDate(val: unknown): IsoDate | null {
	if (val === null || val === undefined || val === '') return null;

	if (val instanceof Date) {
		if (Number.isNaN(val.getTime())) return null;
		return plausibile(ymd(val));
	}

	if (typeof val === 'number') {
		if (!Number.isFinite(val) || val <= 0) return null;
		const d = new Date(Math.round((val - 25569) * 86_400 * 1000));
		if (Number.isNaN(d.getTime())) return null;
		return plausibile(d.toISOString().slice(0, 10));
	}

	if (typeof val === 'string') {
		const s = val.trim();
		if (eIsoDate(s)) return plausibile(s);
		const m = s.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/);
		if (m) {
			const [, g, mese, anno] = m;
			const iso = `${anno}-${mese.padStart(2, '0')}-${g.padStart(2, '0')}`;
			return eIsoDate(iso) ? plausibile(iso) : null;
		}
	}

	return null;
}
```

- [ ] **Step 4: Eseguire i test**

```bash
npm test -- src/lib/server/excel.test.ts
npm run test:fusi
```

Atteso: tutti verdi, in tutti e tre i fusi.

- [ ] **Step 5: Commit**

```bash
git add src/lib/server/excel.ts src/lib/server/excel.test.ts
git commit -m "Aggiungi parseDate con intervallo di plausibilità

Tre rami: Date di SheetJS letta con i componenti locali, seriale Excel letto
in UTC, stringa nei formati italiani. Il filtro sull'anno impedisce che una
quantità o un margine diventino una data, che è la causa degli eventi con
anno 1952 e 2242 finiti in produzione a giugno 2026."
```

---

### Task 3: Raggruppamento in eventi e fusione dei conflitti di date

Questo è il cuore del dominio e contiene il bug più costoso ancora aperto nella versione PHP:
su una catena di tre gruppi da fondere, un prodotto sparisce.

**File:**
- Modificare: `src/lib/server/excel.ts`
- Modificare: `src/lib/server/excel.test.ts`

**Interfacce consumate:** `parseDate`, `giorniDiDifferenza`, `IsoDate`.

**Interfacce prodotte:**
```ts
interface RigaValida {
	insegnaRaw: string;
	insegna: string;
	selloutStart: IsoDate;
	selloutEnd: IsoDate;
	prodotto: string;
	extraInfo: string;
}
interface Prodotto { nome: string; extraInfo: string }
interface Evento {
	insegnaRaw: string;
	insegna: string;
	selloutStart: IsoDate;
	selloutEnd: IsoDate;
	prodotti: Prodotto[];
}
interface Conflitto {
	insegna: string;
	a: { inizio: IsoDate; fine: IsoDate; prodotti: number };
	b: { inizio: IsoDate; fine: IsoDate; prodotti: number };
	risultato: { inizio: IsoDate; fine: IsoDate; prodotti: number };
}
const SOGLIA_FUSIONE_GIORNI = 2;
function raggruppa(righe: RigaValida[]): Evento[]
function fondiConflitti(eventi: Evento[], soglia?: number): { eventi: Evento[]; conflitti: Conflitto[] }
```

- [ ] **Step 1: Scrivere i test (devono fallire)**

Aggiungere in fondo a `src/lib/server/excel.test.ts`:

```ts
import { raggruppa, fondiConflitti, SOGLIA_FUSIONE_GIORNI, type RigaValida } from './excel';

const riga = (o: Partial<RigaValida> = {}): RigaValida => ({
	insegnaRaw: 'ESSELUNGA SPA',
	insegna: 'ESSELUNGA SPA',
	selloutStart: '2026-04-23',
	selloutEnd: '2026-05-06',
	prodotto: 'BURRO 200g',
	extraInfo: 'A',
	...o
});

describe('raggruppa', () => {
	it('mette insieme le righe con stessa insegna e stesse date', () => {
		const ev = raggruppa([riga(), riga({ prodotto: 'GRANA 1kg' })]);
		expect(ev).toHaveLength(1);
		expect(ev[0].prodotti).toEqual([
			{ nome: 'BURRO 200g', extraInfo: 'A' },
			{ nome: 'GRANA 1kg', extraInfo: 'A' }
		]);
	});

	it('separa quando cambia una data', () => {
		expect(raggruppa([riga(), riga({ selloutEnd: '2026-05-10' })])).toHaveLength(2);
	});

	it('separa quando cambia l insegna', () => {
		expect(raggruppa([riga(), riga({ insegna: 'BENNET SPA' })])).toHaveLength(2);
	});

	it('conserva insegnaRaw della prima riga del gruppo', () => {
		const ev = raggruppa([riga({ insegnaRaw: 'Esselunga S.p.A.' }), riga()]);
		expect(ev[0].insegnaRaw).toBe('Esselunga S.p.A.');
	});

	it('non perde prodotti ripetuti', () => {
		// Nei file veri lo stesso prodotto può comparire più volte nello stesso
		// periodo perché appartiene a promozioni diverse. Non è rumore.
		const ev = raggruppa([riga(), riga()]);
		expect(ev[0].prodotti).toHaveLength(2);
	});
});

describe('fondiConflitti', () => {
	const evento = (inizio: string, fine: string, nProdotti: number, insegna = 'ESSELUNGA SPA') => ({
		insegnaRaw: insegna,
		insegna,
		selloutStart: inizio,
		selloutEnd: fine,
		prodotti: Array.from({ length: nProdotti }, (_, i) => ({ nome: `P${i}`, extraInfo: '' }))
	});

	it('fonde quando una data coincide e l altra differisce entro la soglia', () => {
		const { eventi, conflitti } = fondiConflitti([
			evento('2026-04-23', '2026-05-06', 3),
			evento('2026-04-23', '2026-05-07', 5)
		]);
		expect(eventi).toHaveLength(1);
		expect(conflitti).toHaveLength(1);
		// vince chi ha più prodotti: le date sono in blocco quelle del vincitore
		expect(eventi[0].selloutEnd).toBe('2026-05-07');
		expect(eventi[0].prodotti).toHaveLength(8);
	});

	it('non fonde se differiscono entrambe le date', () => {
		// due date diverse significa due promozioni distinte, non un errore
		const { eventi, conflitti } = fondiConflitti([
			evento('2026-04-23', '2026-05-06', 3),
			evento('2026-04-24', '2026-05-07', 5)
		]);
		expect(eventi).toHaveLength(2);
		expect(conflitti).toHaveLength(0);
	});

	it('non fonde oltre la soglia', () => {
		const { eventi } = fondiConflitti([
			evento('2026-04-23', '2026-05-06', 3),
			evento('2026-04-23', '2026-05-09', 5)
		]);
		expect(eventi).toHaveLength(2);
	});

	it('non fonde insegne diverse', () => {
		const { eventi } = fondiConflitti([
			evento('2026-04-23', '2026-05-06', 3, 'ESSELUNGA SPA'),
			evento('2026-04-23', '2026-05-07', 5, 'BENNET SPA')
		]);
		expect(eventi).toHaveLength(2);
	});

	it('a parità di prodotti vince l intervallo più largo', () => {
		const { eventi } = fondiConflitti([
			evento('2026-04-23', '2026-05-06', 3),
			evento('2026-04-23', '2026-05-07', 3)
		]);
		expect(eventi).toHaveLength(1);
		expect(eventi[0].selloutEnd).toBe('2026-05-07');
	});

	it('non perde prodotti su una catena di tre gruppi', () => {
		// Bug ancora aperto nella versione PHP: il ciclo interno continua a usare
		// un gruppo già rimosso, e i suoi prodotti spariscono. 3+5+1 dava 8 su 9.
		const { eventi } = fondiConflitti([
			evento('2026-04-23', '2026-05-06', 3),
			evento('2026-04-23', '2026-05-07', 5),
			evento('2026-04-23', '2026-05-08', 1)
		]);
		expect(eventi).toHaveLength(1);
		expect(eventi[0].prodotti).toHaveLength(9);
	});

	it('conserva sempre il totale dei prodotti', () => {
		const ingresso = [
			evento('2026-04-23', '2026-05-06', 3),
			evento('2026-04-23', '2026-05-07', 5),
			evento('2026-06-01', '2026-06-10', 2, 'BENNET SPA')
		];
		const attesi = ingresso.reduce((s, e) => s + e.prodotti.length, 0);
		const { eventi } = fondiConflitti(ingresso);
		expect(eventi.reduce((s, e) => s + e.prodotti.length, 0)).toBe(attesi);
	});

	it('fonde anche a cavallo del cambio di ora legale', () => {
		// Nella versione PHP questa coppia dava 2.0416 giorni e non veniva fusa.
		const { eventi } = fondiConflitti([
			evento('2026-10-24', '2026-11-05', 3),
			evento('2026-10-26', '2026-11-05', 5)
		]);
		expect(eventi).toHaveLength(1);
	});

	it('la soglia predefinita è due giorni', () => {
		expect(SOGLIA_FUSIONE_GIORNI).toBe(2);
	});
});
```

- [ ] **Step 2: Eseguire e verificare il fallimento**

```bash
npm test -- src/lib/server/excel.test.ts
```

Atteso: FALLISCE, `raggruppa` e `fondiConflitti` non esistono.

- [ ] **Step 3: Implementare in `src/lib/server/excel.ts`**

Aggiungere in fondo al file:

```ts
export interface RigaValida {
	insegnaRaw: string;
	insegna: string;
	selloutStart: IsoDate;
	selloutEnd: IsoDate;
	prodotto: string;
	extraInfo: string;
}

export interface Prodotto {
	nome: string;
	extraInfo: string;
}

export interface Evento {
	insegnaRaw: string;
	insegna: string;
	selloutStart: IsoDate;
	selloutEnd: IsoDate;
	prodotti: Prodotto[];
}

export interface Conflitto {
	insegna: string;
	a: { inizio: IsoDate; fine: IsoDate; prodotti: number };
	b: { inizio: IsoDate; fine: IsoDate; prodotti: number };
	risultato: { inizio: IsoDate; fine: IsoDate; prodotti: number };
}

/**
 * Quanto possono distare le due date perché si tratti dello stesso volantino
 * scritto male, e non di due promozioni diverse. Valore empirico, ricavato dai
 * file veri dei tre fornitori.
 */
export const SOGLIA_FUSIONE_GIORNI = 2;

/** Righe con stessa insegna e stesso periodo diventano un evento con N prodotti. */
export function raggruppa(righe: RigaValida[]): Evento[] {
	const gruppi = new Map<string, Evento>();
	for (const r of righe) {
		const chiave = `${r.insegna}|${r.selloutStart}|${r.selloutEnd}`;
		let g = gruppi.get(chiave);
		if (!g) {
			g = {
				insegnaRaw: r.insegnaRaw,
				insegna: r.insegna,
				selloutStart: r.selloutStart,
				selloutEnd: r.selloutEnd,
				prodotti: []
			};
			gruppi.set(chiave, g);
		}
		g.prodotti.push({ nome: r.prodotto, extraInfo: r.extraInfo });
	}
	return [...gruppi.values()];
}

/**
 * Fonde gli eventi della stessa insegna in cui UNA data coincide e l'altra
 * differisce di pochi giorni: negli Excel di origine lo stesso volantino arriva
 * con date sfalsate.
 *
 * Se differiscono entrambe le date sono due promozioni distinte e non si toccano.
 * Vince chi ha più prodotti; a parità l'intervallo più largo; a parità ancora il
 * primo incontrato. Il perdente cede i prodotti e le date restano in blocco quelle
 * del vincitore, non l'unione dei due intervalli. Il criterio deterministico è ciò
 * che rende l'import ripetibile.
 */
export function fondiConflitti(
	eventi: Evento[],
	soglia = SOGLIA_FUSIONE_GIORNI
): { eventi: Evento[]; conflitti: Conflitto[] } {
	const prodottiInIngresso = eventi.reduce((s, e) => s + e.prodotti.length, 0);
	const conflitti: Conflitto[] = [];
	const rimossi = new Set<Evento>();

	const perInsegna = new Map<string, Evento[]>();
	for (const e of eventi) {
		const l = perInsegna.get(e.insegna);
		if (l) l.push(e);
		else perInsegna.set(e.insegna, [e]);
	}

	for (const lista of perInsegna.values()) {
		if (lista.length < 2) continue;
		for (let i = 0; i < lista.length; i++) {
			if (rimossi.has(lista[i])) continue;
			for (let j = i + 1; j < lista.length; j++) {
				if (rimossi.has(lista[j])) continue;
				const a = lista[i];
				const b = lista[j];

				const stessoInizio = a.selloutStart === b.selloutStart;
				const stessaFine = a.selloutEnd === b.selloutEnd;
				// entrambe uguali (duplicato) o entrambe diverse (due promo): non è un conflitto
				if (stessoInizio === stessaFine) continue;

				const distanza = stessoInizio
					? giorniDiDifferenza(a.selloutEnd, b.selloutEnd)
					: giorniDiDifferenza(a.selloutStart, b.selloutStart);
				if (distanza > soglia) continue;

				let vincitore: Evento;
				let perdente: Evento;
				if (a.prodotti.length !== b.prodotti.length) {
					vincitore = a.prodotti.length > b.prodotti.length ? a : b;
				} else {
					const ampiezza = (e: Evento) => giorniDiDifferenza(e.selloutStart, e.selloutEnd);
					vincitore = ampiezza(a) >= ampiezza(b) ? a : b;
				}
				perdente = vincitore === a ? b : a;

				conflitti.push({
					insegna: a.insegna,
					a: { inizio: a.selloutStart, fine: a.selloutEnd, prodotti: a.prodotti.length },
					b: { inizio: b.selloutStart, fine: b.selloutEnd, prodotti: b.prodotti.length },
					risultato: {
						inizio: vincitore.selloutStart,
						fine: vincitore.selloutEnd,
						prodotti: vincitore.prodotti.length + perdente.prodotti.length
					}
				});

				vincitore.prodotti.push(...perdente.prodotti);
				rimossi.add(perdente);

				// Se a ha perso non esiste più: proseguire il ciclo interno userebbe un
				// gruppo rimosso e ne butterebbe via i prodotti. È il bug della catena
				// a tre gruppi ancora aperto nella versione PHP.
				if (perdente === a) break;
			}
		}
	}

	const risultato = eventi.filter((e) => !rimossi.has(e));
	const prodottiInUscita = risultato.reduce((s, e) => s + e.prodotti.length, 0);
	if (prodottiInUscita !== prodottiInIngresso) {
		throw new Error(
			`fondiConflitti ha perso prodotti: ${prodottiInIngresso} in ingresso, ${prodottiInUscita} in uscita`
		);
	}

	return { eventi: risultato, conflitti };
}
```

Aggiungere `giorniDiDifferenza` all'import in cima al file:

```ts
import { ymd, eIsoDate, giorniDiDifferenza, type IsoDate } from '$lib/date';
```

- [ ] **Step 4: Eseguire i test**

```bash
npm test
npm run test:fusi
```

Atteso: tutti verdi. In particolare "non perde prodotti su una catena di tre gruppi" deve passare:
se fallisce con 8 invece di 9, manca il `break`.

- [ ] **Step 5: Commit**

```bash
git add src/lib/server/excel.ts src/lib/server/excel.test.ts
git commit -m "Aggiungi raggruppamento eventi e fusione dei conflitti di date

Corregge il bug della catena a tre gruppi, dove il ciclo interno continuava a
usare un gruppo già rimosso e ne perdeva i prodotti (3+5+1 dava 8 su 9).
L'invariante sul totale dei prodotti è verificata a runtime e solleva."
```

---

### Task 4: Normalizzazione delle insegne e proposte di accorpamento

**File:**
- Creare: `src/lib/insegne.ts`
- Test: `src/lib/insegne.test.ts`

Sta in `$lib` e non in `$lib/server` perché serve sia al parser lato server sia alla schermata
Impostazioni lato client.

**Interfacce prodotte:**
```ts
interface Alias { id: string; nomeCanonico: string; varianti: string[] }
interface Accorpamento { canonica: string; varianti: string[] }
function normalizzaInsegna(s: string): string
function costruisciMappaAlias(alias: Alias[]): Map<string, string>
function risolviInsegna(raw: string, mappa: Map<string, string>): { canonica: string; mappata: boolean }
function chiaveAccorpamento(s: string): string
function proponiAccorpamenti(insegne: string[]): Accorpamento[]
```

- [ ] **Step 1: Scrivere il test (deve fallire)**

`src/lib/insegne.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import {
	normalizzaInsegna,
	costruisciMappaAlias,
	risolviInsegna,
	chiaveAccorpamento,
	proponiAccorpamenti
} from './insegne';

describe('normalizzaInsegna', () => {
	it('toglie gli spazi ai bordi e alza le maiuscole', () => {
		expect(normalizzaInsegna('  Esselunga Spa ')).toBe('ESSELUNGA SPA');
	});

	it('NON collassa gli spazi interni', () => {
		// Misurato sui dati reali: collassarli non unisce nulla che non fosse
		// già unito, quindi è complessità senza guadagno.
		expect(normalizzaInsegna('COAL  BT')).toBe('COAL  BT');
	});
});

describe('risolviInsegna', () => {
	const alias = [
		{ id: 'a1', nomeCanonico: 'ESSELUNGA SPA', varianti: ['ESSELUNGA', 'Esselunga S.p.A.'] },
		{ id: 'a2', nomeCanonico: 'BENNET SPA', varianti: [] }
	];
	const mappa = costruisciMappaAlias(alias);

	it('riporta una variante al nome canonico', () => {
		expect(risolviInsegna('ESSELUNGA', mappa)).toEqual({ canonica: 'ESSELUNGA SPA', mappata: true });
		expect(risolviInsegna('  esselunga s.p.a. ', mappa)).toEqual({
			canonica: 'ESSELUNGA SPA',
			mappata: true
		});
	});

	it('considera mappato anche il nome canonico stesso', () => {
		// Senza questo, l'app segnala per sempre come "da mappare" un'insegna
		// che è già il nome giusto.
		expect(risolviInsegna('ESSELUNGA SPA', mappa)).toEqual({
			canonica: 'ESSELUNGA SPA',
			mappata: true
		});
		expect(risolviInsegna('BENNET SPA', mappa)).toEqual({ canonica: 'BENNET SPA', mappata: true });
	});

	it('restituisce il nome ripulito se non conosce l insegna', () => {
		expect(risolviInsegna('  TIGROS S.P.A. ', mappa)).toEqual({
			canonica: 'TIGROS S.P.A.',
			mappata: false
		});
	});

	it('un alias senza varianti è valido e serve a dichiarare "questo nome è già giusto"', () => {
		expect(costruisciMappaAlias([{ id: 'x', nomeCanonico: 'COOP', varianti: [] }]).size).toBe(1);
	});

	it('scarta le varianti vuote', () => {
		// Una variante '' farebbe combaciare qualunque cella vuota.
		const m = costruisciMappaAlias([{ id: 'x', nomeCanonico: 'COOP', varianti: ['', '  '] }]);
		expect(risolviInsegna('', m)).toEqual({ canonica: '', mappata: false });
	});
});

describe('proponiAccorpamenti', () => {
	it('riconosce le forme societarie come stessa insegna', () => {
		// Casi presi dai dati reali: 28 gruppi su 226 insegne.
		const p = proponiAccorpamenti(['ESSELUNGA', 'ESSELUNGA SPA']);
		expect(p).toHaveLength(1);
		expect(p[0].varianti.sort()).toEqual(['ESSELUNGA', 'ESSELUNGA SPA']);
	});

	it('gestisce i gruppi da tre', () => {
		const p = proponiAccorpamenti(['MAIORA', 'MAIORA SPA', 'MAIORA SRL']);
		expect(p).toHaveLength(1);
		expect(p[0].varianti).toHaveLength(3);
	});

	it('ignora la punteggiatura', () => {
		const p = proponiAccorpamenti([
			'CE.DI SIGMA CAMPANIA SPA',
			'CE.DI. SIGMA CAMPANIA',
			'CE.DI.SIGMA CAMPANIA S.p.A.'
		]);
		expect(p).toHaveLength(1);
		expect(p[0].varianti).toHaveLength(3);
	});

	it('riconosce le cooperative', () => {
		const p = proponiAccorpamenti(['CONAD NORD OVEST', 'CONAD NORD OVEST SOC COOP']);
		expect(p).toHaveLength(1);
	});

	it('sceglie come canonica la forma più lunga', () => {
		expect(proponiAccorpamenti(['ESSELUNGA', 'ESSELUNGA SPA'])[0].canonica).toBe('ESSELUNGA SPA');
	});

	it('non propone nulla per insegne davvero diverse', () => {
		expect(proponiAccorpamenti(['ESSELUNGA SPA', 'BENNET SPA', 'TIGROS S.P.A.'])).toEqual([]);
	});

	it('non propone accorpamenti per insegne che differiscono nel nome', () => {
		// CONAD CENTRO NORD e CONAD NORD OVEST sono due clienti diversi
		expect(proponiAccorpamenti(['CONAD CENTRO NORD', 'CONAD NORD OVEST'])).toEqual([]);
	});
});
```

- [ ] **Step 2: Eseguire e verificare il fallimento**

```bash
npm test -- src/lib/insegne.test.ts
```

- [ ] **Step 3: Scrivere `src/lib/insegne.ts`**

```ts
/**
 * Normalizzazione dei nomi delle insegne.
 *
 * Gli Excel dei tre fornitori scrivono lo stesso cliente in modi diversi
 * (ESSELUNGA / ESSELUNGA SPA, MAIORA / MAIORA SPA / MAIORA SRL). Sui dati reali
 * sono 28 gruppi su 226 insegne. La funzione alias esiste dalla prima versione
 * ma è rimasta sempre vuota, perché richiedeva di crearli a mano uno per uno:
 * qui il sistema li propone e l'utente conferma.
 */

export interface Alias {
	id: string;
	nomeCanonico: string;
	varianti: string[];
}

export interface Accorpamento {
	canonica: string;
	varianti: string[];
}

/** Forma di confronto di un'insegna. Niente collasso degli spazi interni:
 *  misurato sui dati veri, non unisce nulla di nuovo. */
export function normalizzaInsegna(s: string): string {
	return s.trim().toUpperCase();
}

/**
 * Mappa variante normalizzata → nome canonico, costruita una volta per import.
 * Include i nomi canonici stessi, così un'insegna già scritta bene risulta mappata
 * e non finisce fra quelle da sistemare.
 */
export function costruisciMappaAlias(alias: Alias[]): Map<string, string> {
	const mappa = new Map<string, string>();
	for (const a of alias) {
		const canonico = a.nomeCanonico.trim();
		if (!canonico) continue;
		mappa.set(normalizzaInsegna(canonico), canonico);
		for (const v of a.varianti) {
			const chiave = normalizzaInsegna(v);
			if (chiave) mappa.set(chiave, canonico);
		}
	}
	return mappa;
}

export function risolviInsegna(
	raw: string,
	mappa: Map<string, string>
): { canonica: string; mappata: boolean } {
	const pulito = raw.trim();
	const canonica = mappa.get(normalizzaInsegna(pulito));
	return canonica ? { canonica, mappata: true } : { canonica: pulito, mappata: false };
}

// ATTENZIONE: la versione a regex che stava qui era sbagliata in quattro modi ed è
// stata sostituita durante l'esecuzione (commit e2edbbc). Il codice buono è in
// src/lib/insegne.ts: leggere quello, non riscrivere questo.
//
// Cosa sbagliava, per non rifarlo:
//  - toglieva l'apostrofo PRIMA di applicare la regex, quindi il ramo pensato per
//    SOCIETA' era codice morto: "MAIORA SOCIETA' COOPERATIVA" dava MAIORASOCIETA
//    invece di MAIORA, e sui dati veri lasciava fuori un cliente reale
//  - tollerava la punteggiatura in modo incoerente fra i rami: S.C.A.R.L. no, SCARL sì
//  - non copriva SRLS
//  - toglieva COOP sempre, ma nella distribuzione alimentare COOP in prima posizione
//    è il marchio: Coop Alleanza e Coop Lombardia sono clienti diversi, e collassavano
//
// L'impostazione giusta è a token, non a regex: maiuscolo, ogni carattere non
// alfanumerico diventa spazio, si ricompattano le sigle scritte lettera per lettera
// (S P A → SPA, S C A R L → SCARL), si tolgono i token di rumore da un elenco chiuso,
// e COOP si toglie solo se non è il primo token.

/** Due insegne con la stessa chiave sono lo stesso cliente scritto diversamente. */
export function chiaveAccorpamento(s: string): string {
	/* vedi src/lib/insegne.ts */
}

/**
 * Raggruppa le insegne che collassano sulla stessa chiave. Come canonica propone
 * la forma più lunga, che è quasi sempre la ragione sociale completa.
 */
export function proponiAccorpamenti(insegne: string[]): Accorpamento[] {
	const perChiave = new Map<string, Set<string>>();
	for (const i of insegne) {
		const nome = i.trim();
		if (!nome) continue;
		const k = chiaveAccorpamento(nome);
		if (!k) continue;
		const s = perChiave.get(k);
		if (s) s.add(nome);
		else perChiave.set(k, new Set([nome]));
	}

	const proposte: Accorpamento[] = [];
	for (const insieme of perChiave.values()) {
		if (insieme.size < 2) continue;
		const varianti = [...insieme];
		const canonica = varianti.reduce((a, b) =>
			b.length > a.length || (b.length === a.length && b < a) ? b : a
		);
		proposte.push({ canonica, varianti });
	}
	return proposte.sort((a, b) => a.canonica.localeCompare(b.canonica, 'it'));
}
```

- [ ] **Step 4: Eseguire i test**

```bash
npm test
```

Atteso: tutti verdi. Se "non propone accorpamenti per insegne che differiscono nel nome" fallisce,
la regex `RUMORE` sta mangiando troppo: restringerla.

- [ ] **Step 5: Commit**

```bash
git add src/lib/insegne.ts src/lib/insegne.test.ts
git commit -m "Aggiungi normalizzazione insegne e proposte di accorpamento

Il nome canonico entra nella mappa insieme alle varianti, così un'insegna già
scritta bene non risulta da mappare. proponiAccorpamenti riconosce le forme
societarie e copre 26 dei 28 gruppi presenti nei dati reali."
```

---

### Task 5: Pipeline completa di lettura del workbook

Mette insieme i pezzi e li verifica su un file Excel vero.

**File:**
- Modificare: `src/lib/server/excel.ts`
- Modificare: `src/lib/server/excel.test.ts`
- Creare: `src/lib/server/fixtures/README.md`

**Interfacce consumate:** tutto quanto sopra.

**Interfacce prodotte:**
```ts
interface MappingColonne {
	insegna: number;
	product: number;
	selloutStart: number;
	selloutEnd: number;
	extraInfo: number;
	extraInfoLabel: string;
	headerRow: number;
}
interface RigaScartata { riga: number; motivo: string }
interface EsitoLettura {
	eventi: Evento[];
	conflitti: Conflitto[];
	scartate: RigaScartata[];
	ignorate: string[];
	nonMappate: string[];
	totaleRighe: number;
	valide: number;
	foglio: string;
}
function scegliFoglio(buffer: ArrayBuffer | Uint8Array, mapping: MappingColonne): string
function leggiWorkbook(
	buffer: ArrayBuffer | Uint8Array,
	mapping: MappingColonne,
	alias: Alias[],
	escluse: string[],
	foglio?: string
): EsitoLettura
```

- [ ] **Step 1: Preparare i file di prova**

I file Excel veri contengono piani promo di clienti e **non entrano nella repo** (`.gitignore`
esclude `*.xlsx`). Vanno copiati a mano:

```bash
mkdir -p src/lib/server/fixtures
cp "/Users/paolo/Desktop/xls/Caseifici GT Piani Promo 29.07.xlsx" src/lib/server/fixtures/caseifici.xlsx
cp "/Users/paolo/Desktop/xls/Salumifici GT_Piani Promo_agg 28.07.xlsx" src/lib/server/fixtures/salumifici.xlsx
cp "/Users/paolo/Desktop/xls/PIANO PROMO PARMACOTTO AL 27_07.xlsx" src/lib/server/fixtures/parmacotto.xlsx
```

`src/lib/server/fixtures/README.md`:

```markdown
# File di prova

I tre .xlsx di questa cartella sono piani promo reali e **non sono versionati**.
Chi clona la repo li deve copiare a mano da `~/Desktop/xls` (o richiederli).
I test che li usano si saltano da soli se i file non ci sono.

Mapping colonne verificato il 12 agosto 2026:

| file | foglio | insegna | product | start | end | extra |
|---|---|---|---|---|---|---|
| caseifici.xlsx | DB semplificato | 3 | 7 | 10 | 11 | 13 |
| salumifici.xlsx | Piani Promo Sell Out | 2 | 7 | 10 | 11 | 14 |
| parmacotto.xlsx | Export | 0 | 1 | 3 | 4 | 6 |
```

- [ ] **Step 2: Scrivere il test (deve fallire)**

Aggiungere in fondo a `src/lib/server/excel.test.ts`:

```ts
import { readFileSync, existsSync } from 'node:fs';
import { leggiWorkbook, scegliFoglio, type MappingColonne } from './excel';

const MAPPING: Record<string, MappingColonne> = {
	caseifici: {
		insegna: 3,
		product: 7,
		selloutStart: 10,
		selloutEnd: 11,
		extraInfo: 13,
		extraInfoLabel: 'Meccanica',
		headerRow: 0
	},
	salumifici: {
		insegna: 2,
		product: 7,
		selloutStart: 10,
		selloutEnd: 11,
		extraInfo: 14,
		extraInfoLabel: 'Meccanica',
		headerRow: 0
	},
	parmacotto: {
		insegna: 0,
		product: 1,
		selloutStart: 3,
		selloutEnd: 4,
		extraInfo: 6,
		extraInfoLabel: 'Volantino',
		headerRow: 0
	}
};

const fixture = (nome: string) => `src/lib/server/fixtures/${nome}.xlsx`;
const disponibile = (nome: string) => existsSync(fixture(nome));

describe('leggiWorkbook sui file veri', () => {
	it.runIf(disponibile('caseifici'))('legge il file Caseifici', () => {
		const esito = leggiWorkbook(readFileSync(fixture('caseifici')), MAPPING.caseifici, [], []);
		expect(esito.foglio).toBe('DB semplificato');
		expect(esito.totaleRighe).toBe(10232);
		expect(esito.valide).toBeGreaterThan(10000);
		expect(esito.scartate).toHaveLength(16);
		// zero date rovesciate: è la verifica che il mapping è quello giusto
		expect(esito.eventi.filter((e) => e.selloutEnd < e.selloutStart)).toHaveLength(0);
		// le insegne devono essere ragioni sociali, non codici articolo
		expect(esito.eventi.some((e) => e.insegna === 'ESSELUNGA SPA')).toBe(true);
	});

	it.runIf(disponibile('salumifici'))('legge il file Salumifici', () => {
		const esito = leggiWorkbook(readFileSync(fixture('salumifici')), MAPPING.salumifici, [], []);
		expect(esito.foglio).toBe('Piani Promo Sell Out');
		expect(esito.eventi.length).toBeGreaterThan(700);
		expect(esito.eventi.filter((e) => e.selloutEnd < e.selloutStart)).toHaveLength(0);
	});

	it.runIf(disponibile('parmacotto'))('legge il file Parmacotto', () => {
		const esito = leggiWorkbook(readFileSync(fixture('parmacotto')), MAPPING.parmacotto, [], []);
		expect(esito.foglio).toBe('Export');
		expect(esito.eventi.length).toBeGreaterThan(250);
	});

	it.runIf(disponibile('salumifici'))('sceglie da solo il foglio giusto', () => {
		// I file veri hanno più fogli e quello buono non è il primo.
		expect(scegliFoglio(readFileSync(fixture('salumifici')), MAPPING.salumifici)).toBe(
			'Piani Promo Sell Out'
		);
	});

	it.runIf(disponibile('caseifici'))('vale l invariante totale = valide + scartate + ignorate', () => {
		const esito = leggiWorkbook(readFileSync(fixture('caseifici')), MAPPING.caseifici, [], [
			'ESSELUNGA SPA'
		]);
		expect(esito.valide + esito.scartate.length + esito.ignorate.length).toBe(esito.totaleRighe);
	});

	it.runIf(disponibile('caseifici'))('esclude confrontando il nome canonico, non quello grezzo', () => {
		// Escludere ESSELUNGA SPA deve escludere anche le righe scritte ESSELUNGA,
		// perché l'alias si risolve PRIMA di applicare le esclusioni.
		const alias = [{ id: 'a1', nomeCanonico: 'ESSELUNGA SPA', varianti: ['ESSELUNGA'] }];
		const esito = leggiWorkbook(
			readFileSync(fixture('caseifici')),
			MAPPING.caseifici,
			alias,
			['ESSELUNGA SPA']
		);
		expect(esito.eventi.some((e) => e.insegna === 'ESSELUNGA SPA')).toBe(false);
		expect(esito.ignorate.length).toBeGreaterThan(0);
	});
});

describe('leggiWorkbook, motivi di scarto', () => {
	it.runIf(disponibile('parmacotto'))('numera le righe come le vede l utente in Excel', () => {
		const esito = leggiWorkbook(readFileSync(fixture('parmacotto')), MAPPING.parmacotto, [], []);
		for (const s of esito.scartate) {
			expect(s.riga).toBeGreaterThanOrEqual(2);
			expect(s.motivo).toMatch(/Data inizio|Data fine|Insegna|Prodotto/);
		}
	});
});
```

- [ ] **Step 3: Eseguire e verificare il fallimento**

```bash
npm test -- src/lib/server/excel.test.ts
```

Atteso: FALLISCE, `leggiWorkbook` non esiste.

- [ ] **Step 4: Implementare in `src/lib/server/excel.ts`**

Aggiungere l'import di SheetJS e delle insegne in cima:

```ts
import { read, utils } from 'xlsx';
import { costruisciMappaAlias, risolviInsegna, normalizzaInsegna, type Alias } from '$lib/insegne';
```

e in fondo al file:

```ts
export interface MappingColonne {
	insegna: number;
	product: number;
	selloutStart: number;
	selloutEnd: number;
	extraInfo: number;
	extraInfoLabel: string;
	headerRow: number;
}

export interface RigaScartata {
	riga: number;
	motivo: string;
}

export interface EsitoLettura {
	eventi: Evento[];
	conflitti: Conflitto[];
	scartate: RigaScartata[];
	ignorate: string[];
	nonMappate: string[];
	totaleRighe: number;
	valide: number;
	foglio: string;
}

type Matrice = unknown[][];

/**
 * Il buffer arriva come ArrayBuffer dagli endpoint (File.arrayBuffer()) e come
 * Buffer dai test che leggono da disco. Un Buffer di Node è già una Uint8Array,
 * quindi normalizzando a Uint8Array il tipo 'array' di SheetJS va bene per entrambi.
 */
function aUint8(buffer: ArrayBuffer | Uint8Array): Uint8Array {
	return buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
}

function apri(buffer: ArrayBuffer | Uint8Array) {
	// cellDates:true è obbligatorio: senza, le date arrivano come seriali e il
	// parsing dipende dal number format del foglio, che non tutti i fornitori mettono.
	return read(aUint8(buffer), { type: 'array', cellDates: true });
}

function righeDelFoglio(wb: ReturnType<typeof read>, foglio: string, mapping: MappingColonne) {
	// header:1 legge il foglio come matrice: il mapping è per indice di colonna,
	// non per nome. defval:'' impedisce che le celle vuote facciano slittare gli indici.
	const matrice = utils.sheet_to_json<unknown[]>(wb.Sheets[foglio], {
		header: 1,
		defval: ''
	}) as Matrice;
	return matrice.slice((mapping.headerRow ?? 0) + 1);
}

function quanteValide(righe: Matrice, m: MappingColonne): number {
	let n = 0;
	for (const r of righe) {
		if (
			parseDate(r[m.selloutStart]) &&
			parseDate(r[m.selloutEnd]) &&
			String(r[m.insegna] ?? '').trim() &&
			String(r[m.product] ?? '').trim()
		) {
			n++;
		}
	}
	return n;
}

/**
 * I file veri hanno più fogli e quello buono non è il primo: Salumifici ne ha
 * tre, Parmacotto due. Si sceglie quello che produce più righe valide con il
 * mapping dell'azienda, invece di chiederlo all'utente.
 */
export function scegliFoglio(buffer: ArrayBuffer | Uint8Array, mapping: MappingColonne): string {
	const wb = apri(buffer);
	let migliore = wb.SheetNames[0];
	let massimo = -1;
	for (const nome of wb.SheetNames) {
		const n = quanteValide(righeDelFoglio(wb, nome, mapping), mapping);
		if (n > massimo) {
			massimo = n;
			migliore = nome;
		}
	}
	return migliore;
}

/**
 * Legge un workbook e restituisce gli eventi pronti da salvare.
 *
 * L'ordine dei passaggi è vincolante:
 *   1. risolvi l'alias
 *   2. POI applica le esclusioni, confrontandole con il nome canonico
 *   3. raggruppa
 *   4. fondi i conflitti di date
 *   5. calcola le insegne non mappate
 * Invertire 1 e 2 fa sì che escludere ESSELUNGA SPA non escluda le righe
 * scritte ESSELUNGA.
 */
export function leggiWorkbook(
	buffer: ArrayBuffer | Uint8Array,
	mapping: MappingColonne,
	alias: Alias[],
	escluse: string[],
	foglio?: string
): EsitoLettura {
	const wb = apri(buffer);
	const nomeFoglio = foglio ?? scegliFoglio(buffer, mapping);
	if (!wb.Sheets[nomeFoglio]) throw new Error(`Il file non contiene il foglio "${nomeFoglio}"`);

	const righe = righeDelFoglio(wb, nomeFoglio, mapping);
	const mappaAlias = costruisciMappaAlias(alias);
	const insiemeEscluse = new Set(escluse.map(normalizzaInsegna));

	const valide: RigaValida[] = [];
	const scartate: RigaScartata[] = [];
	const ignorate: string[] = [];
	const nonMappate = new Set<string>();

	for (let i = 0; i < righe.length; i++) {
		const r = righe[i];
		// numero di riga come lo vede l'utente in Excel: intestazione + base 1
		const numeroRiga = i + (mapping.headerRow ?? 0) + 2;

		const inizio = parseDate(r[mapping.selloutStart]);
		const fine = parseDate(r[mapping.selloutEnd]);
		const insegnaRaw = String(r[mapping.insegna] ?? '').trim();
		const prodotto = String(r[mapping.product] ?? '').trim();

		// L'ordine dei controlli e i testi sono quelli della versione PHP:
		// l'utente li ha imparati.
		if (!inizio) {
			scartate.push({ riga: numeroRiga, motivo: 'Data inizio mancante o non valida' });
			continue;
		}
		if (!fine) {
			scartate.push({ riga: numeroRiga, motivo: 'Data fine mancante o non valida' });
			continue;
		}
		if (!insegnaRaw) {
			scartate.push({ riga: numeroRiga, motivo: 'Insegna vuota' });
			continue;
		}
		if (!prodotto) {
			scartate.push({ riga: numeroRiga, motivo: 'Prodotto vuoto' });
			continue;
		}
		if (fine < inizio) {
			scartate.push({
				riga: numeroRiga,
				motivo: `Data fine (${fine}) precedente all'inizio (${inizio})`
			});
			continue;
		}

		const { canonica, mappata } = risolviInsegna(insegnaRaw, mappaAlias);

		if (insiemeEscluse.has(normalizzaInsegna(canonica))) {
			ignorate.push(canonica);
			continue;
		}
		if (!mappata) nonMappate.add(canonica);

		valide.push({
			insegnaRaw,
			insegna: canonica,
			selloutStart: inizio,
			selloutEnd: fine,
			prodotto,
			extraInfo: String(r[mapping.extraInfo] ?? '').trim()
		});
	}

	const { eventi, conflitti } = fondiConflitti(raggruppa(valide));

	if (valide.length + scartate.length + ignorate.length !== righe.length) {
		throw new Error('Invariante violata: valide + scartate + ignorate diverso dal totale righe');
	}

	return {
		eventi,
		conflitti,
		scartate,
		ignorate,
		nonMappate: [...nonMappate].sort((a, b) => a.localeCompare(b, 'it')),
		totaleRighe: righe.length,
		valide: valide.length,
		foglio: nomeFoglio
	};
}
```

- [ ] **Step 5: Eseguire i test**

```bash
npm test
npm run test:fusi
```

Atteso: tutti verdi. Se i numeri sui file veri non combaciano (10232 righe, 16 scarti), il file di
prova non è quello di luglio 2026: aggiornare i valori attesi nel test e annotarlo nel README delle
fixture.

- [ ] **Step 6: Verificare che la suite passi anche senza i file di prova**

```bash
mv src/lib/server/fixtures /tmp/fixtures-promogest
npm test
mv /tmp/fixtures-promogest src/lib/server/fixtures
```

Atteso: verde, con i test sui file veri saltati. Chi clona la repo senza i file deve poter lavorare.

- [ ] **Step 7: Commit**

```bash
git add src/lib/server/excel.ts src/lib/server/excel.test.ts src/lib/server/fixtures/README.md
git commit -m "Aggiungi leggiWorkbook, pipeline completa di lettura

Sceglie da solo il foglio con più righe valide, risolve gli alias PRIMA di
applicare le esclusioni, e scarta le righe con fine precedente all'inizio
invece di accettarle in silenzio. Verificata sui tre file veri di luglio 2026.
I file di prova non sono versionati e i relativi test si saltano da soli."
```

---

## Cosa arriva nelle fasi successive

Fuori dallo scope di questo piano, in ordine.

**Fase 2, dati e API.** Schema Drizzle con i vincoli `CHECK`, client con la guardia su
`DATABASE_URL`, autenticazione JWT con `token_version`, `hooks.server.ts` che protegge solo
`/api/*` e risponde 401 JSON, gli endpoint di lettura, l'import in due passi con bozza e conferma
in un solo `db.batch()`, e lo script di migrazione dai JSON di produzione.

**Fase 3, interfaccia.** Guscio con `ssr = false`, cache dati in `localStorage`, le cinque
schermate, il service worker che precachea anche `prerendered` e non intercetta mai `/api`, e
l'avviso di nuova versione.

## Verifica del piano contro la spec

Coperto qui: §3 (stack e installazione SheetJS dal tarball), §4.1 limitatamente alla
configurazione dell'adapter, §9 test 1, 2, 3, 4, 5, 9 e 12, §10 per intero.

Rimandato con cognizione: §4.2, §4.3, §4.4, §5, §6, §7, §8, §11 e i test 6, 7, 8, 10, 11, che
richiedono database o interfaccia e stanno nelle fasi 2 e 3.
