diff --git a/db/migrations/20261020094100_create_mago_prod_queue.php b/db/migrations/20261020094100_create_mago_prod_queue.php new file mode 100644 index 00000000..c7e87847 --- /dev/null +++ b/db/migrations/20261020094100_create_mago_prod_queue.php @@ -0,0 +1,81 @@ +table('mago_prod_queue', [ + 'comment' => 'Righe ordine Mago da portare in programmazione produzione', + ]) + // --- riga Mago --- + ->addColumn('sale_ord_id', 'integer', ['signed' => false]) + ->addColumn('sub_id', 'integer') + ->addColumn('internal_ord_no', 'string', ['limit' => 30, 'default' => '']) + ->addColumn('line_no', 'integer', ['default' => 0]) + ->addColumn('order_date', 'date', ['null' => true]) + ->addColumn('customer', 'string', ['limit' => 20, 'default' => '']) + ->addColumn('company_name', 'string', ['limit' => 200, 'default' => '']) + ->addColumn('item', 'string', ['limit' => 30, 'default' => '']) + ->addColumn('description', 'string', ['limit' => 255, 'default' => '']) + ->addColumn('cod_art_cli', 'string', ['limit' => 60, 'default' => '']) + ->addColumn('uom', 'string', ['limit' => 10, 'default' => '']) + ->addColumn('qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0]) + ->addColumn('delivered_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0]) + ->addColumn('residual_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0]) + ->addColumn('zibo_date', 'date', ['null' => true]) + ->addColumn('client_date', 'date', ['null' => true]) + ->addColumn('mago_notes', 'text', ['null' => true]) + // --- matrice --- + ->addColumn('matrice_misura', 'string', ['limit' => 30, 'default' => '', 'comment' => 'Valore originale di Mago']) + ->addColumn('mm_tipo', 'string', ['limit' => 2, 'default' => '', 'comment' => 'M matrice, D diametro, R rettangolo, T tubo, Q quadrato, S, ? altro']) + ->addColumn('mm_codice', 'string', ['limit' => 40, 'default' => '', 'comment' => 'Codice matrice estratto (es. 00786S-1)']) + ->addColumn('mm_misura', 'string', ['limit' => 40, 'default' => '', 'comment' => 'Misura (es. 3,3 / 17X12)']) + ->addColumn('idmatrice', 'integer', ['null' => true]) + ->addColumn('match_status', 'string', ['limit' => 20, 'default' => '', 'comment' => 'esatta, revisione, mappa, manuale, da_creare, profilo, anomalia']) + ->addColumn('match_info', 'string', ['limit' => 255, 'default' => '']) + // --- percorso --- + ->addColumn('queue_status', 'string', ['limit' => 20, 'default' => 'nuovo', 'comment' => 'nuovo, importato, scartato, chiuso_mago']) + ->addColumn('discard_reason', 'string', ['limit' => 255, 'default' => '']) + ->addColumn('productiondata_id', 'integer', ['null' => true]) + ->addColumn('mago_changed', 'boolean', ['default' => false, 'comment' => 'Riga cambiata in Mago dopo l\'import']) + ->addColumn('mago_changes', 'string', ['limit' => 500, 'default' => '']) + ->addColumn('first_seen_at', 'datetime', ['null' => true]) + ->addColumn('updated_at', 'datetime', ['null' => true]) + ->addColumn('imported_at', 'datetime', ['null' => true]) + ->addColumn('imported_by', 'string', ['limit' => 100, 'default' => '']) + ->addIndex(['sale_ord_id', 'sub_id'], ['unique' => true, 'name' => 'uk_mpq_line']) + ->addIndex(['queue_status']) + ->addIndex(['productiondata_id']) + ->addIndex(['idmatrice']) + ->create(); + + $this->table('matrice_mago_map', [ + 'comment' => 'Corrispondenze manuali: valore Matrice/Misura di Mago → matrice', + ]) + ->addColumn('codice_mago', 'string', ['limit' => 60]) + ->addColumn('idmatrice', 'integer') + ->addColumn('note', 'string', ['limit' => 255, 'default' => '']) + ->addColumn('created_by', 'string', ['limit' => 100, 'default' => '']) + ->addColumn('created_at', 'datetime', ['null' => true]) + ->addIndex(['codice_mago'], ['unique' => true]) + ->addForeignKey('idmatrice', 'matrice', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + + $this->table('clients') + ->addColumn('mago_code', 'string', ['limit' => 20, 'null' => true, 'after' => 'nome', 'comment' => 'Codice cliente Mago']) + ->addIndex(['mago_code']) + ->update(); + } +} diff --git a/public/userarea/class/MagoProduction.php b/public/userarea/class/MagoProduction.php new file mode 100644 index 00000000..42b937d0 --- /dev/null +++ b/public/userarea/class/MagoProduction.php @@ -0,0 +1,532 @@ + 'Matrice', + 'D' => 'Diametro', + 'R' => 'Rettangolo', + 'T' => 'Tubo', + 'Q' => 'Quadrato', + 'S' => 'Sezione', + '?' => 'Altro', + ]; + + /* ===================== MATRICE / MISURA ===================== */ + + /** + * Scompone il valore "Matrice/Misura" di Mago. + * M00786S → tipo M, codice 00786S + * M00407P-1 → tipo M, codice 00407P-1 + * Q8X8M00936P → tipo M, codice 00936P, misura 8X8 (profilo Q) + * D 3,3 → tipo D, misura 3,3 + * ZB00193P → tipo ?, codice ZB00193P (si cerca il valore intero tra le matrici) + */ + public static function parse($raw) + { + $v = strtoupper(trim(preg_replace('/\s+/', ' ', (string)$raw))); + $out = ['raw' => $v, 'tipo' => '', 'codice' => '', 'misura' => '']; + if ($v === '') return $out; + + if (preg_match('/M(\d{4,6}[A-Z]?(?:-\d+)?)/', $v, $m, PREG_OFFSET_CAPTURE)) { + $out['tipo'] = 'M'; + $out['codice'] = $m[1][0]; + $before = trim(substr($v, 0, $m[0][1])); + if (preg_match('/^[DRTQS]\s*([0-9][0-9.,X ]*)$/', $before, $mm)) $out['misura'] = trim($mm[1]); + return $out; + } + if (preg_match('/^([DRTQS])\s*([0-9][0-9.,X ]*)$/', $v, $m)) { + $out['tipo'] = $m[1]; + $out['misura'] = trim($m[2]); + return $out; + } + $out['tipo'] = '?'; + $out['codice'] = $v; + return $out; + } + + /** Indice delle matrici del portale per il collegamento (caricato una volta). */ + private static function matriceIndex(PDO $pdo) + { + $idx = ['exact' => [], 'base' => [], 'num' => []]; + foreach ($pdo->query("SELECT id, nome FROM matrice WHERE TRIM(nome) <> ''") as $r) { + $tok = strtoupper(strtok(trim($r['nome']), ' ')); + if ($tok === '' || $tok === false) continue; + $idx['exact'][$tok] = ['id' => (int)$r['id'], 'nome' => trim($r['nome'])]; + $base = preg_replace('/-\d+$/', '', $tok); + $idx['base'][$base][] = ['id' => (int)$r['id'], 'nome' => trim($r['nome']), 'tok' => $tok]; + if (preg_match('/^(\d+)/', $tok, $n)) $idx['num'][(int)$n[1]][] = trim($r['nome']); + } + foreach ($idx['base'] as &$list) { + usort($list, function ($a, $b) { + return strnatcmp($b['tok'], $a['tok']); // revisione più alta per prima + }); + } + unset($list); + $idx['map'] = []; + try { + foreach ($pdo->query("SELECT codice_mago, idmatrice FROM matrice_mago_map") as $r) { + $idx['map'][strtoupper(trim($r['codice_mago']))] = (int)$r['idmatrice']; + } + } catch (Throwable $e) { + } + return $idx; + } + + /** + * Trova la matrice del portale per un valore Matrice/Misura. + * Ritorna [idmatrice|null, match_status, match_info, parse] + */ + public static function resolve(array $idx, $raw) + { + $p = self::parse($raw); + if ($p['raw'] === '') return [null, 'anomalia', 'Matrice/Misura vuoto in Mago', $p]; + + // 0) corrispondenza manuale + if (isset($idx['map'][$p['raw']])) return [$idx['map'][$p['raw']], 'mappa', 'Corrispondenza salvata', $p]; + + if ($p['codice'] !== '') { + $c = $p['codice']; + // 1) esatta + if (isset($idx['exact'][$c])) return [$idx['exact'][$c]['id'], 'esatta', '', $p]; + // 2) stessa matrice, altra revisione + $base = preg_replace('/-\d+$/', '', $c); + if (!empty($idx['base'][$base])) { + $best = $idx['base'][$base][0]; + return [$best['id'], 'revisione', "Revisione diversa: Mago $c, anagrafica {$best['nome']}", $p]; + } + // 3) stesso numero, lettera diversa: solo suggerimento + $sim = ''; + if (preg_match('/^(\d+)/', $c, $n) && !empty($idx['num'][(int)$n[1]])) { + $sim = ' · simili: ' . implode(', ', array_slice($idx['num'][(int)$n[1]], 0, 3)); + } + if ($p['tipo'] === 'M') return [null, 'da_creare', "Matrice $c non presente in anagrafica" . $sim, $p]; + return [null, 'anomalia', "Codice $c non riconosciuto" . $sim, $p]; + } + + // profilo standard (D/R/T/Q/S + misura) + $label = self::TIPI[$p['tipo']] ?? $p['tipo']; + return [null, 'profilo', "$label {$p['misura']}: scegliere la matrice", $p]; + } + + /* ===================== CODA "NUOVI DA MAGO" ===================== */ + + /** + * Allinea la coda con le righe d'ordine Mago locali. + * Ritorna un piccolo report (nuove, aggiornate, chiuse, modificate dopo l'import, importate in automatico). + */ + public static function refreshQueue(PDO $pdo) + { + $rep = ['candidati' => 0, 'nuove' => 0, 'aggiornate' => 0, 'chiuse' => 0, 'modificate' => 0, 'auto_importate' => 0]; + $idx = self::matriceIndex($pdo); + + $excl = ''; + foreach (self::EXCLUDED_ITEM_PREFIXES as $i => $pfx) { + $excl .= " AND d.item NOT LIKE " . $pdo->quote($pfx . '%'); + } + + $rows = MagoSalesStats::fetchAllQ($pdo, " + SELECT d.sale_ord_id, d.sub_id, d.line, d.item, d.description, d.uom, d.qty, d.delivered_qty, + d.notes, d.matrice_misura, d.cod_art_cli, + " . MagoSalesStats::ziboDate('d') . " AS zibo_date, + " . MagoSalesStats::clientDate('d') . " AS client_date, + x.internal_ord_no, x.order_date, x.customer, x.company_name + FROM mago_sale_ord_detail d + JOIN " . MagoSalesStats::baseFrom() . " ON x.sale_ord_id = d.sale_ord_id + WHERE x.order_date >= :from + AND x.status IN ('aperto', 'bloccato') + AND d.delivered = 0 AND d.cancelled = 0 + AND d.item <> '' AND d.qty > :minq + $excl + ", ['from' => self::QUEUE_FROM_DATE, 'minq' => self::MIN_QTY]); + $rep['candidati'] = count($rows); + + $existing = []; + foreach ($pdo->query("SELECT * FROM mago_prod_queue") as $q) { + $existing[$q['sale_ord_id'] . '-' . $q['sub_id']] = $q; + } + + $ins = $pdo->prepare(" + INSERT INTO mago_prod_queue ( + sale_ord_id, sub_id, internal_ord_no, line_no, order_date, customer, company_name, item, description, + cod_art_cli, uom, qty, delivered_qty, residual_qty, zibo_date, client_date, mago_notes, + matrice_misura, mm_tipo, mm_codice, mm_misura, idmatrice, match_status, match_info, + queue_status, first_seen_at, updated_at + ) VALUES ( + :sale_ord_id, :sub_id, :internal_ord_no, :line_no, :order_date, :customer, :company_name, :item, :description, + :cod_art_cli, :uom, :qty, :delivered_qty, :residual_qty, :zibo_date, :client_date, :mago_notes, + :matrice_misura, :mm_tipo, :mm_codice, :mm_misura, :idmatrice, :match_status, :match_info, + 'nuovo', NOW(), NOW() + )"); + $upd = $pdo->prepare(" + UPDATE mago_prod_queue SET + internal_ord_no = :internal_ord_no, line_no = :line_no, order_date = :order_date, customer = :customer, + company_name = :company_name, item = :item, description = :description, cod_art_cli = :cod_art_cli, + uom = :uom, qty = :qty, delivered_qty = :delivered_qty, residual_qty = :residual_qty, + zibo_date = :zibo_date, client_date = :client_date, mago_notes = :mago_notes, + matrice_misura = :matrice_misura, mm_tipo = :mm_tipo, mm_codice = :mm_codice, mm_misura = :mm_misura, + idmatrice = :idmatrice, match_status = :match_status, match_info = :match_info, + queue_status = :queue_status, mago_changed = :mago_changed, mago_changes = :mago_changes, + updated_at = NOW() + WHERE id = :id"); + + $seen = []; + $pdo->beginTransaction(); + try { + foreach ($rows as $r) { + $key = $r['sale_ord_id'] . '-' . $r['sub_id']; + $seen[$key] = true; + [$idm, $ms, $mi, $p] = self::resolve($idx, $r['matrice_misura']); + $residual = max(0, (float)$r['qty'] - (float)$r['delivered_qty']); + $data = [ + 'internal_ord_no' => $r['internal_ord_no'], + 'line_no' => (int)$r['line'], + 'order_date' => $r['order_date'], + 'customer' => $r['customer'], + 'company_name' => mb_substr((string)$r['company_name'], 0, 200), + 'item' => $r['item'], + 'description' => mb_substr((string)$r['description'], 0, 255), + 'cod_art_cli' => (string)$r['cod_art_cli'], + 'uom' => $r['uom'], + 'qty' => $r['qty'], + 'delivered_qty' => $r['delivered_qty'], + 'residual_qty' => $residual, + 'zibo_date' => $r['zibo_date'], + 'client_date' => $r['client_date'], + 'mago_notes' => $r['notes'], + 'matrice_misura' => (string)$r['matrice_misura'], + 'mm_tipo' => $p['tipo'], + 'mm_codice' => mb_substr($p['codice'], 0, 40), + 'mm_misura' => mb_substr($p['misura'], 0, 40), + 'idmatrice' => $idm, + 'match_status' => $ms, + 'match_info' => mb_substr($mi, 0, 255), + ]; + + if (!isset($existing[$key])) { + $ins->execute($data + ['sale_ord_id' => $r['sale_ord_id'], 'sub_id' => $r['sub_id']]); + $rep['nuove']++; + continue; + } + + $q = $existing[$key]; + $status = $q['queue_status']; + $changed = (int)$q['mago_changed']; + $changes = $q['mago_changes']; + + // scelta manuale della matrice: non la tocco + if ($q['match_status'] === 'manuale' && $q['idmatrice']) { + $data['idmatrice'] = $q['idmatrice']; + $data['match_status'] = 'manuale'; + $data['match_info'] = $q['match_info']; + } + + if ($status === 'importato') { + $diff = self::diff($q, $data); + if ($diff) { + $changed = 1; + $changes = mb_substr(trim(($changes ? $changes . ' | ' : '') . date('d/m') . ': ' . implode(', ', $diff)), -500); + $rep['modificate']++; + } + // dopo l'import la matrice resta quella scelta in produzione + $data['idmatrice'] = $q['idmatrice']; + $data['match_status'] = $q['match_status']; + $data['match_info'] = $q['match_info']; + } elseif ($status === 'chiuso_mago') { + $status = 'nuovo'; // riaperta in Mago + } + + $upd->execute($data + ['queue_status' => $status, 'mago_changed' => $changed, 'mago_changes' => $changes, 'id' => $q['id']]); + $rep['aggiornate']++; + } + + // righe non più "da produrre" in Mago (consegnate, annullate, cancellate, quantità ≤ 1...) + $why = $pdo->prepare("SELECT delivered, cancelled FROM mago_sale_ord_detail WHERE sale_ord_id = ? AND sub_id = ?"); + $close = $pdo->prepare("UPDATE mago_prod_queue SET queue_status = :s, mago_changed = :c, mago_changes = :t, updated_at = NOW() WHERE id = :id"); + foreach ($existing as $key => $q) { + if (isset($seen[$key]) || in_array($q['queue_status'], ['chiuso_mago'], true)) continue; + $why->execute([$q['sale_ord_id'], $q['sub_id']]); + $d = $why->fetch(PDO::FETCH_ASSOC); + $reason = !$d ? 'riga cancellata in Mago' : ((int)$d['cancelled'] ? 'riga annullata in Mago' : ((int)$d['delivered'] ? 'riga consegnata in Mago' : 'riga non più da produrre in Mago')); + + if ($q['queue_status'] === 'importato') { + // già in produzione: segnalo solo se è un annullamento/cancellazione (la consegna è normale) + if (strpos($reason, 'consegnata') === false && strpos((string)$q['mago_changes'], $reason) === false) { + $close->execute(['s' => 'importato', 'c' => 1, 't' => mb_substr(trim(($q['mago_changes'] ? $q['mago_changes'] . ' | ' : '') . date('d/m') . ': ' . $reason), -500), 'id' => $q['id']]); + $rep['modificate']++; + } + continue; + } + if ($q['queue_status'] === 'nuovo' || $q['queue_status'] === 'scartato') { + $close->execute(['s' => 'chiuso_mago', 'c' => 0, 't' => $reason, 'id' => $q['id']]); + $rep['chiuse']++; + } + } + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } + + // import automatico (spento finché AUTO_IMPORT = false) + if (self::AUTO_IMPORT) { + $ids = $pdo->query("SELECT id FROM mago_prod_queue WHERE queue_status = 'nuovo' AND match_status IN ('esatta','mappa') AND idmatrice IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); + foreach ($ids as $id) { + $pre = self::prefill($pdo, (int)$id); + if (!$pre || !$pre['id_linea']) continue; // linea non univoca: resta da completare a mano + try { + self::import($pdo, (int)$id, $pre, 'automatico'); + $rep['auto_importate']++; + } catch (Throwable $e) { + } + } + } + return $rep; + } + + /** Differenze rilevanti tra la riga in coda e la riga Mago aggiornata. */ + private static function diff(array $q, array $d) + { + $out = []; + if (abs((float)$q['residual_qty'] - (float)$d['residual_qty']) > 0.0005) { + $out[] = 'quantità ' . self::num($q['residual_qty']) . ' → ' . self::num($d['residual_qty']) . ' ' . $d['uom']; + } + if ((string)$q['zibo_date'] !== (string)$d['zibo_date']) $out[] = 'data ZIBO ' . self::dt($q['zibo_date']) . ' → ' . self::dt($d['zibo_date']); + if ((string)$q['client_date'] !== (string)$d['client_date']) $out[] = 'data cliente ' . self::dt($q['client_date']) . ' → ' . self::dt($d['client_date']); + if (strtoupper(trim($q['matrice_misura'])) !== strtoupper(trim($d['matrice_misura']))) $out[] = 'matrice ' . $q['matrice_misura'] . ' → ' . $d['matrice_misura']; + return $out; + } + + private static function num($v) + { + return rtrim(rtrim(number_format((float)$v, 3, ',', '.'), '0'), ','); + } + + private static function dt($v) + { + return $v ? date('d/m/Y', strtotime($v)) : '—'; + } + + /* ===================== PRECOMPILAZIONE ===================== */ + + /** Dati tecnici di una matrice: linee, mescole e velocità dalla scheda di lavoro. */ + public static function matriceInfo(PDO $pdo, $idmatrice) + { + $idmatrice = (int)$idmatrice; + $info = ['lines' => [], 'mescole' => [], 'speed_m_h' => null, 'speed_kg_h' => null, 'g_m' => null, 'worksheet' => null]; + if (!$idmatrice) return $info; + + $st = $pdo->prepare("SELECT idlinea FROM matrici_lines WHERE idmatrice = ? ORDER BY idlinea"); + $st->execute([$idmatrice]); + $info['lines'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN)); + + $st = $pdo->prepare("SELECT id, worksheet_number, speed_expected_m_h, speed_expected_kg_h + FROM work_sheets WHERE idmatrice = ? AND worksheet_status = 'active' + ORDER BY worksheet_date DESC, id DESC LIMIT 1"); + $st->execute([$idmatrice]); + if ($ws = $st->fetch(PDO::FETCH_ASSOC)) { + $info['worksheet'] = (int)$ws['worksheet_number']; + $info['speed_m_h'] = $ws['speed_expected_m_h'] !== null ? (float)$ws['speed_expected_m_h'] : null; + $info['speed_kg_h'] = $ws['speed_expected_kg_h'] !== null ? (float)$ws['speed_expected_kg_h'] : null; + $st = $pdo->prepare("SELECT idmescola, mix_weight_g_m FROM work_sheet_mescole WHERE worksheet_id = ? ORDER BY mix_position"); + $st->execute([$ws['id']]); + $g = 0; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $m) { + $info['mescole'][] = (int)$m['idmescola']; + $g += (float)$m['mix_weight_g_m']; + } + $info['g_m'] = $g > 0 ? $g : null; + } + if (!$info['mescole']) { + $st = $pdo->prepare("SELECT idmescola FROM matrici_mescole WHERE idmatrice = ?"); + $st->execute([$idmatrice]); + $info['mescole'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN)); + } + return $info; + } + + /** Proposta dei campi di productiondata per una riga della coda. */ + public static function prefill(PDO $pdo, $queueId, $idmatrice = null) + { + $st = $pdo->prepare("SELECT * FROM mago_prod_queue WHERE id = ?"); + $st->execute([(int)$queueId]); + $q = $st->fetch(PDO::FETCH_ASSOC); + if (!$q) return null; + + $idm = $idmatrice !== null ? (int)$idmatrice : (int)$q['idmatrice']; + $info = self::matriceInfo($pdo, $idm); + $uom = strtoupper(trim($q['uom'])); + $qty = (float)$q['residual_qty']; + $metri = in_array($uom, ['MT', 'M', 'ML'], true) ? $qty : 0; + + $ore = 0; + if ($metri > 0 && $info['speed_m_h'] > 0) $ore = $metri / $info['speed_m_h']; + elseif ($uom === 'KG' && $info['speed_kg_h'] > 0) $ore = $qty / $info['speed_kg_h']; + + $note = []; + $note[] = "Da Mago: ordine {$q['internal_ord_no']} riga {$q['line_no']} · {$q['item']}" . ($q['cod_art_cli'] ? " · cod. cliente {$q['cod_art_cli']}" : ''); + $note[] = 'Matrice/Misura Mago: ' . $q['matrice_misura'] . ($q['mm_misura'] !== '' ? ' (' . (self::TIPI[$q['mm_tipo']] ?? '') . ' ' . $q['mm_misura'] . ')' : ''); + if ($metri <= 0) $note[] = 'Quantità ordinata: ' . self::num($qty) . ' ' . $q['uom']; + if ($metri > 0 && $info['g_m']) $note[] = 'Kg stimati: ' . self::num(round($metri * $info['g_m'] / 1000, 1)) . ' (scheda di lavoro ' . $info['worksheet'] . ')'; + if (trim((string)$q['mago_notes']) !== '') $note[] = trim($q['mago_notes']); + + return [ + 'queue' => $q, + 'idmatrice' => $idm ?: null, + 'id_linea' => count($info['lines']) === 1 ? $info['lines'][0] : null, + 'lines' => $info['lines'], + 'mescole' => $info['mescole'], + 'metri' => round($metri, 2), + 'kg_sp' => 0, + 'kg_p' => 0, + 'ore_previste' => round($ore, 2), + 'data_zibo' => $q['zibo_date'], + 'data_cliente' => $q['client_date'], + 'conferma_ordine' => $q['internal_ord_no'], + 'note_extra' => implode("\n", $note), + 'id_status' => self::STATUS_DA_PROGRAMMARE, + 'worksheet' => $info['worksheet'], + ]; + } + + /* ===================== IMPORT IN PRODUZIONE ===================== */ + + /** Cliente del portale per un codice cliente Mago (lo crea se non esiste). */ + public static function clientId(PDO $pdo, $code, $name) + { + $code = trim((string)$code); + $name = trim((string)$name) ?: $code; + if ($code === '') return null; + + $st = $pdo->prepare("SELECT id FROM clients WHERE mago_code = ? LIMIT 1"); + $st->execute([$code]); + if ($id = $st->fetchColumn()) return (int)$id; + + $st = $pdo->prepare("SELECT id FROM clients WHERE (mago_code IS NULL OR mago_code = '') AND UPPER(TRIM(nome)) = UPPER(TRIM(?)) LIMIT 1"); + $st->execute([$name]); + if ($id = $st->fetchColumn()) { + $pdo->prepare("UPDATE clients SET mago_code = ? WHERE id = ?")->execute([$code, $id]); + return (int)$id; + } + + // nuovo cliente (il nome è unico: se esiste già con un altro codice aggiungo il codice) + $st = $pdo->prepare("SELECT COUNT(*) FROM clients WHERE nome = ?"); + $st->execute([$name]); + $nome = $st->fetchColumn() ? "$name ($code)" : $name; + $pdo->prepare("INSERT INTO clients (nome, mago_code) VALUES (?, ?)")->execute([mb_substr($nome, 0, 255), $code]); + return (int)$pdo->lastInsertId(); + } + + /** + * Crea il lavoro in productiondata e collega la riga della coda. + * $d: idmatrice, id_linea, mescole[], metri, kg_sp, kg_p, ore_previste, data_zibo, data_cliente, + * note_extra, id_status, remember (salva la corrispondenza codice Mago → matrice) + */ + public static function import(PDO $pdo, $queueId, array $d, $user = '') + { + $st = $pdo->prepare("SELECT * FROM mago_prod_queue WHERE id = ? FOR UPDATE"); + $own = !$pdo->inTransaction(); + if ($own) $pdo->beginTransaction(); + try { + $st->execute([(int)$queueId]); + $q = $st->fetch(PDO::FETCH_ASSOC); + if (!$q) throw new RuntimeException('Riga non trovata'); + if ($q['queue_status'] === 'importato') throw new RuntimeException("Ordine {$q['internal_ord_no']} riga {$q['line_no']}: già in programmazione"); + + $idm = (int)($d['idmatrice'] ?? 0); + $lin = (int)($d['id_linea'] ?? 0); + if (!$idm) throw new RuntimeException("Ordine {$q['internal_ord_no']} riga {$q['line_no']}: manca la matrice"); + if (!$lin) throw new RuntimeException("Ordine {$q['internal_ord_no']} riga {$q['line_no']}: manca la linea"); + $status = (int)($d['id_status'] ?? self::STATUS_DA_PROGRAMMARE); + if (!in_array($status, [self::STATUS_DA_PROGRAMMARE, self::STATUS_PROGRAMMATO], true)) $status = self::STATUS_DA_PROGRAMMARE; + + $idCliente = self::clientId($pdo, $q['customer'], $q['company_name']); + $date = function ($v) { + return MagoSalesStats::isValidDate((string)$v) ? $v : null; + }; + + $pdo->prepare(" + INSERT INTO productiondata + (Data, data_produzione, conferma_ordine, idmatrice, id_linea, id_cliente, data_zibo, data_cliente, + metri, kg_sp, kg_p, ore_previste, note_extra, id_status, + fermomacchina, `hour`, hourprod, kgprod, mtprod, scarto) + VALUES + (CURDATE(), NULL, :conferma, :idmatrice, :id_linea, :id_cliente, :data_zibo, :data_cliente, + :metri, :kg_sp, :kg_p, :ore, :note, :status, + 0, '00:00:00', '00:00:00', 0, 0, 0) + ")->execute([ + 'conferma' => mb_substr($q['internal_ord_no'], 0, 50), + 'idmatrice' => $idm, + 'id_linea' => $lin, + 'id_cliente' => $idCliente, + 'data_zibo' => $date($d['data_zibo'] ?? $q['zibo_date']), + 'data_cliente' => $date($d['data_cliente'] ?? $q['client_date']), + 'metri' => round((float)($d['metri'] ?? 0), 2), + 'kg_sp' => round((float)($d['kg_sp'] ?? 0), 2), + 'kg_p' => round((float)($d['kg_p'] ?? 0), 2), + 'ore' => min(999.99, round((float)($d['ore_previste'] ?? 0), 2)), + 'note' => (string)($d['note_extra'] ?? '') ?: null, + 'status' => $status, + ]); + $pid = (int)$pdo->lastInsertId(); + + $mescole = array_values(array_unique(array_filter(array_map('intval', (array)($d['mescole'] ?? []))))); + if ($mescole) { + $insM = $pdo->prepare("INSERT INTO productiondata_mescole (id_productiondata, id_mescola) VALUES (?, ?)"); + foreach ($mescole as $m) $insM->execute([$pid, $m]); + } + + // la scelta della matrice fatta a mano diventa la regola per le prossime righe con lo stesso codice + $matchStatus = $q['match_status']; + if ((int)$q['idmatrice'] !== $idm) $matchStatus = 'manuale'; + if (!empty($d['remember']) && trim($q['matrice_misura']) !== '') { + $pdo->prepare("INSERT INTO matrice_mago_map (codice_mago, idmatrice, note, created_by, created_at) + VALUES (?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE idmatrice = VALUES(idmatrice), created_by = VALUES(created_by), created_at = NOW()") + ->execute([strtoupper(trim($q['matrice_misura'])), $idm, 'da import ordine ' . $q['internal_ord_no'], mb_substr((string)$user, 0, 100)]); + $matchStatus = 'mappa'; + } + + $pdo->prepare("UPDATE mago_prod_queue SET queue_status = 'importato', productiondata_id = ?, idmatrice = ?, + match_status = ?, imported_at = NOW(), imported_by = ?, mago_changed = 0, mago_changes = '', updated_at = NOW() + WHERE id = ?") + ->execute([$pid, $idm, $matchStatus, mb_substr((string)$user, 0, 100), $q['id']]); + + if ($own) $pdo->commit(); + return $pid; + } catch (Throwable $e) { + if ($own && $pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } + } +} diff --git a/public/userarea/class/MagoSalesStats.php b/public/userarea/class/MagoSalesStats.php index 0dda9934..40aac5c4 100644 --- a/public/userarea/class/MagoSalesStats.php +++ b/public/userarea/class/MagoSalesStats.php @@ -290,7 +290,7 @@ class MagoSalesStats $parts = []; foreach ($words as $w) { $like = "'%" . str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $w) . "%'"; - foreach (['external_ord_no', 'our_reference', 'your_reference', 'notes'] as $col) { + foreach (['internal_ord_no', 'external_ord_no', 'our_reference', 'your_reference', 'notes'] as $col) { $parts[] = "COALESCE($a.$col, '') LIKE $like"; // COALESCE: un campo NULL non deve escludere l'ordine } $parts[] = "EXISTS (SELECT 1 FROM mago_sale_ord_detail ex diff --git a/public/userarea/class/MagoSync.php b/public/userarea/class/MagoSync.php index 631931ad..a8f04912 100644 --- a/public/userarea/class/MagoSync.php +++ b/public/userarea/class/MagoSync.php @@ -76,6 +76,14 @@ class MagoSync $report['documenti'] = 'tabella mago_sale_doc mancante: eseguire la migrazione Phinx'; $this->log('Documenti: tabella mago_sale_doc mancante, salto (eseguire la migrazione)'); } + + // Coda "Nuovi da Mago" per la programmazione produzione (solo dati locali, nessuna chiamata a Mago) + if ($this->tableExists('mago_prod_queue')) { + require_once __DIR__ . '/MagoProduction.php'; + $q = MagoProduction::refreshQueue($this->pdo); + $report['coda_produzione'] = $q; + $this->log("Coda produzione: {$q['nuove']} nuove, {$q['aggiornate']} aggiornate, {$q['chiuse']} chiuse, {$q['modificate']} modificate dopo l'import"); + } } catch (Throwable $e) { $report['ok'] = false; $report['message'] = $e->getMessage(); diff --git a/public/userarea/production_mago_queue.php b/public/userarea/production_mago_queue.php new file mode 100644 index 00000000..995c8674 --- /dev/null +++ b/public/userarea/production_mago_queue.php @@ -0,0 +1,1717 @@ +getConnection(); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// --- Permessi: stessa autorizzazione della programmazione --- +if (!userCan('production.programming.view')) { + if (isset($_GET['action']) || isset($_POST['action'])) { + MagoSalesStats::jsonOut(['success' => false, 'message' => 'Non hai i permessi per questa pagina']); + } + while (ob_get_level()) ob_end_clean(); + http_response_code(403); + echo '

