fixed sommario and added queue mago

This commit is contained in:
2026-10-20 12:08:35 +02:00
parent b6145e0553
commit 89e5388b75
12 changed files with 2558 additions and 7 deletions
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Ponte ordini Mago → programmazione produzione.
*
* mago_prod_queue una riga per ogni riga d'ordine Mago "da produrre" (traccia tutto il percorso:
* nuovo → importato in produzione / scartato / chiuso in Mago)
* matrice_mago_map corrispondenze manuali "valore Matrice/Misura di Mago" → matrice del portale
* clients.mago_code codice cliente Mago (per collegare i clienti del portale)
*/
final class CreateMagoProdQueue extends AbstractMigration
{
public function change(): void
{
$this->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();
}
}
+532
View File
@@ -0,0 +1,532 @@
<?php
/**
* MagoProduction - ponte tra le righe d'ordine Mago e la programmazione produzione.
*
* MagoProduction::refreshQueue($pdo) allinea la coda "Nuovi da Mago" (dopo ogni sync)
* MagoProduction::import($pdo, $id, $data) porta una riga della coda in productiondata
*
* Mago resta in sola lettura: qui si legge solo dalle tabelle locali mago_*.
*/
require_once __DIR__ . '/MagoSalesStats.php';
class MagoProduction
{
/* ===================== IMPOSTAZIONI ===================== */
/** Righe d'ordine considerate: solo ordini da questa data in poi (per partire con poche righe di prova). */
const QUEUE_FROM_DATE = '2026-10-01';
/** Produzione solo se la quantità è maggiore di questo valore (1 o 0 = servizio, indicazione del cliente). */
const MIN_QTY = 1;
/** Articoli che non si producono mai (prefissi del codice articolo Mago). */
const EXCLUDED_ITEM_PREFIXES = ['SPES', 'SPATT'];
/** Import automatico in programmazione delle righe con matrice certa e linea unica (false = solo su conferma). */
const AUTO_IMPORT = false;
/** Status di productiondata assegnato all'import. */
const STATUS_DA_PROGRAMMARE = 1;
const STATUS_PROGRAMMATO = 6;
/** Etichette dei tipi di Matrice/Misura. */
const TIPI = [
'M' => '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;
}
}
}
+1 -1
View File
@@ -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
+8
View File
@@ -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();
File diff suppressed because it is too large Load Diff
@@ -250,6 +250,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action'])) {
<?php endforeach; ?>
</div>
<?php
$nuoviMago = 0;
try {
$nuoviMago = (int)$pdo->query("SELECT COUNT(*) FROM mago_prod_queue WHERE queue_status = 'nuovo' OR (queue_status = 'importato' AND mago_changed = 1)")->fetchColumn();
} catch (Throwable $e) {
}
?>
<a href="production_mago_queue.php" class="btn btn-warning fw-bold">
🆕 Nuovi da Mago <?php if ($nuoviMago): ?><span class="badge bg-dark ms-1"><?= $nuoviMago ?></span><?php endif; ?>
</a>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalNuovo">Aggiungi</button>
<button class="btn btn-secondary" onclick="location.href='production_dashboard.php'">Torna</button>
</div>
@@ -384,6 +384,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action'])) {
<?php endforeach; ?>
</div>
<?php
$nuoviMago = 0;
try {
$nuoviMago = (int)$pdo->query("SELECT COUNT(*) FROM mago_prod_queue WHERE queue_status = 'nuovo' OR (queue_status = 'importato' AND mago_changed = 1)")->fetchColumn();
} catch (Throwable $e) {
}
?>
<a href="production_mago_queue.php" class="btn btn-warning fw-bold">
🆕 Nuovi da Mago <?php if ($nuoviMago): ?><span class="badge bg-dark ms-1"><?= $nuoviMago ?></span><?php endif; ?>
</a>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalNuovo">Aggiungi</button>
<button class="btn btn-secondary" onclick="location.href='production_dashboard.php'">Torna</button>
</div>
+1 -1
View File
@@ -1926,7 +1926,7 @@ if ($action === 'data') {
});
})();
</script>
<script src="sales_help.js?v=2"></script>
<script src="sales_help.js?v=3"></script>
</body>
</html>
+11
View File
@@ -70,6 +70,17 @@
},
/* ===================== SOMMARIO ===================== */
"sm.flow": {
t: "Nuovi ordini",
b: `<p>Quanti ordini sono entrati (per data ordine) e quanto valgono, annullati esclusi:</p>
<ul>
<li><b>Oggi</b>, confrontato con lo stesso giorno della settimana scorsa;</li>
<li><b>Ultimi 7 giorni</b>, confrontati con i 7 giorni prima;</li>
<li><b>Ultimi 30 giorni</b>, confrontati con i 30 giorni prima.</li>
</ul>
<p>▲ verde = più della volta prima · ▼ rosso = meno.</p>
<p class="sh-note">"Oggi" va letto a fine giornata: al mattino è normale che sia basso.</p>`,
},
"sm.invYtd": {
t: "Fatturato dell'anno",
b: `<p>Fatturato da gennaio a oggi rispetto allo <b>stesso periodo dell'anno scorso</b>.</p>
+1 -1
View File
@@ -1300,7 +1300,7 @@ if ($action === 'data') {
});
})();
</script>
<script src="sales_help.js?v=2"></script>
<script src="sales_help.js?v=3"></script>
</body>
</html>
+1 -1
View File
@@ -2354,7 +2354,7 @@ if ($action === 'order') {
});
})();
</script>
<script src="sales_help.js?v=2"></script>
<script src="sales_help.js?v=3"></script>
</body>
</html>
+185 -3
View File
@@ -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') {
<div class="ring" id="heroRing" style="--p:0;"><span id="heroPoints">—<small>PUNTEGGIO</small></span></div>
</div>
<!-- ORDINI IN ARRIVO -->
<div class="flow-head"><span data-help="sm.flow"><i class="fa-solid fa-inbox me-2"></i>Nuovi ordini</span></div>
<div class="flow-grid mb-3" id="flow"></div>
<div class="goodbad mb-2">
<div class="gb-card good">
<h6><i class="fa-solid fa-circle-check me-1"></i> Cosa va bene</h6>
@@ -1720,7 +1868,7 @@ if ($action === 'data') {
<div class="legend-strip">
<span><span class="dot green"></span> Bene</span>
<span><span class="dot yellow"></span> Così così: da tenere d'occhio</span>
<span><span class="dot red"></span> Da Migliorare: serve un intervento</span>
<span><span class="dot red"></span> Male: serve un intervento</span>
<span><span class="dot grey"></span> Dato non disponibile</span>
<span class="ms-auto">Clicca una tessera per i dettagli · ⓘ per la spiegazione</span>
</div>
@@ -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}<small>SU 100</small>`);
}
/* ---------------- NUOVI ORDINI ---------------- */
function arrow(cur, prev) {
if (!prev && !cur) return '<span class="fl-d flat">= come prima</span>';
if (!prev) return '<span class="fl-d up">▲ nuovo</span>';
const p = (cur - prev) / Math.abs(prev) * 100;
const r = Math.round(p);
if (Math.abs(r) < 1) return '<span class="fl-d flat">= stabile</span>';
return `<span class="fl-d ${r > 0 ? 'up' : 'down'}">${r > 0 ? '▲' : '▼'} ${Math.abs(r)}%</span>`;
}
function renderFlow(d) {
const icons = {
today: 'fa-sun',
week: 'fa-calendar-week',
month: 'fa-calendar'
};
$('#flow').html((d.flow || []).map(f => `
<a class="fl-card" href="sales_orders_stats.php" title="Apri Andamento ordini">
<div class="fl-top"><span class="fl-t"><i class="fa-solid ${icons[f.key]} me-1"></i>${esc(f.title)}</span></div>
<div class="fl-row">
<div>
<div class="fl-n">${f.n.toLocaleString('it-IT')}</div>
<div class="fl-l">ordini ${arrow(f.n, f.np)}</div>
</div>
<div class="text-end">
<div class="fl-v">${fmtEurShort(f.v)}</div>
<div class="fl-l">valore ${arrow(f.v, f.vp)}</div>
</div>
</div>
<div class="fl-vs">vs ${esc(f.vs)}: ${f.np.toLocaleString('it-IT')} ordini · ${fmtEurShort(f.vp)}${f.customers !== undefined ? ` · ${f.customers} clienti nel mese` : ''}</div>
</a>`).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') {
});
})();
</script>
<script src="sales_help.js?v=2"></script>
<script src="sales_help.js?v=3"></script>
</body>
</html>