generated from gitea_admin/default
dev
This commit is contained in:
66
README.md
66
README.md
@@ -313,3 +313,69 @@ Exemple pour un export tous les jours à 02:00 :
|
||||
```bash
|
||||
0 2 * * * cd /var/www/ton-projet && npm run export:academie-excel >> /var/log/academy-export.log 2>&1
|
||||
```
|
||||
|
||||
## Export Excel des demandes projet lycée
|
||||
|
||||
Le script `scripts/export-lycee-excel.js` génère un fichier `.xlsx` depuis la table MySQL `projet_lycee_demandes`, puis l'envoie dans Microsoft 365 via Microsoft Graph.
|
||||
|
||||
### Migration SQL
|
||||
|
||||
À exécuter une fois sur la base si tu veux utiliser le mode `pending` :
|
||||
|
||||
```sql
|
||||
source server/database/2026-06-16_lycee_excel_export.sql;
|
||||
```
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
Les variables MySQL et Microsoft Graph communes sont les mêmes que pour l'export Académie :
|
||||
|
||||
```env
|
||||
NUXT_MYSQL_HOST=...
|
||||
NUXT_MYSQL_PORT=3306
|
||||
NUXT_MYSQL_DATABASE=...
|
||||
NUXT_MYSQL_USER=...
|
||||
NUXT_MYSQL_PASSWORD=...
|
||||
MICROSOFT_TENANT_ID=...
|
||||
MICROSOFT_CLIENT_ID=...
|
||||
MICROSOFT_CLIENT_SECRET=...
|
||||
GRAPH_DRIVE_ID=...
|
||||
```
|
||||
|
||||
Variables lycée à ajouter :
|
||||
|
||||
```env
|
||||
LYCEE_GRAPH_EXCEL_UPLOAD_PATH=Projets lycee/Demandes/inscriptions-lycee-{date}.xlsx
|
||||
LYCEE_EXCEL_REPORT_EMAIL=...
|
||||
```
|
||||
|
||||
Optionnel :
|
||||
|
||||
```env
|
||||
LYCEE_GRAPH_EXCEL_ITEM_ID=...
|
||||
LYCEE_EXCEL_EXPORT_MODE=all
|
||||
LYCEE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-lycee-{date}.xlsx
|
||||
LYCEE_EXCEL_SKIP_UPLOAD=1
|
||||
```
|
||||
|
||||
Modes :
|
||||
|
||||
- `LYCEE_EXCEL_EXPORT_MODE=all` : exporte toutes les demandes dans un fichier complet, puis met à jour `exported_to_excel_at` et `export_error`.
|
||||
- `LYCEE_EXCEL_EXPORT_MODE=pending` : exporte seulement les lignes non exportées, puis met aussi `exported_to_excel` à `1`. Nécessite la migration SQL ci-dessus.
|
||||
- En cas d'échec après lecture des demandes, `export_error` est renseigné sur les lignes concernées.
|
||||
- `{date}` dans `LYCEE_GRAPH_EXCEL_UPLOAD_PATH` ou `LYCEE_EXCEL_LOCAL_OUTPUT` est remplacé par la date du jour au format `YYYY-MM-DD`.
|
||||
- `LYCEE_EXCEL_REPORT_EMAIL` reçoit un email de compte rendu après chaque export, ou un email d'erreur si le traitement échoue.
|
||||
|
||||
### Test local sans upload
|
||||
|
||||
```bash
|
||||
LYCEE_EXCEL_SKIP_UPLOAD=1 LYCEE_EXCEL_LOCAL_OUTPUT=tmp/inscriptions-lycee-{date}.xlsx npm run export:lycee-excel
|
||||
```
|
||||
|
||||
### Cron nocturne
|
||||
|
||||
Exemple pour un export tous les jours à 02:15 :
|
||||
|
||||
```bash
|
||||
15 2 * * * cd /var/www/ton-projet && npm run export:lycee-excel >> /var/log/lycee-export.log 2>&1
|
||||
```
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
const { items: seasonSource } = useStrapi("/api/__strapi__/concerts", {
|
||||
locale: "fr-FR",
|
||||
pageSize: 200,
|
||||
pageSize: 400,
|
||||
populate: {
|
||||
saison_concert: true,
|
||||
},
|
||||
@@ -102,6 +102,7 @@
|
||||
|
||||
const { concerts, refresh } = useConcerts({
|
||||
locale: "fr-FR",
|
||||
pageSize: 400,
|
||||
populate: {
|
||||
saison_concert: true,
|
||||
genre_concert: true,
|
||||
|
||||
@@ -320,9 +320,9 @@
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
imgSrc: '/contenus/academie_orchestre_26.jpg',
|
||||
imgAlt: "Académie d'Orchestre 2026",
|
||||
title: "Académie d'Orchestre 2026",
|
||||
imgSrc: '/contenus/academie_web_muet.jpg',
|
||||
imgAlt: "Académie d'Orchestre 2027",
|
||||
title: "Académie d'Orchestre 2027",
|
||||
description: ``,
|
||||
url:"https://www.orchestre-ile.com/mediation/academie",
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"export:academie-excel": "node scripts/export-academie-excel.js",
|
||||
"export:lycee-excel": "node scripts/export-lycee-excel.js",
|
||||
"graph:find-drive": "node scripts/find-sharepoint-drive.js",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
|
||||
BIN
public/contenus/academie_web_muet.jpg
Normal file
BIN
public/contenus/academie_web_muet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
472
scripts/export-lycee-excel.js
Normal file
472
scripts/export-lycee-excel.js
Normal file
@@ -0,0 +1,472 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import process from "node:process"
|
||||
import { PassThrough } from "node:stream"
|
||||
import archiver from "archiver"
|
||||
import mysql from "mysql2/promise"
|
||||
import nodemailer from "nodemailer"
|
||||
|
||||
const ROOT_DIR = process.cwd()
|
||||
const DEFAULT_OUTPUT_PATH = "exports/inscriptions-lycee-{date}.xlsx"
|
||||
|
||||
const columns = [
|
||||
["id", "ID demande"],
|
||||
["created_at", "Date demande"],
|
||||
["nom_etablissement", "Nom établissement"],
|
||||
["departement", "Département"],
|
||||
["ville", "Ville"],
|
||||
["coordinateur_nom", "Coordinateur"],
|
||||
["email", "Email"],
|
||||
["telephone", "Téléphone"],
|
||||
["discipline", "Discipline"],
|
||||
["niveau_classe", "Niveau classe"],
|
||||
["nombre_eleves", "Nombre élèves"],
|
||||
["priorite_concert_rencontre", "Priorité concert-rencontre"],
|
||||
["priorite_immersion", "Priorité immersion"],
|
||||
["priorite_eac", "Priorité EAC"],
|
||||
["priorite_concert_commente", "Priorité concert commenté"],
|
||||
["periode_souhaitee", "Période souhaitée"],
|
||||
]
|
||||
|
||||
function loadDotEnv(filePath = path.join(ROOT_DIR, ".env")) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, "utf8")
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const index = trimmed.indexOf("=")
|
||||
const key = trimmed.slice(0, index).trim()
|
||||
let value = trimmed.slice(index + 1).trim()
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name]
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Variable d'environnement manquante : ${name}`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalEnv(name, fallback = "") {
|
||||
return process.env[name] || fallback
|
||||
}
|
||||
|
||||
function formatLocalDate(date = new Date()) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function resolveDatedPath(filePath) {
|
||||
return filePath.replaceAll("{date}", formatLocalDate())
|
||||
}
|
||||
|
||||
function getSmtpConfig() {
|
||||
return {
|
||||
host: optionalEnv("NUXT_SMTP_HOST"),
|
||||
port: Number(optionalEnv("NUXT_SMTP_PORT", "587")),
|
||||
secure: optionalEnv("NUXT_SMTP_SECURE", "false").toLowerCase() === "true",
|
||||
user: optionalEnv("NUXT_SMTP_USER"),
|
||||
password: optionalEnv("NUXT_SMTP_PASSWORD"),
|
||||
fromEmail: optionalEnv("NUXT_SMTP_FROM_EMAIL"),
|
||||
fromName: optionalEnv("NUXT_SMTP_FROM_NAME"),
|
||||
reportEmail: optionalEnv("ACADEMIE_EXCEL_REPORT_EMAIL", optionalEnv("NUXT_SMTP_FROM_EMAIL")),
|
||||
}
|
||||
}
|
||||
|
||||
function isSmtpConfigured(config) {
|
||||
return Boolean(config.host && config.port && config.user && config.password && config.fromEmail && config.reportEmail)
|
||||
}
|
||||
|
||||
async function sendExportReport({ ok, mode, rowsCount = 0, localOutputPath = "", uploadPath = "", uploadedName = "", skippedUpload = false, error = null }) {
|
||||
const config = getSmtpConfig()
|
||||
|
||||
if (!isSmtpConfigured(config)) {
|
||||
console.warn("Email de rapport non envoyé : configuration SMTP incomplète.")
|
||||
return false
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 15000,
|
||||
auth: {
|
||||
user: config.user,
|
||||
pass: config.password,
|
||||
},
|
||||
})
|
||||
const from = config.fromName ? `"${config.fromName}" <${config.fromEmail}>` : config.fromEmail
|
||||
const subject = ok
|
||||
? `Export Lycée OK - ${formatLocalDate()}`
|
||||
: `Export Lycée ERREUR - ${formatLocalDate()}`
|
||||
const text = ok
|
||||
? [
|
||||
"L'export Excel des demandes projet lycée s'est terminé correctement.",
|
||||
"",
|
||||
`Mode : ${mode}`,
|
||||
`Demandes exportées : ${rowsCount}`,
|
||||
localOutputPath ? `Fichier local : ${localOutputPath}` : "Fichier local : non généré",
|
||||
skippedUpload ? "Upload SharePoint : ignoré" : `Fichier SharePoint : ${uploadPath || uploadedName}`,
|
||||
uploadedName && uploadedName !== uploadPath ? `Nom retourné par SharePoint : ${uploadedName}` : null,
|
||||
].filter(Boolean).join("\n")
|
||||
: [
|
||||
"L'export Excel des demandes projet lycée a échoué.",
|
||||
"",
|
||||
`Mode : ${mode || "non déterminé"}`,
|
||||
`Erreur : ${error?.message || String(error)}`,
|
||||
].join("\n")
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to: config.reportEmail,
|
||||
subject,
|
||||
text,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function trySendExportReport(report) {
|
||||
try {
|
||||
return await sendExportReport(report)
|
||||
} catch (error) {
|
||||
console.error("Erreur envoi email de rapport :", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
function columnName(index) {
|
||||
let name = ""
|
||||
let number = index + 1
|
||||
|
||||
while (number > 0) {
|
||||
const modulo = (number - 1) % 26
|
||||
name = String.fromCharCode(65 + modulo) + name
|
||||
number = Math.floor((number - modulo) / 26)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
function formatCellValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().replace("T", " ").slice(0, 19)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function buildSheetXml(rows) {
|
||||
const allRows = [columns.map(([, label]) => label), ...rows.map((row) => columns.map(([key]) => formatCellValue(row[key])))]
|
||||
const sheetRows = allRows
|
||||
.map((row, rowIndex) => {
|
||||
const rowNumber = rowIndex + 1
|
||||
const cells = row
|
||||
.map((value, columnIndex) => {
|
||||
const reference = `${columnName(columnIndex)}${rowNumber}`
|
||||
return `<c r="${reference}" t="inlineStr"><is><t>${escapeXml(value)}</t></is></c>`
|
||||
})
|
||||
.join("")
|
||||
|
||||
return `<row r="${rowNumber}">${cells}</row>`
|
||||
})
|
||||
.join("")
|
||||
|
||||
const lastColumn = columnName(columns.length - 1)
|
||||
const lastRow = Math.max(allRows.length, 1)
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<dimension ref="A1:${lastColumn}${lastRow}"/>
|
||||
<sheetViews><sheetView workbookViewId="0"/></sheetViews>
|
||||
<sheetFormatPr defaultRowHeight="15"/>
|
||||
<cols>${columns.map((_, index) => `<col min="${index + 1}" max="${index + 1}" width="22" customWidth="1"/>`).join("")}</cols>
|
||||
<sheetData>${sheetRows}</sheetData>
|
||||
<autoFilter ref="A1:${lastColumn}${lastRow}"/>
|
||||
</worksheet>`
|
||||
}
|
||||
|
||||
async function createXlsxBuffer(rows) {
|
||||
const archive = archiver("zip", { zlib: { level: 9 } })
|
||||
const stream = new PassThrough()
|
||||
const chunks = []
|
||||
|
||||
stream.on("data", (chunk) => chunks.push(chunk))
|
||||
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)))
|
||||
stream.on("error", reject)
|
||||
archive.on("error", reject)
|
||||
})
|
||||
|
||||
archive.pipe(stream)
|
||||
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>`, { name: "[Content_Types].xml" })
|
||||
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||||
</Relationships>`, { name: "_rels/.rels" })
|
||||
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="Inscriptions" sheetId="1" r:id="rId1"/></sheets>
|
||||
</workbook>`, { name: "xl/workbook.xml" })
|
||||
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`, { name: "xl/_rels/workbook.xml.rels" })
|
||||
archive.append(buildSheetXml(rows), { name: "xl/worksheets/sheet1.xml" })
|
||||
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||
</styleSheet>`, { name: "xl/styles.xml" })
|
||||
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||
<Application>wondif_vue</Application>
|
||||
</Properties>`, { name: "docProps/app.xml" })
|
||||
archive.append(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dc:title>Demandes projet lycée</dc:title>
|
||||
<dc:creator>wondif_vue</dc:creator>
|
||||
<cp:lastModifiedBy>wondif_vue</cp:lastModifiedBy>
|
||||
<dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:created>
|
||||
<dcterms:modified xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:modified>
|
||||
</cp:coreProperties>`, { name: "docProps/core.xml" })
|
||||
|
||||
await archive.finalize()
|
||||
return completed
|
||||
}
|
||||
|
||||
async function getRows(db, mode) {
|
||||
const where = mode === "pending" ? "WHERE exported_to_excel = 0" : ""
|
||||
const selectColumns = columns.map(([key]) => key).join(", ")
|
||||
const [rows] = await db.execute(`
|
||||
SELECT ${selectColumns}
|
||||
FROM projet_lycee_demandes
|
||||
${where}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`)
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
const tenantId = requireEnv("MICROSOFT_TENANT_ID")
|
||||
const clientId = requireEnv("MICROSOFT_CLIENT_ID")
|
||||
const clientSecret = requireEnv("MICROSOFT_CLIENT_SECRET")
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
scope: "https://graph.microsoft.com/.default",
|
||||
grant_type: "client_credentials",
|
||||
})
|
||||
const response = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur token Microsoft Graph (${response.status}) : ${await response.text()}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.access_token
|
||||
}
|
||||
|
||||
async function uploadToMicrosoft365(buffer) {
|
||||
const driveId = requireEnv("GRAPH_DRIVE_ID")
|
||||
const itemId = optionalEnv("LYCEE_GRAPH_EXCEL_ITEM_ID")
|
||||
const uploadPath = resolveDatedPath(optionalEnv("LYCEE_GRAPH_EXCEL_UPLOAD_PATH", DEFAULT_OUTPUT_PATH)).replace(/^\/+/, "")
|
||||
const token = await getAccessToken()
|
||||
const url = itemId
|
||||
? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/content`
|
||||
: `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:/${uploadPath.split("/").map(encodeURIComponent).join("/")}:/content`
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
},
|
||||
body: buffer,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur upload Microsoft Graph (${response.status}) : ${await response.text()}`)
|
||||
}
|
||||
|
||||
const uploaded = await response.json()
|
||||
|
||||
return { uploaded, uploadPath }
|
||||
}
|
||||
|
||||
async function markRowsExportSucceeded(db, rows, { markAsExported = false } = {}) {
|
||||
if (!rows.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const exportedColumnUpdate = markAsExported ? "exported_to_excel = 1," : ""
|
||||
|
||||
await db.query(
|
||||
`
|
||||
UPDATE projet_lycee_demandes
|
||||
SET ${exportedColumnUpdate}
|
||||
exported_to_excel_at = NOW(),
|
||||
export_error = NULL
|
||||
WHERE id IN (?)
|
||||
`,
|
||||
[rows.map((row) => row.id)]
|
||||
)
|
||||
}
|
||||
|
||||
async function markRowsExportFailed(db, rows, error) {
|
||||
if (!rows.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`
|
||||
UPDATE projet_lycee_demandes
|
||||
SET exported_to_excel_at = NOW(),
|
||||
export_error = ?
|
||||
WHERE id IN (?)
|
||||
`,
|
||||
[String(error?.message || error).slice(0, 5000), rows.map((row) => row.id)]
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadDotEnv()
|
||||
|
||||
const mode = optionalEnv("LYCEE_EXCEL_EXPORT_MODE", "all")
|
||||
|
||||
if (!["all", "pending"].includes(mode)) {
|
||||
throw new Error("LYCEE_EXCEL_EXPORT_MODE doit valoir 'all' ou 'pending'.")
|
||||
}
|
||||
|
||||
const db = await mysql.createConnection({
|
||||
host: requireEnv("NUXT_MYSQL_HOST"),
|
||||
port: Number(optionalEnv("NUXT_MYSQL_PORT", "3306")),
|
||||
database: requireEnv("NUXT_MYSQL_DATABASE"),
|
||||
user: requireEnv("NUXT_MYSQL_USER"),
|
||||
password: requireEnv("NUXT_MYSQL_PASSWORD"),
|
||||
charset: "utf8mb4",
|
||||
})
|
||||
|
||||
let rows = []
|
||||
|
||||
try {
|
||||
rows = await getRows(db, mode)
|
||||
const buffer = await createXlsxBuffer(rows)
|
||||
const localOutputPath = resolveDatedPath(optionalEnv("LYCEE_EXCEL_LOCAL_OUTPUT", ""))
|
||||
let absoluteOutputPath = ""
|
||||
|
||||
if (localOutputPath) {
|
||||
absoluteOutputPath = path.resolve(ROOT_DIR, localOutputPath)
|
||||
fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true })
|
||||
fs.writeFileSync(absoluteOutputPath, buffer)
|
||||
console.log(`Fichier local généré : ${absoluteOutputPath}`)
|
||||
}
|
||||
|
||||
if (optionalEnv("LYCEE_EXCEL_SKIP_UPLOAD", "") === "1") {
|
||||
console.log(`Upload Microsoft 365 ignoré. ${rows.length} ligne(s) générée(s).`)
|
||||
await trySendExportReport({
|
||||
ok: true,
|
||||
mode,
|
||||
rowsCount: rows.length,
|
||||
localOutputPath: absoluteOutputPath,
|
||||
skippedUpload: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { uploaded, uploadPath } = await uploadToMicrosoft365(buffer)
|
||||
|
||||
await markRowsExportSucceeded(db, rows, { markAsExported: mode === "pending" })
|
||||
|
||||
await trySendExportReport({
|
||||
ok: true,
|
||||
mode,
|
||||
rowsCount: rows.length,
|
||||
localOutputPath: absoluteOutputPath,
|
||||
uploadPath,
|
||||
uploadedName: uploaded.name || uploaded.id,
|
||||
})
|
||||
|
||||
console.log(`${rows.length} demande(s) exportée(s) vers Microsoft 365 : ${uploaded.name || uploaded.id}`)
|
||||
} catch (error) {
|
||||
await markRowsExportFailed(db, rows, error)
|
||||
throw error
|
||||
} finally {
|
||||
await db.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error(error)
|
||||
|
||||
try {
|
||||
loadDotEnv()
|
||||
await sendExportReport({
|
||||
ok: false,
|
||||
mode: optionalEnv("LYCEE_EXCEL_EXPORT_MODE", "all"),
|
||||
error,
|
||||
})
|
||||
} catch (mailError) {
|
||||
console.error("Erreur envoi email de rapport :", mailError)
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
})
|
||||
5
server/database/2026-06-16_lycee_excel_export.sql
Normal file
5
server/database/2026-06-16_lycee_excel_export.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE projet_lycee_demandes
|
||||
ADD COLUMN exported_to_excel TINYINT(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN exported_to_excel_at DATETIME NULL,
|
||||
ADD COLUMN export_error TEXT NULL,
|
||||
ADD INDEX idx_projet_lycee_exported_to_excel (exported_to_excel, created_at);
|
||||
@@ -357,7 +357,6 @@ export async function sendProjetAcademieEmails(payload) {
|
||||
mailer.sendMail({
|
||||
from,
|
||||
to: config.academieRequestRecipientEmail,
|
||||
replyTo: payload.email,
|
||||
subject: adminSubject,
|
||||
text: adminText,
|
||||
}),
|
||||
|
||||
BIN
tmp/inscriptions-lycee-2026-06-16.xlsx
Normal file
BIN
tmp/inscriptions-lycee-2026-06-16.xlsx
Normal file
Binary file not shown.
BIN
tmp/inscriptions-lycee-2026-06-17.xlsx
Normal file
BIN
tmp/inscriptions-lycee-2026-06-17.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user