added order name supplier
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Acquisti mescole da Mago:
|
||||
* - suppliers.mago_code: codice fornitore Mago (es. C063 = COMET)
|
||||
* - mago_mix_purchases: righe delle fatture fornitore con articolo MESCOLE (copia locale, sola lettura da Mago)
|
||||
* - fornitore HEXPOL (mancava)
|
||||
*/
|
||||
final class AddMagoMixPurchases extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$t = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
<?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;
|
||||
|
||||
/** 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]]
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Sync acquisti mescole da Mago4 (sola lettura su Mago).
|
||||
*
|
||||
* php mago_sync_mix_cli.php ultimi 90 giorni (incrementale, per il cron)
|
||||
* php mago_sync_mix_cli.php --from=2024-01-01 storico (prima volta)
|
||||
* php mago_sync_mix_cli.php --prices stampa anche il prezzo €/kg per mescola
|
||||
*
|
||||
* Cron consigliato (2 volte al giorno nei giorni lavorativi, lontano dal sync ordini dei :40):
|
||||
* 52 6,13 * * 1-5 flock -n /tmp/mago_sync_mix.lock php /percorso/userarea/mago_sync_mix_cli.php >> /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);
|
||||
+415
-4
@@ -395,8 +395,106 @@
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
/* ---------- 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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -430,6 +528,22 @@
|
||||
<button class="btn btn-add" data-bs-toggle="modal" data-bs-target="#addMescolaModal">➕ Aggiungi Mescola</button>
|
||||
</div>
|
||||
|
||||
<!-- NOMI D'ACQUISTO DA ABBINARE -->
|
||||
<div class="card nm-card mb-3" id="nmUnmatchedCard" style="display:none;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="fw-bold">🧾 Nomi d'acquisto Mago da abbinare <span class="badge bg-warning text-dark" id="nmUnmatchedCount">0</span></div>
|
||||
<div class="small text-muted">Righe delle fatture mescole che non corrispondono a nessuna mescola: senza abbinamento non c'è il prezzo €/kg. <span id="nmSynced"></span></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="nmToggle">Mostra</button>
|
||||
</div>
|
||||
<div class="card-body" id="nmUnmatchedBody" style="display:none;">
|
||||
<div id="nmDuplicates"></div>
|
||||
<div class="small text-muted mb-2">Scegli la mescola, controlla il nome (basta la parte che la identifica) e premi <b>Abbina</b>. Il nome viene aggiunto alla mescola e vale anche per gli acquisti futuri.</div>
|
||||
<div id="nmUnmatchedList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABELLA -->
|
||||
<div class="table-responsive">
|
||||
<table id="tabellaMescole" class="table table-striped align-middle text-center" style="width:100%;">
|
||||
@@ -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 @@
|
||||
? "<span class='badge-files badge-files-ok' title='{$nCert} certificati di analisi caricati'>📄 {$nCert}</span>"
|
||||
: "<span class='badge-files badge-files-none' title='Nessun certificato di analisi'>0</span>";
|
||||
|
||||
$nNomi = (int)($row['n_nomi_acq'] ?? 0);
|
||||
$nomiBadge = "<span class='nacq " . ($nNomi > 0 ? "nacq-ok" : "nacq-no") . "' title='"
|
||||
. ($nNomi > 0 ? "{$nNomi} nomi d'acquisto Mago" : "Nessun nome d'acquisto Mago: il prezzo €/kg non si può calcolare")
|
||||
. "'>🧾 {$nNomi}</span>";
|
||||
|
||||
echo "<tr data-mescola-id='{$row['id']}'>
|
||||
<td>{$badge}</td>
|
||||
<td class='text-start' title=\"" . htmlspecialchars($row['nome'] ?? '', ENT_QUOTES) . "\">{$nomeSafe}</td>
|
||||
<td class='text-start' title=\"" . htmlspecialchars($row['nome'] ?? '', ENT_QUOTES) . "\">{$nomiBadge}{$nomeSafe}</td>
|
||||
<td>
|
||||
<a href='scheda_mescola.php?id={$row['id']}' title='Apri scheda mescola (ID {$row['id']})'>
|
||||
{$nomeUscitaSafe}
|
||||
@@ -567,6 +687,7 @@
|
||||
|
||||
<button type='button' class='btn btn-sm btn-outline-secondary edit-mescola'
|
||||
data-id='{$row['id']}'
|
||||
data-nome='" . htmlspecialchars($row['nome'] ?? '', ENT_QUOTES) . "'
|
||||
data-nomeuscita='" . htmlspecialchars($row['nomeuscita'] ?? '', ENT_QUOTES) . "'
|
||||
data-bs-toggle='tooltip' data-bs-placement='top' title='Modifica'>
|
||||
✏️
|
||||
@@ -699,6 +820,48 @@
|
||||
<input type="hidden" id="afIdMescola">
|
||||
<input type="hidden" id="afEditId">
|
||||
|
||||
<!-- NOMI D'ACQUISTO (MAGO) -->
|
||||
<div class="nm-section">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
|
||||
<div>
|
||||
<h6 class="mb-1">🧾 Nomi d'acquisto (Mago)</h6>
|
||||
<div class="small text-muted">Come questa mescola compare nelle fatture dei fornitori in Mago. Basta la parte che la identifica: "ATES71" vale anche per "ATES71 M100 X 9mm spessore".</div>
|
||||
</div>
|
||||
<div class="nm-price" id="nmPrice"></div>
|
||||
</div>
|
||||
<input type="hidden" id="nmEditId">
|
||||
<div class="row g-2 align-items-end mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Fornitore</label>
|
||||
<select class="form-select" id="nmSupplier"></select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label fw-semibold">Nome in Mago</label>
|
||||
<input type="text" class="form-control" id="nmName" placeholder="es. ATES71 M100">
|
||||
</div>
|
||||
<div class="col-md-3 text-end">
|
||||
<button type="button" class="btn btn-add" id="nmSaveBtn">➕ Aggiungi nome</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mt-1" id="nmCancel" style="display:none;">Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0" id="nmTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Fornitore</th>
|
||||
<th>Nome in Mago</th>
|
||||
<th class="text-end">Acquistato</th>
|
||||
<th>Ultimo acquisto</th>
|
||||
<th style="width:90px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="nmBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="fw-bold">📦 Lotti</h6>
|
||||
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Fornitore</label>
|
||||
@@ -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 '<option value="">Fornitore…</option>' + (nmOpts.suppliers || []).map(s =>
|
||||
`<option value="${s.idsupplier}" ${String(s.idsupplier) === String(selected || "") ? "selected" : ""}>${fEscape(s.supplier_name)}</option>`).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('<tr><td colspan="5" class="text-muted">Carico…</td></tr>');
|
||||
$("#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(`<tr><td colspan="5" class="text-danger">${fEscape((d && d.message) || "Errore")}</td></tr>`);
|
||||
return;
|
||||
}
|
||||
const p = d.price;
|
||||
$("#nmPrice").html(p ?
|
||||
`Prezzo <b>${nmNum(p.price, 3)} €/kg</b> <span class="text-muted">(media ${d.months} mesi${p.old ? "" : ", " + nmNum(p.kg_window) + " kg"})</span>
|
||||
${p.old ? '<span class="nm-old">nessun acquisto recente</span>' : ""}<br>
|
||||
<span class="small text-muted">ultimo ${nmNum(p.last_price, 3)} €/kg del ${nmDate(p.last_date)}</span>` :
|
||||
'<span class="text-muted">Nessun acquisto abbinato: prezzo non disponibile</span>');
|
||||
|
||||
nmSetBadge(idMescola, d.rows.length);
|
||||
if (!d.rows.length) {
|
||||
$("#nmBody").html('<tr><td colspan="5" class="text-muted">Nessun nome d\'acquisto: aggiungi come compare nelle fatture Mago</td></tr>');
|
||||
return;
|
||||
}
|
||||
$("#nmBody").html(d.rows.map(r => `
|
||||
<tr>
|
||||
<td>${fEscape(r.supplier_name || "")}</td>
|
||||
<td><b>${fEscape(r.supplier_mix_name)}</b>${r.note ? `<div class="small text-muted">${fEscape(r.note)}</div>` : ""}</td>
|
||||
<td class="text-end">${r.n ? nmNum(r.kg) + " kg<div class=\"small text-muted\">" + r.n + " righe</div>" : '<span class="text-muted">mai</span>'}</td>
|
||||
<td>${r.last_date ? nmNum(r.last_price, 3) + " €/kg · " + nmDate(r.last_date) + `<div class="small text-muted">${fEscape(r.last_desc || "")}</div>` : ""}</td>
|
||||
<td class="text-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary nm-edit" data-id="${r.id}" data-sup="${r.idsupplier}" data-name="${fEscape(r.supplier_mix_name)}">✏️</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger nm-del" data-id="${r.id}">🗑️</button>
|
||||
</td>
|
||||
</tr>`).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 =>
|
||||
`<div class="alert alert-warning py-2 mb-2">⚠ Lo stesso nome d'acquisto <b>${fEscape(x.name)}</b> è su più mescole: ${x.mescole.map(fEscape).join(", ")}. Toglilo da quella sbagliata.</div>`).join(""));
|
||||
|
||||
const mesOpts = '<option value="">Scegli la mescola…</option>' + o.mescole.map(m =>
|
||||
`<option value="${m.id}">${fEscape(m.nome)}${m.nomeuscita ? " — " + fEscape(m.nomeuscita) : ""}</option>`).join("");
|
||||
|
||||
const $list = $("#nmUnmatchedList");
|
||||
$list.find("select").each(function() {
|
||||
if ($(this).hasClass("select2-hidden-accessible")) $(this).select2("destroy");
|
||||
});
|
||||
$list.html(rows.map((r, i) => `
|
||||
<div class="nm-row" data-i="${i}">
|
||||
<div>
|
||||
<div class="nm-desc">${fEscape(r.description)}</div>
|
||||
<div class="nm-meta">${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" ? ' · <span class="text-danger fw-bold">vale per più mescole</span>' : ""}</div>
|
||||
</div>
|
||||
<select class="form-select nm-u-mescola">${mesOpts}</select>
|
||||
<input type="text" class="form-control nm-u-name" value="${fEscape(r.suggest)}" title="Nome da salvare sulla mescola">
|
||||
<select class="form-select nm-u-sup">${nmSupplierOptions(r.idsupplier)}</select>
|
||||
<button type="button" class="btn btn-add nm-u-save">Abbina</button>
|
||||
</div>`).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();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Nomi d'acquisto (Mago) delle mescole — tabella mescole_supplier_names.
|
||||
*
|
||||
* GET ?action=list&id=ID nomi della mescola con kg acquistati, prezzo €/kg della mescola
|
||||
* GET ?action=unmatched descrizioni d'acquisto Mago non abbinate a nessuna mescola
|
||||
* GET ?action=options mescole e fornitori per le tendine
|
||||
* POST action=save (id) idmescola, idsupplier, name
|
||||
* POST action=delete id
|
||||
*/
|
||||
require_once __DIR__ . '/include/headscript.php';
|
||||
require_once __DIR__ . '/class/MagoClient.php';
|
||||
require_once __DIR__ . '/class/MagoMixPurchases.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function nm_out(array $d)
|
||||
{
|
||||
echo json_encode($d, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = DBHandlerSelect::getInstance()->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()]);
|
||||
}
|
||||
Reference in New Issue
Block a user