Accesso non consentito

Non hai i permessi per visualizzare questa pagina.

Torna alla Dashboard
'; + exit; +} +// chi importa (per la tracciabilità): nome dell'utente collegato +$userName = ''; +if (!empty($iduserlogin)) { + try { + $st = $pdo->prepare("SELECT COALESCE(NULLIF(TRIM(CONCAT_WS(' ', first_name, last_name)), ''), username, email) FROM auth_users WHERE id = ?"); + $st->execute([(int)$iduserlogin]); + $userName = (string)$st->fetchColumn(); + } catch (Throwable $e) { + $userName = 'utente ' . $iduserlogin; + } +} + +/* ============================================================ + * AJAX + * ============================================================ */ +$action = $_POST['action'] ?? ($_GET['action'] ?? ''); + +if ($action !== '') { + try { + // liste per i menu a tendina (una volta sola) + if ($action === 'options') { + MagoSalesStats::jsonOut([ + 'success' => true, + 'matrici' => $pdo->query("SELECT id, TRIM(nome) AS nome FROM matrice WHERE TRIM(nome) <> '' ORDER BY TRIM(nome)")->fetchAll(PDO::FETCH_ASSOC), + 'linee' => $pdo->query("SELECT id, name FROM production_lines ORDER BY line_number")->fetchAll(PDO::FETCH_ASSOC), + 'mescole' => $pdo->query("SELECT id, nome FROM mescole WHERE is_active = 1 ORDER BY nome")->fetchAll(PDO::FETCH_ASSOC), + 'from' => MagoProduction::QUEUE_FROM_DATE, + 'auto' => MagoProduction::AUTO_IMPORT, + ]); + } + + // elenco della coda + if ($action === 'list') { + $counts = ['nuovo' => 0, 'importato' => 0, 'scartato' => 0, 'chiuso_mago' => 0, 'modificati' => 0]; + foreach ($pdo->query("SELECT queue_status, COUNT(*) n, SUM(mago_changed) ch FROM mago_prod_queue GROUP BY queue_status") as $r) { + $counts[$r['queue_status']] = (int)$r['n']; + if ($r['queue_status'] === 'importato') $counts['modificati'] = (int)$r['ch']; + } + $view = $_GET['view'] ?? 'nuovo'; + $where = $view === 'modificati' ? "q.queue_status = 'importato' AND q.mago_changed = 1" + : (in_array($view, ['nuovo', 'importato', 'scartato', 'chiuso_mago'], true) ? "q.queue_status = " . $pdo->quote($view) : "q.queue_status = 'nuovo'"); + $rows = $pdo->query(" + SELECT q.*, TRIM(m.nome) AS matrice_nome, + p.id_status AS prod_status, s.nome AS prod_status_nome, COALESCE(s.badge_color, '#6c757d') AS prod_badge + FROM mago_prod_queue q + LEFT JOIN matrice m ON m.id = q.idmatrice + LEFT JOIN productiondata p ON p.id = q.productiondata_id + LEFT JOIN production_status s ON s.id = p.id_status + WHERE $where + ORDER BY q.zibo_date IS NULL, q.zibo_date, q.internal_ord_no, q.line_no + LIMIT 3000 + ")->fetchAll(PDO::FETCH_ASSOC); + MagoSalesStats::jsonOut(['success' => true, 'counts' => $counts, 'rows' => $rows]); + } + + // proposta di compilazione (anche al cambio matrice nella finestra) + if ($action === 'prefill') { + $idm = isset($_GET['idmatrice']) && $_GET['idmatrice'] !== '' ? (int)$_GET['idmatrice'] : null; + $p = MagoProduction::prefill($pdo, (int)($_GET['id'] ?? 0), $idm); + if (!$p) MagoSalesStats::jsonOut(['success' => false, 'message' => 'Riga non trovata']); + MagoSalesStats::jsonOut(['success' => true, 'prefill' => $p]); + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') MagoSalesStats::jsonOut(['success' => false, 'message' => 'Richiesta non valida']); + + // porta una riga in programmazione + if ($action === 'import') { + $pid = MagoProduction::import($pdo, (int)$_POST['id'], [ + 'idmatrice' => $_POST['idmatrice'] ?? 0, + 'id_linea' => $_POST['id_linea'] ?? 0, + 'mescole' => $_POST['mescole'] ?? [], + 'metri' => $_POST['metri'] ?? 0, + 'kg_sp' => $_POST['kg_sp'] ?? 0, + 'kg_p' => $_POST['kg_p'] ?? 0, + 'ore_previste' => $_POST['ore_previste'] ?? 0, + 'data_zibo' => $_POST['data_zibo'] ?? null, + 'data_cliente' => $_POST['data_cliente'] ?? null, + 'note_extra' => $_POST['note_extra'] ?? '', + 'id_status' => $_POST['id_status'] ?? MagoProduction::STATUS_DA_PROGRAMMARE, + 'remember' => !empty($_POST['remember']), + ], $userName); + MagoSalesStats::jsonOut(['success' => true, 'productiondata_id' => $pid]); + } + + // più righe insieme: solo quelle con matrice e linea già certe + if ($action === 'bulk_import') { + $ok = []; + $skip = []; + $status = (int)($_POST['id_status'] ?? MagoProduction::STATUS_DA_PROGRAMMARE); + foreach ((array)($_POST['ids'] ?? []) as $id) { + $p = MagoProduction::prefill($pdo, (int)$id); + if (!$p) continue; + $label = $p['queue']['internal_ord_no'] . ' riga ' . $p['queue']['line_no']; + if (!$p['idmatrice']) { + $skip[] = "$label: manca la matrice"; + continue; + } + if (!$p['id_linea']) { + $skip[] = "$label: la matrice ha " . (count($p['lines']) ? count($p['lines']) . ' linee possibili' : 'nessuna linea') . ', sceglila a mano'; + continue; + } + try { + $p['id_status'] = $status; + $ok[] = MagoProduction::import($pdo, (int)$id, $p, $userName); + } catch (Throwable $e) { + $skip[] = $e->getMessage(); + } + } + MagoSalesStats::jsonOut(['success' => true, 'imported' => count($ok), 'skipped' => $skip]); + } + + if ($action === 'discard') { + $pdo->prepare("UPDATE mago_prod_queue SET queue_status = 'scartato', discard_reason = ?, updated_at = NOW() WHERE id = ? AND queue_status = 'nuovo'") + ->execute([mb_substr(trim((string)($_POST['reason'] ?? '')), 0, 255), (int)$_POST['id']]); + MagoSalesStats::jsonOut(['success' => true]); + } + if ($action === 'restore') { + $pdo->prepare("UPDATE mago_prod_queue SET queue_status = 'nuovo', discard_reason = '', updated_at = NOW() WHERE id = ? AND queue_status = 'scartato'") + ->execute([(int)$_POST['id']]); + MagoSalesStats::jsonOut(['success' => true]); + } + if ($action === 'ack') { + $pdo->prepare("UPDATE mago_prod_queue SET mago_changed = 0, updated_at = NOW() WHERE id = ?")->execute([(int)$_POST['id']]); + MagoSalesStats::jsonOut(['success' => true]); + } + if ($action === 'refresh') { + MagoSalesStats::jsonOut(['success' => true, 'report' => MagoProduction::refreshQueue($pdo)]); + } + + MagoSalesStats::jsonOut(['success' => false, 'message' => 'Azione sconosciuta']); + } catch (Throwable $e) { + MagoSalesStats::jsonOut(['success' => false, 'message' => $e->getMessage()]); + } +} +?> + + + + + + + + + + Nuovi da Mago - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ +
+
+
+
+
+
Nuovi ordini da Mago
+ Righe d'ordine da portare in programmazione +
+
+
+ + Programmazione +
+
+
+ +
+
+
+
Nuovi
+
—
+
da portare in programmazione
+
+
+
Modificati in Mago
+
—
+
già in produzione, cambiati dopo
+
+
+
In programmazione
+
—
+
già portati in produzione
+
+
+
Scartati
+
—
+
da non produrre
+
+
+
Chiusi in Mago
+
—
+
consegnati o annullati prima
+
+
+ +
+ +
+ + + + + + +
+
+ +
+
+
+
+
+ 0 righe selezionate + verranno portate solo quelle con matrice collegata e linea unica + + + +
+
+
+
+
+
+ + +
+ + + + + + + + + + \ No newline at end of file diff --git a/public/userarea/produzione_programmazione.php b/public/userarea/produzione_programmazione.php index 218a15a5..77cf2af1 100644 --- a/public/userarea/produzione_programmazione.php +++ b/public/userarea/produzione_programmazione.php @@ -250,6 +250,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action'])) { + query("SELECT COUNT(*) FROM mago_prod_queue WHERE queue_status = 'nuovo' OR (queue_status = 'importato' AND mago_changed = 1)")->fetchColumn(); + } catch (Throwable $e) { + } + ?> + + 🆕 Nuovi da Mago + diff --git a/public/userarea/produzione_programmazione_drag.php b/public/userarea/produzione_programmazione_drag.php index a6016197..257688e3 100644 --- a/public/userarea/produzione_programmazione_drag.php +++ b/public/userarea/produzione_programmazione_drag.php @@ -384,6 +384,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action'])) { + query("SELECT COUNT(*) FROM mago_prod_queue WHERE queue_status = 'nuovo' OR (queue_status = 'importato' AND mago_changed = 1)")->fetchColumn(); + } catch (Throwable $e) { + } + ?> + + 🆕 Nuovi da Mago + diff --git a/public/userarea/sales_customer.php b/public/userarea/sales_customer.php index bcdfd2bc..ea3ecddb 100644 --- a/public/userarea/sales_customer.php +++ b/public/userarea/sales_customer.php @@ -1926,7 +1926,7 @@ if ($action === 'data') { }); })(); - + \ No newline at end of file diff --git a/public/userarea/sales_help.js b/public/userarea/sales_help.js index 30495433..50cdc1c3 100644 --- a/public/userarea/sales_help.js +++ b/public/userarea/sales_help.js @@ -70,6 +70,17 @@ }, /* ===================== SOMMARIO ===================== */ + "sm.flow": { + t: "Nuovi ordini", + b: `

Quanti ordini sono entrati (per data ordine) e quanto valgono, annullati esclusi:

+ +

▲ verde = più della volta prima · ▼ rosso = meno.

+

"Oggi" va letto a fine giornata: al mattino è normale che sia basso.

`, + }, "sm.invYtd": { t: "Fatturato dell'anno", b: `

Fatturato da gennaio a oggi rispetto allo stesso periodo dell'anno scorso.

diff --git a/public/userarea/sales_orders_deliveries.php b/public/userarea/sales_orders_deliveries.php index 7e4259e3..e341e725 100644 --- a/public/userarea/sales_orders_deliveries.php +++ b/public/userarea/sales_orders_deliveries.php @@ -1300,7 +1300,7 @@ if ($action === 'data') { }); })(); - + \ No newline at end of file diff --git a/public/userarea/sales_orders_stats.php b/public/userarea/sales_orders_stats.php index ddb09a5b..b035f91c 100644 --- a/public/userarea/sales_orders_stats.php +++ b/public/userarea/sales_orders_stats.php @@ -2354,7 +2354,7 @@ if ($action === 'order') { }); })(); - + \ No newline at end of file diff --git a/public/userarea/sales_summary.php b/public/userarea/sales_summary.php index cd766a9c..98741a43 100644 --- a/public/userarea/sales_summary.php +++ b/public/userarea/sales_summary.php @@ -389,6 +389,56 @@ if ($action === 'data') { return $m['delta'] < 0; })); + // ---------------- ORDINI IN ARRIVO: oggi, 7 giorni, 30 giorni (vs periodo precedente) ---------------- + $fl = MagoSalesStats::fetchAllQ($pdo, " + SELECT + SUM(x.order_date = CURDATE()) AS n0, + SUM(CASE WHEN x.order_date = CURDATE() THEN x.taxable_amount ELSE 0 END) AS v0, + SUM(x.order_date = CURDATE() - INTERVAL 7 DAY) AS n0p, + SUM(CASE WHEN x.order_date = CURDATE() - INTERVAL 7 DAY THEN x.taxable_amount ELSE 0 END) AS v0p, + SUM(x.order_date > CURDATE() - INTERVAL 7 DAY) AS n7, + SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 7 DAY THEN x.taxable_amount ELSE 0 END) AS v7, + SUM(x.order_date <= CURDATE() - INTERVAL 7 DAY AND x.order_date > CURDATE() - INTERVAL 14 DAY) AS n7p, + SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 7 DAY AND x.order_date > CURDATE() - INTERVAL 14 DAY THEN x.taxable_amount ELSE 0 END) AS v7p, + SUM(x.order_date > CURDATE() - INTERVAL 30 DAY) AS n30, + SUM(CASE WHEN x.order_date > CURDATE() - INTERVAL 30 DAY THEN x.taxable_amount ELSE 0 END) AS v30, + SUM(x.order_date <= CURDATE() - INTERVAL 30 DAY AND x.order_date > CURDATE() - INTERVAL 60 DAY) AS n30p, + SUM(CASE WHEN x.order_date <= CURDATE() - INTERVAL 30 DAY AND x.order_date > CURDATE() - INTERVAL 60 DAY THEN x.taxable_amount ELSE 0 END) AS v30p, + COUNT(DISTINCT CASE WHEN x.order_date > CURDATE() - INTERVAL 30 DAY THEN x.customer END) AS c30 + FROM $from + WHERE x.status <> 'annullato' AND x.order_date > CURDATE() - INTERVAL 60 DAY AND x.order_date <= CURDATE() + ", [])[0]; + $flow = [ + [ + 'key' => 'today', + 'title' => 'Oggi', + 'n' => (int)$fl['n0'], + 'v' => (float)$fl['v0'], + 'np' => (int)$fl['n0p'], + 'vp' => (float)$fl['v0p'], + 'vs' => 'stesso giorno della settimana scorsa' + ], + [ + 'key' => 'week', + 'title' => 'Ultimi 7 giorni', + 'n' => (int)$fl['n7'], + 'v' => (float)$fl['v7'], + 'np' => (int)$fl['n7p'], + 'vp' => (float)$fl['v7p'], + 'vs' => '7 giorni precedenti' + ], + [ + 'key' => 'month', + 'title' => 'Ultimi 30 giorni', + 'n' => (int)$fl['n30'], + 'v' => (float)$fl['v30'], + 'np' => (int)$fl['n30p'], + 'vp' => (float)$fl['v30p'], + 'vs' => '30 giorni precedenti', + 'customers' => (int)$fl['c30'] + ], + ]; + // ---------------- GIUDIZIO COMPLESSIVO ---------------- $score = ['green' => 0, 'yellow' => 0, 'red' => 0, 'grey' => 0]; foreach ($T as $t) $score[$t['light']]++; @@ -402,6 +452,7 @@ if ($action === 'data') { 'year' => $curY, 'inv_ok' => $invOk, 'tiles' => $T, + 'flow' => $flow, 'score' => $score, 'points' => $points === null ? null : round($points), 'overall' => $overall, @@ -1290,6 +1341,99 @@ if ($action === 'data') { margin-top: 4px; } + .flow-head { + font-weight: 900; + font-size: 1.05rem; + color: var(--c-ink); + margin: 4px 0 10px; + } + + .flow-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; + } + + .fl-card { + display: block; + text-decoration: none; + color: inherit; + background: linear-gradient(135deg, #eff6ff, #fff); + border: 1px solid #dbeafe; + border-radius: 16px; + padding: 14px 16px; + box-shadow: 0 6px 18px rgba(15, 23, 42, .05); + transition: transform .15s ease, box-shadow .15s ease; + } + + .fl-card:hover { + transform: translateY(-2px); + box-shadow: 0 12px 26px rgba(15, 23, 42, .1); + color: inherit; + } + + .fl-t { + font-size: .78rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .5px; + color: #1d4ed8; + } + + .fl-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 10px; + margin-top: 6px; + } + + .fl-n { + font-size: 2.1rem; + font-weight: 900; + line-height: 1; + color: var(--c-ink); + } + + .fl-v { + font-size: 1.35rem; + font-weight: 800; + color: var(--c-ink); + line-height: 1.1; + } + + .fl-l { + font-size: .8rem; + color: var(--c-muted); + margin-top: 4px; + } + + .fl-d { + font-weight: 800; + margin-left: 4px; + white-space: nowrap; + } + + .fl-d.up { + color: #16a34a; + } + + .fl-d.down { + color: #dc2626; + } + + .fl-d.flat { + color: #64748b; + } + + .fl-vs { + font-size: .76rem; + color: var(--c-muted); + margin-top: 8px; + border-top: 1px dashed #dbeafe; + padding-top: 6px; + } + .goodbad { display: grid; grid-template-columns: 1fr 1fr; @@ -1688,6 +1832,10 @@ if ($action === 'data') {
—PUNTEGGIO
+ +
Nuovi ordini
+
+
Cosa va bene
@@ -1720,7 +1868,7 @@ if ($action === 'data') {
Bene Così così: da tenere d'occhio - Da Migliorare: serve un intervento + Male: serve un intervento Dato non disponibile Clicca una tessera per i dettagli · ⓘ per la spiegazione
@@ -1754,7 +1902,7 @@ if ($action === 'data') { const LIGHT_LABEL = { green: 'Bene', yellow: 'Così così', - red: 'Da Migliorare', + red: 'Male', grey: 'n.d.' }; const LIGHT_ICON = { @@ -1816,6 +1964,7 @@ if ($action === 'data') { } $('#content').show(); renderHero(d); + renderFlow(d); renderGoodBad(d); renderTiles(d); renderLists(d); @@ -1859,6 +2008,39 @@ if ($action === 'data') { $('#heroPoints').html(`${d.points === null ? '—' : d.points}SU 100`); } + /* ---------------- NUOVI ORDINI ---------------- */ + function arrow(cur, prev) { + if (!prev && !cur) return '= come prima'; + if (!prev) return '▲ nuovo'; + const p = (cur - prev) / Math.abs(prev) * 100; + const r = Math.round(p); + if (Math.abs(r) < 1) return '= stabile'; + return `${r > 0 ? '▲' : '▼'} ${Math.abs(r)}%`; + } + + function renderFlow(d) { + const icons = { + today: 'fa-sun', + week: 'fa-calendar-week', + month: 'fa-calendar' + }; + $('#flow').html((d.flow || []).map(f => ` + +
${esc(f.title)}
+
+
+
${f.n.toLocaleString('it-IT')}
+
ordini ${arrow(f.n, f.np)}
+
+
+
${fmtEurShort(f.v)}
+
valore ${arrow(f.v, f.vp)}
+
+
+
vs ${esc(f.vs)}: ${f.np.toLocaleString('it-IT')} ordini · ${fmtEurShort(f.vp)}${f.customers !== undefined ? ` · ${f.customers} clienti nel mese` : ''}
+
`).join('')); + } + function renderGoodBad(d) { const ok = d.tiles.filter(t => t.light === 'green' && t.verdict); const ko = d.tiles.filter(t => (t.light === 'red' || t.light === 'yellow') && t.verdict) @@ -2019,7 +2201,7 @@ if ($action === 'data') { }); })(); - + \ No newline at end of file