added book to bill

This commit is contained in:
2026-10-15 10:06:58 +02:00
parent ab7dd0f2f9
commit c3234e3160
2 changed files with 701 additions and 4 deletions
+118
View File
@@ -70,6 +70,124 @@ class MagoSalesStats
3407876 => -1, // Nota di credito (TD04) → in negativo serie D ...
];
/**
* CLIENTI DA RICHIAMARE: clienti "regolari" (almeno DORMANT_MIN_ORDERS giorni d'ordine negli ultimi
* DORMANT_WINDOW_YEARS anni) in ritardo rispetto al loro ritmo abituale (mediana dei giorni tra due ordini).
* ritardo = giorni dall'ultimo ordine / ritmo abituale
*/
const DORMANT_WINDOW_YEARS = 3;
const DORMANT_MIN_ORDERS = 4;
const DORMANT_MIN_DAYS = 30; // sotto i 30 giorni dall'ultimo ordine nessun allarme
const DORMANT_WATCH = 1.5; // da 1,5 volte il ritmo: attenzione
const DORMANT_SLEEP = 2.0; // da 2 volte: dormiente
const DORMANT_LOST = 4.0; // da 4 volte (o oltre 1 anno): probabilmente perso
/**
* Calcola la lista clienti da richiamare a partire dagli ordini (code, name, order_date, v, salesperson, area, ord_no).
*/
public static function dormantCustomers(array $rows)
{
$by = [];
foreach ($rows as $r) {
$c = $r['code'];
if (!isset($by[$c])) $by[$c] = ['code' => $c, 'name' => $r['name'], 'dates' => [], 'orders' => []];
$by[$c]['dates'][$r['order_date']] = true;
$by[$c]['orders'][] = $r;
}
$today = new DateTime('today');
$out = [];
foreach ($by as $c) {
$dates = array_keys($c['dates']);
sort($dates);
$n = count($dates);
if ($n < self::DORMANT_MIN_ORDERS) continue;
$gaps = [];
for ($k = 1; $k < $n; $k++) {
$gaps[] = (int)(new DateTime($dates[$k - 1]))->diff(new DateTime($dates[$k]))->days;
}
sort($gaps);
$m = count($gaps);
$median = $m % 2 ? $gaps[intdiv($m, 2)] : ($gaps[$m / 2 - 1] + $gaps[$m / 2]) / 2;
$rhythm = max(7, (float)$median);
$last = end($dates);
$since = (int)(new DateTime($last))->diff($today)->days;
if ($since < self::DORMANT_MIN_DAYS) continue;
$ratio = $since / $rhythm;
if ($ratio >= self::DORMANT_LOST || $since >= 365) $level = 'lost';
elseif ($ratio >= self::DORMANT_SLEEP) $level = 'dormant';
elseif ($ratio >= self::DORMANT_WATCH) $level = 'watch';
else continue;
// ordinato nei 12 mesi fino all'ultimo ordine = quanto vale il cliente "a regime"
$from12 = (new DateTime($last))->modify('-365 days')->format('Y-m-d');
$v12 = 0.0;
$lastOrder = null;
foreach ($c['orders'] as $o) {
if ($o['order_date'] > $from12) $v12 += (float)$o['v'];
if (
!$lastOrder || $o['order_date'] > $lastOrder['order_date'] ||
($o['order_date'] === $lastOrder['order_date'] && $o['ord_no'] > $lastOrder['ord_no'])
) $lastOrder = $o;
}
$out[] = [
'code' => $c['code'],
'name' => $c['name'],
'salesperson' => $lastOrder['salesperson'],
'area' => $lastOrder['area'],
'last_date' => $last,
'last_no' => $lastOrder['ord_no'],
'days_since' => $since,
'rhythm' => (int)round($rhythm),
'ratio' => round($ratio, 1),
'orders' => $n,
'value12' => round($v12, 2),
'level' => $level,
];
}
usort($out, function ($a, $b) {
return $b['value12'] <=> $a['value12'];
});
return $out;
}
/**
* Previsione di fine anno: valore dell'anno in corso ad oggi diviso la quota che, negli anni
* precedenti (max 3), si era raggiunta alla stessa data. $rows: [y, full, ytd].
*/
public static function yearForecast(array $rows, $curYear)
{
$cur = null;
$shares = [];
$prevFull = null;
foreach ($rows as $r) {
$y = (int)$r['y'];
if ($y === $curYear) $cur = (float)$r['ytd'];
elseif ($y >= $curYear - 3 && $y < $curYear && (float)$r['full'] > 0 && (float)$r['ytd'] > 0) {
$shares[$y] = (float)$r['ytd'] / (float)$r['full'];
}
if ($y === $curYear - 1) $prevFull = (float)$r['full'];
}
if ($cur === null || !$shares) {
return ['ytd' => $cur, 'forecast' => null, 'prev_full' => $prevFull];
}
$avg = array_sum($shares) / count($shares);
ksort($shares);
return [
'ytd' => round($cur, 2),
'forecast' => round($cur / $avg, 2),
'low' => round($cur / max($shares), 2),
'high' => round($cur / min($shares), 2),
'prev_full' => $prevFull,
'years' => array_keys($shares),
'share' => round($avg * 100, 1),
];
}
/** Etichette dei tipi documento (tabella documenti). */
const INVOICE_TYPE_LABELS = [
3407874 => 'Fattura',