diff --git a/db/migrations/20261027104527_add_mago_mix_purchases.php b/db/migrations/20261027104527_add_mago_mix_purchases.php new file mode 100644 index 00000000..9e384e9a --- /dev/null +++ b/db/migrations/20261027104527_add_mago_mix_purchases.php @@ -0,0 +1,62 @@ +table('suppliers'); + if (!$t->hasColumn('mago_code')) { + $t->addColumn('mago_code', 'string', ['limit' => 20, 'null' => true, 'after' => 'vat']) + ->addIndex(['mago_code'], ['name' => 'idx_suppliers_mago_code']) + ->update(); + } + + // codici Mago già noti dalla prova acquisti (DER-GOM e HEXPOL si collegano da soli al primo sync) + foreach (['COMET' => 'C063', 'SIGEA' => 'S022', 'TOVO' => 'T050', 'EVER COMPOUND' => 'E025'] as $name => $code) { + $this->execute("UPDATE suppliers SET mago_code = '$code' WHERE supplier_name = '$name' AND (mago_code IS NULL OR mago_code = '')"); + } + if (!$this->fetchRow("SELECT idsupplier FROM suppliers WHERE supplier_name LIKE 'HEXPOL%'")) { + $this->execute("INSERT INTO suppliers (supplier_name) VALUES ('HEXPOL')"); + } + + if (!$this->hasTable('mago_mix_purchases')) { + $this->table('mago_mix_purchases', ['id' => false, 'primary_key' => ['purchase_doc_id', 'line'], 'collation' => 'utf8mb4_general_ci']) + ->addColumn('purchase_doc_id', 'integer') + ->addColumn('line', 'integer') + ->addColumn('document_type', 'integer', ['null' => true]) + ->addColumn('doc_date', 'date', ['null' => true]) + ->addColumn('doc_no', 'string', ['limit' => 40, 'null' => true]) + ->addColumn('supplier_code', 'string', ['limit' => 20, 'null' => true]) + ->addColumn('supplier_name', 'string', ['limit' => 150, 'null' => true]) + ->addColumn('item', 'string', ['limit' => 40, 'null' => true]) + ->addColumn('description', 'string', ['limit' => 500, 'null' => true]) + ->addColumn('uom', 'string', ['limit' => 10, 'null' => true]) + ->addColumn('qty', 'decimal', ['precision' => 14, 'scale' => 3, 'default' => 0]) + ->addColumn('unit_value', 'decimal', ['precision' => 14, 'scale' => 5, 'default' => 0]) + ->addColumn('taxable_amount', 'decimal', ['precision' => 14, 'scale' => 2, 'default' => 0]) + ->addColumn('notes', 'string', ['limit' => 500, 'null' => true]) + ->addColumn('purchase_ord_no', 'string', ['limit' => 40, 'null' => true]) + ->addColumn('synced_at', 'datetime', ['null' => true]) + ->addIndex(['doc_date']) + ->addIndex(['supplier_code']) + ->create(); + } + } + + public function down(): void + { + if ($this->hasTable('mago_mix_purchases')) $this->table('mago_mix_purchases')->drop()->save(); + $t = $this->table('suppliers'); + if ($t->hasColumn('mago_code')) $t->removeIndexByName('idx_suppliers_mago_code')->removeColumn('mago_code')->update(); + } +} diff --git a/public/userarea/class/MagoMixPurchases.php b/public/userarea/class/MagoMixPurchases.php new file mode 100644 index 00000000..ae3f6dce --- /dev/null +++ b/public/userarea/class/MagoMixPurchases.php @@ -0,0 +1,408 @@ + 'IMPASTO', 'ZIBO' => 'ZB', 'MESCOLE' => 'MESCOLA', 'TMDT' => 'TMTD', 'TTMD' => 'TMTD', 'BIANCI' => 'BIANCO', 'ESCO' => 'ECO', 'PAFX' => 'PAF']; + const DROP = ['MESCOLA', 'ART', 'STRISCIA', 'PICCOLA', 'SPESSORE', 'MM', 'LP', 'SP', 'PV', 'ZBO', 'FILTRATA', 'FILTRATO', 'RILAVORATE', 'RIPRESE']; + + private static ?array $namesCache = null; + + /* ============================================================ + * NORMALIZZAZIONE + * ============================================================ */ + + /** + * Parole significative di un nome o descrizione: maiuscole, senza revisione, gradi Comet (M100/L080), + * codici (B5904), formato/imballo (STRISCIA…), punteggiatura; "ATES71" → ATES 71; "01" → 1. + */ + public static function tokens(string $s): array + { + $s = strtoupper($s); + $s = preg_replace('/\bREV\.?\s*\d+/', ' ', $s); + $s = preg_replace('/\b[ML]\d{3}\b/', ' ', $s); + $s = preg_replace('/\(B\d+\)?/', ' ', $s); + $s = preg_replace('/\b\w*(STRISC|TRISCIA|STRSC)\w*\b.*$/s', ' ', $s); + $s = preg_replace('/(?<=[A-Z])(?=\d)|(?<=\d)(?=[A-Z])/', ' ', $s); + $out = []; + foreach (preg_split('/[^A-Z0-9]+/', $s) as $t) { + if ($t === '') continue; + if (ctype_digit($t)) $t = (string)(int)$t; + $t = self::SYN[$t] ?? $t; + if (in_array($t, self::DROP, true)) continue; + $out[$t] = true; + } + return array_keys($out); + } + + public static function isExcluded(string $desc): bool + { + return (bool)preg_match(self::EXCLUDE_RE, $desc); + } + + public static function revision(string $desc): ?int + { + return preg_match('/REV\.?\s*(\d+)/i', $desc, $m) ? (int)$m[1] : null; + } + + /* ============================================================ + * NOMI D'ACQUISTO + * ============================================================ */ + + /** Tutti i nomi d'acquisto con le loro parole */ + public static function names(PDO $pdo, bool $refresh = false): array + { + if (self::$namesCache !== null && !$refresh) return self::$namesCache; + $rows = $pdo->query("SELECT sn.id, sn.idmescola, sn.idsupplier, sn.supplier_mix_name, sn.is_preferred, sn.note, + m.nome AS mescola, m.nomeuscita, m.is_active, s.supplier_name, s.mago_code + FROM mescole_supplier_names sn + JOIN mescole m ON m.id = sn.idmescola + LEFT JOIN suppliers s ON s.idsupplier = sn.idsupplier")->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$r) $r['tokens'] = self::tokens((string)$r['supplier_mix_name']); + unset($r); + return self::$namesCache = array_values(array_filter($rows, fn($r) => $r['tokens'])); + } + + /** + * Mescola di una descrizione d'acquisto. + * @return array status: ok | ambiguous | none | excluded ; name (riga nomi) ; candidates (id mescola) + */ + public static function matchDescription(PDO $pdo, string $desc, ?string $supplierCode = null): array + { + if (self::isExcluded($desc)) return ['status' => 'excluded', 'name' => null, 'candidates' => []]; + $T = array_flip(self::tokens($desc)); + $hits = []; + foreach (self::names($pdo) as $n) { + // nome legato a un fornitore con codice Mago diverso da quello della fattura → non vale + if ($supplierCode !== null && $n['mago_code'] && strcasecmp($n['mago_code'], $supplierCode) !== 0) continue; + foreach ($n['tokens'] as $t) if (!isset($T[$t])) continue 2; + $hits[] = $n; + } + if (!$hits) return ['status' => 'none', 'name' => null, 'candidates' => []]; + $max = max(array_map(fn($n) => count($n['tokens']), $hits)); + $best = array_values(array_filter($hits, fn($n) => count($n['tokens']) === $max)); + $ids = array_values(array_unique(array_map(fn($n) => (int)$n['idmescola'], $best))); + if (count($ids) > 1) return ['status' => 'ambiguous', 'name' => $best[0], 'candidates' => $ids]; + return ['status' => 'ok', 'name' => $best[0], 'candidates' => $ids]; + } + + /** Stesso nome (stesse parole) su mescole diverse */ + public static function duplicateNames(PDO $pdo): array + { + $by = []; + foreach (self::names($pdo) as $n) { + $k = implode(' ', $n['tokens']); + $by[$k][(int)$n['idmescola']] = $n['mescola']; + } + return array_filter($by, fn($m) => count($m) > 1); + } + + /* ============================================================ + * RIGHE D'ACQUISTO ABBINATE + * ============================================================ */ + + /** Righe locali (fatture, KG) con la mescola riconosciuta */ + public static function lines(PDO $pdo, ?string $since = null): array + { + $p = []; + $w = "item = '" . self::ITEM . "' AND document_type = " . self::DOC_INVOICE; + if ($since) { + $w .= " AND doc_date >= :since"; + $p['since'] = $since; + } + $st = $pdo->prepare("SELECT * FROM mago_mix_purchases WHERE $w ORDER BY doc_date, purchase_doc_id, line"); + $st->execute($p); + $out = []; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { + $m = self::matchDescription($pdo, (string)$r['description'], $r['supplier_code']); + $r['match'] = $m['status']; + $r['idmescola'] = $m['status'] === 'ok' ? $m['candidates'][0] : null; + $r['candidates'] = $m['candidates']; + $r['qty'] = (float)$r['qty']; + $r['value'] = (float)$r['taxable_amount'] != 0 ? (float)$r['taxable_amount'] : (float)$r['qty'] * (float)$r['unit_value']; + $r['rev'] = self::revision((string)$r['description']); + $out[] = $r; + } + return $out; + } + + /** + * €/kg per mescola. + * @return array [idmescola => price, last_price, last_date, kg_window, kg_total, old (bool), months [ym => €/kg], suppliers [nome => €/kg]] + */ + public static function prices(PDO $pdo, int $months = self::PRICE_MONTHS, ?array $lines = null): array + { + $months = max(1, $months); + $from = date('Y-m-d', strtotime("-$months months")); + $lines = $lines ?? self::lines($pdo); + $acc = []; + foreach ($lines as $l) { + if (!$l['idmescola'] || $l['qty'] <= 0 || $l['value'] <= 0 || strtoupper(trim((string)$l['uom'])) !== 'KG') continue; + $a = &$acc[$l['idmescola']]; + $a['kg_total'] = ($a['kg_total'] ?? 0) + $l['qty']; + $a['val_total'] = ($a['val_total'] ?? 0) + $l['value']; + if ($l['doc_date'] >= $from) { + $a['kg_w'] = ($a['kg_w'] ?? 0) + $l['qty']; + $a['val_w'] = ($a['val_w'] ?? 0) + $l['value']; + $s = $l['supplier_name'] ?: $l['supplier_code']; + $a['sup'][$s]['kg'] = ($a['sup'][$s]['kg'] ?? 0) + $l['qty']; + $a['sup'][$s]['val'] = ($a['sup'][$s]['val'] ?? 0) + $l['value']; + } + if (($a['last_date'] ?? '') <= $l['doc_date']) { + $a['last_date'] = $l['doc_date']; + $a['last_price'] = $l['value'] / $l['qty']; + } + $ym = substr((string)$l['doc_date'], 0, 7); + $a['m'][$ym]['kg'] = ($a['m'][$ym]['kg'] ?? 0) + $l['qty']; + $a['m'][$ym]['val'] = ($a['m'][$ym]['val'] ?? 0) + $l['value']; + unset($a); + } + $out = []; + foreach ($acc as $id => $a) { + $win = !empty($a['kg_w']); + $ms = []; + ksort($a['m']); + foreach ($a['m'] as $ym => $x) $ms[$ym] = round($x['val'] / $x['kg'], 4); + $sup = []; + foreach ($a['sup'] ?? [] as $s => $x) $sup[$s] = round($x['val'] / $x['kg'], 4); + $out[$id] = [ + 'price' => round($win ? $a['val_w'] / $a['kg_w'] : $a['last_price'], 4), + 'last_price' => round($a['last_price'], 4), + 'last_date' => $a['last_date'], + 'kg_window' => round($a['kg_w'] ?? 0, 1), + 'kg_total' => round($a['kg_total'], 1), + 'old' => !$win, + 'months' => $ms, + 'suppliers' => $sup, + ]; + } + return $out; + } + + /** Descrizioni d'acquisto non abbinate (o ambigue), raggruppate per parole, ordinate per valore */ + public static function unmatched(PDO $pdo, ?array $lines = null): array + { + $lines = $lines ?? self::lines($pdo); + $g = []; + foreach ($lines as $l) { + if (!in_array($l['match'], ['none', 'ambiguous'], true)) continue; + $k = implode(' ', self::tokens((string)$l['description'])); + $x = &$g[$k]; + $x['key'] = $k; + $x['description'] = $x['description'] ?? trim(preg_replace('/\s+/', ' ', (string)$l['description'])); + $x['suggest'] = $x['suggest'] ?? self::suggestName((string)$l['description']); + $x['status'] = $l['match']; + $x['candidates'] = $l['candidates']; + $x['supplier_code'] = $l['supplier_code']; + $x['supplier_name'] = $l['supplier_name']; + $x['n'] = ($x['n'] ?? 0) + 1; + $x['kg'] = ($x['kg'] ?? 0) + $l['qty']; + $x['value'] = ($x['value'] ?? 0) + $l['value']; + $x['last_date'] = max($x['last_date'] ?? '', (string)$l['doc_date']); + unset($x); + } + foreach ($g as &$x) { + $x['price'] = $x['kg'] > 0 ? round($x['value'] / $x['kg'], 4) : null; + $x['kg'] = round($x['kg'], 1); + $x['value'] = round($x['value'], 2); + } + unset($x); + usort($g, fn($a, $b) => $b['value'] <=> $a['value']); + return $g; + } + + /** Nome d'acquisto proposto da una descrizione: senza imballo, spessore, revisione, "MESCOLA" */ + public static function suggestName(string $desc): string + { + $s = strtoupper(preg_replace('/\s+/', ' ', trim($desc))); + $s = preg_replace('/^MESCOL[AE]\s*/', '', $s); + $s = preg_replace('/^ART\.?\s*/', '', $s); + $s = preg_replace('/\s*\bREV\.?\s*\d+\b/', '', $s); + $s = preg_replace('/\s*\b\w*(STRISC|TRISCIA|STRSC)\w*\b.*$/', '', $s); + $s = preg_replace('/\s*\b(X\s*)?\d+\s*MM\b.*$/', '', $s); + $s = preg_replace('/\s*\b(PANI|FESTONI|IMPILATA|IN CASSONI|CONFEZIONE)\b.*$/', '', $s); + return trim($s, " -.,"); + } + + /** Righe e kg acquistati per ogni nome d'acquisto (per la modale della mescola) */ + public static function nameStats(PDO $pdo, int $idmescola, ?array $lines = null): array + { + $lines = $lines ?? self::lines($pdo); + $out = []; + foreach ($lines as $l) { + if ($l['match'] !== 'ok' || (int)$l['idmescola'] !== $idmescola) continue; + $m = self::matchDescription($pdo, (string)$l['description'], $l['supplier_code']); + $nid = (int)$m['name']['id']; + $o = &$out[$nid]; + $o['n'] = ($o['n'] ?? 0) + 1; + $o['kg'] = ($o['kg'] ?? 0) + $l['qty']; + $o['value'] = ($o['value'] ?? 0) + $l['value']; + if (($o['last_date'] ?? '') <= $l['doc_date']) { + $o['last_date'] = $l['doc_date']; + $o['last_price'] = $l['qty'] > 0 ? $l['value'] / $l['qty'] : null; + $o['last_desc'] = $l['description']; + $o['last_supplier'] = $l['supplier_name']; + } + unset($o); + } + return $out; + } + + /* ============================================================ + * SINCRONIZZAZIONE DA MAGO (sola lettura) + * ============================================================ */ + + /** + * Copia le righe MESCOLE delle fatture fornitore con data documento >= $from. + * Le righe del periodo che non esistono più in Mago vengono tolte. + * @return array letti, scritti, tolti, fornitori collegati + */ + public static function sync(PDO $pdo, MagoClient $mago, string $from, ?callable $log = null): array + { + $log = $log ?: function ($s) {}; + $fields = [ + 'PurchaseDocId', + 'Line', + 'DocumentType', + 'DocumentDate', + 'DocNo', + 'Supplier', + 'Item', + 'Description', + 'UoM', + 'Qty', + 'UnitValue', + 'TaxableAmount', + 'Notes', + 'PurchaseOrdNo' + ]; + $flt = [ + ['key' => 'Item', 'value' => self::ITEM, 'operator' => '='], + ['key' => 'DocumentDate', 'value' => $from, 'operator' => '>='], + ['key' => 'DocumentType', 'value' => (string)self::DOC_INVOICE, 'operator' => '='], + ]; + $rows = []; + foreach ($mago->iterate('MA_PurchaseDocDetail', ['fields' => $fields, 'filters' => $flt]) as $rec) $rows[] = $rec; + $log(count($rows) . " righe lette da Mago dal $from"); + + // nomi fornitori Mago + $names = []; + foreach (array_unique(array_map(fn($r) => (string)($r['Supplier'] ?? ''), $rows)) as $code) { + if ($code === '') continue; + try { + $r = $mago->getDataTable('MA_CustSupp', [ + 'fields' => ['CustSupp', 'CompanyName', 'CustSuppType'], + 'filters' => [['key' => 'CustSupp', 'value' => $code, 'operator' => '=']], + 'recordsPerPage' => 5 + ]); + foreach ($r['records'] as $c) { + $names[$code] = trim((string)$c['CompanyName']) ?: $code; + if ((string)$c['CustSuppType'] !== '3211264') break; // preferisco il record fornitore + } + } catch (Throwable $e) { + $names[$code] = $code; + } + } + + $now = date('Y-m-d H:i:s'); + $ins = $pdo->prepare("INSERT INTO mago_mix_purchases + (purchase_doc_id, line, document_type, doc_date, doc_no, supplier_code, supplier_name, item, description, uom, qty, unit_value, taxable_amount, notes, purchase_ord_no, synced_at) + VALUES (:id, :line, :type, :date, :no, :sc, :sn, :item, :desc, :uom, :qty, :uv, :ta, :notes, :pon, :now) + ON DUPLICATE KEY UPDATE document_type = VALUES(document_type), doc_date = VALUES(doc_date), doc_no = VALUES(doc_no), + supplier_code = VALUES(supplier_code), supplier_name = VALUES(supplier_name), item = VALUES(item), description = VALUES(description), + uom = VALUES(uom), qty = VALUES(qty), unit_value = VALUES(unit_value), taxable_amount = VALUES(taxable_amount), + notes = VALUES(notes), purchase_ord_no = VALUES(purchase_ord_no), synced_at = VALUES(synced_at)"); + $seen = []; + $pdo->beginTransaction(); + try { + foreach ($rows as $r) { + $id = (int)$r['PurchaseDocId']; + $line = (int)$r['Line']; + $seen["$id-$line"] = true; + $code = (string)($r['Supplier'] ?? ''); + $ins->execute([ + 'id' => $id, + 'line' => $line, + 'type' => (int)$r['DocumentType'], + 'date' => MagoClient::toMysqlDate($r['DocumentDate'] ?? null), + 'no' => mb_substr((string)($r['DocNo'] ?? ''), 0, 40), + 'sc' => $code, + 'sn' => mb_substr($names[$code] ?? $code, 0, 150), + 'item' => (string)($r['Item'] ?? ''), + 'desc' => mb_substr(trim((string)($r['Description'] ?? '')), 0, 500), + 'uom' => trim((string)($r['UoM'] ?? '')), + 'qty' => MagoClient::toDecimal($r['Qty'] ?? 0), + 'uv' => MagoClient::toDecimal($r['UnitValue'] ?? 0), + 'ta' => MagoClient::toDecimal($r['TaxableAmount'] ?? 0), + 'notes' => mb_substr(trim((string)($r['Notes'] ?? '')), 0, 500), + 'pon' => mb_substr((string)($r['PurchaseOrdNo'] ?? ''), 0, 40), + 'now' => $now, + ]); + } + // righe del periodo non più presenti in Mago + $removed = 0; + $st = $pdo->prepare("SELECT purchase_doc_id, line FROM mago_mix_purchases WHERE doc_date >= ?"); + $st->execute([$from]); + $del = $pdo->prepare("DELETE FROM mago_mix_purchases WHERE purchase_doc_id = ? AND line = ?"); + foreach ($st->fetchAll(PDO::FETCH_NUM) as [$id, $line]) { + if (!isset($seen["$id-$line"])) { + $del->execute([$id, $line]); + $removed++; + } + } + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + throw $e; + } + + $linked = self::linkSuppliers($pdo, $names); + $log(count($rows) . " righe salvate, $removed tolte" . ($linked ? ", fornitori collegati: " . implode(', ', $linked) : '')); + return ['read' => count($rows), 'removed' => $removed, 'linked' => $linked]; + } + + /** Collega i fornitori del portale senza codice Mago, per nome (es. "DER-GOM" ↔ "DER-GOM S.R.L.") */ + public static function linkSuppliers(PDO $pdo, array $magoNames): array + { + $norm = fn($s) => preg_replace('/[^A-Z0-9]/', '', strtoupper((string)$s)); + $known = $pdo->query("SELECT mago_code FROM suppliers WHERE mago_code IS NOT NULL AND mago_code <> ''")->fetchAll(PDO::FETCH_COLUMN); + $free = $pdo->query("SELECT idsupplier, supplier_name FROM suppliers WHERE mago_code IS NULL OR mago_code = ''")->fetchAll(PDO::FETCH_ASSOC); + $upd = $pdo->prepare("UPDATE suppliers SET mago_code = ? WHERE idsupplier = ? AND (mago_code IS NULL OR mago_code = '')"); + $done = []; + foreach ($magoNames as $code => $name) { + if (in_array($code, $known, true)) continue; + $nm = $norm($name); + foreach ($free as $i => $s) { + $ns = $norm($s['supplier_name']); + if (strlen($ns) >= 4 && strpos($nm, $ns) === 0) { + $upd->execute([$code, $s['idsupplier']]); + $done[] = $s['supplier_name'] . " = $code"; + unset($free[$i]); + break; + } + } + } + return $done; + } +} diff --git a/public/userarea/mago_sync_mix_cli.php b/public/userarea/mago_sync_mix_cli.php new file mode 100644 index 00000000..16d1c799 --- /dev/null +++ b/public/userarea/mago_sync_mix_cli.php @@ -0,0 +1,93 @@ +> /home/ubuntu/logs/mago_sync_mix.log 2>&1 + */ + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit("Solo da terminale\n"); +} + +set_time_limit(0); +date_default_timezone_set('Europe/Rome'); + +// --- vendor e .env: come mago_sync_cli.php (userarea e fino a 3 cartelle sopra) --- +$dirs = [__DIR__, dirname(__DIR__), dirname(__DIR__, 2), dirname(__DIR__, 3)]; +foreach ($dirs as $d) { + if (file_exists($d . '/vendor/autoload.php')) { + require_once $d . '/vendor/autoload.php'; + break; + } +} +if (class_exists('Dotenv\Dotenv')) { + foreach ($dirs as $d) { + if (file_exists($d . '/.env')) { + Dotenv\Dotenv::createImmutable($d)->safeLoad(); + break; + } + } +} + +// --- DB della piattaforma --- +require_once __DIR__ . '/class/db-functions.php'; +require_once __DIR__ . '/class/MagoClient.php'; +require_once __DIR__ . '/class/MagoMixPurchases.php'; + +$opt = getopt('', ['from::', 'prices']); +$from = (isset($opt['from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$opt['from'])) + ? $opt['from'] + : date('Y-m-d', strtotime('-' . MagoMixPurchases::SYNC_DAYS . ' days')); + +$t0 = microtime(true); +$log = function ($s) { + echo '[' . date('Y-m-d H:i:s') . "] $s\n"; +}; + +$mago = null; +$exit = 0; +try { + $pdo = DBHandlerSelect::getInstance()->getConnection(); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $mago = MagoClient::fromEnv(); + $log("Sync acquisti mescole dal $from"); + MagoMixPurchases::sync($pdo, $mago, $from, $log); + + $lines = MagoMixPurchases::lines($pdo); + $c = array_count_values(array_column($lines, 'match')); + $log(sprintf( + "Righe in archivio: %d · abbinate %d · da abbinare %d · ambigue %d · escluse (recuperi…) %d", + count($lines), + $c['ok'] ?? 0, + $c['none'] ?? 0, + $c['ambiguous'] ?? 0, + $c['excluded'] ?? 0 + )); + + if (isset($opt['prices'])) { + $prices = MagoMixPurchases::prices($pdo, MagoMixPurchases::PRICE_MONTHS, $lines); + $names = $pdo->query("SELECT id, nome FROM mescole")->fetchAll(PDO::FETCH_KEY_PAIR); + uasort($prices, fn($a, $b) => $b['kg_total'] <=> $a['kg_total']); + echo "\nPrezzo €/kg (media " . MagoMixPurchases::PRICE_MONTHS . " mesi)\n"; + foreach ($prices as $id => $p) { + printf(" %-45s %7.3f €/kg ultimo %7.3f del %s%s\n", mb_substr($names[$id] ?? "#$id", 0, 45), $p['price'], $p['last_price'], $p['last_date'], $p['old'] ? ' (vecchio)' : ''); + } + } +} catch (Throwable $e) { + $log('ERRORE: ' . $e->getMessage()); + $exit = 1; +} finally { + if ($mago) { + $ok = $mago->logoff(); + $log('Logoff Mago: ' . ($ok ? 'OK' : 'NON riuscito → ' . $mago->getLastLogoffError())); + } + $log('Durata: ' . round(microtime(true) - $t0, 1) . ' s'); +} +exit($exit); diff --git a/public/userarea/mescole.php b/public/userarea/mescole.php index 7f4cc02f..97f2c476 100644 --- a/public/userarea/mescole.php +++ b/public/userarea/mescole.php @@ -395,8 +395,106 @@ background: #fee2e2; color: #991b1b; } - - + + /* ---------- NOMI D'ACQUISTO (MAGO) ---------- */ + .nacq { + display: inline-block; + min-width: 38px; + margin-right: 6px; + padding: 1px 7px; + border-radius: 999px; + font-size: .75rem; + font-weight: 700; + text-align: center; + vertical-align: middle; + } + + .nacq-ok { + background: #e0f2fe; + color: #075985; + } + + .nacq-no { + background: #fef3c7; + color: #92400e; + } + + .nm-card { + border: 1px solid #fde68a; + background: #fffbeb; + box-shadow: none; + } + + .nm-card .card-header { + background: transparent; + border-bottom: 1px solid #fde68a; + } + + .nm-row { + display: grid; + grid-template-columns: minmax(220px, 2fr) minmax(200px, 1.4fr) minmax(170px, 1fr) minmax(150px, .9fr) auto; + gap: 8px; + align-items: center; + background: #fff; + border: 1px solid #fde68a; + border-radius: 12px; + padding: 10px 12px; + margin-bottom: 8px; + } + + @media (max-width: 1200px) { + .nm-row { + grid-template-columns: 1fr 1fr; + } + } + + .nm-desc { + font-weight: 700; + color: #1f2d3d; + line-height: 1.25; + word-break: break-word; + } + + .nm-meta { + font-size: .82rem; + color: #64748b; + } + + .nm-section { + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 14px; + padding: 14px; + margin-bottom: 18px; + } + + .nm-section h6 { + font-weight: 800; + color: #075985; + } + + .nm-price { + background: #fff; + border: 1px solid #bae6fd; + border-radius: 10px; + padding: 8px 12px; + font-size: .92rem; + } + + .nm-old { + display: inline-block; + background: #fee2e2; + color: #991b1b; + border-radius: 999px; + padding: 1px 8px; + font-size: .75rem; + font-weight: 700; + } + + #nmTable td { + text-align: left; + word-break: break-word; + } @@ -430,6 +528,22 @@ + + +
@@ -474,6 +588,7 @@ IFNULL(q.qty_totale, 0) AS qty_totale, (SELECT COUNT(*) FROM mescole_files mf WHERE mf.idmescola = m.id AND mf.categoria = 'certificati_analisi') AS n_certificati, + (SELECT COUNT(*) FROM mescole_supplier_names sn WHERE sn.idmescola = m.id) AS n_nomi_acq, GROUP_CONCAT(DISTINCT pl.name SEPARATOR ', ') AS linee FROM mescole m LEFT JOIN ( @@ -525,9 +640,14 @@ ? "📄 {$nCert}" : "0"; + $nNomi = (int)($row['n_nomi_acq'] ?? 0); + $nomiBadge = "🧾 {$nNomi}"; + echo " - + '); + $("#nmPrice").html(""); + nmOptions().then(() => { + const $s = $("#nmSupplier"); + if ($s.hasClass("select2-hidden-accessible")) $s.select2("destroy"); + $s.html(nmSupplierOptions("")).select2({ + theme: "bootstrap-5", + width: "100%", + dropdownParent: $("#associaFornitoriModal") + }); + nmResetForm(); + return fetch(NM_URL + "?action=list&id=" + encodeURIComponent(idMescola)).then(r => r.json()); + }).then(d => { + if (!d || !d.success) { + $("#nmBody").html(``); + return; + } + const p = d.price; + $("#nmPrice").html(p ? + `Prezzo ${nmNum(p.price, 3)} €/kg(media ${d.months} mesi${p.old ? "" : ", " + nmNum(p.kg_window) + " kg"}) + ${p.old ? 'nessun acquisto recente' : ""}
+ ultimo ${nmNum(p.last_price, 3)} €/kg del ${nmDate(p.last_date)}` : + 'Nessun acquisto abbinato: prezzo non disponibile'); + + nmSetBadge(idMescola, d.rows.length); + if (!d.rows.length) { + $("#nmBody").html(''); + return; + } + $("#nmBody").html(d.rows.map(r => ` + + + + + + + `).join("")); + }); + } + + $(document).on("click", ".nm-edit", function() { + $("#nmEditId").val($(this).attr("data-id")); + $("#nmSupplier").val($(this).attr("data-sup")).trigger("change"); + $("#nmName").val($(this).attr("data-name")).trigger("focus"); + $("#nmSaveBtn").text("💾 Salva nome"); + $("#nmCancel").show(); + }); + + $("#nmCancel").on("click", nmResetForm); + + $("#nmSaveBtn").on("click", function(e) { + e.preventDefault(); + const idMescola = $("#afIdMescola").val(); + const params = { + action: "save", + id: $("#nmEditId").val(), + idmescola: idMescola, + idsupplier: $("#nmSupplier").val() || "", + name: $("#nmName").val().trim() + }; + if (!params.idsupplier || !params.name) { + Swal.fire({ + icon: "warning", + title: "Attenzione", + text: "Fornitore e nome sono obbligatori" + }); + return; + } + nmPost(params).then(d => { + if (!d.success) { + Swal.fire({ + icon: "error", + title: "Non salvato", + text: d.message || "Errore" + }); + return; + } + nmLoad(idMescola); + nmLoadUnmatched(); + }); + }); + + $(document).on("click", ".nm-del", function() { + const id = $(this).data("id"); + const idMescola = $("#afIdMescola").val(); + Swal.fire({ + title: "Togliere questo nome?", + text: "Gli acquisti con questo nome non saranno più collegati alla mescola.", + icon: "warning", + showCancelButton: true, + confirmButtonText: "Sì, togli", + cancelButtonText: "Annulla", + confirmButtonColor: "#d33" + }).then(res => { + if (!res.isConfirmed) return; + nmPost({ + action: "delete", + id + }).then(d => { + if (d.success) { + nmLoad(idMescola); + nmLoadUnmatched(); + } + }); + }); + }); + + /* ---- riquadro "da abbinare" ---- */ + function nmLoadUnmatched() { + Promise.all([nmOptions(), fetch(NM_URL + "?action=unmatched").then(r => r.json())]).then(([o, d]) => { + if (!d || !d.success) return; + const rows = d.rows || []; + $("#nmUnmatchedCount").text(rows.length); + $("#nmSynced").text(d.synced_at ? "Acquisti aggiornati il " + nmDate(d.synced_at) + " " + String(d.synced_at).substring(11, 16) + "." : ""); + $("#nmUnmatchedCard").toggle(rows.length > 0 || (d.duplicates || []).length > 0); + $("#nmDuplicates").html((d.duplicates || []).map(x => + `
⚠ Lo stesso nome d'acquisto ${fEscape(x.name)} è su più mescole: ${x.mescole.map(fEscape).join(", ")}. Toglilo da quella sbagliata.
`).join("")); + + const mesOpts = '' + o.mescole.map(m => + ``).join(""); + + const $list = $("#nmUnmatchedList"); + $list.find("select").each(function() { + if ($(this).hasClass("select2-hidden-accessible")) $(this).select2("destroy"); + }); + $list.html(rows.map((r, i) => ` +
+
+
${fEscape(r.description)}
+
${fEscape(r.supplier_name || r.supplier_code || "")} · ${nmNum(r.kg)} kg · ${r.price !== null ? nmNum(r.price, 3) + " €/kg" : "—"} · ultimo ${nmDate(r.last_date)} + ${r.status === "ambiguous" ? ' · vale per più mescole' : ""}
+
+ + + + +
`).join("")); + $list.find(".nm-u-mescola").select2({ + theme: "bootstrap-5", + width: "100%" + }); + }); + } + + $("#nmToggle").on("click", function() { + const $b = $("#nmUnmatchedBody").toggle(); + $(this).text($b.is(":visible") ? "Nascondi" : "Mostra"); + }); + + $(document).on("click", ".nm-u-save", function() { + const $r = $(this).closest(".nm-row"); + const idmescola = $r.find(".nm-u-mescola").val(); + const params = { + action: "save", + idmescola, + idsupplier: $r.find(".nm-u-sup").val() || "", + name: $r.find(".nm-u-name").val().trim() + }; + if (!idmescola || !params.idsupplier || !params.name) { + Swal.fire({ + icon: "warning", + title: "Attenzione", + text: "Scegli mescola e fornitore e controlla il nome" + }); + return; + } + nmPost(params).then(d => { + if (!d.success) { + Swal.fire({ + icon: "error", + title: "Non abbinato", + text: d.message || "Errore" + }); + return; + } + const $b = $(`tr[data-mescola-id="${idmescola}"] .nacq`); + const n = (parseInt(($b.text() || "").replace(/\D/g, ""), 10) || 0) + 1; + nmSetBadge(idmescola, n); + Swal.fire({ + icon: "success", + title: "Abbinato", + timer: 1100, + showConfirmButton: false + }); + nmLoadUnmatched(); + }); + }); + + $(function() { + nmLoadUnmatched(); + }); diff --git a/public/userarea/mescole_nomi_acquisto.php b/public/userarea/mescole_nomi_acquisto.php new file mode 100644 index 00000000..419bb90a --- /dev/null +++ b/public/userarea/mescole_nomi_acquisto.php @@ -0,0 +1,113 @@ +getConnection(); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $action = $_GET['action'] ?? $_POST['action'] ?? ''; + + /* ---------------- elenco nomi di una mescola ---------------- */ + if ($action === 'list') { + $id = (int)($_GET['id'] ?? 0); + $st = $pdo->prepare("SELECT sn.id, sn.idsupplier, sn.supplier_mix_name, sn.note, s.supplier_name + FROM mescole_supplier_names sn LEFT JOIN suppliers s ON s.idsupplier = sn.idsupplier + WHERE sn.idmescola = ? ORDER BY s.supplier_name, sn.supplier_mix_name"); + $st->execute([$id]); + $rows = $st->fetchAll(PDO::FETCH_ASSOC); + $lines = MagoMixPurchases::lines($pdo); + $stats = MagoMixPurchases::nameStats($pdo, $id, $lines); + foreach ($rows as &$r) { + $s = $stats[(int)$r['id']] ?? null; + $r['n'] = $s['n'] ?? 0; + $r['kg'] = $s ? round($s['kg'], 1) : 0; + $r['last_price'] = $s && $s['last_price'] !== null ? round($s['last_price'], 4) : null; + $r['last_date'] = $s['last_date'] ?? null; + $r['last_desc'] = $s['last_desc'] ?? null; + } + unset($r); + $prices = MagoMixPurchases::prices($pdo, MagoMixPurchases::PRICE_MONTHS, $lines); + nm_out(['success' => true, 'rows' => $rows, 'price' => $prices[$id] ?? null, 'months' => MagoMixPurchases::PRICE_MONTHS]); + } + + /* ---------------- descrizioni non abbinate ---------------- */ + if ($action === 'unmatched') { + $rows = MagoMixPurchases::unmatched($pdo); + $codes = $pdo->query("SELECT mago_code, idsupplier FROM suppliers WHERE mago_code IS NOT NULL AND mago_code <> ''")->fetchAll(PDO::FETCH_KEY_PAIR); + foreach ($rows as &$r) $r['idsupplier'] = $codes[$r['supplier_code']] ?? null; + unset($r); + $dups = []; + foreach (MagoMixPurchases::duplicateNames($pdo) as $k => $m) $dups[] = ['name' => $k, 'mescole' => array_values($m)]; + $last = $pdo->query("SELECT MAX(synced_at) FROM mago_mix_purchases")->fetchColumn(); + nm_out(['success' => true, 'rows' => $rows, 'synced_at' => $last, 'duplicates' => $dups]); + } + + /* ---------------- tendine ---------------- */ + if ($action === 'options') { + nm_out([ + 'success' => true, + 'mescole' => $pdo->query("SELECT id, nome, nomeuscita FROM mescole WHERE is_active = 1 ORDER BY nome")->fetchAll(PDO::FETCH_ASSOC), + 'suppliers' => $pdo->query("SELECT idsupplier, supplier_name, mago_code FROM suppliers ORDER BY supplier_name")->fetchAll(PDO::FETCH_ASSOC), + ]); + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') nm_out(['success' => false, 'message' => 'Azione non valida']); + + /* ---------------- salva ---------------- */ + if ($action === 'save') { + $id = (int)($_POST['id'] ?? 0); + $idm = (int)($_POST['idmescola'] ?? 0); + $ids = (int)($_POST['idsupplier'] ?? 0); + $name = trim(preg_replace('/\s+/', ' ', (string)($_POST['name'] ?? ''))); + if (!$idm || !$ids || $name === '') nm_out(['success' => false, 'message' => 'Mescola, fornitore e nome sono obbligatori']); + $tok = MagoMixPurchases::tokens($name); + if (!$tok) nm_out(['success' => false, 'message' => 'Il nome non contiene parole utili (es. solo "MESCOLA" o "STRISCIA")']); + + // stesso nome (stesse parole) già su un'altra mescola? + foreach (MagoMixPurchases::names($pdo) as $n) { + if ((int)$n['id'] === $id || (int)$n['idmescola'] === $idm) continue; + if ($n['tokens'] == $tok) { + nm_out(['success' => false, 'message' => "Questo nome è già usato per la mescola \"{$n['mescola']}\". Toglilo da lì prima, oppure scrivi un nome più preciso."]); + } + } + if ($id) { + $st = $pdo->prepare("UPDATE mescole_supplier_names SET idsupplier = ?, supplier_mix_name = ? WHERE id = ? AND idmescola = ?"); + $st->execute([$ids, $name, $id, $idm]); + } else { + $st = $pdo->prepare("INSERT IGNORE INTO mescole_supplier_names (idmescola, idsupplier, supplier_mix_name, is_preferred) VALUES (?, ?, ?, 0)"); + $st->execute([$idm, $ids, $name]); + if (!$st->rowCount()) nm_out(['success' => false, 'message' => 'Questo nome c\'è già per la mescola e il fornitore']); + } + nm_out(['success' => true]); + } + + /* ---------------- elimina ---------------- */ + if ($action === 'delete') { + $st = $pdo->prepare("DELETE FROM mescole_supplier_names WHERE id = ?"); + $st->execute([(int)($_POST['id'] ?? 0)]); + nm_out(['success' => true]); + } + + nm_out(['success' => false, 'message' => 'Azione non valida']); +} catch (Throwable $e) { + nm_out(['success' => false, 'message' => $e->getMessage()]); +}
{$badge}{$nomeSafe}{$nomiBadge}{$nomeSafe} {$nomeUscitaSafe} @@ -567,6 +687,7 @@ + + + +
+ + + + + + + + + + + +
FornitoreNome in MagoAcquistatoUltimo acquisto
+
+ + +
📦 Lotti
+
@@ -935,7 +1098,8 @@ $(document).on("click", ".edit-mescola", function() { $("#editIdMescola").val($(this).data("id")); $("#editNomeUscita").val($(this).data("nomeuscita")); - $("#editNomeMescola").val(""); + // carico il nome interno: prima veniva svuotato e al salvataggio si perdeva + $("#editNomeMescola").val($(this).attr("data-nome") || ""); $("#editMescolaModal").modal("show"); }); @@ -1217,6 +1381,7 @@ .then(() => { $("#associaFornitoriModal").modal("show"); afLoadRows(idMescola); + nmLoad(idMescola); }); }); @@ -1677,6 +1842,252 @@ }); }); }); + + // ============================ + // NOMI D'ACQUISTO (MAGO) + // ============================ + const NM_URL = "mescole_nomi_acquisto.php"; + let nmOpts = null; + + function nmOptions() { + if (nmOpts) return Promise.resolve(nmOpts); + return fetch(NM_URL + "?action=options").then(r => r.json()).then(d => (nmOpts = d)); + } + + function nmNum(v, d) { + return (+v || 0).toLocaleString("it-IT", { + minimumFractionDigits: d || 0, + maximumFractionDigits: d || 0 + }); + } + + function nmDate(s) { + return s ? String(s).substring(0, 10).split("-").reverse().join("/") : ""; + } + + function nmSupplierOptions(selected) { + return '' + (nmOpts.suppliers || []).map(s => + ``).join(""); + } + + function nmPost(params) { + return fetch(NM_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams(params).toString() + }).then(r => r.json()); + } + + function nmSetBadge(idMescola, n) { + const $b = $(`tr[data-mescola-id="${idMescola}"] .nacq`); + $b.text("🧾 " + n).toggleClass("nacq-ok", n > 0).toggleClass("nacq-no", n === 0); + } + + function nmResetForm() { + $("#nmEditId").val(""); + $("#nmName").val(""); + $("#nmSupplier").val("").trigger("change"); + $("#nmSaveBtn").text("➕ Aggiungi nome"); + $("#nmCancel").hide(); + } + + function nmLoad(idMescola) { + $("#nmBody").html('
Carico…
${fEscape((d && d.message) || "Errore")}
Nessun nome d\'acquisto: aggiungi come compare nelle fatture Mago
${fEscape(r.supplier_name || "")}${fEscape(r.supplier_mix_name)}${r.note ? `
${fEscape(r.note)}
` : ""}
${r.n ? nmNum(r.kg) + " kg
" + r.n + " righe
" : 'mai'}
${r.last_date ? nmNum(r.last_price, 3) + " €/kg · " + nmDate(r.last_date) + `
${fEscape(r.last_desc || "")}
` : ""}
+ + +