Files
zibo-dashboard/public/userarea/class/MagoMatriceStats.php
T
2026-10-24 09:54:38 +02:00

846 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Statistiche vendite per MATRICE.
* - prezzo di vendita per unità (€/m, €/kg) nel tempo e per cliente
* - consumo mescole stimato dalla scheda di lavoro (g/m per mescola)
* Fonte: righe ordine Mago (mago_sale_ord_detail), solo lettura. Prezzo = imponibile riga ÷ quantità.
*/
require_once __DIR__ . '/MagoSalesStats.php';
class MagoMatriceStats
{
/** Righe con quantità <= MIN_QTY escluse (servizi: quantità 0 o 1) */
const MIN_QTY = 1;
/** Articoli esclusi (spese, attrezzature) */
const EXCLUDED_ITEM_PREFIXES = ['SPES', 'SPATT'];
/** Prima data considerata */
const SINCE = '2020-01-01';
const UOM_METERS = ['MT', 'M', 'ML', 'MTL'];
const UOM_KG = ['KG', 'KGS'];
const UOM_PIECES = ['N.', 'N', 'NR', 'NR.', 'PZ', 'PZ.'];
/** Tipi di collegamento che contano come "matrice in anagrafica" */
const LINKED_TYPES = ['esatta', 'revisione', 'mappa'];
/** Esito del riconoscimento (campo type) */
const TYPE_LABELS = [
'esatta' => 'Matrice',
'revisione' => 'Altra revisione',
'mappa' => 'Abbinata a mano',
'da_creare' => 'Non in anagrafica',
'profilo' => 'Profilo',
'altro' => 'Non riconosciuta',
];
/** Tipo dalla lettera iniziale di Matrice/Misura (campo cat) */
const CAT_LABELS = [
'M' => 'Matrice',
'T' => 'Tubo',
'R' => 'Rettangolo',
'D' => 'Diametro',
'Q' => 'Quadrato',
'S' => 'Profilo S',
'X' => 'Non riconosciuta',
];
private static ?array $idx = null;
private static array $cache = [];
/* ============================================================
* UTILITY
* ============================================================ */
public static function norm($s): string
{
return strtoupper(preg_replace('/\s+/u', ' ', trim((string)$s)));
}
/** m = metri, kg = chili, pz = pezzi, altro */
public static function uomKind($u): string
{
$u = self::norm($u);
if (in_array($u, self::UOM_METERS, true)) return 'm';
if (in_array($u, self::UOM_KG, true)) return 'kg';
if (in_array($u, self::UOM_PIECES, true)) return 'pz';
return 'altro';
}
public static function photoUrl($p): ?string
{
$p = trim((string)$p);
if ($p === '') return null;
if (preg_match('~^(https?:)?//~i', $p) || strpos($p, '/') !== false) return $p;
return 'photos/matrici/' . $p;
}
private static function inList(string $prefix, array $values, array &$params): string
{
$ph = [];
foreach (array_values($values) as $i => $v) {
$ph[] = ":{$prefix}{$i}";
$params["{$prefix}{$i}"] = $v;
}
return implode(',', $ph);
}
/* ============================================================
* RICONOSCIMENTO MATRICE (stesse regole della coda produzione:
* mappa manuale → codice senza M, con e senza revisione → non in anagrafica / profilo T-R-D-Q-S / altro)
* ============================================================ */
/**
* Codice matrice normalizzato: senza la M iniziale e separato dalla revisione.
* "M00407P-1" → ['00407P', '1'] · "00786S" → ['00786S', '']
*/
public static function splitCode(string $s): array
{
$s = strtoupper(trim($s));
$s = preg_replace('/^M\s?(?=\d)/', '', $s);
$s = preg_replace('/\s+/', '', $s);
if (preg_match('/^(.+?)-(\d+)$/', $s, $m)) return [$m[1], $m[2]];
return [$s, ''];
}
public static function index(PDO $pdo): array
{
if (self::$idx !== null) return self::$idx;
$base = []; // codice senza M e senza revisione => [revisione => id]
$info = [];
foreach ($pdo->query("SELECT id, nome, photo FROM matrice ORDER BY id") as $r) {
$id = (int)$r['id'];
$info[$id] = ['name' => (string)$r['nome'], 'photo' => self::photoUrl($r['photo'])];
$parts = preg_split('/\s+/u', trim((string)$r['nome']));
$first = $parts[0] ?? '';
if ($first === '') continue;
[$code, $rev] = self::splitCode($first);
if ($code === '') continue;
$base[$code][$rev] = $id;
}
$map = [];
try {
foreach ($pdo->query("SELECT codice_mago, idmatrice FROM matrice_mago_map WHERE idmatrice IS NOT NULL") as $r) {
$map[self::norm($r['codice_mago'])] = (int)$r['idmatrice'];
}
} catch (Throwable $e) {
}
return self::$idx = ['base' => $base, 'info' => $info, 'map' => $map];
}
/**
* Matrice dell'anagrafica per codice (senza M) e revisione, provando con e senza revisione.
* - stessa revisione, oppure revisione assente da una delle due parti → esatta
* - revisioni diverse da entrambe le parti → revisione (avviso)
* @return array|null [id, type]
*/
private static function findMatrix(array $ix, string $code, string $rev): ?array
{
$revs = $ix['base'][$code] ?? null;
if (!$revs) return null;
if (isset($revs[$rev])) return [$revs[$rev], 'esatta'];
if ($rev !== '' && isset($revs[''])) return [$revs[''], 'esatta'];
$keys = array_keys($revs);
usort($keys, fn($a, $b) => (int)$b <=> (int)$a); // revisione più alta
return [$revs[$keys[0]], $rev === '' ? 'esatta' : 'revisione'];
}
/**
* @return array key, id, cat (M/T/R/D/Q/S/X), type (esito), label, code
* key: m{id} (matrice in anagrafica) | c:{codice} (non in anagrafica) | p:{valore} (profilo) | x:{valore}
*/
public static function resolve(PDO $pdo, $raw): array
{
$n = self::norm($raw);
if (isset(self::$cache[$n])) return self::$cache[$n];
$ix = self::index($pdo);
$linked = function (int $id, string $type, string $code) use ($ix, $n) {
return ['key' => 'm' . $id, 'id' => $id, 'cat' => 'M', 'type' => $type, 'label' => $ix['info'][$id]['name'] ?? $n, 'code' => $code];
};
if (isset($ix['map'][$n]) && isset($ix['info'][$ix['map'][$n]])) {
$res = $linked($ix['map'][$n], 'mappa', $n);
} elseif (preg_match('/(?<![A-Z])M\s?(\d{3,6}[A-Z]{0,2})(?:\s?-\s?(\d+))?/', $n, $m)) {
// codice matrice anche in mezzo al campo (Q8X8M00936P, S5X5,5 M00456S): si cerca senza la M
$code = $m[1];
$rev = $m[2] ?? '';
$label = $code . ($rev !== '' ? '-' . $rev : '');
$f = self::findMatrix($ix, $code, $rev);
if ($f) {
$res = $linked($f[0], $f[1], $label);
} else {
$res = ['key' => 'c:' . $code, 'id' => null, 'cat' => 'M', 'type' => 'da_creare', 'label' => $label, 'code' => $label];
}
} elseif (preg_match('/^([DRTQS])\s?\d/', $n, $m)) {
$res = ['key' => 'p:' . $n, 'id' => null, 'cat' => $m[1], 'type' => 'profilo', 'label' => $n, 'code' => $n];
} else {
$res = ['key' => 'x:' . $n, 'id' => null, 'cat' => 'X', 'type' => 'altro', 'label' => $n, 'code' => $n];
}
return self::$cache[$n] = $res;
}
/* ============================================================
* MESCOLE
* ============================================================ */
/**
* Per ogni matrice: ultima scheda di lavoro (prima le attive, poi la più recente)
* con le mescole e i loro g/m. Senza scheda: mescole dell'anagrafica matrice (senza g/m).
*/
public static function mixInfo(PDO $pdo, array $ids): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
$out = [];
foreach (array_chunk($ids, 500) as $chunk) {
$in = implode(',', $chunk);
$ws = [];
$rows = $pdo->query("SELECT id, idmatrice, worksheet_number, worksheet_date, revision_code, worksheet_status
FROM work_sheets WHERE idmatrice IN ($in)
ORDER BY idmatrice, (worksheet_status = 'active') DESC, worksheet_date DESC, id DESC")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$m = (int)$r['idmatrice'];
if (!isset($ws[$m])) $ws[$m] = $r;
}
$mix = [];
if ($ws) {
$wsIds = implode(',', array_map(fn($r) => (int)$r['id'], $ws));
foreach (
$pdo->query("SELECT w.worksheet_id, w.idmescola, w.mix_position, w.mix_weight_g_m, m.nome, m.nomeuscita
FROM work_sheet_mescole w JOIN mescole m ON m.id = w.idmescola
WHERE w.worksheet_id IN ($wsIds)
ORDER BY w.worksheet_id, w.mix_position") as $r
) {
$mix[(int)$r['worksheet_id']][] = [
'id' => (int)$r['idmescola'],
'name' => (string)$r['nome'],
'out' => (string)($r['nomeuscita'] ?? ''),
'pos' => (int)$r['mix_position'],
'gm' => $r['mix_weight_g_m'] !== null ? (float)$r['mix_weight_g_m'] : null,
];
}
}
$ana = [];
foreach (
$pdo->query("SELECT mm.idmatrice, m.id, m.nome, m.nomeuscita
FROM matrici_mescole mm JOIN mescole m ON m.id = mm.idmescola
WHERE mm.idmatrice IN ($in) ORDER BY m.nome") as $r
) {
$ana[(int)$r['idmatrice']][] = ['id' => (int)$r['id'], 'name' => (string)$r['nome'], 'out' => (string)($r['nomeuscita'] ?? ''), 'pos' => 0, 'gm' => null];
}
foreach ($chunk as $id) {
$w = $ws[$id] ?? null;
$list = $w ? ($mix[(int)$w['id']] ?? []) : [];
$src = $list ? 'scheda' : 'nessuna';
if (!$list && !empty($ana[$id])) {
$list = $ana[$id];
$src = 'anagrafica';
}
$gmTot = 0.0;
$missing = 0;
foreach ($list as $x) {
if ($x['gm'] !== null && $x['gm'] > 0) $gmTot += $x['gm'];
else $missing++;
}
$out[$id] = [
'ws' => $w ? [
'id' => (int)$w['id'],
'number' => $w['worksheet_number'],
'date' => $w['worksheet_date'],
'rev' => $w['revision_code'],
'active' => $w['worksheet_status'] === 'active',
] : null,
'mix' => $list,
'source' => $src,
'gm_total' => $gmTot,
'gm_missing' => $missing,
];
}
}
return $out;
}
/**
* Kg di mescola per una quantità venduta.
* - Metri: kg = metri × g/m ÷ 1000, mescola per mescola.
* - Chili: i kg si dividono tra le mescole in proporzione ai g/m; senza g/m in parti uguali (2 → 50/50, 3 → 33%).
* - Pezzi: in attesa della conversione pezzi → metri.
* status: ok | gm_part (g/m mancante su qualche mescola) | gm (nessun g/m) | none (nessuna mescola) | pz | altro
*/
public static function mixKg(?array $info, string $kind, float $qty): array
{
if ($kind === 'pz') return ['status' => 'pz', 'kg' => []];
if ($kind === 'altro') return ['status' => 'altro', 'kg' => []];
if (!$info || !$info['mix']) return ['status' => 'none', 'kg' => []];
$kg = [];
if ($kind === 'm') {
if ($info['gm_total'] <= 0) return ['status' => 'gm', 'kg' => []];
foreach ($info['mix'] as $x) {
if ($x['gm'] > 0) $kg[$x['id']] = ($kg[$x['id']] ?? 0) + $qty * $x['gm'] / 1000;
}
return ['status' => $info['gm_missing'] ? 'gm_part' : 'ok', 'kg' => $kg];
}
$n = count($info['mix']);
foreach ($info['mix'] as $x) {
$share = $info['gm_total'] > 0 ? max(0.0, (float)$x['gm']) / $info['gm_total'] : 1 / $n;
$kg[$x['id']] = ($kg[$x['id']] ?? 0) + $qty * $share;
}
return ['status' => ($info['gm_total'] > 0 && $info['gm_missing']) ? 'gm_part' : 'ok', 'kg' => $kg];
}
/* ============================================================
* DATI
* ============================================================ */
/** Righe ordine con Matrice/Misura: dal giorno $lo oppure ancora da consegnare */
public static function fetchLines(PDO $pdo, string $lo, array $customers = [], ?array $raws = null, ?string $uom = null): array
{
if ($raws !== null && !$raws) return [];
$from = MagoSalesStats::baseFrom();
$p = ['lo' => $lo];
$w = [
"x.status <> 'annullato'",
"d.cancelled = 0",
"d.matrice_misura <> ''",
"d.qty > " . (float)self::MIN_QTY,
"(x.order_date >= :lo OR (d.delivered = 0 AND x.status <> 'evaso'))",
];
foreach (self::EXCLUDED_ITEM_PREFIXES as $i => $pre) {
$w[] = "d.item NOT LIKE :ex$i";
$p["ex$i"] = $pre . '%';
}
if ($customers) $w[] = "x.customer IN (" . self::inList('cu', $customers, $p) . ")";
if ($raws !== null) $w[] = "d.matrice_misura IN (" . self::inList('mm', $raws, $p) . ")";
if ($uom !== null) {
$w[] = "UPPER(TRIM(d.uom)) = :uom";
$p['uom'] = $uom;
}
$sql = "SELECT d.sale_ord_id, d.sub_id, d.matrice_misura, d.uom, d.item, d.description, d.qty, d.delivered_qty, d.delivered,
CASE WHEN d.taxable_amount <> 0 THEN d.taxable_amount ELSE d.qty * d.unit_value - d.discount_amount END AS value,
x.order_date, x.customer, x.internal_ord_no, x.status
FROM mago_sale_ord_detail d
JOIN $from ON x.sale_ord_id = d.sale_ord_id
WHERE " . implode(' AND ', $w) . "
ORDER BY x.order_date, d.sale_ord_id, d.sub_id";
return MagoSalesStats::fetchAllQ($pdo, $sql, $p);
}
public static function customerNames(PDO $pdo, array $codes): array
{
$codes = array_values(array_unique(array_filter($codes, fn($c) => $c !== '')));
$out = [];
foreach (array_chunk($codes, 500) as $chunk) {
$p = [];
$in = self::inList('c', $chunk, $p);
$rows = MagoSalesStats::fetchAllQ($pdo, "SELECT cust_supp AS code, company_name AS name FROM mago_cust_supp
WHERE cust_supp_type = " . MagoSalesStats::CUSTOMER_TYPE . " AND cust_supp IN ($in)", $p);
foreach ($rows as $r) $out[$r['code']] = $r['name'] ?: $r['code'];
}
return $out;
}
private static function lineIsOpen(array $l): float
{
if ((int)$l['delivered'] !== 0 || $l['status'] === 'evaso') return 0.0;
return max(0.0, (float)$l['qty'] - (float)$l['delivered_qty']);
}
/* ============================================================
* CLASSIFICA MATRICI + KPI + MESCOLE
* ============================================================ */
public static function buildList(PDO $pdo, string $from, string $to, array $customers = []): array
{
$d12 = date('Y-m-d', strtotime('-365 days'));
$d24 = date('Y-m-d', strtotime('-730 days'));
$lines = self::fetchLines($pdo, min($from, $d24), $customers);
$ids = [];
foreach ($lines as $i => $l) {
$lines[$i]['_r'] = self::resolve($pdo, $l['matrice_misura']);
if ($lines[$i]['_r']['id']) $ids[] = $lines[$i]['_r']['id'];
}
$mix = self::mixInfo($pdo, $ids);
$ix = self::index($pdo);
$spark = [];
for ($i = 11; $i >= 0; $i--) $spark[] = date('Y-m', strtotime("first day of -$i months"));
$sparkPos = array_flip($spark);
$agg = [];
$mixTot = [];
$mixMonth = [];
$mixNames = [];
$kpiClients = [];
$kpi = [
'value' => 0,
'lines' => 0,
'qty_m' => 0,
'val_m' => 0,
'pq_m' => 0,
'qty_kg' => 0,
'val_kg' => 0,
'pq_kg' => 0,
'pz_lines' => 0,
'pz_value' => 0,
'pz_qty' => 0,
'kg_mix' => 0,
'kg_open' => 0,
'open_value' => 0,
'linked_value' => 0,
'profile_value' => 0,
'unlinked_value' => 0,
'gm_missing_lines' => 0,
'gm_missing_value' => 0,
'matrici' => 0,
];
$linkedIds = [];
foreach ($lines as $l) {
$r = $l['_r'];
$uom = self::norm($l['uom']);
$kind = self::uomKind($uom);
$key = $r['key'] . '|' . $uom;
$qty = (float)$l['qty'];
$val = (float)$l['value'];
$date = (string)$l['order_date'];
$ym = substr($date, 0, 7);
$inP = $date >= $from && $date <= $to;
$res = self::lineIsOpen($l);
$mi = $r['id'] ? ($mix[$r['id']] ?? null) : null;
if (!isset($agg[$key])) {
$agg[$key] = [
'key' => $key,
'rkey' => $r['key'],
'id' => $r['id'],
'cat' => $r['cat'],
'type' => $r['type'],
'name' => $r['label'],
'code' => $r['code'],
'photo' => $r['id'] ? ($ix['info'][$r['id']]['photo'] ?? null) : null,
'uom' => $uom,
'kind' => $kind,
'raws' => [],
'n' => 0,
'qty' => 0,
'value' => 0,
'pq' => 0,
'orders' => [],
'clients' => [],
'pmin' => null,
'pmax' => null,
'last_date' => '',
'last_price' => null,
'v12' => 0,
'q12' => 0,
'vp' => 0,
'qp' => 0,
'sv' => array_fill(0, 12, 0),
'sq' => array_fill(0, 12, 0),
'kg' => 0,
'open_qty' => 0,
'open_value' => 0,
'open_kg' => 0,
'mix_status' => self::mixKg($mi, $kind, 1)['status'],
'mix_n' => $mi ? count($mi['mix']) : 0,
'gm_total' => $mi ? $mi['gm_total'] : 0,
];
}
$a = &$agg[$key];
$a['raws'][self::norm($l['matrice_misura'])] = 1;
if ($inP) {
$a['n']++;
$a['qty'] += $qty;
$a['value'] += $val;
$a['orders'][$l['sale_ord_id']] = 1;
$a['clients'][$l['customer']] = 1;
$kpiClients[$l['customer']] = 1;
if ($val > 0) {
$p = $val / $qty;
$a['pq'] += $qty;
$a['pmin'] = $a['pmin'] === null ? $p : min($a['pmin'], $p);
$a['pmax'] = $a['pmax'] === null ? $p : max($a['pmax'], $p);
if ($date >= $a['last_date']) {
$a['last_date'] = $date;
$a['last_price'] = $p;
}
}
$mk = self::mixKg($mi, $kind, $qty);
foreach ($mk['kg'] as $mid => $kg) {
$a['kg'] += $kg;
$kpi['kg_mix'] += $kg;
$mixTot[$mid]['kg'] = ($mixTot[$mid]['kg'] ?? 0) + $kg;
$mixTot[$mid]['mat'][$r['id']] = 1;
$mixMonth[$mid][$ym] = ($mixMonth[$mid][$ym] ?? 0) + $kg;
}
$kpi['value'] += $val;
$kpi['lines']++;
if (in_array($r['type'], self::LINKED_TYPES, true)) {
$kpi['linked_value'] += $val;
$linkedIds[$r['id']] = 1;
} elseif ($r['type'] === 'profilo') {
$kpi['profile_value'] += $val;
} else {
$kpi['unlinked_value'] += $val;
}
if ($kind === 'm') {
$kpi['qty_m'] += $qty;
if ($val > 0) {
$kpi['val_m'] += $val;
$kpi['pq_m'] += $qty;
}
} elseif ($kind === 'kg') {
$kpi['qty_kg'] += $qty;
if ($val > 0) {
$kpi['val_kg'] += $val;
$kpi['pq_kg'] += $qty;
}
} elseif ($kind === 'pz') {
$kpi['pz_lines']++;
$kpi['pz_value'] += $val;
$kpi['pz_qty'] += $qty;
}
if (in_array($mk['status'], ['gm', 'gm_part', 'none'], true) && $r['id']) {
$kpi['gm_missing_lines']++;
$kpi['gm_missing_value'] += $val;
}
}
if ($val > 0) {
if ($date > $d12) {
$a['v12'] += $val;
$a['q12'] += $qty;
} elseif ($date > $d24) {
$a['vp'] += $val;
$a['qp'] += $qty;
}
if (isset($sparkPos[$ym])) {
$a['sv'][$sparkPos[$ym]] += $val;
$a['sq'][$sparkPos[$ym]] += $qty;
}
}
if ($res > 0) {
$ov = $qty > 0 ? $val * $res / $qty : 0;
$a['open_qty'] += $res;
$a['open_value'] += $ov;
$kpi['open_value'] += $ov;
foreach (self::mixKg($mi, $kind, $res)['kg'] as $mid => $kg) {
$a['open_kg'] += $kg;
$kpi['kg_open'] += $kg;
$mixTot[$mid]['kg_open'] = ($mixTot[$mid]['kg_open'] ?? 0) + $kg;
$mixTot[$mid]['mat'][$r['id']] = 1;
}
}
unset($a);
if ($mi) foreach ($mi['mix'] as $x) $mixNames[$x['id']] = $x['name'];
}
$rows = [];
foreach ($agg as $a) {
if ($a['n'] === 0 && $a['open_qty'] <= 0) continue;
$price = $a['pq'] > 0 ? $a['value'] / $a['pq'] : null;
$p12 = $a['q12'] > 0 ? $a['v12'] / $a['q12'] : null;
$pp = $a['qp'] > 0 ? $a['vp'] / $a['qp'] : null;
$sp = [];
for ($i = 0; $i < 12; $i++) $sp[] = $a['sq'][$i] > 0 ? round($a['sv'][$i] / $a['sq'][$i], 4) : null;
$rows[] = [
'key' => $a['key'],
'id' => $a['id'],
'cat' => $a['cat'],
'type' => $a['type'],
'name' => $a['name'],
'code' => $a['code'],
'photo' => $a['photo'],
'uom' => $a['uom'],
'kind' => $a['kind'],
'raws' => array_keys($a['raws']),
'n' => $a['n'],
'orders' => count($a['orders']),
'clients' => count($a['clients']),
'qty' => round($a['qty'], 3),
'value' => round($a['value'], 2),
'price' => $price !== null ? round($price, 4) : null,
'pmin' => $a['pmin'] !== null ? round($a['pmin'], 4) : null,
'pmax' => $a['pmax'] !== null ? round($a['pmax'], 4) : null,
'spread' => ($a['pmin'] > 0 && $a['pmax'] !== null) ? round(($a['pmax'] - $a['pmin']) / $a['pmin'] * 100, 1) : null,
'last_date' => $a['last_date'] ?: null,
'last_price' => $a['last_price'] !== null ? round($a['last_price'], 4) : null,
'price12' => $p12 !== null ? round($p12, 4) : null,
'pricep' => $pp !== null ? round($pp, 4) : null,
'var' => ($p12 !== null && $pp) ? round(($p12 - $pp) / $pp * 100, 1) : null,
'spark' => $sp,
'kg' => round($a['kg'], 1),
'open_qty' => round($a['open_qty'], 3),
'open_value' => round($a['open_value'], 2),
'open_kg' => round($a['open_kg'], 1),
'mix_status' => $a['mix_status'],
'mix_n' => $a['mix_n'],
'gm_total' => $a['gm_total'],
];
}
usort($rows, fn($x, $y) => $y['value'] <=> $x['value']);
$mescole = [];
foreach ($mixTot as $mid => $m) {
$mescole[] = [
'id' => $mid,
'name' => $mixNames[$mid] ?? ('#' . $mid),
'kg' => round($m['kg'] ?? 0, 1),
'kg_open' => round($m['kg_open'] ?? 0, 1),
'matrici' => count($m['mat'] ?? []),
'months' => array_map(fn($v) => round($v, 1), $mixMonth[$mid] ?? []),
];
}
usort($mescole, fn($x, $y) => $y['kg'] <=> $x['kg']);
$kpi['matrici'] = count($linkedIds);
$kpi['clients'] = count($kpiClients);
foreach ($kpi as $k => $v) if (is_float($v)) $kpi[$k] = round($v, 2);
return [
'success' => true,
'from' => $from,
'to' => $to,
'today' => date('Y-m-d'),
'spark_months' => $spark,
'kpi' => $kpi,
'rows' => $rows,
'mescole' => $mescole,
'types' => self::TYPE_LABELS,
'cats' => self::CAT_LABELS,
];
}
/* ============================================================
* DETTAGLIO DI UNA MATRICE (in una unità di misura)
* ============================================================ */
public static function buildDetail(PDO $pdo, string $key, string $from, string $to, array $customers = []): array
{
$pos = strrpos($key, '|');
if ($pos === false) return ['success' => false, 'message' => 'Matrice non indicata'];
$rkey = substr($key, 0, $pos);
$uom = substr($key, $pos + 1);
// valori Mago che portano a questa matrice
$raws = [];
$first = null;
foreach ($pdo->query("SELECT DISTINCT matrice_misura FROM mago_sale_ord_detail WHERE matrice_misura <> ''")->fetchAll(PDO::FETCH_COLUMN) as $raw) {
$r = self::resolve($pdo, $raw);
if ($r['key'] === $rkey) {
$raws[] = $raw;
if (!$first) $first = $r;
}
}
if (!$raws) return ['success' => false, 'message' => 'Nessuna riga d\'ordine per questa matrice'];
$kind = self::uomKind($uom);
$lines = self::fetchLines($pdo, $from, $customers, $raws, $uom);
$ix = self::index($pdo);
$mi = $first['id'] ? (self::mixInfo($pdo, [$first['id']])[$first['id']] ?? null) : null;
$prodLines = [];
if ($first['id']) {
$st = $pdo->prepare("SELECT pl.name, pl.color FROM matrici_lines ml JOIN production_lines pl ON pl.id = ml.idlinea WHERE ml.idmatrice = ? ORDER BY pl.line_number");
$st->execute([$first['id']]);
$prodLines = $st->fetchAll(PDO::FETCH_ASSOC);
}
$names = self::customerNames($pdo, array_column($lines, 'customer'));
$cl = [];
$months = [];
$out = [];
$mixKgP = [];
$mixKgO = [];
$tot = ['n' => 0, 'qty' => 0, 'value' => 0, 'pq' => 0, 'kg' => 0, 'open_qty' => 0, 'open_kg' => 0, 'pmin' => null, 'pmax' => null];
$d12 = date('Y-m-d', strtotime('-365 days'));
$d24 = date('Y-m-d', strtotime('-730 days'));
foreach ($lines as $l) {
$qty = (float)$l['qty'];
$val = (float)$l['value'];
$date = (string)$l['order_date'];
$inP = $date >= $from && $date <= $to;
$res = self::lineIsOpen($l);
$price = ($val > 0 && $qty > 0) ? $val / $qty : null;
$c = $l['customer'];
if ($res > 0) {
$tot['open_qty'] += $res;
foreach (self::mixKg($mi, $kind, $res)['kg'] as $mid => $kg) {
$mixKgO[$mid] = ($mixKgO[$mid] ?? 0) + $kg;
$tot['open_kg'] += $kg;
}
}
if (!$inP) continue;
$kgLine = 0;
foreach (self::mixKg($mi, $kind, $qty)['kg'] as $mid => $kg) {
$mixKgP[$mid] = ($mixKgP[$mid] ?? 0) + $kg;
$kgLine += $kg;
}
$tot['n']++;
$tot['qty'] += $qty;
$tot['value'] += $val;
$tot['kg'] += $kgLine;
if (!isset($cl[$c])) {
$cl[$c] = [
'code' => $c,
'name' => $names[$c] ?? $c,
'n' => 0,
'qty' => 0,
'value' => 0,
'pq' => 0,
'first_date' => null,
'first_price' => null,
'last_date' => null,
'last_price' => null,
'v12' => 0,
'q12' => 0,
'vp' => 0,
'qp' => 0,
'open_qty' => 0
];
}
$x = &$cl[$c];
$x['n']++;
$x['qty'] += $qty;
$x['value'] += $val;
$x['open_qty'] += $res;
if ($price !== null) {
$tot['pq'] += $qty;
$tot['pmin'] = $tot['pmin'] === null ? $price : min($tot['pmin'], $price);
$tot['pmax'] = $tot['pmax'] === null ? $price : max($tot['pmax'], $price);
$x['pq'] += $qty;
if ($x['first_date'] === null) {
$x['first_date'] = $date;
$x['first_price'] = $price;
}
$x['last_date'] = $date;
$x['last_price'] = $price;
if ($date > $d12) {
$x['v12'] += $val;
$x['q12'] += $qty;
} elseif ($date > $d24) {
$x['vp'] += $val;
$x['qp'] += $qty;
}
$ym = substr($date, 0, 7);
$months[$ym]['v'] = ($months[$ym]['v'] ?? 0) + $val;
$months[$ym]['q'] = ($months[$ym]['q'] ?? 0) + $qty;
}
unset($x);
$out[] = [
'date' => $date,
'ord' => $l['internal_ord_no'],
'customer' => $c,
'cname' => $names[$c] ?? $c,
'item' => $l['item'],
'description' => $l['description'],
'raw' => $l['matrice_misura'],
'qty' => round($qty, 3),
'value' => round($val, 2),
'price' => $price !== null ? round($price, 4) : null,
'open' => round($res, 3),
'kg' => round($kgLine, 1),
];
}
$avg = $tot['pq'] > 0 ? $tot['value'] / $tot['pq'] : null;
$clients = [];
foreach ($cl as $x) {
$p = $x['pq'] > 0 ? $x['value'] / $x['pq'] : null;
$p12 = $x['q12'] > 0 ? $x['v12'] / $x['q12'] : null;
$pp = $x['qp'] > 0 ? $x['vp'] / $x['qp'] : null;
$clients[] = [
'code' => $x['code'],
'name' => $x['name'],
'n' => $x['n'],
'qty' => round($x['qty'], 3),
'value' => round($x['value'], 2),
'price' => $p !== null ? round($p, 4) : null,
'dev' => ($p !== null && $avg) ? round(($p - $avg) / $avg * 100, 1) : null,
'first_date' => $x['first_date'],
'first_price' => $x['first_price'] !== null ? round($x['first_price'], 4) : null,
'last_date' => $x['last_date'],
'last_price' => $x['last_price'] !== null ? round($x['last_price'], 4) : null,
'var' => ($p12 !== null && $pp) ? round(($p12 - $pp) / $pp * 100, 1) : null,
'open_qty' => round($x['open_qty'], 3),
];
}
usort($clients, fn($a, $b) => $b['value'] <=> $a['value']);
ksort($months);
$mrows = [];
foreach ($months as $ym => $m) $mrows[] = ['ym' => $ym, 'price' => $m['q'] > 0 ? round($m['v'] / $m['q'], 4) : null, 'qty' => round($m['q'], 3)];
$mixRows = [];
if ($mi) {
foreach ($mi['mix'] as $x) {
$share = $mi['gm_total'] > 0 ? ($x['gm'] > 0 ? $x['gm'] / $mi['gm_total'] : 0) : (count($mi['mix']) ? 1 / count($mi['mix']) : 0);
$mixRows[] = [
'id' => $x['id'],
'name' => $x['name'],
'out' => $x['out'],
'gm' => $x['gm'],
'share' => round($share * 100, 1),
'kg' => round($mixKgP[$x['id']] ?? 0, 1),
'kg_open' => round($mixKgO[$x['id']] ?? 0, 1),
];
}
}
$status = self::mixKg($mi, $kind, 1)['status'];
return [
'success' => true,
'key' => $key,
'head' => [
'id' => $first['id'],
'cat' => $first['cat'],
'cat_label' => self::CAT_LABELS[$first['cat']] ?? $first['cat'],
'type' => $first['type'],
'type_label' => self::TYPE_LABELS[$first['type']] ?? $first['type'],
'name' => $first['label'],
'code' => $first['code'],
'photo' => $first['id'] ? ($ix['info'][$first['id']]['photo'] ?? null) : null,
'uom' => $uom,
'kind' => $kind,
'raws' => array_values(array_unique(array_map([self::class, 'norm'], $raws))),
'lines' => $prodLines,
'ws' => $mi['ws'] ?? null,
'mix_source' => $mi['source'] ?? 'nessuna',
'gm_total' => $mi['gm_total'] ?? 0,
'mix_status' => $status,
],
'tot' => [
'n' => $tot['n'],
'qty' => round($tot['qty'], 3),
'value' => round($tot['value'], 2),
'price' => $avg !== null ? round($avg, 4) : null,
'pmin' => $tot['pmin'] !== null ? round($tot['pmin'], 4) : null,
'pmax' => $tot['pmax'] !== null ? round($tot['pmax'], 4) : null,
'kg' => round($tot['kg'], 1),
'open_qty' => round($tot['open_qty'], 3),
'open_kg' => round($tot['open_kg'], 1),
'clients' => count($clients),
],
'clients' => $clients,
'months' => $mrows,
'mix' => $mixRows,
'lines' => array_reverse($out),
];
}
}