247 lines
10 KiB
PHP
247 lines
10 KiB
PHP
<?php
|
||
// modules/meals/includes/api/import-pdf.php
|
||
require dirname(__DIR__, 4) . '/includes/auth.php';
|
||
require dirname(__DIR__, 4) . '/includes/db.php';
|
||
require dirname(__DIR__, 4) . '/vendor/autoload.php';
|
||
require_login();
|
||
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['pdf_file'])) {
|
||
echo json_encode(['success' => false, 'error' => 'Aucun fichier reçu.']);
|
||
exit;
|
||
}
|
||
|
||
$personId = (int)$_POST['person_id'];
|
||
if ($personId <= 0) {
|
||
echo json_encode(['success' => false, 'error' => 'Enfant non spécifié.']);
|
||
exit;
|
||
}
|
||
|
||
// ---------------------------------------------------------
|
||
// PARSEUR PDF SPATIAL (via smalot/pdfparser pour un décodage
|
||
// fiable des polices Type0/CID + tables ToUnicode)
|
||
// ---------------------------------------------------------
|
||
class NativePdfParser {
|
||
// strtoupper() seul ne gère pas les accents (ex: "Déclinaison" -> "DCLINAISON"
|
||
// au lieu de "DECLINAISON"), ce qui empêchait ce label d'être reconnu.
|
||
private static function cleanLabel($text) {
|
||
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
|
||
return strtoupper(preg_replace('/[^A-Z]/i', '', $ascii ?: $text));
|
||
}
|
||
|
||
public static function getMealData($filename) {
|
||
$texts = [];
|
||
|
||
$parser = new \Smalot\PdfParser\Parser();
|
||
$pdf = $parser->parseFile($filename);
|
||
|
||
foreach ($pdf->getPages() as $page) {
|
||
foreach ($page->getDataTm() as $entry) {
|
||
[$tm, $rawText] = $entry;
|
||
$textVal = trim((string) $rawText);
|
||
if ($textVal === '') continue;
|
||
self::addText($texts, $textVal, (float) $tm[4], (float) $tm[5]);
|
||
}
|
||
}
|
||
|
||
if (empty($texts)) throw new Exception("Impossible de lire le contenu du PDF. Le fichier est peut-être crypté ou corrompu.");
|
||
|
||
// 2. Définition des Colonnes Verticles (X) via les En-têtes de Jour
|
||
$dayLabels = ['LUNDI', 'MARDI', 'MERCREDI', 'JEUDI', 'VENDREDI'];
|
||
$colXs = [];
|
||
foreach ($texts as $t) {
|
||
$clean = self::cleanLabel($t['text']);
|
||
if (in_array($clean, $dayLabels)) {
|
||
$colXs[$clean] = $t['x'];
|
||
}
|
||
}
|
||
|
||
// Sécurité si les jours exacts ne sont pas détectés à cause de la police
|
||
if (count($colXs) < 5) {
|
||
$allXs = array_column($texts, 'x');
|
||
sort($allXs);
|
||
$minX = $allXs[0] ?? 0;
|
||
$maxX = end($allXs) ?? 500;
|
||
$step = ($maxX - $minX) / 5;
|
||
$colXs = [
|
||
'LUNDI' => $minX + $step * 0.5,
|
||
'MARDI' => $minX + $step * 1.5,
|
||
'MERCREDI' => $minX + $step * 2.5,
|
||
'JEUDI' => $minX + $step * 3.5,
|
||
'VENDREDI' => $minX + $step * 4.5,
|
||
];
|
||
}
|
||
$sortedXs = array_values($colXs);
|
||
sort($sortedXs);
|
||
|
||
// 3. Définition des Tranches Horizontales (Y) via les Titres de Rangée (Zone Extrême Gauche)
|
||
$rowHeaders = [];
|
||
foreach ($texts as $t) {
|
||
$clean = self::cleanLabel($t['text']);
|
||
if (in_array($clean, ['HORSDEUVRE', 'DECLINAISON', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'DESSERT', 'LESGOUTERS'])) {
|
||
// On s'assure de ne capter que la colonne tout à gauche
|
||
if ($t['x'] < ($sortedXs[0] - 20)) {
|
||
$rowHeaders[] = ['label' => $clean, 'y' => $t['y']];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!in_array('PLATCHAUD', array_column($rowHeaders, 'label')) || !in_array('GARNITURE', array_column($rowHeaders, 'label'))) {
|
||
throw new Exception("Structure du menu non reconnue (Mots-clés 'PLAT CHAUD' et 'GARNITURE' introuvables).");
|
||
}
|
||
|
||
// 4. Affectation Chirurgicale par Intersection Grille (X, Y)
|
||
$days = [[], [], [], [], []];
|
||
foreach ($texts as $t) {
|
||
$cleanAlpha = self::cleanLabel($t['text']);
|
||
|
||
// Rejet des labels génériques et de la fameuse ligne "Déclinaison"
|
||
if (in_array($cleanAlpha, ['DECLINAISON', 'MENUVEGETARIEN', 'PLATCHAUD', 'GARNITURE', 'PRODUITLAITIER', 'HORSDEUVRE', 'LESGOUTERS', 'DESSERT'])) continue;
|
||
|
||
// Retrait des labels BIO/HVE et mentions superflues
|
||
$textVal = trim(preg_replace('/\b(AB|HVE|MSC|Label Rouge)\b/i', '', $t['text']));
|
||
$textVal = str_replace(['(plat', 'complet)'], '', $textVal);
|
||
$textVal = trim(preg_replace('/[^a-zA-ZÀ-ÿŒœÆæ0-9\s&\-]/u', '', $textVal));
|
||
|
||
if (strlen($textVal) < 3) continue;
|
||
|
||
// Filtre Vertical : de quelle rangée (en-tête) ce texte est-il le plus proche ?
|
||
// Le texte d'une rangée déborde souvent au-dessus de son propre label (lignes
|
||
// empilées vers le haut) sur une hauteur variable d'un menu à l'autre : une
|
||
// fenêtre à tolérance fixe est donc peu fiable. Le plus proche voisin (même
|
||
// principe que pour les colonnes de jour ci-dessous) s'adapte automatiquement.
|
||
$bestRow = null;
|
||
$minRowDiff = PHP_FLOAT_MAX;
|
||
foreach ($rowHeaders as $rh) {
|
||
$diff = abs($t['y'] - $rh['y']);
|
||
if ($diff < $minRowDiff) {
|
||
$minRowDiff = $diff;
|
||
$bestRow = $rh['label'];
|
||
}
|
||
}
|
||
|
||
if ($bestRow === 'PLATCHAUD' || $bestRow === 'GARNITURE') {
|
||
// Filtre Horizontal : Quel est le jour le plus proche ?
|
||
$bestCol = 0;
|
||
$minDiff = PHP_FLOAT_MAX;
|
||
foreach ($sortedXs as $idx => $cx) {
|
||
$diff = abs($t['x'] - $cx);
|
||
if ($diff < $minDiff) {
|
||
$minDiff = $diff;
|
||
$bestCol = $idx;
|
||
}
|
||
}
|
||
$days[$bestCol][] = ['text' => $textVal, 'y' => $t['y'], 'type' => $bestRow === 'PLATCHAUD' ? 'plat' : 'gar'];
|
||
}
|
||
}
|
||
|
||
// 5. Concaténation Finale
|
||
$finalDays = [];
|
||
for ($i = 0; $i < 5; $i++) {
|
||
$plats = array_filter($days[$i], fn($item) => $item['type'] === 'plat');
|
||
$gars = array_filter($days[$i], fn($item) => $item['type'] === 'gar');
|
||
|
||
usort($plats, fn($a, $b) => $b['y'] <=> $a['y']);
|
||
usort($gars, fn($a, $b) => $b['y'] <=> $a['y']);
|
||
|
||
$platText = self::concatLines($plats);
|
||
$garText = self::concatLines($gars);
|
||
|
||
$full = trim("$platText + $garText", " +");
|
||
$finalDays[] = preg_replace('/\s+/', ' ', $full);
|
||
}
|
||
|
||
return $finalDays;
|
||
}
|
||
|
||
private static function concatLines($items) {
|
||
$lines = [];
|
||
$currentLine = "";
|
||
$lastY = null;
|
||
foreach ($items as $item) {
|
||
// Si le décalage Y est trop fort, on considère que c'est une nouvelle ligne
|
||
if ($lastY !== null && abs($lastY - $item['y']) > 8) {
|
||
if ($currentLine) $lines[] = trim($currentLine);
|
||
$currentLine = $item['text'];
|
||
} else {
|
||
$currentLine .= " " . $item['text'];
|
||
}
|
||
$lastY = $item['y'];
|
||
}
|
||
if ($currentLine) $lines[] = trim($currentLine);
|
||
return implode(' ', array_filter($lines, fn($l) => strlen(trim($l)) > 2));
|
||
}
|
||
|
||
private static function addText(&$texts, $rawText, $x, $y) {
|
||
// smalot/pdfparser décode déjà correctement les polices Type0/CID + ToUnicode :
|
||
// il ne reste plus qu'à nettoyer les caractères parasites résiduels.
|
||
$m = str_replace("\0", "", $rawText);
|
||
$m = trim(preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\+&\'’\-]/u', '', $m));
|
||
|
||
if ($m !== '') {
|
||
$texts[] = ['text' => $m, 'x' => $x, 'y' => $y];
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
$filename = $_FILES['pdf_file']['name'];
|
||
|
||
// 1. Extraction robuste de la date depuis le nom de fichier (Menus-du-14-au-20-septembre-2026.pdf)
|
||
$mondayDate = null;
|
||
if (preg_match('/(?:du[\s-]+)?(\d{1,2})[\s-]+(?:([a-zèéû]+)[\s-]+)?au[\s-]+\d{1,2}[\s-]+([a-zèéû]+)[\s-]+(\d{4})/i', $filename, $m)) {
|
||
$monthsMap = ['janvier'=>'01','fevrier'=>'02','février'=>'02','mars'=>'03','avril'=>'04','mai'=>'05','juin'=>'06','juillet'=>'07','aout'=>'08','août'=>'08','septembre'=>'09','octobre'=>'10','novembre'=>'11','decembre'=>'12','décembre'=>'12'];
|
||
|
||
$startMonthRaw = !empty($m[2]) ? strtolower($m[2]) : strtolower($m[3]);
|
||
$endMonthRaw = strtolower($m[3]);
|
||
|
||
$startMonthNum = $monthsMap[$startMonthRaw] ?? '09';
|
||
$endMonthNum = $monthsMap[$endMonthRaw] ?? '09';
|
||
|
||
$startYear = (int)$m[4];
|
||
// Règle métier : Bascule d'année (ex: 28 décembre au 3 janvier 2026 -> 2025-12-28)
|
||
if ($startMonthNum === '12' && $endMonthNum === '01') {
|
||
$startYear--;
|
||
}
|
||
|
||
$mondayDate = $startYear . '-' . $startMonthNum . '-' . str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||
}
|
||
|
||
if (!$mondayDate) {
|
||
echo json_encode(['success' => false, 'error' => tr('meal_err_filename')]);
|
||
exit;
|
||
}
|
||
|
||
// 2. Lancement de l'Analyse Spatiale
|
||
$plats = NativePdfParser::getMealData($_FILES['pdf_file']['tmp_name']);
|
||
|
||
if (empty($plats) || count($plats) !== 5) {
|
||
echo json_encode(['success' => false, 'error' => 'Erreur lors du découpage des 5 colonnes.']);
|
||
exit;
|
||
}
|
||
|
||
// 3. Insertion en BDD (Lundi au Vendredi)
|
||
$pdo->beginTransaction();
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO pf_meals_plan (plan_date, service, person_id, meal_name)
|
||
VALUES (?, 'lunch', ?, ?)
|
||
ON DUPLICATE KEY UPDATE meal_name = VALUES(meal_name)
|
||
");
|
||
|
||
$currentDate = new DateTime($mondayDate);
|
||
for ($i = 0; $i < 5; $i++) {
|
||
$fullMeal = $plats[$i];
|
||
if (!empty($fullMeal) && $fullMeal !== '+') {
|
||
$stmt->execute([$currentDate->format('Y-m-d'), $personId, $fullMeal]);
|
||
}
|
||
$currentDate->modify('+1 day');
|
||
}
|
||
|
||
$pdo->commit();
|
||
echo json_encode(['success' => true]);
|
||
|
||
} catch (Exception $e) {
|
||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||
} |