Wasserzähler QR-Code System + Serienbrief + Admin Import
- Wasserzähler-Stammdaten Import (Drag & Drop Excel/CSV) - QR-Code Druckseite mit Browser-Vorschau - QR-Code Excel Download (ExcelJS, eingebettete QR-PNGs) - Serienbrief wie Vorlage wasserablesung.pdf - HTML-Vorschau (max 20) + PDF Download (PDFKit, 1000+ skalierbar) - Antwortkarte mit QR-Code, Briefmarke, Zählerdaten - Bürgerseite: nur Kundennr./Zählernr./Stand (Datenschutz) - Kundennummer + letzter_stand + letzte_ablesung zum Schema - Bürgermeister: Patrick Krutzler - CAPTCHA verify API Route Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
196
scripts/generate-qr-excel.ts
Normal file
196
scripts/generate-qr-excel.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Generiert eine Excel-Datei mit QR-Codes für alle Wasserzähler.
|
||||
*
|
||||
* Verwendung:
|
||||
* npx tsx scripts/generate-qr-excel.ts
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import QRCode from 'qrcode';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
// .env.local laden
|
||||
function loadEnv() {
|
||||
const envPath = resolve(__dirname, '..', '.env.local');
|
||||
if (!existsSync(envPath)) {
|
||||
console.error('FEHLER: .env.local nicht gefunden');
|
||||
process.exit(1);
|
||||
}
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const value = trimmed.slice(eqIndex + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_KEY);
|
||||
|
||||
async function main() {
|
||||
console.log('Lade Wasserzähler aus Supabase...');
|
||||
|
||||
const { data: zaehler, error } = await supabase
|
||||
.from('wasserzaehler')
|
||||
.select('*')
|
||||
.order('haushalt_name', { ascending: true });
|
||||
|
||||
if (error || !zaehler || zaehler.length === 0) {
|
||||
console.error('Fehler oder keine Daten:', error?.message || 'Keine Einträge');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${zaehler.length} Einträge gefunden. Generiere Excel...\n`);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Gemeindeportal';
|
||||
|
||||
// --- Blatt 1: Übersicht ---
|
||||
const listSheet = workbook.addWorksheet('Übersicht', {
|
||||
pageSetup: { paperSize: 9, orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
|
||||
});
|
||||
|
||||
listSheet.columns = [
|
||||
{ header: 'Kundennummer', key: 'kundennummer', width: 18 },
|
||||
{ header: 'Zählernummer', key: 'zaehlernummer', width: 18 },
|
||||
{ header: 'Name', key: 'name', width: 28 },
|
||||
{ header: 'Adresse', key: 'adresse', width: 30 },
|
||||
{ header: 'Letzter Stand (m³)', key: 'stand', width: 18 },
|
||||
{ header: 'QR-Code URL', key: 'url', width: 55 },
|
||||
];
|
||||
|
||||
const headerRow = listSheet.getRow(1);
|
||||
headerRow.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } };
|
||||
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0D2B4E' } };
|
||||
headerRow.alignment = { vertical: 'middle' };
|
||||
headerRow.height = 28;
|
||||
|
||||
for (const z of zaehler) {
|
||||
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
|
||||
listSheet.addRow({
|
||||
kundennummer: z.kundennummer || '',
|
||||
zaehlernummer: z.zaehlernummer,
|
||||
name: z.haushalt_name,
|
||||
adresse: z.adresse || '',
|
||||
stand: z.alter_stand,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
listSheet.autoFilter = {
|
||||
from: { row: 1, column: 1 },
|
||||
to: { row: zaehler.length + 1, column: 6 },
|
||||
};
|
||||
|
||||
// --- Je 1 Blatt pro Kunde mit QR-Code ---
|
||||
for (let i = 0; i < zaehler.length; i++) {
|
||||
const z = zaehler[i];
|
||||
const url = `${BASE_URL}/wasserzaehler?token=${z.access_token}`;
|
||||
|
||||
const qrBuffer = await QRCode.toBuffer(url, {
|
||||
width: 400, margin: 2, errorCorrectionLevel: 'M', type: 'png',
|
||||
});
|
||||
|
||||
const sheetName = (z.kundennummer || z.haushalt_name || `Kunde ${i + 1}`).slice(0, 31);
|
||||
const sheet = workbook.addWorksheet(sheetName, {
|
||||
pageSetup: {
|
||||
paperSize: 9, orientation: 'portrait',
|
||||
horizontalCentered: true, verticalCentered: true,
|
||||
margins: { left: 0.7, right: 0.7, top: 1.0, bottom: 1.0, header: 0.3, footer: 0.3 },
|
||||
},
|
||||
});
|
||||
|
||||
sheet.getColumn(1).width = 5;
|
||||
sheet.getColumn(2).width = 25;
|
||||
sheet.getColumn(3).width = 35;
|
||||
sheet.getColumn(4).width = 5;
|
||||
|
||||
// Titel
|
||||
sheet.mergeCells('B2:C2');
|
||||
const titleCell = sheet.getCell('B2');
|
||||
titleCell.value = 'Gemeindeamt Weißkirchen an der Traun';
|
||||
titleCell.font = { bold: true, size: 16, color: { argb: 'FF0D2B4E' } };
|
||||
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
sheet.getRow(2).height = 30;
|
||||
|
||||
sheet.mergeCells('B3:C3');
|
||||
const subtitleCell = sheet.getCell('B3');
|
||||
subtitleCell.value = `Wasserzähler-Ablesung ${new Date().getFullYear()}`;
|
||||
subtitleCell.font = { size: 12, color: { argb: 'FF666666' } };
|
||||
subtitleCell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
sheet.getRow(3).height = 22;
|
||||
|
||||
// QR Code Bild
|
||||
const imageId = workbook.addImage({ buffer: qrBuffer as unknown as ExcelJS.Buffer, extension: 'png' });
|
||||
sheet.addImage(imageId, { tl: { col: 1.5, row: 5 }, ext: { width: 250, height: 250 } });
|
||||
|
||||
for (let row = 5; row <= 20; row++) sheet.getRow(row).height = 18;
|
||||
|
||||
// Kundennummer
|
||||
const r = 22;
|
||||
sheet.mergeCells(`B${r}:C${r}`);
|
||||
sheet.getCell(`B${r}`).value = 'Kundennummer';
|
||||
sheet.getCell(`B${r}`).font = { size: 10, color: { argb: 'FF999999' } };
|
||||
sheet.getCell(`B${r}`).alignment = { horizontal: 'center' };
|
||||
|
||||
sheet.mergeCells(`B${r + 1}:C${r + 1}`);
|
||||
sheet.getCell(`B${r + 1}`).value = z.kundennummer || '—';
|
||||
sheet.getCell(`B${r + 1}`).font = { bold: true, size: 22 };
|
||||
sheet.getCell(`B${r + 1}`).alignment = { horizontal: 'center' };
|
||||
sheet.getRow(r + 1).height = 32;
|
||||
|
||||
// Zählernummer
|
||||
sheet.mergeCells(`B${r + 3}:C${r + 3}`);
|
||||
sheet.getCell(`B${r + 3}`).value = 'Zählernummer';
|
||||
sheet.getCell(`B${r + 3}`).font = { size: 10, color: { argb: 'FF999999' } };
|
||||
sheet.getCell(`B${r + 3}`).alignment = { horizontal: 'center' };
|
||||
|
||||
sheet.mergeCells(`B${r + 4}:C${r + 4}`);
|
||||
sheet.getCell(`B${r + 4}`).value = z.zaehlernummer;
|
||||
sheet.getCell(`B${r + 4}`).font = { bold: true, size: 16 };
|
||||
sheet.getCell(`B${r + 4}`).alignment = { horizontal: 'center' };
|
||||
sheet.getRow(r + 4).height = 26;
|
||||
|
||||
// Name (klein, für Zuordnung)
|
||||
sheet.mergeCells(`B${r + 6}:C${r + 6}`);
|
||||
sheet.getCell(`B${r + 6}`).value = z.haushalt_name;
|
||||
sheet.getCell(`B${r + 6}`).font = { size: 11, color: { argb: 'FF999999' } };
|
||||
sheet.getCell(`B${r + 6}`).alignment = { horizontal: 'center' };
|
||||
|
||||
// Anleitung
|
||||
sheet.mergeCells(`B${r + 8}:C${r + 8}`);
|
||||
sheet.getCell(`B${r + 8}`).value = 'Scannen Sie den QR-Code mit Ihrem Smartphone';
|
||||
sheet.getCell(`B${r + 8}`).font = { size: 11, color: { argb: 'FF666666' } };
|
||||
sheet.getCell(`B${r + 8}`).alignment = { horizontal: 'center', wrapText: true };
|
||||
|
||||
sheet.mergeCells(`B${r + 9}:C${r + 9}`);
|
||||
sheet.getCell(`B${r + 9}`).value = 'um Ihren aktuellen Zählerstand zu melden.';
|
||||
sheet.getCell(`B${r + 9}`).font = { size: 11, color: { argb: 'FF666666' } };
|
||||
sheet.getCell(`B${r + 9}`).alignment = { horizontal: 'center', wrapText: true };
|
||||
|
||||
console.log(` ✓ ${z.haushalt_name} (${z.kundennummer || z.zaehlernummer})`);
|
||||
}
|
||||
|
||||
const outputPath = resolve(__dirname, '..', `wasserzaehler_qrcodes_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||
await workbook.xlsx.writeFile(outputPath);
|
||||
|
||||
console.log(`\nExcel gespeichert: ${outputPath}`);
|
||||
console.log(`\nBlatt 1 "Übersicht": Tabelle mit allen Kunden + URLs`);
|
||||
console.log(`Blätter 2-${zaehler.length + 1}: Je 1 Druckseite pro Kunde mit QR-Code`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fehler:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
142
scripts/import-zaehler.ts
Normal file
142
scripts/import-zaehler.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Import-Script: Excel → Supabase (Wasserzähler)
|
||||
*
|
||||
* Liest zaehlerablesen_2025.xlsx und importiert Kundendaten in die
|
||||
* Supabase-Tabelle `wasserzaehler`. Generiert pro Zeile einen access_token.
|
||||
*
|
||||
* Verwendung:
|
||||
* npx tsx scripts/import-zaehler.ts [pfad-zur-excel-datei]
|
||||
*
|
||||
* Erwartet .env.local im Projekt-Root mit:
|
||||
* NEXT_PUBLIC_SUPABASE_URL
|
||||
* SUPABASE_SERVICE_ROLE_KEY
|
||||
* NEXT_PUBLIC_BASE_URL (optional, default: https://gemeindeportal.vercel.app)
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// .env.local laden
|
||||
function loadEnv() {
|
||||
const envPath = resolve(__dirname, '..', '.env.local');
|
||||
if (!existsSync(envPath)) {
|
||||
console.error('FEHLER: .env.local nicht gefunden unter:', envPath);
|
||||
process.exit(1);
|
||||
}
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const value = trimmed.slice(eqIndex + 1).trim();
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_KEY) {
|
||||
console.error('FEHLER: NEXT_PUBLIC_SUPABASE_URL und SUPABASE_SERVICE_ROLE_KEY müssen gesetzt sein.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_KEY);
|
||||
|
||||
interface ExcelRow {
|
||||
Kundennummer: string;
|
||||
Zählernummer: string;
|
||||
Name: string;
|
||||
Adresse: string;
|
||||
'Letzter Stand (m³)': number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const excelPath = process.argv[2] || resolve(__dirname, '..', 'zaehlerablesen_2025.xlsx');
|
||||
|
||||
if (!existsSync(excelPath)) {
|
||||
console.error(`FEHLER: Excel-Datei nicht gefunden: ${excelPath}`);
|
||||
console.error('Verwendung: npx tsx scripts/import-zaehler.ts [pfad-zur-excel-datei]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Lese Excel-Datei: ${excelPath}\n`);
|
||||
|
||||
const workbook = XLSX.readFile(excelPath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json<ExcelRow>(sheet);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.error('FEHLER: Keine Daten in der Excel-Datei gefunden.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${rows.length} Zeile(n) gefunden.\n`);
|
||||
|
||||
const results: { kundennummer: string; name: string; token: string; url: string }[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const kundennummer = String(row.Kundennummer || '').trim();
|
||||
const zaehlernummer = String(row.Zählernummer || row['Zaehlernummer'] || '').trim();
|
||||
const name = String(row.Name || '').trim();
|
||||
const adresse = String(row.Adresse || '').trim();
|
||||
const alterStandRaw = row['Letzter Stand (m³)'] ?? row['Letzter Stand'] ?? 0;
|
||||
const alter_stand = typeof alterStandRaw === 'number' ? alterStandRaw : parseFloat(String(alterStandRaw)) || 0;
|
||||
|
||||
if (!kundennummer || !zaehlernummer) {
|
||||
console.warn(`WARNUNG: Zeile übersprungen (fehlende Kundennummer/Zählernummer):`, row);
|
||||
continue;
|
||||
}
|
||||
|
||||
const access_token = randomUUID();
|
||||
|
||||
const { error } = await supabase.from('wasserzaehler').insert({
|
||||
kundennummer,
|
||||
zaehlernummer,
|
||||
haushalt_name: name,
|
||||
adresse,
|
||||
alter_stand,
|
||||
access_token,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`FEHLER bei Kundennr. ${kundennummer}:`, error.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/wasserzaehler?token=${access_token}`;
|
||||
results.push({ kundennummer, name, token: access_token, url });
|
||||
console.log(`OK: ${kundennummer} — ${name}`);
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('ERGEBNIS — QR-Code URLs');
|
||||
console.log('='.repeat(80));
|
||||
|
||||
console.log('\n%-15s %-25s %-40s'.replace('%-15s', 'Kundennr.').replace('%-25s', 'Name').replace('%-40s', 'URL'));
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
for (const r of results) {
|
||||
console.log(`${r.kundennummer.padEnd(15)} ${r.name.padEnd(25)} ${r.url}`);
|
||||
}
|
||||
|
||||
console.log(`\n${results.length} von ${rows.length} Einträgen erfolgreich importiert.`);
|
||||
console.log(`\nAdmin-Seite mit QR-Codes: ${BASE_URL}/admin/qrcodes`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Unerwarteter Fehler:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user