962 lines
40 KiB
PHP
962 lines
40 KiB
PHP
<?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';
|
||
require_once __DIR__ . '/MagoMixPurchases.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];
|
||
}
|
||
|
||
/* ============================================================
|
||
* COSTO GREZZO (solo mescola)
|
||
* ============================================================ */
|
||
|
||
/** Prezzi €/kg delle mescole (vuoto se gli acquisti non sono ancora sincronizzati) */
|
||
public static function mixPrices(PDO $pdo): array
|
||
{
|
||
try {
|
||
return MagoMixPurchases::prices($pdo);
|
||
} catch (Throwable $e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Costo della mescola per unità venduta.
|
||
* - Metri: €/m = somma di g/m × €/kg ÷ 1000 per ogni mescola della scheda di lavoro.
|
||
* - Chili: €/kg = media dei €/kg delle mescole pesata sui g/m (in parti uguali se mancano i g/m).
|
||
* - Pezzi: non calcolabile (manca la conversione pezzi → metri).
|
||
* $ym: costo in un mese passato (prezzi di quel mese, altrimenti l'ultimo noto prima).
|
||
* status: ok | partial (manca g/m o prezzo di qualche mescola: costo sottostimato) | price (nessun prezzo) | gm | none | pz | altro
|
||
*/
|
||
public static function costOf(?array $mi, string $kind, array $prices, ?string $ym = null): array
|
||
{
|
||
$out = ['status' => 'none', 'cost' => null, 'parts' => [], 'missing' => [], 'check' => []];
|
||
if ($kind === 'pz' || $kind === 'altro') return ['status' => $kind] + $out;
|
||
if (!$mi || !$mi['mix']) return $out;
|
||
if ($kind === 'm' && $mi['gm_total'] <= 0) return ['status' => 'gm'] + $out;
|
||
|
||
$n = count($mi['mix']);
|
||
$cost = 0.0;
|
||
$priced = 0;
|
||
$gmMissing = false;
|
||
foreach ($mi['mix'] as $x) {
|
||
$p = $prices[$x['id']] ?? null;
|
||
$eur = $p ? ($ym ? MagoMixPurchases::priceAt($p, $ym) : $p['price']) : null;
|
||
$gm = $x['gm'] !== null && $x['gm'] > 0 ? (float)$x['gm'] : null;
|
||
if ($gm === null) $gmMissing = true;
|
||
if ($kind === 'm') {
|
||
$part = ($gm !== null && $eur !== null) ? $gm / 1000 * $eur : null;
|
||
} else {
|
||
$share = $mi['gm_total'] > 0 ? ($gm ?? 0) / $mi['gm_total'] : 1 / $n;
|
||
$part = $eur !== null ? $share * $eur : null;
|
||
}
|
||
if ($eur === null) $out['missing'][] = $x['name'];
|
||
if ($part !== null) {
|
||
$cost += $part;
|
||
$priced++;
|
||
}
|
||
if ($p && $p['check']) $out['check'][$p['check']] = $p['check'];
|
||
$out['parts'][] = [
|
||
'id' => $x['id'],
|
||
'name' => $x['name'],
|
||
'gm' => $gm,
|
||
'eur_kg' => $eur !== null ? round($eur, 4) : null,
|
||
'part' => $part !== null ? round($part, 4) : null,
|
||
'old' => $p ? $p['old'] : null,
|
||
'last_date' => $p['last_date'] ?? null,
|
||
'supplier' => $p['last_supplier'] ?? null,
|
||
'check' => $p['check'] ?? null,
|
||
];
|
||
}
|
||
$out['check'] = array_values($out['check']);
|
||
if ($priced === 0) return ['status' => 'price'] + $out;
|
||
$out['cost'] = round($cost, 4);
|
||
$out['status'] = ($out['missing'] || ($kind === 'm' && $gmMissing) || ($kind === 'kg' && $mi['gm_total'] > 0 && $gmMissing)) ? 'partial' : 'ok';
|
||
return $out;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 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);
|
||
$prices = self::mixPrices($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,
|
||
'cost_value' => 0,
|
||
'cost_cover_value' => 0,
|
||
'cost_rows' => 0,
|
||
'cost_partial_rows' => 0,
|
||
'check_rows' => 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;
|
||
$c = self::costOf($a['id'] ? ($mix[$a['id']] ?? null) : null, $a['kind'], $prices);
|
||
$margin = ($c['cost'] !== null && $price) ? $price - $c['cost'] : null;
|
||
if ($c['cost'] !== null && $price && $a['n'] > 0) {
|
||
if ($c['status'] === 'ok') {
|
||
$kpi['cost_value'] += $a['pq'] * $c['cost'];
|
||
$kpi['cost_cover_value'] += $a['value'];
|
||
$kpi['cost_rows']++;
|
||
} else {
|
||
$kpi['cost_partial_rows']++;
|
||
}
|
||
if ($c['check']) $kpi['check_rows']++;
|
||
}
|
||
$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'],
|
||
'cost' => $c['cost'],
|
||
'cost_status' => $c['status'],
|
||
'cost_missing' => $c['missing'],
|
||
'cost_check' => $c['check'],
|
||
'margin' => $margin !== null ? round($margin, 4) : null,
|
||
'margin_pct' => $margin !== null ? round($margin / $price * 100, 1) : null,
|
||
];
|
||
}
|
||
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,
|
||
'price_months' => MagoMixPurchases::PRICE_MONTHS,
|
||
'has_prices' => !empty($prices),
|
||
];
|
||
}
|
||
|
||
/* ============================================================
|
||
* 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;
|
||
$prices = self::mixPrices($pdo);
|
||
$costNow = self::costOf($mi, $kind, $prices);
|
||
|
||
$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),
|
||
'margin_pct' => ($p && $costNow['cost'] !== null) ? round(($p - $costNow['cost']) / $p * 100, 1) : null,
|
||
'last_margin_pct' => ($x['last_price'] && $costNow['cost'] !== null) ? round(($x['last_price'] - $costNow['cost']) / $x['last_price'] * 100, 1) : null,
|
||
];
|
||
}
|
||
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)];
|
||
|
||
// costo mese per mese (prezzi mescole di quel mese) dal primo mese con vendite a oggi
|
||
$costMonths = [];
|
||
if ($costNow['cost'] !== null) {
|
||
$start = $mrows ? $mrows[0]['ym'] : substr($from, 0, 7);
|
||
for ($t = strtotime($start . '-01'); date('Y-m', $t) <= date('Y-m'); $t = strtotime('+1 month', $t)) {
|
||
$ym = date('Y-m', $t);
|
||
$c = self::costOf($mi, $kind, $prices, $ym);
|
||
if ($c['cost'] !== null) $costMonths[] = ['ym' => $ym, 'cost' => $c['cost']];
|
||
}
|
||
}
|
||
|
||
$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,
|
||
],
|
||
'cost' => $costNow + [
|
||
'months' => $costMonths,
|
||
'price_months' => MagoMixPurchases::PRICE_MONTHS,
|
||
'margin_pct' => ($avg && $costNow['cost'] !== null) ? round(($avg - $costNow['cost']) / $avg * 100, 1) : null
|
||
],
|
||
'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),
|
||
];
|
||
}
|
||
}
|