'Der-Gom']; /** Righe che non sono una mescola da prezzare */ const EXCLUDE_RE = '/RECUPER|RITAGLI|\bMIX\b|\bREC\b|DA FILTRARE|RILAVORAR|RESO NON LAVORATO|NON STANDARD|SCADENZA|VARIE|ACCELERANTI|\bPROVA\b/i'; const SYN = ['IMP' => '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], * last_supplier, check (nome fornitore da verificare o null)] */ 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']; $a['last_sc'] = (string)$l['supplier_code']; $a['last_sn'] = (string)($l['supplier_name'] ?: $l['supplier_code']); } $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, 'last_supplier' => $a['last_sn'], 'check' => self::CHECK_SUPPLIERS[$a['last_sc']] ?? null, ]; } return $out; } /** Prezzo di una mescola in un mese: quello del mese, altrimenti l'ultimo dei mesi precedenti */ public static function priceAt(array $p, string $ym): ?float { $best = null; foreach ($p['months'] as $m => $v) { if ($m > $ym) break; $best = $v; } return $best; } /** 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; } }