espace-paie-odentas/app/api/contrats/[id]/generate-pdf/route.ts
odentas dd570d4509 feat: Améliorations majeures des contrats et fiches de paie
- Ajout détails cachets/répétitions/heures au modal ContractDetails
- Card verte avec validation quand tous les contrats ont une fiche de paie
- Système complet de création de fiches de paie avec recherche et vérification
- Modal liste des contrats sans paie avec création directe
- Amélioration édition dates dans PayslipDetailsModal
- Optimisation recherche contrats (ordre des filtres)
- Augmentation limite pagination ContractsGrid à 200
- Ajout logs debug génération PDF logo
- Script SQL vérification cohérence structure/organisation
2025-11-27 20:31:11 +01:00

579 lines
No EOL
22 KiB
TypeScript

// app/api/contrats/[id]/generate-pdf/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { PROFESSIONS_ARTISTE, ProfessionOption } from "@/components/constants/ProfessionsArtiste";
import { parseDateString } from "@/lib/dateFormatter";
import { promises as fs } from "fs";
import path from "path";
// Type pour les féminisations
type ProfessionFeminisation = {
profession_code: string;
profession_label: string;
profession_feminine: string;
};
// Fonction pour récupérer les professions techniciens
async function getTechniciensData(): Promise<ProfessionOption[]> {
try {
const filePath = path.join(process.cwd(), 'public/data/professions-techniciens.json');
const fileContent = await fs.readFile(filePath, 'utf8');
return JSON.parse(fileContent);
} catch (error) {
console.error("Erreur lors du chargement des professions techniciens:", error);
return [];
}
}
// Fonction pour récupérer les féminisations depuis Supabase
async function getFeminisations(): Promise<ProfessionFeminisation[]> {
try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
const { data: feminisations, error } = await supabase
.from('professions_feminisations')
.select('profession_code, profession_label, profession_feminine');
if (error) {
console.warn("Erreur lors du chargement des féminisations depuis Supabase:", error);
return [];
}
return feminisations || [];
} catch (error) {
console.warn("Erreur lors du chargement des féminisations:", error);
return [];
}
}
// Fonction pour trouver le code profession à partir du label
function findProfessionCode(professionLabel: string): string | null {
// Chercher dans les professions artistes
const artisteProfession = PROFESSIONS_ARTISTE.find(p =>
p.label.toLowerCase() === professionLabel.toLowerCase()
);
if (artisteProfession) {
return artisteProfession.code;
}
// Si pas trouvé, nous chercherons dans les techniciens (mais ce sera fait de manière asynchrone)
return null;
}
// Fonction pour obtenir la profession avec le bon genre
async function getProfessionWithGender(professionLabel: string, professionCode: string, civilite: string): Promise<string> {
// Si c'est un homme ou si pas de civilité spécifiée, utiliser la profession normale
if (!civilite || civilite.toLowerCase().includes('monsieur') || civilite.toLowerCase().includes('m.')) {
return professionLabel;
}
// Si c'est une femme, chercher la féminisation
if (civilite.toLowerCase().includes('madame') || civilite.toLowerCase().includes('mme')) {
try {
const feminisations = await getFeminisations();
const feminisation = feminisations.find(f => f.profession_code === professionCode);
if (feminisation && feminisation.profession_feminine) {
console.log(`Utilisation de la profession féminine: "${feminisation.profession_feminine}" pour "${professionLabel}"`);
return feminisation.profession_feminine;
}
} catch (error) {
console.warn("Erreur lors de la recherche de féminisation:", error);
}
}
// Par défaut, retourner la profession originale
return professionLabel;
}
// Fonction de formatage d'une date en "DD/MM/YYYY"
function formatDate(dateStr: string | null): string {
if (!dateStr) return "";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
const dd = String(date.getDate()).padStart(2, '0');
const mm = String(date.getMonth() + 1).padStart(2, '0');
const yyyy = date.getFullYear();
return `${dd}/${mm}/${yyyy}`;
}
// Fonction de polling pour vérifier le statut du document PDFMonkey
async function pollDocumentStatus(documentId: string, pdfMonkeyUrl: string, pdfMonkeyApiKey: string) {
const url = `${pdfMonkeyUrl}/${documentId}`;
let attempts = 0;
const maxAttempts = 10;
let status = "pending";
while (status !== "success" && attempts < maxAttempts) {
console.log(`Polling PDFMonkey (tentative ${attempts + 1}/${maxAttempts})...`);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${pdfMonkeyApiKey}`
}
});
if (!response.ok) {
throw new Error(`Erreur PDFMonkey: ${response.status} ${response.statusText}`);
}
const data = await response.json();
status = data.document.status;
console.log("Statut du document:", status);
if (status === "success") {
return data.document;
}
await new Promise(resolve => setTimeout(resolve, 3000));
attempts++;
}
return { status, attempts };
}
// Fonction pour formater un texte de dates brutes en texte PDFMonkey formaté
function formatDateFieldIfNeeded(dateText: string | null | undefined, yearContext: string): string {
if (!dateText || !dateText.trim()) return "";
// Si contient déjà les marqueurs de formatage, c'est déjà du texte formaté
if (dateText.includes(" ; ") || dateText.includes(" le ") || dateText.includes(" du ")) {
return dateText;
}
// Sinon, parser et reformater
try {
const parsed = parseDateString(dateText, yearContext);
return parsed.pdfFormatted || "";
} catch (error) {
console.warn("Erreur lors du formatage des dates:", error);
return dateText; // Retourner le texte original en cas d'erreur
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const sb = createSbServer();
// Vérification de l'authentification et du staff
const { data: { user }, error: authError } = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "Authentification requise" },
{ status: 401 }
);
}
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staffUser?.is_staff) {
return NextResponse.json(
{ error: "Accès refusé - réservé au staff" },
{ status: 403 }
);
}
// Variables d'environnement
const pdfMonkeyUrl = process.env.PDFMONKEY_URL;
const pdfMonkeyApiKey = process.env.PDFMONKEY_API_KEY;
if (!pdfMonkeyUrl || !pdfMonkeyApiKey) {
return NextResponse.json(
{ error: "Configuration PDFMonkey manquante" },
{ status: 500 }
);
}
// Configuration S3
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
// Récupération du contrat
const { data: contract, error: contractError } = await sb
.from("cddu_contracts")
.select("*")
.eq("id", params.id)
.single();
if (contractError || !contract) {
return NextResponse.json(
{ error: "Contrat non trouvé" },
{ status: 404 }
);
}
console.log("Contrat trouvé:", {
id: contract.id,
org_id: contract.org_id,
contract_number: contract.contract_number
});
// Récupération des données de l'organisation
console.log("Recherche organization_details avec org_id:", contract.org_id);
const { data: orgDetails, error: orgError } = await sb
.from("organization_details")
.select("*")
.eq("org_id", contract.org_id)
.single();
console.log("Résultat organization_details:", {
orgDetails,
orgError,
logoField: orgDetails?.logo ? `${orgDetails.logo.substring(0, 50)}...` : 'NULL'
});
if (orgError || !orgDetails) {
return NextResponse.json(
{
error: "Détails de l'organisation non trouvés",
debug: {
org_id: contract.org_id,
orgError: orgError?.message,
orgDetails: orgDetails
}
},
{ status: 404 }
);
}
// Récupération du nom de la structure depuis la table organizations
console.log("Recherche organizations avec id:", contract.org_id);
const { data: organization } = await sb
.from("organizations")
.select("name")
.eq("id", contract.org_id)
.single();
console.log("Résultat organizations:", organization);
// Récupération des données du salarié
console.log("Recherche salarié avec id:", contract.employee_id);
const { data: salarie, error: salarieError } = await sb
.from("salaries")
.select("*")
.eq("id", contract.employee_id)
.single();
console.log("Résultat salaries:", { salarie, salarieError });
if (salarieError || !salarie) {
return NextResponse.json(
{
error: "Salarié non trouvé",
debug: {
employee_id: contract.employee_id,
salarieError: salarieError?.message
}
},
{ status: 404 }
);
}
// Récupération des données de production si disponible
const { data: production } = await sb
.from("productions")
.select("prod_type")
.eq("name", contract.production_name)
.maybeSingle();
// Construction du nom de fichier
const matricule = salarie.code_salarie || "000";
const filename = `contrat_cddu_${contract.contract_number}_${matricule}.pdf`;
// Extraction du code emploi et de la profession depuis le champ profession
const professionField = contract.profession || "";
let employee_codeprofession = "DEMO001"; // Code par défaut
let employee_profession = professionField;
// Essayer de trouver le code dans les professions artistes d'abord
const artisteCode = findProfessionCode(professionField);
if (artisteCode) {
employee_codeprofession = artisteCode;
// Utiliser la profession avec le bon genre selon la civilité
employee_profession = await getProfessionWithGender(professionField, artisteCode, salarie.civilite || "");
} else {
// Chercher dans les professions techniciens
try {
const techniciensData = await getTechniciensData();
const technicienProfession = techniciensData.find(p =>
p.label.toLowerCase() === professionField.toLowerCase()
);
if (technicienProfession) {
employee_codeprofession = technicienProfession.code;
// Utiliser la profession avec le bon genre selon la civilité
employee_profession = await getProfessionWithGender(professionField, technicienProfession.code, salarie.civilite || "");
} else {
console.warn(`Code profession non trouvé pour: "${professionField}". Utilisation du code par défaut.`);
}
} catch (error) {
console.error("Erreur lors de la recherche du code profession:", error);
}
}
console.log(`Profession: "${professionField}" -> Code: "${employee_codeprofession}" -> Genrée: "${employee_profession}"`);
console.log(`Civilité du salarié: "${salarie.civilite}"`);
// Déterminer la catégorie professionnelle à envoyer
// Pour "Metteur en scène", on envoie "Metteur en scène"
// Pour toutes les autres professions artistes, on envoie "Artiste"
// Pour les techniciens, on envoie "Technicien"
let employee_catpro = contract.categorie_pro || "";
if (contract.categorie_pro === "Artiste" && contract.profession === "Metteur en scène") {
employee_catpro = "Metteur en scène";
}
console.log(`Catégorie professionnelle pour PDFMonkey: "${employee_catpro}"`);
console.log(`Profession du contrat: "${contract.profession}"`);
console.log(`Metteur en scène détecté: ${contract.profession === "Metteur en scène"}`);
console.log(`Jours de travail (jours_travail): "${contract.jours_travail}"`);
console.log(`Jours de travail non-artiste (jours_travail_non_artiste): "${contract.jours_travail_non_artiste}"`);
// Construction du payload pour PDFMonkey selon le mapping CSV
const dataPayload = {
structure_name: organization?.name || orgDetails.structure || "",
structure_adresse: orgDetails.adresse || "",
structure_cpville: orgDetails.cp || "",
structure_ville: orgDetails.ville || "",
structure_siret: orgDetails.siret || "",
structure_licence: orgDetails.licence_spectacles || "",
structure_spectacle: orgDetails.structure_a_spectacles ? "Oui" : "Non",
forme_juridique: orgDetails.forme_juridique || "",
structure_signataire: `${orgDetails.prenom_signataire || ""} ${orgDetails.nom_signataire || ""}`.trim(),
structure_signatairequalite: orgDetails.qualite_signataire || "",
delegation: orgDetails.delegation_signature ? "Oui" : "Non",
employee_civ: salarie.civilite || "",
employee_firstname: salarie.prenom || "",
employee_lastname: salarie.nom || "",
employee_birthname: salarie.nom_de_naissance || "",
employee_pseudo: salarie.pseudonyme || "",
employee_dob: formatDate(salarie.date_naissance),
employee_cob: salarie.lieu_de_naissance || "",
employee_address: salarie.adresse || "",
employee_ss: salarie.nir || "",
employee_cs: salarie.conges_spectacles || "",
employee_profession: employee_profession,
employee_codeprofession: employee_codeprofession,
employee_catpro: employee_catpro,
mineur1618: contract.mineur_entre_16_et_18 || "",
representant_civ: contract.civilite_representant_legal || "",
representant_nom: contract.nom_representant_legal || "",
representant_dob: formatDate(contract.dob_representant_legal),
representant_cob: contract.cob_representant_legal || "",
representant_adresse: contract.adresse_representant_legal || "",
spectacle: contract.production_name || "",
numobjet: contract.objet_spectacle || "",
type_numobjet: production?.prod_type || "",
date_debut: formatDate(contract.start_date),
date_fin: formatDate(contract.end_date),
// Combiner toutes les dates travaillées : représentations, répétitions, jours de travail
// Format: "1 représentation le 12/10 ; 2 heures le 13/10 ; 1 service de répétition par jour du 14/10 au 16/10."
// Formater chaque source au besoin, puis les combiner
// Pour les metteurs en scène, utiliser jours_travail_non_artiste
dates_travaillees: (() => {
// Cas spécifiques
const isMetteurEnScene = contract.profession === "Metteur en scène";
const isTechnicien = (contract.categorie_pro || "").toLowerCase() === "technicien";
if (isMetteurEnScene) {
// Metteur en scène: déjà correct, utiliser jours_travail_non_artiste tel quel
return contract.jours_travail_non_artiste || "";
}
if (isTechnicien) {
// Technicien: utiliser jours_travail_non_artiste tel quel (ne rien changer pour artistes)
return contract.jours_travail_non_artiste || "";
}
// Artistes (autres cas): combiner les dates comme avant
const datesSources = [
formatDateFieldIfNeeded(contract.jours_representations, contract.start_date || new Date().toISOString().slice(0, 10)),
formatDateFieldIfNeeded(contract.jours_repetitions, contract.start_date || new Date().toISOString().slice(0, 10)),
formatDateFieldIfNeeded(contract.jours_travail, contract.start_date || new Date().toISOString().slice(0, 10))
];
return (
datesSources
.filter((s) => s.trim().length > 0)
.join(" ; ")
.replace(/ ; \./g, ".") // Éviter les doubles points
.replace(/\.{2}/, ".") // Éviter les double points
.replace(/; $/, ".") // Fin correcte
) || "";
})(),
salaire_brut: contract.gross_pay
? parseFloat(contract.gross_pay.toString()).toLocaleString('fr-FR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
: "",
date_signature: formatDate(contract.date_signature),
CCN: orgDetails.ccn || "",
// Toujours envoyer precisions_salaire depuis la colonne Supabase
precisions_salaire: contract.precisions_salaire || "",
autreprecision_duree: contract.autreprecision_duree || "",
autreprecision_salaire: contract.autreprecision_salaire || "",
panierrepas: contract.paniers_repas || "",
panierrepasccn: contract.panier_repas_ccn || "",
montantpanierrepas: contract.si_non_montant_par_panier || "",
cachets: {
representations: contract.cachets_representations ? parseInt(contract.cachets_representations) || 0 : 0,
repetitions: contract.services_repetitions ? parseInt(contract.services_repetitions) || 0 : 0,
heures: contract.nombre_d_heures ? parseFloat(contract.nombre_d_heures) || 0 : 0,
heuresparjour: contract.nombre_d_heures_par_jour ? parseFloat(contract.nombre_d_heures_par_jour) || 0 : 0
},
nom_responsable_traitement: orgDetails.nom_responsable_traitement || "",
qualite_responsable_traitement: orgDetails.qualite_responsable_traitement || "",
email_responsable_traitement: orgDetails.email_responsable_traitement || "",
imageUrl: orgDetails.logo || ""
};
// Log de débogage pour vérifier le logo
console.log("🖼️ [PDF Generation] Logo récupéré depuis DB:", {
hasLogo: !!orgDetails.logo,
logoLength: orgDetails.logo?.length || 0,
logoPrefix: orgDetails.logo?.substring(0, 30) || 'VIDE',
hasDataPrefix: orgDetails.logo?.startsWith('data:') || false
});
const pdfPayload = {
document_template_id: "736E1A5F-BBA1-4D3E-91ED-A6184479B58D",
payload: dataPayload,
status: "pending",
filename: filename
};
console.log("Payload envoyé à PDFMonkey:", JSON.stringify(pdfPayload, null, 2));
// Envoi du payload vers PDFMonkey
const pdfResponse = await fetch(pdfMonkeyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${pdfMonkeyApiKey}`
},
body: JSON.stringify(pdfPayload)
});
if (!pdfResponse.ok) {
const errorText = await pdfResponse.text();
console.error("Erreur PDFMonkey:", pdfResponse.status, errorText);
return NextResponse.json(
{ error: `Erreur PDFMonkey: ${pdfResponse.status}` },
{ status: 500 }
);
}
const pdfData = await pdfResponse.json();
console.log("Réponse initiale de PDFMonkey:", JSON.stringify(pdfData));
// Récupération de l'ID du document
const documentId = pdfData.document?.id;
if (!documentId) {
throw new Error("Aucun ID de document retourné par PDFMonkey");
}
console.log("ID du document PDFMonkey:", documentId);
// Polling pour vérifier le statut jusqu'à "success"
const finalDocumentData = await pollDocumentStatus(documentId, pdfMonkeyUrl, pdfMonkeyApiKey);
console.log("Document final:", JSON.stringify(finalDocumentData));
if (finalDocumentData.download_url) {
console.log("Téléchargement du PDF depuis:", finalDocumentData.download_url);
// Télécharger le PDF depuis PDFMonkey
const pdfResponse = await fetch(finalDocumentData.download_url);
if (!pdfResponse.ok) {
throw new Error("Impossible de télécharger le PDF depuis PDFMonkey");
}
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());
// Upload vers S3
const s3Key = `unsigned-contracts/${filename}`;
console.log("Upload du fichier dans S3 sous la clé:", s3Key);
const uploadCommand = new PutObjectCommand({
Bucket: (process.env.AWS_S3_BUCKET || "odentas-docs").trim(),
Key: s3Key,
Body: pdfBuffer,
ContentType: "application/pdf",
});
await s3Client.send(uploadCommand);
console.log("Fichier PDF uploadé sur S3 avec succès.");
// URL S3 pour accéder au fichier
const bucketName = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
const s3Url = `https://${bucketName}.s3.eu-west-3.amazonaws.com/${s3Key}`;
// Mettre à jour le contrat avec l'URL du PDF
console.log("Mise à jour du contrat avec:", {
contract_pdf_url: s3Url,
contract_pdf_filename: filename
});
const { error: updateError } = await sb
.from("cddu_contracts")
.update({
contract_pdf_url: s3Url,
contract_pdf_filename: filename
})
.eq("id", params.id);
if (updateError) {
console.error("Erreur lors de la mise à jour du contrat:", updateError);
} else {
console.log("Contrat mis à jour avec succès");
}
return NextResponse.json({
success: true,
message: "PDF créé et sauvegardé avec succès",
documentId: documentId,
filename: filename,
s3Url: s3Url,
pdfMonkeyResponse: finalDocumentData
});
} else {
return NextResponse.json(
{ error: "Aucun URL de téléchargement disponible" },
{ status: 500 }
);
}
} catch (error) {
console.error("Erreur lors de la génération du PDF:", error);
return NextResponse.json(
{
error: "Erreur interne du serveur",
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}