Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f01180a2d | ||
|
|
60a4d427aa | ||
|
|
56c24f18d1 | ||
|
|
797801b749 | ||
|
|
edafcb357f | ||
|
|
eebfee4092 | ||
|
|
574ffbe6bb | ||
|
|
b8a8e5ad11 | ||
|
|
2d4ee95ab1 | ||
|
|
bfe88658d2 | ||
|
|
a330c8bb29 | ||
|
|
10edd175db | ||
|
|
6fe3957099 | ||
|
|
adc4ee2695 | ||
|
|
12b7de0ac1 | ||
|
|
ccfe34637d | ||
|
|
e042a156c8 | ||
|
|
8502295342 | ||
|
|
a7c224fa10 | ||
|
|
53644fb6cf | ||
|
|
2f0f1135ac | ||
|
|
0b9d5f5b40 | ||
|
|
2bac5d5b86 | ||
|
|
cdcff42677 | ||
|
|
1193785151 | ||
|
|
60a3bcabfa | ||
|
|
a9191b5e44 | ||
|
|
341894cd22 | ||
|
|
128e229578 | ||
|
|
1c793384ff | ||
|
|
98f4f004ed | ||
|
|
fd3580a836 | ||
|
|
9d6c4ec3fb | ||
|
|
381befd1d8 | ||
|
|
d5094bff01 | ||
|
|
a499e7acbd | ||
|
|
8d1f7cb35b | ||
|
|
c14cc4a354 | ||
|
|
570094ab21 | ||
|
|
c4a3e48dd2 | ||
|
|
33c81e2ad1 | ||
|
|
d6d424cae8 | ||
|
|
5444ea943a | ||
|
|
76ed4d11ca | ||
|
|
3329550b19 | ||
|
|
89e5388b75 | ||
|
|
b6145e0553 | ||
|
|
3f5c14836f | ||
|
|
3209ac969f | ||
|
|
a5c2f4f74c | ||
|
|
810baa9244 | ||
|
|
4a6a493ef2 | ||
|
|
f649bc0bd5 | ||
|
|
ac23738037 | ||
|
|
ad4bac5874 | ||
|
|
e17a14d58e | ||
|
|
5d4992f36f | ||
|
|
b52e7191cf | ||
|
|
abd09769f2 | ||
|
|
c3234e3160 | ||
|
|
ab7dd0f2f9 | ||
|
|
5e2ec11dbd | ||
|
|
b2a2f56383 | ||
|
|
22114963db | ||
|
|
9525ba528f | ||
|
|
b031dc6401 | ||
|
|
da4a66e5a9 | ||
|
|
c96717b051 | ||
|
|
bb3d1b1aa1 | ||
|
|
b7ed6c64ed | ||
|
|
c2212f7022 | ||
|
|
63674ea6be | ||
|
|
328772f8da | ||
|
|
06092e24aa | ||
|
|
cc24202937 | ||
|
|
4241d8e62b | ||
|
|
da5c4b8777 | ||
|
|
c394d92ef3 | ||
|
|
8425a7d90c | ||
|
|
fc34f837d4 | ||
|
|
f93dfe6f66 | ||
|
|
273a092562 | ||
|
|
feb0c51a91 | ||
|
|
5a4a39fb3c | ||
|
|
5f2137d8ec | ||
|
|
1dbb93b139 | ||
|
|
55d83c12b0 |
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Seed\AbstractSeed;
|
||||
|
||||
final class PauseReasonsPlannedSeeder extends AbstractSeed
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$data = [
|
||||
['name' => 'Cambio turno', 'is_problem' => 0, 'category' => 'planned'],
|
||||
['name' => 'Aggiunta / cambio mescola', 'is_problem' => 0, 'category' => 'planned'],
|
||||
['name' => 'Cambio produzione', 'is_problem' => 0, 'category' => 'planned'],
|
||||
['name' => 'Attrezzaggio / cambio stampo', 'is_problem' => 0, 'category' => 'planned'],
|
||||
['name' => 'Pulizia impianto', 'is_problem' => 0, 'category' => 'planned'],
|
||||
['name' => 'Manutenzione programmata', 'is_problem' => 0, 'category' => 'planned'],
|
||||
];
|
||||
|
||||
$this->table('pause_reasons')->insert($data)->saveData();
|
||||
}
|
||||
}
|
||||
@@ -1,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.
|
||||
|
||||
---
|
||||
|
||||
## Чего в модуле нет намеренно
|
||||
|
||||
- Калибровка/поверка приборов — вынесена за скоуп самим клиентом. Структура данных её примет отдельной таблицей без переделок.
|
||||
- Импорт истории выполненных работ из старых карточек — журналы в присланных файлах пустые.
|
||||
@@ -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 # только пункты ТЗ
|
||||
```
|
||||
@@ -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,6 +1,6 @@
|
||||
<?php
|
||||
// general terms
|
||||
$titlewebsite = "TRF Smart";
|
||||
$titlewebsite = "SmartMes";
|
||||
|
||||
$accountsettings = "Account Settings";
|
||||
$userprofile = "User Profile";
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
@@ -8,16 +8,57 @@
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 =
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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'])) : ''
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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'; ?>
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"> </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
|
||||
@@ -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
Reference in New Issue
Block a user