Author SHA1 Message Date
Claudio Sironi 6f01180a2d fixed column imballaggi 2026-10-30 16:17:19 +01:00
solocla 60a4d427aa fixed action3 2026-10-29 17:32:51 +01:00
solocla 56c24f18d1 fixed matrici action 2 2026-10-29 17:30:01 +01:00
solocla 797801b749 fixed matrici action 2026-10-29 17:24:14 +01:00
solocla edafcb357f fixed lookup options 2026-10-29 17:22:27 +01:00
solocla eebfee4092 added menu 2026-10-29 16:13:33 +01:00
solocla 574ffbe6bb fixed menu 2026-10-29 11:50:55 +01:00
solocla b8a8e5ad11 session timeout 2026-10-29 09:19:20 +01:00
solocla 2d4ee95ab1 fixed orders 2026-10-28 13:52:47 +01:00
solocla bfe88658d2 fixed logo 2026-10-27 16:50:33 +01:00
solocla a330c8bb29 fixed smartmes logo 2026-10-27 16:45:36 +01:00
solocla 10edd175db fixed website name 2026-10-27 16:33:19 +01:00
solocla 6fe3957099 fixed logincontroller 2026-10-27 16:29:03 +01:00
solocla adc4ee2695 fixed matrrici asales 3 2026-10-27 12:23:56 +01:00
solocla 12b7de0ac1 added quote 2026-10-27 12:23:09 +01:00
solocla ccfe34637d fixed sales matrici2 2026-10-27 12:14:07 +01:00
solocla e042a156c8 matrici sales update 2026-10-27 12:07:55 +01:00
solocla 8502295342 added order name supplier 2026-10-27 11:50:50 +01:00
solocla a7c224fa10 fixed matrriici sales 2026-10-26 14:16:24 +01:00
solocla 53644fb6cf fixed no matrice no import 2026-10-26 14:08:01 +01:00
solocla 2f0f1135ac fixed manage worksheet 2026-10-26 14:05:31 +01:00
solocla 0b9d5f5b40 fixed import foglio di lavoro 2026-10-26 13:56:01 +01:00
solocla 2bac5d5b86 added permission matrici stats 2026-10-24 14:00:19 +02:00
solocla cdcff42677 mago probe purchase update e mescole 2026-10-24 10:30:37 +02:00
solocla 1193785151 mago probe pruchase 2026-10-24 10:14:49 +02:00
solocla 60a3bcabfa fixed sale matrrici 2026-10-24 09:57:36 +02:00
solocla a9191b5e44 added stat matrici 2026-10-24 09:54:38 +02:00
solocla 341894cd22 fixed disegno jump 2026-10-23 20:34:55 +02:00
solocla 128e229578 fixed modal worksheet 2026-10-23 20:28:57 +02:00
solocla 1c793384ff fixed sostegno 2026-10-23 20:25:14 +02:00
solocla 98f4f004ed added scorta to mescola 2026-10-23 20:21:11 +02:00
solocla fd3580a836 fixed name matrici 2026-10-23 17:48:49 +02:00
solocla 9d6c4ec3fb disattuivsa uodate 2026-10-23 17:45:32 +02:00
solocla 381befd1d8 inactive worksheet 2026-10-23 17:41:00 +02:00
solocla d5094bff01 fixed employees permission 2026-10-23 17:27:42 +02:00
solocla a499e7acbd fixed matrici reload 2026-10-22 17:34:51 +02:00
solocla 8d1f7cb35b fixed male 2026-10-22 16:02:39 +02:00
solocla c14cc4a354 added matrici to orders 2026-10-22 14:12:42 +02:00
solocla 570094ab21 fixed personalizza 2026-10-22 13:56:39 +02:00
solocla c4a3e48dd2 added prefernce business overview 2026-10-22 13:48:48 +02:00
solocla 33c81e2ad1 added permission kiosk 2026-10-21 14:03:02 +02:00
solocla d6d424cae8 update kiosk 2026-10-21 12:24:12 +02:00
solocla 5444ea943a kiosk 2026-10-21 10:03:59 +02:00
solocla 76ed4d11ca male to da migliorare 2026-10-20 15:26:12 +02:00
solocla 3329550b19 change vendite name 2026-10-20 15:21:36 +02:00
solocla 89e5388b75 fixed sommario and added queue mago 2026-10-20 12:08:35 +02:00
solocla b6145e0553 magosync matrice 2026-10-20 11:31:17 +02:00
solocla 3f5c14836f fixed dettaglio worksheet 2026-10-20 08:59:38 +02:00
solocla 3209ac969f removed male 2026-10-19 16:10:08 +02:00
solocla a5c2f4f74c titolo dashboard 2026-10-19 15:13:08 +02:00
solocla 810baa9244 navbar fix 2026-10-19 15:11:56 +02:00
solocla 4a6a493ef2 added sales dashboard 2026-10-19 15:09:22 +02:00
solocla f649bc0bd5 added fiel tecnici tags additional 2 and fixed menu 2026-10-19 10:19:15 +02:00
solocla ac23738037 update navbar 2026-10-16 15:42:24 +02:00
solocla ad4bac5874 added sales summary 2026-10-16 15:37:01 +02:00
solocla e17a14d58e fixed login 2026-10-15 15:37:25 +02:00
solocla 5d4992f36f added techoffice 2026-10-15 15:35:58 +02:00
solocla b52e7191cf added tips to statistics 2026-10-15 12:03:40 +02:00
solocla abd09769f2 added scheda cliente 2026-10-15 10:20:41 +02:00
solocla c3234e3160 added book to bill 2026-10-15 10:06:58 +02:00
solocla ab7dd0f2f9 added export to year 2026-10-15 08:50:10 +02:00
solocla 5e2ec11dbd added fatturato 2026-10-14 17:59:06 +02:00
solocla b2a2f56383 fixed stat 4 2026-10-13 17:34:33 +02:00
solocla 22114963db fixed stas3 2026-10-13 17:30:27 +02:00
solocla 9525ba528f fixed date stats2 2026-10-13 16:56:31 +02:00
solocla b031dc6401 fixed date stats 2026-10-13 16:53:41 +02:00
solocla da4a66e5a9 fixed permission 2026-10-13 16:39:54 +02:00
solocla c96717b051 fixed stats 2026-10-13 16:30:47 +02:00
solocla bb3d1b1aa1 toggle bar stats 2026-10-13 14:29:00 +02:00
solocla b7ed6c64ed stats sales with mago 2026-10-13 14:21:39 +02:00
solocla c2212f7022 fixed import fogli di lavoro and disegni tecnici 2026-10-02 11:24:51 +02:00
solocla 63674ea6be import fogli di lavoro 2026-10-01 14:50:29 +02:00
solocla 328772f8da fixed migration 2026-09-21 09:28:17 +02:00
solocla 06092e24aa added production to fill 2026-09-21 09:17:53 +02:00
solocla cc24202937 producstion pause 2026-09-15 15:46:32 +02:00
solocla 4241d8e62b fixed product line view 2 for new 2026-09-09 18:14:02 +02:00
solocla da5c4b8777 added multiple imballaggi 2026-09-09 14:59:26 +02:00
solocla c394d92ef3 fixed nuovo 2026-09-07 15:02:36 +02:00
solocla 8425a7d90c manteinance color 2026-08-25 09:04:07 +02:00
solocla fc34f837d4 Merge branch 'feature/inventory-maintaince' 2026-08-25 08:42:16 +02:00
solocla f93dfe6f66 fixed navbar 2 2026-08-19 08:50:52 +02:00
solocla 273a092562 fixed navbar 2026-08-19 08:49:57 +02:00
solocla feb0c51a91 popup message 2026-08-19 08:35:37 +02:00
solocla 5a4a39fb3c fixe widget 2026-08-14 08:05:24 +02:00
solocla 5f2137d8ec notification on off widget 2026-08-14 08:04:22 +02:00
solocla 1dbb93b139 notification settings and mail 2026-08-14 07:54:17 +02:00
127 changed files with 45151 additions and 958 deletions
@@ -127,10 +127,15 @@ class LoginController extends Controller
return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('UserDatabase')) {
return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('TechOffice')) {
return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('Sales')) {
return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('Ing')) {
return redirect()->to('userarea/production_dashboard.php');
}
// Se il ruolo non è specificato, reindirizza alla home predefinita
return redirect()->intended('/');
// Fallback: qualsiasi altro ruolo (o nessun ruolo) va comunque alla dashboard
return redirect()->to('userarea/production_dashboard.php');
}
protected function logoutAndRedirectToTokenPage(Request $request, $user, ?string $redirectPage): RedirectResponse
+1 -1
View File
@@ -42,7 +42,7 @@
"laravel/tinker": "^2.7",
"laravel/ui": "^4.0",
"phpmailer/phpmailer": "^6.9",
"phpoffice/phpspreadsheet": "^4.1",
"phpoffice/phpspreadsheet": "^5.10",
"proengsoft/laravel-jsvalidation": "^4.0.0",
"robmorgan/phinx": "^0.16.11",
"socialiteproviders/microsoft": "^4.7",
Generated
+22 -16
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "076e7721d08cfea8b06ce75dd8c6c576",
"content-hash": "7dbd2f50581b518d3ed8feecb1117520",
"packages": [
{
"name": "akaunting/laravel-setting",
@@ -4247,16 +4247,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "4.1.0",
"version": "5.10.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "6ff18c3a8df3a945492f75ce455d77f7ad55dd5c"
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/6ff18c3a8df3a945492f75ce455d77f7ad55dd5c",
"reference": "6ff18c3a8df3a945492f75ce455d77f7ad55dd5c",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"shasum": ""
},
"require": {
@@ -4264,6 +4264,7 @@
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
@@ -4277,27 +4278,29 @@
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"php": "^8.2",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"ext-openssl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -4330,6 +4333,9 @@
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
@@ -4346,9 +4352,9 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/4.1.0"
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
},
"time": "2025-03-02T06:52:24+00:00"
"time": "2026-09-17T03:54:50+00:00"
},
{
"name": "phpoption/phpoption",
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddNotificationPreferencesToAuthUsers extends AbstractMigration
{
public function change(): void
{
$table = $this->table('auth_users');
$table
->addColumn('notify_email', 'boolean', [
'default' => true,
'null' => false,
'after' => 'announcements_last_read_at',
'comment' => 'Preferenza notifiche via email',
])
->addColumn('notify_webapp', 'boolean', [
'default' => true,
'null' => false,
'after' => 'notify_email',
'comment' => 'Preferenza notifiche in webapp',
])
->update();
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddCategoryToPauseReasons extends AbstractMigration
{
public function change(): void
{
$this->table('pause_reasons')
->addColumn('category', 'enum', [
'values' => ['planned', 'unplanned'],
'null' => false,
'default' => 'unplanned',
'after' => 'is_problem',
'comment' => 'planned = fermo pianificato (cambio, pulizia, mensa); unplanned = guasto, mancanza materiale, qualità',
])
->update();
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddDailyCapacityToProductionLines extends AbstractMigration
{
public function change(): void
{
$this->table('production_lines')
->addColumn('max_hours_per_day', 'decimal', [
'precision' => 4,
'scale' => 2,
'null' => false,
'default' => 24.00,
'after' => 'color',
'comment' => 'Ore massime di disponibilita giornaliera della linea (default 24)',
])
->update();
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddPreferredLineToAuthUsers extends AbstractMigration
{
public function change(): void
{
$this->table('auth_users')
->addColumn('preferred_line_id', 'integer', [
'signed' => true, // production_lines.id è int(11) SIGNED → deve combaciare
'limit' => 11,
'null' => true,
'default' => null,
'after' => 'notify_webapp',
'comment' => 'Linea di produzione preferita per la Line View (NULL = Tutte)',
])
->addForeignKey('preferred_line_id', 'production_lines', 'id', [
'delete' => 'SET_NULL',
'update' => 'NO_ACTION',
])
->update();
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateWorkSheetPackages extends AbstractMigration
{
public function change(): void
{
$this->table('work_sheet_packages', ['id' => 'id'])
->addColumn('worksheet_id', 'integer', ['limit' => 11])
->addColumn('package_code', 'integer', ['limit' => 11, 'comment' => 'FK -> packaging_items.id'])
->addColumn('package_position', 'integer', ['default' => 1])
->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP'])
->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'update' => 'CURRENT_TIMESTAMP'])
->addIndex(['worksheet_id'], ['name' => 'idx_wsp_ws'])
->addIndex(['package_code'], ['name' => 'idx_wsp_pkg'])
->addIndex(['worksheet_id', 'package_code'], ['unique' => true, 'name' => 'uk_wsp_ws_pkg'])
->addIndex(['worksheet_id', 'package_position'], ['unique' => true, 'name' => 'uk_wsp_ws_position'])
->addForeignKey('worksheet_id', 'work_sheets', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->addForeignKey('package_code', 'packaging_items', 'id', ['update' => 'CASCADE'])
->create();
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class MakePauseReasonNullable extends AbstractMigration
{
public function up(): void
{
$table = $this->table('production_pauses');
// 1. rimuovo la FK che blocca la modifica della colonna
$table->dropForeignKey('reason_id')->update();
// 2. rendo la colonna nullable, mantenendo il tipo reale (tinyint unsigned)
$table->changeColumn('reason_id', 'integer', [
'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
'signed' => false,
'null' => true,
])->update();
// 3. ricreo la FK
$table->addForeignKey('reason_id', 'pause_reasons', 'id', [
'delete' => 'SET_NULL',
'update' => 'NO_ACTION',
])->update();
}
public function down(): void
{
$table = $this->table('production_pauses');
$table->dropForeignKey('reason_id')->update();
$table->changeColumn('reason_id', 'integer', [
'limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
'signed' => false,
'null' => false,
])->update();
$table->addForeignKey('reason_id', 'pause_reasons', 'id', [
'delete' => 'NO_ACTION',
'update' => 'NO_ACTION',
])->update();
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddStatoDaCompilareDati extends AbstractMigration
{
public function up(): void
{
// Stato 11: produzione terminata ma dati (kg/metri/scarto) da inserire dopo
$this->execute("
INSERT INTO production_status (id, nome, ordinamento, badge_color, line_color)
VALUES (11, 'Da compilare dati', 48, '#7c3aed', '#ede9fe')
");
}
public function down(): void
{
$this->execute("DELETE FROM production_status WHERE id = 11");
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddSourceXlsxToWorkSheets extends AbstractMigration
{
public function change(): void
{
$this->table('work_sheets')
->addColumn('source_xlsx_name', 'string', [
'limit' => 255,
'null' => true,
'default' => null,
'after' => 'notes',
])
->addColumn('source_xlsx_path', 'string', [
'limit' => 255,
'null' => true,
'default' => null,
'after' => 'source_xlsx_name',
])
->update();
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddIsDisegnoToMatriceAttachments extends AbstractMigration
{
public function change(): void
{
$this->table('matrice_attachments')
->addColumn('is_disegno', 'boolean', [
'default' => false,
'null' => false,
])
->update();
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Tabelle locali (copia in sola lettura) dei dati Mago4 per le statistiche ordini cliente.
*
* - mago_sale_ord : testate ordini cliente (MA_SaleOrd)
* - mago_cust_supp : anagrafica clienti (MA_CustSupp, solo clienti)
* - mago_sync_state : stato dell'ultima sincronizzazione per ogni entità
*/
final class CreateMagoSalesTables extends AbstractMigration
{
public function change(): void
{
// ---------- ORDINI CLIENTE (testate) ----------
$this->table('mago_sale_ord', [
'id' => false,
'primary_key' => ['sale_ord_id'],
'comment' => 'Copia locale MA_SaleOrd (Mago4) - sola lettura',
])
->addColumn('sale_ord_id', 'integer', ['signed' => false])
->addColumn('internal_ord_no', 'string', ['limit' => 30, 'default' => ''])
->addColumn('external_ord_no', 'string', ['limit' => 60, 'default' => ''])
->addColumn('order_date', 'date', ['null' => true])
->addColumn('expected_delivery_date', 'date', ['null' => true])
->addColumn('confirmed_delivery_date', 'date', ['null' => true])
->addColumn('compulsory_delivery_date', 'date', ['null' => true])
->addColumn('customer', 'string', ['limit' => 20, 'default' => ''])
->addColumn('invoicing_customer', 'string', ['limit' => 20, 'default' => ''])
->addColumn('salesperson', 'string', ['limit' => 20, 'default' => ''])
->addColumn('area_manager', 'string', ['limit' => 20, 'default' => ''])
->addColumn('area', 'string', ['limit' => 20, 'default' => ''])
->addColumn('payment', 'string', ['limit' => 20, 'default' => ''])
->addColumn('currency', 'string', ['limit' => 8, 'default' => ''])
->addColumn('our_reference', 'string', ['limit' => 150, 'default' => ''])
->addColumn('your_reference', 'string', ['limit' => 150, 'default' => ''])
->addColumn('transport', 'string', ['limit' => 20, 'default' => ''])
->addColumn('port', 'string', ['limit' => 20, 'default' => ''])
->addColumn('package', 'string', ['limit' => 40, 'default' => ''])
->addColumn('storage', 'string', ['limit' => 30, 'default' => ''])
->addColumn('job', 'string', ['limit' => 30, 'default' => ''])
->addColumn('delivered', 'boolean', ['default' => false])
->addColumn('invoiced', 'boolean', ['default' => false])
->addColumn('cancelled', 'boolean', ['default' => false])
->addColumn('is_blocked', 'boolean', ['default' => false])
->addColumn('printed', 'boolean', ['default' => false])
->addColumn('archived', 'boolean', ['default' => false])
->addColumn('open_order', 'boolean', ['default' => false])
->addColumn('priority', 'integer', ['default' => 0])
->addColumn('notes', 'text', ['null' => true])
->addColumn('tb_created', 'datetime', ['null' => true])
->addColumn('tb_modified', 'datetime', ['null' => true])
->addColumn('raw_json', 'text', ['null' => true, 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG])
->addColumn('synced_at', 'datetime', ['null' => true])
->addIndex(['order_date'])
->addIndex(['customer'])
->addIndex(['salesperson'])
->addIndex(['area'])
->addIndex(['tb_modified'])
->addIndex(['internal_ord_no'])
->create();
// ---------- ANAGRAFICA CLIENTI ----------
$this->table('mago_cust_supp', [
'id' => false,
'primary_key' => ['cust_supp_type', 'cust_supp'],
'comment' => 'Copia locale MA_CustSupp (Mago4) - sola lettura',
])
->addColumn('cust_supp_type', 'integer')
->addColumn('cust_supp', 'string', ['limit' => 20])
->addColumn('company_name', 'string', ['limit' => 200, 'default' => ''])
->addColumn('city', 'string', ['limit' => 100, 'default' => ''])
->addColumn('county', 'string', ['limit' => 10, 'default' => ''])
->addColumn('region', 'string', ['limit' => 60, 'default' => ''])
->addColumn('country', 'string', ['limit' => 60, 'default' => ''])
->addColumn('iso_country_code', 'string', ['limit' => 4, 'default' => ''])
->addColumn('disabled', 'boolean', ['default' => false])
->addColumn('tb_modified', 'datetime', ['null' => true])
->addColumn('synced_at', 'datetime', ['null' => true])
->addIndex(['company_name'])
->create();
// ---------- STATO SINCRONIZZAZIONE ----------
$this->table('mago_sync_state', [
'id' => false,
'primary_key' => ['entity'],
])
->addColumn('entity', 'string', ['limit' => 50])
->addColumn('last_tb_modified', 'datetime', ['null' => true])
->addColumn('last_run_at', 'datetime', ['null' => true])
->addColumn('last_status', 'string', ['limit' => 20, 'default' => ''])
->addColumn('last_message', 'text', ['null' => true])
->addColumn('records_last_run', 'integer', ['default' => 0])
->addColumn('duration_ms', 'integer', ['default' => 0])
->create();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Importi degli ordini cliente da MA_SaleOrdSummary (Mago4).
*/
final class AddAmountsToMagoSaleOrd extends AbstractMigration
{
public function change(): void
{
$this->table('mago_sale_ord')
->addColumn('taxable_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'notes', 'comment' => 'Imponibile'])
->addColumn('tax_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'taxable_amount', 'comment' => 'IVA'])
->addColumn('total_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'tax_amount', 'comment' => 'Totale documento'])
->addColumn('goods_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'total_amount', 'comment' => 'Valore merce'])
->addColumn('discounts', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'goods_amount', 'comment' => 'Sconti'])
->addColumn('charges', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0, 'after' => 'discounts', 'comment' => 'Spese (trasporto, imballo, incasso, bolli, varie)'])
->addColumn('summary_tb_modified', 'datetime', ['null' => true, 'after' => 'charges', 'comment' => 'NULL = totali non ancora letti'])
->update();
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Righe ordini cliente da MA_SaleOrdDetails (Mago4) e date di consegna
* calcolate dalle righe sulla testata.
*/
final class CreateMagoSaleOrdDetail extends AbstractMigration
{
public function change(): void
{
$this->table('mago_sale_ord_detail', [
'id' => false,
'primary_key' => ['sale_ord_id', 'sub_id'],
'comment' => 'Copia locale MA_SaleOrdDetails (Mago4) - sola lettura',
])
->addColumn('sale_ord_id', 'integer', ['signed' => false])
->addColumn('sub_id', 'integer')
->addColumn('line', 'integer', ['default' => 0])
->addColumn('position', 'integer', ['default' => 0])
->addColumn('line_type', 'integer', ['default' => 0])
->addColumn('item', 'string', ['limit' => 30, 'default' => ''])
->addColumn('description', 'string', ['limit' => 255, 'default' => ''])
->addColumn('uom', 'string', ['limit' => 10, 'default' => ''])
->addColumn('qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0])
->addColumn('delivered_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0])
->addColumn('invoiced_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0])
->addColumn('unit_value', 'decimal', ['precision' => 15, 'scale' => 5, 'default' => 0])
->addColumn('discount_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0])
->addColumn('taxable_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0])
->addColumn('total_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0])
->addColumn('expected_delivery_date', 'date', ['null' => true])
->addColumn('confirmed_delivery_date', 'date', ['null' => true])
->addColumn('delivered', 'boolean', ['default' => false])
->addColumn('invoiced', 'boolean', ['default' => false])
->addColumn('cancelled', 'boolean', ['default' => false])
->addColumn('customer', 'string', ['limit' => 20, 'default' => ''])
->addColumn('order_date', 'date', ['null' => true])
->addColumn('internal_ord_no', 'string', ['limit' => 30, 'default' => ''])
->addColumn('notes', 'text', ['null' => true])
->addColumn('tb_modified', 'datetime', ['null' => true])
->addColumn('synced_at', 'datetime', ['null' => true])
->addIndex(['item'])
->addIndex(['order_date'])
->addIndex(['expected_delivery_date'])
->addIndex(['customer'])
->create();
// Date di consegna ricavate dalle righe (la data in testata spesso resta = data ordine)
$this->table('mago_sale_ord')
->addColumn('line_expected_date', 'date', ['null' => true, 'after' => 'compulsory_delivery_date', 'comment' => 'Prima data prevista tra le righe aperte'])
->addColumn('line_confirmed_date', 'date', ['null' => true, 'after' => 'line_expected_date', 'comment' => 'Prima data confermata tra le righe aperte'])
->addColumn('lines_count', 'integer', ['default' => 0, 'after' => 'line_confirmed_date'])
->update();
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Documenti di vendita da MA_SaleDoc + importi da MA_SaleDocSummary (Mago4).
* Servono per il FATTURATO per data documento (fatture, fatture d'acconto, note di credito).
* Si salvano tutti i tipi (anche DDT): quali contano come fatturato si decide in MagoSalesStats.
*/
final class CreateMagoSaleDoc extends AbstractMigration
{
public function change(): void
{
$this->table('mago_sale_doc', [
'id' => false,
'primary_key' => ['sale_doc_id'],
'comment' => 'Copia locale MA_SaleDoc + MA_SaleDocSummary (Mago4) - sola lettura',
])
->addColumn('sale_doc_id', 'integer', ['signed' => false])
->addColumn('document_type', 'integer', ['default' => 0, 'comment' => '3407874 fattura, 3407883 acconto, 3407876 nota credito, 3407873 DDT...'])
->addColumn('doc_no', 'string', ['limit' => 30, 'default' => ''])
->addColumn('document_date', 'date', ['null' => true])
->addColumn('posting_date', 'date', ['null' => true])
->addColumn('customer', 'string', ['limit' => 20, 'default' => ''])
->addColumn('invoicing_customer', 'string', ['limit' => 20, 'default' => ''])
->addColumn('salesperson', 'string', ['limit' => 20, 'default' => ''])
->addColumn('area', 'string', ['limit' => 20, 'default' => ''])
->addColumn('currency', 'string', ['limit' => 8, 'default' => ''])
->addColumn('tax_journal', 'string', ['limit' => 20, 'default' => '', 'comment' => 'Registro IVA: vuoto per DDT/proforma'])
->addColumn('inv_rsn', 'string', ['limit' => 20, 'default' => ''])
->addColumn('ei_document_type', 'integer', ['default' => 0])
->addColumn('issued', 'boolean', ['default' => false])
->addColumn('cancelled', 'boolean', ['default' => false])
->addColumn('posted_to_accounting', 'boolean', ['default' => false])
->addColumn('taxable_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true])
->addColumn('tax_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true])
->addColumn('total_amount', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true])
->addColumn('summary_tb_modified', 'datetime', ['null' => true])
->addColumn('tb_created', 'datetime', ['null' => true])
->addColumn('tb_modified', 'datetime', ['null' => true])
->addColumn('synced_at', 'datetime', ['null' => true])
->addIndex(['document_date'])
->addIndex(['document_type', 'document_date'])
->addIndex(['customer'])
->create();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddIsStrumentoIsCicloToMatriceAttachments extends AbstractMigration
{
public function change(): void
{
$this->table('matrice_attachments')
->addColumn('is_strumento', 'boolean', [
'default' => false,
'null' => false,
'after' => 'is_disegno',
])
->addColumn('is_ciclo', 'boolean', [
'default' => false,
'null' => false,
'after' => 'is_strumento',
])
->update();
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Righe ordine Mago: campi per la produzione.
* matrice_misura = MA_SaleOrdDetails.Department → colonna "Matrice/Misura" (es. M00786S = matrice 00786S, D 3,3 = diametro)
* cod_art_cli = MA_SaleOrdDetails.ReferenceDocNo → colonna "Cod.Art.Cli" (codice articolo del cliente)
*/
final class AddMatriceToMagoSaleOrdDetail extends AbstractMigration
{
public function change(): void
{
$this->table('mago_sale_ord_detail')
->addColumn('matrice_misura', 'string', ['limit' => 30, 'default' => '', 'after' => 'uom', 'comment' => 'Mago Department: colonna Matrice/Misura'])
->addColumn('cod_art_cli', 'string', ['limit' => 60, 'default' => '', 'after' => 'matrice_misura', 'comment' => 'Mago ReferenceDocNo: Cod.Art.Cli'])
->addIndex(['matrice_misura'])
->update();
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Ponte ordini Mago → programmazione produzione.
*
* mago_prod_queue una riga per ogni riga d'ordine Mago "da produrre" (traccia tutto il percorso:
* nuovo → importato in produzione / scartato / chiuso in Mago)
* matrice_mago_map corrispondenze manuali "valore Matrice/Misura di Mago" → matrice del portale
* clients.mago_code codice cliente Mago (per collegare i clienti del portale)
*/
final class CreateMagoProdQueue extends AbstractMigration
{
public function change(): void
{
$this->table('mago_prod_queue', [
'comment' => 'Righe ordine Mago da portare in programmazione produzione',
])
// --- riga Mago ---
->addColumn('sale_ord_id', 'integer', ['signed' => false])
->addColumn('sub_id', 'integer')
->addColumn('internal_ord_no', 'string', ['limit' => 30, 'default' => ''])
->addColumn('line_no', 'integer', ['default' => 0])
->addColumn('order_date', 'date', ['null' => true])
->addColumn('customer', 'string', ['limit' => 20, 'default' => ''])
->addColumn('company_name', 'string', ['limit' => 200, 'default' => ''])
->addColumn('item', 'string', ['limit' => 30, 'default' => ''])
->addColumn('description', 'string', ['limit' => 255, 'default' => ''])
->addColumn('cod_art_cli', 'string', ['limit' => 60, 'default' => ''])
->addColumn('uom', 'string', ['limit' => 10, 'default' => ''])
->addColumn('qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0])
->addColumn('delivered_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0])
->addColumn('residual_qty', 'decimal', ['precision' => 15, 'scale' => 3, 'default' => 0])
->addColumn('zibo_date', 'date', ['null' => true])
->addColumn('client_date', 'date', ['null' => true])
->addColumn('mago_notes', 'text', ['null' => true])
// --- matrice ---
->addColumn('matrice_misura', 'string', ['limit' => 30, 'default' => '', 'comment' => 'Valore originale di Mago'])
->addColumn('mm_tipo', 'string', ['limit' => 2, 'default' => '', 'comment' => 'M matrice, D diametro, R rettangolo, T tubo, Q quadrato, S, ? altro'])
->addColumn('mm_codice', 'string', ['limit' => 40, 'default' => '', 'comment' => 'Codice matrice estratto (es. 00786S-1)'])
->addColumn('mm_misura', 'string', ['limit' => 40, 'default' => '', 'comment' => 'Misura (es. 3,3 / 17X12)'])
->addColumn('idmatrice', 'integer', ['null' => true])
->addColumn('match_status', 'string', ['limit' => 20, 'default' => '', 'comment' => 'esatta, revisione, mappa, manuale, da_creare, profilo, anomalia'])
->addColumn('match_info', 'string', ['limit' => 255, 'default' => ''])
// --- percorso ---
->addColumn('queue_status', 'string', ['limit' => 20, 'default' => 'nuovo', 'comment' => 'nuovo, importato, scartato, chiuso_mago'])
->addColumn('discard_reason', 'string', ['limit' => 255, 'default' => ''])
->addColumn('productiondata_id', 'integer', ['null' => true])
->addColumn('mago_changed', 'boolean', ['default' => false, 'comment' => 'Riga cambiata in Mago dopo l\'import'])
->addColumn('mago_changes', 'string', ['limit' => 500, 'default' => ''])
->addColumn('first_seen_at', 'datetime', ['null' => true])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('imported_at', 'datetime', ['null' => true])
->addColumn('imported_by', 'string', ['limit' => 100, 'default' => ''])
->addIndex(['sale_ord_id', 'sub_id'], ['unique' => true, 'name' => 'uk_mpq_line'])
->addIndex(['queue_status'])
->addIndex(['productiondata_id'])
->addIndex(['idmatrice'])
->create();
$this->table('matrice_mago_map', [
'comment' => 'Corrispondenze manuali: valore Matrice/Misura di Mago → matrice',
])
->addColumn('codice_mago', 'string', ['limit' => 60])
->addColumn('idmatrice', 'integer')
->addColumn('note', 'string', ['limit' => 255, 'default' => ''])
->addColumn('created_by', 'string', ['limit' => 100, 'default' => ''])
->addColumn('created_at', 'datetime', ['null' => true])
->addIndex(['codice_mago'], ['unique' => true])
->addForeignKey('idmatrice', 'matrice', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
$this->table('clients')
->addColumn('mago_code', 'string', ['limit' => 20, 'null' => true, 'after' => 'nome', 'comment' => 'Codice cliente Mago'])
->addIndex(['mago_code'])
->update();
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Schermo di reparto (kiosk) per la sala pausa.
*
* kiosk_settings impostazioni chiave/valore (durata slide, slide attive, sicurezza, meteo, codice di accesso)
* kiosk_items contenuti scritti dal manager: comunicazioni, benvenuti, turni, menu, avvisi scorrevoli
* kiosk_goals obiettivi con barra di avanzamento (manuali o calcolati dai dati di produzione)
*/
final class CreateKiosk extends AbstractMigration
{
public function change(): void
{
$this->table('kiosk_settings', ['id' => false, 'primary_key' => ['setting_key'], 'comment' => 'Impostazioni schermo di reparto'])
->addColumn('setting_key', 'string', ['limit' => 50])
->addColumn('setting_value', 'text', ['null' => true])
->addColumn('updated_at', 'datetime', ['null' => true])
->create();
$this->table('kiosk_items', ['comment' => 'Contenuti dello schermo di reparto'])
->addColumn('item_type', 'string', ['limit' => 20, 'comment' => 'comunicazione, benvenuto, turni, menu, ticker'])
->addColumn('title', 'string', ['limit' => 150, 'default' => ''])
->addColumn('body', 'text', ['null' => true])
->addColumn('image', 'string', ['limit' => 255, 'default' => ''])
->addColumn('valid_from', 'date', ['null' => true])
->addColumn('valid_to', 'date', ['null' => true])
->addColumn('sort', 'integer', ['default' => 0])
->addColumn('active', 'boolean', ['default' => true])
->addColumn('created_by', 'string', ['limit' => 100, 'default' => ''])
->addColumn('created_at', 'datetime', ['null' => true])
->addColumn('updated_at', 'datetime', ['null' => true])
->addIndex(['item_type', 'active'])
->create();
$this->table('kiosk_goals', ['comment' => 'Obiettivi mostrati sullo schermo di reparto'])
->addColumn('title', 'string', ['limit' => 150])
->addColumn('source', 'string', ['limit' => 30, 'default' => 'manuale', 'comment' => 'manuale, metri_mese, kg_mese, lavori_mese, scarto_mese, fermi_mese'])
->addColumn('unit', 'string', ['limit' => 20, 'default' => ''])
->addColumn('target', 'decimal', ['precision' => 15, 'scale' => 2, 'default' => 0])
->addColumn('current_value', 'decimal', ['precision' => 15, 'scale' => 2, 'null' => true, 'comment' => 'Solo per gli obiettivi manuali'])
->addColumn('lower_is_better', 'boolean', ['default' => false])
->addColumn('period_from', 'date', ['null' => true])
->addColumn('period_to', 'date', ['null' => true])
->addColumn('sort', 'integer', ['default' => 0])
->addColumn('active', 'boolean', ['default' => true])
->addColumn('updated_at', 'datetime', ['null' => true])
->create();
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Preferenze di interfaccia per utente: ordine, larghezza e visibilità dei riquadri nelle pagine.
*/
final class CreateUserUiPrefs extends AbstractMigration
{
public function change(): void
{
$this->table('user_ui_prefs', ['comment' => 'Layout pagine personalizzato per utente'])
->addColumn('user_id', 'integer', ['signed' => false])
->addColumn('page', 'string', ['limit' => 50, 'comment' => 'chiave pagina, es. sales_orders_stats'])
->addColumn('layout', 'text', ['null' => true, 'comment' => 'JSON {order, hidden, sizes}'])
->addColumn('updated_at', 'datetime', ['null' => true])
->addIndex(['user_id', 'page'], ['unique' => true])
->create();
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddSupportWeightToWorkSheetMescole extends AbstractMigration
{
public function change(): void
{
$this->table('work_sheet_mescole')
->addColumn('support_weight_g_m', 'decimal', [
'precision' => 10,
'scale' => 2,
'null' => true,
'default' => null,
'after' => 'mix_weight_g_m',
'comment' => 'Peso metro sostegno mescola (g/m)',
])
->update();
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Permesso specifico per la pagina Matrici (prezzi di vendita per matrice e cliente).
* Dopo la migrazione va assegnato ai ruoli dalla gestione permessi del portale.
*/
final class AddSalesMatriciPermission extends AbstractMigration
{
public function up(): void
{
$exists = $this->fetchRow("SELECT id FROM auth_permissions WHERE name = 'sales.matrici.view'");
if ($exists) return;
$this->table('auth_permissions')->insert([
'name' => 'sales.matrici.view',
'display_name' => 'Statistiche matrici',
'description' => 'Vedere prezzi di vendita, clienti e mescole per matrice',
'removable' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
])->saveData();
}
public function down(): void
{
$this->execute("DELETE pr FROM auth_permission_role pr
JOIN auth_permissions p ON p.id = pr.permission_id
WHERE p.name = 'sales.matrici.view'");
$this->execute("DELETE FROM auth_permissions WHERE name = 'sales.matrici.view'");
}
}
@@ -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,60 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Preventivi per matrice:
* - permesso sales.quote.view (pagina sales_preventivo.php: mostra costi e margini)
* - tabella sales_quotes: preventivi salvati con costo, ricarico, prezzo e commento
*/
final class AddSalesQuotes extends AbstractMigration
{
public function up(): void
{
if (!$this->fetchRow("SELECT id FROM auth_permissions WHERE name = 'sales.quote.view'")) {
$this->table('auth_permissions')->insert([
'name' => 'sales.quote.view',
'display_name' => 'Preventivi matrici',
'description' => 'Calcolare preventivi: costo mescola, ricarico, prezzo suggerito',
'removable' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
])->saveData();
}
if (!$this->hasTable('sales_quotes')) {
$this->table('sales_quotes', ['collation' => 'utf8mb4_general_ci'])
->addColumn('created_at', 'datetime')
->addColumn('user_id', 'integer', ['null' => true])
->addColumn('idmatrice', 'integer')
->addColumn('matrice_name', 'string', ['limit' => 150])
->addColumn('customer_code', 'string', ['limit' => 20, 'null' => true])
->addColumn('customer_name', 'string', ['limit' => 200, 'null' => true])
->addColumn('uom', 'string', ['limit' => 4])
->addColumn('cost_mix', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('scrap_pct', 'decimal', ['precision' => 6, 'scale' => 2, 'default' => 0])
->addColumn('other_costs', 'decimal', ['precision' => 12, 'scale' => 4, 'default' => 0])
->addColumn('cost_total', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('markup_pct', 'decimal', ['precision' => 7, 'scale' => 2, 'null' => true])
->addColumn('price_suggested', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('price_offered', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('ref_last_price', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('ref_avg_price', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => true])
->addColumn('mix_detail', 'text', ['null' => true])
->addColumn('comment', 'text', ['null' => true])
->addIndex(['idmatrice'])
->addIndex(['customer_code'])
->addIndex(['created_at'])
->create();
}
}
public function down(): void
{
if ($this->hasTable('sales_quotes')) $this->table('sales_quotes')->drop()->save();
$this->execute("DELETE pr FROM auth_permission_role pr JOIN auth_permissions p ON p.id = pr.permission_id WHERE p.name = 'sales.quote.view'");
$this->execute("DELETE FROM auth_permissions WHERE name = 'sales.quote.view'");
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WidenLubricationTypeOnWorkSheetMescole extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function up(): void
{
$this->table('work_sheet_mescole')
->changeColumn('lubrication_type', 'string', [
'limit' => 60,
'null' => true,
'default' => null,
'comment' => 'Valore da ws_lookup_options (category lubrication_type)',
])
->update();
}
public function down(): void
{
// i valori non previsti dal vecchio ENUM vengono azzerati, altrimenti il ripristino fallisce
$this->execute("UPDATE work_sheet_mescole SET lubrication_type = NULL WHERE lubrication_type NOT IN ('S','G','N')");
$this->table('work_sheet_mescole')
->changeColumn('lubrication_type', 'enum', [
'values' => ['S', 'G', 'N'],
'null' => true,
'default' => null,
])
->update();
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
final class AddDifficoltaToMatrice extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$this->table('matrice')
->addColumn('difficolta', 'integer', [
'limit' => MysqlAdapter::INT_TINY,
'signed' => false,
'null' => true,
'default' => null,
'after' => 'cliente',
'comment' => 'Difficoltà 1 (facile) - 10 (difficile), NULL = non indicata',
])
->addIndex(['difficolta'], ['name' => 'idx_matrice_difficolta'])
->update();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Seed\AbstractSeed;
final class PauseReasonsPlannedSeeder extends AbstractSeed
{
public function run(): void
{
$data = [
['name' => 'Cambio turno', 'is_problem' => 0, 'category' => 'planned'],
['name' => 'Aggiunta / cambio mescola', 'is_problem' => 0, 'category' => 'planned'],
['name' => 'Cambio produzione', 'is_problem' => 0, 'category' => 'planned'],
['name' => 'Attrezzaggio / cambio stampo', 'is_problem' => 0, 'category' => 'planned'],
['name' => 'Pulizia impianto', 'is_problem' => 0, 'category' => 'planned'],
['name' => 'Manutenzione programmata', 'is_problem' => 0, 'category' => 'planned'],
];
$this->table('pause_reasons')->insert($data)->saveData();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
// general terms
$titlewebsite = "TRF Smart";
$titlewebsite = "SmartMes";
$accountsettings = "Account Settings";
$userprofile = "User Profile";
+38 -1
View File
@@ -167,6 +167,7 @@ try {
exit;
}
// Mescole del foglio
$stmtMix = $pdo->prepare("
SELECT
wsm.*,
@@ -180,6 +181,30 @@ try {
$stmtMix->execute([$worksheetId]);
$mixRows = $stmtMix->fetchAll(PDO::FETCH_ASSOC);
// Imballi del foglio (come nella sezione 3 di manage-worksheet.php)
$stmtPkg = $pdo->prepare("
SELECT
wsp.id,
wsp.package_position,
pi.category AS package_category,
pi.item_name AS package_name,
pi.item_code AS package_item_code
FROM work_sheet_packages wsp
LEFT JOIN packaging_items pi ON pi.id = wsp.package_code
WHERE wsp.worksheet_id = ?
ORDER BY wsp.package_position ASC, wsp.id ASC
");
$stmtPkg->execute([$worksheetId]);
$packageRows = $stmtPkg->fetchAll(PDO::FETCH_ASSOC);
// Etichette dei menu a tendina (marchiatura, frequenze controllo, lubrificazione...)
// Prese tutte, anche le disattivate, così i fogli vecchi mostrano comunque l'etichetta
$lookups = [];
$stmtLk = $pdo->query("SELECT category, value, label FROM ws_lookup_options");
foreach ($stmtLk->fetchAll(PDO::FETCH_ASSOC) as $r) {
$lookups[$r['category']][(string)$r['value']] = $r['label'];
}
echo json_encode([
'success' => true,
'worksheet' => [
@@ -218,6 +243,7 @@ try {
'speed_actual_m_h' => $ws['speed_actual_m_h'] ?? '',
'approved_by' => $ws['approved_by'] ?? '',
'notes' => $ws['notes'] ?? '',
'source_xlsx_path' => $ws['source_xlsx_path'] ?? '',
'created_at' => $ws['created_at'] ?? '',
'created_at_it' => formatDateTimeIT($ws['created_at'] ?? ''),
'updated_at' => $ws['updated_at'] ?? '',
@@ -230,12 +256,23 @@ try {
'mescola_nome' => $r['mescola_nome'] ?? '',
'mescola_uscita' => $r['mescola_uscita'] ?? '',
'mix_weight_g_m' => $r['mix_weight_g_m'] ?? '',
'support_weight_g_m' => $r['support_weight_g_m'] ?? '',
'required_density' => $r['required_density'] ?? '',
'required_hardness_shore_a' => $r['required_hardness_shore_a'] ?? '',
'lubrication_type' => $r['lubrication_type'] ?? '',
'lubrication_notes' => $r['lubrication_notes'] ?? ''
];
}, $mixRows)
}, $mixRows),
'package_rows' => array_map(function ($r) {
return [
'id' => (int)$r['id'],
'package_position' => (int)$r['package_position'],
'package_category' => $r['package_category'] ?? '',
'package_name' => $r['package_name'] ?? '',
'package_item_code' => $r['package_item_code'] ?? ''
];
}, $packageRows),
'lookups' => (object)$lookups
]);
exit;
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

@@ -1,6 +1,6 @@
(function () {
function escapeHtml(str) {
return String(str || "")
return String(str === null || str === undefined ? "" : str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
@@ -8,16 +8,57 @@
.replace(/'/g, "&#039;");
}
function valueOrDash(v) {
return v === null || v === undefined || String(v).trim() === ""
? "—"
: escapeHtml(v);
function isEmpty(v) {
return v === null || v === undefined || String(v).trim() === "";
}
function renderReadonlyField(label, value) {
function valueOrDash(v) {
return isEmpty(v) ? "—" : escapeHtml(v);
}
// Traduce il valore salvato (es. "every_2h") nell'etichetta del menu a tendina,
// usando le etichette di ws_lookup_options restituite dall'endpoint.
// Se l'etichetta non c'è, mostra il valore così com'è.
function lookupLabel(lookups, category, value) {
if (isEmpty(value)) return "";
const map = lookups && lookups[category];
if (map && map[String(value)] !== undefined) return map[String(value)];
return value;
}
// Campo in sola lettura: etichetta piccola sopra, valore in evidenza sotto
function wsdField(label, value, extraClass) {
const empty = isEmpty(value);
return `
<div class="readonly-label">${escapeHtml(label)}</div>
<div class="readonly-value">${valueOrDash(value)}</div>
<div class="wsd-field ${extraClass || ""}">
<div class="wsd-label">${escapeHtml(label)}</div>
<div class="wsd-value ${empty ? "wsd-empty" : ""}">${valueOrDash(value)}</div>
</div>
`;
}
// Blocco di testo lungo (note, misure di controllo)
function wsdText(label, value, emptyText) {
const empty = isEmpty(value);
return `
<div class="wsd-text-block">
${label ? `<div class="wsd-label mb-1">${escapeHtml(label)}</div>` : ""}
<div class="wsd-text ${empty ? "wsd-empty" : ""}">${empty ? escapeHtml(emptyText || "—") : escapeHtml(value)}</div>
</div>
`;
}
function wsdSection(theme, title, badge, body, colClass) {
return `
<div class="${colClass || "col-12"}">
<div class="wsd-section wsd-${theme}">
<div class="wsd-head">
<span>${title}</span>
${badge ? `<span class="wsd-head-badge">${badge}</span>` : ""}
</div>
<div class="wsd-body">${body}</div>
</div>
</div>
`;
}
@@ -130,10 +171,480 @@
});
};
// ================================================================
// Tab allegati della matrice: disegni tecnici, strumenti, cicli
// ================================================================
const WS_ATT_KINDS = {
disegni: {
flag: "is_disegno",
label: "Disegni tecnici",
icon: "📐",
itemLabel: "Disegno",
empty:
"Nessun disegno tecnico per questo profilo. " +
"Si possono segnare dalla gestione matrici (graffetta → pulsante compasso).",
},
strumenti: {
flag: "is_strumento",
label: "Strumenti di lavoro",
icon: "🔧",
itemLabel: "Strumento",
empty:
"Nessuno strumento di lavoro per questo profilo. " +
"Si possono segnare dalla gestione matrici (graffetta → pulsante chiave inglese).",
},
cicli: {
flag: "is_ciclo",
label: "Cicli di lavoro",
icon: "🔁",
itemLabel: "Ciclo",
empty:
"Nessun ciclo di lavoro per questo profilo. " +
"Si possono segnare dalla gestione matrici (graffetta → pulsante frecce).",
},
};
let wsAttLists = { disegni: [], strumenti: [], cicli: [] };
function wsFileExt(name) {
const p = String(name || "").split(".");
return p.length > 1 ? p.pop().toLowerCase() : "";
}
function wsIsImage(ext) {
return ["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext);
}
function showWorksheetAttachment(kind, index) {
const list = wsAttLists[kind] || [];
const f = list[index];
if (!f) return;
$(`.ws-disegno-item[data-kind="${kind}"]`).removeClass("active");
$(
`.ws-disegno-item[data-kind="${kind}"][data-index="${index}"]`,
).addClass("active");
const name = f.file_name || "";
const path = f.file_url || f.file_path || "";
const ext = wsFileExt(name);
let viewer;
if (ext === "pdf") {
viewer = `<iframe src="${escapeHtml(path)}#zoom=page-width"></iframe>`;
} else if (wsIsImage(ext)) {
viewer = `<div class="img-wrap"><img src="${escapeHtml(path)}" alt="${escapeHtml(name)}"></div>`;
} else {
viewer = `<div class="h-100 d-flex justify-content-center align-items-center text-muted">Anteprima non disponibile per questo formato: usa "Apri in nuova scheda"</div>`;
}
$(`#wsToolbar_${kind}`).html(`
<div>
<div class="fw-bold">${escapeHtml(name)}</div>
${f.description ? `<div class="small text-muted">${escapeHtml(f.description)}</div>` : ""}
</div>
<a href="${escapeHtml(path)}" target="_blank" class="worksheet-open-link">
<i class="fa-solid fa-arrow-up-right-from-square"></i> Apri in nuova scheda
</a>
`);
$(`#wsViewer_${kind}`).html(viewer);
}
function renderWorksheetAttachmentKind(kind) {
const cfg = WS_ATT_KINDS[kind];
const list = wsAttLists[kind];
const $c = $(`#wsContainer_${kind}`);
$(`#wsCount_${kind}`).text(list.length);
if (!list.length) {
$c.html(
`<div class="text-muted p-3">${escapeHtml(cfg.empty)}</div>`,
);
return;
}
let listHtml = "";
if (list.length > 1) {
listHtml =
'<div class="ws-disegni-list">' +
list
.map(
(f, i) =>
`<button type="button" class="ws-disegno-item" data-kind="${kind}" data-index="${i}">${cfg.icon} ${escapeHtml(f.file_name || cfg.itemLabel + " " + (i + 1))}</button>`,
)
.join("") +
"</div>";
}
$c.html(`
${listHtml}
<div class="ws-disegno-toolbar" id="wsToolbar_${kind}"></div>
<div class="ws-disegno-viewer" id="wsViewer_${kind}"></div>
`);
// Se l'utente ha già aperto il tab mentre caricava, mostro subito il primo
if ($(`.ws-tab[data-tab="${kind}"]`).hasClass("active")) {
showWorksheetAttachment(kind, 0);
}
}
function loadWorksheetAttachments(idmatrice) {
wsAttLists = { disegni: [], strumenti: [], cicli: [] };
const kinds = Object.keys(WS_ATT_KINDS);
if (!idmatrice) {
kinds.forEach((k) => {
$(`#wsCount_${k}`).text("0");
$(`#wsContainer_${k}`).html(
'<div class="text-muted">Nessun profilo collegato a questo foglio.</div>',
);
});
return;
}
fetch("get_matrice_attachments.php?id=" + encodeURIComponent(idmatrice))
.then((r) => r.json())
.then((data) => {
if (!data.success)
throw new Error(
data.message || "Errore caricamento allegati",
);
const all = data.attachments || [];
kinds.forEach((k) => {
const flag = WS_ATT_KINDS[k].flag;
wsAttLists[k] = all.filter(
(a) => parseInt(a[flag], 10) === 1,
);
renderWorksheetAttachmentKind(k);
});
})
.catch((err) => {
kinds.forEach((k) => {
$(`#wsCount_${k}`).text("!");
$(`#wsContainer_${k}`).html(
'<div class="text-danger">Errore nel caricamento degli allegati: ' +
escapeHtml(err.message || "") +
"</div>",
);
});
});
}
// ================================================================
// Dettaglio foglio di lavoro (sola lettura)
// Rispecchia le sezioni di manage-worksheet.php:
// 1 Dati principali · 2 Controlli · 3 Imballo · 4 Velocità · 5 Note · 6 Mescole
// ================================================================
function renderWorksheetDetail(data) {
const ws = data.worksheet || {};
const mixRows = data.mix_rows || [];
const packageRows = data.package_rows || [];
const lookups = data.lookups || {};
const isInactive = ws.worksheet_status === "inactive";
const statusLabel =
ws.worksheet_status_label || (isInactive ? "Inattivo" : "Attivo");
const statusBadge = isInactive
? `<span class="worksheet-badge-status-inactive">${escapeHtml(statusLabel)}</span>`
: `<span class="worksheet-badge-status-active">${escapeHtml(statusLabel)}</span>`;
const cliente = ws.customer_name || ws.matrice_cliente || "";
// ---------- Intestazione ----------
const excelLink = ws.source_xlsx_path
? `<a href="download_worksheet_file.php?id=${encodeURIComponent(ws.id || "")}"
target="_blank" class="worksheet-open-link">
<i class="fa-solid fa-file-excel"></i> Excel di origine
</a>`
: "";
const headerHtml = `
<div class="worksheet-title-box mb-3">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<h4 class="mb-1">${escapeHtml(ws.worksheet_number_label || "—")} / Rev. ${escapeHtml(ws.revision_label || "0")}</h4>
<small>${ws.matrice_nome ? "Profilo: " + escapeHtml(ws.matrice_nome) : "Nessun profilo collegato"}</small>
</div>
<div class="d-flex gap-2 flex-wrap">
${excelLink}
<a href="manage-worksheet.php?id=${encodeURIComponent(ws.id || "")}"
target="_blank" class="worksheet-open-link">
<i class="fa-solid fa-arrow-up-right-from-square"></i> Apri foglio completo
</a>
</div>
</div>
</div>
`;
// ---------- Striscia riepilogo ----------
const summaryHtml = `
<div class="wsd-summary mb-3">
<div class="wsd-sum-item">
<div class="wsd-label">Stato</div>
<div>${statusBadge}</div>
</div>
<div class="wsd-sum-item">
<div class="wsd-label">Cliente</div>
<div class="wsd-sum-value">${valueOrDash(cliente)}</div>
</div>
<div class="wsd-sum-item">
<div class="wsd-label">Codice profilo</div>
<div class="wsd-sum-value">${valueOrDash(ws.profile_type_code)}</div>
</div>
<div class="wsd-sum-item">
<div class="wsd-label">Data foglio</div>
<div class="wsd-sum-value">${valueOrDash(ws.worksheet_date_it)}</div>
</div>
<div class="wsd-sum-item">
<div class="wsd-label">Mescole / Imballi</div>
<div class="wsd-sum-value">${mixRows.length} / ${packageRows.length}</div>
</div>
</div>
`;
// ---------- 1. Dati principali ----------
const sec1 = wsdSection(
"blue",
"1. Dati principali",
"",
`
<div class="wsd-fields">
${wsdField("Matrice / Profilo", ws.matrice_nome)}
${wsdField("Data foglio", ws.worksheet_date_it)}
${wsdField("Codice profilo", ws.profile_type_code)}
${wsdField("Cliente (override)", ws.customer_name)}
${wsdField("Marchiatura", lookupLabel(lookups, "marking", ws.marking))}
${wsdField("Visto / Approvazione", ws.approved_by)}
</div>
<div class="wsd-meta">
Creato il ${valueOrDash(ws.created_at_it)} · Ultimo aggiornamento ${valueOrDash(ws.updated_at_it)}
</div>
`,
"col-lg-6",
);
// ---------- 2. Controlli di produzione ----------
const sec2 = wsdSection(
"green",
"2. Controlli di produzione",
"",
`
<div class="wsd-fields">
${wsdField("Taglio", lookupLabel(lookups, "control_frequency_cut", ws.control_frequency_cut))}
${wsdField("Disegno", lookupLabel(lookups, "control_frequency_drawing", ws.control_frequency_drawing))}
${wsdField("Dima", lookupLabel(lookups, "control_frequency_jig", ws.control_frequency_jig))}
${wsdField("Modalità dima", lookupLabel(lookups, "control_mode_jig", ws.control_mode_jig))}
</div>
${wsdText("Impostazione misure controllo produzione", ws.prod_control_measure_settings, "Nessuna indicazione")}
`,
"col-lg-6",
);
// ---------- 3. Imballo e confezionamento ----------
let packagesHtml = `<div class="wsd-empty mb-2">Nessun imballo associato</div>`;
if (packageRows.length) {
packagesHtml = `
<div class="table-responsive mb-3">
<table class="table table-sm table-striped align-middle mb-0 wsd-table">
<thead>
<tr>
<th style="width:60px;">Pos</th>
<th style="width:200px;">Categoria</th>
<th>Imballo</th>
</tr>
</thead>
<tbody>
${packageRows
.map(
(p) => `
<tr>
<td>${valueOrDash(p.package_position)}</td>
<td>${valueOrDash(p.package_category)}</td>
<td>
<div class="fw-bold">${valueOrDash(p.package_name)}</div>
${p.package_item_code ? `<div class="small text-muted">${escapeHtml(p.package_item_code)}</div>` : ""}
</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
`;
}
const sec3 = wsdSection(
"orange",
"3. Imballo e confezionamento",
`${packageRows.length} imball${packageRows.length === 1 ? "o" : "i"}`,
`
<div class="row g-3">
<div class="col-lg-6">
${packagesHtml}
</div>
<div class="col-lg-6">
<div class="wsd-fields">
${wsdField("Metri per confezione", ws.meters_per_package)}
${wsdField("Tolleranza metri/conf.", ws.meters_per_package_tolerance)}
${wsdField("Conf./pezzi per scatola", ws.packages_or_pieces_per_box)}
${wsdField("Metri per scatola", ws.meters_per_box)}
${wsdField("Scatole/conf. per bancale", ws.boxes_or_packages_per_pallet)}
</div>
${wsdText("Note metri/confezione", ws.meters_per_package_notes, "Nessuna nota")}
</div>
</div>
`,
"col-12",
);
// ---------- 4. Velocità di produzione ----------
const sec4 = wsdSection(
"purple",
"4. Velocità di produzione",
"",
`
<table class="wsd-speed">
<thead>
<tr><th></th><th>Prevista</th><th>Effettiva</th></tr>
</thead>
<tbody>
<tr>
<th>kg/h</th>
<td class="${isEmpty(ws.speed_expected_kg_h) ? "wsd-empty" : ""}">${valueOrDash(ws.speed_expected_kg_h)}</td>
<td class="${isEmpty(ws.speed_actual_kg_h) ? "wsd-empty" : ""}">${valueOrDash(ws.speed_actual_kg_h)}</td>
</tr>
<tr>
<th>m/h</th>
<td class="${isEmpty(ws.speed_expected_m_h) ? "wsd-empty" : ""}">${valueOrDash(ws.speed_expected_m_h)}</td>
<td class="${isEmpty(ws.speed_actual_m_h) ? "wsd-empty" : ""}">${valueOrDash(ws.speed_actual_m_h)}</td>
</tr>
</tbody>
</table>
`,
"col-lg-6",
);
// ---------- 5. Note finali ----------
const sec5 = wsdSection(
"gray",
"5. Note finali",
"",
wsdText("", ws.notes, "Nessuna nota"),
"col-lg-6",
);
// ---------- 6. Mescole ----------
let mixHtml = `<div class="wsd-empty">Nessuna mescola associata</div>`;
if (mixRows.length) {
mixHtml = `
<div class="table-responsive">
<table class="table table-striped align-middle mb-0 wsd-table">
<thead>
<tr>
<th style="width:60px;">Pos</th>
<th>Mescola</th>
<th style="width:120px;">Peso g/m</th>
<th style="width:120px;">Sostegno g/m</th>
<th style="width:150px;">Densità</th>
<th style="width:150px;">Durezza (Sh A)</th>
<th style="width:150px;">Lubrificazione</th>
<th>Note lubr.</th>
</tr>
</thead>
<tbody>
${mixRows
.map(
(r) => `
<tr>
<td>${valueOrDash(r.mix_position)}</td>
<td>
<div class="fw-bold">${valueOrDash(r.mescola_nome)}</div>
${r.mescola_uscita ? `<div class="small text-muted">${escapeHtml(r.mescola_uscita)}</div>` : ""}
</td>
<td>${valueOrDash(r.mix_weight_g_m)}</td>
<td>${valueOrDash(r.support_weight_g_m)}</td>
<td>${valueOrDash(r.required_density)}</td>
<td>${valueOrDash(r.required_hardness_shore_a)}</td>
<td>${valueOrDash(lookupLabel(lookups, "lubrication_type", r.lubrication_type))}</td>
<td>${valueOrDash(r.lubrication_notes)}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</div>
`;
}
const sec6 = wsdSection(
"cyan",
"6. Mescole associate al foglio",
`${mixRows.length} rig${mixRows.length === 1 ? "a" : "he"}`,
mixHtml,
"col-12",
);
const datiHtml = `
${summaryHtml}
<div class="row g-3">
${sec1}
${sec2}
${sec3}
${sec4}
${sec5}
${sec6}
</div>
`;
// ---------- Tab allegati ----------
const attTabsHtml = Object.keys(WS_ATT_KINDS)
.map((k) => {
const c = WS_ATT_KINDS[k];
return `
<button type="button" class="ws-tab" data-tab="${k}">
${c.icon} ${escapeHtml(c.label)} <span class="ws-tab-count" id="wsCount_${k}">…</span>
</button>`;
})
.join("");
const attPanesHtml = Object.keys(WS_ATT_KINDS)
.map((k) => {
const c = WS_ATT_KINDS[k];
return `
<div class="ws-tab-pane" data-pane="${k}">
<div id="wsContainer_${k}">
<div class="text-muted">Caricamento ${escapeHtml(c.label.toLowerCase())}...</div>
</div>
</div>`;
})
.join("");
return `
${headerHtml}
<div class="ws-tabs">
<button type="button" class="ws-tab active" data-tab="dati">
<i class="fa-solid fa-file-lines me-1"></i> Dati foglio
</button>
${attTabsHtml}
</div>
<div class="ws-tab-pane active" data-pane="dati">
${datiHtml}
</div>
${attPanesHtml}
`;
}
window.loadWorksheetDetail = function (worksheetId, endpoint) {
endpoint = endpoint || "ajax/worksheet-linked-data.php";
$("#worksheetDetailContainer").html(
const $box = $("#worksheetDetailContainer");
$box.html(
'<div class="text-muted">Caricamento dettaglio foglio...</div>',
);
@@ -155,10 +666,9 @@
})
.then((data) => {
if (!data.success) {
console.error("Errore worksheets:", data);
$("#worksheetsListContainer").html(
'<div class="text-danger">Errore nel caricamento dei fogli: ' +
console.error("Errore dettaglio foglio:", data);
$box.html(
'<div class="text-danger">Errore nel caricamento del foglio: ' +
escapeHtml(
data.message || "nessun dettaglio restituito",
) +
@@ -167,170 +677,13 @@
return;
}
const ws = data.worksheet || {};
const mixRows = data.mix_rows || [];
let mixHtml = `<div class="text-muted">Nessuna mescola associata</div>`;
if (mixRows.length) {
mixHtml = `
<div class="table-responsive">
<table class="table table-striped align-middle mix-readonly-table">
<thead>
<tr>
<th style="width:80px;">Pos</th>
<th>Mescola</th>
<th style="width:120px;">Peso g/m</th>
<th style="width:140px;">Densità</th>
<th style="width:150px;">Durezza</th>
<th style="width:130px;">Lubr.</th>
<th>Note lubr.</th>
</tr>
</thead>
<tbody>
`;
mixRows.forEach((r) => {
const nomeMescola = r.mescola_uscita
? `${escapeHtml(r.mescola_nome || "—")} <div class="small text-muted">${escapeHtml(r.mescola_uscita)}</div>`
: escapeHtml(r.mescola_nome || "—");
mixHtml += `
<tr>
<td>${escapeHtml(String(r.mix_position || ""))}</td>
<td>${nomeMescola}</td>
<td>${valueOrDash(r.mix_weight_g_m)}</td>
<td>${valueOrDash(r.required_density)}</td>
<td>${valueOrDash(r.required_hardness_shore_a)}</td>
<td>${valueOrDash(r.lubrication_type)}</td>
<td>${valueOrDash(r.lubrication_notes)}</td>
</tr>
`;
});
mixHtml += `
</tbody>
</table>
</div>
`;
}
const html = `
<div class="worksheet-title-box mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<h4 class="mb-1">${escapeHtml(ws.worksheet_number_label || "—")} / Rev. ${escapeHtml(ws.revision_label || "0")}</h4>
<small>
Stato: ${escapeHtml(ws.worksheet_status_label || "Attivo")}
${ws.matrice_nome ? " • Profilo: " + escapeHtml(ws.matrice_nome) : ""}
${ws.matrice_cliente ? " • Cliente matrice: " + escapeHtml(ws.matrice_cliente) : ""}
</small>
</div>
<div>
<a href="manage-worksheet.php?id=${escapeHtml(String(ws.id || ""))}"
target="_blank"
class="worksheet-open-link">
<i class="fa-solid fa-arrow-up-right-from-square"></i>
Apri foglio completo
</a>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="readonly-card">
<div class="readonly-card-header">Dati principali</div>
<div class="readonly-card-body">
<div class="readonly-grid">
${renderReadonlyField("Foglio", ws.worksheet_number_label)}
${renderReadonlyField("Revisione", ws.revision_label)}
${renderReadonlyField("Stato", ws.worksheet_status_label)}
${renderReadonlyField("Data foglio", ws.worksheet_date_it)}
${renderReadonlyField("Cliente override", ws.customer_name)}
${renderReadonlyField("Codice profilo", ws.profile_type_code)}
${renderReadonlyField("Marchiatura", ws.marking)}
${renderReadonlyField("Approvato da", ws.approved_by)}
${renderReadonlyField("Creato il", ws.created_at_it)}
${renderReadonlyField("Ultimo aggiornamento", ws.updated_at_it)}
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="readonly-card">
<div class="readonly-card-header">Controlli produzione</div>
<div class="readonly-card-body">
<div class="readonly-grid">
${renderReadonlyField("Taglio", ws.control_frequency_cut)}
${renderReadonlyField("Disegno", ws.control_frequency_drawing)}
${renderReadonlyField("Dima", ws.control_frequency_jig)}
${renderReadonlyField("Modalità dima", ws.control_mode_jig)}
</div>
<hr>
<div class="readonly-label mb-2">Impostazione misure controllo produzione</div>
<div class="readonly-value" style="white-space:pre-wrap;">${valueOrDash(ws.prod_control_measure_settings)}</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="readonly-card">
<div class="readonly-card-header">Packaging / Confezionamento</div>
<div class="readonly-card-body">
<div class="readonly-grid">
${renderReadonlyField("Codice confezione", ws.requested_package_code)}
${renderReadonlyField("Metri per confezione", ws.meters_per_package)}
${renderReadonlyField("Tolleranza metri/conf.", ws.meters_per_package_tolerance)}
${renderReadonlyField("Scatola tipo", ws.box_type)}
${renderReadonlyField("Conf./pezzi per scatola", ws.packages_or_pieces_per_box)}
${renderReadonlyField("Metri per scatola", ws.meters_per_box)}
${renderReadonlyField("Bancale tipo", ws.pallet_type)}
${renderReadonlyField("Scatole/conf. per bancale", ws.boxes_or_packages_per_pallet)}
</div>
<hr>
<div class="readonly-label mb-2">Note metri / confezione</div>
<div class="readonly-value" style="white-space:pre-wrap;">${valueOrDash(ws.meters_per_package_notes)}</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="readonly-card">
<div class="readonly-card-header">Velocità e note</div>
<div class="readonly-card-body">
<div class="readonly-grid">
${renderReadonlyField("Vel. prevista kg/h", ws.speed_expected_kg_h)}
${renderReadonlyField("Vel. effettiva kg/h", ws.speed_actual_kg_h)}
${renderReadonlyField("Vel. prevista m/h", ws.speed_expected_m_h)}
${renderReadonlyField("Vel. effettiva m/h", ws.speed_actual_m_h)}
</div>
<hr>
<div class="readonly-label mb-2">Note</div>
<div class="readonly-value" style="white-space:pre-wrap;">${valueOrDash(ws.notes)}</div>
</div>
</div>
</div>
<div class="col-12">
<div class="readonly-card">
<div class="readonly-card-header">Mescole associate al foglio</div>
<div class="readonly-card-body">
${mixHtml}
</div>
</div>
</div>
</div>
`;
$("#worksheetDetailContainer").html(html);
$box.html(renderWorksheetDetail(data));
loadWorksheetAttachments((data.worksheet || {}).idmatrice);
})
.catch((err) => {
console.error("Catch loadMatriceWorksheets:", err);
$("#worksheetsListContainer").html(
'<div class="text-danger">Errore nel caricamento dei fogli: ' +
console.error("Errore dettaglio foglio:", err);
$box.html(
'<div class="text-danger">Errore nel caricamento del foglio: ' +
escapeHtml(err.message || "errore JavaScript/fetch") +
"</div>",
);
@@ -349,6 +702,32 @@
).show();
});
$(document).on("click", ".ws-tab", function () {
const tab = $(this).data("tab");
$(".ws-tab").removeClass("active");
$(this).addClass("active");
$(".ws-tab-pane").removeClass("active");
$(`.ws-tab-pane[data-pane="${tab}"]`).addClass("active");
// Carico il file solo la prima volta che si apre il tab
if (
WS_ATT_KINDS[tab] &&
(wsAttLists[tab] || []).length &&
$(`#wsViewer_${tab}`).is(":empty")
) {
showWorksheetAttachment(tab, 0);
}
});
$(document).on("click", ".ws-disegno-item", function () {
showWorksheetAttachment(
$(this).data("kind"),
parseInt($(this).data("index"), 10),
);
});
$(document).on("click", ".open-worksheet-detail", function () {
const worksheetId = $(this).data("id");
const endpoint =
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* attach_worksheet_file.php
* -------------------------------------------------------------------------
* Riceve worksheet_id + file .xlsx (campo "xlsx_file"), salva il file in
* uploads/worksheets/ e lo collega al foglio (colonne source_xlsx_*).
* Chiamato dal flusso di import DOPO il salvataggio della testata.
* -------------------------------------------------------------------------
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
include('include/headscript.php');
function jexit($a)
{
echo json_encode($a, JSON_UNESCAPED_UNICODE);
exit;
}
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jexit(['success' => false, 'message' => 'Metodo non valido']);
}
$worksheet_id = isset($_POST['worksheet_id']) && is_numeric($_POST['worksheet_id'])
? (int)$_POST['worksheet_id'] : 0;
if ($worksheet_id <= 0) {
jexit(['success' => false, 'message' => 'worksheet_id mancante o non valido']);
}
if (!isset($_FILES['xlsx_file']) || $_FILES['xlsx_file']['error'] !== UPLOAD_ERR_OK) {
jexit(['success' => false, 'message' => 'File non ricevuto']);
}
$file = $_FILES['xlsx_file'];
if ($file['size'] > 5 * 1024 * 1024) {
jexit(['success' => false, 'message' => 'File troppo grande (max 5 MB)']);
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'xlsx') {
jexit(['success' => false, 'message' => 'Sono ammessi solo file .xlsx']);
}
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
// il foglio deve esistere
$chk = $pdo->prepare("SELECT id FROM work_sheets WHERE id = ?");
$chk->execute([$worksheet_id]);
if (!$chk->fetchColumn()) {
jexit(['success' => false, 'message' => 'Foglio di lavoro inesistente']);
}
// cartella di destinazione (accanto allo script)
$dir = __DIR__ . '/uploads/worksheets';
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
jexit(['success' => false, 'message' => 'Impossibile creare la cartella uploads/worksheets']);
}
$origName = $file['name'];
$safeStored = 'ws_' . $worksheet_id . '_' . date('Ymd_His') . '.xlsx';
$destAbs = $dir . '/' . $safeStored;
$destRel = 'uploads/worksheets/' . $safeStored; // relativo alla cartella dello script
if (!move_uploaded_file($file['tmp_name'], $destAbs)) {
jexit(['success' => false, 'message' => 'Salvataggio del file fallito']);
}
$upd = $pdo->prepare("UPDATE work_sheets SET source_xlsx_name = ?, source_xlsx_path = ? WHERE id = ?");
$upd->execute([$origName, $destRel, $worksheet_id]);
jexit(['success' => true, 'name' => $origName, 'path' => $destRel]);
} catch (Exception $e) {
jexit(['success' => false, 'message' => $e->getMessage()]);
}
+397
View File
@@ -0,0 +1,397 @@
<?php
/**
* Kiosk - dati dello schermo di reparto (sala pausa).
*
* Mostra solo dati di produzione e comunicazioni: niente fatturato, prezzi o clienti.
* I tempi di produzione sono salvati in UTC (UTC_TIMESTAMP): i giorni si calcolano in ora italiana.
*/
class Kiosk
{
const TZ = 'Europe/Rome';
/** Stati non conclusi: tutto il resto con end_time è un lavoro finito. */
const NOT_FINISHED = [1, 6, 2, 7];
const ST_PROGRAMMATO = 6;
const ST_PRODUZIONE = 2;
const ST_PAUSA = 7;
const SLIDES = [
'linee' => 'Linee adesso',
'oggi' => 'Oggi e settimana',
'andamento' => 'Andamento 14 giorni',
'fermi' => 'Fermi della settimana',
'coda' => 'Lavoro in arrivo',
'meteo' => 'Meteo e previsioni',
'sicurezza' => 'Giorni senza infortuni',
'obiettivi' => 'Obiettivi',
'comunicazioni' => 'Comunicazioni',
'benvenuti' => 'Benvenuti e ringraziamenti',
'turni' => 'Turni e calendario',
'menu' => 'Menu',
];
const ITEM_TYPES = [
'comunicazione' => 'Comunicazione',
'benvenuto' => 'Benvenuto / grazie',
'turni' => 'Turni e calendario',
'menu' => 'Menu',
'ticker' => 'Avviso scorrevole',
];
/** Obiettivi: calcolati dai dati (sul periodo dell'obiettivo) o inseriti a mano. */
const GOAL_SOURCES = [
'manuale' => ['Valore inserito a mano', '', false],
'metri_mese' => ['Metri prodotti', 'm', false],
'kg_mese' => ['Kg prodotti', 'kg', false],
'lavori_mese' => ['Lavori completati', '', false],
'scarto_mese' => ['Scarto %', '%', true],
'fermi_mese' => ['Ore di fermo', 'h', true],
];
const DEFAULTS = [
'company' => 'Zibo',
'slide_seconds' => '15',
'slide_durations' => '',
'slides' => 'linee,oggi,andamento,fermi,coda,meteo,sicurezza,obiettivi,comunicazioni,benvenuti,turni,menu',
'scarto_target' => '3',
'last_incident' => '',
'record_days' => '',
'safety_note' => '',
'city' => '',
'lat' => '',
'lon' => '',
'token' => '',
];
/* ===================== IMPOSTAZIONI ===================== */
public static function settings(PDO $pdo)
{
$s = self::DEFAULTS;
foreach ($pdo->query("SELECT setting_key, setting_value FROM kiosk_settings") as $r) {
$s[$r['setting_key']] = (string)$r['setting_value'];
}
return $s;
}
public static function set(PDO $pdo, $key, $value)
{
$pdo->prepare("INSERT INTO kiosk_settings (setting_key, setting_value, updated_at) VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()")
->execute([$key, (string)$value]);
}
/** Codice segreto del link dello schermo (creato al primo uso). */
public static function token(PDO $pdo, $renew = false)
{
$t = self::settings($pdo)['token'];
if ($t === '' || $renew) {
$t = bin2hex(random_bytes(12));
self::set($pdo, 'token', $t);
}
return $t;
}
/** Secondi per tipo di slide (vuoto = durata predefinita). */
public static function durations(array $s)
{
$out = [];
foreach ((array)json_decode((string)$s['slide_durations'], true) as $k => $v) {
if (isset(self::SLIDES[$k]) && (int)$v > 0) $out[$k] = max(4, min(300, (int)$v));
}
return $out;
}
/* ===================== DATE ===================== */
private static function tz()
{
return new DateTimeZone(self::TZ);
}
/** Mezzanotte locale di oggi + $days giorni. */
public static function day($days = 0)
{
$d = new DateTime('today', self::tz());
if ($days) $d->modify(($days > 0 ? '+' : '') . $days . ' day');
return $d;
}
public static function monday($weeks = 0)
{
$d = new DateTime('monday this week', self::tz());
if ($weeks) $d->modify(($weeks > 0 ? '+' : '') . $weeks . ' week');
return $d;
}
/** DateTime locale → stringa UTC per confrontare con start_time / end_time. */
public static function utc(DateTime $d)
{
$x = clone $d;
$x->setTimezone(new DateTimeZone('UTC'));
return $x->format('Y-m-d H:i:s');
}
public static function ms($utc)
{
return $utc ? (new DateTime($utc, new DateTimeZone('UTC')))->getTimestamp() * 1000 : null;
}
/* ===================== PRODUZIONE ===================== */
/** Lavori conclusi tra due istanti locali. */
public static function produced(PDO $pdo, DateTime $from, DateTime $to)
{
$st = $pdo->prepare("SELECT COUNT(*) n, COALESCE(SUM(mtprod), 0) mt, COALESCE(SUM(kgprod), 0) kg, COALESCE(SUM(scarto), 0) sc
FROM productiondata
WHERE end_time >= ? AND end_time < ? AND id_status NOT IN (" . implode(',', self::NOT_FINISHED) . ")");
$st->execute([self::utc($from), self::utc($to)]);
$r = $st->fetch(PDO::FETCH_ASSOC);
$kg = (float)$r['kg'];
$sc = (float)$r['sc'];
return [
'n' => (int)$r['n'],
'mt' => round((float)$r['mt']),
'kg' => round($kg),
'scarto' => ($kg + $sc) > 0 ? round($sc / ($kg + $sc) * 100, 1) : null,
];
}
/** Ore di fermo (pause) tra due istanti locali, per motivo. */
public static function stops(PDO $pdo, DateTime $from, DateTime $to)
{
$f = self::utc($from);
$t = self::utc($to);
$st = $pdo->prepare("
SELECT COALESCE(pr.name, 'Motivo non indicato') AS name, COALESCE(MAX(pr.is_problem), 0) AS problem,
SUM(GREATEST(0, TIMESTAMPDIFF(SECOND, GREATEST(pp.start_pause, ?), LEAST(COALESCE(pp.end_pause, UTC_TIMESTAMP()), ?)))) AS sec
FROM production_pauses pp
LEFT JOIN pause_reasons pr ON pr.id = pp.reason_id
WHERE pp.start_pause < ? AND COALESCE(pp.end_pause, UTC_TIMESTAMP()) > ?
GROUP BY COALESCE(pr.name, 'Motivo non indicato')
ORDER BY sec DESC");
$st->execute([$f, $t, $t, $f]);
$items = [];
$tot = 0;
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$h = (int)$r['sec'] / 3600;
if ($h <= 0) continue;
$tot += $h;
$items[] = ['name' => $r['name'], 'h' => round($h, 1), 'problem' => (int)$r['problem'] === 1];
}
return ['total_h' => round($tot, 1), 'items' => $items];
}
private static function lines(PDO $pdo)
{
$lines = [];
foreach ($pdo->query("SELECT * FROM production_lines ORDER BY line_number, id")->fetchAll(PDO::FETCH_ASSOC) as $l) {
if (isset($l['status']) && $l['status'] !== '' && $l['status'] !== 'active') continue;
$lines[(int)$l['id']] = [
'id' => (int)$l['id'],
'name' => $l['name'],
'color' => !empty($l['color']) ? $l['color'] : '#64748b',
'job' => null,
'queue_n' => 0,
'queue_h' => 0,
'next' => null,
];
}
// in macchina
$st = $pdo->query("
SELECT p.id, p.id_linea, p.id_status, p.start_time, p.ore_previste, m.nome AS matrice,
(SELECT GROUP_CONCAT(ms.nome ORDER BY ms.nome SEPARATOR ', ') FROM productiondata_mescole pm JOIN mescole ms ON ms.id = pm.id_mescola WHERE pm.id_productiondata = p.id) AS mescole,
(SELECT COALESCE(SUM(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)), 0) FROM production_pauses pp WHERE pp.id_production = p.id AND pp.end_pause IS NOT NULL) AS pause_sec,
(SELECT pp.start_pause FROM production_pauses pp WHERE pp.id_production = p.id AND pp.end_pause IS NULL ORDER BY pp.start_pause DESC LIMIT 1) AS open_pause
FROM productiondata p LEFT JOIN matrice m ON m.id = p.idmatrice
WHERE p.id_status IN (" . self::ST_PRODUZIONE . "," . self::ST_PAUSA . ")
ORDER BY p.start_time");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $j) {
$lid = (int)$j['id_linea'];
if (!isset($lines[$lid]) || $lines[$lid]['job']) continue;
$lines[$lid]['job'] = [
'matrice' => $j['matrice'],
'mescole' => (string)$j['mescole'],
'paused' => (int)$j['id_status'] === self::ST_PAUSA,
'start_ms' => self::ms($j['start_time']),
'pause_sec' => (int)$j['pause_sec'],
'open_pause_ms' => self::ms($j['open_pause']),
'ore' => (float)$j['ore_previste'],
];
}
// coda
foreach (
$pdo->query("SELECT p.id_linea, m.nome, p.ore_previste FROM productiondata p LEFT JOIN matrice m ON m.id = p.idmatrice
WHERE p.id_status = " . self::ST_PROGRAMMATO . " ORDER BY p.priority IS NULL, p.priority, p.id") as $r
) {
$lid = (int)$r['id_linea'];
if (!isset($lines[$lid])) continue;
$lines[$lid]['queue_n']++;
$lines[$lid]['queue_h'] += (float)$r['ore_previste'];
if ($lines[$lid]['next'] === null) $lines[$lid]['next'] = $r['nome'];
}
foreach ($lines as &$l) $l['queue_h'] = round($l['queue_h'], 1);
unset($l);
return array_values($lines);
}
/** Metri prodotti negli ultimi $days giorni, per giorno e linea. */
private static function trend(PDO $pdo, array $lines, $days = 14)
{
$from = self::day(- ($days - 1));
$labels = [];
$idx = [];
$GG = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab'];
for ($i = 0; $i < $days; $i++) {
$d = (clone $from)->modify("+$i day");
$idx[$d->format('Y-m-d')] = $i;
$labels[] = $GG[(int)$d->format('w')] . ' ' . $d->format('d/m');
}
$series = [];
foreach ($lines as $l) $series[$l['id']] = ['name' => $l['name'], 'color' => $l['color'], 'data' => array_fill(0, $days, 0)];
$st = $pdo->prepare("SELECT id_linea, end_time, mtprod FROM productiondata
WHERE end_time >= ? AND id_status NOT IN (" . implode(',', self::NOT_FINISHED) . ")");
$st->execute([self::utc($from)]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$d = (new DateTime($r['end_time'], new DateTimeZone('UTC')))->setTimezone(self::tz())->format('Y-m-d');
$lid = (int)$r['id_linea'];
if (isset($idx[$d], $series[$lid])) $series[$lid]['data'][$idx[$d]] += round((float)$r['mtprod']);
}
return ['labels' => $labels, 'series' => array_values($series)];
}
/** Contenuti validi oggi, raggruppati per tipo. */
public static function items(PDO $pdo, $onlyValid = true)
{
$sql = "SELECT * FROM kiosk_items" . ($onlyValid ? " WHERE active = 1 AND (valid_from IS NULL OR valid_from <= :d) AND (valid_to IS NULL OR valid_to >= :d2)" : "") . " ORDER BY sort, id DESC";
$st = $pdo->prepare($sql);
$today = self::day()->format('Y-m-d');
$st->execute($onlyValid ? ['d' => $today, 'd2' => $today] : []);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
public static function goals(PDO $pdo, $onlyActive = true)
{
$rows = $pdo->query("SELECT * FROM kiosk_goals" . ($onlyActive ? " WHERE active = 1" : "") . " ORDER BY sort, id")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$g) {
$from = $g['period_from'] ? new DateTime($g['period_from'], self::tz()) : new DateTime('first day of this month 00:00', self::tz());
$to = $g['period_to'] ? (new DateTime($g['period_to'], self::tz()))->modify('+1 day') : (new DateTime('first day of next month 00:00', self::tz()));
$now = new DateTime('now', self::tz());
$end = $to < $now ? $to : $now;
$cur = null;
switch ($g['source']) {
case 'metri_mese':
$cur = self::produced($pdo, $from, $end)['mt'];
break;
case 'kg_mese':
$cur = self::produced($pdo, $from, $end)['kg'];
break;
case 'lavori_mese':
$cur = self::produced($pdo, $from, $end)['n'];
break;
case 'scarto_mese':
$cur = self::produced($pdo, $from, $end)['scarto'];
break;
case 'fermi_mese':
$cur = self::stops($pdo, $from, $end)['total_h'];
break;
default:
$cur = $g['current_value'] !== null ? (float)$g['current_value'] : null;
}
$target = (float)$g['target'];
$lower = (int)$g['lower_is_better'] === 1;
$pct = null;
if ($cur !== null && $target > 0) {
$pct = $lower ? ($cur <= $target ? 100 : max(0, round($target / max($cur, 0.0001) * 100))) : min(100, round($cur / $target * 100));
}
$g['current'] = $cur;
$g['pct'] = $pct;
$g['ok'] = $cur !== null && ($lower ? $cur <= $target : $cur >= $target);
$g['period_label'] = $from->format('d/m') . ' – ' . (clone $to)->modify('-1 day')->format('d/m');
}
unset($g);
return $rows;
}
public static function safety(array $s)
{
if ($s['last_incident'] === '') return null;
$last = new DateTime($s['last_incident'], self::tz());
$days = (int)$last->diff(self::day())->format('%r%a');
$record = $s['record_days'] !== '' ? (int)$s['record_days'] : null;
return [
'days' => max(0, $days),
'record' => $record !== null ? max($record, $days) : null,
'since' => $last->format('d/m/Y'),
'note' => $s['safety_note'],
];
}
/* ===================== TUTTO PER LO SCHERMO ===================== */
public static function data(PDO $pdo)
{
$s = self::settings($pdo);
$now = new DateTime('now', self::tz());
$lines = self::lines($pdo);
// oggi a quest'ora contro ieri alla stessa ora; settimana in corso contro la scorsa allo stesso punto
$today = self::day();
$yest = self::day(-1);
$yestSame = (clone $yest)->setTime((int)$now->format('H'), (int)$now->format('i'), (int)$now->format('s'));
$mon = self::monday();
$monPrev = self::monday(-1);
$samePrev = (clone $monPrev)->modify('+' . ($now->getTimestamp() - $mon->getTimestamp()) . ' seconds');
$items = ['comunicazione' => [], 'benvenuto' => [], 'turni' => [], 'menu' => [], 'ticker' => []];
foreach (self::items($pdo) as $it) {
if (!isset($items[$it['item_type']])) continue;
$items[$it['item_type']][] = [
'id' => (int)$it['id'],
'title' => $it['title'],
'body' => (string)$it['body'],
'image' => $it['image'],
];
}
$goals = array_map(function ($g) {
return [
'title' => $g['title'],
'unit' => $g['unit'],
'target' => (float)$g['target'],
'current' => $g['current'],
'pct' => $g['pct'],
'ok' => $g['ok'],
'lower' => (int)$g['lower_is_better'] === 1,
'period' => $g['period_label'],
];
}, self::goals($pdo));
return [
'success' => true,
'now_ms' => time() * 1000,
'company' => $s['company'],
'settings' => [
'slide_seconds' => max(6, min(120, (int)$s['slide_seconds'] ?: 15)),
'slides' => array_values(array_filter(explode(',', $s['slides']))),
'durations' => (object)self::durations($s),
'scarto_target' => (float)str_replace(',', '.', $s['scarto_target']),
'city' => $s['city'],
'lat' => $s['lat'],
'lon' => $s['lon'],
],
'lines' => $lines,
'today' => ['cur' => self::produced($pdo, $today, $now), 'prev' => self::produced($pdo, $yest, $yestSame)],
'week' => ['cur' => self::produced($pdo, $mon, $now), 'prev' => self::produced($pdo, $monPrev, $samePrev)],
'trend' => self::trend($pdo, $lines),
'stops' => self::stops($pdo, $mon, $now),
'safety' => self::safety($s),
'goals' => $goals,
'items' => $items,
];
}
}
+402
View File
@@ -0,0 +1,402 @@
<?php
/**
* MagoClient - client REST in SOLA LETTURA per Mago4 (Zucchetti) - ERPServiceProvider
*
* Uso tipico:
* $mago = MagoClient::fromEnv();
* foreach ($mago->iterate('MA_SaleOrd', ['filters' => [...]]) as $rec) { ... }
* $mago->logoff(); // SEMPRE: le sessioni occupano licenza
*
* Variabili .env richieste:
* MAGO_BASE_URL, MAGO_USER, MAGO_PASSWORD, MAGO_COMPANY
* Opzionale:
* MAGO_TIMEOUT (secondi, default 60)
*
* Compatibile PHP 7.4+
*/
class MagoApiException extends RuntimeException {}
class MagoClient
{
/** @var string */
private $baseUrl;
/** @var string */
private $username;
/** @var string */
private $password;
/** @var string */
private $company;
/** @var int */
private $timeout;
/** @var string|null */
private $token = null;
/** @var string|null */
private $lastLogoffError = null;
/** @var string|null percorso di logoff che ha risposto correttamente */
private $logoffPath = null;
public function __construct($baseUrl, $username, $password, $company, $timeout = 60)
{
$this->baseUrl = rtrim((string)$baseUrl, '/') . '/';
$this->username = (string)$username;
$this->password = (string)$password;
$this->company = (string)$company;
$this->timeout = max(5, (int)$timeout);
}
/**
* Crea il client leggendo le credenziali da .env (dotenv) / variabili d'ambiente.
*/
public static function fromEnv()
{
$get = function ($k, $default = null) {
if (isset($_ENV[$k]) && $_ENV[$k] !== '') return $_ENV[$k];
if (isset($_SERVER[$k]) && $_SERVER[$k] !== '') return $_SERVER[$k];
$v = getenv($k);
return ($v !== false && $v !== '') ? $v : $default;
};
$cfg = [
'MAGO_BASE_URL' => $get('MAGO_BASE_URL'),
'MAGO_USER' => $get('MAGO_USER'),
'MAGO_PASSWORD' => $get('MAGO_PASSWORD'),
'MAGO_COMPANY' => $get('MAGO_COMPANY'),
];
$missing = array_keys(array_filter($cfg, function ($v) {
return $v === null;
}));
if ($missing) {
throw new MagoApiException('Variabili .env mancanti: ' . implode(', ', $missing));
}
return new self(
$cfg['MAGO_BASE_URL'],
$cfg['MAGO_USER'],
$cfg['MAGO_PASSWORD'],
$cfg['MAGO_COMPANY'],
(int)$get('MAGO_TIMEOUT', 60)
);
}
public function __destruct()
{
// Rete di sicurezza: se lo script dimentica il logoff, libero comunque la licenza
$this->logoff();
}
public function isLoggedIn()
{
return $this->token !== null;
}
public function getLastLogoffError()
{
return $this->lastLogoffError;
}
public function getLogoffPath()
{
return $this->logoffPath;
}
/* =========================================================
* SESSIONE
* ========================================================= */
public function login()
{
$r = $this->request('api/account-manager/login', [
'username' => $this->username,
'password' => $this->password,
'company' => $this->company,
], false);
$token = $r['data']['token'] ?? null;
if (!is_string($token) || $token === '') {
throw new MagoApiException('Login Mago fallito (HTTP ' . $r['status'] . '): ' . $this->extractMessage($r));
}
$this->token = $token;
return $this->token;
}
/**
* Chiude la sessione e libera lo slot di licenza.
* Non lancia eccezioni: ritorna true/false (dettaglio in getLastLogoffError()).
*/
public function logoff()
{
if ($this->token === null) return true;
$this->lastLogoffError = null;
// Il percorso "logoff" ha risposto 404: provo le varianti note e memorizzo quella buona
$candidates = $this->logoffPath
? [$this->logoffPath]
: ['api/account-manager/logoff', 'api/account-manager/logout', 'api/account-manager/logOff'];
$errors = [];
try {
foreach ($candidates as $path) {
$r = $this->request($path, ['token' => $this->token], true);
if ($r['status'] >= 400) {
$errors[] = $path . ' → HTTP ' . $r['status'];
continue;
}
$this->logoffPath = $path;
return true;
}
$this->lastLogoffError = implode(' | ', $errors);
return false;
} catch (Throwable $e) {
$this->lastLogoffError = $e->getMessage();
return false;
} finally {
$this->token = null;
}
}
/* =========================================================
* LETTURA DATI
* ========================================================= */
/**
* Legge UNA pagina di una tabella.
*
* $opts:
* 'fields' => ['Campo1', 'Campo2'] (opzionale)
* 'filters' => [['key'=>'OrderDate','value'=>'2026-01-01T00:00:00','operator'=>'>=']]
* value può essere anche DateTimeInterface
* 'recordsPerPage' => 200 (massimo accettato da Mago)
* 'pageNumber' => 1
*
* Ritorna: ['total'=>int, 'count'=>int, 'page'=>int, 'records'=>array]
*/
public function getDataTable($tableName, array $opts = [])
{
$body = [
'tableName' => (string)$tableName,
'recordsPerPage' => max(1, min(200, (int)($opts['recordsPerPage'] ?? 200))), // Mago restituisce max 200 per pagina
'pageNumber' => max(1, (int)($opts['pageNumber'] ?? 1)),
];
if (!empty($opts['fields'])) {
$body['fieldsName'] = array_values($opts['fields']);
}
if (!empty($opts['filters'])) {
$body['filters'] = $this->normalizeFilters($opts['filters']);
}
$data = $this->callWithRelogin('api/magoservices/getDataTable', $body);
if (($data['response'] ?? '') !== 'OK') {
throw new MagoApiException("getDataTable($tableName) risposta non OK: " . $this->extractMessage(['data' => $data, 'raw' => json_encode($data)]));
}
$d = $data['responseData'] ?? [];
return [
'total' => (int)($d['totalRecordsNumber'] ?? 0),
'count' => (int)($d['currentPageRecordsNumber'] ?? 0),
'page' => (int)($d['currentPageNumber'] ?? $body['pageNumber']),
'records' => is_array($d['records'] ?? null) ? $d['records'] : [],
];
}
/**
* Itera TUTTI i record di una tabella paginando in automatico (generator, poca memoria).
*
* foreach ($mago->iterate('MA_SaleOrd', ['filters'=>[...], 'recordsPerPage'=>500]) as $rec) {...}
*/
public function iterate($tableName, array $opts = [], $maxPages = 100000)
{
$perPage = max(1, min(200, (int)($opts['recordsPerPage'] ?? 200)));
$page = 1;
$read = 0;
do {
$r = $this->getDataTable($tableName, array_merge($opts, [
'recordsPerPage' => $perPage,
'pageNumber' => $page,
]));
if (empty($r['records'])) break;
foreach ($r['records'] as $rec) {
$read++;
yield $rec;
}
$page++;
} while ($read < $r['total'] && $page <= $maxPages);
}
/**
* Conta i record (chiede 1 solo record e legge totalRecordsNumber).
*/
public function count($tableName, array $filters = [])
{
$r = $this->getDataTable($tableName, [
'filters' => $filters,
'recordsPerPage' => 1,
'pageNumber' => 1,
]);
return $r['total'];
}
/* =========================================================
* HELPER DI CONVERSIONE (per l'import in MySQL)
* ========================================================= */
/**
* Converte una data Mago in formato MySQL. Le date "vuote" di Mago (1799-12-31) diventano null.
*/
public static function toMysqlDate($value, $withTime = false)
{
if ($value === null) return null;
$value = trim((string)$value);
if ($value === '' || strpos($value, '1799-12-31') === 0) return null;
$ts = strtotime($value);
if ($ts === false) return null;
return date($withTime ? 'Y-m-d H:i:s' : 'Y-m-d', $ts);
}
/**
* Converte i booleani di Mago (true/false, 1/0, "1"/"0", "true"/"false", "Y"/"N") in 0/1.
*/
public static function toBool($value)
{
if (is_bool($value)) return $value ? 1 : 0;
$v = strtolower(trim((string)$value));
return in_array($v, ['1', 'true', 'y', 'yes', 's', 'si'], true) ? 1 : 0;
}
public static function toDecimal($value)
{
if ($value === null || $value === '') return 0.0;
return (float)str_replace(',', '.', (string)$value);
}
/* =========================================================
* INTERNI
* ========================================================= */
private function normalizeFilters(array $filters)
{
$out = [];
foreach ($filters as $f) {
if (!isset($f['key'])) continue;
$value = $f['value'] ?? '';
if ($value instanceof DateTimeInterface) {
$value = $value->format('Y-m-d\TH:i:s');
}
// Verificato: una data SENZA orario dà risultati errati → aggiungo sempre T00:00:00
if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
$value .= 'T00:00:00';
}
$out[] = [
'key' => (string)$f['key'],
'value' => is_bool($value) ? ($value ? 'true' : 'false') : (string)$value,
'operator' => (string)($f['operator'] ?? '='),
];
}
return $out;
}
private function callWithRelogin($path, $body)
{
if ($this->token === null) {
$this->login();
}
$r = $this->request($path, $body, true);
// Token scaduto → rifaccio login UNA volta e ritento
if ($this->isAuthError($r)) {
$this->token = null;
$this->login();
$r = $this->request($path, $body, true);
}
if ($r['status'] >= 400 || $r['data'] === null) {
throw new MagoApiException('Errore Mago (HTTP ' . $r['status'] . ') su ' . $path . ': ' . $this->extractMessage($r));
}
return $r['data'];
}
private function isAuthError(array $r)
{
$raw = (string)$r['raw'];
return stripos($raw, 'LoginInfo Not Found') !== false
|| stripos($raw, 'Missing token') !== false
|| $r['status'] === 401;
}
private function request($path, $body, $auth)
{
if (!function_exists('curl_init')) {
throw new MagoApiException('Estensione PHP cURL non disponibile');
}
$headers = [
'Content-Type: application/json',
'Accept: application/json',
];
if ($auth) {
if ($this->token === null) {
throw new MagoApiException('Chiamata autenticata senza token (login non eseguito)');
}
$headers[] = 'Authorization: ' . $this->token; // token nudo, senza "Bearer"
}
$ch = curl_init($this->baseUrl . ltrim($path, '/'));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => $this->timeout,
]);
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new MagoApiException('Connessione a Mago fallita (VPN attiva?): ' . $err);
}
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($raw, true);
return [
'status' => $status,
'data' => is_array($data) ? $data : null,
'raw' => $raw,
];
}
private function extractMessage(array $r)
{
$d = $r['data'] ?? null;
if (is_array($d)) {
foreach (['message', 'Message', 'error', 'errorMessage', 'responseMessage', 'response'] as $k) {
if (!empty($d[$k]) && is_string($d[$k])) return $d[$k];
}
}
$raw = trim((string)($r['raw'] ?? ''));
return $raw !== '' ? mb_substr($raw, 0, 300) : 'risposta vuota';
}
}
+961
View File
@@ -0,0 +1,961 @@
<?php
/**
* Statistiche vendite per MATRICE.
* - prezzo di vendita per unità (€/m, €/kg) nel tempo e per cliente
* - consumo mescole stimato dalla scheda di lavoro (g/m per mescola)
* Fonte: righe ordine Mago (mago_sale_ord_detail), solo lettura. Prezzo = imponibile riga ÷ quantità.
*/
require_once __DIR__ . '/MagoSalesStats.php';
require_once __DIR__ . '/MagoMixPurchases.php';
class MagoMatriceStats
{
/** Righe con quantità <= MIN_QTY escluse (servizi: quantità 0 o 1) */
const MIN_QTY = 1;
/** Articoli esclusi (spese, attrezzature) */
const EXCLUDED_ITEM_PREFIXES = ['SPES', 'SPATT'];
/** Prima data considerata */
const SINCE = '2020-01-01';
const UOM_METERS = ['MT', 'M', 'ML', 'MTL'];
const UOM_KG = ['KG', 'KGS'];
const UOM_PIECES = ['N.', 'N', 'NR', 'NR.', 'PZ', 'PZ.'];
/** Tipi di collegamento che contano come "matrice in anagrafica" */
const LINKED_TYPES = ['esatta', 'revisione', 'mappa'];
/** Esito del riconoscimento (campo type) */
const TYPE_LABELS = [
'esatta' => 'Matrice',
'revisione' => 'Altra revisione',
'mappa' => 'Abbinata a mano',
'da_creare' => 'Non in anagrafica',
'profilo' => 'Profilo',
'altro' => 'Non riconosciuta',
];
/** Tipo dalla lettera iniziale di Matrice/Misura (campo cat) */
const CAT_LABELS = [
'M' => 'Matrice',
'T' => 'Tubo',
'R' => 'Rettangolo',
'D' => 'Diametro',
'Q' => 'Quadrato',
'S' => 'Profilo S',
'X' => 'Non riconosciuta',
];
private static ?array $idx = null;
private static array $cache = [];
/* ============================================================
* UTILITY
* ============================================================ */
public static function norm($s): string
{
return strtoupper(preg_replace('/\s+/u', ' ', trim((string)$s)));
}
/** m = metri, kg = chili, pz = pezzi, altro */
public static function uomKind($u): string
{
$u = self::norm($u);
if (in_array($u, self::UOM_METERS, true)) return 'm';
if (in_array($u, self::UOM_KG, true)) return 'kg';
if (in_array($u, self::UOM_PIECES, true)) return 'pz';
return 'altro';
}
public static function photoUrl($p): ?string
{
$p = trim((string)$p);
if ($p === '') return null;
if (preg_match('~^(https?:)?//~i', $p) || strpos($p, '/') !== false) return $p;
return 'photos/matrici/' . $p;
}
private static function inList(string $prefix, array $values, array &$params): string
{
$ph = [];
foreach (array_values($values) as $i => $v) {
$ph[] = ":{$prefix}{$i}";
$params["{$prefix}{$i}"] = $v;
}
return implode(',', $ph);
}
/* ============================================================
* RICONOSCIMENTO MATRICE (stesse regole della coda produzione:
* mappa manuale → codice senza M, con e senza revisione → non in anagrafica / profilo T-R-D-Q-S / altro)
* ============================================================ */
/**
* Codice matrice normalizzato: senza la M iniziale e separato dalla revisione.
* "M00407P-1" → ['00407P', '1'] · "00786S" → ['00786S', '']
*/
public static function splitCode(string $s): array
{
$s = strtoupper(trim($s));
$s = preg_replace('/^M\s?(?=\d)/', '', $s);
$s = preg_replace('/\s+/', '', $s);
if (preg_match('/^(.+?)-(\d+)$/', $s, $m)) return [$m[1], $m[2]];
return [$s, ''];
}
public static function index(PDO $pdo): array
{
if (self::$idx !== null) return self::$idx;
$base = []; // codice senza M e senza revisione => [revisione => id]
$info = [];
foreach ($pdo->query("SELECT id, nome, photo FROM matrice ORDER BY id") as $r) {
$id = (int)$r['id'];
$info[$id] = ['name' => (string)$r['nome'], 'photo' => self::photoUrl($r['photo'])];
$parts = preg_split('/\s+/u', trim((string)$r['nome']));
$first = $parts[0] ?? '';
if ($first === '') continue;
[$code, $rev] = self::splitCode($first);
if ($code === '') continue;
$base[$code][$rev] = $id;
}
$map = [];
try {
foreach ($pdo->query("SELECT codice_mago, idmatrice FROM matrice_mago_map WHERE idmatrice IS NOT NULL") as $r) {
$map[self::norm($r['codice_mago'])] = (int)$r['idmatrice'];
}
} catch (Throwable $e) {
}
return self::$idx = ['base' => $base, 'info' => $info, 'map' => $map];
}
/**
* Matrice dell'anagrafica per codice (senza M) e revisione, provando con e senza revisione.
* - stessa revisione, oppure revisione assente da una delle due parti → esatta
* - revisioni diverse da entrambe le parti → revisione (avviso)
* @return array|null [id, type]
*/
private static function findMatrix(array $ix, string $code, string $rev): ?array
{
$revs = $ix['base'][$code] ?? null;
if (!$revs) return null;
if (isset($revs[$rev])) return [$revs[$rev], 'esatta'];
if ($rev !== '' && isset($revs[''])) return [$revs[''], 'esatta'];
$keys = array_keys($revs);
usort($keys, fn($a, $b) => (int)$b <=> (int)$a); // revisione più alta
return [$revs[$keys[0]], $rev === '' ? 'esatta' : 'revisione'];
}
/**
* @return array key, id, cat (M/T/R/D/Q/S/X), type (esito), label, code
* key: m{id} (matrice in anagrafica) | c:{codice} (non in anagrafica) | p:{valore} (profilo) | x:{valore}
*/
public static function resolve(PDO $pdo, $raw): array
{
$n = self::norm($raw);
if (isset(self::$cache[$n])) return self::$cache[$n];
$ix = self::index($pdo);
$linked = function (int $id, string $type, string $code) use ($ix, $n) {
return ['key' => 'm' . $id, 'id' => $id, 'cat' => 'M', 'type' => $type, 'label' => $ix['info'][$id]['name'] ?? $n, 'code' => $code];
};
if (isset($ix['map'][$n]) && isset($ix['info'][$ix['map'][$n]])) {
$res = $linked($ix['map'][$n], 'mappa', $n);
} elseif (preg_match('/(?<![A-Z])M\s?(\d{3,6}[A-Z]{0,2})(?:\s?-\s?(\d+))?/', $n, $m)) {
// codice matrice anche in mezzo al campo (Q8X8M00936P, S5X5,5 M00456S): si cerca senza la M
$code = $m[1];
$rev = $m[2] ?? '';
$label = $code . ($rev !== '' ? '-' . $rev : '');
$f = self::findMatrix($ix, $code, $rev);
if ($f) {
$res = $linked($f[0], $f[1], $label);
} else {
$res = ['key' => 'c:' . $code, 'id' => null, 'cat' => 'M', 'type' => 'da_creare', 'label' => $label, 'code' => $label];
}
} elseif (preg_match('/^([DRTQS])\s?\d/', $n, $m)) {
$res = ['key' => 'p:' . $n, 'id' => null, 'cat' => $m[1], 'type' => 'profilo', 'label' => $n, 'code' => $n];
} else {
$res = ['key' => 'x:' . $n, 'id' => null, 'cat' => 'X', 'type' => 'altro', 'label' => $n, 'code' => $n];
}
return self::$cache[$n] = $res;
}
/* ============================================================
* MESCOLE
* ============================================================ */
/**
* Per ogni matrice: ultima scheda di lavoro (prima le attive, poi la più recente)
* con le mescole e i loro g/m. Senza scheda: mescole dell'anagrafica matrice (senza g/m).
*/
public static function mixInfo(PDO $pdo, array $ids): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
$out = [];
foreach (array_chunk($ids, 500) as $chunk) {
$in = implode(',', $chunk);
$ws = [];
$rows = $pdo->query("SELECT id, idmatrice, worksheet_number, worksheet_date, revision_code, worksheet_status
FROM work_sheets WHERE idmatrice IN ($in)
ORDER BY idmatrice, (worksheet_status = 'active') DESC, worksheet_date DESC, id DESC")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$m = (int)$r['idmatrice'];
if (!isset($ws[$m])) $ws[$m] = $r;
}
$mix = [];
if ($ws) {
$wsIds = implode(',', array_map(fn($r) => (int)$r['id'], $ws));
foreach (
$pdo->query("SELECT w.worksheet_id, w.idmescola, w.mix_position, w.mix_weight_g_m, m.nome, m.nomeuscita
FROM work_sheet_mescole w JOIN mescole m ON m.id = w.idmescola
WHERE w.worksheet_id IN ($wsIds)
ORDER BY w.worksheet_id, w.mix_position") as $r
) {
$mix[(int)$r['worksheet_id']][] = [
'id' => (int)$r['idmescola'],
'name' => (string)$r['nome'],
'out' => (string)($r['nomeuscita'] ?? ''),
'pos' => (int)$r['mix_position'],
'gm' => $r['mix_weight_g_m'] !== null ? (float)$r['mix_weight_g_m'] : null,
];
}
}
$ana = [];
foreach (
$pdo->query("SELECT mm.idmatrice, m.id, m.nome, m.nomeuscita
FROM matrici_mescole mm JOIN mescole m ON m.id = mm.idmescola
WHERE mm.idmatrice IN ($in) ORDER BY m.nome") as $r
) {
$ana[(int)$r['idmatrice']][] = ['id' => (int)$r['id'], 'name' => (string)$r['nome'], 'out' => (string)($r['nomeuscita'] ?? ''), 'pos' => 0, 'gm' => null];
}
foreach ($chunk as $id) {
$w = $ws[$id] ?? null;
$list = $w ? ($mix[(int)$w['id']] ?? []) : [];
$src = $list ? 'scheda' : 'nessuna';
if (!$list && !empty($ana[$id])) {
$list = $ana[$id];
$src = 'anagrafica';
}
$gmTot = 0.0;
$missing = 0;
foreach ($list as $x) {
if ($x['gm'] !== null && $x['gm'] > 0) $gmTot += $x['gm'];
else $missing++;
}
$out[$id] = [
'ws' => $w ? [
'id' => (int)$w['id'],
'number' => $w['worksheet_number'],
'date' => $w['worksheet_date'],
'rev' => $w['revision_code'],
'active' => $w['worksheet_status'] === 'active',
] : null,
'mix' => $list,
'source' => $src,
'gm_total' => $gmTot,
'gm_missing' => $missing,
];
}
}
return $out;
}
/**
* Kg di mescola per una quantità venduta.
* - Metri: kg = metri × g/m ÷ 1000, mescola per mescola.
* - Chili: i kg si dividono tra le mescole in proporzione ai g/m; senza g/m in parti uguali (2 → 50/50, 3 → 33%).
* - Pezzi: in attesa della conversione pezzi → metri.
* status: ok | gm_part (g/m mancante su qualche mescola) | gm (nessun g/m) | none (nessuna mescola) | pz | altro
*/
public static function mixKg(?array $info, string $kind, float $qty): array
{
if ($kind === 'pz') return ['status' => 'pz', 'kg' => []];
if ($kind === 'altro') return ['status' => 'altro', 'kg' => []];
if (!$info || !$info['mix']) return ['status' => 'none', 'kg' => []];
$kg = [];
if ($kind === 'm') {
if ($info['gm_total'] <= 0) return ['status' => 'gm', 'kg' => []];
foreach ($info['mix'] as $x) {
if ($x['gm'] > 0) $kg[$x['id']] = ($kg[$x['id']] ?? 0) + $qty * $x['gm'] / 1000;
}
return ['status' => $info['gm_missing'] ? 'gm_part' : 'ok', 'kg' => $kg];
}
$n = count($info['mix']);
foreach ($info['mix'] as $x) {
$share = $info['gm_total'] > 0 ? max(0.0, (float)$x['gm']) / $info['gm_total'] : 1 / $n;
$kg[$x['id']] = ($kg[$x['id']] ?? 0) + $qty * $share;
}
return ['status' => ($info['gm_total'] > 0 && $info['gm_missing']) ? 'gm_part' : 'ok', 'kg' => $kg];
}
/* ============================================================
* COSTO GREZZO (solo mescola)
* ============================================================ */
/** Prezzi €/kg delle mescole (vuoto se gli acquisti non sono ancora sincronizzati) */
public static function mixPrices(PDO $pdo): array
{
try {
return MagoMixPurchases::prices($pdo);
} catch (Throwable $e) {
return [];
}
}
/**
* Costo della mescola per unità venduta.
* - Metri: €/m = somma di g/m × €/kg ÷ 1000 per ogni mescola della scheda di lavoro.
* - Chili: €/kg = media dei €/kg delle mescole pesata sui g/m (in parti uguali se mancano i g/m).
* - Pezzi: non calcolabile (manca la conversione pezzi → metri).
* $ym: costo in un mese passato (prezzi di quel mese, altrimenti l'ultimo noto prima).
* status: ok | partial (manca g/m o prezzo di qualche mescola: costo sottostimato) | price (nessun prezzo) | gm | none | pz | altro
*/
public static function costOf(?array $mi, string $kind, array $prices, ?string $ym = null): array
{
$out = ['status' => 'none', 'cost' => null, 'parts' => [], 'missing' => [], 'check' => []];
if ($kind === 'pz' || $kind === 'altro') return ['status' => $kind] + $out;
if (!$mi || !$mi['mix']) return $out;
if ($kind === 'm' && $mi['gm_total'] <= 0) return ['status' => 'gm'] + $out;
$n = count($mi['mix']);
$cost = 0.0;
$priced = 0;
$gmMissing = false;
foreach ($mi['mix'] as $x) {
$p = $prices[$x['id']] ?? null;
$eur = $p ? ($ym ? MagoMixPurchases::priceAt($p, $ym) : $p['price']) : null;
$gm = $x['gm'] !== null && $x['gm'] > 0 ? (float)$x['gm'] : null;
if ($gm === null) $gmMissing = true;
if ($kind === 'm') {
$part = ($gm !== null && $eur !== null) ? $gm / 1000 * $eur : null;
} else {
$share = $mi['gm_total'] > 0 ? ($gm ?? 0) / $mi['gm_total'] : 1 / $n;
$part = $eur !== null ? $share * $eur : null;
}
if ($eur === null) $out['missing'][] = $x['name'];
if ($part !== null) {
$cost += $part;
$priced++;
}
if ($p && $p['check']) $out['check'][$p['check']] = $p['check'];
$out['parts'][] = [
'id' => $x['id'],
'name' => $x['name'],
'gm' => $gm,
'eur_kg' => $eur !== null ? round($eur, 4) : null,
'part' => $part !== null ? round($part, 4) : null,
'old' => $p ? $p['old'] : null,
'last_date' => $p['last_date'] ?? null,
'supplier' => $p['last_supplier'] ?? null,
'check' => $p['check'] ?? null,
];
}
$out['check'] = array_values($out['check']);
if ($priced === 0) return ['status' => 'price'] + $out;
$out['cost'] = round($cost, 4);
$out['status'] = ($out['missing'] || ($kind === 'm' && $gmMissing) || ($kind === 'kg' && $mi['gm_total'] > 0 && $gmMissing)) ? 'partial' : 'ok';
return $out;
}
/* ============================================================
* DATI
* ============================================================ */
/** Righe ordine con Matrice/Misura: dal giorno $lo oppure ancora da consegnare */
public static function fetchLines(PDO $pdo, string $lo, array $customers = [], ?array $raws = null, ?string $uom = null): array
{
if ($raws !== null && !$raws) return [];
$from = MagoSalesStats::baseFrom();
$p = ['lo' => $lo];
$w = [
"x.status <> 'annullato'",
"d.cancelled = 0",
"d.matrice_misura <> ''",
"d.qty > " . (float)self::MIN_QTY,
"(x.order_date >= :lo OR (d.delivered = 0 AND x.status <> 'evaso'))",
];
foreach (self::EXCLUDED_ITEM_PREFIXES as $i => $pre) {
$w[] = "d.item NOT LIKE :ex$i";
$p["ex$i"] = $pre . '%';
}
if ($customers) $w[] = "x.customer IN (" . self::inList('cu', $customers, $p) . ")";
if ($raws !== null) $w[] = "d.matrice_misura IN (" . self::inList('mm', $raws, $p) . ")";
if ($uom !== null) {
$w[] = "UPPER(TRIM(d.uom)) = :uom";
$p['uom'] = $uom;
}
$sql = "SELECT d.sale_ord_id, d.sub_id, d.matrice_misura, d.uom, d.item, d.description, d.qty, d.delivered_qty, d.delivered,
CASE WHEN d.taxable_amount <> 0 THEN d.taxable_amount ELSE d.qty * d.unit_value - d.discount_amount END AS value,
x.order_date, x.customer, x.internal_ord_no, x.status
FROM mago_sale_ord_detail d
JOIN $from ON x.sale_ord_id = d.sale_ord_id
WHERE " . implode(' AND ', $w) . "
ORDER BY x.order_date, d.sale_ord_id, d.sub_id";
return MagoSalesStats::fetchAllQ($pdo, $sql, $p);
}
public static function customerNames(PDO $pdo, array $codes): array
{
$codes = array_values(array_unique(array_filter($codes, fn($c) => $c !== '')));
$out = [];
foreach (array_chunk($codes, 500) as $chunk) {
$p = [];
$in = self::inList('c', $chunk, $p);
$rows = MagoSalesStats::fetchAllQ($pdo, "SELECT cust_supp AS code, company_name AS name FROM mago_cust_supp
WHERE cust_supp_type = " . MagoSalesStats::CUSTOMER_TYPE . " AND cust_supp IN ($in)", $p);
foreach ($rows as $r) $out[$r['code']] = $r['name'] ?: $r['code'];
}
return $out;
}
private static function lineIsOpen(array $l): float
{
if ((int)$l['delivered'] !== 0 || $l['status'] === 'evaso') return 0.0;
return max(0.0, (float)$l['qty'] - (float)$l['delivered_qty']);
}
/* ============================================================
* CLASSIFICA MATRICI + KPI + MESCOLE
* ============================================================ */
public static function buildList(PDO $pdo, string $from, string $to, array $customers = []): array
{
$d12 = date('Y-m-d', strtotime('-365 days'));
$d24 = date('Y-m-d', strtotime('-730 days'));
$lines = self::fetchLines($pdo, min($from, $d24), $customers);
$ids = [];
foreach ($lines as $i => $l) {
$lines[$i]['_r'] = self::resolve($pdo, $l['matrice_misura']);
if ($lines[$i]['_r']['id']) $ids[] = $lines[$i]['_r']['id'];
}
$mix = self::mixInfo($pdo, $ids);
$ix = self::index($pdo);
$prices = self::mixPrices($pdo);
$spark = [];
for ($i = 11; $i >= 0; $i--) $spark[] = date('Y-m', strtotime("first day of -$i months"));
$sparkPos = array_flip($spark);
$agg = [];
$mixTot = [];
$mixMonth = [];
$mixNames = [];
$kpiClients = [];
$kpi = [
'value' => 0,
'lines' => 0,
'qty_m' => 0,
'val_m' => 0,
'pq_m' => 0,
'qty_kg' => 0,
'val_kg' => 0,
'pq_kg' => 0,
'pz_lines' => 0,
'pz_value' => 0,
'pz_qty' => 0,
'kg_mix' => 0,
'kg_open' => 0,
'open_value' => 0,
'linked_value' => 0,
'profile_value' => 0,
'unlinked_value' => 0,
'gm_missing_lines' => 0,
'gm_missing_value' => 0,
'matrici' => 0,
'cost_value' => 0,
'cost_cover_value' => 0,
'cost_rows' => 0,
'cost_partial_rows' => 0,
'check_rows' => 0,
];
$linkedIds = [];
foreach ($lines as $l) {
$r = $l['_r'];
$uom = self::norm($l['uom']);
$kind = self::uomKind($uom);
$key = $r['key'] . '|' . $uom;
$qty = (float)$l['qty'];
$val = (float)$l['value'];
$date = (string)$l['order_date'];
$ym = substr($date, 0, 7);
$inP = $date >= $from && $date <= $to;
$res = self::lineIsOpen($l);
$mi = $r['id'] ? ($mix[$r['id']] ?? null) : null;
if (!isset($agg[$key])) {
$agg[$key] = [
'key' => $key,
'rkey' => $r['key'],
'id' => $r['id'],
'cat' => $r['cat'],
'type' => $r['type'],
'name' => $r['label'],
'code' => $r['code'],
'photo' => $r['id'] ? ($ix['info'][$r['id']]['photo'] ?? null) : null,
'uom' => $uom,
'kind' => $kind,
'raws' => [],
'n' => 0,
'qty' => 0,
'value' => 0,
'pq' => 0,
'orders' => [],
'clients' => [],
'pmin' => null,
'pmax' => null,
'last_date' => '',
'last_price' => null,
'v12' => 0,
'q12' => 0,
'vp' => 0,
'qp' => 0,
'sv' => array_fill(0, 12, 0),
'sq' => array_fill(0, 12, 0),
'kg' => 0,
'open_qty' => 0,
'open_value' => 0,
'open_kg' => 0,
'mix_status' => self::mixKg($mi, $kind, 1)['status'],
'mix_n' => $mi ? count($mi['mix']) : 0,
'gm_total' => $mi ? $mi['gm_total'] : 0,
];
}
$a = &$agg[$key];
$a['raws'][self::norm($l['matrice_misura'])] = 1;
if ($inP) {
$a['n']++;
$a['qty'] += $qty;
$a['value'] += $val;
$a['orders'][$l['sale_ord_id']] = 1;
$a['clients'][$l['customer']] = 1;
$kpiClients[$l['customer']] = 1;
if ($val > 0) {
$p = $val / $qty;
$a['pq'] += $qty;
$a['pmin'] = $a['pmin'] === null ? $p : min($a['pmin'], $p);
$a['pmax'] = $a['pmax'] === null ? $p : max($a['pmax'], $p);
if ($date >= $a['last_date']) {
$a['last_date'] = $date;
$a['last_price'] = $p;
}
}
$mk = self::mixKg($mi, $kind, $qty);
foreach ($mk['kg'] as $mid => $kg) {
$a['kg'] += $kg;
$kpi['kg_mix'] += $kg;
$mixTot[$mid]['kg'] = ($mixTot[$mid]['kg'] ?? 0) + $kg;
$mixTot[$mid]['mat'][$r['id']] = 1;
$mixMonth[$mid][$ym] = ($mixMonth[$mid][$ym] ?? 0) + $kg;
}
$kpi['value'] += $val;
$kpi['lines']++;
if (in_array($r['type'], self::LINKED_TYPES, true)) {
$kpi['linked_value'] += $val;
$linkedIds[$r['id']] = 1;
} elseif ($r['type'] === 'profilo') {
$kpi['profile_value'] += $val;
} else {
$kpi['unlinked_value'] += $val;
}
if ($kind === 'm') {
$kpi['qty_m'] += $qty;
if ($val > 0) {
$kpi['val_m'] += $val;
$kpi['pq_m'] += $qty;
}
} elseif ($kind === 'kg') {
$kpi['qty_kg'] += $qty;
if ($val > 0) {
$kpi['val_kg'] += $val;
$kpi['pq_kg'] += $qty;
}
} elseif ($kind === 'pz') {
$kpi['pz_lines']++;
$kpi['pz_value'] += $val;
$kpi['pz_qty'] += $qty;
}
if (in_array($mk['status'], ['gm', 'gm_part', 'none'], true) && $r['id']) {
$kpi['gm_missing_lines']++;
$kpi['gm_missing_value'] += $val;
}
}
if ($val > 0) {
if ($date > $d12) {
$a['v12'] += $val;
$a['q12'] += $qty;
} elseif ($date > $d24) {
$a['vp'] += $val;
$a['qp'] += $qty;
}
if (isset($sparkPos[$ym])) {
$a['sv'][$sparkPos[$ym]] += $val;
$a['sq'][$sparkPos[$ym]] += $qty;
}
}
if ($res > 0) {
$ov = $qty > 0 ? $val * $res / $qty : 0;
$a['open_qty'] += $res;
$a['open_value'] += $ov;
$kpi['open_value'] += $ov;
foreach (self::mixKg($mi, $kind, $res)['kg'] as $mid => $kg) {
$a['open_kg'] += $kg;
$kpi['kg_open'] += $kg;
$mixTot[$mid]['kg_open'] = ($mixTot[$mid]['kg_open'] ?? 0) + $kg;
$mixTot[$mid]['mat'][$r['id']] = 1;
}
}
unset($a);
if ($mi) foreach ($mi['mix'] as $x) $mixNames[$x['id']] = $x['name'];
}
$rows = [];
foreach ($agg as $a) {
if ($a['n'] === 0 && $a['open_qty'] <= 0) continue;
$price = $a['pq'] > 0 ? $a['value'] / $a['pq'] : null;
$p12 = $a['q12'] > 0 ? $a['v12'] / $a['q12'] : null;
$pp = $a['qp'] > 0 ? $a['vp'] / $a['qp'] : null;
$sp = [];
for ($i = 0; $i < 12; $i++) $sp[] = $a['sq'][$i] > 0 ? round($a['sv'][$i] / $a['sq'][$i], 4) : null;
$c = self::costOf($a['id'] ? ($mix[$a['id']] ?? null) : null, $a['kind'], $prices);
$margin = ($c['cost'] !== null && $price) ? $price - $c['cost'] : null;
if ($c['cost'] !== null && $price && $a['n'] > 0) {
if ($c['status'] === 'ok') {
$kpi['cost_value'] += $a['pq'] * $c['cost'];
$kpi['cost_cover_value'] += $a['value'];
$kpi['cost_rows']++;
} else {
$kpi['cost_partial_rows']++;
}
if ($c['check']) $kpi['check_rows']++;
}
$rows[] = [
'key' => $a['key'],
'id' => $a['id'],
'cat' => $a['cat'],
'type' => $a['type'],
'name' => $a['name'],
'code' => $a['code'],
'photo' => $a['photo'],
'uom' => $a['uom'],
'kind' => $a['kind'],
'raws' => array_keys($a['raws']),
'n' => $a['n'],
'orders' => count($a['orders']),
'clients' => count($a['clients']),
'qty' => round($a['qty'], 3),
'value' => round($a['value'], 2),
'price' => $price !== null ? round($price, 4) : null,
'pmin' => $a['pmin'] !== null ? round($a['pmin'], 4) : null,
'pmax' => $a['pmax'] !== null ? round($a['pmax'], 4) : null,
'spread' => ($a['pmin'] > 0 && $a['pmax'] !== null) ? round(($a['pmax'] - $a['pmin']) / $a['pmin'] * 100, 1) : null,
'last_date' => $a['last_date'] ?: null,
'last_price' => $a['last_price'] !== null ? round($a['last_price'], 4) : null,
'price12' => $p12 !== null ? round($p12, 4) : null,
'pricep' => $pp !== null ? round($pp, 4) : null,
'var' => ($p12 !== null && $pp) ? round(($p12 - $pp) / $pp * 100, 1) : null,
'spark' => $sp,
'kg' => round($a['kg'], 1),
'open_qty' => round($a['open_qty'], 3),
'open_value' => round($a['open_value'], 2),
'open_kg' => round($a['open_kg'], 1),
'mix_status' => $a['mix_status'],
'mix_n' => $a['mix_n'],
'gm_total' => $a['gm_total'],
'cost' => $c['cost'],
'cost_status' => $c['status'],
'cost_missing' => $c['missing'],
'cost_check' => $c['check'],
'margin' => $margin !== null ? round($margin, 4) : null,
'margin_pct' => $margin !== null ? round($margin / $price * 100, 1) : null,
];
}
usort($rows, fn($x, $y) => $y['value'] <=> $x['value']);
$mescole = [];
foreach ($mixTot as $mid => $m) {
$mescole[] = [
'id' => $mid,
'name' => $mixNames[$mid] ?? ('#' . $mid),
'kg' => round($m['kg'] ?? 0, 1),
'kg_open' => round($m['kg_open'] ?? 0, 1),
'matrici' => count($m['mat'] ?? []),
'months' => array_map(fn($v) => round($v, 1), $mixMonth[$mid] ?? []),
];
}
usort($mescole, fn($x, $y) => $y['kg'] <=> $x['kg']);
$kpi['matrici'] = count($linkedIds);
$kpi['clients'] = count($kpiClients);
foreach ($kpi as $k => $v) if (is_float($v)) $kpi[$k] = round($v, 2);
return [
'success' => true,
'from' => $from,
'to' => $to,
'today' => date('Y-m-d'),
'spark_months' => $spark,
'kpi' => $kpi,
'rows' => $rows,
'mescole' => $mescole,
'types' => self::TYPE_LABELS,
'cats' => self::CAT_LABELS,
'price_months' => MagoMixPurchases::PRICE_MONTHS,
'has_prices' => !empty($prices),
];
}
/* ============================================================
* DETTAGLIO DI UNA MATRICE (in una unità di misura)
* ============================================================ */
public static function buildDetail(PDO $pdo, string $key, string $from, string $to, array $customers = []): array
{
$pos = strrpos($key, '|');
if ($pos === false) return ['success' => false, 'message' => 'Matrice non indicata'];
$rkey = substr($key, 0, $pos);
$uom = substr($key, $pos + 1);
// valori Mago che portano a questa matrice
$raws = [];
$first = null;
foreach ($pdo->query("SELECT DISTINCT matrice_misura FROM mago_sale_ord_detail WHERE matrice_misura <> ''")->fetchAll(PDO::FETCH_COLUMN) as $raw) {
$r = self::resolve($pdo, $raw);
if ($r['key'] === $rkey) {
$raws[] = $raw;
if (!$first) $first = $r;
}
}
if (!$raws) return ['success' => false, 'message' => 'Nessuna riga d\'ordine per questa matrice'];
$kind = self::uomKind($uom);
$lines = self::fetchLines($pdo, $from, $customers, $raws, $uom);
$ix = self::index($pdo);
$mi = $first['id'] ? (self::mixInfo($pdo, [$first['id']])[$first['id']] ?? null) : null;
$prices = self::mixPrices($pdo);
$costNow = self::costOf($mi, $kind, $prices);
$prodLines = [];
if ($first['id']) {
$st = $pdo->prepare("SELECT pl.name, pl.color FROM matrici_lines ml JOIN production_lines pl ON pl.id = ml.idlinea WHERE ml.idmatrice = ? ORDER BY pl.line_number");
$st->execute([$first['id']]);
$prodLines = $st->fetchAll(PDO::FETCH_ASSOC);
}
$names = self::customerNames($pdo, array_column($lines, 'customer'));
$cl = [];
$months = [];
$out = [];
$mixKgP = [];
$mixKgO = [];
$tot = ['n' => 0, 'qty' => 0, 'value' => 0, 'pq' => 0, 'kg' => 0, 'open_qty' => 0, 'open_kg' => 0, 'pmin' => null, 'pmax' => null];
$d12 = date('Y-m-d', strtotime('-365 days'));
$d24 = date('Y-m-d', strtotime('-730 days'));
foreach ($lines as $l) {
$qty = (float)$l['qty'];
$val = (float)$l['value'];
$date = (string)$l['order_date'];
$inP = $date >= $from && $date <= $to;
$res = self::lineIsOpen($l);
$price = ($val > 0 && $qty > 0) ? $val / $qty : null;
$c = $l['customer'];
if ($res > 0) {
$tot['open_qty'] += $res;
foreach (self::mixKg($mi, $kind, $res)['kg'] as $mid => $kg) {
$mixKgO[$mid] = ($mixKgO[$mid] ?? 0) + $kg;
$tot['open_kg'] += $kg;
}
}
if (!$inP) continue;
$kgLine = 0;
foreach (self::mixKg($mi, $kind, $qty)['kg'] as $mid => $kg) {
$mixKgP[$mid] = ($mixKgP[$mid] ?? 0) + $kg;
$kgLine += $kg;
}
$tot['n']++;
$tot['qty'] += $qty;
$tot['value'] += $val;
$tot['kg'] += $kgLine;
if (!isset($cl[$c])) {
$cl[$c] = [
'code' => $c,
'name' => $names[$c] ?? $c,
'n' => 0,
'qty' => 0,
'value' => 0,
'pq' => 0,
'first_date' => null,
'first_price' => null,
'last_date' => null,
'last_price' => null,
'v12' => 0,
'q12' => 0,
'vp' => 0,
'qp' => 0,
'open_qty' => 0
];
}
$x = &$cl[$c];
$x['n']++;
$x['qty'] += $qty;
$x['value'] += $val;
$x['open_qty'] += $res;
if ($price !== null) {
$tot['pq'] += $qty;
$tot['pmin'] = $tot['pmin'] === null ? $price : min($tot['pmin'], $price);
$tot['pmax'] = $tot['pmax'] === null ? $price : max($tot['pmax'], $price);
$x['pq'] += $qty;
if ($x['first_date'] === null) {
$x['first_date'] = $date;
$x['first_price'] = $price;
}
$x['last_date'] = $date;
$x['last_price'] = $price;
if ($date > $d12) {
$x['v12'] += $val;
$x['q12'] += $qty;
} elseif ($date > $d24) {
$x['vp'] += $val;
$x['qp'] += $qty;
}
$ym = substr($date, 0, 7);
$months[$ym]['v'] = ($months[$ym]['v'] ?? 0) + $val;
$months[$ym]['q'] = ($months[$ym]['q'] ?? 0) + $qty;
}
unset($x);
$out[] = [
'date' => $date,
'ord' => $l['internal_ord_no'],
'customer' => $c,
'cname' => $names[$c] ?? $c,
'item' => $l['item'],
'description' => $l['description'],
'raw' => $l['matrice_misura'],
'qty' => round($qty, 3),
'value' => round($val, 2),
'price' => $price !== null ? round($price, 4) : null,
'open' => round($res, 3),
'kg' => round($kgLine, 1),
];
}
$avg = $tot['pq'] > 0 ? $tot['value'] / $tot['pq'] : null;
$clients = [];
foreach ($cl as $x) {
$p = $x['pq'] > 0 ? $x['value'] / $x['pq'] : null;
$p12 = $x['q12'] > 0 ? $x['v12'] / $x['q12'] : null;
$pp = $x['qp'] > 0 ? $x['vp'] / $x['qp'] : null;
$clients[] = [
'code' => $x['code'],
'name' => $x['name'],
'n' => $x['n'],
'qty' => round($x['qty'], 3),
'value' => round($x['value'], 2),
'price' => $p !== null ? round($p, 4) : null,
'dev' => ($p !== null && $avg) ? round(($p - $avg) / $avg * 100, 1) : null,
'first_date' => $x['first_date'],
'first_price' => $x['first_price'] !== null ? round($x['first_price'], 4) : null,
'last_date' => $x['last_date'],
'last_price' => $x['last_price'] !== null ? round($x['last_price'], 4) : null,
'var' => ($p12 !== null && $pp) ? round(($p12 - $pp) / $pp * 100, 1) : null,
'open_qty' => round($x['open_qty'], 3),
'margin_pct' => ($p && $costNow['cost'] !== null) ? round(($p - $costNow['cost']) / $p * 100, 1) : null,
'last_margin_pct' => ($x['last_price'] && $costNow['cost'] !== null) ? round(($x['last_price'] - $costNow['cost']) / $x['last_price'] * 100, 1) : null,
];
}
usort($clients, fn($a, $b) => $b['value'] <=> $a['value']);
ksort($months);
$mrows = [];
foreach ($months as $ym => $m) $mrows[] = ['ym' => $ym, 'price' => $m['q'] > 0 ? round($m['v'] / $m['q'], 4) : null, 'qty' => round($m['q'], 3)];
// costo mese per mese (prezzi mescole di quel mese) dal primo mese con vendite a oggi
$costMonths = [];
if ($costNow['cost'] !== null) {
$start = $mrows ? $mrows[0]['ym'] : substr($from, 0, 7);
for ($t = strtotime($start . '-01'); date('Y-m', $t) <= date('Y-m'); $t = strtotime('+1 month', $t)) {
$ym = date('Y-m', $t);
$c = self::costOf($mi, $kind, $prices, $ym);
if ($c['cost'] !== null) $costMonths[] = ['ym' => $ym, 'cost' => $c['cost']];
}
}
$mixRows = [];
if ($mi) {
foreach ($mi['mix'] as $x) {
$share = $mi['gm_total'] > 0 ? ($x['gm'] > 0 ? $x['gm'] / $mi['gm_total'] : 0) : (count($mi['mix']) ? 1 / count($mi['mix']) : 0);
$mixRows[] = [
'id' => $x['id'],
'name' => $x['name'],
'out' => $x['out'],
'gm' => $x['gm'],
'share' => round($share * 100, 1),
'kg' => round($mixKgP[$x['id']] ?? 0, 1),
'kg_open' => round($mixKgO[$x['id']] ?? 0, 1),
];
}
}
$status = self::mixKg($mi, $kind, 1)['status'];
return [
'success' => true,
'key' => $key,
'head' => [
'id' => $first['id'],
'cat' => $first['cat'],
'cat_label' => self::CAT_LABELS[$first['cat']] ?? $first['cat'],
'type' => $first['type'],
'type_label' => self::TYPE_LABELS[$first['type']] ?? $first['type'],
'name' => $first['label'],
'code' => $first['code'],
'photo' => $first['id'] ? ($ix['info'][$first['id']]['photo'] ?? null) : null,
'uom' => $uom,
'kind' => $kind,
'raws' => array_values(array_unique(array_map([self::class, 'norm'], $raws))),
'lines' => $prodLines,
'ws' => $mi['ws'] ?? null,
'mix_source' => $mi['source'] ?? 'nessuna',
'gm_total' => $mi['gm_total'] ?? 0,
'mix_status' => $status,
],
'cost' => $costNow + [
'months' => $costMonths,
'price_months' => MagoMixPurchases::PRICE_MONTHS,
'margin_pct' => ($avg && $costNow['cost'] !== null) ? round(($avg - $costNow['cost']) / $avg * 100, 1) : null
],
'tot' => [
'n' => $tot['n'],
'qty' => round($tot['qty'], 3),
'value' => round($tot['value'], 2),
'price' => $avg !== null ? round($avg, 4) : null,
'pmin' => $tot['pmin'] !== null ? round($tot['pmin'], 4) : null,
'pmax' => $tot['pmax'] !== null ? round($tot['pmax'], 4) : null,
'kg' => round($tot['kg'], 1),
'open_qty' => round($tot['open_qty'], 3),
'open_kg' => round($tot['open_kg'], 1),
'clients' => count($clients),
],
'clients' => $clients,
'months' => $mrows,
'mix' => $mixRows,
'lines' => array_reverse($out),
];
}
}
+427
View File
@@ -0,0 +1,427 @@
<?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;
}
}
+562
View File
@@ -0,0 +1,562 @@
<?php
/**
* MagoProduction - ponte tra le righe d'ordine Mago e la programmazione produzione.
*
* MagoProduction::refreshQueue($pdo) allinea la coda "Nuovi da Mago" (dopo ogni sync)
* MagoProduction::import($pdo, $id, $data) porta una riga della coda in productiondata
*
* Mago resta in sola lettura: qui si legge solo dalle tabelle locali mago_*.
*/
require_once __DIR__ . '/MagoSalesStats.php';
class MagoProduction
{
/* ===================== IMPOSTAZIONI ===================== */
/** Righe d'ordine considerate: solo ordini da questa data in poi (per partire con poche righe di prova). */
const QUEUE_FROM_DATE = '2026-10-01';
/** Produzione solo se la quantità è maggiore di questo valore (1 o 0 = servizio, indicazione del cliente). */
const MIN_QTY = 1;
/** Articoli che non si producono mai (prefissi del codice articolo Mago). */
const EXCLUDED_ITEM_PREFIXES = ['SPES', 'SPATT'];
/** Import automatico in programmazione delle righe con matrice certa e linea unica (false = solo su conferma). */
const AUTO_IMPORT = false;
/** Status di productiondata assegnato all'import. */
const STATUS_DA_PROGRAMMARE = 1;
const STATUS_PROGRAMMATO = 6;
/** Etichette dei tipi di Matrice/Misura. */
const TIPI = [
'M' => 'Matrice',
'D' => 'Diametro',
'R' => 'Rettangolo',
'T' => 'Tubo',
'Q' => 'Quadrato',
'S' => 'Sezione',
'?' => 'Altro',
];
/* ===================== MATRICE / MISURA ===================== */
/**
* Scompone il valore "Matrice/Misura" di Mago.
* M00786S → tipo M, codice 00786S
* M00407P-1 → tipo M, codice 00407P-1
* Q8X8M00936P → tipo M, codice 00936P, misura 8X8 (profilo Q)
* D 3,3 → tipo D, misura 3,3
* ZB00193P → tipo ?, codice ZB00193P (si cerca il valore intero tra le matrici)
*/
public static function parse($raw)
{
$v = strtoupper(trim(preg_replace('/\s+/', ' ', (string)$raw)));
$out = ['raw' => $v, 'tipo' => '', 'codice' => '', 'misura' => ''];
if ($v === '') return $out;
if (preg_match('/M(\d{4,6}[A-Z]?(?:-\d+)?)/', $v, $m, PREG_OFFSET_CAPTURE)) {
$out['tipo'] = 'M';
$out['codice'] = $m[1][0];
$before = trim(substr($v, 0, $m[0][1]));
if (preg_match('/^[DRTQS]\s*([0-9][0-9.,X ]*)$/', $before, $mm)) $out['misura'] = trim($mm[1]);
return $out;
}
if (preg_match('/^([DRTQS])\s*([0-9][0-9.,X ]*)$/', $v, $m)) {
$out['tipo'] = $m[1];
$out['misura'] = trim($m[2]);
return $out;
}
$out['tipo'] = '?';
$out['codice'] = $v;
return $out;
}
/** Indice delle matrici del portale per il collegamento (caricato una volta). */
private static function matriceIndex(PDO $pdo)
{
$idx = ['exact' => [], 'base' => [], 'num' => []];
foreach ($pdo->query("SELECT id, nome FROM matrice WHERE TRIM(nome) <> ''") as $r) {
$tok = strtoupper(strtok(trim($r['nome']), ' '));
if ($tok === '' || $tok === false) continue;
$idx['exact'][$tok] = ['id' => (int)$r['id'], 'nome' => trim($r['nome'])];
$base = preg_replace('/-\d+$/', '', $tok);
$idx['base'][$base][] = ['id' => (int)$r['id'], 'nome' => trim($r['nome']), 'tok' => $tok];
if (preg_match('/^(\d+)/', $tok, $n)) $idx['num'][(int)$n[1]][] = trim($r['nome']);
}
foreach ($idx['base'] as &$list) {
usort($list, function ($a, $b) {
return strnatcmp($b['tok'], $a['tok']); // revisione più alta per prima
});
}
unset($list);
$idx['map'] = [];
try {
foreach ($pdo->query("SELECT codice_mago, idmatrice FROM matrice_mago_map") as $r) {
$idx['map'][strtoupper(trim($r['codice_mago']))] = (int)$r['idmatrice'];
}
} catch (Throwable $e) {
}
return $idx;
}
/**
* Trova la matrice del portale per un valore Matrice/Misura.
* Ritorna [idmatrice|null, match_status, match_info, parse]
*/
public static function resolve(array $idx, $raw)
{
$p = self::parse($raw);
if ($p['raw'] === '') return [null, 'anomalia', 'Matrice/Misura vuoto in Mago', $p];
// 0) corrispondenza manuale
if (isset($idx['map'][$p['raw']])) return [$idx['map'][$p['raw']], 'mappa', 'Corrispondenza salvata', $p];
if ($p['codice'] !== '') {
$c = $p['codice'];
// 1) esatta
if (isset($idx['exact'][$c])) return [$idx['exact'][$c]['id'], 'esatta', '', $p];
// 2) stessa matrice, altra revisione
$base = preg_replace('/-\d+$/', '', $c);
if (!empty($idx['base'][$base])) {
$best = $idx['base'][$base][0];
return [$best['id'], 'revisione', "Revisione diversa: Mago $c, anagrafica {$best['nome']}", $p];
}
// 3) stesso numero, lettera diversa: solo suggerimento
$sim = '';
if (preg_match('/^(\d+)/', $c, $n) && !empty($idx['num'][(int)$n[1]])) {
$sim = ' · simili: ' . implode(', ', array_slice($idx['num'][(int)$n[1]], 0, 3));
}
if ($p['tipo'] === 'M') return [null, 'da_creare', "Matrice $c non presente in anagrafica" . $sim, $p];
return [null, 'anomalia', "Codice $c non riconosciuto" . $sim, $p];
}
// profilo standard (D/R/T/Q/S + misura)
$label = self::TIPI[$p['tipo']] ?? $p['tipo'];
return [null, 'profilo', "$label {$p['misura']}: scegliere la matrice", $p];
}
/* ===================== CODA "NUOVI DA MAGO" ===================== */
/**
* Allinea la coda con le righe d'ordine Mago locali.
* Ritorna un piccolo report (nuove, aggiornate, chiuse, modificate dopo l'import, importate in automatico).
*/
public static function refreshQueue(PDO $pdo)
{
$rep = ['candidati' => 0, 'nuove' => 0, 'aggiornate' => 0, 'chiuse' => 0, 'modificate' => 0, 'auto_importate' => 0];
$idx = self::matriceIndex($pdo);
$excl = '';
foreach (self::EXCLUDED_ITEM_PREFIXES as $i => $pfx) {
$excl .= " AND d.item NOT LIKE " . $pdo->quote($pfx . '%');
}
$rows = MagoSalesStats::fetchAllQ($pdo, "
SELECT d.sale_ord_id, d.sub_id, d.line, d.item, d.description, d.uom, d.qty, d.delivered_qty,
d.notes, d.matrice_misura, d.cod_art_cli,
" . MagoSalesStats::ziboDate('d') . " AS zibo_date,
" . MagoSalesStats::clientDate('d') . " AS client_date,
x.internal_ord_no, x.order_date, x.customer, x.company_name
FROM mago_sale_ord_detail d
JOIN " . MagoSalesStats::baseFrom() . " ON x.sale_ord_id = d.sale_ord_id
WHERE x.order_date >= :from
AND x.status IN ('aperto', 'bloccato')
AND d.delivered = 0 AND d.cancelled = 0
AND d.item <> '' AND d.qty > :minq
$excl
", ['from' => self::QUEUE_FROM_DATE, 'minq' => self::MIN_QTY]);
$rep['candidati'] = count($rows);
$existing = [];
foreach ($pdo->query("SELECT * FROM mago_prod_queue") as $q) {
$existing[$q['sale_ord_id'] . '-' . $q['sub_id']] = $q;
}
$ins = $pdo->prepare("
INSERT INTO mago_prod_queue (
sale_ord_id, sub_id, internal_ord_no, line_no, order_date, customer, company_name, item, description,
cod_art_cli, uom, qty, delivered_qty, residual_qty, zibo_date, client_date, mago_notes,
matrice_misura, mm_tipo, mm_codice, mm_misura, idmatrice, match_status, match_info,
queue_status, first_seen_at, updated_at
) VALUES (
:sale_ord_id, :sub_id, :internal_ord_no, :line_no, :order_date, :customer, :company_name, :item, :description,
:cod_art_cli, :uom, :qty, :delivered_qty, :residual_qty, :zibo_date, :client_date, :mago_notes,
:matrice_misura, :mm_tipo, :mm_codice, :mm_misura, :idmatrice, :match_status, :match_info,
'nuovo', NOW(), NOW()
)");
$upd = $pdo->prepare("
UPDATE mago_prod_queue SET
internal_ord_no = :internal_ord_no, line_no = :line_no, order_date = :order_date, customer = :customer,
company_name = :company_name, item = :item, description = :description, cod_art_cli = :cod_art_cli,
uom = :uom, qty = :qty, delivered_qty = :delivered_qty, residual_qty = :residual_qty,
zibo_date = :zibo_date, client_date = :client_date, mago_notes = :mago_notes,
matrice_misura = :matrice_misura, mm_tipo = :mm_tipo, mm_codice = :mm_codice, mm_misura = :mm_misura,
idmatrice = :idmatrice, match_status = :match_status, match_info = :match_info,
queue_status = :queue_status, mago_changed = :mago_changed, mago_changes = :mago_changes,
updated_at = NOW()
WHERE id = :id");
$seen = [];
$pdo->beginTransaction();
try {
foreach ($rows as $r) {
$key = $r['sale_ord_id'] . '-' . $r['sub_id'];
$seen[$key] = true;
[$idm, $ms, $mi, $p] = self::resolve($idx, $r['matrice_misura']);
$residual = max(0, (float)$r['qty'] - (float)$r['delivered_qty']);
$data = [
'internal_ord_no' => $r['internal_ord_no'],
'line_no' => (int)$r['line'],
'order_date' => $r['order_date'],
'customer' => $r['customer'],
'company_name' => mb_substr((string)$r['company_name'], 0, 200),
'item' => $r['item'],
'description' => mb_substr((string)$r['description'], 0, 255),
'cod_art_cli' => (string)$r['cod_art_cli'],
'uom' => $r['uom'],
'qty' => $r['qty'],
'delivered_qty' => $r['delivered_qty'],
'residual_qty' => $residual,
'zibo_date' => $r['zibo_date'],
'client_date' => $r['client_date'],
'mago_notes' => $r['notes'],
'matrice_misura' => (string)$r['matrice_misura'],
'mm_tipo' => $p['tipo'],
'mm_codice' => mb_substr($p['codice'], 0, 40),
'mm_misura' => mb_substr($p['misura'], 0, 40),
'idmatrice' => $idm,
'match_status' => $ms,
'match_info' => mb_substr($mi, 0, 255),
];
if (!isset($existing[$key])) {
$ins->execute($data + ['sale_ord_id' => $r['sale_ord_id'], 'sub_id' => $r['sub_id']]);
$rep['nuove']++;
continue;
}
$q = $existing[$key];
$status = $q['queue_status'];
$changed = (int)$q['mago_changed'];
$changes = $q['mago_changes'];
// scelta manuale della matrice: non la tocco
if ($q['match_status'] === 'manuale' && $q['idmatrice']) {
$data['idmatrice'] = $q['idmatrice'];
$data['match_status'] = 'manuale';
$data['match_info'] = $q['match_info'];
}
if ($status === 'importato') {
$diff = self::diff($q, $data);
if ($diff) {
$changed = 1;
$changes = mb_substr(trim(($changes ? $changes . ' | ' : '') . date('d/m') . ': ' . implode(', ', $diff)), -500);
$rep['modificate']++;
}
// dopo l'import la matrice resta quella scelta in produzione
$data['idmatrice'] = $q['idmatrice'];
$data['match_status'] = $q['match_status'];
$data['match_info'] = $q['match_info'];
} elseif ($status === 'chiuso_mago') {
$status = 'nuovo'; // riaperta in Mago
}
$upd->execute($data + ['queue_status' => $status, 'mago_changed' => $changed, 'mago_changes' => $changes, 'id' => $q['id']]);
$rep['aggiornate']++;
}
// righe non più "da produrre" in Mago (consegnate, annullate, cancellate, quantità ≤ 1...)
$why = $pdo->prepare("SELECT delivered, cancelled FROM mago_sale_ord_detail WHERE sale_ord_id = ? AND sub_id = ?");
$close = $pdo->prepare("UPDATE mago_prod_queue SET queue_status = :s, mago_changed = :c, mago_changes = :t, updated_at = NOW() WHERE id = :id");
foreach ($existing as $key => $q) {
if (isset($seen[$key]) || in_array($q['queue_status'], ['chiuso_mago'], true)) continue;
$why->execute([$q['sale_ord_id'], $q['sub_id']]);
$d = $why->fetch(PDO::FETCH_ASSOC);
$reason = !$d ? 'riga cancellata in Mago' : ((int)$d['cancelled'] ? 'riga annullata in Mago' : ((int)$d['delivered'] ? 'riga consegnata in Mago' : 'riga non più da produrre in Mago'));
if ($q['queue_status'] === 'importato') {
// già in produzione: segnalo solo se è un annullamento/cancellazione (la consegna è normale)
if (strpos($reason, 'consegnata') === false && strpos((string)$q['mago_changes'], $reason) === false) {
$close->execute(['s' => 'importato', 'c' => 1, 't' => mb_substr(trim(($q['mago_changes'] ? $q['mago_changes'] . ' | ' : '') . date('d/m') . ': ' . $reason), -500), 'id' => $q['id']]);
$rep['modificate']++;
}
continue;
}
if ($q['queue_status'] === 'nuovo' || $q['queue_status'] === 'scartato') {
$close->execute(['s' => 'chiuso_mago', 'c' => 0, 't' => $reason, 'id' => $q['id']]);
$rep['chiuse']++;
}
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
// import automatico (spento finché AUTO_IMPORT = false)
if (self::AUTO_IMPORT) {
$ids = $pdo->query("SELECT id FROM mago_prod_queue WHERE queue_status = 'nuovo' AND match_status IN ('esatta','mappa') AND idmatrice IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN);
foreach ($ids as $id) {
$pre = self::prefill($pdo, (int)$id);
if (!$pre || !$pre['id_linea']) continue; // linea non univoca: resta da completare a mano
try {
self::import($pdo, (int)$id, $pre, 'automatico');
$rep['auto_importate']++;
} catch (Throwable $e) {
}
}
}
return $rep;
}
/** Differenze rilevanti tra la riga in coda e la riga Mago aggiornata. */
private static function diff(array $q, array $d)
{
$out = [];
if (abs((float)$q['residual_qty'] - (float)$d['residual_qty']) > 0.0005) {
$out[] = 'quantità ' . self::num($q['residual_qty']) . ' → ' . self::num($d['residual_qty']) . ' ' . $d['uom'];
}
if ((string)$q['zibo_date'] !== (string)$d['zibo_date']) $out[] = 'data ZIBO ' . self::dt($q['zibo_date']) . ' → ' . self::dt($d['zibo_date']);
if ((string)$q['client_date'] !== (string)$d['client_date']) $out[] = 'data cliente ' . self::dt($q['client_date']) . ' → ' . self::dt($d['client_date']);
if (strtoupper(trim($q['matrice_misura'])) !== strtoupper(trim($d['matrice_misura']))) $out[] = 'matrice ' . $q['matrice_misura'] . ' → ' . $d['matrice_misura'];
return $out;
}
private static function num($v)
{
return rtrim(rtrim(number_format((float)$v, 3, ',', '.'), '0'), ',');
}
/** Riconosce la matrice di un valore Matrice/Misura (indice caricato una volta per richiesta). [idmatrice|null, match_status, match_info] */
public static function lookup(PDO $pdo, $raw)
{
static $idx = null;
if ($idx === null) {
try {
$idx = self::matriceIndex($pdo);
} catch (Throwable $e) {
$idx = ['exact' => [], 'base' => [], 'num' => [], 'map' => []];
}
}
[$id, $ms, $mi] = self::resolve($idx, $raw);
return [$id, $ms, $mi];
}
private static function dt($v)
{
return $v ? date('d/m/Y', strtotime($v)) : '—';
}
/* ===================== PRECOMPILAZIONE ===================== */
/** Dati tecnici di una matrice: linee, mescole e velocità dalla scheda di lavoro. */
public static function matriceInfo(PDO $pdo, $idmatrice)
{
$idmatrice = (int)$idmatrice;
$info = ['lines' => [], 'mescole' => [], 'speed_m_h' => null, 'speed_kg_h' => null, 'g_m' => null, 'worksheet' => null];
if (!$idmatrice) return $info;
$st = $pdo->prepare("SELECT idlinea FROM matrici_lines WHERE idmatrice = ? ORDER BY idlinea");
$st->execute([$idmatrice]);
$info['lines'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
$st = $pdo->prepare("SELECT id, worksheet_number, speed_expected_m_h, speed_expected_kg_h
FROM work_sheets WHERE idmatrice = ? AND worksheet_status = 'active'
ORDER BY worksheet_date DESC, id DESC LIMIT 1");
$st->execute([$idmatrice]);
if ($ws = $st->fetch(PDO::FETCH_ASSOC)) {
$info['worksheet'] = (int)$ws['worksheet_number'];
$info['speed_m_h'] = $ws['speed_expected_m_h'] !== null ? (float)$ws['speed_expected_m_h'] : null;
$info['speed_kg_h'] = $ws['speed_expected_kg_h'] !== null ? (float)$ws['speed_expected_kg_h'] : null;
$st = $pdo->prepare("SELECT idmescola, mix_weight_g_m FROM work_sheet_mescole WHERE worksheet_id = ? ORDER BY mix_position");
$st->execute([$ws['id']]);
$g = 0;
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $m) {
$info['mescole'][] = (int)$m['idmescola'];
$g += (float)$m['mix_weight_g_m'];
}
$info['g_m'] = $g > 0 ? $g : null;
}
if (!$info['mescole']) {
$st = $pdo->prepare("SELECT idmescola FROM matrici_mescole WHERE idmatrice = ?");
$st->execute([$idmatrice]);
$info['mescole'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
return $info;
}
/** Proposta dei campi di productiondata per una riga della coda. */
public static function prefill(PDO $pdo, $queueId, $idmatrice = null)
{
$st = $pdo->prepare("SELECT * FROM mago_prod_queue WHERE id = ?");
$st->execute([(int)$queueId]);
$q = $st->fetch(PDO::FETCH_ASSOC);
if (!$q) return null;
$idm = $idmatrice !== null ? (int)$idmatrice : (int)$q['idmatrice'];
$info = self::matriceInfo($pdo, $idm);
$uom = strtoupper(trim($q['uom']));
$qty = (float)$q['residual_qty'];
$metri = in_array($uom, ['MT', 'M', 'ML'], true) ? $qty : 0;
$ore = 0;
if ($metri > 0 && $info['speed_m_h'] > 0) $ore = $metri / $info['speed_m_h'];
elseif ($uom === 'KG' && $info['speed_kg_h'] > 0) $ore = $qty / $info['speed_kg_h'];
$note = [];
$note[] = "Da Mago: ordine {$q['internal_ord_no']} riga {$q['line_no']} · {$q['item']}" . ($q['cod_art_cli'] ? " · cod. cliente {$q['cod_art_cli']}" : '');
$note[] = 'Matrice/Misura Mago: ' . $q['matrice_misura'] . ($q['mm_misura'] !== '' ? ' (' . (self::TIPI[$q['mm_tipo']] ?? '') . ' ' . $q['mm_misura'] . ')' : '');
if ($metri <= 0) $note[] = 'Quantità ordinata: ' . self::num($qty) . ' ' . $q['uom'];
if ($metri > 0 && $info['g_m']) $note[] = 'Kg stimati: ' . self::num(round($metri * $info['g_m'] / 1000, 1)) . ' (scheda di lavoro ' . $info['worksheet'] . ')';
if (trim((string)$q['mago_notes']) !== '') $note[] = trim($q['mago_notes']);
return [
'queue' => $q,
'idmatrice' => $idm ?: null,
'id_linea' => count($info['lines']) === 1 ? $info['lines'][0] : null,
'lines' => $info['lines'],
'mescole' => $info['mescole'],
'metri' => round($metri, 2),
'kg_sp' => 0,
'kg_p' => 0,
'ore_previste' => round($ore, 2),
'data_zibo' => $q['zibo_date'],
'data_cliente' => $q['client_date'],
'conferma_ordine' => $q['internal_ord_no'],
'note_extra' => implode("\n", $note),
'id_status' => self::STATUS_DA_PROGRAMMARE,
'worksheet' => $info['worksheet'],
];
}
/* ===================== IMPORT IN PRODUZIONE ===================== */
/** Cliente del portale per un codice cliente Mago (lo crea se non esiste). */
public static function clientId(PDO $pdo, $code, $name)
{
$code = trim((string)$code);
$name = trim((string)$name) ?: $code;
if ($code === '') return null;
$st = $pdo->prepare("SELECT id FROM clients WHERE mago_code = ? LIMIT 1");
$st->execute([$code]);
if ($id = $st->fetchColumn()) return (int)$id;
$st = $pdo->prepare("SELECT id FROM clients WHERE (mago_code IS NULL OR mago_code = '') AND UPPER(TRIM(nome)) = UPPER(TRIM(?)) LIMIT 1");
$st->execute([$name]);
if ($id = $st->fetchColumn()) {
$pdo->prepare("UPDATE clients SET mago_code = ? WHERE id = ?")->execute([$code, $id]);
return (int)$id;
}
// nuovo cliente (il nome è unico: se esiste già con un altro codice aggiungo il codice)
$st = $pdo->prepare("SELECT COUNT(*) FROM clients WHERE nome = ?");
$st->execute([$name]);
$nome = $st->fetchColumn() ? "$name ($code)" : $name;
$pdo->prepare("INSERT INTO clients (nome, mago_code) VALUES (?, ?)")->execute([mb_substr($nome, 0, 255), $code]);
return (int)$pdo->lastInsertId();
}
/**
* Prossima posizione libera (in fondo) per uno stato della programmazione.
* La pagina numera le posizioni di tutte le linee in un'unica sequenza per stato,
* quindi MAX + 1 mette il lavoro in fondo anche alla sua linea.
*/
public static function nextPriority(PDO $pdo, $status)
{
$st = $pdo->prepare("SELECT COALESCE(MAX(priority), 0) + 1 FROM productiondata WHERE id_status = ?");
$st->execute([(int)$status]);
return (int)$st->fetchColumn();
}
/**
* Crea il lavoro in productiondata e collega la riga della coda.
* $d: idmatrice, id_linea, mescole[], metri, kg_sp, kg_p, ore_previste, data_zibo, data_cliente,
* note_extra, id_status, remember (salva la corrispondenza codice Mago → matrice)
*/
public static function import(PDO $pdo, $queueId, array $d, $user = '')
{
$st = $pdo->prepare("SELECT * FROM mago_prod_queue WHERE id = ? FOR UPDATE");
$own = !$pdo->inTransaction();
if ($own) $pdo->beginTransaction();
try {
$st->execute([(int)$queueId]);
$q = $st->fetch(PDO::FETCH_ASSOC);
if (!$q) throw new RuntimeException('Riga non trovata');
if ($q['queue_status'] === 'importato') throw new RuntimeException("Ordine {$q['internal_ord_no']} riga {$q['line_no']}: già in programmazione");
$idm = (int)($d['idmatrice'] ?? 0);
$lin = (int)($d['id_linea'] ?? 0);
if (!$idm) throw new RuntimeException("Ordine {$q['internal_ord_no']} riga {$q['line_no']}: manca la matrice");
if (!$lin) throw new RuntimeException("Ordine {$q['internal_ord_no']} riga {$q['line_no']}: manca la linea");
$status = (int)($d['id_status'] ?? self::STATUS_DA_PROGRAMMARE);
if (!in_array($status, [self::STATUS_DA_PROGRAMMARE, self::STATUS_PROGRAMMATO], true)) $status = self::STATUS_DA_PROGRAMMARE;
$idCliente = self::clientId($pdo, $q['customer'], $q['company_name']);
$date = function ($v) {
return MagoSalesStats::isValidDate((string)$v) ? $v : null;
};
$priority = self::nextPriority($pdo, $status);
$pdo->prepare("
INSERT INTO productiondata
(Data, data_produzione, conferma_ordine, idmatrice, id_linea, id_cliente, data_zibo, data_cliente,
metri, kg_sp, kg_p, ore_previste, note_extra, id_status, priority,
fermomacchina, `hour`, hourprod, kgprod, mtprod, scarto)
VALUES
(CURDATE(), NULL, :conferma, :idmatrice, :id_linea, :id_cliente, :data_zibo, :data_cliente,
:metri, :kg_sp, :kg_p, :ore, :note, :status, :priority,
0, '00:00:00', '00:00:00', 0, 0, 0)
")->execute([
'priority' => $priority,
'conferma' => mb_substr($q['internal_ord_no'], 0, 50),
'idmatrice' => $idm,
'id_linea' => $lin,
'id_cliente' => $idCliente,
'data_zibo' => $date($d['data_zibo'] ?? $q['zibo_date']),
'data_cliente' => $date($d['data_cliente'] ?? $q['client_date']),
'metri' => round((float)($d['metri'] ?? 0), 2),
'kg_sp' => round((float)($d['kg_sp'] ?? 0), 2),
'kg_p' => round((float)($d['kg_p'] ?? 0), 2),
'ore' => min(999.99, round((float)($d['ore_previste'] ?? 0), 2)),
'note' => (string)($d['note_extra'] ?? '') ?: null,
'status' => $status,
]);
$pid = (int)$pdo->lastInsertId();
$mescole = array_values(array_unique(array_filter(array_map('intval', (array)($d['mescole'] ?? [])))));
if ($mescole) {
$insM = $pdo->prepare("INSERT INTO productiondata_mescole (id_productiondata, id_mescola) VALUES (?, ?)");
foreach ($mescole as $m) $insM->execute([$pid, $m]);
}
// la scelta della matrice fatta a mano diventa la regola per le prossime righe con lo stesso codice
$matchStatus = $q['match_status'];
if ((int)$q['idmatrice'] !== $idm) $matchStatus = 'manuale';
if (!empty($d['remember']) && trim($q['matrice_misura']) !== '') {
$pdo->prepare("INSERT INTO matrice_mago_map (codice_mago, idmatrice, note, created_by, created_at)
VALUES (?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE idmatrice = VALUES(idmatrice), created_by = VALUES(created_by), created_at = NOW()")
->execute([strtoupper(trim($q['matrice_misura'])), $idm, 'da import ordine ' . $q['internal_ord_no'], mb_substr((string)$user, 0, 100)]);
$matchStatus = 'mappa';
}
$pdo->prepare("UPDATE mago_prod_queue SET queue_status = 'importato', productiondata_id = ?, idmatrice = ?,
match_status = ?, imported_at = NOW(), imported_by = ?, mago_changed = 0, mago_changes = '', updated_at = NOW()
WHERE id = ?")
->execute([$pid, $idm, $matchStatus, mb_substr((string)$user, 0, 100), $q['id']]);
if ($own) $pdo->commit();
return $pid;
} catch (Throwable $e) {
if ($own && $pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
}
+301
View File
@@ -0,0 +1,301 @@
<?php
/**
* Preventivo per matrice: costo della mescola, storico prezzi di vendita, preventivi salvati.
* Usa MagoMatriceStats (riconoscimento matrici, schede di lavoro, costo) e MagoMixPurchases (€/kg pagati).
*/
require_once __DIR__ . '/MagoMatriceStats.php';
class MagoQuote
{
/** Mesi di storico per prezzi e costo nel grafico */
const HISTORY_MONTHS = 24;
/** Ricerca matrici per la tendina (nome, con foto e indicazione della scheda di lavoro) */
public static function searchMatrici(PDO $pdo, string $q, int $limit = 30): array
{
$q = trim($q);
$p = [];
$w = '';
if ($q !== '') {
// cerco anche senza la M iniziale: "M00786S" trova "00786S"
$q2 = preg_replace('/^M(?=\d)/i', '', $q);
$w = "WHERE m.nome LIKE :q OR m.nome LIKE :q2";
$p['q'] = '%' . $q . '%';
$p['q2'] = '%' . $q2 . '%';
}
$st = $pdo->prepare("SELECT m.id, m.nome, m.photo,
(SELECT COUNT(*) FROM work_sheets ws JOIN work_sheet_mescole wm ON wm.worksheet_id = ws.id
WHERE ws.idmatrice = m.id AND wm.mix_weight_g_m > 0) AS n_gm
FROM matrice m $w
ORDER BY (m.nome LIKE :start) DESC, m.nome
LIMIT " . (int)$limit);
$p['start'] = ($q !== '' ? preg_replace('/^M(?=\d)/i', '', $q) : '') . '%';
$st->execute($p);
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[] = ['id' => (int)$r['id'], 'text' => $r['nome'], 'photo' => MagoMatriceStats::photoUrl($r['photo']), 'has_gm' => (int)$r['n_gm'] > 0];
}
return $out;
}
/** Valori Mago del campo Matrice/Misura che portano a questa matrice */
public static function rawsFor(PDO $pdo, int $id): array
{
$raws = [];
foreach ($pdo->query("SELECT DISTINCT matrice_misura FROM mago_sale_ord_detail WHERE matrice_misura <> ''")->fetchAll(PDO::FETCH_COLUMN) as $raw) {
if (MagoMatriceStats::resolve($pdo, $raw)['id'] === $id) $raws[] = $raw;
}
return $raws;
}
/**
* Tutti i dati per il preventivo.
* @param string $kind m | kg
*/
public static function data(PDO $pdo, int $id, string $kind, ?string $customer): array
{
$kind = $kind === 'kg' ? 'kg' : 'm';
$st = $pdo->prepare("SELECT id, nome, photo FROM matrice WHERE id = ?");
$st->execute([$id]);
$mat = $st->fetch(PDO::FETCH_ASSOC);
if (!$mat) return ['success' => false, 'message' => 'Matrice non trovata'];
$mi = MagoMatriceStats::mixInfo($pdo, [$id])[$id] ?? null;
$prices = MagoMatriceStats::mixPrices($pdo);
$cost = MagoMatriceStats::costOf($mi, $kind, $prices);
// mescole con tutti i dati per il calcolo modificabile nella pagina
$mix = [];
foreach ($mi['mix'] ?? [] as $x) {
$p = $prices[$x['id']] ?? null;
$mix[] = [
'id' => $x['id'],
'name' => $x['name'],
'out' => $x['out'],
'gm' => $x['gm'] !== null && $x['gm'] > 0 ? (float)$x['gm'] : null,
'eur_kg' => $p ? $p['price'] : null,
'last_price' => $p['last_price'] ?? null,
'last_date' => $p['last_date'] ?? null,
'old' => $p ? $p['old'] : null,
'supplier' => $p['last_supplier'] ?? null,
'check' => $p['check'] ?? null,
];
}
// costo mese per mese
$months = [];
for ($i = self::HISTORY_MONTHS - 1; $i >= 0; $i--) $months[] = date('Y-m', strtotime("first day of -$i months"));
$costMonths = [];
if ($cost['cost'] !== null) {
foreach ($months as $ym) {
$c = MagoMatriceStats::costOf($mi, $kind, $prices, $ym);
if ($c['cost'] !== null) $costMonths[$ym] = $c['cost'];
}
}
// vendite della matrice nell'unità scelta
$raws = self::rawsFor($pdo, $id);
$since = $months[0] . '-01';
$d12 = date('Y-m-d', strtotime('-365 days'));
$lines = [];
foreach ($raws ? MagoMatriceStats::fetchLines($pdo, $since, [], $raws) : [] as $l) {
if ($l['order_date'] < $since) continue;
if (MagoMatriceStats::uomKind($l['uom']) !== $kind) continue;
if ((float)$l['value'] <= 0 || (float)$l['qty'] <= 0) continue;
$lines[] = $l;
}
$names = MagoMatriceStats::customerNames($pdo, array_column($lines, 'customer'));
$all = ['m' => [], 'v12' => 0, 'q12' => 0, 'min' => null, 'max' => null, 'clients' => []];
$cus = ['m' => [], 'v12' => 0, 'q12' => 0, 'last_price' => null, 'last_date' => null, 'lines' => []];
foreach ($lines as $l) {
$q = (float)$l['qty'];
$v = (float)$l['value'];
$pr = $v / $q;
$ym = substr($l['order_date'], 0, 7);
$all['m'][$ym]['v'] = ($all['m'][$ym]['v'] ?? 0) + $v;
$all['m'][$ym]['q'] = ($all['m'][$ym]['q'] ?? 0) + $q;
if ($l['order_date'] > $d12) {
$all['v12'] += $v;
$all['q12'] += $q;
$all['min'] = $all['min'] === null ? $pr : min($all['min'], $pr);
$all['max'] = $all['max'] === null ? $pr : max($all['max'], $pr);
$c = &$all['clients'][$l['customer']];
$c['v'] = ($c['v'] ?? 0) + $v;
$c['q'] = ($c['q'] ?? 0) + $q;
unset($c);
}
if ($customer !== null && $l['customer'] === $customer) {
$cus['m'][$ym]['v'] = ($cus['m'][$ym]['v'] ?? 0) + $v;
$cus['m'][$ym]['q'] = ($cus['m'][$ym]['q'] ?? 0) + $q;
if ($l['order_date'] > $d12) {
$cus['v12'] += $v;
$cus['q12'] += $q;
}
$cus['last_price'] = $pr;
$cus['last_date'] = $l['order_date'];
$cus['lines'][] = ['date' => $l['order_date'], 'ord' => $l['internal_ord_no'], 'qty' => round($q, 3), 'price' => round($pr, 4)];
}
}
$avgM = function (array $m) {
$o = [];
ksort($m);
foreach ($m as $ym => $x) $o[$ym] = round($x['v'] / $x['q'], 4);
return $o;
};
$clients = [];
foreach ($all['clients'] as $code => $c) $clients[] = ['code' => $code, 'name' => $names[$code] ?? $code, 'price' => round($c['v'] / $c['q'], 4), 'qty' => round($c['q'], 1)];
usort($clients, fn($a, $b) => $b['qty'] <=> $a['qty']);
$avg12 = $all['q12'] > 0 ? $all['v12'] / $all['q12'] : null;
return [
'success' => true,
'matrice' => [
'id' => (int)$mat['id'],
'name' => $mat['nome'],
'photo' => MagoMatriceStats::photoUrl($mat['photo']),
'raws' => $raws,
'ws' => $mi['ws'] ?? null,
'mix_source' => $mi['source'] ?? 'nessuna'
],
'kind' => $kind,
'uom' => $kind === 'kg' ? 'KG' : 'MT',
'price_months' => MagoMixPurchases::PRICE_MONTHS,
'cost' => ['cost' => $cost['cost'], 'status' => $cost['status'], 'missing' => $cost['missing'], 'check' => $cost['check']],
'mix' => $mix,
'months' => $months,
'cost_months' => $costMonths,
'all' => [
'months' => $avgM($all['m']),
'avg12' => $avg12 !== null ? round($avg12, 4) : null,
'min12' => $all['min'] !== null ? round($all['min'], 4) : null,
'max12' => $all['max'] !== null ? round($all['max'], 4) : null,
'clients' => array_slice($clients, 0, 12),
'n_clients' => count($clients),
'markup' => ($avg12 && $cost['cost'] && $cost['status'] === 'ok') ? round(($avg12 / $cost['cost'] - 1) * 100, 0) : null,
],
'customer' => $customer === null ? null : [
'code' => $customer,
'name' => $names[$customer] ?? self::customerName($pdo, $customer),
'months' => $avgM($cus['m']),
'avg12' => $cus['q12'] > 0 ? round($cus['v12'] / $cus['q12'], 4) : null,
'last_price' => $cus['last_price'] !== null ? round($cus['last_price'], 4) : null,
'last_date' => $cus['last_date'],
'lines' => array_slice(array_reverse($cus['lines']), 0, 12),
],
'quotes' => self::quotes($pdo, $id, null),
];
}
public static function customerName(PDO $pdo, string $code): string
{
return MagoMatriceStats::customerNames($pdo, [$code])[$code] ?? $code;
}
/** Preventivi salvati di una matrice (tutti i clienti), più recenti prima */
public static function quotes(PDO $pdo, ?int $idmatrice, ?string $customer, int $limit = 50): array
{
$w = [];
$p = [];
if ($idmatrice) {
$w[] = 'q.idmatrice = :m';
$p['m'] = $idmatrice;
}
if ($customer) {
$w[] = 'q.customer_code = :c';
$p['c'] = $customer;
}
$user = "TRIM(CONCAT(IFNULL(u.first_name, ''), ' ', IFNULL(u.last_name, '')))";
try {
$st = $pdo->prepare("SELECT q.*, NULLIF($user, '') AS user_name, u.username
FROM sales_quotes q LEFT JOIN auth_users u ON u.id = q.user_id
" . ($w ? 'WHERE ' . implode(' AND ', $w) : '') . "
ORDER BY q.created_at DESC, q.id DESC LIMIT " . (int)$limit);
$st->execute($p);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
return [];
}
}
/** Salva un preventivo. I numeri arrivano dalla pagina; il costo è ricalcolato dalle mescole inviate. */
public static function save(PDO $pdo, array $in, ?int $userId): array
{
$id = (int)($in['idmatrice'] ?? 0);
$st = $pdo->prepare("SELECT nome FROM matrice WHERE id = ?");
$st->execute([$id]);
$name = $st->fetchColumn();
if (!$name) return ['success' => false, 'message' => 'Matrice non trovata'];
$kind = ($in['kind'] ?? 'm') === 'kg' ? 'kg' : 'm';
$num = function ($v) {
if ($v === null || $v === '') return null;
return (float)str_replace(',', '.', (string)$v);
};
// mescole inviate dalla pagina (g/m e €/kg eventualmente corretti a mano)
$mix = json_decode((string)($in['mix'] ?? '[]'), true) ?: [];
$cost = 0.0;
$ok = 0;
$gmTot = 0.0;
foreach ($mix as $x) $gmTot += max(0, (float)($x['gm'] ?? 0));
foreach ($mix as &$x) {
$gm = $num($x['gm'] ?? null);
$eur = $num($x['eur_kg'] ?? null);
$x = ['id' => (int)($x['id'] ?? 0), 'name' => (string)($x['name'] ?? ''), 'gm' => $gm, 'eur_kg' => $eur, 'edited' => !empty($x['edited'])];
if ($eur === null) continue;
if ($kind === 'm') {
if ($gm) {
$cost += $gm / 1000 * $eur;
$ok++;
}
} else {
$share = $gmTot > 0 ? max(0, (float)$gm) / $gmTot : 1 / max(1, count($mix));
$cost += $share * $eur;
$ok++;
}
}
unset($x);
$costMix = $ok ? $cost : null;
$scrap = $num($in['scrap_pct'] ?? 0) ?? 0;
$other = $num($in['other_costs'] ?? 0) ?? 0;
$markup = $num($in['markup_pct'] ?? null);
$costTotal = $costMix !== null ? $costMix * (1 + $scrap / 100) + $other : ($other ?: null);
$suggested = ($costTotal !== null && $markup !== null) ? $costTotal * (1 + $markup / 100) : null;
$offered = $num($in['price_offered'] ?? null) ?? $suggested;
if ($offered === null) return ['success' => false, 'message' => 'Manca il prezzo: indica il ricarico o il prezzo proposto'];
$cust = trim((string)($in['customer'] ?? ''));
$st = $pdo->prepare("INSERT INTO sales_quotes
(created_at, user_id, idmatrice, matrice_name, customer_code, customer_name, uom, cost_mix, scrap_pct, other_costs, cost_total,
markup_pct, price_suggested, price_offered, ref_last_price, ref_avg_price, mix_detail, comment)
VALUES (NOW(), :u, :m, :mn, :cc, :cn, :uom, :cm, :sp, :oc, :ct, :mk, :ps, :po, :rl, :ra, :md, :cmt)");
$st->execute([
'u' => $userId ?: null,
'm' => $id,
'mn' => $name,
'cc' => $cust !== '' ? $cust : null,
'cn' => $cust !== '' ? self::customerName($pdo, $cust) : null,
'uom' => $kind === 'kg' ? 'KG' : 'MT',
'cm' => $costMix,
'sp' => $scrap,
'oc' => $other,
'ct' => $costTotal,
'mk' => $markup,
'ps' => $suggested,
'po' => $offered,
'rl' => $num($in['ref_last_price'] ?? null),
'ra' => $num($in['ref_avg_price'] ?? null),
'md' => json_encode($mix, JSON_UNESCAPED_UNICODE),
'cmt' => trim((string)($in['comment'] ?? '')) ?: null,
]);
return ['success' => true, 'id' => (int)$pdo->lastInsertId()];
}
public static function delete(PDO $pdo, int $id): bool
{
$st = $pdo->prepare("DELETE FROM sales_quotes WHERE id = ?");
$st->execute([$id]);
return $st->rowCount() > 0;
}
}
+563
View File
@@ -0,0 +1,563 @@
<?php
/**
* MagoSalesStats - funzioni comuni alle pagine statistiche ordini cliente
* (sales_orders_stats.php e sales_orders_years.php).
* Legge SOLO dalle tabelle locali sincronizzate da Mago.
*/
class MagoSalesStats
{
const CUSTOMER_TYPE = 3211264;
/*
* MAPPATURA DATE DI CONSEGNA (righe e testata usano gli stessi nomi colonna)
*
* Data ZIBO = data comunicata da Zibo al cliente → è la SCADENZA che conta (ritardo in rosso)
* Data cliente = data desiderata dal cliente → riferimento (ritardo in arancione)
*
* Per invertirle basta scambiare i due valori qui sotto.
*/
// Verificato sui dati reali (13/10/2026): la data ZIBO è ExpectedDeliveryDate, la data cliente è ConfirmedDeliveryDate
const ZIBO_DATE_COL = 'expected_delivery_date'; // Mago: ExpectedDeliveryDate ("Data ZIBO")
const CLIENT_DATE_COL = 'confirmed_delivery_date'; // Mago: ConfirmedDeliveryDate ("DATA CLIENTE")
/*
* DATA ZIBO = SETTIMANA DI CONSEGNA
* In Mago la data ZIBO è il primo giorno della settimana: per il cliente significa "entro quella settimana".
* La scadenza reale è quindi la fine di quella settimana; durante la settimana compare un avviso.
* ZIBO_IS_WEEK = false → la data ZIBO torna a valere come giorno preciso
* ZIBO_WEEK_END_DAYS = giorni dopo il lunedì: 6 = domenica, 4 = venerdì
*/
const ZIBO_IS_WEEK = true;
const ZIBO_WEEK_END_DAYS = 6;
/*
* ORDINI ESCLUSI da tutte le statistiche: parole cercate (senza distinzione maiuscole/minuscole) in
* numero ordine esterno, nostro/vostro riferimento, note di testata, descrizioni e note delle righe.
* Lasciare l'elenco vuoto per non escludere nulla.
*/
const EXCLUDE_KEYWORDS = ['listino'];
/** Data ZIBO "valida" di una riga/testata: in Mago, se non compilata, resta uguale alla data ordine. */
public static function ziboDate($a)
{
$c = $a . '.' . self::ZIBO_DATE_COL;
return "CASE WHEN $c > $a.order_date THEN $c END";
}
public static function clientDate($a)
{
$c = $a . '.' . self::CLIENT_DATE_COL;
return "CASE WHEN $c > $a.order_date THEN $c END";
}
/** Scadenza della data ZIBO: fine della settimana indicata (o il giorno stesso se ZIBO_IS_WEEK = false). */
public static function ziboDeadline($a)
{
$z = self::ziboDate($a);
if (!self::ZIBO_IS_WEEK) return $z;
return "DATE_ADD($z, INTERVAL (" . (int)self::ZIBO_WEEK_END_DAYS . " - WEEKDAY($z)) DAY)";
}
/**
* FATTURATO: tipi di documento di vendita che contano (verificati sui dati reali il 14/10/2026)
* e segno con cui contano. Esclusi: DDT 3407873, DDT non valorizzati 3407881,
* proforma 3407888, movimenti deposito 3407890.
*/
const INVOICE_TYPES = [
3407874 => 1, // Fattura (immediata / differita) serie A ...
3407883 => 1, // Fattura d'acconto (TD02) serie B
3407876 => -1, // Nota di credito (TD04) → in negativo serie D ...
];
/**
* CLIENTI DA RICHIAMARE: clienti "regolari" (almeno DORMANT_MIN_ORDERS giorni d'ordine negli ultimi
* DORMANT_WINDOW_YEARS anni) in ritardo rispetto al loro ritmo abituale (mediana dei giorni tra due ordini).
* ritardo = giorni dall'ultimo ordine / ritmo abituale
*/
const DORMANT_WINDOW_YEARS = 3;
const DORMANT_MIN_ORDERS = 4;
const DORMANT_MIN_DAYS = 30; // sotto i 30 giorni dall'ultimo ordine nessun allarme
const DORMANT_WATCH = 1.5; // da 1,5 volte il ritmo: attenzione
const DORMANT_SLEEP = 2.0; // da 2 volte: dormiente
const DORMANT_LOST = 4.0; // da 4 volte (o oltre 1 anno): probabilmente perso
/**
* Calcola la lista clienti da richiamare a partire dagli ordini (code, name, order_date, v, salesperson, area, ord_no).
*/
public static function dormantCustomers(array $rows)
{
$by = [];
foreach ($rows as $r) {
$c = $r['code'];
if (!isset($by[$c])) $by[$c] = ['code' => $c, 'name' => $r['name'], 'dates' => [], 'orders' => []];
$by[$c]['dates'][$r['order_date']] = true;
$by[$c]['orders'][] = $r;
}
$out = [];
foreach ($by as $c) {
$info = self::rhythmInfo(array_keys($c['dates']));
if (!$info || !in_array($info['level'], ['watch', 'dormant', 'lost'], true)) continue;
$last = $info['last_date'];
$since = $info['days_since'];
$rhythm = $info['rhythm'];
$ratio = $info['ratio'];
$level = $info['level'];
$n = $info['orders'];
// ordinato nei 12 mesi fino all'ultimo ordine = quanto vale il cliente "a regime"
$from12 = (new DateTime($last))->modify('-365 days')->format('Y-m-d');
$v12 = 0.0;
$lastOrder = null;
foreach ($c['orders'] as $o) {
if ($o['order_date'] > $from12) $v12 += (float)$o['v'];
if (
!$lastOrder || $o['order_date'] > $lastOrder['order_date'] ||
($o['order_date'] === $lastOrder['order_date'] && $o['ord_no'] > $lastOrder['ord_no'])
) $lastOrder = $o;
}
$out[] = [
'code' => $c['code'],
'name' => $c['name'],
'salesperson' => $lastOrder['salesperson'],
'area' => $lastOrder['area'],
'last_date' => $last,
'last_no' => $lastOrder['ord_no'],
'days_since' => $since,
'rhythm' => $rhythm,
'ratio' => $ratio,
'orders' => $n,
'value12' => round($v12, 2),
'level' => $level,
];
}
usort($out, function ($a, $b) {
return $b['value12'] <=> $a['value12'];
});
return $out;
}
/**
* Ritmo d'ordine di un cliente a partire dalle date d'ordine (Y-m-d) degli ultimi DORMANT_WINDOW_YEARS anni.
* level: 'ok' | 'watch' | 'dormant' | 'lost' | 'few' (troppo pochi ordini per stimare un ritmo)
*/
public static function rhythmInfo(array $dates)
{
$dates = array_values(array_unique($dates));
sort($dates);
$n = count($dates);
if (!$n) return null;
$today = new DateTime('today');
$last = end($dates);
$since = (int)(new DateTime($last))->diff($today)->days;
$out = ['orders' => $n, 'last_date' => $last, 'days_since' => $since, 'rhythm' => null, 'ratio' => null, 'level' => 'few'];
if ($n < self::DORMANT_MIN_ORDERS) return $out;
$gaps = [];
for ($k = 1; $k < $n; $k++) {
$gaps[] = (int)(new DateTime($dates[$k - 1]))->diff(new DateTime($dates[$k]))->days;
}
sort($gaps);
$m = count($gaps);
$median = $m % 2 ? $gaps[intdiv($m, 2)] : ($gaps[$m / 2 - 1] + $gaps[$m / 2]) / 2;
$rhythm = max(7, (float)$median);
$ratio = $since / $rhythm;
if ($since < self::DORMANT_MIN_DAYS) $level = 'ok';
elseif ($ratio >= self::DORMANT_LOST || $since >= 365) $level = 'lost';
elseif ($ratio >= self::DORMANT_SLEEP) $level = 'dormant';
elseif ($ratio >= self::DORMANT_WATCH) $level = 'watch';
else $level = 'ok';
$out['rhythm'] = (int)round($rhythm);
$out['ratio'] = round($ratio, 1);
$out['level'] = $level;
return $out;
}
/**
* Previsione di fine anno: valore dell'anno in corso ad oggi diviso la quota che, negli anni
* precedenti (max 3), si era raggiunta alla stessa data. $rows: [y, full, ytd].
*/
public static function yearForecast(array $rows, $curYear)
{
$cur = null;
$shares = [];
$prevFull = null;
foreach ($rows as $r) {
$y = (int)$r['y'];
if ($y === $curYear) $cur = (float)$r['ytd'];
elseif ($y >= $curYear - 3 && $y < $curYear && (float)$r['full'] > 0 && (float)$r['ytd'] > 0) {
$shares[$y] = (float)$r['ytd'] / (float)$r['full'];
}
if ($y === $curYear - 1) $prevFull = (float)$r['full'];
}
if ($cur === null || !$shares) {
return ['ytd' => $cur, 'forecast' => null, 'prev_full' => $prevFull];
}
$avg = array_sum($shares) / count($shares);
ksort($shares);
return [
'ytd' => round($cur, 2),
'forecast' => round($cur / $avg, 2),
'low' => round($cur / max($shares), 2),
'high' => round($cur / min($shares), 2),
'prev_full' => $prevFull,
'years' => array_keys($shares),
'share' => round($avg * 100, 1),
];
}
/**
* SOMMARIO (semafori): soglie [verde, rosso]. Tra le due = giallo.
* Per gli indicatori "più alto è meglio" verde se >= primo valore, rosso se < secondo.
* Per quelli "più basso è meglio" verde se <= primo valore, rosso se > secondo.
*/
const SUMMARY_THRESHOLDS = [
'growth' => [3, -3], // % crescita vs stessa data anno prima (fatturato, ordinato, previsione)
'b2b' => [1.05, 0.95], // book-to-bill 90 gg
'cover_weeks' => [8, 4], // settimane di lavoro in portafoglio
'late_pct' => [10, 25], // % del portafoglio già oltre data ZIBO (più basso è meglio)
'risk_pct' => [3, 8], // % fatturato 12 mesi di clienti dormienti/persi (più basso è meglio)
'top5_pct' => [40, 60], // % fatturato 12 mesi dei primi 5 clienti (più basso è meglio)
'cn_pct' => [1.5, 3], // % note di credito su fatturato 12 mesi (più basso è meglio)
'customers' => [0, -5], // % variazione clienti attivi 12 mesi
];
/** Semaforo: 'green' | 'yellow' | 'red' | 'grey' (dato non disponibile). */
public static function light($value, $key, $lowerIsBetter = false)
{
if ($value === null || !is_finite((float)$value)) return 'grey';
[$g, $r] = self::SUMMARY_THRESHOLDS[$key];
$v = (float)$value;
if ($lowerIsBetter) return $v <= $g ? 'green' : ($v > $r ? 'red' : 'yellow');
return $v >= $g ? 'green' : ($v < $r ? 'red' : 'yellow');
}
/** Etichette dei tipi documento (tabella documenti). */
const INVOICE_TYPE_LABELS = [
3407874 => 'Fattura',
3407883 => 'Fattura d\'acconto',
3407876 => 'Nota di credito',
];
/** Vista fatture/note di credito (alias i) con imponibile già col segno giusto (revenue). */
public static function invoiceFrom()
{
$types = implode(',', array_map('intval', array_keys(self::INVOICE_TYPES)));
$sign = 'CASE d.document_type';
foreach (self::INVOICE_TYPES as $t => $sg) $sign .= ' WHEN ' . (int)$t . ' THEN ' . ((int)$sg < 0 ? -1 : 1);
$sign .= ' ELSE 0 END';
return "(
SELECT d.sale_doc_id, d.document_type, d.doc_no, d.document_date,
d.customer, d.salesperson, d.area,
COALESCE(NULLIF(c.company_name, ''), d.customer) AS company_name,
($sign) * COALESCE(d.taxable_amount, 0) AS revenue,
($sign) < 0 AS is_credit_note,
d.summary_tb_modified IS NULL AS no_amount
FROM mago_sale_doc d
LEFT JOIN mago_cust_supp c
ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = d.customer
WHERE d.document_type IN ($types) AND d.cancelled = 0
) i";
}
/** Scadenza di testata (usata solo per ordini senza righe). */
public static function headerDue($a)
{
return "COALESCE(" . self::ziboDeadline($a) . ", " . self::clientDate($a) . ", $a.compulsory_delivery_date)";
}
/** Scadenza effettiva di una riga: fine settimana ZIBO, se manca la data cliente. */
public static function lineDue($a)
{
return "COALESCE(" . self::ziboDeadline($a) . ", " . self::clientDate($a) . ")";
}
/**
* Condizione SQL che ESCLUDE gli ordini contenenti una delle parole in EXCLUDE_KEYWORDS
* (alias della testata: $a). Stringa vuota se non c'è nulla da escludere.
*/
public static function excludeCondition($a)
{
$words = array_filter(array_map('trim', self::EXCLUDE_KEYWORDS));
if (!$words) return '1=1';
$parts = [];
foreach ($words as $w) {
$like = "'%" . str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $w) . "%'";
foreach (['internal_ord_no', 'external_ord_no', 'our_reference', 'your_reference', 'notes'] as $col) {
$parts[] = "COALESCE($a.$col, '') LIKE $like"; // COALESCE: un campo NULL non deve escludere l'ordine
}
$parts[] = "EXISTS (SELECT 1 FROM mago_sale_ord_detail ex
WHERE ex.sale_ord_id = $a.sale_ord_id
AND (COALESCE(ex.description, '') LIKE $like OR COALESCE(ex.notes, '') LIKE $like))";
}
return 'NOT (' . implode(' OR ', $parts) . ')';
}
/** Aggregati per ordine calcolati dalle righe (alias l). */
public static function lineAggregates()
{
$open = "(d.delivered = 0 AND d.cancelled = 0)";
$due = self::lineDue('d');
return "(
SELECT d.sale_ord_id,
COUNT(*) AS lines_total,
SUM($open) AS open_lines,
MIN(CASE WHEN $open THEN " . self::ziboDate('d') . " END) AS next_zibo,
MIN(CASE WHEN $open THEN " . self::clientDate('d') . " END) AS next_client,
MIN(" . self::ziboDate('d') . ") AS first_zibo,
MIN(" . self::clientDate('d') . ") AS first_client,
MIN(CASE WHEN $open THEN $due END) AS next_due,
MIN($due) AS first_due,
COUNT(DISTINCT CASE WHEN $open THEN $due END) AS open_dates,
SUM($open AND $due < CURDATE()) AS late_lines,
SUM($open AND " . self::clientDate('d') . " < CURDATE()) AS late_lines_client,
SUM($open AND $due >= CURDATE() AND YEARWEEK($due, 3) = YEARWEEK(CURDATE(), 3)) AS week_lines
FROM mago_sale_ord_detail d
WHERE d.item <> '' OR d.qty <> 0
GROUP BY d.sale_ord_id
) l";
}
/** Risposta JSON e fine script (pulisce l'output di headscript). */
public static function jsonOut(array $data)
{
while (ob_get_level()) ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
public static function isValidDate($d)
{
return is_string($d) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false;
}
/** Parametro GET multiplo (es. customers[]) come array pulito. */
public static function arrParam($key)
{
$v = $_GET[$key] ?? [];
if (!is_array($v)) $v = [$v];
return array_values(array_filter(array_map(function ($x) {
return trim((string)$x);
}, $v), function ($x) {
return $x !== '';
}));
}
/**
* Vista base: ordini + nome cliente + stato + date di consegna (alias x).
*
* zibo_date = prima data ZIBO tra le righe ancora da consegnare (se tutte consegnate: la prima in assoluto);
* senza righe: data ZIBO di testata
* client_date = stessa logica con la data cliente
* due_date = scadenza dell'ordine: la più vicina tra le righe aperte (riga: data ZIBO, se manca data cliente)
* late_lines / open_lines = righe aperte in ritardo / righe aperte (per il "ritardo parziale")
*/
public static function baseFrom()
{
return "(
SELECT
o.*,
COALESCE(NULLIF(c.company_name, ''), o.customer) AS company_name,
COALESCE(l.next_zibo, l.first_zibo, " . self::ziboDate('o') . ") AS zibo_date,
COALESCE(l.next_client, l.first_client, " . self::clientDate('o') . ") AS client_date,
-- scadenza = la più vicina tra le righe aperte, dove ogni riga scade alla sua data ZIBO
-- (o alla data cliente se la data ZIBO non è compilata)
COALESCE(
l.next_due, l.first_due,
" . self::ziboDeadline('o') . ", " . self::clientDate('o') . ", o.compulsory_delivery_date
) AS due_date,
COALESCE(l.lines_total, 0) AS lines_total,
COALESCE(l.open_lines, 0) AS open_lines,
COALESCE(l.open_dates, 0) AS open_dates,
COALESCE(l.late_lines, 0) AS late_lines,
COALESCE(l.late_lines_client, 0) AS late_lines_client,
COALESCE(l.week_lines, 0) AS week_lines,
-- RITARDO calcolato sulle RIGHE: un ordine è in ritardo solo se ha almeno una riga aperta scaduta.
-- (Solo se l'ordine non ha righe si usa la data di testata.)
CASE WHEN o.cancelled = 0 AND o.delivered = 0 AND (
(COALESCE(l.lines_total, 0) > 0 AND l.late_lines > 0)
OR (COALESCE(l.lines_total, 0) = 0 AND " . self::headerDue('o') . " < CURDATE())
) THEN 1 ELSE 0 END AS is_late,
CASE WHEN o.cancelled = 0 AND o.delivered = 0 AND (
(COALESCE(l.lines_total, 0) > 0 AND l.late_lines_client > 0)
OR (COALESCE(l.lines_total, 0) = 0 AND " . self::clientDate('o') . " < CURDATE())
) THEN 1 ELSE 0 END AS is_late_client,
-- scadenza delle sole righe ancora aperte (per il grafico scadenze)
CASE WHEN COALESCE(l.lines_total, 0) > 0 THEN l.next_due ELSE " . self::headerDue('o') . " END AS open_due,
CASE
WHEN o.cancelled = 1 THEN 'annullato'
WHEN o.delivered = 1 THEN 'evaso'
WHEN o.is_blocked = 1 THEN 'bloccato'
ELSE 'aperto'
END AS status
FROM mago_sale_ord o
LEFT JOIN mago_cust_supp c
ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = o.customer
LEFT JOIN " . self::lineAggregates() . " ON l.sale_ord_id = o.sale_ord_id
WHERE " . self::excludeCondition('o') . "
) x";
}
/**
* Vista righe aperte (alias r) per la pagina Consegne: riga + ordine + date effettive.
*/
public static function openLinesFrom()
{
return "(
SELECT
d.sale_ord_id, d.sub_id, d.line, d.position, d.item, d.description, d.uom,
d.qty, d.delivered_qty,
GREATEST(d.qty - d.delivered_qty, 0) AS residual_qty,
CASE WHEN d.qty > 0 THEN ROUND(d.taxable_amount * GREATEST(d.qty - d.delivered_qty, 0) / d.qty, 2)
ELSE d.taxable_amount END AS residual_value,
d.taxable_amount,
" . self::ziboDate('d') . " AS zibo_date,
" . self::clientDate('d') . " AS client_date,
" . self::lineDue('d') . " AS due_date,
x.internal_ord_no, x.order_date, x.customer, x.company_name,
x.salesperson, x.area, x.status, x.your_reference, x.external_ord_no
FROM mago_sale_ord_detail d
JOIN " . self::baseFrom() . " ON x.sale_ord_id = d.sale_ord_id
WHERE d.delivered = 0 AND d.cancelled = 0
AND x.status IN ('aperto','bloccato')
AND (d.item <> '' OR d.qty <> 0)
) r";
}
/**
* WHERE + parametri dai filtri in querystring.
* $override sostituisce from/to (stringa vuota = nessun filtro data).
*/
public static function buildWhere(array $override = [])
{
$w = ['1=1'];
$p = [];
$from = array_key_exists('from', $override) ? $override['from'] : ($_GET['from'] ?? '');
$to = array_key_exists('to', $override) ? $override['to'] : ($_GET['to'] ?? '');
if (self::isValidDate($from)) {
$w[] = 'x.order_date >= :from';
$p['from'] = $from;
}
if (self::isValidDate($to)) {
$w[] = 'x.order_date <= :to';
$p['to'] = $to;
}
$multi = [
'customers' => 'x.customer',
'salespeople' => 'x.salesperson',
'areas' => 'x.area',
];
foreach ($multi as $key => $col) {
$vals = self::arrParam($key);
if (!$vals) continue;
$ph = [];
foreach ($vals as $i => $v) {
$name = $key . $i;
$ph[] = ':' . $name;
$p[$name] = $v;
}
$w[] = "$col IN (" . implode(',', $ph) . ")";
}
$statusMap = [
'aperto' => "x.status = 'aperto'",
'evaso' => "x.status = 'evaso'",
'annullato' => "x.status = 'annullato'",
'bloccato' => "x.status = 'bloccato'",
'ritardo' => "x.is_late = 1",
'ritardo_cliente' => "x.is_late_client = 1",
'settimana' => "(x.status IN ('aperto','bloccato') AND x.week_lines > 0)", // righe da consegnare questa settimana
// ordini con righe aperte la cui data ZIBO è nell'anno in corso (ritardi compresi) / nell'anno prossimo
'zibo_anno' => "(x.status IN ('aperto','bloccato') AND EXISTS (SELECT 1 FROM mago_sale_ord_detail zd WHERE zd.sale_ord_id = x.sale_ord_id AND zd.delivered = 0 AND zd.cancelled = 0 AND YEAR(" . self::ziboDate('zd') . ") <= YEAR(CURDATE())))",
'zibo_prossimo' => "(x.status IN ('aperto','bloccato') AND EXISTS (SELECT 1 FROM mago_sale_ord_detail zd WHERE zd.sale_ord_id = x.sale_ord_id AND zd.delivered = 0 AND zd.cancelled = 0 AND YEAR(" . self::ziboDate('zd') . ") = YEAR(CURDATE()) + 1))",
'da_fatturare' => "(x.delivered = 1 AND x.invoiced = 0 AND x.cancelled = 0)",
'fatturato' => "x.invoiced = 1",
];
$statuses = array_intersect(self::arrParam('statuses'), array_keys($statusMap));
if ($statuses) {
$w[] = '(' . implode(' OR ', array_map(function ($s) use ($statusMap) {
return $statusMap[$s];
}, $statuses)) . ')';
}
$q = trim((string)($_GET['q'] ?? ''));
if ($q !== '') {
$like = '%' . $q . '%';
$w[] = '(x.internal_ord_no LIKE :q1 OR x.external_ord_no LIKE :q2 OR x.your_reference LIKE :q3 OR x.company_name LIKE :q4 OR x.customer LIKE :q5)';
$p['q1'] = $p['q2'] = $p['q3'] = $p['q4'] = $p['q5'] = $like;
}
return [implode(' AND ', $w), $p];
}
public static function fetchAllQ(PDO $pdo, $sql, array $params)
{
$st = $pdo->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Stato dell'ultima sincronizzazione (per il badge in alto). */
public static function syncInfo(PDO $pdo)
{
try {
$rows = $pdo->query("SELECT * FROM mago_sync_state")->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $r) {
$out[$r['entity']] = [
'last_run_at' => $r['last_run_at'] ? date('d/m/Y H:i', strtotime($r['last_run_at'])) : '',
'last_status' => $r['last_status'],
'last_message' => $r['last_message'],
'records' => (int)$r['records_last_run'],
];
}
$out['total_orders'] = (int)$pdo->query("SELECT COUNT(*) FROM mago_sale_ord")->fetchColumn();
return $out;
} catch (Throwable $e) {
return ['error' => 'Tabelle Mago non presenti: eseguire la migrazione Phinx', 'total_orders' => 0];
}
}
/** Opzioni dei filtri (clienti, agenti, aree, anni disponibili). */
public static function filterOptions(PDO $pdo)
{
try {
$customers = $pdo->query("
SELECT o.customer AS code, COALESCE(NULLIF(c.company_name,''), o.customer) AS name, COUNT(*) AS n
FROM mago_sale_ord o
LEFT JOIN mago_cust_supp c ON c.cust_supp_type = " . self::CUSTOMER_TYPE . " AND c.cust_supp = o.customer
WHERE o.customer <> ''
GROUP BY o.customer, name
ORDER BY name
")->fetchAll(PDO::FETCH_ASSOC);
$salespeople = $pdo->query("SELECT salesperson AS code, COUNT(*) AS n FROM mago_sale_ord WHERE salesperson <> '' GROUP BY salesperson ORDER BY salesperson")->fetchAll(PDO::FETCH_ASSOC);
$areas = $pdo->query("SELECT area AS code, COUNT(*) AS n FROM mago_sale_ord WHERE area <> '' GROUP BY area ORDER BY area")->fetchAll(PDO::FETCH_ASSOC);
$range = $pdo->query("SELECT MIN(order_date) AS min_date, MAX(order_date) AS max_date FROM mago_sale_ord")->fetch(PDO::FETCH_ASSOC);
$years = array_map('intval', $pdo->query("SELECT DISTINCT YEAR(order_date) FROM mago_sale_ord WHERE order_date IS NOT NULL ORDER BY 1")->fetchAll(PDO::FETCH_COLUMN));
return [
'success' => true,
'customers' => $customers,
'salespeople' => $salespeople,
'areas' => $areas,
'range' => $range,
'years' => $years,
'sync' => self::syncInfo($pdo),
];
} catch (Throwable $e) {
return ['success' => false, 'message' => $e->getMessage(), 'sync' => self::syncInfo($pdo)];
}
}
}
+907
View File
@@ -0,0 +1,907 @@
<?php
/**
* MagoSync - sincronizza dati Mago4 → tabelle MySQL locali (Mago in SOLA LETTURA).
*
* $sync = new MagoSync($pdo, MagoClient::fromEnv());
* $report = $sync->run(['limit' => 50]); // prova con pochi ordini
* $report = $sync->run(); // incrementale (TbModified)
* $report = $sync->run(['full' => true]); // tutto dal 2026-01-01
*
* Il logoff da Mago viene fatto sempre, anche in caso di errore.
*/
require_once __DIR__ . '/MagoClient.php';
class MagoSync
{
const CUSTOMER_TYPE = 3211264; // CustSuppType "Cliente" (verificato sui dati)
const DEFAULT_FROM = '2020-01-01'; // storico dal 2020 per confronto anni
const ENTITY_ORDERS = 'sale_ord';
const ENTITY_CUSTOMER = 'cust_supp';
const ENTITY_SUMMARY = 'sale_ord_summary';
const ENTITY_DETAILS = 'sale_ord_details';
const ENTITY_DOCS = 'sale_doc';
const ENTITY_DOC_SUMM = 'sale_doc_summary';
/** @var PDO */
private $pdo;
/** @var MagoClient */
private $mago;
/** @var callable|null */
private $logger;
public function __construct(PDO $pdo, MagoClient $mago, ?callable $logger = null)
{
$this->pdo = $pdo;
$this->mago = $mago;
$this->logger = $logger;
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
/**
* Opzioni:
* limit int|null numero massimo di ordini (modalità prova)
* full bool ignora l'incrementale e rilegge tutto dal 'from'
* from string data minima ordini (default 2026-01-01)
* skip_customers bool non sincronizzare l'anagrafica clienti
*/
public function run(array $opts = [])
{
$limit = isset($opts['limit']) && (int)$opts['limit'] > 0 ? (int)$opts['limit'] : null;
$full = !empty($opts['full']);
$from = !empty($opts['from']) ? $opts['from'] : self::DEFAULT_FROM;
$report = ['ok' => true, 'limit' => $limit, 'full' => $full, 'from' => $from];
if (!$this->acquireLock()) {
return ['ok' => false, 'message' => 'Sincronizzazione già in corso, riprova tra poco.'];
}
try {
$this->mago->login();
if (empty($opts['skip_customers'])) {
$report['clienti'] = $this->syncCustomers($full);
}
$report['ordini'] = $this->syncSaleOrders($from, $limit, $full);
$report['righe'] = $this->syncSaleOrderDetails($from, $full, (bool)$limit);
$report['importi'] = $this->syncSaleOrderSummaries($from, $full, (bool)$limit);
// Documenti di vendita (fatture / note di credito) per il fatturato
if ($this->tableExists('mago_sale_doc')) {
$report['documenti'] = $this->syncSaleDocs($from, $limit, $full);
$report['importi_documenti'] = $this->syncSaleDocSummaries($from, $full, (bool)$limit);
} else {
$report['documenti'] = 'tabella mago_sale_doc mancante: eseguire la migrazione Phinx';
$this->log('Documenti: tabella mago_sale_doc mancante, salto (eseguire la migrazione)');
}
// Coda "Nuovi da Mago" per la programmazione produzione (solo dati locali, nessuna chiamata a Mago)
if ($this->tableExists('mago_prod_queue')) {
require_once __DIR__ . '/MagoProduction.php';
$q = MagoProduction::refreshQueue($this->pdo);
$report['coda_produzione'] = $q;
$this->log("Coda produzione: {$q['nuove']} nuove, {$q['aggiornate']} aggiornate, {$q['chiuse']} chiuse, {$q['modificate']} modificate dopo l'import");
}
} catch (Throwable $e) {
$report['ok'] = false;
$report['message'] = $e->getMessage();
$this->log('ERRORE: ' . $e->getMessage());
} finally {
$logoffOk = $this->mago->logoff();
$report['logoff'] = $logoffOk ? 'OK' : ('non riuscito: ' . $this->mago->getLastLogoffError());
$this->releaseLock();
}
return $report;
}
/* =========================================================
* ORDINI CLIENTE
* ========================================================= */
public function syncSaleOrders($from, $limit = null, $full = false)
{
$t0 = microtime(true);
$state = $this->getState(self::ENTITY_ORDERS);
$filters = [['key' => 'OrderDate', 'value' => $from . 'T00:00:00', 'operator' => '>=']];
// Incrementale: solo i record modificati dall'ultima sincronizzazione completa
$incremental = !$full && !$limit && !empty($state['last_tb_modified']);
if ($incremental) {
$filters[] = [
'key' => 'TbModified',
'value' => date('Y-m-d\TH:i:s', strtotime($state['last_tb_modified'])),
'operator' => '>=',
];
}
$this->log('Ordini: ' . ($incremental ? 'incrementale da ' . $state['last_tb_modified'] : 'completa dal ' . $from) . ($limit ? " (prova: max $limit)" : ''));
$sql = "INSERT INTO mago_sale_ord (
sale_ord_id, internal_ord_no, external_ord_no, order_date,
expected_delivery_date, confirmed_delivery_date, compulsory_delivery_date,
customer, invoicing_customer, salesperson, area_manager, area,
payment, currency, our_reference, your_reference,
transport, port, package, storage, job,
delivered, invoiced, cancelled, is_blocked, printed, archived, open_order,
priority, notes, tb_created, tb_modified, raw_json, synced_at
) VALUES (
:sale_ord_id, :internal_ord_no, :external_ord_no, :order_date,
:expected_delivery_date, :confirmed_delivery_date, :compulsory_delivery_date,
:customer, :invoicing_customer, :salesperson, :area_manager, :area,
:payment, :currency, :our_reference, :your_reference,
:transport, :port, :package, :storage, :job,
:delivered, :invoiced, :cancelled, :is_blocked, :printed, :archived, :open_order,
:priority, :notes, :tb_created, :tb_modified, :raw_json, NOW()
)
ON DUPLICATE KEY UPDATE
internal_ord_no = VALUES(internal_ord_no),
external_ord_no = VALUES(external_ord_no),
order_date = VALUES(order_date),
expected_delivery_date = VALUES(expected_delivery_date),
confirmed_delivery_date = VALUES(confirmed_delivery_date),
compulsory_delivery_date = VALUES(compulsory_delivery_date),
customer = VALUES(customer),
invoicing_customer = VALUES(invoicing_customer),
salesperson = VALUES(salesperson),
area_manager = VALUES(area_manager),
area = VALUES(area),
payment = VALUES(payment),
currency = VALUES(currency),
our_reference = VALUES(our_reference),
your_reference = VALUES(your_reference),
transport = VALUES(transport),
port = VALUES(port),
package = VALUES(package),
storage = VALUES(storage),
job = VALUES(job),
delivered = VALUES(delivered),
invoiced = VALUES(invoiced),
cancelled = VALUES(cancelled),
is_blocked = VALUES(is_blocked),
printed = VALUES(printed),
archived = VALUES(archived),
open_order = VALUES(open_order),
priority = VALUES(priority),
notes = VALUES(notes),
tb_created = VALUES(tb_created),
tb_modified = VALUES(tb_modified),
raw_json = VALUES(raw_json),
synced_at = NOW()";
$stmt = $this->pdo->prepare($sql);
$count = 0;
$maxModified = $state['last_tb_modified'] ?? null;
$this->pdo->beginTransaction();
try {
foreach ($this->mago->iterate('MA_SaleOrd', ['filters' => $filters]) as $r) {
$stmt->execute($this->mapSaleOrd($r));
$count++;
$mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
if ($mod && (!$maxModified || $mod > $maxModified)) {
$maxModified = $mod;
}
if ($count % 200 === 0) {
$this->pdo->commit();
$this->pdo->beginTransaction();
$this->log(" ordini salvati: $count");
}
if ($limit && $count >= $limit) break;
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
$this->saveState(self::ENTITY_ORDERS, null, 'errore', $e->getMessage(), $count, $t0, false);
throw $e;
}
// In modalità prova NON avanzo il punto di ripresa (i record non arrivano in ordine di modifica)
$this->saveState(
self::ENTITY_ORDERS,
$limit ? null : $maxModified,
'ok',
($limit ? "Prova: $count ordini" : ($incremental ? "Incrementale: $count ordini" : "Completa: $count ordini")),
$count,
$t0,
!$limit
);
$this->log("Ordini sincronizzati: $count");
return ['records' => $count, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
}
private function mapSaleOrd(array $r)
{
$s = function ($k, $len = 255) use ($r) {
return mb_substr(trim((string)($r[$k] ?? '')), 0, $len);
};
return [
'sale_ord_id' => (int)($r['SaleOrdId'] ?? 0),
'internal_ord_no' => $s('InternalOrdNo', 30),
'external_ord_no' => $s('ExternalOrdNo', 60),
'order_date' => MagoClient::toMysqlDate($r['OrderDate'] ?? null),
'expected_delivery_date' => MagoClient::toMysqlDate($r['ExpectedDeliveryDate'] ?? null),
'confirmed_delivery_date' => MagoClient::toMysqlDate($r['ConfirmedDeliveryDate'] ?? null),
'compulsory_delivery_date' => MagoClient::toMysqlDate($r['CompulsoryDeliveryDate'] ?? null),
'customer' => $s('Customer', 20),
'invoicing_customer' => $s('InvoicingCustomer', 20),
'salesperson' => $s('Salesperson', 20),
'area_manager' => $s('AreaManager', 20),
'area' => $s('Area', 20),
'payment' => $s('Payment', 20),
'currency' => $s('Currency', 8),
'our_reference' => $s('OurReference', 150),
'your_reference' => $s('YourReference', 150),
'transport' => $s('Transport', 20),
'port' => $s('Port', 20),
'package' => $s('Package', 40),
'storage' => $s('StoragePhase1', 30),
'job' => $s('Job', 30),
'delivered' => MagoClient::toBool($r['Delivered'] ?? false),
'invoiced' => MagoClient::toBool($r['Invoiced'] ?? false),
'cancelled' => MagoClient::toBool($r['Cancelled'] ?? false),
'is_blocked' => MagoClient::toBool($r['IsBlocked'] ?? false),
'printed' => MagoClient::toBool($r['Printed'] ?? false),
'archived' => MagoClient::toBool($r['Archived'] ?? false),
'open_order' => MagoClient::toBool($r['OpenOrder'] ?? false),
'priority' => (int)($r['Priority'] ?? 0),
'notes' => trim((string)($r['Notes'] ?? '')) ?: null,
'tb_created' => MagoClient::toMysqlDate($r['TbCreated'] ?? null, true),
'tb_modified' => MagoClient::toMysqlDate($r['TbModified'] ?? null, true),
'raw_json' => json_encode($r, JSON_UNESCAPED_UNICODE),
];
}
/* =========================================================
* RIGHE ORDINE (MA_SaleOrdDetails → mago_sale_ord_detail)
* ========================================================= */
/**
* Le righe hanno OrderDate, quindi si filtrano come le testate.
* Vengono salvate solo le righe di ordini già presenti in locale.
* Nelle letture complete le righe degli ordini del periodo vengono
* prima cancellate, così spariscono anche le righe eliminate in Mago.
*/
public function syncSaleOrderDetails($from, $full = false, $testMode = false)
{
$t0 = microtime(true);
$state = $this->getState(self::ENTITY_DETAILS);
// ordini locali del periodo (per scartare righe di ordini non sincronizzati)
$st = $this->pdo->prepare("SELECT sale_ord_id FROM mago_sale_ord WHERE order_date >= ?");
$st->execute([$from]);
$localIds = array_flip(array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN)));
if (!$localIds) {
return ['records' => 0, 'salvate' => 0, 'incrementale' => false, 'ms' => $this->ms($t0)];
}
$incremental = !$full && !$testMode && !empty($state['last_tb_modified']);
$filters = [['key' => 'OrderDate', 'value' => $from . 'T00:00:00', 'operator' => '>=']];
if ($incremental) {
$filters[] = [
'key' => 'TbModified',
'value' => date('Y-m-d\TH:i:s', strtotime($state['last_tb_modified'])),
'operator' => '>=',
];
}
if ($testMode) {
// prova: leggo solo le righe nel periodo degli ordini importati
$range = $this->pdo->query("SELECT MIN(order_date), MAX(order_date) FROM mago_sale_ord WHERE order_date >= " . $this->pdo->quote($from))->fetch(PDO::FETCH_NUM);
$filters = [
['key' => 'OrderDate', 'value' => $range[0] . 'T00:00:00', 'operator' => '>='],
['key' => 'OrderDate', 'value' => $range[1] . 'T00:00:00', 'operator' => '<='],
];
}
$this->log('Righe: ' . ($incremental ? 'incrementale da ' . $state['last_tb_modified'] : 'completa dal ' . $from));
$stmt = $this->pdo->prepare("
INSERT INTO mago_sale_ord_detail (
sale_ord_id, sub_id, line, position, line_type, item, description, uom,
qty, delivered_qty, invoiced_qty, unit_value, discount_amount, taxable_amount, total_amount,
expected_delivery_date, confirmed_delivery_date, delivered, invoiced, cancelled,
customer, order_date, internal_ord_no, notes, matrice_misura, cod_art_cli, tb_modified, synced_at
) VALUES (
:sale_ord_id, :sub_id, :line, :position, :line_type, :item, :description, :uom,
:qty, :delivered_qty, :invoiced_qty, :unit_value, :discount_amount, :taxable_amount, :total_amount,
:expected, :confirmed, :delivered, :invoiced, :cancelled,
:customer, :order_date, :internal_ord_no, :notes, :matrice_misura, :cod_art_cli, :tb_modified, NOW()
)
ON DUPLICATE KEY UPDATE
line = VALUES(line), position = VALUES(position), line_type = VALUES(line_type),
item = VALUES(item), description = VALUES(description), uom = VALUES(uom),
qty = VALUES(qty), delivered_qty = VALUES(delivered_qty), invoiced_qty = VALUES(invoiced_qty),
unit_value = VALUES(unit_value), discount_amount = VALUES(discount_amount),
taxable_amount = VALUES(taxable_amount), total_amount = VALUES(total_amount),
expected_delivery_date = VALUES(expected_delivery_date),
confirmed_delivery_date = VALUES(confirmed_delivery_date),
delivered = VALUES(delivered), invoiced = VALUES(invoiced), cancelled = VALUES(cancelled),
customer = VALUES(customer), order_date = VALUES(order_date),
internal_ord_no = VALUES(internal_ord_no), notes = VALUES(notes),
matrice_misura = VALUES(matrice_misura), cod_art_cli = VALUES(cod_art_cli),
tb_modified = VALUES(tb_modified), synced_at = NOW()
");
$read = 0;
$saved = 0;
$maxModified = $state['last_tb_modified'] ?? null;
$this->pdo->beginTransaction();
try {
if (!$incremental) {
// lettura completa: riparto pulito per gli ordini del periodo
$this->pdo->prepare("
DELETE d FROM mago_sale_ord_detail d
JOIN mago_sale_ord o ON o.sale_ord_id = d.sale_ord_id
WHERE o.order_date >= ?
")->execute([$from]);
}
foreach ($this->mago->iterate('MA_SaleOrdDetails', ['filters' => $filters]) as $r) {
$read++;
$id = (int)($r['SaleOrdId'] ?? 0);
$mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod;
if (!isset($localIds[$id])) continue;
$s = function ($k, $len) use ($r) {
return mb_substr(trim((string)($r[$k] ?? '')), 0, $len);
};
$d = function ($k, $dec = 2) use ($r) {
return round(MagoClient::toDecimal($r[$k] ?? 0), $dec);
};
$stmt->execute([
'sale_ord_id' => $id,
'sub_id' => (int)($r['SubId'] ?? $r['Line'] ?? 0),
'line' => (int)($r['Line'] ?? 0),
'position' => (int)($r['Position'] ?? 0),
'line_type' => (int)($r['LineType'] ?? 0),
'item' => $s('Item', 30),
'description' => $s('Description', 255),
'uom' => $s('UoM', 10),
'qty' => $d('Qty', 3),
'delivered_qty' => $d('DeliveredQty', 3),
'invoiced_qty' => $d('InvoicedQty', 3),
'unit_value' => $d('UnitValue', 5),
'discount_amount' => $d('DiscountAmount'),
'taxable_amount' => $d('TaxableAmount'),
'total_amount' => $d('TotalAmount'),
'expected' => MagoClient::toMysqlDate($r['ExpectedDeliveryDate'] ?? null),
'confirmed' => MagoClient::toMysqlDate($r['ConfirmedDeliveryDate'] ?? null),
'delivered' => MagoClient::toBool($r['Delivered'] ?? false),
'invoiced' => MagoClient::toBool($r['Invoiced'] ?? false),
'cancelled' => MagoClient::toBool($r['Cancelled'] ?? false),
'customer' => $s('Customer', 20),
'order_date' => MagoClient::toMysqlDate($r['OrderDate'] ?? null),
'internal_ord_no' => $s('InternalOrdNo', 30),
'matrice_misura' => $s('Department', 30), // colonna "Matrice/Misura" in Mago (es. M00786S, D 3,3)
'cod_art_cli' => $s('ReferenceDocNo', 60), // colonna "Cod.Art.Cli" in Mago
'notes' => trim((string)($r['Notes'] ?? '')) ?: null,
'tb_modified' => $mod,
]);
$saved++;
if ($read % 200 === 0) {
$this->pdo->commit();
$this->pdo->beginTransaction();
$this->log(" righe lette: $read");
}
}
$this->refreshLineDates($from);
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
$this->saveState(self::ENTITY_DETAILS, null, 'errore', $e->getMessage(), $read, $t0, false);
if (!$incremental) {
// lettura completa interrotta: al prossimo giro si riparte da capo
$this->pdo->prepare("UPDATE mago_sync_state SET last_tb_modified = NULL WHERE entity = ?")
->execute([self::ENTITY_DETAILS]);
}
throw $e;
}
$this->saveState(self::ENTITY_DETAILS, $testMode ? null : $maxModified, 'ok', "Lette $read righe, salvate $saved", $read, $t0, !$testMode);
$this->log("Righe: lette $read, salvate $saved");
return ['records' => $read, 'salvate' => $saved, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
}
/**
* Riporta sulla testata la prima data di consegna delle righe ancora da consegnare
* (se tutte consegnate: la prima data tra tutte le righe).
* La data confermata vale solo se successiva alla data ordine.
*/
private function refreshLineDates($from)
{
$this->pdo->prepare("
UPDATE mago_sale_ord o
LEFT JOIN (
SELECT sale_ord_id,
COALESCE(
MIN(CASE WHEN delivered = 0 AND cancelled = 0 THEN expected_delivery_date END),
MIN(expected_delivery_date)
) AS exp_date,
COALESCE(
MIN(CASE WHEN delivered = 0 AND cancelled = 0 AND confirmed_delivery_date > order_date THEN confirmed_delivery_date END),
MIN(CASE WHEN confirmed_delivery_date > order_date THEN confirmed_delivery_date END)
) AS conf_date,
COUNT(*) AS n
FROM mago_sale_ord_detail
WHERE item <> '' OR qty <> 0
GROUP BY sale_ord_id
) d ON d.sale_ord_id = o.sale_ord_id
SET o.line_expected_date = d.exp_date,
o.line_confirmed_date = d.conf_date,
o.lines_count = COALESCE(d.n, 0)
WHERE o.order_date >= ?
")->execute([$from]);
}
/* =========================================================
* IMPORTI (MA_SaleOrdSummary → colonne importo di mago_sale_ord)
* ========================================================= */
/**
* MA_SaleOrdSummary non ha la data ordine: filtro per TbModified, che è
* l'unico filtro data verificato. Parto dalla creazione del primo ordine
* locale del periodo (i totali nascono insieme all'ordine) e aggiorno
* solo gli ordini già presenti in locale.
*/
public function syncSaleOrderSummaries($from, $full = false, $testMode = false)
{
$t0 = microtime(true);
$state = $this->getState(self::ENTITY_SUMMARY);
$info = $this->pdo->query(
"
SELECT MIN(COALESCE(tb_created, order_date)) AS first_created,
SUM(summary_tb_modified IS NULL) AS missing,
COUNT(*) AS n
FROM mago_sale_ord
WHERE order_date >= " . $this->pdo->quote($from)
)->fetch(PDO::FETCH_ASSOC);
if (empty($info['n'])) {
return ['records' => 0, 'aggiornati' => 0, 'incrementale' => false, 'ms' => $this->ms($t0)];
}
// Incrementale solo se tutti gli ordini locali hanno già i totali
$incremental = !$full && !$testMode && !empty($state['last_tb_modified']) && (int)$info['missing'] === 0;
$since = $incremental
? $state['last_tb_modified']
: date('Y-m-d 00:00:00', strtotime($info['first_created'] . ' -1 day'));
$this->log('Importi: ' . ($incremental ? 'incrementale' : 'completa') . ' da ' . $since);
$filters = [['key' => 'TbModified', 'value' => date('Y-m-d\TH:i:s', strtotime($since)), 'operator' => '>=']];
$stmt = $this->pdo->prepare("
UPDATE mago_sale_ord SET
taxable_amount = :taxable,
tax_amount = :tax,
total_amount = :total,
goods_amount = :goods,
discounts = :discounts,
charges = :charges,
summary_tb_modified = :mod
WHERE sale_ord_id = :id
");
$read = 0;
$updated = 0;
$maxModified = $state['last_tb_modified'] ?? null;
$this->pdo->beginTransaction();
try {
foreach ($this->mago->iterate('MA_SaleOrdSummary', ['filters' => $filters]) as $r) {
$read++;
$mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
$d = function ($k) use ($r) {
return round(MagoClient::toDecimal($r[$k] ?? 0), 2);
};
$stmt->execute([
'taxable' => $d('TaxableAmount'),
'tax' => $d('TaxAmount'),
'total' => $d('TotalAmount'),
'goods' => $d('GoodsAmount'),
'discounts' => $d('Discounts'),
'charges' => $d('ShippingCharges') + $d('PackagingCharges') + $d('CollectionCharges')
+ $d('StampsCharges') + $d('AdditionalCharges') + $d('InsuranceCharges'),
'mod' => $mod ?: date('Y-m-d H:i:s'),
'id' => (int)($r['SaleOrdId'] ?? 0),
]);
$updated += $stmt->rowCount() > 0 ? 1 : 0;
if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod;
if ($read % 200 === 0) {
$this->pdo->commit();
$this->pdo->beginTransaction();
$this->log(" totali letti: $read");
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
$this->saveState(self::ENTITY_SUMMARY, null, 'errore', $e->getMessage(), $read, $t0, false);
throw $e;
}
$this->saveState(self::ENTITY_SUMMARY, $testMode ? null : $maxModified, 'ok', "Letti $read totali, $updated ordini aggiornati", $read, $t0, !$testMode);
$this->log("Importi: letti $read, ordini aggiornati $updated");
return ['records' => $read, 'aggiornati' => $updated, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
}
/* =========================================================
* DOCUMENTI DI VENDITA (MA_SaleDoc → mago_sale_doc) per il fatturato
* ========================================================= */
/**
* Testate documenti (tutti i tipi: fatture, note di credito, DDT...), filtrate per DocumentDate.
* Lettura completa: alla fine cancella in locale i documenti del periodo non più presenti in Mago.
*/
public function syncSaleDocs($from, $limit = null, $full = false)
{
$t0 = microtime(true);
$state = $this->getState(self::ENTITY_DOCS);
$filters = [['key' => 'DocumentDate', 'value' => $from . 'T00:00:00', 'operator' => '>=']];
$incremental = !$full && !$limit && !empty($state['last_tb_modified']);
if ($incremental) {
$filters[] = [
'key' => 'TbModified',
'value' => date('Y-m-d\TH:i:s', strtotime($state['last_tb_modified'])),
'operator' => '>=',
];
}
// solo i campi che servono (la testata ha ~200 campi: così è molto più veloce)
$fields = [
'SaleDocId',
'DocumentType',
'DocNo',
'DocumentDate',
'PostingDate',
'CustSupp',
'InvoicingCustomer',
'Salesperson',
'Area',
'Currency',
'TaxJournal',
'InvRsn',
'EIDocumentType',
'Issued',
'Cancelled',
'PostedToAccounting',
'TbCreated',
'TbModified'
];
$this->log('Documenti: ' . ($incremental ? 'incrementale da ' . $state['last_tb_modified'] : 'completa dal ' . $from) . ($limit ? " (prova: max $limit)" : ''));
$stmt = $this->pdo->prepare("
INSERT INTO mago_sale_doc (
sale_doc_id, document_type, doc_no, document_date, posting_date, customer, invoicing_customer,
salesperson, area, currency, tax_journal, inv_rsn, ei_document_type,
issued, cancelled, posted_to_accounting, tb_created, tb_modified, synced_at
) VALUES (
:id, :type, :doc_no, :doc_date, :posting_date, :customer, :inv_customer,
:salesperson, :area, :currency, :tax_journal, :inv_rsn, :ei_type,
:issued, :cancelled, :posted, :tb_created, :tb_modified, NOW()
)
ON DUPLICATE KEY UPDATE
document_type = VALUES(document_type), doc_no = VALUES(doc_no),
document_date = VALUES(document_date), posting_date = VALUES(posting_date),
customer = VALUES(customer), invoicing_customer = VALUES(invoicing_customer),
salesperson = VALUES(salesperson), area = VALUES(area), currency = VALUES(currency),
tax_journal = VALUES(tax_journal), inv_rsn = VALUES(inv_rsn), ei_document_type = VALUES(ei_document_type),
issued = VALUES(issued), cancelled = VALUES(cancelled), posted_to_accounting = VALUES(posted_to_accounting),
tb_created = VALUES(tb_created), tb_modified = VALUES(tb_modified), synced_at = NOW()
");
$count = 0;
$seen = [];
$maxModified = $state['last_tb_modified'] ?? null;
$this->pdo->beginTransaction();
try {
foreach ($this->mago->iterate('MA_SaleDoc', ['filters' => $filters, 'fields' => $fields]) as $r) {
$s = function ($k, $len) use ($r) {
return mb_substr(trim((string)($r[$k] ?? '')), 0, $len);
};
$id = (int)($r['SaleDocId'] ?? 0);
$mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
$stmt->execute([
'id' => $id,
'type' => (int)($r['DocumentType'] ?? 0),
'doc_no' => $s('DocNo', 30),
'doc_date' => MagoClient::toMysqlDate($r['DocumentDate'] ?? null),
'posting_date' => MagoClient::toMysqlDate($r['PostingDate'] ?? null),
'customer' => $s('CustSupp', 20),
'inv_customer' => $s('InvoicingCustomer', 20),
'salesperson' => $s('Salesperson', 20),
'area' => $s('Area', 20),
'currency' => $s('Currency', 8),
'tax_journal' => $s('TaxJournal', 20),
'inv_rsn' => $s('InvRsn', 20),
'ei_type' => (int)($r['EIDocumentType'] ?? 0),
'issued' => MagoClient::toBool($r['Issued'] ?? false),
'cancelled' => MagoClient::toBool($r['Cancelled'] ?? false),
'posted' => MagoClient::toBool($r['PostedToAccounting'] ?? false),
'tb_created' => MagoClient::toMysqlDate($r['TbCreated'] ?? null, true),
'tb_modified' => $mod,
]);
$count++;
$seen[$id] = true;
if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod;
if ($count % 200 === 0) {
$this->pdo->commit();
$this->pdo->beginTransaction();
$this->log(" documenti salvati: $count");
}
if ($limit && $count >= $limit) break;
}
// lettura completa riuscita: tolgo i documenti del periodo cancellati in Mago
$deleted = 0;
if (!$incremental && !$limit) {
$st = $this->pdo->prepare("SELECT sale_doc_id FROM mago_sale_doc WHERE document_date >= ?");
$st->execute([$from]);
$del = $this->pdo->prepare("DELETE FROM mago_sale_doc WHERE sale_doc_id = ?");
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $id) {
if (!isset($seen[(int)$id])) {
$del->execute([$id]);
$deleted++;
}
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
$this->saveState(self::ENTITY_DOCS, null, 'errore', $e->getMessage(), $count, $t0, false);
throw $e;
}
$msg = ($limit ? "Prova: $count documenti" : ($incremental ? "Incrementale: $count documenti" : "Completa: $count documenti")) . ($deleted ? ", $deleted cancellati" : '');
$this->saveState(self::ENTITY_DOCS, $limit ? null : $maxModified, 'ok', $msg, $count, $t0, !$limit);
$this->log("Documenti sincronizzati: $count" . ($deleted ? " ($deleted non più presenti in Mago, cancellati)" : ''));
return ['records' => $count, 'cancellati' => $deleted, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
}
/**
* Importi dei documenti (MA_SaleDocSummary). Come per gli ordini si filtra per TbModified
* partendo dalla creazione del primo documento senza importo (o di tutti, in lettura completa).
*/
public function syncSaleDocSummaries($from, $full = false, $testMode = false)
{
$t0 = microtime(true);
$state = $this->getState(self::ENTITY_DOC_SUMM);
$info = $this->pdo->query(
"
SELECT MIN(COALESCE(tb_created, document_date)) AS first_created,
MIN(CASE WHEN summary_tb_modified IS NULL THEN COALESCE(tb_created, document_date) END) AS first_missing,
COUNT(*) AS n
FROM mago_sale_doc
WHERE document_date >= " . $this->pdo->quote($from)
)->fetch(PDO::FETCH_ASSOC);
if (empty($info['n'])) {
return ['records' => 0, 'aggiornati' => 0, 'incrementale' => false, 'ms' => $this->ms($t0)];
}
// Incrementale: dal punto di ripresa, ma se ci sono documenti nuovi senza importo parto dalla loro creazione
$incremental = !$full && !$testMode && !empty($state['last_tb_modified']);
if ($incremental) {
$since = $state['last_tb_modified'];
if (!empty($info['first_missing'])) {
$miss = date('Y-m-d 00:00:00', strtotime($info['first_missing'] . ' -1 day'));
if ($miss < $since) $since = $miss;
}
} else {
$since = date('Y-m-d 00:00:00', strtotime($info['first_created'] . ' -1 day'));
}
$this->log('Importi documenti: ' . ($incremental ? 'incrementale' : 'completa') . ' da ' . $since);
$filters = [['key' => 'TbModified', 'value' => date('Y-m-d\TH:i:s', strtotime($since)), 'operator' => '>=']];
$stmt = $this->pdo->prepare("
UPDATE mago_sale_doc SET
taxable_amount = :taxable, tax_amount = :tax, total_amount = :total, summary_tb_modified = :mod
WHERE sale_doc_id = :id
");
$read = 0;
$updated = 0;
$maxModified = $state['last_tb_modified'] ?? null;
$this->pdo->beginTransaction();
try {
foreach (
$this->mago->iterate('MA_SaleDocSummary', [
'filters' => $filters,
'fields' => ['SaleDocId', 'TaxableAmount', 'TaxAmount', 'TotalAmount', 'TbModified'],
]) as $r
) {
$read++;
$mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
$stmt->execute([
'taxable' => round(MagoClient::toDecimal($r['TaxableAmount'] ?? 0), 2),
'tax' => round(MagoClient::toDecimal($r['TaxAmount'] ?? 0), 2),
'total' => round(MagoClient::toDecimal($r['TotalAmount'] ?? 0), 2),
'mod' => $mod ?: date('Y-m-d H:i:s'),
'id' => (int)($r['SaleDocId'] ?? 0),
]);
$updated += $stmt->rowCount() > 0 ? 1 : 0;
if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod;
if ($read % 200 === 0) {
$this->pdo->commit();
$this->pdo->beginTransaction();
$this->log(" totali documenti letti: $read");
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
$this->saveState(self::ENTITY_DOC_SUMM, null, 'errore', $e->getMessage(), $read, $t0, false);
throw $e;
}
$this->saveState(self::ENTITY_DOC_SUMM, $testMode ? null : $maxModified, 'ok', "Letti $read totali, $updated documenti aggiornati", $read, $t0, !$testMode);
$this->log("Importi documenti: letti $read, documenti aggiornati $updated");
return ['records' => $read, 'aggiornati' => $updated, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
}
private function tableExists($name)
{
$st = $this->pdo->prepare("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?");
$st->execute([$name]);
return (int)$st->fetchColumn() > 0;
}
/* =========================================================
* ANAGRAFICA CLIENTI (per decodificare i codici cliente)
* ========================================================= */
public function syncCustomers($full = false)
{
$t0 = microtime(true);
$state = $this->getState(self::ENTITY_CUSTOMER);
$filters = [['key' => 'CustSuppType', 'value' => (string)self::CUSTOMER_TYPE, 'operator' => '=']];
$incremental = !$full && !empty($state['last_tb_modified']);
if ($incremental) {
$filters[] = [
'key' => 'TbModified',
'value' => date('Y-m-d\TH:i:s', strtotime($state['last_tb_modified'])),
'operator' => '>=',
];
}
$this->log('Clienti: ' . ($incremental ? 'incrementale' : 'completa'));
$stmt = $this->pdo->prepare("
INSERT INTO mago_cust_supp
(cust_supp_type, cust_supp, company_name, city, county, region, country, iso_country_code, disabled, tb_modified, synced_at)
VALUES
(:t, :c, :name, :city, :county, :region, :country, :iso, :disabled, :mod, NOW())
ON DUPLICATE KEY UPDATE
company_name = VALUES(company_name), city = VALUES(city), county = VALUES(county),
region = VALUES(region), country = VALUES(country), iso_country_code = VALUES(iso_country_code),
disabled = VALUES(disabled), tb_modified = VALUES(tb_modified), synced_at = NOW()
");
$count = 0;
$maxModified = $state['last_tb_modified'] ?? null;
$this->pdo->beginTransaction();
try {
foreach ($this->mago->iterate('MA_CustSupp', ['filters' => $filters]) as $r) {
$mod = MagoClient::toMysqlDate($r['TbModified'] ?? null, true);
$stmt->execute([
't' => (int)($r['CustSuppType'] ?? self::CUSTOMER_TYPE),
'c' => mb_substr(trim((string)($r['CustSupp'] ?? '')), 0, 20),
'name' => mb_substr(trim((string)($r['CompanyName'] ?? '')), 0, 200),
'city' => mb_substr(trim((string)($r['City'] ?? '')), 0, 100),
'county' => mb_substr(trim((string)($r['County'] ?? '')), 0, 10),
'region' => mb_substr(trim((string)($r['Region'] ?? '')), 0, 60),
'country' => mb_substr(trim((string)($r['Country'] ?? '')), 0, 60),
'iso' => mb_substr(trim((string)($r['ISOCountryCode'] ?? '')), 0, 4),
'disabled' => MagoClient::toBool($r['Disabled'] ?? false),
'mod' => $mod,
]);
$count++;
if ($mod && (!$maxModified || $mod > $maxModified)) $maxModified = $mod;
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
$this->saveState(self::ENTITY_CUSTOMER, null, 'errore', $e->getMessage(), $count, $t0, false);
throw $e;
}
$this->saveState(self::ENTITY_CUSTOMER, $maxModified, 'ok', "$count clienti", $count, $t0, true);
$this->log("Clienti sincronizzati: $count");
return ['records' => $count, 'incrementale' => $incremental, 'ms' => $this->ms($t0)];
}
/* =========================================================
* STATO / LOCK / LOG
* ========================================================= */
private function getState($entity)
{
$st = $this->pdo->prepare("SELECT * FROM mago_sync_state WHERE entity = ?");
$st->execute([$entity]);
return $st->fetch(PDO::FETCH_ASSOC) ?: [];
}
private function saveState($entity, $lastModified, $status, $message, $records, $t0, $updateCursor)
{
$sql = "INSERT INTO mago_sync_state (entity, last_tb_modified, last_run_at, last_status, last_message, records_last_run, duration_ms)
VALUES (:e, :m, NOW(), :s, :msg, :r, :d)
ON DUPLICATE KEY UPDATE
" . ($updateCursor ? "last_tb_modified = COALESCE(VALUES(last_tb_modified), last_tb_modified)," : "") . "
last_run_at = NOW(),
last_status = VALUES(last_status),
last_message = VALUES(last_message),
records_last_run = VALUES(records_last_run),
duration_ms = VALUES(duration_ms)";
$this->pdo->prepare($sql)->execute([
'e' => $entity,
'm' => $updateCursor ? $lastModified : null,
's' => $status,
'msg' => mb_substr((string)$message, 0, 2000),
'r' => (int)$records,
'd' => (int)round((microtime(true) - $t0) * 1000),
]);
}
private function acquireLock()
{
return (int)$this->pdo->query("SELECT GET_LOCK('mago_sync', 0)")->fetchColumn() === 1;
}
private function releaseLock()
{
try {
$this->pdo->query("SELECT RELEASE_LOCK('mago_sync')");
} catch (Throwable $e) {
}
}
private function ms($t0)
{
return (int)round((microtime(true) - $t0) * 1000);
}
private function log($msg)
{
if ($this->logger) {
call_user_func($this->logger, '[' . date('H:i:s') . '] ' . $msg);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* Preferenze di interfaccia per utente (posizione, larghezza e visibilità dei riquadri nelle pagine).
*
* Tabella user_ui_prefs: una riga per utente e pagina, layout in JSON:
* { "order": ["orders","monthly",...], "hidden": ["items"], "sizes": {"monthly": 12} }
*/
class UiPrefs
{
const SIZES = [4, 5, 6, 7, 8, 12];
public static function validPage(string $page): bool
{
return (bool)preg_match('/^[a-z0-9_]{1,50}$/', $page);
}
/** Layout salvato (array vuoto se non c'è o se la tabella non esiste ancora). */
public static function get(PDO $pdo, int $userId, string $page): array
{
if ($userId <= 0 || !self::validPage($page)) return [];
try {
$st = $pdo->prepare('SELECT layout FROM user_ui_prefs WHERE user_id = ? AND page = ?');
$st->execute([$userId, $page]);
$j = $st->fetchColumn();
$d = $j ? json_decode($j, true) : null;
return is_array($d) ? self::clean($d) : [];
} catch (Throwable $e) {
return [];
}
}
public static function save(PDO $pdo, int $userId, string $page, array $layout): bool
{
if ($userId <= 0 || !self::validPage($page)) return false;
$json = json_encode(self::clean($layout));
$st = $pdo->prepare('INSERT INTO user_ui_prefs (user_id, page, layout, updated_at) VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE layout = VALUES(layout), updated_at = NOW()');
return $st->execute([$userId, $page, $json]);
}
public static function reset(PDO $pdo, int $userId, string $page): bool
{
if ($userId <= 0 || !self::validPage($page)) return false;
$st = $pdo->prepare('DELETE FROM user_ui_prefs WHERE user_id = ? AND page = ?');
return $st->execute([$userId, $page]);
}
/** Attributi HTML per il contenitore dei riquadri: data-layout + data-prefs. */
public static function attr(PDO $pdo, $userId, string $page): string
{
$p = self::get($pdo, (int)$userId, $page);
return 'data-layout="' . htmlspecialchars($page) . '" data-prefs="'
. htmlspecialchars(json_encode($p ?: new stdClass()), ENT_QUOTES) . '"';
}
private static function clean(array $d): array
{
$key = function ($k) {
return is_string($k) && preg_match('/^[a-z0-9_]{1,40}$/', $k);
};
$order = array_values(array_unique(array_filter((array)($d['order'] ?? []), $key)));
$hidden = array_values(array_unique(array_filter((array)($d['hidden'] ?? []), $key)));
$sizes = [];
foreach ((array)($d['sizes'] ?? []) as $k => $v) {
if ($key($k) && in_array((int)$v, self::SIZES, true)) $sizes[$k] = (int)$v;
}
return [
'order' => array_slice($order, 0, 60),
'hidden' => array_slice($hidden, 0, 60),
'sizes' => $sizes ?: new stdClass(),
];
}
}
+127
View File
@@ -154,4 +154,131 @@
.fa {
color: #1f2d3d !important;
}
.topbar .navbar {
position: relative;
}
/* ======= MARCHIO SMARTMES (centrato nella topbar) ======= */
.smes-brand {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
white-space: nowrap;
z-index: 1;
}
.smes-mark svg {
width: 34px;
height: 34px;
display: block;
border-radius: 9px;
box-shadow: 0 0 0 2px rgba(255, 255, 255, .85), 0 3px 8px rgba(15, 30, 70, .25);
}
.smes-text {
display: flex;
flex-direction: column;
line-height: 1.05;
}
.smes-name {
font-size: 1.3rem;
font-weight: 600;
letter-spacing: -.3px;
color: #0f1e46;
}
.smes-accent {
font-weight: 800;
color: #1a3fd1;
}
.smes-sub {
font-size: .6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.2px;
color: #0f1e46;
opacity: .65;
margin-top: 3px;
}
/* Tema scuro */
html.dark-theme .smes-name {
color: #e2e8f0;
}
html.dark-theme .smes-accent {
color: #7aa2ff;
}
html.dark-theme .smes-sub {
color: #94a3b8;
}
/* Tablet e telefono: il marchio occupa il suo spazio (niente sovrapposizioni) */
@media (max-width: 991px) {
.smes-sub {
display: none;
}
.smes-brand {
position: static;
transform: none;
margin-left: auto;
margin-right: auto;
min-width: 0;
}
.topbar .navbar .top-menu {
margin-left: 0 !important;
}
}
/* Telefono */
@media (max-width: 576px) {
.smes-brand {
gap: 7px;
}
.smes-mark svg {
width: 28px;
height: 28px;
}
.smes-name {
font-size: 1.1rem;
}
}
/* Telefono stretto: recupero spazio dalla barra */
@media (max-width: 400px) {
.topbar .navbar {
gap: .5rem !important;
}
.topbar .user-box {
padding-left: .25rem !important;
padding-right: .25rem !important;
}
.smes-brand {
gap: 6px;
}
.smes-mark svg {
width: 24px;
height: 24px;
}
.smes-name {
font-size: .95rem;
}
}
</style>
@@ -0,0 +1,51 @@
<?php
/**
* download_worksheet_file.php?id=WORKSHEET_ID
* -------------------------------------------------------------------------
* Restituisce il file Excel di origine allegato a un foglio di lavoro,
* con il nome file originale. Protetto contro path-traversal.
* -------------------------------------------------------------------------
*/
include('include/headscript.php');
$id = isset($_GET['id']) && is_numeric($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
http_response_code(400);
exit('ID non valido');
}
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare("SELECT source_xlsx_name, source_xlsx_path FROM work_sheets WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || empty($row['source_xlsx_path'])) {
http_response_code(404);
exit('Nessun file allegato a questo foglio');
}
// Risoluzione sicura: il file deve stare dentro uploads/worksheets
$abs = __DIR__ . '/' . $row['source_xlsx_path'];
$real = realpath($abs);
$baseDir = realpath(__DIR__ . '/uploads/worksheets');
if ($real === false || $baseDir === false || strpos($real, $baseDir) !== 0 || !is_file($real)) {
http_response_code(404);
exit('File non trovato');
}
$downloadName = $row['source_xlsx_name'] !== '' ? $row['source_xlsx_name'] : basename($real);
$asciiName = preg_replace('/[^A-Za-z0-9._-]/', '_', $downloadName);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header("Content-Disposition: attachment; filename=\"{$asciiName}\"; filename*=UTF-8''" . rawurlencode($downloadName));
header('Content-Length: ' . filesize($real));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, max-age=0, must-revalidate');
readfile($real);
exit;
+7
View File
@@ -121,6 +121,13 @@
value="<?= htmlspecialchars($line['color'] ?? '#dc2626') ?>">
</div>
<div class="mb-3">
<label for="maxHours" class="form-label fw-semibold">Ore massime al giorno</label>
<input type="number" step="0.5" min="0" max="24" id="maxHours" name="max_hours_per_day"
class="form-control" value="<?= htmlspecialchars($line['max_hours_per_day'] ?? 24) ?>">
<small class="text-muted">Capacità teorica giornaliera. Default 24 (h24).</small>
</div>
<div class="mb-3">
<label for="status" class="form-label fw-semibold">Stato</label>
<select id="status" name="status" class="form-select">
+6 -5
View File
@@ -6,10 +6,11 @@ $pdo = DBHandlerSelect::getInstance()->getConnection();
/* ==========================================
PERMISSIONS
========================================== */
$isHrManager = Auth::user()->hasRole('Admin')
|| Auth::user()->hasRole('Superuser')
|| Auth::user()->hasRole('employee-hr')
|| Auth::user()->hasRole('manager');
$canEdit = userCan('hr.employee_profiles.edit');
$canViewOthers = $canEdit || userCan('hr.employee_profiles.view');
// Compatibilità con il resto della pagina (pulsante "Torna ai Dipendenti" ecc.)
$isHrManager = $canViewOthers;
/* ==========================================
RESOLVE TARGET EMPLOYEE
@@ -67,7 +68,7 @@ if (!$isHrManager && $employee && (int)$employee['auth_user_id'] !== (int)$iduse
exit;
}
$canEdit = $isHrManager;
/* ==========================================
EMPLOYEE JOB ROLES / SUB ROLES (multi assignment)
@@ -27,6 +27,9 @@ try {
file_type,
description,
sort_order,
is_disegno,
is_strumento,
is_ciclo,
created_at,
updated_at
FROM matrice_attachments
@@ -50,6 +53,9 @@ try {
'file_type' => $row['file_type'],
'description' => $row['description'] ?? '',
'sort_order' => (int)($row['sort_order'] ?? 0),
'is_disegno' => (int)($row['is_disegno'] ?? 0),
'is_strumento' => (int)($row['is_strumento'] ?? 0),
'is_ciclo' => (int)($row['is_ciclo'] ?? 0),
'created_at' => !empty($row['created_at']) ? date('d/m/Y H:i', strtotime($row['created_at'])) : '',
'updated_at' => !empty($row['updated_at']) ? date('d/m/Y H:i', strtotime($row['updated_at'])) : ''
];
+642
View File
@@ -0,0 +1,642 @@
<?php
/**
* import_worksheet_xlsx.php
* -------------------------------------------------------------------------
* Riceve un file .xlsx (modulo ZIBOGOMMA "FOGLIO DI LAVORO") via POST
* multipart (campo file: "xlsx_file") e restituisce una PROPOSTA in JSON
* pronta per essere revisionata dall'utente in manage-worksheet.php.
*
* NON scrive nulla sul database: fa solo lettura (mescole, packaging_items)
* per proporre gli abbinamenti. Il salvataggio resta agli endpoint esistenti.
*
* Dipendenze: solo estensioni PHP standard (zip, xml/simplexml). Niente Composer.
* -------------------------------------------------------------------------
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
// Connessione DB (stesso pattern di manage-worksheet.php)
include('include/headscript.php');
require __DIR__ . '/../../vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as XlsDate;
function fail($msg)
{
echo json_encode(['success' => false, 'message' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
/* =========================================================================
* 1) READER XLSX (senza dipendenze)
* ========================================================================= */
function excel_serial_to_date($serial)
{
$days = (int)floor((float)$serial);
$base = new DateTime('1899-12-30');
$base->modify("+{$days} day");
return $base->format('Y-m-d');
}
function read_xlsx_grid($path)
{
try {
$reader = IOFactory::createReader('Xlsx');
// niente setReadDataOnly(true): serve il formato per riconoscere le date
$spreadsheet = $reader->load($path);
} catch (\Throwable $e) {
throw new Exception('Impossibile leggere il file .xlsx: ' . $e->getMessage());
}
$sheet = $spreadsheet->getSheet(0); // primo foglio
$grid = [];
foreach ($sheet->getRowIterator() as $row) {
$rowN = $row->getRowIndex();
$it = $row->getCellIterator();
$it->setIterateOnlyExistingCells(true);
foreach ($it as $c) {
$colL = $c->getColumn(); // lettera colonna, es. "D"
// IMPORTANTE: nel modulo ZIBOGOMMA quasi tutte le celle sono formule
// che puntano a un file esterno ('[1] Base2'!...). Il valore reale sta
// nella CACHE della cella. Perciò:
// - cella formula -> getOldCalculatedValue() (valore in cache, NO ricalcolo)
// - cella normale -> getValue()
$val = $c->isFormula() ? $c->getOldCalculatedValue() : $c->getValue();
if ($val === null || $val === '') continue;
if (XlsDate::isDateTime($c) && is_numeric($val)) {
$val = XlsDate::excelToDateTimeObject((float)$val)->format('Y-m-d');
} elseif (is_numeric($val)) {
$val = $val + 0;
}
$grid[$rowN][$colL] = $val;
}
}
return $grid;
}
/* =========================================================================
* 2) ESTRATTORE label-anchored (ancorato alle etichette in colonna A)
* ========================================================================= */
function norm($s)
{
$s = (string)$s;
$s = str_replace(
['à', 'è', 'é', 'ì', 'ò', 'ù', 'À', 'È', 'É', 'Ì', 'Ò', 'Ù'],
['a', 'e', 'e', 'i', 'o', 'u', 'a', 'e', 'e', 'i', 'o', 'u'],
$s
);
$s = strtolower($s);
$s = preg_replace('/[^a-z0-9\/ ]+/', ' ', $s);
$s = preg_replace('/\s+/', ' ', $s);
return trim($s);
}
function cell($grid, $row, $col)
{
return isset($grid[$row][$col]) ? trim((string)$grid[$row][$col]) : '';
}
function row_by_label($grid, $label, $col = 'A')
{
$target = norm($label);
foreach ($grid as $rn => $cols) {
$a = norm($cols[$col] ?? '');
if ($a === $target || ($target !== '' && strpos($a, $target) === 0)) return $rn;
}
return null;
}
function val_at_label($grid, $label, $valcol = 'D')
{
$r = row_by_label($grid, $label);
return $r ? cell($grid, $r, $valcol) : '';
}
function is_known_label($a)
{
$known = [
'foglio di lavoro',
'data',
'cliente',
'tipo profilo',
'peso a metro calcolato g/m',
'nome mescola in entrata',
'densita richiesta g/cm3',
'durezza richiesta shore a',
'marchiatura',
'lubrificazione',
'impostazione misure per il controllo della produzione',
'impostazione lunghezza spezzoni',
'frequenza di controllo',
'frequenza di controllo disegno',
'frequenza di controllo in dima',
'modalita di controllo in dima',
'confezione richiesta',
'metri/confezione',
'tipo scatola',
'n confezioni o pezzi per scatola',
'metri/scatola',
'tipo di bancale',
'n confezioni o scatole per bancale',
'velocita di produzione'
];
foreach ($known as $k) if (strpos($a, $k) === 0) return true;
return false;
}
function get_freq_cut($grid)
{
foreach ($grid as $rn => $cols) {
if (norm($cols['A'] ?? '') === 'frequenza di controllo') return cell($grid, $rn, 'D');
}
return '';
}
function num_or_null($v)
{
if ($v === '' || $v === null) return null;
return is_numeric($v) ? $v + 0 : null;
}
function dotdec_to_comma($v)
{
// 1.2 -> 1,2 ma lascia intatto "+/-0,02"
return preg_replace('/(\d)\.(\d)/', '$1,$2', (string)$v);
}
/* ---- sezione "Imballo e confezionamento" ---- */
/** primo numero intero della cella ("80", "160 m", "12 o 14" -> 12) */
function first_int($v)
{
if (is_int($v) || is_float($v)) return (int)round($v);
if (preg_match('/\d+(?:[.,]\d+)?/', (string)$v, $m)) return (int)round((float)str_replace(',', '.', $m[0]));
return null;
}
/** la cella ha più numeri alternativi? ("12 o 14") */
function has_multi_num($v)
{
return preg_match_all('/\d+/', (string)$v) > 1;
}
function is_tolerance_txt($v)
{
$v = strtolower((string)$v);
return strpos($v, 'toll') !== false || preg_match('/[+-]\s*\d/', $v);
}
function fmt_num($n)
{
$f = (float)$n;
return (floor($f) == $f) ? (string)(int)$f : rtrim(rtrim(number_format($f, 2, '.', ''), '0'), '.');
}
/** "Toll Metrat. + 1,5m/-0" -> "-0/+1.5m" "+/- 4m" -> "+/- 4m" */
function parse_tolerance($v)
{
$s = str_replace(',', '.', (string)$v);
$num = '(\d+(?:\.\d+)?)';
if (preg_match('/\+\s*\/\s*-\s*' . $num . '/', $s, $m) || preg_match('/±\s*' . $num . '/u', $s, $m)) {
return '+/- ' . fmt_num($m[1]) . 'm';
}
$plus = preg_match('/\+\s*' . $num . '/', $s, $mp) ? fmt_num($mp[1]) : null;
$minus = preg_match('/-\s*' . $num . '/', $s, $mm) ? fmt_num($mm[1]) : null;
if ($plus !== null || $minus !== null) return '-' . ($minus ?? '0') . '/+' . ($plus ?? '0') . 'm';
return trim((string)$v); // formato non riconosciuto: testo originale
}
function extract_packaging($grid)
{
$out = [
'meters_per_package' => null,
'meters_per_package_tolerance' => '',
'meters_per_package_notes' => '',
'packages_or_pieces_per_box' => null,
'meters_per_box' => null,
'boxes_or_packages_per_pallet' => null,
];
$notes = [];
// metri/confezione: valore in D, note in E, tolleranza in E o nelle righe sotto senza etichetta
$r = row_by_label($grid, 'metri/confezione');
if ($r) {
$d = cell($grid, $r, 'D');
$out['meters_per_package'] = first_int($d);
$cand = [cell($grid, $r, 'E')];
for ($i = $r + 1; $i <= $r + 3 && norm(cell($grid, $i, 'A')) === ''; $i++) {
$cand[] = cell($grid, $i, 'D');
$cand[] = cell($grid, $i, 'E');
}
if (is_tolerance_txt($d) && has_multi_num($d)) $cand[] = $d; // "80 +1,5/-0" nella stessa cella
foreach ($cand as $c) {
if ($c === '') continue;
if ($out['meters_per_package_tolerance'] === '' && is_tolerance_txt($c)) {
$out['meters_per_package_tolerance'] = parse_tolerance($c);
} elseif (!is_tolerance_txt($c)) {
$notes[] = $c; // es. "40x2"
}
}
}
$v = val_at_label($grid, 'n confezioni o pezzi per scatola', 'D');
$out['packages_or_pieces_per_box'] = first_int($v);
if (has_multi_num($v)) $notes[] = 'Conf. x scatola ' . $v;
$out['meters_per_box'] = first_int(val_at_label($grid, 'metri/scatola', 'D'));
// "12 o 14": nel campo numerico va 12, il testo completo nelle note
$v = val_at_label($grid, 'n confezioni o scatole per bancale', 'D');
$out['boxes_or_packages_per_pallet'] = first_int($v);
if (has_multi_num($v)) $notes[] = 'Scatole x Bancale ' . $v;
$out['meters_per_package_notes'] = implode('. ', array_unique($notes));
return $out;
}
function extract_fields($grid)
{
$out = [];
$wd = val_at_label($grid, 'data', 'D');
if ($wd !== '' && is_numeric($wd)) $wd = excel_serial_to_date($wd);
$out['worksheet_date'] = $wd;
$out['customer_name'] = val_at_label($grid, 'cliente', 'D');
$out['profile_type_code'] = val_at_label($grid, 'tipo profilo', 'D');
$out['marking'] = strtoupper(val_at_label($grid, 'marchiatura', 'D'));
// prod_control: riga "impostazione misure..." + continuazione senza label + "impostazione lunghezza spezzoni"
$lines = [];
$r = row_by_label($grid, 'impostazione misure per il controllo');
if ($r) {
for ($i = $r; $i <= $r + 1; $i++) {
$d = cell($grid, $i, 'D');
$e = cell($grid, $i, 'E');
if ($d !== '' && ($i === $r || norm(cell($grid, $i, 'A')) === '')) {
$lines[] = trim($d . ' ' . $e);
}
}
}
$rl = row_by_label($grid, 'impostazione lunghezza spezzoni');
if ($rl) {
$d = cell($grid, $rl, 'D');
$e = cell($grid, $rl, 'E');
if ($d !== '') $lines[] = trim($d . ' ' . $e);
}
$out['prod_control_measure_settings'] = implode("\n", $lines);
// lookup da evidenziare (RAW, value scelto dall'utente)
$out['raw_freq_cut'] = get_freq_cut($grid);
$out['raw_freq_drawing'] = val_at_label($grid, 'frequenza di controllo disegno', 'D');
$out['raw_freq_jig'] = val_at_label($grid, 'frequenza di controllo in dima', 'D');
$out['raw_mode_jig'] = val_at_label($grid, 'modalita di controllo in dima', 'D');
// mescola
$out['mescola_raw'] = val_at_label($grid, 'nome mescola in entrata', 'D');
$out['mix_weight_g_m'] = num_or_null(val_at_label($grid, 'peso a metro calcolato', 'D'));
$rd = row_by_label($grid, 'densita richiesta');
$out['required_density'] = $rd ? dotdec_to_comma(trim(cell($grid, $rd, 'D') . ' ' . cell($grid, $rd, 'E'))) : '';
$rh = row_by_label($grid, 'durezza richiesta');
$out['required_hardness_shore_a'] = $rh ? dotdec_to_comma(trim(cell($grid, $rh, 'D') . ' ' . cell($grid, $rh, 'E'))) : '';
$out['lubrication_type'] = strtoupper(val_at_label($grid, 'lubrificazione', 'D'));
// packaging (3 celle contenitore -> 3 righe)
$out['pkg_confezione_raw'] = val_at_label($grid, 'confezione richiesta', 'D');
$out['pkg_scatola_raw'] = val_at_label($grid, 'tipo scatola', 'D');
$out['pkg_bancale_raw'] = val_at_label($grid, 'tipo di bancale', 'D');
// metri/confezione, tolleranza, note, pezzi per scatola, metri/scatola, scatole per bancale
$out = array_merge($out, extract_packaging($grid));
// velocità
$out['speed_expected_kg_h'] = null;
$out['speed_expected_m_h'] = null;
$rv = row_by_label($grid, 'velocita di produzione');
if ($rv) {
$v = num_or_null(cell($grid, $rv, 'C'));
$out['speed_expected_kg_h'] = $v === null ? null : round($v, 2);
for ($i = $rv; $i <= $rv + 2; $i++) {
if (norm(cell($grid, $i, 'B')) === 'm/h') {
$v = num_or_null(cell($grid, $i, 'C'));
$out['speed_expected_m_h'] = $v === null ? null : round($v, 2);
break;
}
}
}
// note: annotazioni libere colonna E + righe istruzione in fondo
$noteBits = [];
// (metri/confezione colonna E ora va in "Note metri/confezione")
foreach (['confezione richiesta', 'metri/scatola'] as $lbl) {
$rr = row_by_label($grid, $lbl);
if ($rr && cell($grid, $rr, 'E') !== '') $noteBits[] = cell($grid, $rr, 'E');
}
foreach ($grid as $rn => $cols) {
$a = norm($cols['A'] ?? '');
if ($a !== '' && cell($grid, $rn, 'D') === '' && cell($grid, $rn, 'C') === '' && !is_known_label($a)) {
if (strlen($a) > 12) $noteBits[] = trim((string)$cols['A']);
}
}
$out['notes'] = implode("\n", array_values(array_unique(array_filter($noteBits))));
return $out;
}
/* =========================================================================
* 3) MATCHER su DB
* ========================================================================= */
function strip_code($s)
{
$s = strtolower((string)$s);
$s = str_replace(['à', 'è', 'é', 'ì', 'ò', 'ù'], ['a', 'e', 'e', 'i', 'o', 'u'], $s);
return preg_replace('/[^a-z0-9]+/', '', $s); // solo lettere + cifre (ignora spazi e punteggiatura)
}
/** porzione iniziale prima del primo separatore descrittivo (es. "ATES 71 - EPDM..." -> "ATES 71") */
function raw_head($s)
{
$parts = preg_split('/\s*[-–—|(:,]\s*/u', (string)$s, 2);
return trim($parts[0] ?? '');
}
function match_mescola($raw, array $mescole)
{
if (trim((string)$raw) === '') return [null, 'none', null];
$rawStrip = strip_code($raw);
$headStrip = strip_code(raw_head($raw));
// 1) uguaglianza ignorando spazi/punteggiatura, sul nome intero (gestisce "ATES 71" = "ATES71")
foreach ($mescole as $m) if ($rawStrip !== '' && strip_code($m['nome']) === $rawStrip) return [(int)$m['id'], 'exact', $m['nome']];
// 2) parte di nome composito (a / b / c)
foreach ($mescole as $m) foreach (explode('/', $m['nome']) as $p) if ($rawStrip !== '' && strip_code($p) === $rawStrip) return [(int)$m['id'], 'exact-part', $m['nome']];
// 3) nome in uscita
foreach ($mescole as $m) if ($rawStrip !== '' && strip_code($m['nomeuscita'] ?? '') === $rawStrip) return [(int)$m['id'], 'exact-uscita', $m['nome']];
// 4) codice iniziale prima di " - descrizione" (gestisce "ATES 71 - EPDM...")
if ($headStrip !== '' && $headStrip !== $rawStrip) {
foreach ($mescole as $m) if (strip_code($m['nome']) === $headStrip) return [(int)$m['id'], 'code', $m['nome']];
foreach ($mescole as $m) foreach (explode('/', $m['nome']) as $p) if (strip_code($p) === $headStrip) return [(int)$m['id'], 'code-part', $m['nome']];
}
return [null, 'none', null];
}
function dim_tokens($s)
{
$s = strtolower((string)$s);
$s = str_replace(',', '.', $s);
preg_match_all('/\d+(?:\.\d+)?/', $s, $m);
$nums = $m[0];
if (count($nums) < 2) return [];
$forms = [];
$forms[] = implode('x', $nums);
$mm = array_map(function ($n) {
$f = (float)$n;
if ($f < 10 && strpos($n, '.') !== false) return (string)round($f * 1000);
return (string)($f == (int)$f ? (int)$f : $f);
}, $nums);
$forms[] = implode('x', $mm);
return array_values(array_unique($forms));
}
function match_packaging($raw, array $packaging, $preferCat = null)
{
$rawStrip = strip_code($raw);
if ($rawStrip === '') return [null, 'none', null, null, true];
// 1) nome/codice ESATTO (categoria solo come spareggio) -> sicuro
$exact = [];
foreach ($packaging as $p) {
if (strip_code($p['item_code']) === $rawStrip || strip_code($p['item_name']) === $rawStrip) $exact[] = $p;
}
if ($exact) {
$pick = $exact[0];
foreach ($exact as $p) if ($preferCat && $p['category'] === $preferCat) {
$pick = $p;
break;
}
return [(int)$pick['id'], 'name-code', $pick['item_name'], $pick['category'], false];
}
// 2) DIMENSIONI -> sicuro se unico, proposta se ambiguo
$rd = dim_tokens($raw);
if ($rd) {
$dm = [];
foreach ($packaging as $p) {
$pf = array_merge(dim_tokens($p['item_name']), dim_tokens($p['item_code']));
if (array_intersect($rd, $pf)) $dm[] = $p;
}
if ($dm) {
$pick = $dm[0];
foreach ($dm as $p) if ($preferCat && $p['category'] === $preferCat) {
$pick = $p;
break;
}
$ambiguous = count($dm) > 1;
return [(int)$pick['id'], 'dimension', $pick['item_name'], $pick['category'], $ambiguous];
}
}
// 3) ABBREVIAZIONE: testo corto, prefisso UNICO di un nome -> solo proposta
if (strlen($rawStrip) <= 4) {
$cand = [];
foreach ($packaging as $p) if (strpos(strip_code($p['item_name']), $rawStrip) === 0) $cand[] = $p;
if (count($cand) === 1) return [(int)$cand[0]['id'], 'abbrev', $cand[0]['item_name'], $cand[0]['category'], true];
}
return [null, 'none', null, null, true];
}
/* ---- match generico su dropdown (ws_lookup_options) ---- */
function lk_norm($s)
{
$s = (string)$s;
$s = str_replace(
['à', 'è', 'é', 'ì', 'ò', 'ù', 'À', 'È', 'É', 'Ì', 'Ò', 'Ù'],
['a', 'e', 'e', 'i', 'o', 'u', 'a', 'e', 'e', 'i', 'o', 'u'],
$s
);
$s = strtolower($s);
$s = preg_replace('/[^a-z0-9 ]+/', ' ', $s);
$s = preg_replace('/\s+/', ' ', $s);
return trim($s);
}
function lk_toks($s)
{
$t = array_filter(explode(' ', lk_norm($s)), fn($x) => $x !== '');
return array_values(array_unique($t));
}
/** abbina il testo Excel a una voce del dropdown. Ritorna [value|null, label|null, confidence] */
function match_lookup($raw, array $options)
{
$r = lk_norm($raw);
if ($r === '') return [null, null, 'none'];
// tier 1: uguale a value o label
foreach ($options as $o) {
if (lk_norm($o['value']) === $r || lk_norm($o['label']) === $r) {
return [$o['value'], $o['label'], 'exact'];
}
}
// tier 2: sovrapposizione token >= 0.8 (set più piccolo di almeno 2 token)
$rt = lk_toks($raw);
$best = null;
$bestScore = 0.0;
foreach ($options as $o) {
$ot = lk_toks($o['label']);
if (count($ot) < 2 || count($rt) < 2) continue;
$score = count(array_intersect($rt, $ot)) / min(count($rt), count($ot));
if ($score > $bestScore) {
$bestScore = $score;
$best = $o;
}
}
if ($best && $bestScore >= 0.8) return [$best['value'], $best['label'], 'fuzzy'];
return [null, null, 'none'];
}
/** costruisce la voce JSON per un lookup di testata */
function build_lookup($raw, array $options)
{
[$v, $lab, $conf] = match_lookup($raw, $options);
return [
'raw' => $raw,
'value' => $v, // null = nessun suggerimento
'label' => $lab,
'confidence' => $conf,
'needs_review' => ($raw !== ''), // sempre da verificare se c'era un valore nel file
];
}
/* =========================================================================
* 4) MAIN
* ========================================================================= */
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['xlsx_file'])) {
fail('Nessun file ricevuto.');
}
$file = $_FILES['xlsx_file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
fail('Errore nel caricamento del file (codice ' . $file['error'] . ').');
}
if ($file['size'] > 5 * 1024 * 1024) {
fail('File troppo grande (max 5 MB).');
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'xlsx') {
fail('Formato non supportato: carica un file .xlsx.');
}
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$grid = read_xlsx_grid($file['tmp_name']);
$fields = extract_fields($grid);
$warnings = [];
// enum di sicurezza
if ($fields['marking'] !== '' && !in_array($fields['marking'], ['S', 'N'], true)) {
$warnings[] = "Marchiatura '{$fields['marking']}' non riconosciuta (attese S/N).";
$fields['marking'] = '';
}
if ($fields['lubrication_type'] !== '' && !in_array($fields['lubrication_type'], ['S', 'G', 'N'], true)) {
$warnings[] = "Lubrificazione '{$fields['lubrication_type']}' non riconosciuta (attese S/G/N).";
$fields['lubrication_type'] = '';
}
// carica tabelle di riferimento
$mescole = $pdo->query("SELECT id, nome, nomeuscita FROM mescole WHERE is_active = 1")->fetchAll(PDO::FETCH_ASSOC);
$packaging = $pdo->query("SELECT id, category, item_name, item_code FROM packaging_items WHERE is_active = 1")->fetchAll(PDO::FETCH_ASSOC);
// opzioni dei dropdown (ws_lookup_options) raggruppate per categoria
$lookupOpts = [];
foreach ($pdo->query("SELECT category, value, label FROM ws_lookup_options WHERE is_active = 1 ORDER BY sort_order ASC")->fetchAll(PDO::FETCH_ASSOC) as $o) {
$lookupOpts[$o['category']][] = $o;
}
// --- mescola (1 riga) ---
[$mid, $mconf, $mname] = match_mescola($fields['mescola_raw'], $mescole);
$mescoleOut = [];
if ($fields['mescola_raw'] !== '') {
$mescoleOut[] = [
'raw' => $fields['mescola_raw'],
'idmescola' => $mid,
'matched_name' => $mname,
'confidence' => $mconf,
'needs_review' => ($mid === null || $mconf === 'contains'),
'mix_weight_g_m' => $fields['mix_weight_g_m'],
'required_density' => $fields['required_density'],
'required_hardness_shore_a' => $fields['required_hardness_shore_a'],
'lubrication_type' => $fields['lubrication_type'],
];
}
// --- packaging (3 celle contenitore -> righe) ---
$packagesOut = [];
$pkgSpecs = [
['source' => 'Confezione richiesta', 'raw' => $fields['pkg_confezione_raw'], 'cat' => null],
['source' => 'Tipo scatola', 'raw' => $fields['pkg_scatola_raw'], 'cat' => 'BOX'],
['source' => 'Tipo di bancale', 'raw' => $fields['pkg_bancale_raw'], 'cat' => 'PALLET'],
];
foreach ($pkgSpecs as $spec) {
if ($spec['raw'] === '') continue;
[$pid, $pconf, $pname, $pcat, $pneeds] = match_packaging($spec['raw'], $packaging, $spec['cat']);
$packagesOut[] = [
'source' => $spec['source'],
'raw' => $spec['raw'],
'package_code' => $pid,
'matched_name' => $pname,
'category' => $pcat,
'confidence' => $pconf,
'needs_review' => ($pid === null) ? true : $pneeds,
];
}
// --- risposta ---
echo json_encode([
'success' => true,
'header' => [
'worksheet_date' => $fields['worksheet_date'],
'customer_name' => $fields['customer_name'],
'profile_type_code' => $fields['profile_type_code'],
'marking' => $fields['marking'],
'prod_control_measure_settings' => $fields['prod_control_measure_settings'],
'meters_per_package' => $fields['meters_per_package'],
'meters_per_package_tolerance' => $fields['meters_per_package_tolerance'],
'meters_per_package_notes' => $fields['meters_per_package_notes'],
'meters_per_box' => $fields['meters_per_box'],
'packages_or_pieces_per_box' => $fields['packages_or_pieces_per_box'],
'boxes_or_packages_per_pallet' => $fields['boxes_or_packages_per_pallet'],
'speed_expected_kg_h' => $fields['speed_expected_kg_h'],
'speed_expected_m_h' => $fields['speed_expected_m_h'],
'notes' => $fields['notes'],
],
// lookup da EVIDENZIARE: value = null, l'utente sceglie dal menu
'header_lookups' => [
'control_frequency_cut' => build_lookup($fields['raw_freq_cut'], $lookupOpts['control_frequency_cut'] ?? []),
'control_frequency_drawing' => build_lookup($fields['raw_freq_drawing'], $lookupOpts['control_frequency_drawing'] ?? []),
'control_frequency_jig' => build_lookup($fields['raw_freq_jig'], $lookupOpts['control_frequency_jig'] ?? []),
'control_mode_jig' => build_lookup($fields['raw_mode_jig'], $lookupOpts['control_mode_jig'] ?? []),
],
'mescole' => $mescoleOut,
'packages' => $packagesOut,
'warnings' => $warnings,
], JSON_UNESCAPED_UNICODE);
exit;
} catch (Exception $e) {
fail($e->getMessage());
}
+2 -1
View File
@@ -1,3 +1,4 @@
<footer class="page-footer">
<p class="mb-0">C.E. Soft srl © 2025. All right reserved.</p>
</footer>
</footer>
<?php include __DIR__ . '/session_timeout.php'; ?>
+26 -1
View File
@@ -60,4 +60,29 @@ require_once(__DIR__ . '/../../languages/en/general.php');
//include("generalsettings.php");
require_once __DIR__ . '/permissions_helper.php';
?>
require_once(__DIR__ . '/popup-moved.php');
// Timbra la sessione: tiene vive le sessioni ancora valide dell'utente e rinnova il cookie di Laravel
try {
$now = time();
$lifetime = (int)(function_exists('config') ? config('session.lifetime', 120) : 120); // minuti
$table = function_exists('config') ? config('session.table', 'auth_sessions') : 'auth_sessions';
$pdoSess = DBHandlerSelect::getInstance()->getConnection();
$pdoSess->prepare("UPDATE `$table` SET last_activity = ? WHERE user_id = ? AND last_activity > ?")
->execute([$now, $iduserlogin, $now - $lifetime * 60]);
// le pagine userarea non passano da Laravel: rinnovo io la scadenza del cookie di sessione
$cookie = function_exists('config') ? config('session.cookie') : null;
if ($cookie && !empty($_COOKIE[$cookie]) && !headers_sent() && !(function_exists('config') && config('session.expire_on_close'))) {
setcookie($cookie, $_COOKIE[$cookie], [
'expires' => $now + $lifetime * 60,
'path' => function_exists('config') ? (config('session.path') ?: '/') : '/',
'domain' => function_exists('config') ? (config('session.domain') ?: '') : '',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => function_exists('config') ? (config('session.same_site') ?: 'Lax') : 'Lax',
]);
}
} catch (\Throwable $e) { /* non bloccare la pagina */
}
+116 -32
View File
@@ -43,23 +43,7 @@
</a>
<ul>
<?php if (userCan('templates.dashboard.view')) : ?>
<li>
<a href="templates_dashboard.php">
<i class='bx bx-radio-circle'></i>
<?= htmlspecialchars($dashtemplate, ENT_QUOTES, 'UTF-8'); ?>
</a>
</li>
<?php endif; ?>
<?php if (userCan('templates.create.view')) : ?>
<li>
<a href="insert_template_xls.php">
<i class='bx bx-radio-circle'></i>
<?= htmlspecialchars($insertnewtemplatexls, ENT_QUOTES, 'UTF-8'); ?>
</a>
</li>
<?php endif; ?>
<?php if (userCan('production.programming.view')) : ?>
<li>
@@ -180,6 +164,11 @@
<i class='bx bx-radio-circle'></i>Line View
</a>
</li>
<li>
<a href="production_da_compilare.php">
<i class='bx bx-radio-circle'></i>Dati Prod
</a>
</li>
<?php endif; ?>
<?php if (userCan('production.stats.view')) : ?>
@@ -190,6 +179,14 @@
</li>
<?php endif; ?>
<?php if (userCan('production.stats.view')) : ?>
<li>
<a href="pause_stats.php">
<i class='bx bx-radio-circle'></i>Pause &amp; Fermi
</a>
</li>
<?php endif; ?>
<?php if (userCan('production.manager.view')) : ?>
<li>
<a href="manager_produzione.php">
@@ -218,6 +215,75 @@
<?php endif; ?>
<?php
$canSeeSales =
userCan('sales.stats.view')
|| userCan('sales.matrici.view')
|| userCan('sales.quote.view')
|| userCan('sales.deliveries.view');
?>
<?php if ($canSeeSales) : ?>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
<i class="bx bx-bar-chart-alt-2"></i>
</div>
<div class="menu-title">Business Overview</div>
</a>
<ul>
<?php if (userCan('sales.stats.view')) : ?>
<li>
<a href="sales_summary.php">
<i class='fa-solid fa-gauge-high'></i>Sommario
</a>
</li>
<li>
<a href="sales_orders_stats.php">
<i class='bx bx-radio-circle'></i>Andamento ordini
</a>
</li>
<li>
<a href="sales_orders_years.php">
<i class='bx bx-radio-circle'></i>Confronto anni
</a>
</li>
<li>
<a href="sales_customer.php">
<i class='fa-solid fa-address-card'></i>Scheda cliente
</a>
</li>
<?php endif; ?>
<?php if (userCan('sales.matrici.view')) : ?>
<li>
<a href="sales_matrici.php">
<i class='fa-solid fa-shapes'></i>Matrici e prezzi
</a>
</li>
<?php endif; ?>
<?php if (userCan('sales.quote.view')) : ?>
<li>
<a href="sales_preventivo.php">
<i class='fa-solid fa-calculator'></i>Preventivo
</a>
</li>
<?php endif; ?>
<?php if (userCan('sales.deliveries.view')) : ?>
<li>
<a href="sales_orders_deliveries.php">
<i class='bx bx-radio-circle'></i>Consegne
</a>
</li>
<?php endif; ?>
</ul>
</li>
<?php endif; ?>
<?php
$canSeeServices =
userCan('services.status.view')
@@ -420,24 +486,42 @@
<?php endif; ?>
</ul>
</li>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
<i class="bx bx-ruler"></i>
</div>
<div class="menu-title">Misure</div>
</a>
<ul>
<li>
<a href="cad-area.php">
<i class='bx bx-radio-circle'></i>Cad Area
</a>
</li>
</ul>
</li>
<?php endif; ?>
<?php if (userCan('kiosk.manage')) : ?>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
<i class="bx bx-ruler"></i>
</div>
<div class="menu-title">Kiosk</div>
</a>
<ul>
<li>
<a href="kiosk_admin.php">
<i class='bx bx-radio-circle'></i>Kiosk Admin
</a>
</li>
</ul>
</li>
<?php endif; ?>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
<i class="bx bx-ruler"></i>
</div>
<div class="menu-title">Misure</div>
</a>
<ul>
<li>
<a href="cad-area.php">
<i class='bx bx-radio-circle'></i>Cad Area
</a>
</li>
</ul>
</li>
<li class="menu-label">Others</li>
<li>
+158
View File
@@ -0,0 +1,158 @@
<?php
// popup-moved.php — Avviso di trasferimento webapp
// Attivo solo se nel .env: POPUP_MOVED_ENABLED=true
// --- Trova il .env risalendo le cartelle ---
$envPath = null;
$dir = __DIR__;
for ($i = 0; $i < 6; $i++) {
if (is_readable($dir . '/.env')) {
$envPath = $dir . '/.env';
break;
}
$dir = dirname($dir);
}
$enabled = false;
$url = 'https://zibo-gestionale.cesoft.io/';
if ($envPath !== null) {
foreach (file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) {
continue;
}
list($k, $v) = explode('=', $line, 2);
$k = trim($k);
$v = trim($v, " \t\"'");
if ($k === 'POPUP_MOVED_ENABLED') {
$enabled = in_array(strtolower($v), ['true', '1', 'on', 'yes'], true);
}
if ($k === 'POPUP_MOVED_URL' && $v !== '') {
$url = $v;
}
}
}
// --- Se disattivato, esci subito senza stampare nulla ---
if (!$enabled) {
return;
}
$urlSafe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
?>
<style>
#zibo-moved-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, .72);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
backdrop-filter: blur(3px);
}
#zibo-moved-box {
background: #fff;
max-width: 460px;
width: calc(100% - 40px);
border-radius: 16px;
padding: 36px 32px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, .35);
animation: ziboIn .25s ease;
}
@keyframes ziboIn {
from {
opacity: 0;
transform: translateY(12px)
}
to {
opacity: 1;
transform: none
}
}
#zibo-moved-box .zibo-ico {
width: 60px;
height: 60px;
margin: 0 auto 18px;
border-radius: 50%;
background: #eef4ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
#zibo-moved-box h2 {
margin: 0 0 10px;
font-size: 22px;
color: #0f172a;
}
#zibo-moved-box p {
margin: 0 0 8px;
color: #475569;
font-size: 15px;
line-height: 1.5;
}
#zibo-moved-box .zibo-url {
display: inline-block;
margin: 6px 0 22px;
font-weight: 600;
color: #2563eb;
word-break: break-all;
font-size: 15px;
}
#zibo-moved-box .zibo-btn-go {
display: block;
width: 100%;
background: #2563eb;
color: #fff;
border: none;
padding: 14px;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background .15s;
}
#zibo-moved-box .zibo-btn-go:hover {
background: #1d4ed8;
}
#zibo-moved-box .zibo-stay {
margin-top: 14px;
background: none;
border: none;
color: #94a3b8;
font-size: 13px;
cursor: pointer;
text-decoration: underline;
}
#zibo-moved-box .zibo-stay:hover {
color: #64748b;
}
</style>
<div id="zibo-moved-overlay">
<div id="zibo-moved-box">
<div class="zibo-ico">🚀</div>
<h2>La webapp è stata spostata</h2>
<p>L'applicazione è ora disponibile al nuovo indirizzo ufficiale:</p>
<a class="zibo-url" href="<?php echo $urlSafe; ?>"><?php echo $urlSafe; ?></a>
<a class="zibo-btn-go" href="<?php echo $urlSafe; ?>">Vai al nuovo sito</a>
<button class="zibo-stay" onclick="document.getElementById('zibo-moved-overlay').style.display='none'">
Resta su questo sito (sconsigliato — non è più aggiornato)
</button>
</div>
</div>
@@ -0,0 +1,32 @@
<?php
/**
* include/session_timeout.php - avviso "stai per essere scollegato" su tutte le pagine userarea.
* Da includere una volta in fondo a include/footer.php.
*
* Per escludere una pagina (es. postazioni tablet sempre accese) mettere PRIMA dell'include del footer:
* $sessionTimeoutDisabled = true;
*/
if (!empty($sessionTimeoutDisabled)) return;
// durata della sessione di Vanguard (minuti), come nel .env (SESSION_LIFETIME)
$__stLifetime = 120;
try {
if (function_exists('config')) $__stLifetime = (int)config('session.lifetime', 120);
} catch (\Throwable $e) {
}
// indirizzo di uscita: la parte prima di /userarea/ + /logout (come il redirect al login di headscript.php)
$__stScript = $_SERVER['SCRIPT_NAME'] ?? '';
$__stPos = strpos($__stScript, '/userarea/');
$__stBase = $__stPos !== false ? substr($__stScript, 0, $__stPos) : '';
?>
<script>
window.SESSION_TIMEOUT = {
lifetimeMin: <?= max(5, (int)$__stLifetime) ?>,
logoutUrl: <?= json_encode($__stBase . '/logout') ?>,
loginUrl: <?= json_encode($__stBase . '/login') ?>,
pingUrl: 'session_ping.php'
};
</script>
<script src="session_timeout.js?v=1"></script>
+38 -8
View File
@@ -1,10 +1,10 @@
<?php
// Build an absolute URL to employee-profile.php so it works from any depth
// (e.g. /userarea/index.php, /userarea/scadenzario/index.php).
$__scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
$__pos = strpos($__scriptName, '/userarea/');
$__base = $__pos !== false ? substr($__scriptName, 0, $__pos) : '';
$__myProfileHref = $__base . '/userarea/employee-profile.php';
// Build an absolute URL to employee-profile.php so it works from any depth
// (e.g. /userarea/index.php, /userarea/scadenzario/index.php).
$__scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
$__pos = strpos($__scriptName, '/userarea/');
$__base = $__pos !== false ? substr($__scriptName, 0, $__pos) : '';
$__myProfileHref = $__base . '/userarea/employee-profile.php';
?>
<header>
<div class="topbar d-flex align-items-center">
@@ -12,6 +12,27 @@
<div class="mobile-toggle-menu"><i class='bx bx-menu'></i>
</div>
<a href="production_dashboard.php" class="smes-brand" aria-label="SmartMes">
<span class="smes-mark">
<svg viewBox="0 0 32 32" aria-hidden="true">
<defs>
<linearGradient id="smesGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#2563eb" />
<stop offset="1" stop-color="#7c3aed" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#smesGrad)" />
<path d="M7 21l5-6 4 4 4-7 5 5" fill="none" stroke="#fff" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round" />
<circle cx="25" cy="17" r="1.8" fill="#fff" />
</svg>
</span>
<span class="smes-text">
<span class="smes-name">Smart<span class="smes-accent">Mes</span></span>
<span class="smes-sub">Manufacturing Execution System</span>
</span>
</a>
<div class="search-bar d-lg-block d-none" data-bs-toggle="modal" data-bs-target="#SearchModal">
<!-- <a href="javascript:;" class="btn d-flex align-items-center"><i class='bx bx-search'></i>Search</a> -->
</div>
@@ -95,7 +116,7 @@
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item d-flex align-items-center" href="<?= htmlspecialchars($__myProfileHref) ?>"
onclick="event.preventDefault(); window.location.assign(this.href);">
onclick="event.preventDefault(); window.location.assign(this.href);">
<i class="bx bx-id-card fs-5"></i><span>Il Mio Profilo</span>
</a>
</li>
@@ -104,6 +125,15 @@
<i class="bx bx-user fs-5"></i><span>Utente</span>
</a>
</li>
<li>
<a class="dropdown-item d-flex align-items-center" href="notification_settings.php">
<i class="bx bx-bell fs-5"></i><span>Notifiche</span>
</a>
</li>
<li>
<div class="dropdown-divider mb-0"></div>
</li>
<li>
<div class="dropdown-divider mb-0"></div>
</li>
@@ -117,4 +147,4 @@
</div>
</nav>
</div>
</header>
</header>
+45 -34
View File
@@ -1,4 +1,5 @@
<?php
/**
* Training reminders widget for the production dashboard.
* Visible to HR / manager / Admin / User / Superuser.
@@ -9,8 +10,15 @@ if (!isset($pdo)) {
$pdo = DBHandlerSelect::getInstance()->getConnection();
}
/* Check user webapp notification preference */
$__notifyStmt = $pdo->prepare("SELECT notify_webapp FROM auth_users WHERE id = ? LIMIT 1");
$__notifyStmt->execute([(int)$iduserlogin]);
if ((int)$__notifyStmt->fetchColumn() !== 1) {
return;
}
$__trWidgetHr = isset($user)
&& ( $user->hasRole('Admin')
&& ($user->hasRole('Admin')
|| $user->hasRole('Superuser')
|| $user->hasRole('employee-hr')
|| $user->hasRole('manager'));
@@ -47,8 +55,11 @@ foreach ($__trRows as $__r) {
$__due = DateTime::createFromFormat('Y-m-d', $__r['next_due_date']);
if (!$__due) continue;
$__days = (int)$__today->diff($__due)->format('%r%a');
if ($__days < 0) { $__expiredCount++; }
elseif ($__days <= $__rem) { $__dueSoonCount++; }
if ($__days < 0) {
$__expiredCount++;
} elseif ($__days <= $__rem) {
$__dueSoonCount++;
}
}
/* Missing mandatory trainings (status = not_present) */
@@ -68,34 +79,34 @@ if ($__expiredCount === 0 && $__dueSoonCount === 0 && $__notPresentCount === 0)
}
?>
<div class="my-deadlines-widgets">
<?php if ($__expiredCount > 0): ?>
<a class="mdw mdw-red" href="trainings.php?status=expired">
<span class="mdw-icon"><i class="fa-solid fa-graduation-cap"></i></span>
<span class="mdw-body">
<span class="mdw-count"><?= (int)$__expiredCount ?></span>
<span class="mdw-label d-block">Formazion<?= $__expiredCount === 1 ? 'e scaduta' : 'i scadute' ?></span>
</span>
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
</a>
<?php endif; ?>
<?php if ($__dueSoonCount > 0): ?>
<a class="mdw mdw-orange" href="trainings.php?status=due_soon">
<span class="mdw-icon"><i class="fa-solid fa-hourglass-half"></i></span>
<span class="mdw-body">
<span class="mdw-count"><?= (int)$__dueSoonCount ?></span>
<span class="mdw-label d-block">Formazion<?= $__dueSoonCount === 1 ? 'e da aggiornare' : 'i da aggiornare' ?></span>
</span>
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
</a>
<?php endif; ?>
<?php if ($__notPresentCount > 0): ?>
<a class="mdw mdw-gray" href="trainings.php?status=not_present">
<span class="mdw-icon"><i class="fa-solid fa-circle-question"></i></span>
<span class="mdw-body">
<span class="mdw-count"><?= (int)$__notPresentCount ?></span>
<span class="mdw-label d-block">Obbligator<?= $__notPresentCount === 1 ? 'ia non presente' : 'ie non presenti' ?></span>
</span>
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
</a>
<?php endif; ?>
</div>
<?php if ($__expiredCount > 0): ?>
<a class="mdw mdw-red" href="trainings.php?status=expired">
<span class="mdw-icon"><i class="fa-solid fa-graduation-cap"></i></span>
<span class="mdw-body">
<span class="mdw-count"><?= (int)$__expiredCount ?></span>
<span class="mdw-label d-block">Formazion<?= $__expiredCount === 1 ? 'e scaduta' : 'i scadute' ?></span>
</span>
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
</a>
<?php endif; ?>
<?php if ($__dueSoonCount > 0): ?>
<a class="mdw mdw-orange" href="trainings.php?status=due_soon">
<span class="mdw-icon"><i class="fa-solid fa-hourglass-half"></i></span>
<span class="mdw-body">
<span class="mdw-count"><?= (int)$__dueSoonCount ?></span>
<span class="mdw-label d-block">Formazion<?= $__dueSoonCount === 1 ? 'e da aggiornare' : 'i da aggiornare' ?></span>
</span>
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
</a>
<?php endif; ?>
<?php if ($__notPresentCount > 0): ?>
<a class="mdw mdw-gray" href="trainings.php?status=not_present">
<span class="mdw-icon"><i class="fa-solid fa-circle-question"></i></span>
<span class="mdw-body">
<span class="mdw-count"><?= (int)$__notPresentCount ?></span>
<span class="mdw-label d-block">Obbligator<?= $__notPresentCount === 1 ? 'ia non presente' : 'ie non presenti' ?></span>
</span>
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
</a>
<?php endif; ?>
</div>
@@ -1,3 +1,323 @@
<style>
.ws-tabs {
display: flex;
gap: 8px;
border-bottom: 2px solid #dbeafe;
margin-bottom: 16px;
flex-wrap: wrap;
}
.ws-tab {
border: 0;
background: #f1f5f9;
color: #475569;
font-weight: 700;
padding: 10px 18px;
border-radius: 12px 12px 0 0;
cursor: pointer;
font-size: 1rem;
}
.ws-tab.active {
background: #0d6efd;
color: #fff;
}
.ws-tab[data-tab="disegni"].active {
background: #6f42c1;
}
.ws-tab[data-tab="strumenti"].active {
background: #fd7e14;
}
.ws-tab[data-tab="cicli"].active {
background: #0f9d77;
}
.ws-tab-count {
display: inline-block;
min-width: 24px;
padding: 1px 7px;
margin-left: 6px;
border-radius: 999px;
background: rgba(0, 0, 0, .12);
font-size: .8rem;
}
.ws-tab-pane {
display: none;
}
.ws-tab-pane.active {
display: block;
}
.ws-disegni-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.ws-disegno-item {
border: 2px solid #6f42c1;
background: #fff;
color: #6f42c1;
font-weight: 700;
padding: 8px 14px;
border-radius: 10px;
cursor: pointer;
}
.ws-disegno-item.active {
background: #6f42c1;
color: #fff;
}
.ws-disegno-item[data-kind="strumenti"] {
border-color: #fd7e14;
color: #fd7e14;
}
.ws-disegno-item[data-kind="strumenti"].active {
background: #fd7e14;
color: #fff;
}
.ws-disegno-item[data-kind="cicli"] {
border-color: #0f9d77;
color: #0f9d77;
}
.ws-disegno-item[data-kind="cicli"].active {
background: #0f9d77;
color: #fff;
}
.ws-disegno-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.ws-disegno-viewer {
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
background: #f8fafc;
height: 70vh;
}
.ws-disegno-viewer iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
display: block;
}
.ws-disegno-viewer .img-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #111;
}
.ws-disegno-viewer img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* ================================================================
Dettaglio foglio in sola lettura (stesse sezioni e colori
di manage-worksheet.php)
================================================================ */
.wsd-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.wsd-sum-item {
background: #fff;
border: 1px solid #e5edf6;
border-radius: 14px;
padding: 10px 14px;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.wsd-sum-value {
font-size: 1.05rem;
font-weight: 800;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wsd-section {
border: 1px solid #e7edf5;
border-radius: 16px;
background: #fff;
overflow: hidden;
height: 100%;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
}
.wsd-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-weight: 800;
font-size: 1rem;
}
.wsd-head-badge {
background: rgba(255, 255, 255, .7);
border-radius: 999px;
padding: 3px 10px;
font-size: .78rem;
font-weight: 700;
}
.wsd-body {
padding: 14px 16px;
}
.wsd-blue .wsd-head {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #123b7a;
}
.wsd-green .wsd-head {
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
color: #14532d;
}
.wsd-orange .wsd-head {
background: linear-gradient(135deg, #ffedd5, #fed7aa);
color: #9a3412;
}
.wsd-purple .wsd-head {
background: linear-gradient(135deg, #ede9fe, #ddd6fe);
color: #5b21b6;
}
.wsd-gray .wsd-head {
background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
color: #374151;
}
.wsd-cyan .wsd-head {
background: linear-gradient(135deg, #cffafe, #a5f3fc);
color: #155e75;
}
.wsd-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px 16px;
}
.wsd-label {
font-size: .75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .03em;
color: #6b7280;
margin-bottom: 2px;
}
.wsd-value {
font-size: 1.02rem;
font-weight: 700;
color: #111827;
word-break: break-word;
}
.wsd-empty {
color: #b6bec9 !important;
font-weight: 400 !important;
font-style: italic;
}
.wsd-text-block {
margin-top: 14px;
}
.wsd-text {
background: #f8fafc;
border: 1px solid #e9eef5;
border-radius: 10px;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
color: #1f2937;
}
.wsd-meta {
margin-top: 14px;
padding-top: 10px;
border-top: 1px dashed #e5e7eb;
font-size: .82rem;
color: #6b7280;
}
.wsd-table thead th {
background: #eef4fb !important;
color: #163a63;
font-weight: 800;
font-size: .88rem;
}
.wsd-speed {
width: 100%;
border-collapse: separate;
border-spacing: 0 6px;
}
.wsd-speed thead th {
font-size: .75rem;
font-weight: 700;
text-transform: uppercase;
color: #6b7280;
text-align: center;
}
.wsd-speed tbody th {
width: 70px;
color: #5b21b6;
font-weight: 800;
}
.wsd-speed td {
text-align: center;
font-size: 1.15rem;
font-weight: 800;
color: #111827;
background: #f8f7ff;
border-radius: 10px;
padding: 8px;
}
@media (max-width: 992px) {
.wsd-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
<!-- MODALE LISTA FOGLI DI LAVORO -->
<div class="modal fade" id="worksheetsListModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-worksheet-list">
@@ -33,7 +353,7 @@
<h5 class="modal-title mb-0">
<i class="fa-solid fa-file-lines me-2"></i>Dettaglio foglio di lavoro
</h5>
<small class="text-muted">Visualizzazione completa in sola lettura</small>
<small class="text-muted">Sola lettura · per modificare usa "Apri foglio completo"</small>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -148,6 +148,7 @@
<th>Modello</th>
<th>Marca</th>
<th>Colore</th>
<th>Max h/giorno</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
@@ -159,7 +160,7 @@
$stmt = $pdo->query("SELECT * FROM production_lines ORDER BY line_number ASC");
if ($stmt->rowCount() === 0) {
echo "<tr><td colspan='8' class='text-muted'>Nessuna linea di produzione presente</td></tr>";
echo "<tr><td colspan='9' class='text-muted'>Nessuna linea di produzione presente</td></tr>";
} else {
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$badge = $row['status'] === 'active'
@@ -174,9 +175,10 @@
<td>{$lineNameEsc}</td>
<td>" . htmlspecialchars($row['model']) . "</td>
<td>" . htmlspecialchars($row['brand']) . "</td>
<td>
<td>
<div style='width:28px; height:28px; border-radius:6px; border:1px solid #999; background: {$row['color']}; margin:auto;'></div>
</td>
<td>" . number_format((float)($row['max_hours_per_day'] ?? 24), 2) . " h</td>
<td>{$badge}</td>
<td>
<button class='btn-action params' title='Parametri linea'
@@ -243,6 +245,14 @@
id="color" name="color" value="#dc2626" title="Scegli colore">
</div>
<div class="mb-3">
<label for="maxHours" class="form-label fw-semibold">Ore massime al giorno</label>
<input type="number" step="0.5" min="0" max="24" class="form-control"
id="maxHours" name="max_hours_per_day" value="24"
title="Capacità giornaliera della linea (default 24)">
<small class="text-muted">Capacità teorica giornaliera. Default 24 (h24).</small>
</div>
<div class="text-center">
<button type="submit" class="btn btn-add">💾 Salva</button>
</div>
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* mago_probe.php - SCRIPT DI TEST (sola lettura) per l'integrazione Mago4
*
* Da lanciare UNA volta dal browser, da utente loggato, con la VPN attiva.
* Mostra: colonne reali delle tabelle, operatori dei filtri supportati,
* collegamento testata/righe, filtro incrementale TbModified, logoff.
*
* Copia l'output e incollalo in chat. Poi questo file si può cancellare.
*/
ob_start();
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php'); // sessione/login della piattaforma
require_once __DIR__ . '/class/MagoClient.php';
// Se dotenv non è già caricato da headscript, provo a caricarlo qui
if (empty($_ENV['MAGO_BASE_URL']) && getenv('MAGO_BASE_URL') === false) {
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
if (class_exists('Dotenv\Dotenv')) {
Dotenv\Dotenv::createImmutable(__DIR__)->safeLoad();
}
}
while (ob_get_level()) ob_end_clean();
header('Content-Type: text/plain; charset=utf-8');
set_time_limit(300);
$report = [
'php_version' => PHP_VERSION,
'env_letto' => !empty($_ENV['MAGO_BASE_URL']) || getenv('MAGO_BASE_URL') !== false,
];
function ms($t0)
{
return round((microtime(true) - $t0) * 1000) . ' ms';
}
function describeRecord($rec)
{
$out = [];
foreach ((array)$rec as $k => $v) {
$out[$k] = gettype($v) . ' | ' . (is_scalar($v) || $v === null ? var_export($v, true) : json_encode($v));
}
return $out;
}
function testCount(MagoClient $mago, $label, $table, array $filters)
{
$t0 = microtime(true);
try {
return ['test' => $label, 'totale' => $mago->count($table, $filters), 'tempo' => ms($t0)];
} catch (Throwable $e) {
return ['test' => $label, 'ERRORE' => $e->getMessage(), 'tempo' => ms($t0)];
}
}
try {
$mago = MagoClient::fromEnv();
// 1) LOGIN
$t0 = microtime(true);
$mago->login();
$report['login'] = 'OK (' . ms($t0) . ')';
// 2) COLONNE REALI + record di esempio
$samples = [];
foreach (['MA_SaleOrd', 'MA_SaleOrdDetail', 'MA_CustSupp', 'MA_Items'] as $table) {
$t0 = microtime(true);
try {
$r = $mago->getDataTable($table, ['recordsPerPage' => 1, 'pageNumber' => 1]);
$samples[$table] = $r['records'][0] ?? [];
$report['tabelle'][$table] = [
'totale_record' => $r['total'],
'tempo' => ms($t0),
'colonne' => describeRecord($samples[$table]),
];
} catch (Throwable $e) {
$report['tabelle'][$table] = ['ERRORE' => $e->getMessage()];
}
}
// 3) OPERATORI DEI FILTRI su MA_SaleOrd.OrderDate
$f2026 = ['key' => 'OrderDate', 'value' => '2026-01-01T00:00:00', 'operator' => '>='];
$fBefore27 = ['key' => 'OrderDate', 'value' => '2027-01-01T00:00:00', 'operator' => '<'];
$report['filtri'] = [
testCount($mago, 'nessun filtro', 'MA_SaleOrd', []),
testCount($mago, 'OrderDate >= 2026-01-01', 'MA_SaleOrd', [$f2026]),
testCount($mago, 'OrderDate < 2027-01-01', 'MA_SaleOrd', [$fBefore27]),
testCount($mago, 'OrderDate <= 2025-12-31', 'MA_SaleOrd', [['key' => 'OrderDate', 'value' => '2025-12-31T23:59:59', 'operator' => '<=']]),
testCount($mago, 'DUE filtri (>= 2026 AND < 2027) → sono in AND?', 'MA_SaleOrd', [$f2026, $fBefore27]),
testCount($mago, 'OrderDate >= "2026-01-01" (solo data, senza ora)', 'MA_SaleOrd', [['key' => 'OrderDate', 'value' => '2026-01-01', 'operator' => '>=']]),
testCount($mago, 'TbModified >= ultimi 7 giorni (sync incrementale)', 'MA_SaleOrd', [['key' => 'TbModified', 'value' => date('Y-m-d\T00:00:00', strtotime('-7 days')), 'operator' => '>=']]),
];
// like sul numero ordine (prefisso del record di esempio)
$ordNo = (string)($samples['MA_SaleOrd']['InternalOrdNo'] ?? '');
if ($ordNo !== '') {
$prefix = mb_substr($ordNo, 0, 3);
$report['filtri'][] = testCount($mago, "InternalOrdNo like '{$prefix}%'", 'MA_SaleOrd', [['key' => 'InternalOrdNo', 'value' => $prefix . '%', 'operator' => 'like']]);
$report['filtri'][] = testCount($mago, "InternalOrdNo LIKE '{$prefix}%' (maiuscolo)", 'MA_SaleOrd', [['key' => 'InternalOrdNo', 'value' => $prefix . '%', 'operator' => 'LIKE']]);
}
// 4) COLLEGAMENTO TESTATA → RIGHE
$saleOrdId = $samples['MA_SaleOrd']['SaleOrdId'] ?? null;
if ($saleOrdId !== null) {
$report['collegamento_righe'] = testCount($mago, "MA_SaleOrdDetail con SaleOrdId = $saleOrdId", 'MA_SaleOrdDetail', [['key' => 'SaleOrdId', 'value' => $saleOrdId, 'operator' => '=']]);
// provo anche un filtro per intervallo di id (utile per sincronizzare le righe solo del 2026)
$report['collegamento_righe_range'] = testCount($mago, "MA_SaleOrdDetail con SaleOrdId >= $saleOrdId", 'MA_SaleOrdDetail', [['key' => 'SaleOrdId', 'value' => $saleOrdId, 'operator' => '>=']]);
} else {
$report['collegamento_righe'] = 'SaleOrdId non presente nel record di testata';
}
// 5) DIMENSIONE MASSIMA PAGINA (velocità del sync)
foreach ([500, 1000, 2000] as $pp) {
$t0 = microtime(true);
try {
$r = $mago->getDataTable('MA_SaleOrd', ['filters' => [$f2026], 'recordsPerPage' => $pp, 'pageNumber' => 1]);
$report['pagine'][] = ['richiesti' => $pp, 'restituiti' => $r['count'], 'tempo' => ms($t0)];
} catch (Throwable $e) {
$report['pagine'][] = ['richiesti' => $pp, 'ERRORE' => $e->getMessage()];
}
}
// 6) Primo ordine 2026 completo (per vedere valori reali di stato/importi)
try {
$r = $mago->getDataTable('MA_SaleOrd', ['filters' => [$f2026], 'recordsPerPage' => 1, 'pageNumber' => 1]);
$ord2026 = $r['records'][0] ?? null;
$report['esempio_ordine_2026'] = $ord2026;
if ($ord2026 && isset($ord2026['SaleOrdId'])) {
$rd = $mago->getDataTable('MA_SaleOrdDetail', [
'filters' => [['key' => 'SaleOrdId', 'value' => $ord2026['SaleOrdId'], 'operator' => '=']],
'recordsPerPage' => 3,
'pageNumber' => 1,
]);
$report['esempio_righe_ordine_2026'] = $rd['records'];
}
} catch (Throwable $e) {
$report['esempio_ordine_2026'] = ['ERRORE' => $e->getMessage()];
}
} catch (Throwable $e) {
$report['ERRORE_GENERALE'] = $e->getMessage();
} finally {
if (isset($mago)) {
$ok = $mago->logoff();
$report['logoff'] = $ok ? 'OK' : ('FALLITO: ' . $mago->getLastLogoffError());
}
}
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@@ -0,0 +1,68 @@
<?php
/**
* mago_probe_matrice_cli.php - SOLO LETTURA. Mostra le righe di un ordine Mago con TUTTI i campi
* ed evidenzia quelli che contengono il codice cercato (per trovare dove Mago salva la matrice).
*
* Uso: php mago_probe_matrice_cli.php --ord=26/00680 --find=00786 > probe_matrice.txt 2>&1
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Solo da riga di comando');
}
$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;
}
}
}
require_once __DIR__ . '/class/MagoClient.php';
$opts = getopt('', ['ord:', 'find::']);
$ord = $opts['ord'] ?? '';
$find = $opts['find'] ?? '';
if ($ord === '') {
exit("Indica il numero ordine: --ord=26/00680\n");
}
$mago = MagoClient::fromEnv();
try {
$mago->login();
$r = $mago->getDataTable('MA_SaleOrdDetails', [
'filters' => [['key' => 'InternalOrdNo', 'value' => $ord, 'operator' => 'like']],
'recordsPerPage' => 50,
]);
echo "Righe trovate per l'ordine $ord: " . $r['total'] . PHP_EOL;
foreach ($r['records'] as $i => $rec) {
echo PHP_EOL . '=== RIGA ' . ($i + 1) . ' (Line ' . ($rec['Line'] ?? '?') . ', articolo ' . ($rec['Item'] ?? '') . ') ===' . PHP_EOL;
if ($find !== '') {
$hits = [];
foreach ($rec as $k => $v) {
if (is_scalar($v) && stripos((string)$v, $find) !== false) $hits[] = "$k = $v";
}
echo '>>> CAMPI CHE CONTENGONO "' . $find . '": ' . ($hits ? implode(' | ', $hits) : 'nessuno') . PHP_EOL;
}
// tutti i campi non vuoti
foreach ($rec as $k => $v) {
if ($v === '' || $v === null || $v === 0 || $v === 0.0 || $v === false || $v === '1799-12-31T00:00:00') continue;
echo str_pad($k, 32) . ' ' . (is_scalar($v) ? str_replace(["\r", "\n"], [' ', ' ⏎ '], (string)$v) : json_encode($v)) . PHP_EOL;
}
}
} catch (Throwable $e) {
fwrite(STDERR, PHP_EOL . 'ERRORE: ' . $e->getMessage() . PHP_EOL);
} finally {
$mago->logoff();
echo PHP_EOL . 'Logoff eseguito.' . PHP_EOL;
}
+310
View File
@@ -0,0 +1,310 @@
<?php
/**
* mago_probe_mescole.php - PROVA in sola lettura: righe d'acquisto dell'articolo MESCOLE in Mago4.
* Raggruppa per descrizione (e per Department / codice fornitore) e prova l'abbinamento con le mescole del portale.
*
* Non scrive niente nel database. Fa sempre il logoff da Mago.
*
* Uso (da terminale, nella cartella userarea):
* php mago_probe_mescole.php ultimi 24 mesi, solo fatture fornitore (9830401)
* php mago_probe_mescole.php --months=36
* php mago_probe_mescole.php --item=MESCOLE --types=9830401
* L'avanzamento esce a video; il resto va dove lo mandi (es. > /tmp/probe_mescole.txt).
* CSV: /tmp/mago_mescole_descrizioni_*.csv e /tmp/mago_mescole_righe_*.csv
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Solo da terminale\n");
}
require_once __DIR__ . '/class/MagoClient.php';
$opt = getopt('', ['months::', 'item::', 'types::']);
$months = max(1, (int)($opt['months'] ?? 24));
$itemCode = (string)($opt['item'] ?? 'MESCOLE');
$docTypes = array_values(array_filter(array_map('trim', explode(',', (string)($opt['types'] ?? '9830401'))), 'strlen'));
$since = date('Y-m-d', strtotime("-$months months"));
const MAX_PAGES = 100;
function probe_load_env(string $dir): ?string
{
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/vendor/autoload.php')) require_once $dir . '/vendor/autoload.php';
if (is_file($dir . '/.env')) {
if (class_exists('Dotenv\\Dotenv')) {
Dotenv\Dotenv::createImmutable($dir)->safeLoad();
} else {
foreach (file($dir . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if ($line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = array_map('trim', explode('=', $line, 2));
$v = trim($v, "\"'");
$_ENV[$k] = $v;
putenv("$k=$v");
}
}
return $dir . '/.env';
}
$dir = dirname($dir);
}
return null;
}
function h(string $t): void
{
echo "\n" . str_repeat('=', 78) . "\n " . $t . "\n" . str_repeat('=', 78) . "\n";
}
function cut($s, int $n): string
{
$s = preg_replace('/\s+/', ' ', trim((string)$s));
return mb_strlen($s) > $n ? mb_substr($s, 0, $n - 1) . '…' : $s;
}
function pad($s, int $n, bool $right = false): string
{
$s = cut($s, $n);
$p = str_repeat(' ', max(0, $n - mb_strlen($s)));
return $right ? $p . $s : $s . $p;
}
function num($v, int $d = 0): string
{
return number_format((float)$v, $d, ',', '.');
}
function norm(string $s): string
{
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $s));
}
/** descrizione "pulita" per raggruppare: maiuscole, spazi singoli, senza spessori/misure finali tipo "X 9mm spessore" */
function descKey(string $s): string
{
$s = strtoupper(preg_replace('/\s+/', ' ', trim($s)));
return $s;
}
probe_load_env(__DIR__);
echo "Prova MESCOLE in Mago4 · articolo $itemCode · tipi " . implode(',', $docTypes) . " · dal $since\n";
$mago = null;
$t0 = microtime(true);
try {
$mago = MagoClient::fromEnv();
$mago->login();
echo "Login Mago: OK\n";
$fields = [
'PurchaseDocId',
'Line',
'DocumentType',
'DocumentDate',
'DocNo',
'Supplier',
'Item',
'Description',
'Department',
'SupplierCode',
'ReferenceDocNo',
'UoM',
'Qty',
'UnitValue',
'TaxableAmount',
'DiscountAmount',
'Lot',
'SupplierLotNo',
'Notes',
'PurchaseOrdNo'
];
$rows = [];
foreach ($docTypes as $tp) {
$flt = [
['key' => 'Item', 'value' => $itemCode, 'operator' => '='],
['key' => 'DocumentDate', 'value' => $since, 'operator' => '>='],
['key' => 'DocumentType', 'value' => $tp, 'operator' => '='],
];
for ($page = 1; $page <= MAX_PAGES; $page++) {
$tq = microtime(true);
$r = $mago->getDataTable('MA_PurchaseDocDetail', ['fields' => $fields, 'filters' => $flt, 'recordsPerPage' => 200, 'pageNumber' => $page]);
fwrite(STDERR, sprintf(" tipo %s · pagina %d: %d righe su %s (%.1f s)\n", $tp, $page, $r['count'], num($r['total']), microtime(true) - $tq));
foreach ($r['records'] as $rec) {
$rec['_type'] = $tp;
$rows[] = $rec;
}
if (!$r['records'] || $page * 200 >= $r['total']) break;
}
}
/* nomi fornitori */
$supp = [];
foreach ($rows as $x) $supp[(string)($x['Supplier'] ?? '')] = 1;
$suppNames = [];
foreach (array_keys($supp) as $s) {
if ($s === '') continue;
try {
$r = $mago->getDataTable('MA_CustSupp', ['fields' => ['CustSuppType', 'CustSupp', 'CompanyName'], 'filters' => [['key' => 'CustSupp', 'value' => $s, 'operator' => '=']], 'recordsPerPage' => 5]);
foreach ($r['records'] as $c) {
$suppNames[$s] = $c['CompanyName'] ?: $s;
if ((string)$c['CustSuppType'] !== '3211264') break;
}
} catch (Throwable $e) {
}
}
h('1. RIGHE LETTE: ' . num(count($rows)));
if (!$rows) exit(0);
/* quanto sono compilati i campi che potrebbero identificare la mescola */
$fill = [];
foreach (['Description', 'Department', 'SupplierCode', 'ReferenceDocNo', 'Notes', 'Lot', 'SupplierLotNo'] as $f) {
$n = 0;
$vals = [];
foreach ($rows as $x) {
$v = trim((string)($x[$f] ?? ''));
if ($v !== '') {
$n++;
$vals[cut($v, 40)] = 1;
}
}
echo pad($f, 16) . pad(num($n) . ' righe compilate', 22, true) . ' ' . num(count($vals)) . " valori diversi" .
($vals ? ' es: ' . cut(implode(' · ', array_slice(array_keys($vals), 0, 4)), 90) : '') . "\n";
}
$uoms = [];
foreach ($rows as $x) $uoms[strtoupper(trim((string)($x['UoM'] ?? '')))] = ($uoms[strtoupper(trim((string)($x['UoM'] ?? '')))] ?? 0) + 1;
echo "UM: " . implode(', ', array_map(fn($k, $v) => "$k ($v)", array_keys($uoms), $uoms)) . "\n";
/* raggruppo per descrizione */
$g = [];
foreach ($rows as $x) {
$k = descKey((string)($x['Description'] ?? ''));
$qty = MagoClient::toDecimal($x['Qty']);
$val = MagoClient::toDecimal($x['TaxableAmount']);
if ($val == 0) $val = $qty * MagoClient::toDecimal($x['UnitValue']);
$d = MagoClient::toMysqlDate($x['DocumentDate']);
$a = &$g[$k];
$a['desc'] = $k;
$a['n'] = ($a['n'] ?? 0) + 1;
$a['qty'] = ($a['qty'] ?? 0) + $qty;
$a['val'] = ($a['val'] ?? 0) + $val;
$a['supp'][$suppNames[$x['Supplier'] ?? ''] ?? ($x['Supplier'] ?? '')] = 1;
$a['dep'][trim((string)($x['Department'] ?? ''))] = 1;
$a['sc'][trim((string)($x['SupplierCode'] ?? ''))] = 1;
if (($a['last'] ?? '') <= $d) {
$a['last'] = $d;
$a['last_price'] = $qty ? $val / $qty : 0;
}
$a['first'] = isset($a['first']) ? min($a['first'], $d) : $d;
unset($a);
}
uasort($g, fn($a, $b) => $b['val'] <=> $a['val']);
/* mescole del portale */
$pdo = null;
$names = [];
try {
foreach (['/class/DBHandlerSelect.php', '/include/DBHandlerSelect.php', '/class/DBHandler.php'] as $f) {
if (!class_exists('DBHandlerSelect') && is_file(__DIR__ . $f)) {
ob_start();
require_once __DIR__ . $f;
ob_end_clean();
}
}
if (class_exists('DBHandlerSelect')) $pdo = DBHandlerSelect::getInstance()->getConnection();
} catch (Throwable $e) {
$pdo = null;
}
if ($pdo) {
foreach ($pdo->query("SELECT id, nome, nomeuscita FROM mescole") as $m) {
foreach (['nome', 'nomeuscita'] as $k) {
$n = norm((string)$m[$k]);
if (strlen($n) >= 3) $names[$n] = ['id' => $m['id'], 'name' => $m['nome'], 'via' => $k];
}
}
try {
foreach ($pdo->query("SELECT sn.idmescola, sn.supplier_mix_name, m.nome FROM mescole_supplier_names sn JOIN mescole m ON m.id = sn.idmescola") as $m) {
$n = norm((string)$m['supplier_mix_name']);
if (strlen($n) >= 3) $names[$n] = ['id' => $m['idmescola'], 'name' => $m['nome'], 'via' => 'nome fornitore'];
}
} catch (Throwable $e) {
}
uksort($names, fn($a, $b) => strlen($b) <=> strlen($a)); // prima i nomi più lunghi
}
$matchOf = function (string $desc) use ($names) {
$nd = norm($desc);
foreach ($names as $n => $m) {
if (strpos($nd, $n) !== false) return $m;
}
return null;
};
h('2. DESCRIZIONI DIVERSE: ' . num(count($g)) . ($pdo ? ' · mescole portale confrontate: ' . num(count($names)) . ' nomi' : ' · database portale NON raggiungibile'));
echo pad('Descrizione', 40) . pad('Righe', 7, true) . pad('Kg', 12, true) . pad('Valore €', 12, true) . pad('€/kg', 8, true) . pad('Ultimo', 8, true) .
' ' . pad('Ult. data', 10) . ' ' . pad('Mescola portale', 20) . " Fornitore / Department\n";
echo str_repeat('-', 150) . "\n";
$matched = 0;
$matchedVal = 0;
$totVal = 0;
foreach ($g as $a) {
$m = $pdo ? $matchOf($a['desc']) : null;
$totVal += $a['val'];
if ($m) {
$matched++;
$matchedVal += $a['val'];
}
$g[$a['desc']]['match'] = $m;
}
foreach (array_slice($g, 0, 100) as $a) {
$dep = implode(',', array_filter(array_keys($a['dep'])));
echo pad($a['desc'], 40) . pad(num($a['n']), 7, true) . pad(num($a['qty']), 12, true) . pad(num($a['val']), 12, true) .
pad($a['qty'] ? num($a['val'] / $a['qty'], 3) : '-', 8, true) . pad(num($a['last_price'], 3), 8, true) . ' ' . pad($a['last'], 10) .
' ' . pad($a['match'] ? $a['match']['name'] : ($pdo ? '—' : ''), 20) . ' ' . cut(implode(' | ', array_keys($a['supp'])), 30) . ($dep ? ' · dep: ' . cut($dep, 20) : '') . "\n";
}
if (count($g) > 100) echo "… e altre " . num(count($g) - 100) . " descrizioni (tutte nel CSV)\n";
if ($pdo) echo "\nDescrizioni abbinate a una mescola: " . num($matched) . " su " . num(count($g)) . " · " . ($totVal ? num($matchedVal / $totVal * 100, 1) : 0) . "% del valore\n";
/* esempi grezzi */
h('3. 15 RIGHE D\'ESEMPIO (tutti i campi letti)');
foreach (array_slice($rows, -15) as $x) {
$out = [];
foreach ($fields as $f) if (isset($x[$f]) && trim((string)$x[$f]) !== '' && !in_array($f, ['PurchaseDocId', 'Item'], true)) $out[] = $f . '=' . cut($x[$f], 40);
echo ' · ' . implode(' | ', $out) . "\n";
}
/* CSV */
$stamp = date('Ymd_His');
$csv1 = sys_get_temp_dir() . "/mago_mescole_descrizioni_$stamp.csv";
$fh = fopen($csv1, 'w');
fputcsv($fh, ['descrizione', 'righe', 'kg', 'valore', 'prezzo_medio', 'ultimo_prezzo', 'prima_data', 'ultima_data', 'mescola_portale', 'fornitori', 'department', 'codice_fornitore'], ';');
foreach ($g as $a) {
fputcsv($fh, [
$a['desc'],
$a['n'],
number_format($a['qty'], 3, ',', ''),
number_format($a['val'], 2, ',', ''),
$a['qty'] ? number_format($a['val'] / $a['qty'], 4, ',', '') : '',
number_format($a['last_price'], 4, ',', ''),
$a['first'],
$a['last'],
$a['match']['name'] ?? '',
implode(' | ', array_keys($a['supp'])),
implode(' | ', array_filter(array_keys($a['dep']))),
implode(' | ', array_filter(array_keys($a['sc'])))
], ';');
}
fclose($fh);
$csv2 = sys_get_temp_dir() . "/mago_mescole_righe_$stamp.csv";
$fh = fopen($csv2, 'w');
fputcsv($fh, array_merge(['fornitore_nome'], $fields), ';');
foreach ($rows as $x) {
$line = [$suppNames[$x['Supplier'] ?? ''] ?? ($x['Supplier'] ?? '')];
foreach ($fields as $f) $line[] = is_scalar($x[$f] ?? '') ? (string)($x[$f] ?? '') : json_encode($x[$f]);
fputcsv($fh, $line, ';');
}
fclose($fh);
h('FINE');
echo "CSV descrizioni: $csv1\nCSV righe: $csv2\n";
} catch (Throwable $e) {
echo "\nERRORE: " . $e->getMessage() . "\n";
} finally {
if ($mago) {
$ok = $mago->logoff();
echo "Logoff Mago: " . ($ok ? 'OK' : 'NON riuscito → ' . $mago->getLastLogoffError()) . "\n";
}
echo "Durata: " . round(microtime(true) - $t0, 1) . " s\n";
}
+396
View File
@@ -0,0 +1,396 @@
<?php
/**
* mago_probe_purchases.php - PROVA in sola lettura: acquisti di Zibo Gomma in Mago4 (mescole).
*
* Non scrive niente nel database. Fa sempre il logoff da Mago.
*
* Uso (da terminale, nella cartella userarea):
* php mago_probe_purchases.php ultimi 24 mesi
* php mago_probe_purchases.php --months=36 periodo diverso
* php mago_probe_purchases.php --pages=60 righe lette al massimo = pagine × 200 (default 40)
* php mago_probe_purchases.php --fields stampa anche tutti i campi con un valore d'esempio
* php mago_probe_purchases.php --types=9830401,9830402 tipi documento da leggere (default fattura fornitore)
*
* L'avanzamento (pagina per pagina) esce a video anche se l'output va in un file.
*
* Stampa:
* 1. quali tabelle d'acquisto esistono, quanti record hanno e i loro campi
* 2. i tipi di documento d'acquisto (per capire qual è la fattura fornitore)
* 3. gli articoli acquistati, ordinati per valore, con UM, quantità, prezzo medio e ultimo
* 4. quali articoli somigliano alle mescole del portale (se il database è raggiungibile)
* 5. le ultime righe d'acquisto
* e salva un CSV con gli articoli in /tmp.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Solo da terminale\n");
}
require_once __DIR__ . '/class/MagoClient.php';
/* ---------------- opzioni ---------------- */
$opt = getopt('', ['months::', 'pages::', 'fields', 'types::']);
$months = max(1, (int)($opt['months'] ?? 24));
$maxPages = max(1, (int)($opt['pages'] ?? 40));
$showFields = isset($opt['fields']);
// tipi documento da leggere: 9830401 = fattura fornitore (dal primo test). Più tipi separati da virgola.
$docTypes = array_values(array_filter(array_map('trim', explode(',', (string)($opt['types'] ?? '9830401'))), 'strlen'));
$since = date('Y-m-d', strtotime("-$months months"));
/* ---------------- .env ---------------- */
function probe_load_env(string $dir): ?string
{
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/vendor/autoload.php')) require_once $dir . '/vendor/autoload.php';
if (is_file($dir . '/.env')) {
if (class_exists('Dotenv\\Dotenv')) {
Dotenv\Dotenv::createImmutable($dir)->safeLoad();
} else {
foreach (file($dir . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if ($line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = array_map('trim', explode('=', $line, 2));
$v = trim($v, "\"'");
$_ENV[$k] = $v;
putenv("$k=$v");
}
}
return $dir . '/.env';
}
$dir = dirname($dir);
}
return null;
}
/* ---------------- stampa ---------------- */
function h(string $t): void
{
echo "\n" . str_repeat('=', 78) . "\n " . $t . "\n" . str_repeat('=', 78) . "\n";
}
function cut($s, int $n): string
{
$s = preg_replace('/\s+/', ' ', trim((string)$s));
return mb_strlen($s) > $n ? mb_substr($s, 0, $n - 1) . '…' : $s;
}
function pad($s, int $n, bool $right = false): string
{
$s = cut($s, $n);
$p = str_repeat(' ', max(0, $n - mb_strlen($s)));
return $right ? $p . $s : $s . $p;
}
function num($v, int $d = 0): string
{
return number_format((float)$v, $d, ',', '.');
}
function field(array $keys, array $candidates): ?string
{
foreach ($candidates as $c) {
foreach ($keys as $k) if (strcasecmp($k, $c) === 0) return $k;
}
return null;
}
function norm(string $s): string
{
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $s));
}
$envFile = probe_load_env(__DIR__);
echo "Prova acquisti Mago4 · dal $since ($months mesi) · max " . ($maxPages * 200) . " righe\n";
echo ".env: " . ($envFile ?: 'non trovato, uso le variabili d\'ambiente') . "\n";
$mago = null;
$t0 = microtime(true);
try {
$mago = MagoClient::fromEnv();
$mago->login();
echo "Login Mago: OK\n";
/* =========================================================
* 1. TABELLE
* ========================================================= */
h('1. TABELLE D\'ACQUISTO IN MAGO');
$tables = [
'MA_PurchaseOrd' => 'Ordini a fornitore (testata)',
'MA_PurchaseOrdDetails' => 'Ordini a fornitore (righe)',
'MA_PurchaseOrdSummary' => 'Ordini a fornitore (totali)',
'MA_PurchaseDoc' => 'DDT / fatture fornitore (testata)',
'MA_PurchaseDocDetail' => 'DDT / fatture fornitore (righe)',
'MA_PurchaseDocSummary' => 'DDT / fatture fornitore (totali)',
'MA_Items' => 'Anagrafica articoli',
];
$found = [];
foreach ($tables as $t => $label) {
try {
$r = $mago->getDataTable($t, ['recordsPerPage' => 1]);
$keys = $r['records'] ? array_keys($r['records'][0]) : [];
$found[$t] = ['keys' => $keys, 'sample' => $r['records'][0] ?? []];
echo pad($t, 24) . pad($label, 36) . pad(num($r['total']) . ' record', 16, true) . ' ' . count($keys) . " campi\n";
} catch (Throwable $e) {
echo pad($t, 24) . pad($label, 36) . " NON DISPONIBILE: " . cut($e->getMessage(), 80) . "\n";
}
}
if ($showFields) {
foreach ($found as $t => $f) {
if (!$f['keys']) continue;
echo "\n-- $t: campi --\n";
foreach ($f['sample'] as $k => $v) echo ' ' . pad($k, 32) . cut(is_scalar($v) ? (string)$v : json_encode($v), 60) . "\n";
}
}
/* scelgo la fonte: prima i documenti fornitore (prezzo pagato), poi gli ordini */
$src = null;
foreach ([['MA_PurchaseDoc', 'MA_PurchaseDocDetail', 'Documenti fornitore'], ['MA_PurchaseOrd', 'MA_PurchaseOrdDetails', 'Ordini a fornitore']] as $c) {
if (!empty($found[$c[0]]['keys']) && !empty($found[$c[1]]['keys'])) {
$src = $c;
break;
}
}
if (!$src) {
h('Nessuna coppia testata/righe d\'acquisto leggibile: mi fermo qui');
exit(0);
}
[$headT, $detT, $srcLabel] = $src;
$hk = $found[$headT]['keys'];
$dk = $found[$detT]['keys'];
$hId = field($hk, ['PurchaseDocId', 'PurchaseOrdId']);
$dId = field($dk, ['PurchaseDocId', 'PurchaseOrdId']);
$hDate = field($hk, ['DocumentDate', 'OrderDate', 'PostingDate']);
$hNo = field($hk, ['DocNo', 'InternalOrdNo', 'SupplierDocNo']);
$hSupp = field($hk, ['Supplier', 'CustSupp']);
$hType = field($hk, ['DocumentType']);
$dItem = field($dk, ['Item']);
$dDesc = field($dk, ['Description']);
$dUom = field($dk, ['UoM', 'BaseUoM']);
$dQty = field($dk, ['Qty']);
$dVal = field($dk, ['TaxableAmount']);
$dUnit = field($dk, ['UnitValue']);
$dMod = field($dk, ['TBModified', 'TbModified']);
echo "\nFonte usata: $srcLabel ($headT + $detT)\n";
echo "Campi riconosciuti: id=$hId/$dId data=$hDate n°=$hNo fornitore=$hSupp tipo=" . ($hType ?: '-') .
" | articolo=$dItem UM=$dUom qtà=$dQty imponibile=" . ($dVal ?: '-') . " prezzo=" . ($dUnit ?: '-') . "\n";
if (!$dId || !$dItem || !$dQty) {
h('Mancano campi indispensabili (id, data, articolo, quantità): rilancia con --fields e mandami l\'output');
exit(0);
}
/* =========================================================
* 2. TIPI DI DOCUMENTO DEL PERIODO
* ========================================================= */
$dType = field($dk, ['DocumentType']);
$dDate = field($dk, ['DocumentDate', 'OrderDate']);
$dNo = field($dk, ['DocNo', 'PurchaseOrdNo']);
$dSupp = field($dk, ['Supplier']);
h('2. TIPI DI DOCUMENTO DEL PERIODO');
$known = ['9830400' => 'DDT fornitore?', '9830401' => 'Fattura fornitore?', '9830402' => 'Nota di credito?'];
if ($hType && $hDate) {
foreach ($known as $tp => $lbl) {
try {
$n = $mago->count($headT, [['key' => $hDate, 'value' => $since, 'operator' => '>='], ['key' => $hType, 'value' => $tp, 'operator' => '=']]);
echo pad($tp, 12) . pad($lbl, 22) . pad(num($n), 10, true) . "\n";
} catch (Throwable $e) {
echo pad($tp, 12) . " errore: " . cut($e->getMessage(), 60) . "\n";
}
}
}
echo "Righe lette solo dai tipi: " . implode(', ', $docTypes) . " (cambia con --types=…)\n";
/* =========================================================
* 3. RIGHE (solo i campi che servono, filtro su data e tipo documento)
* ========================================================= */
if (!$dDate) {
h('Nella tabella righe manca la data documento: rilancia con --fields e mandami l\'output');
exit(0);
}
$fields = array_values(array_filter([$dId, $dDate, $dNo, $dSupp, $dType, $dItem, $dDesc, $dUom, $dQty, $dVal, $dUnit, field($dk, ['LineType'])]));
$items = [];
$last = [];
$lines = 0;
$read = 0;
$total = null;
foreach ($docTypes as $tp) {
$flt = [['key' => $dDate, 'value' => $since, 'operator' => '>=']];
if ($dType && $tp !== '') $flt[] = ['key' => $dType, 'value' => $tp, 'operator' => '='];
for ($page = 1; $page <= $maxPages; $page++) {
$tq = microtime(true);
$r = $mago->getDataTable($detT, ['fields' => $fields, 'filters' => $flt, 'recordsPerPage' => 200, 'pageNumber' => $page]);
$total = $r['total'];
fwrite(STDERR, sprintf(" tipo %s · pagina %d: %d righe su %s (%.1f s)\n", $tp, $page, $r['count'], num($total), microtime(true) - $tq));
if (!$r['records']) break;
foreach ($r['records'] as $rec) {
$read++;
$item = trim((string)($rec[$dItem] ?? ''));
$qty = MagoClient::toDecimal($rec[$dQty] ?? 0);
if ($item === '' || $qty == 0) continue;
$val = $dVal ? MagoClient::toDecimal($rec[$dVal]) : 0;
if ($val == 0 && $dUnit) $val = $qty * MagoClient::toDecimal($rec[$dUnit]);
$date = MagoClient::toMysqlDate($rec[$dDate] ?? null);
$supp = $dSupp ? (string)$rec[$dSupp] : '';
$lines++;
$it = &$items[$item];
$it['item'] = $item;
$it['desc'] = $it['desc'] ?? ($dDesc ? (string)$rec[$dDesc] : '');
$it['uom'][$dUom ? strtoupper(trim((string)$rec[$dUom])) : '?'] = 1;
$it['n'] = ($it['n'] ?? 0) + 1;
$it['qty'] = ($it['qty'] ?? 0) + $qty;
$it['val'] = ($it['val'] ?? 0) + $val;
$it['supp'][$supp] = 1;
if (($it['last'] ?? '') <= $date) {
$it['last'] = $date;
$it['last_price'] = $qty ? $val / $qty : 0;
}
unset($it);
$last[] = [$date, $dNo ? $rec[$dNo] : '', $supp, $tp, $item, $dDesc ? $rec[$dDesc] : '', $dUom ? $rec[$dUom] : '', $qty, $qty ? $val / $qty : 0, $val];
}
if ($page * 200 >= $total) break;
}
if ($total !== null && $total > $maxPages * 200) echo "ATTENZIONE tipo $tp: lette " . num($maxPages * 200) . " righe su " . num($total) . " (aumenta --pages)\n";
}
$skipped = 0;
uasort($items, fn($a, $b) => $b['val'] <=> $a['val']);
/* nomi fornitori */
$suppNames = [];
$allSupp = [];
foreach ($items as $it) foreach (array_keys($it['supp']) as $s) $allSupp[$s] = ($allSupp[$s] ?? 0) + $it['val'];
arsort($allSupp);
foreach (array_slice(array_keys($allSupp), 0, 30) as $s) {
if ($s === '') continue;
try {
$r = $mago->getDataTable('MA_CustSupp', ['filters' => [['key' => 'CustSupp', 'value' => $s, 'operator' => '=']], 'recordsPerPage' => 5]);
foreach ($r['records'] as $c) {
$suppNames[$s] = $c['CompanyName'] ?? $s;
if (($c['CustSuppType'] ?? '') != 3211264) break; // preferisco il record fornitore
}
} catch (Throwable $e) {
}
}
h("3. ARTICOLI ACQUISTATI: " . num(count($items)) . " articoli su " . num($lines) . " righe" . ($skipped ? " (" . num($skipped) . " righe fuori periodo)" : ''));
echo pad('Articolo', 16) . pad('Descrizione', 30) . pad('UM', 6) . pad('Quantità', 12, true) . pad('Valore €', 13, true) .
pad('€/UM medio', 11, true) . pad('Ultimo €', 10, true) . ' ' . pad('Ultimo', 10) . " Fornitore\n";
echo str_repeat('-', 140) . "\n";
foreach (array_slice($items, 0, 80) as $it) {
$s = array_keys($it['supp']);
$sn = $suppNames[$s[0]] ?? $s[0];
echo pad($it['item'], 16) . pad($it['desc'], 30) . pad(implode('/', array_keys($it['uom'])), 6) .
pad(num($it['qty'], 1), 12, true) . pad(num($it['val'], 0), 13, true) .
pad($it['qty'] ? num($it['val'] / $it['qty'], 3) : '-', 11, true) . pad(num($it['last_price'], 3), 10, true) .
' ' . pad($it['last'], 10) . ' ' . cut($sn, 26) . (count($s) > 1 ? ' +' . (count($s) - 1) : '') . "\n";
}
if (count($items) > 80) echo "… e altri " . num(count($items) - 80) . " articoli (tutti nel CSV)\n";
/* =========================================================
* 4. MESCOLE DEL PORTALE
* ========================================================= */
h('4. ARTICOLI CHE SOMIGLIANO ALLE MESCOLE DEL PORTALE');
$pdo = null;
try {
foreach (['/class/DBHandlerSelect.php', '/include/DBHandlerSelect.php', '/class/DBHandler.php'] as $f) {
if (!class_exists('DBHandlerSelect') && is_file(__DIR__ . $f)) {
ob_start();
require_once __DIR__ . $f;
ob_end_clean();
}
}
if (class_exists('DBHandlerSelect')) $pdo = DBHandlerSelect::getInstance()->getConnection();
} catch (Throwable $e) {
$pdo = null;
}
if (!$pdo) {
echo "Database del portale non raggiungibile da qui: confronto saltato (lo faremo dopo, dal CSV).\n";
} else {
$names = [];
foreach ($pdo->query("SELECT id, nome, nomeuscita FROM mescole") as $m) {
foreach (['nome', 'nomeuscita'] as $k) {
$n = norm((string)$m[$k]);
if (strlen($n) >= 3) $names[$n] = ['id' => $m['id'], 'name' => $m['nome'], 'via' => $k];
}
}
try {
foreach ($pdo->query("SELECT sn.idmescola, sn.supplier_mix_name, m.nome FROM mescole_supplier_names sn JOIN mescole m ON m.id = sn.idmescola") as $m) {
$n = norm((string)$m['supplier_mix_name']);
if (strlen($n) >= 3) $names[$n] = ['id' => $m['idmescola'], 'name' => $m['nome'], 'via' => 'nome fornitore'];
}
} catch (Throwable $e) {
}
echo "Mescole nel portale: " . num($pdo->query("SELECT COUNT(*) FROM mescole")->fetchColumn()) . " · nomi confrontati: " . num(count($names)) . "\n\n";
$hit = 0;
$exact = 0;
foreach ($items as $it) {
$ni = norm($it['item']);
$nd = norm($it['desc']);
$match = null;
$how = '';
if (isset($names[$ni])) {
$match = $names[$ni];
$how = 'codice = ' . $match['via'];
$exact++;
} else {
foreach ($names as $n => $m) {
if (strlen($n) >= 4 && (strpos($ni, $n) !== false || strpos($nd, $n) !== false)) {
$match = $m;
$how = 'contenuto in ' . (strpos($ni, $n) !== false ? 'codice' : 'descrizione') . ' (' . $m['via'] . ')';
break;
}
}
}
if (!$match) continue;
$hit++;
if ($hit <= 60) {
echo pad($it['item'], 16) . pad($it['desc'], 32) . ' → ' . pad($match['name'], 22) . pad(implode('/', array_keys($it['uom'])), 6) .
pad($it['qty'] ? num($it['val'] / $it['qty'], 3) . ' €' : '-', 12, true) . ' ' . $how . "\n";
}
}
echo "\nArticoli collegabili a una mescola: " . num($hit) . " (di cui " . num($exact) . " con codice identico) su " . num(count($items)) . "\n";
}
/* =========================================================
* 5. ULTIME RIGHE
* ========================================================= */
h('5. ULTIME 30 RIGHE D\'ACQUISTO');
usort($last, fn($a, $b) => strcmp($b[0], $a[0]));
foreach (array_slice($last, 0, 30) as $l) {
echo pad($l[0], 11) . pad($l[1], 12) . pad($suppNames[$l[2]] ?? $l[2], 22) . pad($l[4], 14) . pad($l[5], 28) .
pad($l[6], 5) . pad(num($l[7], 1), 10, true) . pad(num($l[8], 3), 10, true) . pad(num($l[9], 2), 12, true) . "\n";
}
/* CSV */
$csv = sys_get_temp_dir() . '/mago_acquisti_articoli_' . date('Ymd_His') . '.csv';
$fh = fopen($csv, 'w');
fputcsv($fh, ['articolo', 'descrizione', 'um', 'righe', 'quantita', 'valore', 'prezzo_medio', 'ultimo_prezzo', 'ultima_data', 'fornitori'], ';');
foreach ($items as $it) {
fputcsv($fh, [
$it['item'],
$it['desc'],
implode('/', array_keys($it['uom'])),
$it['n'],
number_format($it['qty'], 3, ',', ''),
number_format($it['val'], 2, ',', ''),
$it['qty'] ? number_format($it['val'] / $it['qty'], 4, ',', '') : '',
number_format($it['last_price'], 4, ',', ''),
$it['last'],
implode(' | ', array_map(fn($s) => $suppNames[$s] ?? $s, array_keys($it['supp']))),
], ';');
}
fclose($fh);
h('FINE');
echo "CSV articoli: $csv\n";
} catch (Throwable $e) {
echo "\nERRORE: " . $e->getMessage() . "\n";
} finally {
if ($mago) {
$ok = $mago->logoff();
echo "Logoff Mago: " . ($ok ? 'OK' : 'NON riuscito → ' . $mago->getLastLogoffError()) . "\n";
}
echo "Durata: " . round(microtime(true) - $t0, 1) . " s\n";
}
+141
View File
@@ -0,0 +1,141 @@
<?php
/**
* mago_probe_saledoc2_cli.php - SOLO LETTURA. Riepilogo dei documenti di vendita PER TIPO
* (n. documenti, imponibile totale, registro IVA, causale...) per capire quali sono fatture
* e quali note di credito. Non scrive nulla nel database.
*
* Uso: php mago_probe_saledoc2_cli.php --from=2026-01-01 > probe_saledoc2.txt 2>&1
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Solo da riga di comando');
}
set_time_limit(0);
$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;
}
}
}
require_once __DIR__ . '/class/MagoClient.php';
$opts = getopt('', ['from::']);
$from = $opts['from'] ?? date('Y') . '-01-01';
$mago = MagoClient::fromEnv();
try {
$mago->login();
// 1) tutte le testate del periodo, solo i campi utili
$fields = [
'SaleDocId',
'DocumentType',
'DocNo',
'DocumentDate',
'CustSupp',
'TaxJournal',
'InvoicingTaxJournal',
'InvRsn',
'ShippingReason',
'EIDocumentType',
'InvoiceFollows',
'Issued',
'Cancelled',
'PostedToAccounting',
'CorrectionDocument',
'StubBook'
];
$docs = [];
$minId = PHP_INT_MAX;
foreach (
$mago->iterate('MA_SaleDoc', [
'filters' => [['key' => 'DocumentDate', 'value' => $from, 'operator' => '>=']],
'fields' => $fields,
]) as $r
) {
$id = (int)$r['SaleDocId'];
$docs[$id] = $r;
$minId = min($minId, $id);
}
echo "Documenti dal $from: " . count($docs) . PHP_EOL;
// 2) importi: leggo i totali con SaleDocId >= il più piccolo del periodo
$tax = [];
if ($docs) {
foreach (
$mago->iterate('MA_SaleDocSummary', [
'filters' => [['key' => 'SaleDocId', 'value' => (string)$minId, 'operator' => '>=']],
'fields' => ['SaleDocId', 'TaxableAmount', 'TotalAmount'],
]) as $r
) {
$tax[(int)$r['SaleDocId']] = (float)$r['TaxableAmount'];
}
}
echo "Totali letti: " . count($tax) . PHP_EOL;
// 3) riepilogo per tipo
$sum = [];
$add = function (&$arr, $v) {
$v = (string)$v;
if ($v === '') $v = '(vuoto)';
$arr[$v] = ($arr[$v] ?? 0) + 1;
};
foreach ($docs as $id => $r) {
$t = (string)$r['DocumentType'];
if (!isset($sum[$t])) $sum[$t] = [
'documenti' => 0,
'imponibile' => 0.0,
'senza_importo' => 0,
'annullati' => 0,
'in_contabilita' => 0,
'TaxJournal' => [],
'InvRsn' => [],
'ShippingReason' => [],
'EIDocumentType' => [],
'StubBook' => [],
'InvoiceFollows' => [],
'esempi' => []
];
$s = &$sum[$t];
$s['documenti']++;
if (isset($tax[$id])) $s['imponibile'] += $tax[$id];
else $s['senza_importo']++;
if (!empty($r['Cancelled'])) $s['annullati']++;
if (!empty($r['PostedToAccounting'])) $s['in_contabilita']++;
$add($s['TaxJournal'], $r['TaxJournal'] ?? '');
$add($s['InvRsn'], $r['InvRsn'] ?? '');
$add($s['ShippingReason'], $r['ShippingReason'] ?? '');
$add($s['EIDocumentType'], $r['EIDocumentType'] ?? '');
$add($s['StubBook'], $r['StubBook'] ?? '');
$add($s['InvoiceFollows'], !empty($r['InvoiceFollows']) ? 'si' : 'no');
if (count($s['esempi']) < 4) $s['esempi'][] = $r['DocNo'] . ' del ' . substr((string)$r['DocumentDate'], 0, 10) . ' cliente ' . $r['CustSupp'] . ' imponibile ' . ($tax[$id] ?? '?');
unset($s);
}
ksort($sum);
foreach ($sum as &$s) {
$s['imponibile'] = round($s['imponibile'], 2);
foreach (['TaxJournal', 'InvRsn', 'ShippingReason', 'EIDocumentType', 'StubBook', 'InvoiceFollows'] as $k) arsort($s[$k]);
}
unset($s);
echo PHP_EOL . '=== Riepilogo per DocumentType ===' . PHP_EOL;
echo json_encode($sum, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
} catch (Throwable $e) {
fwrite(STDERR, PHP_EOL . 'ERRORE: ' . $e->getMessage() . PHP_EOL);
} finally {
$mago->logoff();
echo PHP_EOL . 'Logoff eseguito.' . PHP_EOL;
}
@@ -0,0 +1,73 @@
<?php
/**
* mago_probe_saledoc_cli.php - SOLO LETTURA. Esplora i documenti di vendita (fatture) in Mago
* per preparare i grafici del fatturato. Non scrive nulla nel database.
*
* Uso: php mago_probe_saledoc_cli.php
* php mago_probe_saledoc_cli.php --from=2025-01-01
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Solo da riga di comando');
}
set_time_limit(0);
$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;
}
}
}
require_once __DIR__ . '/class/MagoClient.php';
$opts = getopt('', ['from::']);
$from = $opts['from'] ?? date('Y') . '-01-01';
$out = function ($title, $data = null) {
echo PHP_EOL . '=== ' . $title . ' ===' . PHP_EOL;
if ($data !== null) echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
};
$mago = MagoClient::fromEnv();
try {
$mago->login();
$f = [['key' => 'DocumentDate', 'value' => $from, 'operator' => '>=']];
// 1) testata documento: 2 record completi (tutti i campi)
$r = $mago->getDataTable('MA_SaleDoc', ['filters' => $f, 'recordsPerPage' => 2]);
$out("MA_SaleDoc dal $from: totale documenti = " . $r['total'] . ' (2 esempi completi)', $r['records']);
// 2) quanti documenti per TIPO (fattura, DDT, nota di credito...): mi serve per capire i codici
$types = [];
$examples = [];
foreach ($mago->iterate('MA_SaleDoc', ['filters' => $f, 'fields' => ['SaleDocId', 'DocumentType', 'DocNo', 'DocumentDate']]) as $rec) {
$t = (string)($rec['DocumentType'] ?? '?');
$types[$t] = ($types[$t] ?? 0) + 1;
if (!isset($examples[$t])) $examples[$t] = ($rec['DocNo'] ?? '') . ' del ' . substr((string)($rec['DocumentDate'] ?? ''), 0, 10) . ' (SaleDocId ' . ($rec['SaleDocId'] ?? '') . ')';
}
ksort($types);
$rows = [];
foreach ($types as $t => $n) $rows[] = ['DocumentType' => $t, 'documenti' => $n, 'esempio' => $examples[$t]];
$out('Documenti per DocumentType', $rows);
// 3) totali: 2 record completi
$r = $mago->getDataTable('MA_SaleDocSummary', ['recordsPerPage' => 2]);
$out('MA_SaleDocSummary (2 esempi completi)', $r['records']);
} catch (Throwable $e) {
fwrite(STDERR, PHP_EOL . 'ERRORE: ' . $e->getMessage() . PHP_EOL);
} finally {
$mago->logoff();
echo PHP_EOL . 'Logoff eseguito.' . PHP_EOL;
}
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* mago_sync_cli.php - Sincronizzazione Mago4 → MySQL da riga di comando (cron).
*
* Uso:
* php mago_sync_cli.php incrementale (solo modificati, TbModified)
* php mago_sync_cli.php --limit=50 PROVA: solo 50 ordini
* php mago_sync_cli.php --full rilegge tutti gli ordini dal 2026-01-01
* php mago_sync_cli.php --from=2026-06-01 --full
*
* Cron consigliato (ogni 30 minuti, nei giorni lavorativi 7-20):
* *\/30 7-20 * * 1-5 php /percorso/progetto/mago_sync_cli.php >> /percorso/log/mago_sync.log 2>&1
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Solo da riga di comando');
}
set_time_limit(0);
date_default_timezone_set('Europe/Rome');
// --- vendor e .env: li cerco in 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/MagoSync.php';
$opts = getopt('', ['limit::', 'full', 'from::', 'skip-customers']);
$runOpts = [
'limit' => isset($opts['limit']) ? (int)$opts['limit'] : null,
'full' => isset($opts['full']),
'from' => $opts['from'] ?? null,
'skip_customers' => isset($opts['skip-customers']),
];
try {
$pdo = DBHandlerSelect::getInstance()->getConnection();
$sync = new MagoSync($pdo, MagoClient::fromEnv(), function ($msg) {
echo $msg . PHP_EOL;
});
echo '=== Sync Mago ' . date('Y-m-d H:i:s') . ' ===' . PHP_EOL;
$report = $sync->run($runOpts);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(!empty($report['ok']) ? 0 : 1);
} catch (Throwable $e) {
fwrite(STDERR, 'ERRORE: ' . $e->getMessage() . PHP_EOL);
exit(1);
}
+93
View File
@@ -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);
+804 -18
View File
@@ -233,20 +233,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['aj
$row_id = (int)($_POST['row_id'] ?? 0);
$idmescola = (int)($_POST['idmescola'] ?? 0);
$pos = 0; // auto-assign
if ($idmescola <= 0) {
echo json_encode(['success' => false, 'message' => 'Seleziona una mescola']);
exit;
}
if ($pos <= 0) $pos = 1;
$mix_weight = $_POST['mix_weight_g_m'] !== '' ? (float)$_POST['mix_weight_g_m'] : null;
$density = trim($_POST['required_density'] ?? '');
$hardness = trim($_POST['required_hardness_shore_a'] ?? '');
$lub_type = ($_POST['lubrication_type'] ?? '') !== '' ? $_POST['lubrication_type'] : null;
$lub_note = trim($_POST['lubrication_notes'] ?? '');
$mix_weight = ($_POST['mix_weight_g_m'] ?? '') !== ''
? (float)str_replace(',', '.', $_POST['mix_weight_g_m'])
: null;
$support_weight = ($_POST['support_weight_g_m'] ?? '') !== ''
? (float)str_replace(',', '.', $_POST['support_weight_g_m'])
: null;
$density = trim($_POST['required_density'] ?? '');
$hardness = trim($_POST['required_hardness_shore_a'] ?? '');
$lub_type = ($_POST['lubrication_type'] ?? '') !== '' ? $_POST['lubrication_type'] : null;
$lub_note = trim($_POST['lubrication_notes'] ?? '');
$pdo->beginTransaction();
@@ -255,6 +257,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['aj
UPDATE work_sheet_mescole SET
idmescola = ?,
mix_weight_g_m = ?,
support_weight_g_m = ?,
required_density = ?,
required_hardness_shore_a = ?,
lubrication_type = ?,
@@ -264,6 +267,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['aj
$stmt->execute([
$idmescola,
$mix_weight,
$support_weight,
$density !== '' ? $density : null,
$hardness !== '' ? $hardness : null,
$lub_type,
@@ -290,17 +294,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['aj
$stmt = $pdo->prepare("
INSERT INTO work_sheet_mescole
(
worksheet_id, idmescola, mix_position, mix_weight_g_m,
worksheet_id, idmescola, mix_position,
mix_weight_g_m, support_weight_g_m,
required_density, required_hardness_shore_a,
lubrication_type, lubrication_notes
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$worksheet_id,
$idmescola,
$pos,
$mix_weight,
$support_weight,
$density !== '' ? $density : null,
$hardness !== '' ? $hardness : null,
$lub_type,
@@ -327,6 +333,68 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['aj
exit;
}
// Add package row
if ($action === 'save_package_row') {
$worksheet_id = (int)($_POST['worksheet_id'] ?? 0);
if ($worksheet_id <= 0) {
echo json_encode(['success' => false, 'message' => 'Salva prima il foglio (testata)']);
exit;
}
$package_code = (int)($_POST['package_code'] ?? 0);
if ($package_code <= 0) {
echo json_encode(['success' => false, 'message' => 'Seleziona un imballo']);
exit;
}
$pdo->beginTransaction();
$stmtPos = $pdo->prepare("
SELECT IFNULL(MAX(package_position), 0) + 1
FROM work_sheet_packages
WHERE worksheet_id = ?
FOR UPDATE
");
$stmtPos->execute([$worksheet_id]);
$pos = (int)$stmtPos->fetchColumn();
if ($pos <= 0) $pos = 1;
try {
$stmt = $pdo->prepare("
INSERT INTO work_sheet_packages
(worksheet_id, package_code, package_position)
VALUES (?, ?, ?)
");
$stmt->execute([$worksheet_id, $package_code, $pos]);
} catch (PDOException $ex) {
$pdo->rollBack();
if ((int)$ex->errorInfo[1] === 1062) {
echo json_encode(['success' => false, 'message' => 'Imballo già presente in questo foglio']);
exit;
}
throw $ex;
}
$pdo->commit();
echo json_encode(['success' => true]);
exit;
}
// Delete package row
if ($action === 'delete_package_row') {
$row_id = (int)($_POST['row_id'] ?? 0);
if ($row_id <= 0) {
echo json_encode(['success' => false, 'message' => 'ID riga non valido']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM work_sheet_packages WHERE id = ?");
$stmt->execute([$row_id]);
echo json_encode(['success' => true]);
exit;
}
echo json_encode(['success' => false, 'message' => 'Azione sconosciuta']);
exit;
} catch (Exception $e) {
@@ -406,6 +474,7 @@ $mescole = $pdo->query("
// Load worksheet (edit)
$worksheet = null;
$mixRows = [];
$packageRows = [];
if ($worksheet_id > 0) {
$stmt = $pdo->prepare("
@@ -430,6 +499,20 @@ if ($worksheet_id > 0) {
");
$stmt->execute([$worksheet_id]);
$mixRows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare("
SELECT
wsp.*,
pi.item_name AS package_name,
pi.item_code AS package_item_code,
pi.category AS package_category
FROM work_sheet_packages wsp
LEFT JOIN packaging_items pi ON pi.id = wsp.package_code
WHERE wsp.worksheet_id = ?
ORDER BY wsp.package_position ASC, wsp.id ASC
");
$stmt->execute([$worksheet_id]);
$packageRows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$worksheet_id = 0;
}
@@ -886,6 +969,44 @@ $isEdit = ($worksheet_id > 0);
padding: 12px;
}
}
.import-review {
background: #fff8db !important;
border-color: #f59e0b !important;
}
.import-hint {
font-size: 0.78rem;
color: #92400e;
margin-top: 4px;
font-weight: 700;
}
.import-banner {
background: #ecfeff;
border: 1px solid #a5f3fc;
color: #155e75;
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 16px;
font-size: 0.9rem;
}
tr.import-pending td {
background: #fff8db !important;
}
.pending-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: #fde68a;
color: #92400e;
border-radius: 999px;
padding: 3px 10px;
font-size: 0.75rem;
font-weight: 800;
}
</style>
</head>
@@ -906,6 +1027,13 @@ $isEdit = ($worksheet_id > 0);
</div>
<div class="top-actions">
<input type="file" id="xlsxImportInput" accept=".xlsx" style="display:none">
<button type="button" class="btn btn-history" id="btnImportXlsx">📥 Importa da Excel</button>
<?php if ($worksheet && !empty($worksheet['source_xlsx_path'])): ?>
<a class="btn btn-history" href="download_worksheet_file.php?id=<?= (int)$worksheet_id ?>" target="_blank">
📎 Excel di origine
</a>
<?php endif; ?>
<button class="btn btn-history" onclick="location.href='worksheets.php'">↩️ Storico fogli</button>
</div>
</div>
@@ -1079,10 +1207,56 @@ $isEdit = ($worksheet_id > 0);
<div class="col-lg-4">
<label class="form-label">Elemento imballo</label>
<select class="form-select" name="requested_package_code" id="requested_package_code">
<select class="form-select" id="requested_package_code">
<option value="">-- Prima scegli una categoria --</option>
</select>
<div class="input-tip">Il valore salvato è il codice dell'elemento</div>
<div class="input-tip">Scegli l'elemento e premi "Aggiungi imballo"</div>
</div>
<div class="col-lg-4">
<label class="form-label d-none d-lg-block">&nbsp;</label>
<button type="button" class="btn w-100" id="btnAddPackage" disabled
style="background:linear-gradient(135deg,#ea580c,#f97316);color:#fff;border:0;border-radius:12px;min-height:46px;font-weight:800;box-shadow:0 10px 24px rgba(249,115,22,0.18);">
➕ Aggiungi imballo
</button>
</div>
<div class="col-12">
<div class="table-wrap">
<table class="table table-striped table-bordered mb-0">
<thead>
<tr>
<th style="width:70px;">Pos</th>
<th>Categoria</th>
<th>Imballo</th>
<th style="width:120px;">Azioni</th>
</tr>
</thead>
<tbody id="packageRowsBody">
<?php foreach ($packageRows as $pr): ?>
<tr>
<td><?= (int)$pr['package_position'] ?></td>
<td><?= h($pr['package_category'] ?? '-') ?></td>
<td>
<div class="fw-bold"><?= h($pr['package_name'] ?? '-') ?></div>
<?php if (!empty($pr['package_item_code'])): ?>
<div class="text-muted small"><?= h($pr['package_item_code']) ?></div>
<?php endif; ?>
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger delete-package"
data-id="<?= (int)$pr['id'] ?>">🗑️</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="input-tip mt-2">Puoi aggiungere più imballi. I dati "metri per confezione", tolleranza, note, ecc. restano unici per il foglio.</div>
</div>
<div class="col-12">
<hr class="my-1">
</div>
<div class="col-lg-4 col-md-6">
@@ -1221,6 +1395,7 @@ $isEdit = ($worksheet_id > 0);
<th style="width:70px;">Pos</th>
<th>Mescola</th>
<th style="width:120px;">Peso (g/m)</th>
<th style="width:120px;">Sostegno (g/m)</th>
<th style="width:140px;">Densità</th>
<th style="width:150px;">Durezza</th>
<th style="width:120px;">Lubr.</th>
@@ -1238,6 +1413,7 @@ $isEdit = ($worksheet_id > 0);
<?php endif; ?>
</td>
<td><?= h($r['mix_weight_g_m'] ?? '-') ?></td>
<td><?= h($r['support_weight_g_m'] ?? '-') ?></td>
<td><?= h($r['required_density'] ?? '-') ?></td>
<td><?= h($r['required_hardness_shore_a'] ?? '-') ?></td>
<td><?= h($r['lubrication_type'] ?? '-') ?></td>
@@ -1286,9 +1462,10 @@ $isEdit = ($worksheet_id > 0);
<input type="hidden" name="worksheet_id" id="mixWorksheetId" value="<?= (int)$worksheet_id ?>">
<input type="hidden" name="row_id" id="mixRowId" value="0">
<input type="hidden" name="mix_position" id="mix_position" value="">
<input type="hidden" id="mixStagedIdx" value="">
<div class="row g-3">
<div class="col-lg-8">
<div class="col-lg-6">
<label class="form-label">Mescola *</label>
<select class="form-select" name="idmescola" id="idmescola" required>
<option value="">-- Seleziona --</option>
@@ -1300,11 +1477,16 @@ $isEdit = ($worksheet_id > 0);
</select>
</div>
<div class="col-lg-4">
<div class="col-lg-3 col-md-6">
<label class="form-label">Peso metro mescola (g/m)</label>
<input type="number" step="0.01" class="form-control" name="mix_weight_g_m" id="mix_weight_g_m">
</div>
<div class="col-lg-3 col-md-6">
<label class="form-label">Sostegno mescola (g/m)</label>
<input type="number" step="0.01" class="form-control" name="support_weight_g_m" id="support_weight_g_m">
</div>
<div class="col-lg-4">
<label class="form-label">Densità richiesta</label>
<input type="text" class="form-control" name="required_density" id="required_density" placeholder="es. 1,22 +/- 0,03">
@@ -1471,6 +1653,12 @@ $isEdit = ($worksheet_id > 0);
window.history.replaceState({}, '', newUrl);
}
// Import da Excel in corso: salva righe e ricarica
if (window.importStaged) {
window.finalizeImportIfAny(data.id);
return;
}
Swal.fire({
icon: 'success',
title: 'Testata salvata',
@@ -1497,7 +1685,7 @@ $isEdit = ($worksheet_id > 0);
// Prepare modal for add
$('#mixModal').on('show.bs.modal', function() {
const wsId = parseInt(wsIdInput.val() || '0', 10);
if (wsId <= 0) {
if (wsId <= 0 && !window.importStaged) {
Swal.fire({
icon: 'warning',
title: 'Salva prima la testata',
@@ -1506,12 +1694,12 @@ $isEdit = ($worksheet_id > 0);
return false;
}
// Se stiamo modificando una riga esistente, non resettare il form
if (parseInt($('#mixRowId').val() || '0', 10) > 0) {
// Se stiamo modificando una riga esistente o provvisoria, non resettare il form
if (parseInt($('#mixRowId').val() || '0', 10) > 0 || $('#mixStagedIdx').val() !== '') {
return;
}
$('#mixModalTitle').text('Aggiungi Mescola');
$('#mixModalTitle').text(wsId > 0 ? 'Aggiungi Mescola' : 'Aggiungi Mescola (verrà salvata con la testata)');
$('#mixForm')[0].reset();
$('#mixRowId').val('0');
$('#mixWorksheetId').val(wsId);
@@ -1519,6 +1707,11 @@ $isEdit = ($worksheet_id > 0);
$('#lubrication_type').val('');
});
$('#mixModal').on('hidden.bs.modal', function() {
$('#mixStagedIdx').val('');
$('#mixRowId').val('0');
});
// Edit mix row
$(document).on('click', '.edit-mix', function() {
let row = $(this).attr('data-row');
@@ -1534,6 +1727,7 @@ $isEdit = ($worksheet_id > 0);
$('#idmescola').val(row.idmescola).trigger('change');
$('#mix_weight_g_m').val(row.mix_weight_g_m ?? '');
$('#support_weight_g_m').val(row.support_weight_g_m ?? '');
$('#required_density').val(row.required_density ?? '');
$('#required_hardness_shore_a').val(row.required_hardness_shore_a ?? '');
$('#lubrication_type').val(row.lubrication_type ?? '');
@@ -1547,6 +1741,11 @@ $isEdit = ($worksheet_id > 0);
$('#mixForm').on('submit', function(e) {
e.preventDefault();
if (parseInt(wsIdInput.val() || '0', 10) <= 0 && window.importStaged) {
window.stageMixFromForm();
return;
}
const formData = new FormData(this);
formData.append('ajax', '1');
formData.append('action', 'save_mix_row');
@@ -1613,6 +1812,593 @@ $isEdit = ($worksheet_id > 0);
});
});
});
// ---- IMBALLI ----
const btnAddPackage = $('#btnAddPackage');
// Sblocca il bottone imballo se la testata è salvata
function enablePackageButtonIfSaved() {
const id = parseInt(wsIdInput.val() || '0', 10);
btnAddPackage.prop('disabled', id <= 0);
}
enablePackageButtonIfSaved();
// Ogni volta che salvi la testata, riabilita anche questo bottone
const _origEnableMix = enableMixButtonIfSaved;
enableMixButtonIfSaved = function() {
_origEnableMix();
enablePackageButtonIfSaved();
};
enableMixButtonIfSaved();
// Click: aggiungi imballo
btnAddPackage.on('click', function() {
const wsId = parseInt(wsIdInput.val() || '0', 10);
if (wsId <= 0 && window.importStaged) {
const code = $('#requested_package_code').val();
if (!code) {
Swal.fire({
icon: 'warning',
title: 'Seleziona un imballo',
timer: 1500
});
return;
}
window.stagePackage(code, $('#requested_package_code option:selected').text(), $('#packaging_category').val());
return;
}
if (wsId <= 0) {
Swal.fire({
icon: 'warning',
title: 'Salva prima la testata',
timer: 1500
});
return;
}
const packageCode = $('#requested_package_code').val();
if (!packageCode) {
Swal.fire({
icon: 'warning',
title: 'Seleziona un imballo',
timer: 1500
});
return;
}
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `ajax=1&action=save_package_row&worksheet_id=${wsId}&package_code=${encodeURIComponent(packageCode)}`
})
.then(r => r.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Imballo aggiunto!',
timer: 800
})
.then(() => location.reload());
} else {
Swal.fire({
icon: 'error',
title: 'Errore',
text: data.message
});
}
})
.catch(err => {
Swal.fire({
icon: 'error',
title: 'Errore',
text: err.message
});
});
});
// Click: elimina imballo
$(document).on('click', '.delete-package', function() {
const rowId = $(this).data('id');
Swal.fire({
title: 'Eliminare questo imballo dal foglio?',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sì, elimina',
cancelButtonText: 'Annulla'
}).then(result => {
if (!result.isConfirmed) return;
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `ajax=1&action=delete_package_row&row_id=${rowId}`
})
.then(r => r.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Eliminato!',
timer: 800
})
.then(() => location.reload());
} else {
Swal.fire({
icon: 'error',
title: 'Errore',
text: data.message
});
}
});
});
});
});
</script>
<script>
$(function() {
window.importStaged = null;
function escH(v) {
return $('<div>').text(v == null ? '' : String(v)).html();
}
function stagedButtons() {
// con import in corso si può lavorare anche prima del salvataggio
if (window.importStaged) $('#btnAddMix, #btnAddPackage').prop('disabled', false);
}
function renderPendingPackages(packages) {
$('#packageRowsBody tr.import-pending').remove();
$('#packageImportNote').remove();
const $tb = $('#packageRowsBody');
const nonAbbinati = [];
const rows = [];
(packages || []).forEach(function(p, i) {
// righe solo per gli imballi ABBINATI (sicuri, proposti o aggiunti a mano)
if (!p.package_code) {
nonAbbinati.push('“' + escH(p.raw) + '” (' + escH(p.source) + ')');
return;
}
const stato = p.manual ?
'<span class="pending-badge">➕ aggiunto, verrà salvato</span>' :
(p.needs_review ?
'<span class="pending-badge">❓ proposto — verifica</span>' :
'<span class="pending-badge">⏳ verrà salvato</span>');
const origine = p.manual ? 'aggiunto a mano' : ('dal file: ' + escH(p.raw) + ' (' + escH(p.source) + ')');
rows.push(
'<tr class="import-pending">' +
'<td>—</td>' +
'<td>' + escH(p.category || '—') + '</td>' +
'<td><div class="fw-bold">' + escH(p.matched_name || '—') + '</div>' +
'<div class="text-muted small">' + origine + '</div></td>' +
'<td class="text-nowrap">' + stato +
' <button type="button" class="btn btn-sm btn-outline-danger staged-pkg-del ms-1" data-i="' + i + '" title="Togli">🗑️</button></td>' +
'</tr>'
);
});
$tb.prepend(rows.join(''));
// nota per gli imballi NON abbinati (niente righe vuote)
if (nonAbbinati.length) {
const $note = $(
'<div id="packageImportNote" class="import-hint" style="margin-bottom:10px;">' +
'⚠ Dal file non abbinati automaticamente: ' + nonAbbinati.join(', ') +
'. Se servono, sceglili qui sopra e premi “Aggiungi imballo”.' +
'</div>'
);
$('#packageRowsBody').closest('.table-wrap').before($note);
}
}
function renderPendingMescole(mescole) {
// rimuove eventuali righe provvisorie precedenti e la riga "nessun dato" di DataTables
$('#tabellaMixRows tbody tr.import-pending').remove();
$('#tabellaMixRows tbody td.dataTables_empty').closest('tr').remove();
const $tb = $('#tabellaMixRows tbody');
const rows = [];
(mescole || []).forEach(function(m, i) {
const nome = m.idmescola ?
escH(m.matched_name || m.raw || '-') :
'<span style="color:#b45309;">⚠ da abbinare</span>';
const origine = m.manual ? 'aggiunta a mano' : ('dal file: ' + escH(m.raw));
const stato = m.idmescola ?
'<span class="pending-badge">⏳ verrà salvata</span>' :
'<span class="pending-badge">❓ scegli la mescola</span>';
rows.push(
'<tr class="import-pending">' +
'<td>—</td>' +
'<td><div class="fw-bold">' + nome + '</div>' +
'<div class="text-muted small">' + origine + '</div></td>' +
'<td>' + escH(m.mix_weight_g_m ?? '-') + '</td>' +
'<td>' + escH(m.support_weight_g_m ?? '-') + '</td>' +
'<td>' + escH(m.required_density || '-') + '</td>' +
'<td>' + escH(m.required_hardness_shore_a || '-') + '</td>' +
'<td>' + escH(m.lubrication_type || '-') + '</td>' +
'<td class="text-nowrap">' + stato + '<br>' +
'<button type="button" class="btn btn-sm btn-outline-primary staged-mix-edit mt-1" data-i="' + i + '">✏️ Modifica</button> ' +
'<button type="button" class="btn btn-sm btn-outline-danger staged-mix-del mt-1" data-i="' + i + '" title="Togli">🗑️</button>' +
'</td>' +
'</tr>'
);
});
$tb.prepend(rows.join(''));
}
// ---- modifica della lista provvisoria (prima del salvataggio) ----
window.stagePackage = function(code, label, category) {
const st = window.importStaged;
if (!st) return;
if (st.packages.some(p => String(p.package_code) === String(code))) {
Swal.fire({
icon: 'warning',
title: 'Imballo già presente',
timer: 1500
});
return;
}
st.packages.push({
package_code: code,
matched_name: label,
category: category || '',
raw: '',
source: 'manuale',
needs_review: false,
manual: true
});
renderPendingPackages(st.packages);
$('#requested_package_code').val('');
};
$(document).on('click', '.staged-pkg-del', function() {
const st = window.importStaged;
if (!st) return;
st.packages.splice(parseInt($(this).data('i'), 10), 1);
renderPendingPackages(st.packages);
});
$(document).on('click', '.staged-mix-del', function() {
const st = window.importStaged;
if (!st) return;
st.mescole.splice(parseInt($(this).data('i'), 10), 1);
renderPendingMescole(st.mescole);
});
$(document).on('click', '.staged-mix-edit', function() {
const st = window.importStaged;
if (!st) return;
const i = parseInt($(this).data('i'), 10);
const m = st.mescole[i];
if (!m) return;
$('#mixForm')[0].reset();
$('#mixRowId').val('0');
$('#mixStagedIdx').val(i);
$('#mixModalTitle').text(m.idmescola ? 'Modifica mescola (non ancora salvata)' : 'Mescola dal file: ' + (m.raw || '') + ' — seleziona la corrispondenza');
$('#idmescola').prop('disabled', false).val(m.idmescola ? String(m.idmescola) : '').trigger('change');
$('#mix_weight_g_m').val(m.mix_weight_g_m ?? '');
$('#support_weight_g_m').val(m.support_weight_g_m ?? '');
$('#required_density').val(m.required_density ?? '');
$('#required_hardness_shore_a').val(m.required_hardness_shore_a ?? '');
$('#lubrication_type').val(m.lubrication_type ?? '');
$('#lubrication_notes').val(m.lubrication_notes ?? '');
bootstrap.Modal.getOrCreateInstance(document.getElementById('mixModal')).show();
});
// salvataggio del modal mescola quando il foglio non esiste ancora
window.stageMixFromForm = function() {
const st = window.importStaged;
if (!st) return;
const idm = $('#idmescola').val();
if (!idm) {
Swal.fire({
icon: 'warning',
title: 'Seleziona una mescola',
timer: 1500
});
return;
}
const idxTxt = $('#mixStagedIdx').val();
const old = idxTxt !== '' ? (st.mescole[parseInt(idxTxt, 10)] || {}) : {};
const m = Object.assign({}, old, {
idmescola: idm,
matched_name: $.trim($('#idmescola option:selected').text()),
needs_review: false,
mix_weight_g_m: $('#mix_weight_g_m').val(),
support_weight_g_m: $('#support_weight_g_m').val(),
required_density: $('#required_density').val(),
required_hardness_shore_a: $('#required_hardness_shore_a').val(),
lubrication_type: $('#lubrication_type').val(),
lubrication_notes: $('#lubrication_notes').val()
});
if (idxTxt !== '') {
st.mescole[parseInt(idxTxt, 10)] = m;
} else {
m.raw = '';
m.manual = true;
st.mescole.push(m);
}
renderPendingMescole(st.mescole);
bootstrap.Modal.getOrCreateInstance(document.getElementById('mixModal')).hide();
};
function postForm(obj) {
return fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(obj)
}).then(r => r.json()).catch(() => ({
success: false
}));
}
function setVal(name, val) {
if (val === null || val === undefined) return;
const $el = $('[name="' + name + '"]');
if ($el.length) $el.val(val);
}
function markReview(name, info) {
const $el = $('#' + name);
if (!$el.length) return;
info = info || {};
$el.addClass('import-review');
$el.val(info.value ? info.value : ''); // pre-seleziona il suggerimento se c'è
const $wrap = $el.closest('[class*="col-"]');
let $tip = $wrap.find('.import-hint');
if (!$tip.length) {
$tip = $('<div class="import-hint"></div>');
$el.after($tip);
}
$tip.text(info.value ?
'Suggerito dal file: ' + (info.label || info.value) + ' — verifica' :
'Dal file: ' + (info.raw || '') + ' — scegli il valore');
}
function renderBanner(data) {
const bits = [];
(data.mescole || []).forEach(m =>
bits.push('Mescola “' + m.raw + '” ' + (m.idmescola ? '✓' : '⚠ da abbinare a mano')));
(data.packages || []).forEach(p =>
bits.push('Imballo “' + p.raw + '” (' + p.source + ') ' + (p.package_code ? '✓' : '⚠ da aggiungere a mano')));
let html = '<div class="import-banner"><strong>Import completato.</strong> Controlla i campi evidenziati in giallo. Imballi e mescole in giallo si possono togliere (🗑️), correggere o aggiungere: al “Salva testata” viene salvato quello che vedi in tabella.';
if (bits.length) html += '<ul style="margin:8px 0 0 18px;">' + bits.map(b => '<li>' + b + '</li>').join('') + '</ul>';
if ((data.warnings || []).length) html += '<div style="color:#b45309;margin-top:6px;">' + data.warnings.join('<br>') + '</div>';
html += '</div>';
let $b = $('#importBanner');
if (!$b.length) {
$b = $('<div id="importBanner"></div>');
$('#worksheetHeaderForm').prepend($b);
}
$b.html(html);
}
// Salvataggio righe dopo la testata (chiamato dal handler esistente)
window.finalizeImportIfAny = async function(newId) {
const staged = window.importStaged;
if (!staged) return false;
const unresolvedPkgs = [];
const matchedMescole = [];
const unmatchedMescole = [];
// imballi: salva quelli rimasti in tabella (sicuri, proposti non tolti, aggiunti a mano)
for (const p of (staged.packages || [])) {
if (p.package_code) {
await postForm({
ajax: 1,
action: 'save_package_row',
worksheet_id: newId,
package_code: p.package_code
});
} else {
unresolvedPkgs.push('Imballo “' + p.raw + '” (' + p.source + ') → nessun abbinamento, aggiungi a mano');
}
}
// mescole: separa risolte / non risolte
for (const m of (staged.mescole || [])) {
if (m.idmescola) matchedMescole.push(m);
else unmatchedMescole.push(m);
}
// salva automaticamente le mescole riconosciute
for (const m of matchedMescole) {
await postForm({
ajax: 1,
action: 'save_mix_row',
worksheet_id: newId,
idmescola: m.idmescola,
mix_weight_g_m: m.mix_weight_g_m ?? '',
support_weight_g_m: m.support_weight_g_m ?? '',
required_density: m.required_density ?? '',
required_hardness_shore_a: m.required_hardness_shore_a ?? '',
lubrication_type: m.lubrication_type ?? '',
lubrication_notes: m.lubrication_notes ?? ''
});
}
window.importStaged = null;
// allega il file Excel di origine al foglio appena creato
if (window.importFile) {
try {
const fd = new FormData();
fd.append('worksheet_id', newId);
fd.append('xlsx_file', window.importFile);
await fetch('attach_worksheet_file.php', {
method: 'POST',
body: fd
});
} catch (e) {
/* se l'allegato fallisce non blocchiamo il flusso */
}
window.importFile = null;
}
// mescola NON riconosciuta -> apri il modal già precompilato (il foglio ora esiste)
if (unmatchedMescole.length) {
const m = unmatchedMescole[0]; // il modulo ha 1 mescola per foglio
$('#wsId').val(newId);
$('#mixWorksheetId').val(newId);
if (!window.location.search.includes('id=')) {
window.history.replaceState({}, '', 'manage-worksheet.php?id=' + newId);
}
const extra = unresolvedPkgs.length ? ('\n\nDa aggiungere a mano: ' + unresolvedPkgs.join(', ')) : '';
await Swal.fire({
icon: 'info',
title: 'Mescola da abbinare',
text: 'La mescola “' + m.raw + '” non è stata riconosciuta. Selezionala dal menu: peso, densità, durezza e lubrificazione sono già compilati.' + extra
});
// precompila i campi dopo che il modal (e select2) è pronto
$('#mixModal').one('shown.bs.modal', function() {
$('#mixModalTitle').text('Mescola dal file: ' + m.raw + ' — seleziona la corrispondenza');
$('#mixRowId').val(0);
$('#mixWorksheetId').val(newId);
$('#mix_weight_g_m').val(m.mix_weight_g_m ?? '');
$('#support_weight_g_m').val(m.support_weight_g_m ?? '');
$('#required_density').val(m.required_density ?? '');
$('#required_hardness_shore_a').val(m.required_hardness_shore_a ?? '');
$('#lubrication_type').val(m.lubrication_type ?? '');
$('#idmescola').val('').trigger('change');
});
const modal = new bootstrap.Modal(document.getElementById('mixModal'));
modal.show();
return true; // niente reload: ci pensa il salvataggio del modal (save_mix_row + reload)
}
// tutto risolto: messaggio + reload
let msg = 'Testata e righe importate salvate correttamente.';
if (unresolvedPkgs.length) msg += '\n\nDa aggiungere a mano dopo il ricaricamento:\n• ' + unresolvedPkgs.join('\n• ');
await Swal.fire({
icon: unresolvedPkgs.length ? 'warning' : 'success',
title: 'Import completato',
text: msg
});
window.location.href = 'manage-worksheet.php?id=' + newId;
return true;
};
$('#btnImportXlsx').on('click', function() {
const wsId = parseInt($('#wsId').val() || '0', 10);
if (wsId > 0) {
Swal.fire({
icon: 'info',
title: 'Import solo su foglio nuovo',
text: 'Apri “Nuovo Foglio di Lavoro” per importare un Excel.'
});
return;
}
if (!$('#idmatrice').val()) {
Swal.fire({
icon: 'warning',
title: 'Seleziona prima la matrice',
text: 'Scegli la matrice / profilo, poi importa l’Excel.'
}).then(() => $('#idmatrice').select2('open'));
return;
}
$('#xlsxImportInput').val('').click();
});
$('#xlsxImportInput').on('change', function() {
const file = this.files && this.files[0];
if (!file) return;
window.importFile = file; // conserva il file per allegarlo dopo il salvataggio
const fd = new FormData();
fd.append('xlsx_file', file);
$('#packageImportNote').remove(); // azzera nota import precedente
Swal.fire({
title: 'Lettura Excel…',
allowOutsideClick: false,
didOpen: () => Swal.showLoading()
});
fetch('import_worksheet_xlsx.php', {
method: 'POST',
body: fd
})
.then(async r => {
const t = await r.text();
try {
return JSON.parse(t);
} catch (e) {
throw new Error('Risposta non valida dal parser:\n' + t.slice(0, 300));
}
})
.then(data => {
Swal.close();
if (!data.success) {
Swal.fire({
icon: 'error',
title: 'Errore import',
text: data.message || 'Errore sconosciuto'
});
return;
}
const h = data.header || {};
setVal('worksheet_date', h.worksheet_date);
setVal('customer_name', h.customer_name);
setVal('profile_type_code', h.profile_type_code);
setVal('marking', h.marking);
setVal('prod_control_measure_settings', h.prod_control_measure_settings);
setVal('meters_per_package', h.meters_per_package);
setVal('meters_per_package_tolerance', h.meters_per_package_tolerance);
setVal('meters_per_package_notes', h.meters_per_package_notes);
setVal('meters_per_box', h.meters_per_box);
setVal('packages_or_pieces_per_box', h.packages_or_pieces_per_box);
setVal('boxes_or_packages_per_pallet', h.boxes_or_packages_per_pallet);
setVal('speed_expected_kg_h', h.speed_expected_kg_h);
setVal('speed_expected_m_h', h.speed_expected_m_h);
setVal('notes', h.notes);
const lk = data.header_lookups || {};
Object.keys(lk).forEach(name => {
if (lk[name] && lk[name].needs_review) markReview(name, lk[name]);
});
window.importStaged = {
mescole: data.mescole || [],
packages: data.packages || [],
warnings: data.warnings || []
};
renderBanner(data);
renderPendingMescole(data.mescole || []);
renderPendingPackages(data.packages || []);
stagedButtons();
Swal.fire({
icon: 'success',
title: 'Excel letto',
text: 'Controlla i campi evidenziati, poi premi “Salva testata”.',
timer: 1800
});
})
.catch(err => {
Swal.close();
Swal.fire({
icon: 'error',
title: 'Errore',
text: err.message
});
});
});
});
</script>
</body>
@@ -33,7 +33,7 @@ $MNT_BASE = $mntPos !== false
<script src="manutenzioni/assets/fullcalendar-it.js"></script>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function() {
if (typeof window.Swal === 'undefined') {
var local = document.createElement('script');
local.src = 'manutenzioni/assets/sweetalert2.min.js';
@@ -50,122 +50,633 @@ $MNT_BASE = $mntPos !== false
--mnt-card-border: #d6e5e9;
}
.mnt-card { border: none; border-radius: 0.75rem; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); overflow: hidden; }
.mnt-card .card-header { background: var(--mnt-card-bg); border-bottom: 1px solid var(--mnt-card-border); padding: 1rem 1.25rem; }
.mnt-card .card-header h5 { font-weight: 700; color: var(--mnt-heading); margin: 0; font-size: 1.05rem; letter-spacing: -0.01em; }
.mnt-card .card-body { padding: 1.25rem; }
.mnt-card {
border: none;
border-radius: 0.75rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.btn-mnt-primary { background: var(--mnt-primary); border: none; color: #fff; font-weight: 600; font-size: 0.85rem; padding: 0.5rem 1rem; border-radius: 0.5rem; transition: all 0.2s; }
.btn-mnt-primary:hover { background: var(--mnt-primary-hover); color: #fff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(47, 125, 143, 0.3); }
.btn-mnt-outline { background: transparent; border: 1.5px solid var(--mnt-primary); color: var(--mnt-primary); font-weight: 600; font-size: 0.85rem; padding: 0.45rem 1rem; border-radius: 0.5rem; transition: all 0.2s; }
.btn-mnt-outline:hover { background: var(--mnt-primary); color: #fff; transform: translateY(-1px); }
.mnt-card .card-header {
background: var(--mnt-card-bg);
border-bottom: 1px solid var(--mnt-card-border);
padding: 1rem 1.25rem;
}
.btn-action { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 0.4rem; font-size: 0.85rem; transition: all 0.15s; text-decoration: none; }
.btn-action-view { background: rgba(47, 125, 143, 0.12); color: var(--mnt-primary); }
.btn-action-view:hover { background: var(--mnt-primary); color: #fff; }
.btn-action-edit { background: rgba(13, 110, 253, 0.12); color: #0d6efd; }
.btn-action-edit:hover { background: #0d6efd; color: #fff; }
.btn-action-delete { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
.btn-action-delete:hover { background: #dc3545; color: #fff; }
.btn-action-done { background: rgba(25, 135, 84, 0.12); color: #198754; }
.btn-action-done:hover { background: #198754; color: #fff; }
.mnt-card .card-header h5 {
font-weight: 700;
color: var(--mnt-heading);
margin: 0;
font-size: 1.05rem;
letter-spacing: -0.01em;
}
.mnt-card .card-body {
padding: 1.25rem;
}
.btn-mnt-primary {
background: var(--mnt-primary);
border: none;
color: #fff;
font-weight: 600;
font-size: 0.85rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
.btn-mnt-primary:hover {
background: var(--mnt-primary-hover);
color: #fff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(47, 125, 143, 0.3);
}
.btn-mnt-outline {
background: transparent;
border: 1.5px solid var(--mnt-primary);
color: var(--mnt-primary);
font-weight: 600;
font-size: 0.85rem;
padding: 0.45rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
.btn-mnt-outline:hover {
background: var(--mnt-primary);
color: #fff;
transform: translateY(-1px);
}
.btn-action {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.4rem;
font-size: 0.85rem;
transition: all 0.15s;
text-decoration: none;
}
.btn-action-view {
background: rgba(47, 125, 143, 0.12);
color: var(--mnt-primary);
}
.btn-action-view:hover {
background: var(--mnt-primary);
color: #fff;
}
.btn-action-edit {
background: rgba(13, 110, 253, 0.12);
color: #0d6efd;
}
.btn-action-edit:hover {
background: #0d6efd;
color: #fff;
}
.btn-action-delete {
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
}
.btn-action-delete:hover {
background: #dc3545;
color: #fff;
}
.btn-action-done {
background: rgba(25, 135, 84, 0.12);
color: #198754;
}
.btn-action-done:hover {
background: #198754;
color: #fff;
}
/* Schedule state badges */
.mnt-badge { display: inline-block; padding: 0.22rem 0.6rem; border-radius: 999px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; }
.mnt-badge-overdue { background: rgba(220, 53, 69, 0.14); color: #b02a37; }
.mnt-badge-soon { background: rgba(255, 193, 7, 0.2); color: #9a7000; }
.mnt-badge-ok { background: rgba(25, 135, 84, 0.14); color: #146c43; }
.mnt-badge-none { background: rgba(108, 117, 125, 0.14); color: #565e64; }
.mnt-badge-critical { background: rgba(220, 53, 69, 0.9); color: #fff; }
.mnt-badge-soft { background: rgba(47, 125, 143, 0.12); color: var(--mnt-primary); }
.mnt-badge-status-active { background: rgba(25, 135, 84, 0.14); color: #146c43; }
.mnt-badge-status-out_of_service { background: rgba(255, 193, 7, 0.2); color: #9a7000; }
.mnt-badge-status-decommissioned { background: rgba(108, 117, 125, 0.16); color: #495057; }
.mnt-cat-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.modal-content > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.modal-content > form > .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.mnt-title-cell { max-width: 320px; }
.mnt-equipment-cell { max-width: 240px; }
.mnt-freq-cell { max-width: 190px; }
.mnt-person-cell { max-width: 150px; }
.mnt-name-cell { max-width: 300px; }
.mnt-badge {
display: inline-block;
padding: 0.22rem 0.6rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.01em;
white-space: nowrap;
}
.mnt-badge-overdue {
background: rgba(220, 53, 69, 0.14);
color: #b02a37;
}
.mnt-badge-soon {
background: rgba(255, 193, 7, 0.2);
color: #9a7000;
}
.mnt-badge-ok {
background: rgba(25, 135, 84, 0.14);
color: #146c43;
}
.mnt-badge-none {
background: rgba(108, 117, 125, 0.14);
color: #565e64;
}
.mnt-badge-critical {
background: rgba(220, 53, 69, 0.9);
color: #fff;
}
.mnt-badge-soft {
background: rgba(47, 125, 143, 0.12);
color: var(--mnt-primary);
}
.mnt-badge-status-active {
background: rgba(25, 135, 84, 0.14);
color: #146c43;
}
.mnt-badge-status-out_of_service {
background: rgba(255, 193, 7, 0.2);
color: #9a7000;
}
.mnt-badge-status-decommissioned {
background: rgba(108, 117, 125, 0.16);
color: #495057;
}
.mnt-cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.modal-content>form {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.modal-content>form>.modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.mnt-title-cell {
max-width: 320px;
}
.mnt-equipment-cell {
max-width: 240px;
}
.mnt-freq-cell {
max-width: 190px;
}
.mnt-person-cell {
max-width: 150px;
}
.mnt-name-cell {
max-width: 300px;
}
.mnt-name-cell .mnt-title-clamp {
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mnt-title-clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
min-width: 0;
}
.mnt-equipment-cell .mnt-title-clamp {
flex: 1 1 auto;
}
.mnt-title-clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; min-width: 0; }
.mnt-equipment-cell .mnt-title-clamp { flex: 1 1 auto; }
/* Mobile-first cards; the table appears from xl (1200px) upwards */
.mnt-item-card { background: #fff; border: 1px solid var(--mnt-card-border); border-left: 5px solid var(--row-color, #e9ecef); border-radius: 0.6rem; padding: 0.85rem 0.95rem; margin-bottom: 0.6rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); }
.mnt-item-card .ic-title { font-weight: 700; color: var(--mnt-heading); font-size: 0.95rem; word-break: break-word; }
.mnt-item-card .ic-meta { font-size: 0.8rem; color: #6c757d; margin-top: 0.3rem; }
.mnt-item-card .ic-meta strong { color: var(--mnt-heading); font-weight: 600; }
.mnt-item-card .ic-actions { display: flex; gap: 0.4rem; justify-content: flex-end; margin-top: 0.6rem; }
.mnt-item-card {
background: #fff;
border: 1px solid var(--mnt-card-border);
border-left: 5px solid var(--row-color, #e9ecef);
border-radius: 0.6rem;
padding: 0.85rem 0.95rem;
margin-bottom: 0.6rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.mnt-item-card .ic-title {
font-weight: 700;
color: var(--mnt-heading);
font-size: 0.95rem;
word-break: break-word;
}
.mnt-item-card .ic-meta {
font-size: 0.8rem;
color: #6c757d;
margin-top: 0.3rem;
}
.mnt-item-card .ic-meta strong {
color: var(--mnt-heading);
font-weight: 600;
}
.mnt-item-card .ic-actions {
display: flex;
gap: 0.4rem;
justify-content: flex-end;
margin-top: 0.6rem;
}
/* Tile contatori: sfondo pieno tenue, testo scuro coordinato */
.mnt-tile {
border: 1px solid transparent;
border-left: none;
box-shadow: none;
transition: transform .12s ease, box-shadow .12s ease;
}
.mnt-tile:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.10);
}
.mnt-tile.mnt-tile-active {
box-shadow: 0 0 0 2px currentColor inset;
}
.mnt-tile-overdue {
background: rgba(220, 53, 69, 0.14);
color: #b02a37;
}
.mnt-tile-due_soon {
background: rgba(255, 193, 7, 0.20);
color: #9a7000;
}
.mnt-tile-ok {
background: rgba(25, 135, 84, 0.14);
color: #146c43;
}
.mnt-tile-none {
background: rgba(108, 117, 125, 0.14);
color: #565e64;
}
.mnt-tile .ic-title {
color: inherit;
}
.mnt-tile .ic-meta {
color: inherit;
opacity: 0.85;
}
@media (min-width: 768px) {
.d-xl-none > .mnt-item-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 1rem; }
.d-xl-none > .mnt-item-card > :not(.ic-actions) { grid-column: 1; }
.d-xl-none > .mnt-item-card > .ic-actions { grid-column: 2; grid-row: 1 / -1; margin-top: 0; }
.d-xl-none>.mnt-item-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
}
.d-xl-none>.mnt-item-card> :not(.ic-actions) {
grid-column: 1;
}
.d-xl-none>.mnt-item-card>.ic-actions {
grid-column: 2;
grid-row: 1 / -1;
margin-top: 0;
}
}
.mnt-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
padding: 0.85rem 0.25rem 0.25rem;
font-size: 0.85rem;
}
.mnt-pager-info {
color: #6c757d;
}
.mnt-pager-info strong {
color: var(--mnt-heading);
}
.mnt-pager-per {
display: flex;
align-items: center;
gap: 0.35rem;
}
.mnt-pager-pages {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.mnt-pager-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid var(--mnt-card-border);
border-radius: 0.45rem;
background: #fff;
color: var(--mnt-heading);
text-decoration: none;
}
.mnt-pager-link:hover {
border-color: #2f7d8f;
color: #2f7d8f;
}
.mnt-pager-link.is-current {
background: #2f7d8f;
border-color: #2f7d8f;
color: #fff;
font-weight: 700;
}
.mnt-pager-link.is-disabled {
opacity: 0.4;
pointer-events: none;
}
.mnt-pager-gap {
padding: 0 0.15rem;
color: #adb5bd;
}
.mnt-pager { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1.25rem; padding: 0.85rem 0.25rem 0.25rem; font-size: 0.85rem; }
.mnt-pager-info { color: #6c757d; }
.mnt-pager-info strong { color: var(--mnt-heading); }
.mnt-pager-per { display: flex; align-items: center; gap: 0.35rem; }
.mnt-pager-pages { display: flex; align-items: center; gap: 0.25rem; margin-left: auto; }
.mnt-pager-link { display: inline-flex; align-items: center; justify-content: center; min-width: 2rem; height: 2rem; padding: 0 0.5rem; border: 1px solid var(--mnt-card-border); border-radius: 0.45rem; background: #fff; color: var(--mnt-heading); text-decoration: none; }
.mnt-pager-link:hover { border-color: #2f7d8f; color: #2f7d8f; }
.mnt-pager-link.is-current { background: #2f7d8f; border-color: #2f7d8f; color: #fff; font-weight: 700; }
.mnt-pager-link.is-disabled { opacity: 0.4; pointer-events: none; }
.mnt-pager-gap { padding: 0 0.15rem; color: #adb5bd; }
@media (max-width: 575.98px) {
.mnt-pager-pages { margin-left: 0; width: 100%; justify-content: center; }
.mnt-pager-pages {
margin-left: 0;
width: 100%;
justify-content: center;
}
}
.mnt-thumb { width: 46px; height: 46px; border-radius: 0.45rem; object-fit: cover; border: 1px solid var(--mnt-card-border); background: #f6f9fa; }
.mnt-thumb-placeholder { display: inline-flex; align-items: center; justify-content: center; color: #adb5bd; font-size: 1.1rem; }
.mnt-thumb {
width: 46px;
height: 46px;
border-radius: 0.45rem;
object-fit: cover;
border: 1px solid var(--mnt-card-border);
background: #f6f9fa;
}
.empty-state { text-align: center; padding: 3rem 1rem; color: #6c757d; }
.empty-state i { font-size: 3rem; opacity: 0.3; margin-bottom: 1rem; display: block; }
.mnt-thumb-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: #adb5bd;
font-size: 1.1rem;
}
.mnt-filter-bar { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 1rem; }
.mnt-filter-bar .form-select, .mnt-filter-bar .form-control { min-width: 150px; flex: 1 1 150px; max-width: 100%; }
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6c757d;
}
.mnt-section-title { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #8a9aa0; margin-bottom: 0.5rem; }
.mnt-kv { display: flex; gap: 0.5rem; padding: 0.35rem 0; border-bottom: 1px dashed #eceff1; font-size: 0.88rem; }
.mnt-kv:last-child { border-bottom: none; }
.mnt-kv dt { flex: 0 0 42%; color: #6c757d; font-weight: 500; margin: 0; }
.mnt-kv dd { flex: 1; margin: 0; color: var(--mnt-heading); font-weight: 600; word-break: break-word; }
.empty-state i {
font-size: 3rem;
opacity: 0.3;
margin-bottom: 1rem;
display: block;
}
.mnt-photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.6rem; }
.mnt-photo-item { position: relative; border-radius: 0.5rem; overflow: hidden; border: 1px solid var(--mnt-card-border); aspect-ratio: 1; }
.mnt-photo-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mnt-photo-item .photo-tools { position: absolute; inset: auto 0 0 0; display: flex; gap: 0.25rem; justify-content: center; padding: 0.25rem; background: rgba(0, 0, 0, 0.45); opacity: 0; transition: opacity 0.15s; }
.mnt-photo-item:hover .photo-tools { opacity: 1; }
.mnt-photo-item .photo-tools button { border: none; background: transparent; color: #fff; font-size: 0.8rem; padding: 0.1rem 0.35rem; }
.mnt-photo-item.is-cover { border-color: var(--mnt-primary); box-shadow: 0 0 0 2px rgba(47, 125, 143, 0.25); }
.mnt-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1rem;
}
.mnt-file-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f4f5; font-size: 0.88rem; }
.mnt-file-row:last-child { border-bottom: none; }
.mnt-file-row .file-name { flex: 1; word-break: break-all; }
.mnt-filter-bar .form-select,
.mnt-filter-bar .form-control {
min-width: 150px;
flex: 1 1 150px;
max-width: 100%;
}
.mnt-section-title {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #8a9aa0;
margin-bottom: 0.5rem;
}
.mnt-kv {
display: flex;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px dashed #eceff1;
font-size: 0.88rem;
}
.mnt-kv:last-child {
border-bottom: none;
}
.mnt-kv dt {
flex: 0 0 42%;
color: #6c757d;
font-weight: 500;
margin: 0;
}
.mnt-kv dd {
flex: 1;
margin: 0;
color: var(--mnt-heading);
font-weight: 600;
word-break: break-word;
}
.mnt-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.6rem;
}
.mnt-photo-item {
position: relative;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid var(--mnt-card-border);
aspect-ratio: 1;
}
.mnt-photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.mnt-photo-item .photo-tools {
position: absolute;
inset: auto 0 0 0;
display: flex;
gap: 0.25rem;
justify-content: center;
padding: 0.25rem;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition: opacity 0.15s;
}
.mnt-photo-item:hover .photo-tools {
opacity: 1;
}
.mnt-photo-item .photo-tools button {
border: none;
background: transparent;
color: #fff;
font-size: 0.8rem;
padding: 0.1rem 0.35rem;
}
.mnt-photo-item.is-cover {
border-color: var(--mnt-primary);
box-shadow: 0 0 0 2px rgba(47, 125, 143, 0.25);
}
.mnt-file-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
border-bottom: 1px solid #f1f4f5;
font-size: 0.88rem;
}
.mnt-file-row:last-child {
border-bottom: none;
}
.mnt-file-row .file-name {
flex: 1;
word-break: break-all;
}
/* Signature pad (tablet) */
.mnt-signature-wrap { border: 1px dashed var(--mnt-card-border); border-radius: 0.5rem; background: #fcfdfd; position: relative; }
.mnt-signature-wrap canvas { width: 100%; height: 160px; display: block; touch-action: none; border-radius: 0.5rem; }
.mnt-signature-wrap {
border: 1px dashed var(--mnt-card-border);
border-radius: 0.5rem;
background: #fcfdfd;
position: relative;
}
.mnt-timeline { position: relative; padding-left: 1.25rem; }
.mnt-timeline::before { content: ''; position: absolute; left: 6px; top: 4px; bottom: 4px; width: 2px; background: #e6eef0; }
.mnt-timeline-item { position: relative; padding-bottom: 0.9rem; }
.mnt-timeline-item::before { content: ''; position: absolute; left: -1.25rem; top: 0.35rem; width: 10px; height: 10px; border-radius: 50%; background: var(--mnt-primary); box-shadow: 0 0 0 3px #fff; }
.mnt-timeline-item .tl-date { font-size: 0.75rem; color: #8a9aa0; font-weight: 600; }
.mnt-timeline-item .tl-body { font-size: 0.88rem; color: var(--mnt-heading); }
.mnt-signature-wrap canvas {
width: 100%;
height: 160px;
display: block;
touch-action: none;
border-radius: 0.5rem;
}
.mnt-timeline {
position: relative;
padding-left: 1.25rem;
}
.mnt-timeline::before {
content: '';
position: absolute;
left: 6px;
top: 4px;
bottom: 4px;
width: 2px;
background: #e6eef0;
}
.mnt-timeline-item {
position: relative;
padding-bottom: 0.9rem;
}
.mnt-timeline-item::before {
content: '';
position: absolute;
left: -1.25rem;
top: 0.35rem;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--mnt-primary);
box-shadow: 0 0 0 3px #fff;
}
.mnt-timeline-item .tl-date {
font-size: 0.75rem;
color: #8a9aa0;
font-weight: 600;
}
.mnt-timeline-item .tl-body {
font-size: 0.88rem;
color: var(--mnt-heading);
}
@media (max-width: 575.98px) {
.mnt-card .card-header { flex-direction: column; gap: 0.75rem; align-items: flex-start !important; }
.header-actions { width: 100%; }
.header-actions .btn { width: 100%; justify-content: center; }
.mnt-card .card-header {
flex-direction: column;
gap: 0.75rem;
align-items: flex-start !important;
}
.header-actions {
width: 100%;
}
.header-actions .btn {
width: 100%;
justify-content: center;
}
}
</style>
</style>
+249 -237
View File
@@ -108,235 +108,236 @@ $MNT_TITLE = 'Manutenzioni';
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="page-wrapper">
<div class="page-content">
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5><i class="fa-solid fa-screwdriver-wrench me-2"></i>Manutenzioni</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/calendar.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-calendar-days"></i><span>Calendario</span>
</a>
<a href="manutenzioni/index.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-boxes-stacked"></i><span>Registro</span>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-2 mb-3">
<?php
$tiles = [
['key' => 'overdue', 'label' => 'Scadute', 'class' => 'mnt-badge-overdue'],
['key' => 'due_soon', 'label' => 'In scadenza', 'class' => 'mnt-badge-soon'],
['key' => 'ok', 'label' => 'In regola', 'class' => 'mnt-badge-ok'],
['key' => 'none', 'label' => 'Senza scadenza', 'class' => 'mnt-badge-none'],
];
?>
<?php foreach ($tiles as $tile): ?>
<div class="col-6 col-md-3">
<?php
// Percorso completo, non "?...": con <base href="/userarea/">
// un href di sola query porterebbe alla dashboard del template.
// page is dropped: changing the filter changes the result
// set, and staying on page 3 of the previous one is nonsense.
$tileQuery = $_GET;
unset($tileQuery['page']);
$tileUrl = 'manutenzioni/maintenances.php?' . http_build_query(
array_merge($tileQuery, ['state' => $filterState === $tile['key'] ? '' : $tile['key']])
);
?>
<a class="d-block text-decoration-none" href="<?= mnt_h($tileUrl) ?>">
<div class="mnt-item-card mb-0 <?= $filterState === $tile['key'] ? 'border-2' : '' ?>"
style="--row-color: <?= $tile['key'] === 'overdue' ? '#dc3545' : ($tile['key'] === 'due_soon' ? '#f0a202' : ($tile['key'] === 'ok' ? '#198754' : '#adb5bd')) ?>">
<div class="ic-title" style="font-size:1.4rem"><?= (int)$counters[$tile['key']] ?></div>
<div class="ic-meta"><?= $tile['label'] ?></div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<form class="mnt-filter-bar" method="get" id="filterForm">
<input type="hidden" name="state" value="<?= mnt_h($filterState) ?>">
<select class="form-select" name="type">
<option value="">Programmate e straordinarie</option>
<?php foreach ($typeLabels as $value => $label): ?>
<option value="<?= mnt_h($value) ?>" <?= $filterType === $value ? 'selected' : '' ?>>
Solo <?= mnt_h(mb_strtolower($label)) ?>
</option>
<?php endforeach; ?>
</select>
<select class="form-select" name="category">
<option value="">Tutte le categorie</option>
<?php foreach ($formData['categories'] as $category): ?>
<option value="<?= (int)$category['id'] ?>" <?= $filterCategory === (int)$category['id'] ? 'selected' : '' ?>>
<?= mnt_h($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-check d-flex align-items-center gap-2 ms-1">
<input class="form-check-input mt-0" type="checkbox" name="critical" value="1" id="fCritical" <?= $filterCritical ? 'checked' : '' ?>>
<label class="form-check-label" for="fCritical">Solo critiche</label>
</div>
<div class="form-check d-flex align-items-center gap-2 ms-1">
<input class="form-check-input mt-0" type="checkbox" name="mine" value="1" id="fMine" <?= $filterMine ? 'checked' : '' ?>>
<label class="form-check-label" for="fMine">Assegnate a me</label>
</div>
<?php if ($perPage !== 25): ?>
<input type="hidden" name="per_page" value="<?= (int)$perPage ?>">
<?php endif; ?>
<!-- No «Filtra» button: selects and checkboxes all submit on change,
so it never had anything left to do. Kept hidden only as the
no-JavaScript fallback. -->
<button type="submit" class="visually-hidden">Filtra</button>
<a href="manutenzioni/maintenances.php" class="btn btn-mnt-outline btn-reset-filters d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-xmark"></i><span>Azzera filtri</span>
</a>
</form>
<?php if (!$maintenances): ?>
<div class="empty-state">
<i class="fa-solid fa-screwdriver-wrench"></i>
<p>Nessuna manutenzione con questi filtri.</p>
</div>
<?php else: ?>
<div id="maintenanceList">
<!-- CARD -->
<div class="d-xl-none">
<?php foreach ($maintenances as $maintenance): ?>
<?php $badge = mnt_due_badge($maintenance['state'], $maintenance['frequency_unit']); ?>
<div class="mnt-item-card" data-id="<?= (int)$maintenance['id'] ?>"
style="--row-color: <?= $maintenance['is_critical'] ? '#dc3545' : mnt_h($maintenance['category_color'] ?? '#e9ecef') ?>">
<div class="ic-title"><?= mnt_h($maintenance['equipment_name']) ?></div>
<div class="ic-meta">
<?php if ($maintenance['code']): ?><strong><?= mnt_h($maintenance['code']) ?></strong> · <?php endif; ?>
<?= mnt_h($maintenance['title']) ?>
</div>
<div class="ic-meta">
Prossimo: <strong><?= mnt_format_date($maintenance['next_due_date']) ?></strong>
<span class="mnt-badge <?= $badge['class'] ?> ms-1"><?= $badge['label'] ?></span>
</div>
<div class="ic-actions">
<a class="btn-action btn-action-view" href="manutenzioni/equipment.php?id=<?= (int)$maintenance['equipment_id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-done btn-register" title="Registra intervento">
<i class="fa-solid fa-check"></i>
</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- DESKTOP -->
<!-- Table only from 1200px up: measured, the 6 columns need
~1280px to fit, so on a tablet it only scrolls sideways. -->
<div class="d-none d-xl-block table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th class="mnt-equipment-cell">Attrezzatura</th>
<th>Manutenzione</th>
<th class="mnt-freq-cell">Frequenza</th>
<th class="mnt-person-cell">Incaricato</th>
<th class="text-center">Prossima</th>
<th class="text-center" style="width:130px">Azioni</th>
</tr>
</thead>
<tbody>
<?php foreach ($maintenances as $maintenance): ?>
<?php $badge = mnt_due_badge($maintenance['state'], $maintenance['frequency_unit']); ?>
<tr data-id="<?= (int)$maintenance['id'] ?>">
<td class="mnt-equipment-cell">
<span class="d-flex align-items-start gap-2">
<span class="mnt-cat-dot mt-1" style="background: <?= mnt_h($maintenance['category_color'] ?? '#adb5bd') ?>"></span>
<span class="fw-semibold mnt-title-clamp" style="color:var(--mnt-heading)"
title="<?= mnt_h($maintenance['equipment_name']) ?>"><?= mnt_h($maintenance['equipment_name']) ?></span>
</span>
<div class="small text-muted"><?= mnt_h($maintenance['category_name'] ?? '') ?></div>
</td>
<td class="mnt-title-cell">
<?php if ($maintenance['code']): ?>
<span class="mnt-badge mnt-badge-soft me-1"><?= mnt_h($maintenance['code']) ?></span>
<?php endif; ?>
<?php if ($maintenance['is_critical']): ?>
<span class="mnt-badge mnt-badge-critical ms-1">Critica</span>
<?php endif; ?>
<div class="mnt-title-clamp" title="<?= mnt_h($maintenance['title']) ?>">
<?= mnt_h($maintenance['title']) ?>
</div>
<div class="small text-muted">
<?= mnt_h($typeLabels[$maintenance['intervention_type']] ?? '') ?>
· <?= mnt_h($executionLabels[$maintenance['execution_type']] ?? '') ?>
<?php if ($maintenance['supplier_name']): ?>
— <?= mnt_h($maintenance['supplier_name']) ?>
<?php endif; ?>
</div>
</td>
<td class="small mnt-freq-cell"><div class="mnt-title-clamp"><?= mnt_h(mnt_format_frequency(
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
$maintenance['frequency_unit'],
$maintenance['frequency_note']
)) ?></div></td>
<td class="small mnt-person-cell">
<?= mnt_h($maintenance['assignee_name'] ?: '—') ?>
<?php if ($maintenance['supervisor_name']): ?>
<div class="text-muted"><?= mnt_h($maintenance['supervisor_name']) ?></div>
<?php endif; ?>
</td>
<td class="text-center">
<div><?= mnt_format_date($maintenance['next_due_date']) ?></div>
<span class="mnt-badge <?= $badge['class'] ?>"><?= $badge['label'] ?></span>
</td>
<td class="text-center">
<div class="d-inline-flex gap-1">
<a class="btn-action btn-action-view" title="Apri scheda"
href="manutenzioni/equipment.php?id=<?= (int)$maintenance['equipment_id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-done btn-register" title="Registra intervento">
<i class="fa-solid fa-check"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php
$mntPagerUrl = 'manutenzioni/maintenances.php';
$mntPagerPage = $page;
$mntPagerPages = $totalPages;
$mntPagerTotal = $totalMaintenances;
$mntPagerPer = $perPage;
include __DIR__ . '/include/pagination.php';
?>
<?php endif; ?>
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5><i class="fa-solid fa-screwdriver-wrench me-2"></i>Manutenzioni</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/calendar.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-calendar-days"></i><span>Calendario</span>
</a>
<a href="manutenzioni/index.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-boxes-stacked"></i><span>Registro</span>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-2 mb-3">
<?php
$tiles = [
['key' => 'overdue', 'label' => 'Scadute', 'class' => 'mnt-badge-overdue'],
['key' => 'due_soon', 'label' => 'In scadenza', 'class' => 'mnt-badge-soon'],
['key' => 'ok', 'label' => 'In regola', 'class' => 'mnt-badge-ok'],
['key' => 'none', 'label' => 'Senza scadenza', 'class' => 'mnt-badge-none'],
];
?>
<?php foreach ($tiles as $tile): ?>
<div class="col-6 col-md-3">
<?php
// Percorso completo, non "?...": con <base href="/userarea/">
// un href di sola query porterebbe alla dashboard del template.
// page is dropped: changing the filter changes the result
// set, and staying on page 3 of the previous one is nonsense.
$tileQuery = $_GET;
unset($tileQuery['page']);
$tileUrl = 'manutenzioni/maintenances.php?' . http_build_query(
array_merge($tileQuery, ['state' => $filterState === $tile['key'] ? '' : $tile['key']])
);
?>
<a class="d-block text-decoration-none" href="<?= mnt_h($tileUrl) ?>">
<div class="mnt-item-card mnt-tile mnt-tile-<?= $tile['key'] ?> mb-0 <?= $filterState === $tile['key'] ? 'mnt-tile-active' : '' ?>">
<div class="ic-title" style="font-size:1.4rem"><?= (int)$counters[$tile['key']] ?></div>
<div class="ic-meta"><?= $tile['label'] ?></div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<form class="mnt-filter-bar" method="get" id="filterForm">
<input type="hidden" name="state" value="<?= mnt_h($filterState) ?>">
<select class="form-select" name="type">
<option value="">Programmate e straordinarie</option>
<?php foreach ($typeLabels as $value => $label): ?>
<option value="<?= mnt_h($value) ?>" <?= $filterType === $value ? 'selected' : '' ?>>
Solo <?= mnt_h(mb_strtolower($label)) ?>
</option>
<?php endforeach; ?>
</select>
<select class="form-select" name="category">
<option value="">Tutte le categorie</option>
<?php foreach ($formData['categories'] as $category): ?>
<option value="<?= (int)$category['id'] ?>" <?= $filterCategory === (int)$category['id'] ? 'selected' : '' ?>>
<?= mnt_h($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-check d-flex align-items-center gap-2 ms-1">
<input class="form-check-input mt-0" type="checkbox" name="critical" value="1" id="fCritical" <?= $filterCritical ? 'checked' : '' ?>>
<label class="form-check-label" for="fCritical">Solo critiche</label>
</div>
<div class="form-check d-flex align-items-center gap-2 ms-1">
<input class="form-check-input mt-0" type="checkbox" name="mine" value="1" id="fMine" <?= $filterMine ? 'checked' : '' ?>>
<label class="form-check-label" for="fMine">Assegnate a me</label>
</div>
<?php if ($perPage !== 25): ?>
<input type="hidden" name="per_page" value="<?= (int)$perPage ?>">
<?php endif; ?>
<!-- No «Filtra» button: selects and checkboxes all submit on change,
so it never had anything left to do. Kept hidden only as the
no-JavaScript fallback. -->
<button type="submit" class="visually-hidden">Filtra</button>
<a href="manutenzioni/maintenances.php" class="btn btn-mnt-outline btn-reset-filters d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-xmark"></i><span>Azzera filtri</span>
</a>
</form>
<?php if (!$maintenances): ?>
<div class="empty-state">
<i class="fa-solid fa-screwdriver-wrench"></i>
<p>Nessuna manutenzione con questi filtri.</p>
</div>
<?php else: ?>
<div id="maintenanceList">
<!-- CARD -->
<div class="d-xl-none">
<?php foreach ($maintenances as $maintenance): ?>
<?php $badge = mnt_due_badge($maintenance['state'], $maintenance['frequency_unit']); ?>
<div class="mnt-item-card" data-id="<?= (int)$maintenance['id'] ?>"
style="--row-color: <?= $maintenance['is_critical'] ? '#dc3545' : mnt_h($maintenance['category_color'] ?? '#e9ecef') ?>">
<div class="ic-title"><?= mnt_h($maintenance['equipment_name']) ?></div>
<div class="ic-meta">
<?php if ($maintenance['code']): ?><strong><?= mnt_h($maintenance['code']) ?></strong> · <?php endif; ?>
<?= mnt_h($maintenance['title']) ?>
</div>
<div class="ic-meta">
Prossimo: <strong><?= mnt_format_date($maintenance['next_due_date']) ?></strong>
<span class="mnt-badge <?= $badge['class'] ?> ms-1"><?= $badge['label'] ?></span>
</div>
<div class="ic-actions">
<a class="btn-action btn-action-view" href="manutenzioni/equipment.php?id=<?= (int)$maintenance['equipment_id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-done btn-register" title="Registra intervento">
<i class="fa-solid fa-check"></i>
</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- DESKTOP -->
<!-- Table only from 1200px up: measured, the 6 columns need
~1280px to fit, so on a tablet it only scrolls sideways. -->
<div class="d-none d-xl-block table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th class="mnt-equipment-cell">Attrezzatura</th>
<th>Manutenzione</th>
<th class="mnt-freq-cell">Frequenza</th>
<th class="mnt-person-cell">Incaricato</th>
<th class="text-center">Prossima</th>
<th class="text-center" style="width:130px">Azioni</th>
</tr>
</thead>
<tbody>
<?php foreach ($maintenances as $maintenance): ?>
<?php $badge = mnt_due_badge($maintenance['state'], $maintenance['frequency_unit']); ?>
<tr data-id="<?= (int)$maintenance['id'] ?>">
<td class="mnt-equipment-cell">
<span class="d-flex align-items-start gap-2">
<span class="mnt-cat-dot mt-1" style="background: <?= mnt_h($maintenance['category_color'] ?? '#adb5bd') ?>"></span>
<span class="fw-semibold mnt-title-clamp" style="color:var(--mnt-heading)"
title="<?= mnt_h($maintenance['equipment_name']) ?>"><?= mnt_h($maintenance['equipment_name']) ?></span>
</span>
<div class="small text-muted"><?= mnt_h($maintenance['category_name'] ?? '') ?></div>
</td>
<td class="mnt-title-cell">
<?php if ($maintenance['code']): ?>
<span class="mnt-badge mnt-badge-soft me-1"><?= mnt_h($maintenance['code']) ?></span>
<?php endif; ?>
<?php if ($maintenance['is_critical']): ?>
<span class="mnt-badge mnt-badge-critical ms-1">Critica</span>
<?php endif; ?>
<div class="mnt-title-clamp" title="<?= mnt_h($maintenance['title']) ?>">
<?= mnt_h($maintenance['title']) ?>
</div>
<div class="small text-muted">
<?= mnt_h($typeLabels[$maintenance['intervention_type']] ?? '') ?>
· <?= mnt_h($executionLabels[$maintenance['execution_type']] ?? '') ?>
<?php if ($maintenance['supplier_name']): ?>
— <?= mnt_h($maintenance['supplier_name']) ?>
<?php endif; ?>
</div>
</td>
<td class="small mnt-freq-cell">
<div class="mnt-title-clamp"><?= mnt_h(mnt_format_frequency(
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
$maintenance['frequency_unit'],
$maintenance['frequency_note']
)) ?></div>
</td>
<td class="small mnt-person-cell">
<?= mnt_h($maintenance['assignee_name'] ?: '—') ?>
<?php if ($maintenance['supervisor_name']): ?>
<div class="text-muted"><?= mnt_h($maintenance['supervisor_name']) ?></div>
<?php endif; ?>
</td>
<td class="text-center">
<div><?= mnt_format_date($maintenance['next_due_date']) ?></div>
<span class="mnt-badge <?= $badge['class'] ?>"><?= $badge['label'] ?></span>
</td>
<td class="text-center">
<div class="d-inline-flex gap-1">
<a class="btn-action btn-action-view" title="Apri scheda"
href="manutenzioni/equipment.php?id=<?= (int)$maintenance['equipment_id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-done btn-register" title="Registra intervento">
<i class="fa-solid fa-check"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php
$mntPagerUrl = 'manutenzioni/maintenances.php';
$mntPagerPage = $page;
$mntPagerPages = $totalPages;
$mntPagerTotal = $totalMaintenances;
$mntPagerPer = $perPage;
include __DIR__ . '/include/pagination.php';
?>
<?php endif; ?>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php if ($canManage): ?>
@@ -345,26 +346,37 @@ $MNT_TITLE = 'Manutenzioni';
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
$(function () {
$('#filterForm select, #filterForm input[type=checkbox]').on('change', function () { $('#filterForm').submit(); });
$(function() {
$('#filterForm select, #filterForm input[type=checkbox]').on('change', function() {
$('#filterForm').submit();
});
<?php if ($canManage): ?>
$('#maintenanceList').on('click', '.btn-register', function () {
const id = $(this).closest('[data-id]').data('id');
$.getJSON('manutenzioni/ajax/get_maintenance.php', { id: id })
.done(res => {
if (!res.success) { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); return; }
mntOpenInterventionModal(null, {
id: res.maintenance.id,
title: res.maintenance.title,
equipment_name: res.maintenance.equipment_name,
supplier_id: res.maintenance.supplier_id
$('#maintenanceList').on('click', '.btn-register', function() {
const id = $(this).closest('[data-id]').data('id');
$.getJSON('manutenzioni/ajax/get_maintenance.php', {
id: id
})
.done(res => {
if (!res.success) {
Swal.fire({
icon: 'error',
title: 'Errore',
text: res.message
});
return;
}
mntOpenInterventionModal(null, {
id: res.maintenance.id,
title: res.maintenance.title,
equipment_name: res.maintenance.equipment_name,
supplier_id: res.maintenance.supplier_id
});
});
});
});
});
<?php endif; ?>
});
</script>
</body>
</html>
</html>
File diff suppressed because it is too large Load Diff
+415 -4
View File
@@ -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&#39;acquisto Mago" : "Nessun nome d&#39;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>
+113
View File
@@ -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()]);
}
+415
View File
@@ -0,0 +1,415 @@
<?php include('include/headscript.php'); ?>
<?php
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$userId = (int)($iduserlogin ?? 0);
if ($userId <= 0) {
die('Utente non valido.');
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['notif_settings_csrf'])) {
$_SESSION['notif_settings_csrf'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['notif_settings_csrf'];
$successMessage = '';
$errorMessage = '';
// Load current user notification preferences.
$stmtUser = $pdo->prepare("
SELECT
id,
email,
first_name,
last_name,
notify_email,
notify_webapp
FROM auth_users
WHERE id = ?
LIMIT 1
");
$stmtUser->execute([$userId]);
$profileUser = $stmtUser->fetch(PDO::FETCH_ASSOC);
if (!$profileUser) {
die('Utente non trovato.');
}
function e($value)
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postedToken = $_POST['csrf_token'] ?? '';
if (!hash_equals($csrfToken, $postedToken)) {
$errorMessage = 'Sessione non valida. Ricarica la pagina e riprova.';
} else {
$notifyEmail = isset($_POST['notify_email']) ? 1 : 0;
$notifyWebapp = isset($_POST['notify_webapp']) ? 1 : 0;
try {
$stmtUpdate = $pdo->prepare("
UPDATE auth_users
SET
notify_email = :notify_email,
notify_webapp = :notify_webapp,
updated_at = NOW()
WHERE id = :id
LIMIT 1
");
$stmtUpdate->execute([
':notify_email' => $notifyEmail,
':notify_webapp' => $notifyWebapp,
':id' => $userId,
]);
$successMessage = 'Preferenze di notifica aggiornate correttamente.';
// Reload updated user.
$stmtUser->execute([$userId]);
$profileUser = $stmtUser->fetch(PDO::FETCH_ASSOC);
$_SESSION['notif_settings_csrf'] = bin2hex(random_bytes(32));
$csrfToken = $_SESSION['notif_settings_csrf'];
} catch (Exception $e) {
$errorMessage = 'Errore durante il salvataggio delle preferenze.';
}
}
}
$notifyEmail = (int)($profileUser['notify_email'] ?? 0);
$notifyWebapp = (int)($profileUser['notify_webapp'] ?? 0);
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title>Notifiche <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<style>
body {
background: linear-gradient(135deg, #f3f6f8, #e8eef3);
font-family: 'Segoe UI', sans-serif;
color: #2b3e50;
}
.page-content {
padding: 1.4rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
}
.settings-wrap {
width: 100%;
max-width: 820px;
}
h3.settings-title {
text-align: center;
font-weight: 800;
color: #2b3e50;
margin-bottom: 1.2rem;
letter-spacing: 0.3px;
}
.settings-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
overflow: hidden;
}
.settings-header {
padding: 16px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
gap: 12px;
}
.settings-icon {
font-size: 1.8rem;
line-height: 1;
}
.settings-heading {
margin: 0;
font-weight: 800;
font-size: 1.1rem;
}
.settings-subtitle {
margin: 0;
color: #6b7a89;
font-size: 0.92rem;
}
.settings-body {
padding: 20px;
}
.notif-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-radius: 16px;
border: 1px solid #e2e8f0;
background: linear-gradient(135deg, #f9fbfd, #f2f7fc);
margin-bottom: 16px;
}
.notif-info {
display: flex;
align-items: center;
gap: 14px;
}
.notif-emoji {
font-size: 2rem;
line-height: 1;
width: 54px;
height: 54px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.06);
}
.notif-texts h6 {
margin: 0;
font-weight: 800;
font-size: 1.02rem;
color: #2b3e50;
}
.notif-texts p {
margin: 2px 0 0 0;
color: #6b7a89;
font-size: 0.88rem;
}
/* Toggle switch */
.switch {
position: relative;
display: inline-block;
width: 58px;
height: 32px;
flex-shrink: 0;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #cbd5e1;
transition: 0.3s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 24px;
width: 24px;
left: 4px;
bottom: 4px;
background: #fff;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
input:checked+.slider {
background: linear-gradient(135deg, #61ce5d, #4fb84b);
}
input:checked+.slider:before {
transform: translateX(26px);
}
.btn-save-settings {
border: 0;
border-radius: 16px;
padding: 13px 24px;
font-size: 1rem;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, #61ce5dff, #61ce5dff);
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.btn-save-settings:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.13);
color: #fff;
}
.btn-back {
border: 0;
border-radius: 16px;
padding: 13px 20px;
font-size: 1rem;
font-weight: 800;
color: #2b3e50;
background: linear-gradient(135deg, #e5e7eb, #f3f4f6);
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.btn-back:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.13);
color: #2b3e50;
}
.alert {
border-radius: 16px;
border: 0;
font-weight: 600;
}
.actions-row {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
}
@media (max-width: 768px) {
.settings-body {
padding: 16px;
}
.notif-row {
flex-direction: row;
align-items: center;
}
.actions-row {
flex-direction: column;
}
.btn-save-settings,
.btn-back {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="wrapper toggled">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="settings-wrap">
<h3 class="settings-title">Preferenze di Notifica</h3>
<?php if ($successMessage): ?>
<div class="alert alert-success">
<?= e($successMessage); ?>
</div>
<?php endif; ?>
<?php if ($errorMessage): ?>
<div class="alert alert-danger">
<?= e($errorMessage); ?>
</div>
<?php endif; ?>
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= e($csrfToken); ?>">
<div class="settings-card">
<div class="settings-header">
<div class="settings-icon">🔔</div>
<div>
<p class="settings-heading">Canali di notifica</p>
<p class="settings-subtitle">Scegli come vuoi ricevere gli avvisi del sistema</p>
</div>
</div>
<div class="settings-body">
<div class="notif-row">
<div class="notif-info">
<div class="notif-emoji">📧</div>
<div class="notif-texts">
<h6>Notifiche via Email</h6>
<p>Ricevi gli avvisi all'indirizzo <?= e($profileUser['email']); ?></p>
</div>
</div>
<label class="switch">
<input type="checkbox" name="notify_email" value="1" <?= $notifyEmail ? 'checked' : ''; ?>>
<span class="slider"></span>
</label>
</div>
<div class="notif-row">
<div class="notif-info">
<div class="notif-emoji">💻</div>
<div class="notif-texts">
<h6>Notifiche in WebApp</h6>
<p>Ricevi gli avvisi direttamente nell'applicazione</p>
</div>
</div>
<label class="switch">
<input type="checkbox" name="notify_webapp" value="1" <?= $notifyWebapp ? 'checked' : ''; ?>>
<span class="slider"></span>
</label>
</div>
<div class="actions-row">
<a href="production_dashboard.php" class="btn-back">← Torna alla dashboard</a>
<button type="submit" class="btn-save-settings">Salva preferenze</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<?php include('jsinclude.php'); ?>
<?php include('include/footer.php'); ?>
</div>
</body>
</html>
+217 -39
View File
@@ -68,41 +68,50 @@
vertical-align: middle;
}
/* Column widths */
#tabPackaging th:nth-child(1),
#tabPackaging td:nth-child(1) {
width: 60px;
max-width: 60px;
/* Ridimensionamento colonne: le larghezze stanno nel <colgroup> */
#tabPackaging thead th {
position: relative;
}
#tabPackaging th:nth-child(2),
#tabPackaging td:nth-child(2) {
width: 170px;
max-width: 170px;
.pkcol-grip {
position: absolute;
top: 0;
right: 0;
width: 14px;
height: 100%;
cursor: col-resize;
z-index: 6;
touch-action: none;
}
#tabPackaging th:nth-child(4),
#tabPackaging td:nth-child(4) {
width: 140px;
max-width: 140px;
.pkcol-grip::after {
content: "";
position: absolute;
top: 22%;
bottom: 22%;
right: 0;
width: 3px;
border-radius: 2px;
background: #8fb3e3;
}
#tabPackaging th:nth-child(5),
#tabPackaging td:nth-child(5) {
width: 110px;
max-width: 110px;
.pkcol-grip:hover::after,
.pkcol-grip.pkcol-active::after {
background: #0d6efd;
}
#tabPackaging th:nth-child(6),
#tabPackaging td:nth-child(6) {
width: 120px;
max-width: 120px;
body.pkcol-dragging,
body.pkcol-dragging * {
cursor: col-resize !important;
user-select: none !important;
}
#tabPackaging th:nth-child(7),
#tabPackaging td:nth-child(7) {
width: 420px;
max-width: 420px;
/* Pulsanti azione solo icona */
.pk-act {
min-width: 40px;
padding: 4px 8px;
font-size: 1.05rem;
line-height: 1.2;
}
.back-dashboard {
@@ -166,6 +175,7 @@
<option value="OTHER">Altro</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="pkColReset" title="Riporta le colonne alla larghezza iniziale">↔ Colonne predefinite</button>
</div>
<?php
@@ -229,6 +239,16 @@
<div class="table-responsive">
<table id="tabPackaging" class="table table-striped align-middle text-center" style="width:100%;">
<!-- Larghezze iniziali: la colonna senza width (Nome) prende lo spazio che resta -->
<colgroup>
<col data-w="60" style="width:60px">
<col data-w="150" style="width:150px">
<col data-flex="1">
<col data-w="220" style="width:220px">
<col data-w="120" style="width:120px">
<col data-w="110" style="width:110px">
<col data-w="230" data-min="210" style="width:230px">
</colgroup>
<thead>
<tr>
<th>ID</th>
@@ -259,7 +279,8 @@
? "<span class='badge bg-success'>Attivo</span>"
: "<span class='badge bg-secondary'>Inattivo</span>";
$toggleText = $isActive ? "Disattiva" : "Attiva";
$toggleClass = $isActive ? "btn-outline-warning" : "btn-outline-success";
$toggleClass = $isActive ? "btn-outline-danger" : "btn-outline-success";
$toggleIcon = $isActive ? "🚫" : "✅";
$qtyTot = number_format((float)$r['qty_totale'], 3, ',', '.');
$trClass = $isActive ? "" : " class='inactive-item'";
@@ -272,23 +293,19 @@
<td><span class='qty-badge'>{$qtyTot}</span></td>
<td>{$badge}</td>
<td>
<button class='btn btn-sm btn-outline-primary edit-item'
<button class='btn btn-sm btn-outline-primary pk-act edit-item'
title='Modifica' aria-label='Modifica'
data-id='{$r['id']}'
data-category='" . htmlspecialchars($r['category'], ENT_QUOTES) . "'
data-name='" . htmlspecialchars($r['item_name'], ENT_QUOTES) . "'
data-code='" . htmlspecialchars($r['item_code'], ENT_QUOTES) . "'>
✏️ Modifica
</button>
<button class='btn btn-sm btn-outline-dark manage-stock'
data-code='" . htmlspecialchars($r['item_code'], ENT_QUOTES) . "'>✏️</button>
<button class='btn btn-sm btn-outline-dark pk-act manage-stock'
title='Stock' aria-label='Stock'
data-id='{$r['id']}'
data-name='" . htmlspecialchars($r['item_name'], ENT_QUOTES) . "'>
📦 Stock
</button>
<button class='btn btn-sm {$toggleClass} toggle-item'
data-id='{$r['id']}'>
🔁 {$toggleText}
</button>
data-name='" . htmlspecialchars($r['item_name'], ENT_QUOTES) . "'>📦 Stock</button>
<button class='btn btn-sm {$toggleClass} pk-act toggle-item'
title='{$toggleText}' aria-label='{$toggleText}'
data-id='{$r['id']}'>{$toggleIcon}</button>
</td>
</tr>";
}
@@ -454,6 +471,7 @@
<script>
let dt;
let pkCols = null;
function refreshUrlParams() {
const url = new URL(window.location.href);
@@ -534,6 +552,153 @@
});
}
// ===== Colonne ridimensionabili =====
// Le larghezze stanno nel <colgroup>. La colonna "flex" (Nome) assorbe le differenze,
// quindi la tabella resta sempre larga quanto lo schermo e Azioni non esce mai.
// Trascinando un bordo, lo spazio si sposta tra le due colonne vicine.
function pkColResize(tableId) {
var table = document.getElementById(tableId);
if (!table) return null;
var KEY = 'zibo_pkcols_' + tableId;
var FLEX_MIN = 120;
var cols = Array.prototype.slice.call(table.querySelectorAll('colgroup col'));
var ths = Array.prototype.slice.call(table.querySelectorAll('thead tr:first-child th'));
var defaults = cols.map(function(c) {
return c.hasAttribute('data-flex') ? null : parseInt(c.getAttribute('data-w'), 10);
});
var justDragged = false;
function isFlex(i) {
return cols[i].hasAttribute('data-flex');
}
function minOf(i) {
if (isFlex(i)) return FLEX_MIN;
return parseInt(cols[i].getAttribute('data-min') || '50', 10);
}
function setW(i, w) {
if (!isFlex(i)) cols[i].style.width = Math.round(w) + 'px';
}
function realW(i) {
return ths[i].getBoundingClientRect().width;
}
function apply(widths) {
widths.forEach(function(w, i) {
if (w !== null) setW(i, w);
});
}
function current() {
return cols.map(function(c, i) {
return isFlex(i) ? null : parseInt(c.style.width, 10);
});
}
function save() {
try {
localStorage.setItem(KEY, JSON.stringify(current()));
} catch (e) {}
}
function reset() {
apply(defaults);
try {
localStorage.removeItem(KEY);
} catch (e) {}
}
// Ripristino: solo se le colonne salvate stanno nello schermo attuale
try {
var saved = JSON.parse(localStorage.getItem(KEY) || 'null');
if (Array.isArray(saved) && saved.length === cols.length) {
var sum = saved.reduce(function(a, b) {
return a + (b || 0);
}, 0);
if (sum + FLEX_MIN <= table.parentNode.clientWidth) apply(saved);
}
} catch (e) {}
ths.forEach(function(th, i) {
if (i === ths.length - 1) return; // ultima colonna: nessun bordo a destra
var grip = document.createElement('span');
grip.className = 'pkcol-grip';
grip.title = 'Trascina per allargare o stringere';
th.appendChild(grip);
grip.addEventListener('pointerdown', function(e) {
if (e.button !== undefined && e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
var j = i + 1;
var startX = e.clientX;
var a0 = realW(i);
var b0 = realW(j);
var lo = minOf(i) - a0; // spostamento minimo (negativo)
var hi = b0 - minOf(j); // spostamento massimo
if (hi < 0) hi = 0;
if (lo > 0) lo = 0;
// fissa le due colonne alla larghezza reale attuale
setW(i, a0);
setW(j, b0);
try {
grip.setPointerCapture(e.pointerId);
} catch (err) {}
grip.classList.add('pkcol-active');
document.body.classList.add('pkcol-dragging');
function onMove(ev) {
var d = ev.clientX - startX;
if (d < lo) d = lo;
if (d > hi) d = hi;
setW(i, a0 + d);
setW(j, b0 - d);
}
function onUp() {
grip.removeEventListener('pointermove', onMove);
grip.removeEventListener('pointerup', onUp);
grip.removeEventListener('pointercancel', onUp);
grip.classList.remove('pkcol-active');
document.body.classList.remove('pkcol-dragging');
justDragged = true;
setTimeout(function() {
justDragged = false;
}, 300);
save();
}
grip.addEventListener('pointermove', onMove);
grip.addEventListener('pointerup', onUp);
grip.addEventListener('pointercancel', onUp);
});
grip.addEventListener('dblclick', function(e) {
e.stopPropagation();
reset();
});
});
// Il trascinamento non deve far partire l'ordinamento di DataTables
table.querySelector('thead').addEventListener('click', function(e) {
if (justDragged || (e.target.classList && e.target.classList.contains('pkcol-grip'))) {
e.stopPropagation();
e.preventDefault();
}
}, true);
return {
reset: reset
};
}
$(document).ready(function() {
// Init filters from URL
const urlParams = new URLSearchParams(window.location.search);
@@ -545,11 +710,24 @@
[2, 'asc']
],
pageLength: 50,
autoWidth: false,
columnDefs: [{
targets: -1,
orderable: false
}],
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
},
initComplete: function() {
// dopo che DataTables ha costruito le intestazioni
pkCols = pkColResize('tabPackaging');
}
});
$('#pkColReset').on('click', function() {
if (pkCols) pkCols.reset();
});
$('#filterActive, #filterCategory').on('change', refreshUrlParams);
});
// Open edit item modal
+958
View File
@@ -0,0 +1,958 @@
<?php
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php');
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$linee = $pdo->query("SELECT id, name, color FROM production_lines ORDER BY line_number")->fetchAll();
$matrici = $pdo->query("SELECT id, nome FROM matrice ORDER BY nome")->fetchAll();
$mescole = $pdo->query("SELECT id, nome FROM mescole ORDER BY nome")->fetchAll();
if (!empty($_GET['ajax'])) {
header('Content-Type: application/json');
$dateFrom = $_GET['date_from'] ?? '';
$dateTo = $_GET['date_to'] ?? '';
$lineRaw = $_GET['lines'] ?? '';
$lineArr = $lineRaw !== '' ? array_values(array_filter(array_map('intval', explode(',', $lineRaw)))) : [];
$matId = isset($_GET['matrice_id']) && $_GET['matrice_id'] !== '' ? (int)$_GET['matrice_id'] : null;
$mescId = isset($_GET['mescola_id']) && $_GET['mescola_id'] !== '' ? (int)$_GET['mescola_id'] : null;
$where = ["pp.end_pause IS NOT NULL"];
$params = [];
if ($dateFrom !== '') {
$where[] = "p.Data >= :date_from";
$params['date_from'] = $dateFrom;
}
if ($dateTo !== '') {
$where[] = "p.Data <= :date_to";
$params['date_to'] = $dateTo;
}
if (!empty($lineArr)) {
$where[] = "p.id_linea IN (" . implode(',', $lineArr) . ")";
}
if ($matId !== null) {
$where[] = "p.idmatrice = :mat_id";
$params['mat_id'] = $matId;
}
if ($mescId !== null) {
$where[] = "EXISTS (SELECT 1 FROM productiondata_mescole pm
WHERE pm.id_productiondata = p.id AND pm.id_mescola = :mesc_id)";
$params['mesc_id'] = $mescId;
}
$whereSql = implode(' AND ', $where);
try {
$sqlKpi = "SELECT
COUNT(*) AS n_pause, COUNT(DISTINCT pp.id_production) AS n_prod_con_pause,
COALESCE(SUM(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)),0) AS tot_s,
COALESCE(AVG(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)),0) AS media_s,
COALESCE(MAX(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)),0) AS max_s,
COALESCE(SUM(CASE WHEN pr.is_problem=1 THEN 1 ELSE 0 END),0) AS n_problemi,
COALESCE(SUM(CASE WHEN pr.category='planned' THEN TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause) ELSE 0 END),0) AS planned_s,
COALESCE(SUM(CASE WHEN pr.category='unplanned' THEN TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause) ELSE 0 END),0) AS unplanned_s
FROM production_pauses pp
JOIN productiondata p ON p.id = pp.id_production
JOIN pause_reasons pr ON pr.id = pp.reason_id
WHERE $whereSql";
$stmt = $pdo->prepare($sqlKpi);
$stmt->execute($params);
$kpi = $stmt->fetch(PDO::FETCH_ASSOC);
$sqlMotivo = "SELECT pr.id, pr.name AS motivo, pr.is_problem, pr.category,
COUNT(pp.id) AS n_pause,
COALESCE(SUM(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)),0) AS durata_s,
COALESCE(AVG(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)),0) AS media_s
FROM production_pauses pp
JOIN productiondata p ON p.id = pp.id_production
JOIN pause_reasons pr ON pr.id = pp.reason_id
WHERE $whereSql GROUP BY pr.id ORDER BY durata_s DESC";
$stmt = $pdo->prepare($sqlMotivo);
$stmt->execute($params);
$perMotivo = $stmt->fetchAll(PDO::FETCH_ASSOC);
$totDurata = array_sum(array_map(fn($x) => (int)$x['durata_s'], $perMotivo));
$cum = 0;
foreach ($perMotivo as &$m) {
$cum += (int)$m['durata_s'];
$m['cum_perc'] = ($totDurata > 0) ? round($cum / $totDurata * 100, 1) : 0;
$m['perc'] = ($totDurata > 0) ? round($m['durata_s'] / $totDurata * 100, 1) : 0;
}
unset($m);
$sqlLinea = "SELECT l.name AS linea, l.color,
COUNT(pp.id) AS n_pause,
COALESCE(SUM(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)),0) AS durata_s,
COALESCE(SUM(CASE WHEN pr.is_problem=1 THEN 1 ELSE 0 END),0) AS n_problemi
FROM production_pauses pp
JOIN productiondata p ON p.id = pp.id_production
JOIN production_lines l ON l.id = p.id_linea
JOIN pause_reasons pr ON pr.id = pp.reason_id
WHERE $whereSql GROUP BY l.id ORDER BY durata_s DESC";
$stmt = $pdo->prepare($sqlLinea);
$stmt->execute($params);
$perLinea = $stmt->fetchAll(PDO::FETCH_ASSOC);
$sqlGiorno = "SELECT p.Data AS giorno,
COUNT(pp.id) AS n_pause,
COALESCE(SUM(TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause)),0) AS durata_s
FROM production_pauses pp
JOIN productiondata p ON p.id = pp.id_production
JOIN pause_reasons pr ON pr.id = pp.reason_id
WHERE $whereSql GROUP BY p.Data ORDER BY p.Data";
$stmt = $pdo->prepare($sqlGiorno);
$stmt->execute($params);
$perGiorno = $stmt->fetchAll(PDO::FETCH_ASSOC);
$sqlDett = "SELECT pp.id, pp.start_pause, pp.end_pause, pp.note,
TIMESTAMPDIFF(SECOND, pp.start_pause, pp.end_pause) AS durata_s,
pr.name AS motivo, pr.is_problem, pr.category,
l.name AS linea, m.nome AS matrice, p.id AS prod_id
FROM production_pauses pp
JOIN productiondata p ON p.id = pp.id_production
LEFT JOIN production_lines l ON l.id = p.id_linea
LEFT JOIN matrice m ON m.id = p.idmatrice
JOIN pause_reasons pr ON pr.id = pp.reason_id
WHERE $whereSql ORDER BY pp.start_pause DESC LIMIT 500";
$stmt = $pdo->prepare($sqlDett);
$stmt->execute($params);
$dettaglio = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'kpi' => [
'n_pause' => (int)$kpi['n_pause'],
'n_prod_con_pause' => (int)$kpi['n_prod_con_pause'],
'tot_s' => (int)$kpi['tot_s'],
'media_s' => (int)$kpi['media_s'],
'max_s' => (int)$kpi['max_s'],
'n_problemi' => (int)$kpi['n_problemi'],
'planned_s' => (int)$kpi['planned_s'],
'unplanned_s' => (int)$kpi['unplanned_s'],
],
'per_motivo' => $perMotivo,
'per_linea' => $perLinea,
'per_giorno' => $perGiorno,
'dettaglio' => $dettaglio,
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'msg' => $e->getMessage()]);
}
exit;
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Statistiche Pause &amp; Fermi</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<?php include('cssinclude.php'); ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
.stats-header {
background: linear-gradient(135deg, #b91c1c, #ef4444);
color: #fff;
padding: 1.1rem 1.4rem;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 1rem;
flex-wrap: wrap;
gap: .8rem;
margin-bottom: 1rem;
}
.stats-header h4 {
margin: 0;
font-weight: 600;
}
.header-actions {
display: flex;
gap: .5rem;
flex-wrap: wrap;
}
.hbtn {
background: rgba(255, 255, 255, .22);
border: 1px solid rgba(255, 255, 255, .4);
color: #fff;
padding: .5rem 1rem;
border-radius: .7rem;
text-decoration: none;
font-weight: 600;
}
.hbtn:hover {
background: rgba(255, 255, 255, .34);
color: #fff;
}
.hbtn.active {
background: #fff;
color: #b91c1c;
}
.filters-bar {
background: #fff;
padding: 1rem 1.2rem;
border-radius: 1rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, .05);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: .9rem;
align-items: end;
margin-bottom: 1rem;
}
.filters-bar label {
display: block;
font-weight: 600;
color: #b91c1c;
font-size: .8rem;
margin-bottom: .3rem;
}
.filters-bar input,
.filters-bar select {
width: 100%;
padding: .55rem .7rem;
border: 1px solid #cbd5e1;
border-radius: .6rem;
font-size: .95rem;
background: #fff;
}
.line-buttons {
display: flex;
gap: .5rem;
flex-wrap: wrap;
}
.line-btn2 {
flex: 1;
min-width: 70px;
padding: .55rem .3rem;
border: 2px solid transparent;
border-radius: .6rem;
color: #fff;
font-weight: 700;
cursor: pointer;
font-size: .9rem;
opacity: .35;
transition: .15s;
}
.line-btn2.active {
opacity: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
}
.filters-actions {
display: flex;
gap: .5rem;
}
.btn-apply {
background: #dc2626;
color: #fff;
border: none;
padding: .6rem 1rem;
border-radius: .6rem;
font-weight: 600;
cursor: pointer;
flex: 1;
}
.btn-reset {
background: #e2e8f0;
color: #475569;
border: none;
padding: .6rem 1rem;
border-radius: .6rem;
font-weight: 600;
cursor: pointer;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.kpi-card {
position: relative;
background: #fff;
border-radius: 1rem;
padding: 1.1rem 1.2rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, .05);
border-left: 5px solid #ef4444;
}
.kpi-card .kpi-label {
font-size: .72rem;
text-transform: uppercase;
letter-spacing: .5px;
color: #64748b;
font-weight: 700;
}
.kpi-card .kpi-value {
font-size: 1.7rem;
font-weight: 800;
color: #0f172a;
margin-top: .2rem;
line-height: 1.1;
}
.kpi-card .kpi-sub {
font-size: .8rem;
color: #94a3b8;
margin-top: .2rem;
}
.kpi-card.amber {
border-left-color: #f59e0b;
}
.kpi-card.slate {
border-left-color: #64748b;
}
.kpi-card.green {
border-left-color: #10b981;
}
.kpi-card.red {
border-left-color: #ef4444;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.chart-card {
position: relative;
background: #fff;
border-radius: 1rem;
padding: 1.1rem 1.2rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, .05);
}
.chart-card.wide {
grid-column: 1 / -1;
}
.chart-card h6 {
font-weight: 700;
color: #1e293b;
margin: 0 0 .8rem;
font-size: .95rem;
padding-right: 1.8rem;
}
.chart-wrap {
position: relative;
height: 300px;
}
.table-card {
position: relative;
background: #fff;
border-radius: 1rem;
padding: 1.1rem 1.2rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, .05);
overflow-x: auto;
margin-bottom: 2rem;
}
.table-card h6 {
font-weight: 700;
color: #1e293b;
margin: 0 0 .8rem;
padding-right: 1.8rem;
}
table.stats-table {
width: 100%;
border-collapse: collapse;
font-size: .85rem;
}
table.stats-table th {
background: #fee2e2;
color: #991b1b;
text-align: left;
padding: .55rem .6rem;
}
table.stats-table td {
padding: .5rem .6rem;
border-bottom: 1px solid #eef2f7;
color: #334155;
}
table.stats-table tr:hover td {
background: #f8fafc;
}
.pill {
display: inline-block;
padding: .15rem .55rem;
border-radius: 999px;
font-size: .72rem;
font-weight: 700;
}
.pill-prob {
background: #fee2e2;
color: #991b1b;
}
.pill-norm {
background: #e0e7ff;
color: #3730a3;
}
.pill-plan {
background: #fef3c7;
color: #92400e;
}
.pill-unplan {
background: #fee2e2;
color: #991b1b;
}
.muted {
color: #94a3b8;
}
.loading {
text-align: center;
padding: 3rem;
color: #64748b;
}
.empty {
text-align: center;
padding: 3rem;
color: #94a3b8;
}
/* --- PULSANTINO "?" INFO --- */
.info-btn {
position: absolute;
top: .6rem;
right: .6rem;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
cursor: pointer;
background: #e2e8f0;
color: #475569;
font-size: .72rem;
font-weight: 800;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: .15s;
z-index: 5;
}
.info-btn:hover {
background: #dc2626;
color: #fff;
}
.info-pop {
position: absolute;
top: 2.1rem;
right: .6rem;
width: 250px;
max-width: 80vw;
background: #0f172a;
color: #f1f5f9;
border-radius: .6rem;
padding: .7rem .8rem;
font-size: .8rem;
line-height: 1.4;
font-weight: 400;
text-transform: none;
letter-spacing: normal;
box-shadow: 0 8px 24px rgba(0, 0, 0, .25);
z-index: 50;
display: none;
}
.info-pop.show {
display: block;
}
.info-pop strong {
color: #fff;
}
.info-pop .info-pop-title {
display: block;
font-weight: 800;
margin-bottom: .25rem;
color: #fff;
}
</style>
</head>
<body>
<div class="wrapper toggled">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="stats-header">
<h4><i class="bi bi-pause-circle"></i> Statistiche Pause &amp; Fermi</h4>
<div class="header-actions">
<a class="hbtn" href="production_stats.php"><i class="bi bi-speedometer2"></i> Produzione</a>
<a class="hbtn active" href="pause_stats.php"><i class="bi bi-pause-circle"></i> Pause &amp; Fermi</a>
<a class="hbtn" href="production_dashboard.php">Torna</a>
</div>
</div>
<div class="filters-bar">
<div><label>Da (data)</label><input type="date" id="fDateFrom"></div>
<div><label>A (data)</label><input type="date" id="fDateTo"></div>
<div style="grid-column:span 2;">
<label>Linee</label>
<div class="line-buttons" id="lineButtons">
<?php foreach ($linee as $l): ?>
<button type="button" class="line-btn2 active" data-line="<?= (int)$l['id'] ?>"
style="background:<?= htmlspecialchars($l['color'] ?: '#ef4444') ?>;">
<?= htmlspecialchars($l['name']) ?>
</button>
<?php endforeach; ?>
</div>
</div>
<div><label>Matrice</label><select id="fMatrice">
<option value="">Tutte</option>
<?php foreach ($matrici as $m): ?><option value="<?= (int)$m['id'] ?>"><?= htmlspecialchars($m['nome']) ?></option><?php endforeach; ?>
</select></div>
<div><label>Mescola</label><select id="fMescola">
<option value="">Tutte</option>
<?php foreach ($mescole as $me): ?><option value="<?= (int)$me['id'] ?>"><?= htmlspecialchars($me['nome']) ?></option><?php endforeach; ?>
</select></div>
<div class="filters-actions">
<button class="btn-apply" id="btnApply"><i class="bi bi-funnel"></i> Applica</button>
<button class="btn-reset" id="btnReset">Reset</button>
</div>
</div>
<div class="kpi-grid" id="kpiGrid">
<div class="loading">Caricamento…</div>
</div>
<div class="charts-grid" id="chartsGrid" style="display:none;">
<div class="chart-card wide">
<button type="button" class="info-btn" data-title="Pareto delle causali" data-info="Il grafico più utile. Le barre sono le ore perse per ogni causa, ordinate dalla peggiore. La linea sale e mostra la somma progressiva in percentuale. Regola pratica: poche cause fanno la maggior parte del tempo perso. Concentrati sulle prime 2-3 barre a sinistra.">?</button>
<h6>Pareto causali — tempo perso e cumulata</h6>
<div class="chart-wrap"><canvas id="chartPareto"></canvas></div>
</div>
<div class="chart-card">
<button type="button" class="info-btn" data-title="Pianificato vs non pianificato" data-info="La fetta di tempo perso previsto (arancione, es. cambio turno, pulizia) contro quello imprevisto (rosso, es. guasti). Una torta sbilanciata verso il rosso significa molte perdite evitabili.">?</button>
<h6>Ripartizione tempo: pianificato vs non pianificato</h6>
<div class="chart-wrap"><canvas id="chartCat"></canvas></div>
</div>
<div class="chart-card">
<button type="button" class="info-btn" data-title="Tempo perso per linea" data-info="Quale linea perde più ore di produzione a causa dei fermi. Individua la linea più problematica su cui concentrare le attenzioni.">?</button>
<h6>Tempo perso per linea (ore)</h6>
<div class="chart-wrap"><canvas id="chartLinea"></canvas></div>
</div>
<div class="chart-card">
<button type="button" class="info-btn" data-title="Numero fermi per causale" data-info="Quante volte si verifica ogni causa (la frequenza, non la durata). Una causa che compare tante volte anche se breve indica un problema ricorrente da eliminare alla radice.">?</button>
<h6>Numero fermi per causale</h6>
<div class="chart-wrap"><canvas id="chartCount"></canvas></div>
</div>
<div class="chart-card">
<button type="button" class="info-btn" data-title="Andamento giornaliero" data-info="Le ore perse giorno per giorno. Individua le giornate nere e cerca cosa è successo (un guasto grave, una fornitura mancata).">?</button>
<h6>Andamento fermi giornaliero (ore)</h6>
<div class="chart-wrap"><canvas id="chartGiorno"></canvas></div>
</div>
</div>
<div class="table-card" id="tableCard" style="display:none;">
<button type="button" class="info-btn" data-title="Dettaglio eventi" data-info="Ogni riga è un singolo fermo: inizio, fine, durata, motivo, tipo (Problema o Normale), categoria (Pianificato o Non pianificato), linea, matrice e la nota dell'operatore. Il massimo dettaglio per ricostruire cosa è successo in un momento preciso.">?</button>
<h6>Dettaglio eventi di pausa</h6>
<table class="stats-table">
<thead>
<tr>
<th>Inizio</th>
<th>Fine</th>
<th>Durata</th>
<th>Motivo</th>
<th>Tipo</th>
<th>Categoria</th>
<th>Linea</th>
<th>Matrice</th>
<th>Prod. #</th>
<th>Nota</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div><!-- /.page-content -->
</div><!-- /.page-wrapper -->
</div><!-- /.wrapper -->
<?php include('jsinclude.php'); ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
function secToHms(s) {
s = Math.max(0, parseInt(s || 0, 10));
const h = String(Math.floor(s / 3600)).padStart(2, '0'),
m = String(Math.floor((s % 3600) / 60)).padStart(2, '0'),
x = String(s % 60).padStart(2, '0');
return `${h}:${m}:${x}`;
}
function secToH(s) {
return parseInt(s || 0, 10) / 3600;
}
function fmtDT(iso) {
if (!iso) return '-';
const d = new Date(iso.replace(' ', 'T'));
if (isNaN(d)) return iso;
const p = n => String(n).padStart(2, '0');
return `${p(d.getDate())}/${p(d.getMonth()+1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function fmtDateIt(iso) {
if (!iso) return '-';
const p = iso.split('-');
return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : iso;
}
let charts = {};
function destroyChart(k) {
if (charts[k]) {
charts[k].destroy();
charts[k] = null;
}
}
// Testi spiegazione per i KPI
const KPI_INFO = {
n_pause: {
t: "Fermi totali",
d: "Quante volte le linee si sono fermate in pausa nel periodo. Sotto, quante lavorazioni sono state coinvolte."
},
tot: {
t: "Tempo perso",
d: "Il totale di tutte le pause sommate: il costo in tempo complessivo dei fermi. Confrontalo tra periodi per vedere se migliora."
},
media: {
t: "Durata media",
d: "Quanto dura in media un singolo fermo (accanto, il fermo più lungo). Se il massimo è molto più alto della media, c'è stato un episodio eccezionale da approfondire."
},
problemi: {
t: "Problemi tecnici",
d: "Quanti fermi erano dovuti a un guasto o anomalia vera, e la loro percentuale sul totale. È la spia della salute degli impianti."
},
planned: {
t: "Pianificato",
d: "Il tempo dei fermi previsti (cambio turno, pulizia, manutenzione programmata...). È tempo normale, in parte inevitabile."
},
unplanned: {
t: "Non pianificato",
d: "Il tempo dei fermi imprevisti (guasti, mancanza materiale, qualità...). È QUESTO il tempo da ridurre: sono le perdite evitabili."
},
};
function infoBtn(key) {
const i = KPI_INFO[key];
if (!i) return '';
return `<button type="button" class="info-btn" data-title="${i.t}" data-info="${i.d.replace(/"/g,'&quot;')}">?</button>`;
}
function renderKpi(k) {
const pctProblemi = k.n_pause > 0 ? Math.round(k.n_problemi / k.n_pause * 100) : 0;
$('#kpiGrid').html(`
<div class="kpi-card">${infoBtn('n_pause')}<div class="kpi-label">Fermi totali</div><div class="kpi-value">${k.n_pause}</div><div class="kpi-sub">${k.n_prod_con_pause} produzioni coinvolte</div></div>
<div class="kpi-card">${infoBtn('tot')}<div class="kpi-label">Tempo perso</div><div class="kpi-value" style="font-size:1.25rem;">${secToHms(k.tot_s)}</div><div class="kpi-sub">totale fermi</div></div>
<div class="kpi-card slate">${infoBtn('media')}<div class="kpi-label">Durata media</div><div class="kpi-value" style="font-size:1.25rem;">${secToHms(k.media_s)}</div><div class="kpi-sub">max ${secToHms(k.max_s)}</div></div>
<div class="kpi-card red">${infoBtn('problemi')}<div class="kpi-label">Problemi tecnici</div><div class="kpi-value">${k.n_problemi}</div><div class="kpi-sub">${pctProblemi}% dei fermi</div></div>
<div class="kpi-card green">${infoBtn('planned')}<div class="kpi-label">Pianificato</div><div class="kpi-value" style="font-size:1.25rem;">${secToHms(k.planned_s)}</div><div class="kpi-sub">fermi previsti</div></div>
<div class="kpi-card amber">${infoBtn('unplanned')}<div class="kpi-label">Non pianificato</div><div class="kpi-value" style="font-size:1.25rem;">${secToHms(k.unplanned_s)}</div><div class="kpi-sub">da ridurre</div></div>
`);
}
function renderCharts(d) {
const mot = d.per_motivo || [],
linee = d.per_linea || [],
giorni = d.per_giorno || [],
k = d.kpi;
destroyChart('pareto');
charts.pareto = new Chart($('#chartPareto'), {
data: {
labels: mot.map(x => x.motivo),
datasets: [{
type: 'bar',
label: 'Ore perse',
data: mot.map(x => +secToH(x.durata_s).toFixed(2)),
backgroundColor: mot.map(x => x.category === 'planned' ? '#f59e0b' : '#ef4444'),
yAxisID: 'y'
},
{
type: 'line',
label: 'Cumulata %',
data: mot.map(x => x.cum_perc),
borderColor: '#1e293b',
backgroundColor: '#1e293b',
yAxisID: 'y1',
tension: .2,
pointRadius: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
position: 'left',
title: {
display: true,
text: 'Ore'
}
},
y1: {
beginAtZero: true,
max: 100,
position: 'right',
grid: {
drawOnChartArea: false
},
title: {
display: true,
text: '% cumulata'
}
}
}
}
});
destroyChart('cat');
charts.cat = new Chart($('#chartCat'), {
type: 'doughnut',
data: {
labels: ['Pianificato', 'Non pianificato'],
datasets: [{
data: [+secToH(k.planned_s).toFixed(2), +secToH(k.unplanned_s).toFixed(2)],
backgroundColor: ['#f59e0b', '#ef4444']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
destroyChart('linea');
charts.linea = new Chart($('#chartLinea'), {
type: 'bar',
data: {
labels: linee.map(x => x.linea),
datasets: [{
label: 'Ore perse',
data: linee.map(x => +secToH(x.durata_s).toFixed(2)),
backgroundColor: linee.map(x => x.color || '#3b82f6')
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
},
plugins: {
legend: {
display: false
}
}
}
});
destroyChart('count');
charts.count = new Chart($('#chartCount'), {
type: 'bar',
data: {
labels: mot.map(x => x.motivo),
datasets: [{
label: 'N. fermi',
data: mot.map(x => x.n_pause),
backgroundColor: '#64748b'
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true
}
},
plugins: {
legend: {
display: false
}
}
}
});
destroyChart('giorno');
charts.giorno = new Chart($('#chartGiorno'), {
type: 'line',
data: {
labels: giorni.map(x => fmtDateIt(x.giorno)),
datasets: [{
label: 'Ore perse',
data: giorni.map(x => +secToH(x.durata_s).toFixed(2)),
borderColor: '#ef4444',
backgroundColor: 'rgba(239,68,68,.15)',
fill: true,
tension: .3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
},
plugins: {
legend: {
display: false
}
}
}
});
}
function renderTable(rows) {
if (!rows || !rows.length) {
$('#tableBody').html('<tr><td colspan="10" class="empty">Nessun fermo per i filtri selezionati</td></tr>');
return;
}
let html = '';
rows.forEach(r => {
const tipo = (String(r.is_problem) === '1') ? '<span class="pill pill-prob">Problema</span>' : '<span class="pill pill-norm">Normale</span>';
const cat = (r.category === 'planned') ? '<span class="pill pill-plan">Pianificato</span>' : '<span class="pill pill-unplan">Non pian.</span>';
html += `<tr><td>${fmtDT(r.start_pause)}</td><td>${fmtDT(r.end_pause)}</td><td>${secToHms(r.durata_s)}</td>
<td>${r.motivo||'-'}</td><td>${tipo}</td><td>${cat}</td>
<td>${r.linea||'-'}</td><td>${r.matrice||'-'}</td><td>#${r.prod_id}</td>
<td>${r.note?('<span class="muted">'+r.note+'</span>'):'<span class="muted">-</span>'}</td></tr>`;
});
$('#tableBody').html(html);
}
function selectedLines() {
return $('#lineButtons .line-btn2.active').map(function() {
return $(this).data('line');
}).get().join(',');
}
function loadStats() {
$('#kpiGrid').html('<div class="loading">Caricamento…</div>');
$('#chartsGrid, #tableCard').hide();
$.getJSON('', {
ajax: 1,
date_from: $('#fDateFrom').val(),
date_to: $('#fDateTo').val(),
lines: selectedLines(),
matrice_id: $('#fMatrice').val(),
mescola_id: $('#fMescola').val()
},
function(r) {
if (!r.success) {
$('#kpiGrid').html('<div class="empty">Errore: ' + (r.msg || '') + '</div>');
return;
}
renderKpi(r.kpi);
if (r.kpi.n_pause === 0) {
$('#chartsGrid, #tableCard').hide();
$('#tableBody').html('');
return;
}
$('#chartsGrid, #tableCard').show();
renderCharts(r);
renderTable(r.dettaglio);
}).fail(function() {
$('#kpiGrid').html('<div class="empty">Errore di comunicazione con il server.</div>');
});
}
$(function() {
const today = new Date(),
past = new Date();
past.setDate(today.getDate() - 30);
$('#fDateTo').val(today.toISOString().slice(0, 10));
$('#fDateFrom').val(past.toISOString().slice(0, 10));
$('#lineButtons').on('click', '.line-btn2', function() {
$(this).toggleClass('active');
if ($('#lineButtons .line-btn2.active').length === 0) {
$('#lineButtons .line-btn2').addClass('active');
}
loadStats();
});
$('#btnApply').on('click', loadStats);
$('#btnReset').on('click', function() {
$('#lineButtons .line-btn2').addClass('active');
$('#fMatrice,#fMescola').val('');
$('#fDateTo').val(today.toISOString().slice(0, 10));
$('#fDateFrom').val(past.toISOString().slice(0, 10));
loadStats();
});
// --- POPOVER INFO (delegato: funziona anche sui box creati via JS) ---
$(document).on('click', '.info-btn', function(e) {
e.stopPropagation();
const $btn = $(this);
const alreadyOpen = $btn.next('.info-pop').length && $btn.next('.info-pop').hasClass('show');
$('.info-pop').remove();
if (alreadyOpen) return;
const title = $btn.data('title') || '';
const text = $btn.data('info') || '';
const $pop = $('<div class="info-pop show"></div>');
$pop.html('<span class="info-pop-title">' + title + '</span>' + text);
$btn.after($pop);
});
$(document).on('click', function() {
$('.info-pop').remove();
});
loadStats();
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

File diff suppressed because it is too large Load Diff
+712
View File
@@ -0,0 +1,712 @@
<?php
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php');
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
// Stato Qualità di destinazione dopo la compilazione
$statusQualita = (int)$pdo->query("SELECT id FROM production_status WHERE id = 3 LIMIT 1")->fetchColumn();
if (!$statusQualita) {
die("Errore: stato Controllo qualità (3) non trovato in production_status");
}
// --- SALVATAGGIO DATI DI PRODUZIONE (da stato 11 → Qualità 3) ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['save_compile'])) {
header('Content-Type: application/json; charset=utf-8');
$id = (int)($_POST['id'] ?? 0);
$kgprod = (float)($_POST['kgprod'] ?? 0);
$mtprod = (float)($_POST['mtprod'] ?? 0);
$scarto = (float)($_POST['scarto'] ?? 0);
$note = trim($_POST['note'] ?? '');
try {
if ($id <= 0) {
echo json_encode(['success' => false, 'msg' => 'ID non valido.']);
exit;
}
if ($kgprod <= 0 && $mtprod <= 0) {
echo json_encode(['success' => false, 'msg' => 'Inserisci almeno uno tra Kg o Metri prodotti.']);
exit;
}
// Verifico che la riga sia davvero in stato 11 (da compilare)
$stmtChk = $pdo->prepare("SELECT id_status FROM productiondata WHERE id = :id LIMIT 1");
$stmtChk->execute(['id' => $id]);
$cur = $stmtChk->fetchColumn();
if ($cur === false) {
echo json_encode(['success' => false, 'msg' => 'Produzione non trovata.']);
exit;
}
if ((int)$cur !== 11) {
echo json_encode(['success' => false, 'msg' => 'Questa produzione non è più in stato "Da compilare".']);
exit;
}
// Calcolo scarto %
$totaleKg = $kgprod + $scarto;
$scartoPerc = $totaleKg > 0 ? round(($scarto / $totaleKg) * 100, 2) : 0.00;
// Update: riempio i dati e passo a Qualità (3)
$sql = "UPDATE productiondata
SET kgprod = :kgprod,
mtprod = :mtprod,
scarto = :scarto,
scarto_perc = :scarto_perc,
note_operatore = :note,
id_status = :status
WHERE id = :id AND id_status = 11";
$stmt = $pdo->prepare($sql);
$stmt->execute([
'kgprod' => $kgprod,
'mtprod' => $mtprod,
'scarto' => $scarto,
'scarto_perc' => $scartoPerc,
'note' => $note ?: null,
'status' => $statusQualita,
'id' => $id
]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'msg' => $e->getMessage()]);
}
exit;
}
// --- AJAX: lista produzioni in stato 11 ---
if (!empty($_GET['ajax'])) {
$sql = "SELECT
p.*,
m.nome AS matrice,
l.name AS linea,
c.nome AS cliente,
(
SELECT GROUP_CONCAT(mes.nome ORDER BY mes.nome SEPARATOR ' | ')
FROM productiondata_mescole pm
JOIN mescole mes ON mes.id = pm.id_mescola
WHERE pm.id_productiondata = p.id
) AS mescole_list
FROM productiondata p
LEFT JOIN matrice m ON p.idmatrice = m.id
LEFT JOIN production_lines l ON p.id_linea = l.id
LEFT JOIN clients c ON p.id_cliente = c.id
WHERE p.id_status = 11
ORDER BY p.end_time ASC, p.id ASC";
$records = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
if (empty($records)) {
echo '<div class="no-records"><i class="bi bi-check2-circle"></i><br>Nessuna produzione da compilare.</div>';
exit;
}
// Helper: secondi -> HH:MM:SS
$fmt = function ($sec) {
$sec = (int)$sec;
$h = floor($sec / 3600);
$m = floor(($sec % 3600) / 60);
$s = $sec % 60;
return sprintf('%02d:%02d:%02d', $h, $m, $s);
};
foreach ($records as $r) {
$occupato = $fmt($r['tempo_totale_produzione'] ?? 0);
$pause = $fmt($r['seconds_pause'] ?? 0);
$netto = $fmt($r['seconds_produzione'] ?? 0);
$endTime = $r['end_time'] ? date('d/m/Y H:i', strtotime($r['end_time'])) : '—';
$teoKg = number_format((float)($r['kg_sp'] ?? 0), 2, '.', '');
$teoMt = number_format((float)($r['metri'] ?? 0), 2, '.', '');
$descr = trim($r['note_extra'] ?? '');
$descrShow = $descr !== '' ? $descr : '—';
echo '<div class="compile-card" data-id="' . (int)$r['id'] . '"
data-teokg="' . htmlspecialchars($teoKg) . '"
data-teomt="' . htmlspecialchars($teoMt) . '">';
// --- RIGA COMPATTA (sempre visibile) ---
echo '<div class="compile-row">';
echo ' <div class="compile-main">';
echo ' <div class="compile-title">' . htmlspecialchars($r['matrice'] ?? '—') . '</div>';
echo ' <div class="compile-descr">' . htmlspecialchars($descrShow) . '</div>';
echo ' <div class="compile-meta">';
echo ' <span><i class="bi bi-person"></i> ' . htmlspecialchars($r['cliente'] ?? '—') . '</span>';
echo ' <span><i class="bi bi-clock-history"></i> ' . $endTime . '</span>';
echo ' </div>';
echo ' </div>';
echo ' <div class="compile-right">';
echo ' <span class="compile-badge">Compila dati</span>';
echo ' <i class="bi bi-chevron-down expand-arrow"></i>';
echo ' </div>';
echo '</div>';
// --- DETTAGLIO (collassato) ---
echo '<div class="compile-detail">';
echo ' <div class="compile-grid">';
echo ' <div><strong>Linea</strong><span>' . htmlspecialchars($r['linea'] ?? '—') . '</span></div>';
echo ' <div><strong>Tempo occupato</strong><span>' . $occupato . '</span></div>';
echo ' <div><strong>Tempo pause</strong><span>' . $pause . '</span></div>';
echo ' <div><strong>Tempo netto</strong><span>' . $netto . '</span></div>';
echo ' </div>';
if (!empty($r['mescole_list'])) {
echo ' <div class="compile-mescole"><strong>Mescole:</strong> '
. htmlspecialchars($r['mescole_list']) . '</div>';
}
echo ' <div class="compile-actions">';
echo ' <button class="compile-btn btn-fill" data-id="' . (int)$r['id'] . '">Compila dati</button>';
echo ' </div>';
echo '</div>'; // .compile-detail
echo '</div>'; // .compile-card
}
exit;
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Dati da compilare - Produzione</title>
<?php include('cssinclude.php'); ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
font-family: 'Segoe UI', sans-serif;
}
body {
background: #f1f5f9;
-webkit-tap-highlight-color: transparent;
}
.wrapper,
.page-wrapper,
.page-content {
height: 100%;
display: flex;
flex-direction: column;
}
.page-content {
padding: 0;
}
.main-card {
flex: 1;
display: flex;
flex-direction: column;
margin: 0.8rem;
border-radius: 1.2rem;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
background: #fff;
}
.card-header {
background: linear-gradient(135deg, #1d4ed8, #3b82f6);
color: white;
padding: 1.2rem 1.5rem;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h5 {
margin: 0;
font-weight: 600;
font-size: 1.35rem;
color: #fff;
}
.back-btn {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
padding: 0.5rem 1rem;
border-radius: 0.8rem;
font-weight: 500;
cursor: pointer;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.35);
}
#recordsContainer {
flex: 1;
overflow-y: auto;
padding: 1rem 1.5rem 5rem 1.5rem;
background: #f8fafc;
}
/* --- CARD COMPATTA --- */
.compile-card {
background: #fff;
border-radius: 0.7rem;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
margin-bottom: 0.6rem;
overflow: hidden;
transition: box-shadow 0.2s;
}
.compile-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.compile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 1rem;
cursor: pointer;
}
.compile-main {
min-width: 0;
flex: 1;
}
.compile-title {
font-size: 1.05rem;
font-weight: 700;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compile-descr {
font-size: 0.85rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.1rem;
}
.compile-meta {
display: flex;
gap: 1.2rem;
flex-wrap: wrap;
margin-top: 0.35rem;
font-size: 0.82rem;
color: #475569;
}
.compile-meta span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.compile-meta i {
color: #94a3b8;
}
.compile-right {
display: flex;
align-items: center;
gap: 0.7rem;
flex-shrink: 0;
}
.compile-badge {
background: #dbeafe;
color: #1e40af;
border: 1px solid #bfdbfe;
padding: 0.3rem 0.8rem;
border-radius: 2rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
.expand-arrow {
font-size: 1.1rem;
color: #94a3b8;
transition: transform 0.3s ease;
}
.compile-card.expanded .expand-arrow {
transform: rotate(180deg);
}
/* --- DETTAGLIO COLLASSATO --- */
.compile-detail {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease, padding 0.35s ease;
padding: 0 1rem;
border-top: 0 solid #e2e8f0;
}
.compile-card.expanded .compile-detail {
max-height: 500px;
padding: 0.9rem 1rem 1rem 1rem;
border-top: 1px solid #e2e8f0;
}
.compile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.6rem 1rem;
}
.compile-grid strong {
display: block;
color: #64748b;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 0.1rem;
}
.compile-grid span {
color: #1e293b;
font-weight: 600;
font-size: 1rem;
}
.compile-mescole {
color: #475569;
font-size: 0.9rem;
margin-top: 0.7rem;
}
.compile-actions {
margin-top: 0.9rem;
display: flex;
justify-content: flex-end;
}
.compile-btn {
padding: 0.75rem 1.4rem;
font-size: 1rem;
font-weight: 700;
border: none;
border-radius: 0.7rem;
cursor: pointer;
color: white;
letter-spacing: 0.5px;
}
.btn-fill {
background: linear-gradient(135deg, #10b981, #059669);
}
.btn-fill:hover {
background: linear-gradient(135deg, #059669, #047857);
}
.no-records {
text-align: center;
padding: 4rem 1.5rem;
color: #64748b;
font-size: 1.15rem;
}
.no-records i {
font-size: 3.5rem;
color: #cbd5e1;
}
/* --- MODALE --- */
.custom-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
justify-content: center;
align-items: center;
}
.custom-modal.active {
display: flex !important;
}
.custom-modal .modal-content {
background: white;
padding: 2rem;
border-radius: 1.2rem;
width: 95%;
max-width: 780px;
max-height: 92vh;
overflow-y: auto;
text-align: left;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
.final-flex {
display: flex;
gap: 2rem;
margin-top: 1.2rem;
flex-wrap: wrap;
}
.final-col {
flex: 1;
min-width: 260px;
}
.teo-box {
background: #eef6ff;
padding: 1rem;
border-radius: 0.8rem;
}
.teo-row {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.teo-row label {
font-weight: 600;
color: #1e3a8a;
}
.teo-row span {
font-weight: 700;
color: #0f172a;
font-size: 1.25rem;
display: block;
}
.real-box label {
margin-top: 0.6rem;
font-weight: 600;
display: block;
}
.input-big {
width: 100%;
padding: 0.9rem;
font-size: 1.25rem;
border-radius: 0.6rem;
border: 1px solid #cbd5e1;
margin-bottom: 1rem;
}
.modal-buttons {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.modal-btn {
flex: 1;
padding: 0.9rem;
font-weight: 600;
border: none;
border-radius: 0.8rem;
cursor: pointer;
font-size: 1.05rem;
}
.modal-confirm {
background: #10b981;
color: white;
}
.modal-cancel {
background: #e2e8f0;
color: #475569;
}
</style>
</head>
<body>
<div class="wrapper" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="main-card">
<div class="card-header">
<h5>Dati di produzione da compilare</h5>
<button class="back-btn" onclick="location.href='production_line.php'">Torna</button>
</div>
<div id="recordsContainer">
<!-- Caricato via JS -->
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<!-- MODALE COMPILAZIONE DATI -->
<div id="compileModal" class="custom-modal">
<div class="modal-content">
<h3>Dati di produzione</h3>
<p>Confronta i valori teorici e inserisci quelli reali:</p>
<div class="final-flex">
<div class="final-col teo-box">
<h4>Teorici</h4>
<div class="teo-row">
<label>Kg teorici:</label>
<span id="teoKg">0.00</span>
</div>
<div class="teo-row">
<label>Metri teorici:</label>
<span id="teoMt">0.00</span>
</div>
</div>
<div class="final-col real-box">
<h4>Reali</h4>
<label>Kg prodotti:</label>
<input type="number" id="kgprod" step="0.01" min="0" placeholder="0.00" class="input-big">
<label>Metri prodotti:</label>
<input type="number" id="mtprod" step="0.01" min="0" placeholder="0.00" class="input-big">
<label>Scarto (kg):</label>
<input type="number" id="scarto" step="0.01" min="0" placeholder="0.00" class="input-big">
<label>Scarto (%):</label>
<input type="text" id="scartoPerc" class="input-big" readonly>
<label>Note operatore:</label>
<textarea id="compileNote" rows="3" class="input-big"></textarea>
</div>
</div>
<div class="modal-buttons">
<button id="compileCancel" class="modal-btn modal-cancel">Annulla</button>
<button id="compileConfirm" class="modal-btn modal-confirm">Conferma e invia a Qualità</button>
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<?php include('jsinclude.php'); ?>
<script>
$(function() {
function loadRecords() {
$('#recordsContainer').html(
'<div class="text-center p-5"><i class="bi bi-hourglass-split" style="font-size:2.5rem;color:#94a3b8;"></i></div>'
);
$.get('', {
ajax: 1
}, function(html) {
$('#recordsContainer').html(html);
}).fail(function() {
$('#recordsContainer').html('<div class="no-records">Errore di connessione</div>');
});
}
// Collasso/espansione riga (clic sulla riga, non sui bottoni)
$(document).on('click', '.compile-row', function() {
$(this).closest('.compile-card').toggleClass('expanded');
});
function recalcScrapPerc() {
const kg = parseFloat($('#kgprod').val()) || 0;
const sc = parseFloat($('#scarto').val()) || 0;
const tot = kg + sc;
const perc = tot > 0 ? (sc / tot) * 100 : 0;
$('#scartoPerc').val(perc.toFixed(2) + ' %');
}
// Apri modale compilazione
$(document).on('click', '.compile-btn', function(e) {
e.stopPropagation();
const card = $(this).closest('.compile-card');
const id = card.data('id');
$('#kgprod, #mtprod, #scarto').val('');
$('#scartoPerc').val('');
$('#compileNote').val('');
$('#teoKg').text(card.data('teokg') || '0.00');
$('#teoMt').text(card.data('teomt') || '0.00');
$('#kgprod, #scarto').off('input').on('input', recalcScrapPerc);
// Conferma
$('#compileConfirm').off('click').on('click', function() {
const kg = parseFloat($('#kgprod').val()) || 0;
const mt = parseFloat($('#mtprod').val()) || 0;
const scarto = parseFloat($('#scarto').val()) || 0;
const note = $('#compileNote').val().trim();
if (kg <= 0 && mt <= 0) {
alert('Inserisci almeno uno tra Kg o Metri prodotti.');
return;
}
$('#compileConfirm').prop('disabled', true);
$.post('', {
save_compile: 1,
id: id,
kgprod: kg,
mtprod: mt,
scarto: scarto,
note: note
}, function(r) {
$('#compileConfirm').prop('disabled', false);
if (r.success) {
$('#compileModal').removeClass('active');
loadRecords();
} else {
alert(r.msg || 'Errore nel salvataggio.');
}
}, 'json').fail(function() {
$('#compileConfirm').prop('disabled', false);
alert('Errore di comunicazione con il server.');
});
});
$('#compileModal').addClass('active');
});
// Annulla → non salva, resta in stato 11
$('#compileCancel').on('click', function() {
$('#compileModal').removeClass('active');
});
$(document).on('click', '#compileModal', function(e) {
if ($(e.target).is('#compileModal')) {
$('#compileModal').removeClass('active');
}
});
loadRecords();
});
</script>
</body>
</html>
+115 -13
View File
@@ -23,12 +23,42 @@ $dashboardSections = [
'permission' => 'production.line_view.view',
],
[
'label' => 'Statistiche',
'label' => 'Magazzino',
'icon' => '📦',
'class' => 'btn-magazzino',
'url' => 'warehouse_dashboard.php',
'permission' => 'warehouse.dashboard.view',
],
[
'label' => 'Smart-Alert',
'icon' => '⏰',
'class' => 'btn-scadenziario',
'url' => 'scadenzario/index.php',
'permission' => 'deadlines.view',
],
],
],
[
'id' => 'secDirezione',
'title' => 'Direzione',
'subtitle' => 'Statistiche, analisi fermi e strumenti di supervisione',
'icon' => '📊',
'open' => false,
'buttons' => [
[
'label' => 'Statistiche Produzione',
'icon' => '📈',
'class' => 'btn-statistiche',
'url' => 'production_stats.php',
'permission' => 'production.stats.view',
],
[
'label' => 'Pause & Fermi',
'icon' => '🛑',
'class' => 'btn-pause-stats',
'url' => 'pause_stats.php',
'permission' => 'production.stats.view',
],
[
'label' => 'Manager',
'icon' => '👔',
@@ -43,19 +73,63 @@ $dashboardSections = [
'url' => 'manager_stats.php',
'permission' => 'production.manager_stats.view',
],
],
],
[
'id' => 'secVendite',
'title' => 'Business Overview',
'subtitle' => 'Ordini, fatturato, clienti, prezzi e margini delle matrici, preventivi e consegne (dati Mago)',
'icon' => '💶',
'open' => false,
'buttons' => [
[
'label' => 'Magazzino',
'icon' => '📦',
'class' => 'btn-magazzino',
'url' => 'warehouse_dashboard.php',
'permission' => 'warehouse.dashboard.view',
'label' => 'Sommario',
'icon' => '🚦',
'class' => 'btn-sales-summary',
'url' => 'sales_summary.php',
'permission' => 'sales.stats.view',
],
[
'label' => 'Smart-Alert',
'icon' => '⏰',
'class' => 'btn-scadenziario',
'url' => 'scadenzario/index.php',
'permission' => 'deadlines.view',
'label' => 'Andamento ordini',
'icon' => '🧾',
'class' => 'btn-sales',
'url' => 'sales_orders_stats.php',
'permission' => 'sales.stats.view',
],
[
'label' => 'Confronto anni',
'icon' => '📊',
'class' => 'btn-sales',
'url' => 'sales_orders_years.php',
'permission' => 'sales.stats.view',
],
[
'label' => 'Scheda cliente',
'icon' => '🪪',
'class' => 'btn-sales',
'url' => 'sales_customer.php',
'permission' => 'sales.stats.view',
],
[
'label' => 'Matrici e prezzi',
'icon' => '💎',
'class' => 'btn-sales-matrici',
'url' => 'sales_matrici.php',
'permission' => 'sales.matrici.view',
],
[
'label' => 'Preventivo',
'icon' => '🧮',
'class' => 'btn-sales-quote',
'url' => 'sales_preventivo.php',
'permission' => 'sales.quote.view',
],
[
'label' => 'Consegne',
'icon' => '🚚',
'class' => 'btn-sales-deliveries',
'url' => 'sales_orders_deliveries.php',
'permission' => 'sales.deliveries.view',
],
],
],
@@ -74,7 +148,7 @@ $dashboardSections = [
'permission' => 'masterdata.mescole.view',
],
[
'label' => 'Elenco Profili',
'label' => 'Matrici',
'icon' => '🧩',
'class' => 'btn-matrici',
'url' => 'matrici.php',
@@ -421,6 +495,11 @@ $dashboardSections = [
background: linear-gradient(135deg, #d2f7d9, #e7ffea);
}
.btn-pause-stats {
background: linear-gradient(135deg, #fca5a5, #fecaca);
color: #7f1d1d;
}
.btn-magazzino {
background: linear-gradient(135deg, #b9e3ffff, #d7f1ffff);
}
@@ -469,6 +548,29 @@ $dashboardSections = [
background: linear-gradient(135deg, #e5e7eb, #f3f4f6);
}
.btn-sales-summary {
background: linear-gradient(135deg, #198754, #3bb77e);
color: #fff;
}
.btn-sales {
background: linear-gradient(135deg, #b9ebc7, #dcf7e4);
}
.btn-sales-deliveries {
background: linear-gradient(135deg, #fde68a, #fef3c7);
}
.btn-sales-matrici {
background: linear-gradient(135deg, #ddd6fe, #ede9fe);
color: #4c1d95;
}
.btn-sales-quote {
background: linear-gradient(135deg, #99f6e4, #ccfbf1);
color: #134e4a;
}
/* Tablet/mobile */
@media (max-width: 992px) {
.dashboard-grid {
@@ -612,7 +714,7 @@ $dashboardSections = [
<?php include(__DIR__ . '/include/training_widget.php'); ?>
</div>
<h3 class="dashboard-title">Dashboard</h3>
<h3 class="dashboard-title">Gestionale ZIBOGOMMA</h3>
<!-- ===== STATISTICHE PRINCIPALI ===== -->
<div class="stats-row">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -241,7 +241,7 @@
</div>
</div>
</div>
<?php include('jsinclude.php'); ?>
<script>
$(document).ready(function() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More