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
RMubarakzyanov 55d83c12b0 maintaince module 2026-08-12 21:29:55 +03:00
224 changed files with 53019 additions and 3503 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();
}
}
-275
View File
@@ -1,275 +0,0 @@
# Manutenzioni — тест-план
Полный список проверок. **Бо́льшая часть автоматизирована** — руками остаётся только то, что действительно требует глаз (список ниже).
---
## Автопрогон — одна команда
```bash
make test # всё: 20 юнитов + 68 API-проверок + 69 браузерных тестов
```
По отдельности (`make` без аргументов покажет весь список):
```bash
make test-unit # логика расписания, без базы и сервера (0.02 с)
make test-api # логин, ajax, БД, файлы, права — 68 проверок
make test-e2e # браузер на 1440 px — 69 тестов
make test-sizes # полный прогон на каждом размере: 1440 / 820 / 390 px
make test-e2e-ui # интерактивный режим, видно каждый шаг
```
### Прогон по размерам экрана
`make test-sizes` гоняет **всю** сюиту трижды — на PC (1440), планшете (820) и телефоне (390):
| Размер | Тестов | Что проверяется отдельно |
|---|---|---|
| desktop 1440 | 69 | + вёрстка на трёх ширинах и сквозной обход |
| tablet 820 | 56 | целевое устройство по ТЗ |
| mobile 390 | 56 | карточки вместо таблиц, полноэкранные модалки |
Итого 181 браузерная проверка. Тесты написаны без привязки к вёрстке: хелпер `listItem()` берёт строку таблицы или карточку — смотря что видно на этой ширине.
## Видео вместо кликанья
```bash
make demo # ~50 с: сквозной обход модуля → tests/e2e/video/demo.mp4
make video # весь прогон, склеенный по устройствам → tests/e2e/video/
make video-sizes # то же для каждого размера → tests/e2e/video/<формат>/
```
`make demo` прогоняет `tour.spec.js` — один непрерывный сценарий: реестр и фильтры → импортированная карточка `mod. 6.3-1` → создание оборудования → добавление ТО → регистрация работы с подписью мышью → пересчёт срока → история → печатная форма → оба календаря → категории → телефон и планшет. Это полноценный тест: если что-то сломано, он упадёт.
`make video` записывает все 69 тестов и склеивает их в пять файлов (`ffmpeg`, склейка по разрешениям — иначе кадры разного размера не соединить):
| Файл | Что внутри | Длительность |
|---|---|---|
| `demo.mp4` | сквозной обход | ~50 с |
| `suite-funzionali.mp4` | реестр, ТО, интервенции, календари, печать, права | ~70 с |
| `suite-desktop.mp4` | вёрстка 1440 px | ~15 с |
| `suite-tablet.mp4` | вёрстка 820 px + подпись пальцем | ~35 с |
| `suite-mobile.mp4` | вёрстка 390 px | ~15 с |
Прогоны API и браузерный делают настоящий логин, работают с живой страницей и сверяют результат в базе. Заводят только данные с префиксом `ZZTEST_` / `ZZE2E_` и **удаляют их за собой**, в том числе при падении. Оба возвращают ненулевой код при ошибке — годятся для CI.
Полезное для e2e:
```bash
npm run test:e2e:ui # интерактивный режим: видно каждый шаг
npm run test:e2e:headed # с открытым браузером
npx playwright show-trace tests/e2e/.artifacts/<папка>/trace.zip # разбор падения
```
**Скриншоты.** Каждый прогон e2e складывает в `tests/e2e/screenshots/` по 5 картинок на устройство (390 / 820 / 1440 px): реестр, список ТО, карточка, календарь, модалка интервенции, плюс `tablet-signature.png` с реальным росчерком пальцем. Проще пролистать их, чем кликать вручную.
**Что закрыто автоматикой:** разделы 1, 2, 3, 5, 6, 7, 8, 9 (включая генерацию PDF), 10, 11, 12 (запуск без ошибок).
## Что смотреть глазами (короткий список)
Осталось немного — то, что машина проверить не может в принципе:
| Что | Пункты |
|---|---|
| Субъективное качество вёрстки — по скриншотам из `tests/e2e/screenshots/` | 2.1, 11.1–11.4 |
| Как выглядит PDF после реальной печати из браузера | 9.4, 9.5 |
| **Реальная доставка писем** на живой ящик | 12.2, 12.4 |
| Импортированные из XLS данные — выборочная сверка с их файлами | 13.2–13.7 |
| Подпись пальцем на **настоящем** планшете (в браузере эмулируется, но железо стоит потрогать) | 6.6 |
Ниже — полный список, если хочется пройти всё подряд руками.
---
## 0. Подготовка
- База: `http://localhost:8001/zibo-dashboard/public` (контейнеры `zibo-app` + `zibo-db`, база на порту 3308).
Если не подняты: `docker compose up -d` (порт 3307 может быть занят — тогда нужен override на 3308).
- Логин под админом (роль Admin, id 1). В форме логина поле называется **username**, туда e-mail.
- Дев-база уже наполнена импортом: **101 единица оборудования, 126 ТО, 20 подрядчиков, 5 категорий**.
- Всё меню модуля: сайдбар → **Manutenzioni**.
---
## 1. Ничего старого не сломалось (сначала это!)
Переименование `production_tools` → `inv_equipment` затронуло модуль производства.
- [ ] **1.1** `userarea/production_tools.php` — открывается по прежнему адресу, в списке видны `Taglierina` (×2), `Marcatura Laser`, `Coestrusore diametro 70` **и** оборудование из импорта.
- [ ] **1.2** Там же: кнопка «Aggiungi Strumento» — создать тестовый инструмент. Сохраняется, появляется в списке.
- [ ] **1.3** Он же появился в новом реестре (`Manutenzioni → Registro Attrezzature`) в категории **Attrezzature**.
- [ ] **1.4** Редактирование инструмента на старой странице: в поле «Stato» теперь три варианта (Attivo / Fuori servizio / Dismesso). Меняем на «Fuori servizio» → сохранилось.
- [ ] **1.5** Кнопка удаления на старой странице — раньше **всегда падала** с «Invalid ID.», теперь помечает как «Dismesso». Проверить на тестовом инструменте.
- [ ] **1.6** `userarea/skills.php` — открывается, выпадающий список «Attrezzature» заполнен.
- [ ] **1.7** `userarea/production_line_view2.php` — открывается, колонка с инструментами у записей производства не пустая и не сломана.
- [ ] **1.8** `userarea/production_dashboard.php` — открывается, кнопка «Attrezzature» ведёт куда вела.
---
## 2. Реестр оборудования
`Manutenzioni → Registro Attrezzature`
- [ ] **2.1** Список открывается, у части позиций видна обложка-заглушка (шестерёнка), у категорий свои цветные точки.
- [ ] **2.2** Поиск «CARRELLO» → находит погрузчики.
- [ ] **2.3** Фильтр по категории **Edifici e impianti** → ~24 позиции (здания, противопожарные двери, чиллеры, котёл).
- [ ] **2.4** Фильтр по линии **Linea 1 — UHF** → 10 позиций, все с «LINEA 1» в названии.
- [ ] **2.5** Фильтр по статусу «Dismesso» → пусто (или только то, что сам пометил в п.1.4).
- [ ] **2.6** Кнопка «Azzera» сбрасывает все фильтры.
- [ ] **2.7** «Nuova attrezzatura» → заполнить имя + категорию **Attrezzature** → сохраняется, появляется в списке.
- [ ] **2.8** В той же форме выбрать категорию **Linea di produzione** → появляется обязательное поле «Linea di produzione». Попытка сохранить без линии → предупреждение.
- [ ] **2.9** Редактирование (карандаш) — форма открывается **с заполненными** полями, изменения сохраняются.
- [ ] **2.10** Удаление (корзина) своей тестовой записи → удаляется после подтверждения.
- [ ] **2.11** Удаление `Taglierina` (id 1) → **отказ**: «используется в 2 записях производства, пометьте как Dismesso».
---
## 3. Категории
`Manutenzioni → Categorie`
- [ ] **3.1** Пять категорий: Linea di produzione, Edifici e impianti, Attrezzature, Accessori, Qualità. У «Linea di produzione» стоит галка «Richiede linea».
- [ ] **3.2** Создать категорию «Test QA» с цветом и порядком → появилась.
- [ ] **3.3** Создать вторую с тем же именем → отказ «Esiste già una categoria con questo nome».
- [ ] **3.4** Отредактировать «Test QA», поставить «Richiede una linea di produzione» → сохранилось; в форме оборудования при выборе этой категории появляется поле линии.
- [ ] **3.5** Удалить «Test QA» (пустую) → удаляется.
- [ ] **3.6** Удалить «Attrezzature» → **отказ**: используется N оборудованием.
---
## 4. Карточка оборудования, фото и документы
Открыть `CARRELLO ELEVATORE CAR.MI. MOD. PL416` (из реестра).
- [ ] **4.1** Слева блок «Dati»: матрикола `55551002N5069`, производитель `CAR.MI.`, дата ввода в эксплуатацию 01/01/2002.
- [ ] **4.2** Справа 8 работ с кодами **A–H**, частоты: A — «Ogni 2 settimane», B–E — «Ogni 3 mesi», F,G — «Ogni 6 mesi», H — «Ogni 1 anno».
- [ ] **4.3** Загрузить фото (кнопка «+» в блоке «Foto») → появилось в галерее и **сразу стало обложкой** (рамка), обложка видна в списке реестра.
- [ ] **4.4** Загрузить второе фото → обложка не поменялась. Нажать на нём звёздочку → стало обложкой.
- [ ] **4.5** Загрузить документ PDF в блок «Documenti» → появился, открывается по клику в новой вкладке.
- [ ] **4.6** Попробовать загрузить `.php` файл → отказ «Tipo di file non consentito».
- [ ] **4.7** Скопировать ссылку на фото, открыть в **приватном окне** (без сессии) → 401, файл не отдаётся.
- [ ] **4.8** Удалить фото и документ → исчезают.
---
## 5. Манутенции (ТО)
- [ ] **5.1** «Nuova manutenzione» на карточке: код `Z`, название, тип **Programmata**, исполнение **Interna**, частота «Ogni 2 mesi», преавизо 15, исполнитель и ответственный → сохранилось, видно в списке.
- [ ] **5.2** Исполнение **Esterna** → появляется обязательный «Fornitore esterno». Сохранить без него → предупреждение.
- [ ] **5.3** Частота **«Al bisogno»** → поле «Ogni» прячется; сохранённая запись показывает «Al bisogno», даты «Prossimo» нет.
- [ ] **5.4** Поле «Nota sulla frequenza»: вписать «prima dell'accensione» → текст виден в строке ТО после частоты.
- [ ] **5.5** Галка «Manutenzione critica» → красная плашка «Critica» и красная полоса слева.
- [ ] **5.6** Галка «Attiva» снята → строка тускнеет, плашка «Disattivata»; такое ТО пропадает из общего списка `Manutenzioni`.
- [ ] **5.7** Приложить файл-инструкцию в форме ТО → после сохранения счётчик «N istruzioni» в строке; при повторном открытии формы файл в списке, скачивается, удаляется.
- [ ] **5.8** Удалить своё тестовое ТО → предупреждение о числе интервентов, после подтверждения исчезает.
- [ ] **5.9** Открыть `CHILLER UFFICI E POMPA DI CALORE ELCO` → 4 работы A–D **без даты** (сезонные), в примечании частоты полный текст «prima dell'accensione / durante il periodo estivo… / dopo lo spegnimento». Плюс одна внешняя от `Piacenza Impianti`.
- [ ] **5.10** Открыть `EVAPORATORE SOTTOVUOTO…` → **три** ТО: внутреннее + два внешних, `Ecotechno` и `Ecogenerazione` (не должны схлопнуться в одно).
---
## 6. Регистрация интервенций и пересчёт срока
Это ядро модуля — проверить внимательно. Взять любое ТО с частотой (например CARMI, код B — «Ogni 3 mesi»).
- [ ] **6.1** Кнопка «✓» → открывается форма, дата подставлена сегодняшняя, вверху написано, к какому оборудованию и ТО относится.
- [ ] **6.2** Поставить дату **три месяца назад**, статус **Completato**, эсито **Conforme**, оператора, заметку, материалы → сохранить.
- [ ] **6.3** В строке ТО: «Ultimo» = введённая дата, «Prossimo» = она же + 3 месяца, плашка состояния пересчиталась.
- [ ] **6.4** Кнопка «Storico interventi (N)» разворачивает таймлайн, там видна запись с оператором, эсито и заметками.
- [ ] **6.5** **Подпись:** в форме интервенции расписаться мышью в поле подписи → после сохранения миниатюра подписи видна в таймлайне и в печатной форме.
- [ ] **6.6** На планшете/телефоне: подпись рисуется пальцем, страница при этом не скроллится.
- [ ] **6.7** «Cancella firma» очищает поле; при редактировании существующей интервенции стирает и сохранённую подпись.
- [ ] **6.8** Добавить интервенцию в статусе **Pianificato** на будущую дату → «Prossimo» **не изменился** (двигают только завершённые).
- [ ] **6.9** Приложить файл к интервенции (модуль подрядчика) → счётчик скрепки в таймлайне, файл открывается.
- [ ] **6.10** Удалить последнюю завершённую интервенцию → «Prossimo» откатился на предыдущую дату (или опустел, если она была единственной).
- [ ] **6.11** Редактирование интервенции (карандаш в таймлайне) → поля подставлены, изменение даты пересчитывает срок.
---
## 7. Общий список ТО
`Manutenzioni → Manutenzioni`
- [ ] **7.1** Четыре плитки сверху: Scadute / In scadenza / In regola / Al bisogno, сумма ≈ числу активных ТО активного оборудования.
- [ ] **7.2** Клик по плитке «Scadute» фильтрует список; повторный клик снимает фильтр.
- [ ] **7.3** Фильтр «Solo programmata» / «Solo straordinaria» работает.
- [ ] **7.4** Галка «Solo critiche» → остаются только с красной плашкой.
- [ ] **7.5** Галка «Assegnate a me» → остаются ТО, где ты исполнитель или ответственный (нужно, чтобы твой `auth_users` был связан с `employees.auth_user_id`; если связи нет — список пустой, это ожидаемо).
- [ ] **7.6** Сортировка: просроченные и ближайшие сверху, «Al bisogno» — в конце.
- [ ] **7.7** Кнопка «✓» прямо из списка открывает ту же форму интервенции.
---
## 8. Календари
- [ ] **8.1** `Manutenzioni → Calendario`: события на датах, цвет по состоянию (красный — просрочено, жёлтый — скоро, иначе цвет категории).
- [ ] **8.2** Клик по событию → открывается карточка нужного оборудования.
- [ ] **8.3** Фильтры «тип» и «категория» перерисовывают календарь.
- [ ] **8.4** На телефоне календарь открывается сразу в виде списка (listMonth), не ломается по ширине.
- [ ] **8.5** `Manutenzioni → Calendario Cumulativo`: три переключателя — Scadenze 📅, Formazione 🎓, Manutenzioni 🔧.
- [ ] **8.6** Выключение слоя убирает его события, включение возвращает.
- [ ] **8.7** Клик по событию scadenza ведёт в scadenzario, по formazione — в trainings, по manutenzione — в карточку оборудования.
- [ ] **8.8** Выключить все три слоя → календарь пуст, ошибок нет.
---
## 9. Печать / PDF
- [ ] **9.1** На карточке кнопка «Scheda PDF» → открывается вёрстка их бланка: шапка «SCHEDA MANUTENZIONE», справа «ZIBOGOMMA / mod. 6.3-1», строка «MACCHINA:».
- [ ] **9.2** Таблица «Interventi previsti» — коды, описания, частоты, исполнение (внутр./фирма).
- [ ] **9.3** Таблица «Registro interventi eseguiti» — дата, код, описание, оператор, картинка подписи.
- [ ] **9.4** Ctrl/Cmd+P → в предпросмотре печати кнопки скрыты, таблицы не рвутся по строкам, шапка таблицы повторяется на второй странице.
- [ ] **9.5** Сохранить как PDF — файл читаемый.
---
## 10. Права (важно — проверяется на бэке, не только в меню)
Понадобится второй пользователь с ролью без прав `maintenance.*` (например `employee`).
- [ ] **10.1** Под таким пользователем пункт «Manutenzioni» в меню не виден.
- [ ] **10.2** Прямой заход на `userarea/manutenzioni/index.php` → «Permesso negato», а не страница.
- [ ] **10.3** Выдать в «Autorizzazioni» роли только `maintenance.equipment.view` → реестр открывается, но **кнопок создания/редактирования нет**, блок «Manutenzioni» на карточке пишет, что прав нет.
- [ ] **10.4** Добавить `maintenance.manage` → кнопки появились, создание работает.
---
## 11. Мобильный и планшет
Проверять на реальном планшете или в devtools (375px и 768px).
- [ ] **11.1** 375px: реестр показан **карточками**, не таблицей; ничего не уезжает за край, горизонтального скролла нет.
- [ ] **11.2** 375px: список ТО — карточками; плитки-счётчики по две в ряд.
- [ ] **11.3** Модалки открываются на весь экран, кнопки внизу доступны.
- [ ] **11.4** 768px (планшет — целевое устройство по ТЗ): таблицы читаемы, сайдбар по умолчанию скрыт, гамбургер работает.
- [ ] **11.5** Форма интервенции на планшете: подпись пальцем, все поля доступны без зума.
---
## 12. Уведомления (cron)
- [ ] **12.1** Поставить у ТО исполнителя, у которого `employees.auth_user_id` связан с реальным почтовым ящиком, и дату «Prossimo» в прошлом.
- [ ] **12.2** Запустить: `docker exec zibo-app php /var/www/zibo-dashboard/public/userarea/manutenzioni/cron/send_notifications.php`
→ в консоли «Inviate: N», письмо приходит исполнителю **и** ответственному.
- [ ] **12.3** Запустить второй раз в тот же день → «saltate», повторных писем нет.
- [ ] **12.4** В письме: тема с названием оборудования, дата, для критичного ТО — пометка, кнопка ведёт на карточку.
- [ ] **12.5** ТО с частотой «Al bisogno» писем не порождает.
---
## 13. Импортированные данные (выборочно, глазами)
- [ ] **13.1** Всего в реестре ~101 позиция; фильтр по категориям даёт ~74 Attrezzature, ~24 Edifici e impianti, 3 Linee.
- [ ] **13.2** У `FABBRICATI` — 8 работ уборки от `PULINET di Angelini Bassano` (giornaliero / settimanale / mensile / trimestrale / al bisogno).
- [ ] **13.3** У позиций с «LINEA 1/2/3» в названии проставлена соответствующая линия (в карточке строка «Linea»).
- [ ] **13.4** Исполнители расставлены: Alfonso, Gabriele, Junior, Marco, Ester, Marina.
- [ ] **13.5** У `DERATTIZZAZIONE CON ESCHE DOMESTICHE` исполнитель пуст, а в заметках ТО написано «Incaricato da XLS: M.GRANATA» — это фирма, записанная в колонке внутренних работ; надо решить, переводить ли её в подрядчики.
- [ ] **13.6** В заметках оборудования сохранены имена их бумажных карточек («Schede originali: …»).
- [ ] **13.7** **Ожидаемо и требует решения клиента:** у 110 из 125 ТО частота «Al bisogno» — в сводном XLS частот попросту нет, они лежат в отдельных карточках `mod. 6.3-1`, а прислали три из 57.
---
## Чего в модуле нет намеренно
- Калибровка/поверка приборов — вынесена за скоуп самим клиентом. Структура данных её примет отдельной таблицей без переделок.
- Импорт истории выполненных работ из старых карточек — журналы в присланных файлах пустые.
-105
View File
@@ -1,105 +0,0 @@
# Manutenzioni — трассируемость: ТЗ → тесты
Каждый пункт исходного документа клиента (`Manutenzioni - ACS.pdf`) и то, чем он подтверждён.
Условные обозначения: **UI** — браузерный тест (Playwright, гоняется на 1440/820/390), **API** — `tests/manutenzioni/api_test.php`, **U** — юнит-тест PHPUnit.
---
## 1. Реестр оборудования
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Единая база оборудования, у каждой позиции категория | UI | `§1.2 le quattro categorie del capitolato esistono e sono gestibili` |
| Категории: Linea di produzione, Attrezzature, Accessori, Qualità | UI | там же |
| Категории **не хардкод**, свой CRUD | UI + API | `Categories › CRUD from the modal, and categories in use cannot be deleted`; API: создание, дубль имени, запрет удаления занятой |
| Для категории «линия» обязательно указать линию | UI + API | `§1.5 il campo «linea» compare solo per le categorie che lo richiedono`; API: отказ без линии |
| Фильтрация по категории / линии / отделу | UI | `§1.5 the registry filters by production line and by department`; `Equipment registry › the filters really narrow the list` |
| Перенос существующих `production_lines` и `production_tools` | UI + API | `§1.6 le linee e gli attrezzi già esistenti sono nel registro unico`; API: старые страницы производства работают после переименования |
| Поля: название, серийник, партия, дата покупки, дата ввода, производитель, тип, ubicazione, отдел, заметки | UI | `§1.7 tutti i campi dell'anagrafica si salvano e si rivedono` — сохранение, возврат в форму и отображение в карточке |
| Статус (attivo / fuori servizio / dismesso) исключает неактивное из планировочных вью | UI | `§1.7 un'attrezzatura non attiva sparisce dalle viste di pianificazione` — проверяет и список ТО, и календарь |
| Вложения: файлы-инструкции и мануалы | UI + API | `Photos, cover image and documents › documents upload and stay downloadable`; отказ на недопустимый тип |
| Обложка + дополнительные фото | UI | `la prima foto caricata diventa copertina, anche nel registro`; `la stellina sposta la copertina sulla seconda foto`; `la foto si elimina previa conferma` |
| Калибровка — вне скоупа | — | намеренно не реализована; отдельной таблицы нет |
## 2. Модуль обслуживания
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| 0..N обслуживаний на единицу; 0 = вне планирования | UI | `§2.1 un'attrezzatura senza manutenzioni resta fuori dalla pianificazione` |
| Несколько видов ТО одновременно | UI | `§2.2 la stessa attrezzatura porta più manutenzioni insieme` (три работы на одной машине) |
| Типы: программное и внеплановое | UI + API | `«solo critiche» e «programmate/straordinarie» restringono l'elenco`; `riaprendo una manutenzione i campi sono quelli salvati` |
| Флаг критичности | UI | `una manutenzione critica è evidenziata nella scheda` |
| Внешние исполнители: указать подрядчика | UI + API | `«esterna» chiede il fornitore e blocca il salvataggio senza` |
| Приложить форму подрядчика | UI | `un allegato dell'intervento compare nello storico` |
| Название/описание | UI | `riaprendo una manutenzione i campi sono quelli salvati` |
| Частота через выпадающий список интервалов | UI + U | `«al bisogno» nasconde il campo della frequenza`; `la nota di frequenza resta visibile`; юниты на все единицы интервала |
| Файлы-инструкции к ТО | UI | `le istruzioni allegate a una manutenzione si vedono, si riaprono e si eliminano` |
| Срок предупреждения | UI | `riaprendo una manutenzione i campi sono quelli salvati` (alert_days) |
| Один исполнитель + один ответственный | UI | `§2.6 incaricato e responsabile si salvano e si leggono nella scheda` |
| Следующий срок = от последней выполненной работы | UI + U | `registrare un intervento completato aggiorna le date a schermo`; `un intervento pianificato non sposta la scadenza`; `eliminando l'ultimo intervento completato la scadenza torna indietro`; 20 юнитов на арифметику дат |
## 3. Журнал интервенций
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Привязка к конкретному ТО | UI | `la registrazione rapida dall'elenco aggiorna la scadenza` (контекст в модалке) |
| Дата | UI | `registrare un intervento completato aggiorna le date a schermo` |
| Заметки | UI | `riaprendo un intervento i campi sono quelli salvati` |
| Оператор + подпись | UI | `la firma disegnata finisce nello storico`; `la firma si cancella con il pulsante dedicato`; `si firma col dito e la pagina non scorre sotto il tratto` (планшет/телефон) |
| Статус (pianificato / in corso / completato) | UI | `un intervento pianificato non sposta la scadenza` |
| Результат (conforme / bloccato …) | UI | `riaprendo un intervento i campi sono quelli salvati` |
| Материалы / замененные части | UI | там же |
| Для внешних: подрядчик и его форма | UI | `un allegato dell'intervento compare nello storico` |
## 4. Фильтры и вью
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Фильтр между программными и внеплановыми | UI | `«solo critiche» e «programmate/straordinarie» restringono l'elenco` |
| Календарь ТО по срокам | UI | `il calendario disegna gli eventi e il click porta alla scheda`; `i filtri del calendario rifanno la richiesta` |
## 5. Выгрузка PDF
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Карточка с историей обслуживания по единице | UI | `la scheda riproduce il modulo cartaceo` (шапка mod. 6.3-1, работы, журнал) |
| Собственно PDF | UI | `in stampa la barra dei pulsanti sparisce e il PDF si genera` |
## 6. Уведомления
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Письма о просрочке и заранее | API | cron запускается без ошибок; выборка находит просроченное ТО |
| Получатели: ответственный **и** исполнитель | — | **браузером не проверяется**: письма уходят из cron, а не из UI. Логика получателей сверена запросом; фактическая доставка — ручной пункт 12 тест-плана |
## 7. Сводный календарь
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Scadenze + formazione + manutenzioni в одном календаре | UI | `ogni strato porta i propri eventi`; `il calendario cumulativo accende e spegne gli strati` |
| Разные цветные бейджи по типу срока | UI | `§7 i tre tipi di scadenza hanno colori distinti` (свой цвет и свой значок у каждого слоя) |
| Переход из календаря в нужный модуль | UI | `dal calendario cumulativo si arriva alla scheda dell'attrezzatura` |
## 8. Техтребования
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Работа на PC и планшете → адаптивность | UI × 3 | весь набор гоняется на 1440 / 820 / 390; `Layout › …` (нет горизонтальной прокрутки, карточки против таблицы, модалка достижима, сайдбар не прыгает) |
---
## Что осталось за пределами автотестов
| Пункт | Почему | Где проверять |
|---|---|---|
| Фактическая доставка писем | нужен живой SMTP и ящик | ручной чеклист, раздел 12 |
| Внешний вид PDF после печати из браузера | субъективно | ручной чеклист, разделы 9.4–9.5 |
| Подпись на физическом планшете | эмуляция ≠ железо | ручной чеклист, 6.6 |
| Соответствие импортированных данных их XLS | сверка с бумагой | ручной чеклист, раздел 13 |
## Как перепроверить
```bash
make test-sizes # весь набор на трёх размерах
npx playwright test tests/e2e/capitolato.spec.js # только пункты ТЗ
```
-82
View File
@@ -1,82 +0,0 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test')
/**
* E2E suite for the Manutenzioni (maintenance) module.
*
* Covers what the PHP tests cannot see: modals, canvas signature, calendars,
* printing and the layout on tablet/phone.
*
* Prerequisite: the project containers are up
* docker compose up -d
* Base URL can be overridden with MNT_BASE_URL.
*/
// The trailing slash is mandatory: without it the relative paths used by the
// tests would resolve against the domain root instead of the app.
const baseURL = (process.env.MNT_BASE_URL || 'http://localhost:8001/zibo-dashboard/public').replace(/\/?$/, '/')
/** Viewports under test: phone, tablet (the reference device), desktop. */
const SIZES = {
desktop: { width: 1440, height: 900 },
tablet: { width: 820, height: 1180 },
mobile: { width: 390, height: 844 },
}
const SIZE = SIZES[process.env.MNT_SIZE] ? process.env.MNT_SIZE : 'desktop'
module.exports = defineConfig({
testDir: './tests/e2e',
outputDir: './tests/e2e/.artifacts',
globalSetup: require.resolve('./tests/e2e/global-setup'),
globalTeardown: require.resolve('./tests/e2e/global-teardown'),
// the tests write to the same fixture data: no parallelism
workers: 1,
fullyParallel: false,
// Video recording slows every page down noticeably: with the same limits the
// longer tests would time out without anything actually being broken.
timeout: process.env.MNT_VIDEO ? 90_000 : 45_000,
expect: { timeout: process.env.MNT_VIDEO ? 20_000 : 10_000 },
// The HTML report moves attachments into hash-named folders: handy to browse,
// but it makes grouping the videos per device impossible. With MNT_VIDEO we
// therefore keep the plain reporter and leave the files in .artifacts.
reporter: process.env.MNT_VIDEO
// The JSON feeds the stitching step: it maps every video to the exact test
// title, which then gets burned into the caption bar.
? [['list'], ['json', { outputFile: 'tests/e2e/.report/results.json' }]]
: process.env.CI
? [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/.report' }]]
: [['list']],
use: {
baseURL,
storageState: 'tests/e2e/.auth/state.json',
locale: 'it-IT',
screenshot: 'only-on-failure',
trace: process.env.MNT_VIDEO ? 'on' : 'retain-on-failure',
// By default the video is kept only for failing tests: with MNT_VIDEO=1
// (npm run test:e2e:video) everything is recorded, one .webm per test,
// handy to show the module running without clicking through it.
video: process.env.MNT_VIDEO
? { mode: 'on', size: { width: 1280, height: 720 } }
: 'retain-on-failure',
},
// One viewport per invocation: the tests create data under fixed names, and
// running them in parallel across viewports would make them collide on the
// same database. The loop over the three viewports lives in the Makefile
// (make test-sizes).
projects: [
{
name: SIZE,
use: {
...devices['Desktop Chrome'],
viewport: SIZES[SIZE],
hasTouch: SIZE !== 'desktop',
},
},
],
})
+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>
+18 -21
View File
@@ -6,40 +6,37 @@ header('Content-Type: application/json');
require_once 'include/headscript.php';
/**
* Soft-deletes a tool: it is marked as decommissioned, not removed, because
* production records reference it (productiondata_tools.tool_id).
*
* production_tools is now the unified registry inv_equipment.
*
* Note: the page calls this endpoint with GET ?id=..., while the previous
* version only read $_POST['id'] and required a name — so deletion always
* failed with "Invalid ID.". Both methods are accepted now.
*/
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$id = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$tool_type = trim($_POST['tool_type'] ?? '');
$description = trim($_POST['description'] ?? '');
$is_active = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1;
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'Invalid ID.']);
exit;
}
if ($name === '') {
echo json_encode(['success' => false, 'message' => 'Name is required.']);
$stmt = $pdo->prepare("SELECT id FROM inv_equipment WHERE id = ?");
$stmt->execute([$id]);
if (!$stmt->fetchColumn()) {
echo json_encode(['success' => false, 'message' => 'Strumento non trovato.']);
exit;
}
$sql = "UPDATE production_tools
SET name = :name,
tool_type = :tool_type,
description = :description,
is_active = :is_active
WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
'name' => $name,
'tool_type' => $tool_type ?: null,
'description' => $description ?: null,
'is_active' => $is_active,
'id' => $id
]);
$stmt = $pdo->prepare("UPDATE inv_equipment SET status = 'decommissioned' WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
@@ -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">
+9 -4
View File
@@ -17,7 +17,7 @@ try {
$toolType = trim($_POST['tool_type'] ?? '');
$manufacturer = trim($_POST['manufacturer'] ?? '');
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1;
$status = (string)($_POST['status'] ?? 'active');
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'Invalid ID.']);
@@ -29,14 +29,19 @@ try {
exit;
}
$sql = "UPDATE production_tools
if (!in_array($status, ['active', 'out_of_service', 'decommissioned'], true)) {
$status = 'active';
}
// production_tools is now inv_equipment (see manutenzioni/sql)
$sql = "UPDATE inv_equipment
SET name = :name,
registration_number = :registration_number,
serial_number = :serial_number,
tool_type = :tool_type,
manufacturer = :manufacturer,
description = :description,
is_active = :is_active
status = :status
WHERE id = :id";
$stmt = $pdo->prepare($sql);
@@ -47,7 +52,7 @@ try {
'tool_type' => $toolType ?: null,
'manufacturer' => $manufacturer ?: null,
'description' => $description ?: null,
'is_active' => $isActive,
'status' => $status,
'id' => $id
]);
+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 */
}
+175 -31
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')
@@ -362,22 +428,100 @@
</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>
<?php
$canSeeMaintenance =
userCan('maintenance.equipment.view')
|| userCan('maintenance.maintenances.view')
|| userCan('maintenance.calendar.view')
|| userCan('maintenance.categories.view');
?>
<?php if ($canSeeMaintenance) : ?>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
<i class="bx bx-wrench"></i>
</div>
<div class="menu-title">Manutenzioni</div>
</a>
<ul>
<?php if (userCan('maintenance.equipment.view')) : ?>
<li>
<a href="manutenzioni/index.php">
<i class='bx bx-radio-circle'></i>Registro Attrezzature
</a>
</li>
<?php endif; ?>
<?php if (userCan('maintenance.maintenances.view')) : ?>
<li>
<a href="manutenzioni/maintenances.php">
<i class='bx bx-radio-circle'></i>Manutenzioni
</a>
</li>
<?php endif; ?>
<?php if (userCan('maintenance.calendar.view')) : ?>
<li>
<a href="manutenzioni/calendar.php">
<i class='bx bx-radio-circle'></i>Calendario
</a>
</li>
<?php endif; ?>
<li>
<a href="manutenzioni/calendar_global.php">
<i class='bx bx-radio-circle'></i>Calendario Cumulativo
</a>
</li>
<?php if (userCan('maintenance.categories.view')) : ?>
<li>
<a href="manutenzioni/categories/index.php">
<i class='bx bx-radio-circle'></i>Categorie
</a>
</li>
<?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 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>
@@ -0,0 +1,17 @@
```bash
# 1. database — nell'ordine, tutti idempotenti
cd public/userarea/manutenzioni/sql
for f in 1_rename_production_tools 2_create_tables 3_seed_and_link 4_permissions; do
mysql -u <user> -p <database> < $f.sql
done
# 2. dati reali del cliente dagli XLS (facoltativo)
mysql -u <user> -p <database> < 5_import_from_xls.sql
# 3. cartella allegati scrivibile
chmod 755 ../attachments && chown www-data:www-data ../attachments
# 4. cron giornaliero delle notifiche
echo '0 7 * * * php /var/www/html/public/userarea/manutenzioni/cron/send_notifications.php' | crontab -
```
@@ -0,0 +1,82 @@
<?php
/**
* Auth + permission guard for the Manutenzioni ajax endpoints.
* Include at the top of every handler; it defines $currentUserId.
*
* Authorisation is enforced here, on the backend — the UI only hides
* buttons, it never decides what a request is allowed to do.
*/
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
header('Content-Type: application/json; charset=utf-8');
if (empty($_SESSION['iduserlogin'])) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Non autorizzato. Effettua il login.']);
exit;
}
$currentUserId = (int)$_SESSION['iduserlogin'];
require_once __DIR__ . '/../include/functions.php';
if (!function_exists('mnt_user_can')) {
/**
* Permission check straight from the Vanguard RBAC tables
* (the Auth facade is not bootstrapped in these endpoints).
*/
function mnt_user_can(string $permission): bool
{
global $currentUserId;
static $permissions = null;
if ($permissions === null) {
$stmt = mnt_pdo()->prepare("
SELECT p.name
FROM auth_users u
INNER JOIN auth_permission_role pr ON pr.role_id = u.role_id
INNER JOIN auth_permissions p ON p.id = pr.permission_id
WHERE u.id = ?
");
$stmt->execute([$currentUserId]);
$permissions = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
return in_array($permission, $permissions, true);
}
}
if (!function_exists('mnt_require_permission')) {
function mnt_require_permission(string $permission): void
{
if (!mnt_user_can($permission)) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Permesso negato.']);
exit;
}
}
}
if (!function_exists('mnt_json_fail')) {
function mnt_json_fail(string $message, int $code = 200): void
{
if ($code !== 200) {
http_response_code($code);
}
echo json_encode(['success' => false, 'message' => $message]);
exit;
}
}
if (!function_exists('mnt_json_ok')) {
function mnt_json_ok(array $payload = []): void
{
echo json_encode(['success' => true] + $payload);
exit;
}
}
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
// The FK is ON DELETE SET NULL, so a delete would silently orphan items:
// refuse while the category is still in use.
$stmt = $pdo->prepare("SELECT COUNT(*) FROM inv_equipment WHERE category_id = ?");
$stmt->execute([$id]);
$inUse = (int)$stmt->fetchColumn();
if ($inUse > 0) {
mnt_json_fail('Impossibile eliminare: la categoria è usata da ' . $inUse . ' attrezzature.');
}
$pdo->prepare("DELETE FROM inv_categories WHERE id = ?")->execute([$id]);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,85 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT id, name FROM inv_equipment WHERE id = ?");
$stmt->execute([$id]);
$equipment = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$equipment) {
mnt_json_fail('Attrezzatura non trovata.');
}
// Legacy production records reference tools without ON DELETE CASCADE:
// refuse with a clear message instead of letting the FK throw.
$stmt = $pdo->prepare("SELECT COUNT(*) FROM productiondata_tools WHERE tool_id = ?");
$stmt->execute([$id]);
$usedInProduction = (int)$stmt->fetchColumn();
if ($usedInProduction > 0) {
mnt_json_fail(
'Impossibile eliminare: l\'attrezzatura è usata in ' . $usedInProduction .
' registrazioni di produzione. Impostala su "Dismesso" per escluderla dalle pianificazioni.'
);
}
// Collect stored files before the cascade removes their rows
$storedNames = [];
$stmt = $pdo->prepare("SELECT stored_name FROM inv_equipment_files WHERE equipment_id = ?");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("
SELECT f.stored_name
FROM maint_maintenance_files f
INNER JOIN maint_maintenances m ON m.id = f.maintenance_id
WHERE m.equipment_id = ?
");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("
SELECT f.stored_name
FROM maint_intervention_files f
INNER JOIN maint_interventions i ON i.id = f.intervention_id
WHERE i.equipment_id = ?
");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("SELECT signature_path FROM maint_interventions WHERE equipment_id = ? AND signature_path IS NOT NULL");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
// cover_file_id points at inv_equipment_files, which cascades from the
// equipment row: clear it first so the FK does not block the delete.
$pdo->beginTransaction();
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = NULL WHERE id = ?")->execute([$id]);
$pdo->prepare("DELETE FROM inv_equipment WHERE id = ?")->execute([$id]);
$pdo->commit();
foreach (array_filter($storedNames) as $storedName) {
mnt_delete_stored_file($storedName);
}
mnt_log($pdo, 'equipment_deleted', null, null, null, $currentUserId, null, $equipment['name']);
mnt_json_ok();
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,56 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$scope = (string)($_POST['scope'] ?? 'equipment');
$id = (int)($_POST['id'] ?? 0);
$tables = [
'equipment' => 'inv_equipment_files',
'maintenance' => 'maint_maintenance_files',
'intervention' => 'maint_intervention_files',
];
if (!isset($tables[$scope])) {
mnt_json_fail('Scope non valido.');
}
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT * FROM {$tables[$scope]} WHERE id = ?");
$stmt->execute([$id]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
mnt_json_fail('File non trovato.');
}
// Clear the cover reference first — the FK would block the delete
if ($scope === 'equipment') {
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = NULL WHERE cover_file_id = ?")->execute([$id]);
}
$pdo->prepare("DELETE FROM {$tables[$scope]} WHERE id = ?")->execute([$id]);
mnt_delete_stored_file((string)$file['stored_name']);
mnt_log(
$pdo,
'file_deleted',
$scope === 'equipment' ? (int)$file['equipment_id'] : null,
$scope === 'maintenance' ? (int)$file['maintenance_id'] : null,
$scope === 'intervention' ? (int)$file['intervention_id'] : null,
$currentUserId,
null,
(string)$file['original_name']
);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT * FROM maint_interventions WHERE id = ?");
$stmt->execute([$id]);
$intervention = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$intervention) {
mnt_json_fail('Intervento non trovato.');
}
$stmt = $pdo->prepare("SELECT stored_name FROM maint_intervention_files WHERE intervention_id = ?");
$stmt->execute([$id]);
$storedNames = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!empty($intervention['signature_path'])) {
$storedNames[] = $intervention['signature_path'];
}
$pdo->prepare("DELETE FROM maint_interventions WHERE id = ?")->execute([$id]);
foreach (array_filter($storedNames) as $storedName) {
mnt_delete_stored_file($storedName);
}
// Removing the last completed intervention must roll the due date back
mnt_recalc_maintenance($pdo, (int)$intervention['maintenance_id']);
mnt_log(
$pdo,
'intervention_deleted',
(int)$intervention['equipment_id'],
(int)$intervention['maintenance_id'],
null,
$currentUserId,
null,
'Intervento del ' . $intervention['performed_at']
);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,53 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT id, equipment_id, title FROM maint_maintenances WHERE id = ?");
$stmt->execute([$id]);
$maintenance = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$maintenance) {
mnt_json_fail('Manutenzione non trovata.');
}
// Files of the maintenance and of its interventions (both cascade in DB)
$stmt = $pdo->prepare("SELECT stored_name FROM maint_maintenance_files WHERE maintenance_id = ?");
$stmt->execute([$id]);
$storedNames = $stmt->fetchAll(PDO::FETCH_COLUMN);
$stmt = $pdo->prepare("
SELECT f.stored_name
FROM maint_intervention_files f
INNER JOIN maint_interventions i ON i.id = f.intervention_id
WHERE i.maintenance_id = ?
");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("SELECT signature_path FROM maint_interventions WHERE maintenance_id = ? AND signature_path IS NOT NULL");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$pdo->prepare("DELETE FROM maint_maintenances WHERE id = ?")->execute([$id]);
foreach (array_filter($storedNames) as $storedName) {
mnt_delete_stored_file($storedName);
}
mnt_log($pdo, 'maintenance_deleted', (int)$maintenance['equipment_id'], null, null, $currentUserId, null, $maintenance['title']);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,72 @@
<?php
/**
* Serves an attachment. The attachments/ folder itself is closed by
* .htaccess — this is the only way to read a file, and it requires a
* logged-in session.
*
* scope = equipment | maintenance | intervention | signature
*/
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['iduserlogin'])) {
http_response_code(401);
exit('Non autorizzato.');
}
require_once __DIR__ . '/../include/functions.php';
$scope = (string)($_GET['scope'] ?? 'equipment');
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
exit('ID non valido.');
}
$tables = [
'equipment' => 'inv_equipment_files',
'maintenance' => 'maint_maintenance_files',
'intervention' => 'maint_intervention_files',
];
$pdo = mnt_pdo();
if ($scope === 'signature') {
$stmt = $pdo->prepare("SELECT signature_path AS stored_name, 'firma.png' AS original_name, 'image/png' AS mime_type FROM maint_interventions WHERE id = ?");
$stmt->execute([$id]);
} elseif (isset($tables[$scope])) {
$stmt = $pdo->prepare("SELECT stored_name, original_name, mime_type FROM {$tables[$scope]} WHERE id = ?");
$stmt->execute([$id]);
} else {
http_response_code(400);
exit('Scope non valido.');
}
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file || empty($file['stored_name'])) {
http_response_code(404);
exit('File non trovato.');
}
// basename() keeps a crafted stored_name from escaping the folder
$path = __DIR__ . '/../attachments/' . basename((string)$file['stored_name']);
if (!is_file($path)) {
http_response_code(404);
exit('File non presente sul server.');
}
$mimeType = $file['mime_type'] ?: 'application/octet-stream';
$disposition = isset($_GET['download']) ? 'attachment' : 'inline';
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($path));
header('Content-Disposition: ' . $disposition . '; filename="' . rawurlencode((string)$file['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
readfile($path);
@@ -0,0 +1,80 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.calendar.view');
/**
* FullCalendar feed for maintenance due dates.
* Optional filters: type=scheduled|extraordinary, category=<id>
*/
try {
$pdo = mnt_pdo();
$start = (string)($_GET['start'] ?? date('Y-m-01', strtotime('-6 months')));
$end = (string)($_GET['end'] ?? date('Y-m-t', strtotime('+12 months')));
$type = (string)($_GET['type'] ?? '');
$categoryId = (int)($_GET['category'] ?? 0);
// Le attrezzature dismesse o fuori servizio restano fuori dalle viste di
// pianificazione: è a questo che serve lo stato (capitolato §1).
$where = [
"m.is_active = 1",
"e.status = 'active'",
"m.next_due_date IS NOT NULL",
"m.next_due_date BETWEEN ? AND ?",
];
$params = [substr($start, 0, 10), substr($end, 0, 10)];
if (array_key_exists($type, mnt_intervention_types())) {
$where[] = "m.intervention_type = ?";
$params[] = $type;
}
if ($categoryId > 0) {
$where[] = "e.category_id = ?";
$params[] = $categoryId;
}
$stmt = $pdo->prepare("
SELECT m.id, m.title, m.code, m.next_due_date, m.alert_days, m.is_critical, m.intervention_type,
e.id AS equipment_id, e.name AS equipment_name,
c.color AS category_color
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
LEFT JOIN inv_categories c ON c.id = e.category_id
WHERE " . implode(' AND ', $where) . "
ORDER BY m.next_due_date ASC
");
$stmt->execute($params);
$events = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$state = mnt_due_state($row['next_due_date'], (int)$row['alert_days']);
// Overdue/soon override the category colour so urgency wins visually
$color = $state === 'overdue' ? '#dc3545' : ($state === 'due_soon' ? '#f0a202' : ($row['category_color'] ?: '#2f7d8f'));
$events[] = [
'id' => (int)$row['id'],
'title' => ($row['code'] ? $row['code'] . ' — ' : '') . $row['equipment_name'] . ': ' . $row['title'],
'start' => $row['next_due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'equipmentId' => (int)$row['equipment_id'],
'equipmentName' => $row['equipment_name'],
'maintenanceTitle' => $row['title'],
'isCritical' => (int)$row['is_critical'] === 1,
'state' => $state,
'type' => $row['intervention_type'],
],
];
}
echo json_encode($events);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
@@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.equipment.view');
try {
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = mnt_pdo()->prepare("SELECT * FROM inv_equipment WHERE id = ?");
$stmt->execute([$id]);
$equipment = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$equipment) {
mnt_json_fail('Attrezzatura non trovata.');
}
mnt_json_ok(['equipment' => $equipment]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,122 @@
<?php
require_once __DIR__ . '/auth_check.php';
/**
* Cumulative calendar feed: deadlines + trainings + maintenances.
* Each layer is returned only if the user may see that module, and only
* if the layer is requested via ?layers=deadlines,trainings,maintenances
*/
try {
$pdo = mnt_pdo();
$start = substr((string)($_GET['start'] ?? date('Y-m-01', strtotime('-6 months'))), 0, 10);
$end = substr((string)($_GET['end'] ?? date('Y-m-t', strtotime('+12 months'))), 0, 10);
$requested = array_filter(explode(',', (string)($_GET['layers'] ?? 'deadlines,trainings,maintenances')));
$wants = fn(string $layer): bool => in_array($layer, $requested, true);
$events = [];
// ---- Maintenances --------------------------------------------------
if ($wants('maintenances') && mnt_user_can('maintenance.calendar.view')) {
$stmt = $pdo->prepare("
SELECT m.id, m.title, m.code, m.next_due_date, m.alert_days, m.is_critical,
e.id AS equipment_id, e.name AS equipment_name
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
WHERE m.is_active = 1
AND e.status = 'active'
AND m.next_due_date BETWEEN ? AND ?
");
$stmt->execute([$start, $end]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$state = mnt_due_state($row['next_due_date'], (int)$row['alert_days']);
$color = $state === 'overdue' ? '#dc3545' : '#2f7d8f';
$events[] = [
'id' => 'maint-' . $row['id'],
'title' => '🔧 ' . $row['equipment_name'] . ': ' . $row['title'],
'start' => $row['next_due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'layer' => 'maintenances',
'url' => 'manutenzioni/equipment.php?id=' . (int)$row['equipment_id'],
'state' => $state,
],
];
}
}
// ---- Deadlines (scadenzario) --------------------------------------
if ($wants('deadlines') && mnt_user_can('deadlines.view')) {
$stmt = $pdo->prepare("
SELECT d.id, d.topic, d.due_date, d.status, d.notification_days, s.name AS subject_name
FROM scad_deadlines d
LEFT JOIN scad_subjects s ON s.id = d.subject_id
WHERE d.due_date BETWEEN ? AND ?
");
$stmt->execute([$start, $end]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$isCompleted = $row['status'] === 'completed';
$state = $isCompleted ? 'ok' : mnt_due_state($row['due_date'], (int)$row['notification_days']);
$color = $isCompleted ? '#6c757d' : ($state === 'overdue' ? '#b02a37' : '#6f42c1');
$events[] = [
'id' => 'scad-' . $row['id'],
'title' => '📅 ' . $row['topic'],
'start' => $row['due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'layer' => 'deadlines',
'url' => 'scadenzario/detail.php?id=' . (int)$row['id'],
'state' => $state,
],
];
}
}
// ---- Trainings (formazione) ---------------------------------------
if ($wants('trainings') && mnt_user_can('hr.trainings.view')) {
$stmt = $pdo->prepare("
SELECT t.id, t.next_due_date, t.reminder_days,
tt.name AS topic_name,
CONCAT(e.first_name, ' ', e.last_name) AS employee_name
FROM employee_trainings t
INNER JOIN training_topics tt ON tt.id = t.training_topic_id
INNER JOIN employees e ON e.id = t.employee_id
WHERE t.next_due_date IS NOT NULL AND t.next_due_date BETWEEN ? AND ?
");
$stmt->execute([$start, $end]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$state = mnt_due_state($row['next_due_date'], (int)($row['reminder_days'] ?? 0));
$color = $state === 'overdue' ? '#a94442' : '#198754';
$events[] = [
'id' => 'train-' . $row['id'],
'title' => '🎓 ' . $row['topic_name'] . ' — ' . $row['employee_name'],
'start' => $row['next_due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'layer' => 'trainings',
'url' => 'trainings.php',
'state' => $state,
],
];
}
}
echo json_encode($events);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
@@ -0,0 +1,51 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.maintenances.view');
try {
$pdo = mnt_pdo();
$maintenanceId = (int)($_GET['maintenance_id'] ?? 0);
$interventionId = (int)($_GET['id'] ?? 0);
if ($interventionId > 0) {
$stmt = $pdo->prepare("SELECT * FROM maint_interventions WHERE id = ?");
$stmt->execute([$interventionId]);
$intervention = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$intervention) {
mnt_json_fail('Intervento non trovato.');
}
$stmt = $pdo->prepare("SELECT id, original_name FROM maint_intervention_files WHERE intervention_id = ?");
$stmt->execute([$interventionId]);
mnt_json_ok([
'intervention' => $intervention,
'files' => $stmt->fetchAll(PDO::FETCH_ASSOC),
]);
}
if ($maintenanceId <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("
SELECT i.*,
CONCAT(e.first_name, ' ', e.last_name) AS operator_full_name,
s.supplier_name,
(SELECT COUNT(*) FROM maint_intervention_files f WHERE f.intervention_id = i.id) AS file_count
FROM maint_interventions i
LEFT JOIN employees e ON e.id = i.operator_employee_id
LEFT JOIN suppliers s ON s.idsupplier = i.supplier_id
WHERE i.maintenance_id = ?
ORDER BY i.performed_at DESC, i.id DESC
");
$stmt->execute([$maintenanceId]);
mnt_json_ok(['interventions' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,43 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.maintenances.view');
try {
$pdo = mnt_pdo();
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("
SELECT m.*, e.name AS equipment_name
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
WHERE m.id = ?
");
$stmt->execute([$id]);
$maintenance = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$maintenance) {
mnt_json_fail('Manutenzione non trovata.');
}
$stmt = $pdo->prepare("
SELECT id, original_name, size
FROM maint_maintenance_files
WHERE maintenance_id = ?
ORDER BY created_at ASC
");
$stmt->execute([$id]);
mnt_json_ok([
'maintenance' => $maintenance,
'files' => $stmt->fetchAll(PDO::FETCH_ASSOC),
]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,53 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$name = trim((string)($_POST['name'] ?? ''));
$code = trim((string)($_POST['code'] ?? ''));
$color = trim((string)($_POST['color'] ?? '#6c757d'));
$requiresLine = !empty($_POST['requires_line']) ? 1 : 0;
$sortOrder = (int)($_POST['sort_order'] ?? 999);
$isActive = !empty($_POST['is_active']) ? 1 : 0;
$description = trim((string)($_POST['description'] ?? ''));
if ($name === '') {
mnt_json_fail('Il nome è obbligatorio.');
}
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
$color = '#6c757d';
}
// name is UNIQUE — check first so the user gets a readable message
$stmt = $pdo->prepare("SELECT id FROM inv_categories WHERE name = ? AND id <> ?");
$stmt->execute([$name, $id]);
if ($stmt->fetchColumn()) {
mnt_json_fail('Esiste già una categoria con questo nome.');
}
if ($id > 0) {
$stmt = $pdo->prepare("
UPDATE inv_categories
SET name = ?, code = ?, color = ?, requires_line = ?, sort_order = ?, is_active = ?, description = ?
WHERE id = ?
");
$stmt->execute([$name, $code ?: null, $color, $requiresLine, $sortOrder, $isActive, $description ?: null, $id]);
mnt_json_ok(['id' => $id]);
}
$stmt = $pdo->prepare("
INSERT INTO inv_categories (name, code, color, requires_line, sort_order, is_active, description)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([$name, $code ?: null, $color, $requiresLine, $sortOrder, $isActive, $description ?: null]);
mnt_json_ok(['id' => (int)$pdo->lastInsertId()]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,95 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$name = trim((string)($_POST['name'] ?? ''));
$categoryId = (int)($_POST['category_id'] ?? 0);
$lineId = (int)($_POST['line_id'] ?? 0);
$registration = trim((string)($_POST['registration_number'] ?? ''));
$serial = trim((string)($_POST['serial_number'] ?? ''));
$batchLot = trim((string)($_POST['batch_lot'] ?? ''));
$manufacturer = trim((string)($_POST['manufacturer'] ?? ''));
$purchaseDate = trim((string)($_POST['purchase_date'] ?? ''));
$commissioningDate = trim((string)($_POST['commissioning_date'] ?? ''));
$toolType = trim((string)($_POST['tool_type'] ?? ''));
$status = (string)($_POST['status'] ?? 'active');
$departmentId = (int)($_POST['department_id'] ?? 0);
$location = trim((string)($_POST['location'] ?? ''));
$description = trim((string)($_POST['description'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
if ($name === '') {
mnt_json_fail('Il nome è obbligatorio.');
}
if ($categoryId <= 0) {
mnt_json_fail('La categoria è obbligatoria.');
}
if (!array_key_exists($status, mnt_equipment_statuses())) {
mnt_json_fail('Stato non valido.');
}
// A category flagged requires_line must carry a production line
$stmt = $pdo->prepare("SELECT requires_line FROM inv_categories WHERE id = ?");
$stmt->execute([$categoryId]);
$requiresLine = $stmt->fetchColumn();
if ($requiresLine === false) {
mnt_json_fail('Categoria inesistente.');
}
if ((int)$requiresLine === 1 && $lineId <= 0) {
mnt_json_fail('Per questa categoria la linea di produzione è obbligatoria.');
}
$values = [
'name' => $name,
'category_id' => $categoryId,
'line_id' => $lineId ?: null,
'registration_number' => $registration ?: null,
'serial_number' => $serial ?: null,
'batch_lot' => $batchLot ?: null,
'manufacturer' => $manufacturer ?: null,
'purchase_date' => $purchaseDate ?: null,
'commissioning_date' => $commissioningDate ?: null,
'tool_type' => $toolType ?: null,
'status' => $status,
'department_id' => $departmentId ?: null,
'location' => $location ?: null,
'description' => $description ?: null,
'notes' => $notes ?: null,
];
if ($id > 0) {
$assignments = [];
foreach (array_keys($values) as $column) {
$assignments[] = "$column = :$column";
}
$stmt = $pdo->prepare("UPDATE inv_equipment SET " . implode(', ', $assignments) . " WHERE id = :id");
$stmt->execute($values + ['id' => $id]);
mnt_log($pdo, 'equipment_updated', $id, null, null, $currentUserId, $values);
mnt_json_ok(['id' => $id]);
}
$columns = array_keys($values);
$placeholders = array_map(fn($column) => ':' . $column, $columns);
$stmt = $pdo->prepare(
"INSERT INTO inv_equipment (" . implode(', ', $columns) . ", created_by)
VALUES (" . implode(', ', $placeholders) . ", :created_by)"
);
$stmt->execute($values + ['created_by' => $currentUserId]);
$newId = (int)$pdo->lastInsertId();
mnt_log($pdo, 'equipment_created', $newId, null, null, $currentUserId, $values);
mnt_json_ok(['id' => $newId]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,154 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
/**
* Registers (or updates) one intervention against a maintenance and
* recomputes the maintenance schedule.
*
* The operator signature arrives as a data URL from the tablet canvas and
* is stored as a PNG in attachments/.
*/
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$maintenanceId = (int)($_POST['maintenance_id'] ?? 0);
$performedAt = trim((string)($_POST['performed_at'] ?? ''));
$status = (string)($_POST['status'] ?? 'completed');
$result = trim((string)($_POST['result'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
$materials = trim((string)($_POST['materials'] ?? ''));
$operatorId = (int)($_POST['operator_employee_id'] ?? 0);
$operatorName = trim((string)($_POST['operator_name'] ?? ''));
$supplierId = (int)($_POST['supplier_id'] ?? 0);
$signatureData = (string)($_POST['signature'] ?? '');
$clearSignature = !empty($_POST['clear_signature']);
if ($performedAt === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $performedAt)) {
mnt_json_fail('La data dell\'intervento è obbligatoria.');
}
$validStatuses = mnt_lookup($pdo, 'maint_status', ['planned' => '', 'in_progress' => '', 'completed' => '']);
if (!array_key_exists($status, $validStatuses)) {
mnt_json_fail('Stato intervento non valido.');
}
$validResults = mnt_lookup($pdo, 'maint_result', []);
if ($result !== '' && $validResults && !array_key_exists($result, $validResults)) {
mnt_json_fail('Esito intervento non valido.');
}
// Existing row: maintenance is taken from the record, not from the client
if ($id > 0) {
$stmt = $pdo->prepare("SELECT * FROM maint_interventions WHERE id = ?");
$stmt->execute([$id]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$existing) {
mnt_json_fail('Intervento non trovato.');
}
$maintenanceId = (int)$existing['maintenance_id'];
$equipmentId = (int)$existing['equipment_id'];
$signaturePath = $existing['signature_path'];
} else {
$stmt = $pdo->prepare("SELECT equipment_id FROM maint_maintenances WHERE id = ?");
$stmt->execute([$maintenanceId]);
$equipmentId = (int)$stmt->fetchColumn();
if (!$equipmentId) {
mnt_json_fail('Manutenzione non trovata.');
}
$signaturePath = null;
}
// Signature handling
if ($clearSignature && $signaturePath) {
mnt_delete_stored_file((string)$signaturePath);
$signaturePath = null;
}
if ($signatureData !== '' && preg_match('#^data:image/png;base64,#', $signatureData)) {
$binary = base64_decode(substr($signatureData, strlen('data:image/png;base64,')), true);
if ($binary === false || strlen($binary) > 2 * 1024 * 1024) {
mnt_json_fail('Firma non valida.');
}
$uploadDir = __DIR__ . '/../attachments/';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
mnt_json_fail('Cartella allegati non scrivibile.');
}
$newSignature = uniqid('sig_', true) . '.png';
$newSignature = preg_replace('/[^A-Za-z0-9._-]/', '_', $newSignature);
if (file_put_contents($uploadDir . $newSignature, $binary) === false) {
mnt_json_fail('Impossibile salvare la firma.');
}
if ($signaturePath) {
mnt_delete_stored_file((string)$signaturePath);
}
$signaturePath = $newSignature;
}
$values = [
'performed_at' => $performedAt,
'status' => $status,
'result' => $result ?: null,
'notes' => $notes ?: null,
'materials' => $materials ?: null,
'operator_employee_id' => $operatorId ?: null,
'operator_name' => $operatorName ?: null,
'supplier_id' => $supplierId ?: null,
'signature_path' => $signaturePath,
];
if ($id > 0) {
$assignments = [];
foreach (array_keys($values) as $column) {
$assignments[] = "$column = :$column";
}
$stmt = $pdo->prepare("UPDATE maint_interventions SET " . implode(', ', $assignments) . " WHERE id = :id");
$stmt->execute($values + ['id' => $id]);
$interventionId = $id;
$action = 'intervention_updated';
} else {
$values['maintenance_id'] = $maintenanceId;
$values['equipment_id'] = $equipmentId;
$values['created_by'] = $currentUserId;
$columns = array_keys($values);
$placeholders = array_map(fn($column) => ':' . $column, $columns);
$stmt = $pdo->prepare(
"INSERT INTO maint_interventions (" . implode(', ', $columns) . ")
VALUES (" . implode(', ', $placeholders) . ")"
);
$stmt->execute($values);
$interventionId = (int)$pdo->lastInsertId();
$action = 'intervention_created';
}
// Only completed interventions move the next due date; this call handles it
mnt_recalc_maintenance($pdo, $maintenanceId);
mnt_log($pdo, $action, $equipmentId, $maintenanceId, $interventionId, $currentUserId, $values);
$stmt = $pdo->prepare("SELECT last_done_date, next_due_date FROM maint_maintenances WHERE id = ?");
$stmt->execute([$maintenanceId]);
$schedule = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
mnt_json_ok(['id' => $interventionId, 'schedule' => $schedule]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,121 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$equipmentId = (int)($_POST['equipment_id'] ?? 0);
$code = trim((string)($_POST['code'] ?? ''));
$title = trim((string)($_POST['title'] ?? ''));
$description = trim((string)($_POST['description'] ?? ''));
$interventionType = (string)($_POST['intervention_type'] ?? 'scheduled');
$executionType = (string)($_POST['execution_type'] ?? 'internal');
$isCritical = !empty($_POST['is_critical']) ? 1 : 0;
$frequencyUnit = (string)($_POST['frequency_unit'] ?? 'month');
$frequencyValue = (int)($_POST['frequency_value'] ?? 0);
$frequencyNote = trim((string)($_POST['frequency_note'] ?? ''));
$alertDays = (int)($_POST['alert_days'] ?? 7);
$assigneeId = (int)($_POST['assignee_employee_id'] ?? 0);
$supervisorId = (int)($_POST['supervisor_employee_id'] ?? 0);
$supplierId = (int)($_POST['supplier_id'] ?? 0);
$isActive = isset($_POST['is_active']) ? (int)!empty($_POST['is_active']) : 1;
$notes = trim((string)($_POST['notes'] ?? ''));
if ($title === '') {
mnt_json_fail('Il titolo è obbligatorio.');
}
if (!array_key_exists($interventionType, mnt_intervention_types())) {
mnt_json_fail('Tipo di intervento non valido.');
}
if (!array_key_exists($executionType, mnt_execution_types())) {
mnt_json_fail('Tipo di esecuzione non valido.');
}
if (!array_key_exists($frequencyUnit, mnt_frequency_units())) {
mnt_json_fail('Unità di frequenza non valida.');
}
if ($frequencyUnit !== 'on_demand' && $frequencyValue <= 0) {
mnt_json_fail('Indica ogni quanto va eseguita la manutenzione, oppure scegli "Al bisogno".');
}
if ($executionType === 'external' && $supplierId <= 0) {
mnt_json_fail('Per una manutenzione esterna indica il fornitore.');
}
if ($frequencyUnit === 'on_demand') {
$frequencyValue = null;
}
$values = [
'code' => $code ?: null,
'title' => $title,
'description' => $description ?: null,
'intervention_type' => $interventionType,
'execution_type' => $executionType,
'is_critical' => $isCritical,
'frequency_value' => $frequencyValue,
'frequency_unit' => $frequencyUnit,
'frequency_note' => $frequencyNote ?: null,
'alert_days' => max(0, $alertDays),
'assignee_employee_id' => $assigneeId ?: null,
'supervisor_employee_id' => $supervisorId ?: null,
'supplier_id' => $supplierId ?: null,
'is_active' => $isActive,
'notes' => $notes ?: null,
];
if ($id > 0) {
$stmt = $pdo->prepare("SELECT equipment_id FROM maint_maintenances WHERE id = ?");
$stmt->execute([$id]);
$equipmentId = (int)$stmt->fetchColumn();
if (!$equipmentId) {
mnt_json_fail('Manutenzione non trovata.');
}
$assignments = [];
foreach (array_keys($values) as $column) {
$assignments[] = "$column = :$column";
}
$stmt = $pdo->prepare("UPDATE maint_maintenances SET " . implode(', ', $assignments) . " WHERE id = :id");
$stmt->execute($values + ['id' => $id]);
// frequency may have changed — the due date must follow
mnt_recalc_maintenance($pdo, $id);
mnt_log($pdo, 'maintenance_updated', $equipmentId, $id, null, $currentUserId, $values);
mnt_json_ok(['id' => $id]);
}
if ($equipmentId <= 0) {
mnt_json_fail('Attrezzatura non valida.');
}
$stmt = $pdo->prepare("SELECT id FROM inv_equipment WHERE id = ?");
$stmt->execute([$equipmentId]);
if (!$stmt->fetchColumn()) {
mnt_json_fail('Attrezzatura non trovata.');
}
$values['equipment_id'] = $equipmentId;
$values['created_by'] = $currentUserId;
$columns = array_keys($values);
$placeholders = array_map(fn($column) => ':' . $column, $columns);
$stmt = $pdo->prepare(
"INSERT INTO maint_maintenances (" . implode(', ', $columns) . ")
VALUES (" . implode(', ', $placeholders) . ")"
);
$stmt->execute($values);
$newId = (int)$pdo->lastInsertId();
mnt_log($pdo, 'maintenance_created', $equipmentId, $newId, null, $currentUserId, $values);
mnt_json_ok(['id' => $newId]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,35 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$fileId = (int)($_POST['file_id'] ?? 0);
if ($fileId <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT equipment_id, kind FROM inv_equipment_files WHERE id = ?");
$stmt->execute([$fileId]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
mnt_json_fail('Immagine non trovata.');
}
if ($file['kind'] !== 'photo') {
mnt_json_fail('Solo una foto può essere usata come copertina.');
}
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = ? WHERE id = ?")
->execute([$fileId, (int)$file['equipment_id']]);
mnt_log($pdo, 'cover_changed', (int)$file['equipment_id'], null, null, $currentUserId);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,105 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
/**
* Multi-file upload for the three attachment tables.
* scope = equipment | maintenance | intervention
* kind = file | photo (equipment only; photos feed the gallery)
*/
try {
$pdo = mnt_pdo();
$scope = (string)($_POST['scope'] ?? 'equipment');
$ownerId = (int)($_POST['owner_id'] ?? 0);
$kind = ($_POST['kind'] ?? 'file') === 'photo' ? 'photo' : 'file';
$config = [
'equipment' => ['table' => 'inv_equipment_files', 'owner' => 'equipment_id', 'parent' => 'inv_equipment'],
'maintenance' => ['table' => 'maint_maintenance_files', 'owner' => 'maintenance_id', 'parent' => 'maint_maintenances'],
'intervention' => ['table' => 'maint_intervention_files', 'owner' => 'intervention_id', 'parent' => 'maint_interventions'],
];
if (!isset($config[$scope])) {
mnt_json_fail('Scope non valido.');
}
if ($ownerId <= 0) {
mnt_json_fail('ID non valido.');
}
if (empty($_FILES['files']['name'][0])) {
mnt_json_fail('Nessun file selezionato.');
}
$target = $config[$scope];
$stmt = $pdo->prepare("SELECT id FROM {$target['parent']} WHERE id = ?");
$stmt->execute([$ownerId]);
if (!$stmt->fetchColumn()) {
mnt_json_fail('Record collegato non trovato.');
}
$columns = $scope === 'equipment'
? "({$target['owner']}, kind, original_name, stored_name, mime_type, size, uploaded_by)"
: "({$target['owner']}, original_name, stored_name, mime_type, size, uploaded_by)";
$placeholders = $scope === 'equipment' ? '(?, ?, ?, ?, ?, ?, ?)' : '(?, ?, ?, ?, ?, ?)';
$insert = $pdo->prepare("INSERT INTO {$target['table']} $columns VALUES $placeholders");
$uploaded = [];
$errors = [];
$fileCount = count($_FILES['files']['name']);
for ($i = 0; $i < $fileCount; $i++) {
$file = [
'name' => $_FILES['files']['name'][$i],
'type' => $_FILES['files']['type'][$i],
'tmp_name' => $_FILES['files']['tmp_name'][$i],
'error' => $_FILES['files']['error'][$i],
'size' => $_FILES['files']['size'][$i],
];
try {
$stored = mnt_store_upload($file, $scope);
} catch (RuntimeException $e) {
$errors[] = $file['name'] . ': ' . $e->getMessage();
continue;
}
// A file uploaded as a photo but not actually an image is kept as a file
$rowKind = ($kind === 'photo' && $stored['is_image']) ? 'photo' : 'file';
$values = $scope === 'equipment'
? [$ownerId, $rowKind, $stored['original_name'], $stored['stored_name'], $stored['mime_type'], $stored['size'], $currentUserId]
: [$ownerId, $stored['original_name'], $stored['stored_name'], $stored['mime_type'], $stored['size'], $currentUserId];
$insert->execute($values);
$uploaded[] = ['id' => (int)$pdo->lastInsertId(), 'name' => $stored['original_name']];
}
if (!$uploaded) {
mnt_json_fail($errors ? implode('; ', $errors) : 'Nessun file caricato.');
}
// The first photo of an item becomes its cover automatically
if ($scope === 'equipment' && $kind === 'photo') {
$stmt = $pdo->prepare("SELECT cover_file_id FROM inv_equipment WHERE id = ?");
$stmt->execute([$ownerId]);
if (!$stmt->fetchColumn()) {
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = ? WHERE id = ?")
->execute([$uploaded[0]['id'], $ownerId]);
}
}
$equipmentId = $scope === 'equipment' ? $ownerId : null;
$maintenanceId = $scope === 'maintenance' ? $ownerId : null;
$interventionId = $scope === 'intervention' ? $ownerId : null;
mnt_log($pdo, 'file_added', $equipmentId, $maintenanceId, $interventionId, $currentUserId, null,
implode(', ', array_column($uploaded, 'name')));
mnt_json_ok(['uploaded' => $uploaded, 'errors' => $errors]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,6 @@
/*!
FullCalendar Core v6.1.9
Docs & License: https://fullcalendar.io
(c) 2023 Adam Shaw
*/
!function(e){"use strict";var t={code:"it",week:{dow:1,doy:4},buttonText:{prev:"Prec",next:"Succ",today:"Oggi",year:"Anno",month:"Mese",week:"Settimana",day:"Giorno",list:"Agenda"},weekText:"Sm",allDayText:"Tutto il giorno",moreLinkText:e=>"+altri "+e,noEventsText:"Non ci sono eventi da visualizzare"};FullCalendar.globalLocales.push(t)}();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
*
!.gitignore
!.htaccess
@@ -0,0 +1 @@
Deny from all
+120
View File
@@ -0,0 +1,120 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
$pdo = mnt_pdo();
if (!userCan('maintenance.calendar.view')) {
http_response_code(403);
exit('Permesso negato.');
}
$formData = mnt_form_data($pdo);
$MNT_TITLE = 'Calendario manutenzioni';
$MNT_PLUGINS = ['fullcalendar'];
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/include/page_head.php'; ?>
<style>
#mntCalendar { --fc-border-color: #e6eef0; }
#mntCalendar .fc-toolbar-title { font-size: 1.05rem; font-weight: 700; color: var(--mnt-heading); }
#mntCalendar .fc-event { cursor: pointer; font-size: 0.75rem; }
@media (max-width: 767.98px) {
#mntCalendar .fc-toolbar { flex-direction: column; gap: 0.5rem; }
}
</style>
</head>
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<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-calendar-days me-2"></i>Calendario manutenzioni</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/calendar_global.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-layer-group"></i><span>Calendario cumulativo</span>
</a>
<a href="manutenzioni/maintenances.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-list"></i><span>Elenco</span>
</a>
</div>
</div>
<div class="card-body">
<div class="mnt-filter-bar">
<select class="form-select" id="filterType">
<option value="">Programmate e straordinarie</option>
<?php foreach (mnt_intervention_types() as $value => $label): ?>
<option value="<?= mnt_h($value) ?>">Solo <?= mnt_h(mb_strtolower($label)) ?></option>
<?php endforeach; ?>
</select>
<select class="form-select" id="filterCategory">
<option value="">Tutte le categorie</option>
<?php foreach ($formData['categories'] as $category): ?>
<option value="<?= (int)$category['id'] ?>"><?= mnt_h($category['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="d-flex gap-3 flex-wrap mb-3 small">
<span><span class="mnt-badge mnt-badge-overdue">Scaduta</span></span>
<span><span class="mnt-badge mnt-badge-soon">In scadenza</span></span>
<span><span class="mnt-badge mnt-badge-ok">In regola (colore della categoria)</span></span>
</div>
<div id="mntCalendar"></div>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('mntCalendar');
const isMobile = window.innerWidth < 768;
const calendar = new FullCalendar.Calendar(el, {
locale: 'it',
initialView: isMobile ? 'listMonth' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: isMobile ? 'listMonth,dayGridMonth' : 'dayGridMonth,listMonth'
},
height: 'auto',
firstDay: 1,
events: function (info, success, failure) {
$.getJSON('manutenzioni/ajax/get_calendar_events.php', {
start: info.startStr,
end: info.endStr,
type: $('#filterType').val(),
category: $('#filterCategory').val()
}).done(success).fail(failure);
},
eventClick: function (info) {
window.location = 'manutenzioni/equipment.php?id=' + info.event.extendedProps.equipmentId;
}
});
calendar.render();
$('#filterType, #filterCategory').on('change', () => calendar.refetchEvents());
});
</script>
</body>
</html>
@@ -0,0 +1,127 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
// Cumulative calendar: deadlines + trainings + maintenances.
// Visible to anyone who can see at least one of the three modules; each
// layer is filtered again on the backend by its own permission.
$layers = [
'deadlines' => ['label' => 'Scadenze', 'permission' => 'deadlines.view', 'color' => '#6f42c1', 'icon' => '📅'],
'trainings' => ['label' => 'Formazione', 'permission' => 'hr.trainings.view', 'color' => '#198754', 'icon' => '🎓'],
'maintenances' => ['label' => 'Manutenzioni', 'permission' => 'maintenance.calendar.view', 'color' => '#2f7d8f', 'icon' => '🔧'],
];
$availableLayers = array_filter($layers, fn($layer) => userCan($layer['permission']));
if (!$availableLayers) {
http_response_code(403);
exit('Permesso negato.');
}
$MNT_TITLE = 'Calendario cumulativo';
$MNT_PLUGINS = ['fullcalendar'];
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/include/page_head.php'; ?>
<style>
#mntGlobalCalendar { --fc-border-color: #e6eef0; }
#mntGlobalCalendar .fc-toolbar-title { font-size: 1.05rem; font-weight: 700; color: var(--mnt-heading); }
#mntGlobalCalendar .fc-event { cursor: pointer; font-size: 0.75rem; }
.mnt-layer-toggle { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.35rem 0.75rem; border-radius: 999px; border: 1.5px solid var(--layer-color); color: var(--layer-color); font-weight: 600; font-size: 0.8rem; cursor: pointer; user-select: none; transition: all 0.15s; }
.mnt-layer-toggle input { display: none; }
.mnt-layer-toggle.is-on { background: var(--layer-color); color: #fff; }
@media (max-width: 767.98px) {
#mntGlobalCalendar .fc-toolbar { flex-direction: column; gap: 0.5rem; }
}
</style>
</head>
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<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-layer-group me-2"></i>Calendario cumulativo</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<?php if (userCan('maintenance.calendar.view')): ?>
<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>Solo manutenzioni</span>
</a>
<?php endif; ?>
</div>
</div>
<div class="card-body">
<div class="d-flex gap-2 flex-wrap mb-3">
<?php foreach ($availableLayers as $key => $layer): ?>
<label class="mnt-layer-toggle is-on" style="--layer-color: <?= mnt_h($layer['color']) ?>">
<input type="checkbox" class="layer-checkbox" value="<?= mnt_h($key) ?>" checked>
<span><?= $layer['icon'] ?> <?= mnt_h($layer['label']) ?></span>
</label>
<?php endforeach; ?>
</div>
<div id="mntGlobalCalendar"></div>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('mntGlobalCalendar');
const isMobile = window.innerWidth < 768;
function activeLayers() {
return $('.layer-checkbox:checked').map((i, cb) => cb.value).get().join(',');
}
const calendar = new FullCalendar.Calendar(el, {
locale: 'it',
initialView: isMobile ? 'listMonth' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: isMobile ? 'listMonth,dayGridMonth' : 'dayGridMonth,listMonth'
},
height: 'auto',
firstDay: 1,
events: function (info, success, failure) {
const layers = activeLayers();
if (!layers) { success([]); return; }
$.getJSON('manutenzioni/ajax/get_global_calendar_events.php', {
start: info.startStr,
end: info.endStr,
layers: layers
}).done(success).fail(failure);
},
eventClick: function (info) {
const url = info.event.extendedProps.url;
if (url) { window.location = url; }
}
});
calendar.render();
$('.layer-checkbox').on('change', function () {
$(this).closest('.mnt-layer-toggle').toggleClass('is-on', this.checked);
calendar.refetchEvents();
});
});
</script>
</body>
</html>

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