Files
zibo-dashboard/public/userarea/class/MagoMixPurchases.php
T
2026-10-27 12:07:55 +01:00

428 lines
20 KiB
PHP

<?php
/**
* Acquisti mescole da Mago4 e prezzo €/kg per mescola.
*
* - sync(): copia in mago_mix_purchases le righe delle FATTURE fornitore con articolo MESCOLE (Mago in sola lettura)
* - la mescola si riconosce dalla DESCRIZIONE della riga, confrontata con i "nomi d'acquisto" della mescola
* (tabella mescole_supplier_names): un nome vale se tutte le sue parole sono nella descrizione.
* Es. il nome "ATES71" riconosce "ATES71 M100 X 9mm spessore". Vince il nome più specifico (più parole).
* - prices(): €/kg per mescola = media ponderata sui kg degli ultimi PRICE_MONTHS mesi; se non ci sono acquisti
* nel periodo si usa l'ultimo prezzo pagato (segnalato come "vecchio").
*/
class MagoMixPurchases
{
/** Articolo Mago delle mescole */
const ITEM = 'MESCOLE';
/** Tipo documento fattura fornitore (9830400 DDT, 9830402 nota di credito) */
const DOC_INVOICE = 9830401;
/** Mesi per la media del prezzo (modificabile) */
const PRICE_MONTHS = 3;
/** Sync incrementale: rilegge le fatture degli ultimi N giorni */
const SYNC_DAYS = 90;
/** Fornitori il cui prezzo va verificato (es. conto lavoro: il prezzo non comprende le materie prime) */
const CHECK_SUPPLIERS = ['D022' => '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;
}
}