deepseek den plan umsetzen lassen
This commit is contained in:
1 parent
6c60a72e0f
commit
044e4df783
16 files changed
+971
-55
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Tätigkeitsnachweis-Generator package."""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tätigkeitsnachweis-Generator – Konfigurations-Loader."""
|
||||
|
||||
import configparser
|
||||
import json
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import AppConfig, FieldSchema, OutputMapping
|
||||
|
||||
|
||||
def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
|
||||
"""Lädt settings.ini und gibt ein AppConfig-Objekt zurück."""
|
||||
ini_path = config_dir / "settings.ini"
|
||||
if not ini_path.exists():
|
||||
raise FileNotFoundError(f"settings.ini nicht gefunden: {ini_path}")
|
||||
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(ini_path, encoding="utf-8")
|
||||
|
||||
paths = parser["paths"] if "paths" in parser else {}
|
||||
output = parser["output"] if "output" in parser else {}
|
||||
parsing = parser["parsing"] if "parsing" in parser else {}
|
||||
|
||||
return AppConfig(
|
||||
input_dir=str(project_root / paths.get("input_dir", "input")),
|
||||
template_dir=str(project_root / paths.get("template_dir", "template")),
|
||||
output_dir=str(project_root / paths.get("output_dir", "output")),
|
||||
filename_pattern=output.get("filename_pattern", "{last_name}_{first_name}_Tätigkeitsnachweis.xlsx"),
|
||||
month_sheet_pattern=parsing.get("month_sheet_pattern", "* 20??"),
|
||||
skip_empty_name=parsing.getboolean("skip_empty_name", True),
|
||||
skip_empty_antragsnummer=parsing.getboolean("skip_empty_antragsnummer", True),
|
||||
)
|
||||
|
||||
|
||||
def load_input_schema(config_dir: Path) -> list[FieldSchema]:
|
||||
"""Lädt input-fields.json."""
|
||||
schema_path = config_dir / "input-fields.json"
|
||||
if not schema_path.exists():
|
||||
raise FileNotFoundError(f"input-fields.json nicht gefunden: {schema_path}")
|
||||
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return [FieldSchema(**item) for item in data]
|
||||
|
||||
|
||||
def load_output_mapping(config_dir: Path) -> list[OutputMapping]:
|
||||
"""Lädt output-fields.json."""
|
||||
mapping_path = config_dir / "output-fields.json"
|
||||
if not mapping_path.exists():
|
||||
raise FileNotFoundError(f"output-fields.json nicht gefunden: {mapping_path}")
|
||||
|
||||
with open(mapping_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return [OutputMapping(**item) for item in data]
|
||||
|
||||
|
||||
def get_available_files(directory: Path, extension: str = ".xlsx") -> list[str]:
|
||||
"""Listet alle Dateien mit der angegebenen Erweiterung im Verzeichnis auf."""
|
||||
if not directory.exists():
|
||||
return []
|
||||
files = sorted(
|
||||
f.name for f in directory.iterdir()
|
||||
if f.is_file() and f.suffix.lower() == extension and not f.name.startswith("~")
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def get_available_input_files(config: AppConfig) -> list[str]:
|
||||
"""Listet alle .xlsx-Dateien im input-Ordner."""
|
||||
return get_available_files(Path(config.input_dir))
|
||||
|
||||
|
||||
def get_available_templates(config: AppConfig) -> list[str]:
|
||||
"""Listet alle .xlsx-Dateien im template-Ordner."""
|
||||
return get_available_files(Path(config.template_dir))
|
||||
|
||||
|
||||
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
|
||||
"""Prüft, ob ein Sheet-Name dem Monatsmuster entspricht."""
|
||||
return fnmatch.fnmatch(sheet_name.strip(), pattern)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tätigkeitsnachweis-Generator – Datenmodelle."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmployeeRecord:
|
||||
"""Eine Zeile aus einem Monats-Sheet der Input-Datei."""
|
||||
name: str
|
||||
antragsnummer: str
|
||||
qualifikation: str
|
||||
funktion: str
|
||||
stellenanteil: float
|
||||
monat: str # z. B. "01.2025"
|
||||
taetigkeit: str
|
||||
id: Optional[int] = None
|
||||
kommentar: Optional[str] = None
|
||||
|
||||
@property
|
||||
def month_index(self) -> int:
|
||||
"""Extrahiere den Monatsindex (1–12) aus dem Monatsfeld."""
|
||||
try:
|
||||
return int(self.monat.split(".")[0])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
@property
|
||||
def year(self) -> int:
|
||||
"""Extrahiere das Jahr aus dem Monatsfeld."""
|
||||
try:
|
||||
return int(self.monat.split(".")[1])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldSchema:
|
||||
"""Definition eines Feldes aus input-fields.json."""
|
||||
column: str
|
||||
field_name: str
|
||||
type: str
|
||||
required: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputMapping:
|
||||
"""Definition eines Mappings aus output-fields.json."""
|
||||
source_field: str
|
||||
target_cell: str
|
||||
format: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""Gesamte App-Konfiguration."""
|
||||
input_dir: str
|
||||
template_dir: str
|
||||
output_dir: str
|
||||
filename_pattern: str
|
||||
input_schema: list = field(default_factory=list)
|
||||
output_mapping: list = field(default_factory=list)
|
||||
month_sheet_pattern: str = "* 20??"
|
||||
skip_empty_name: bool = True
|
||||
skip_empty_antragsnummer: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Ergebnis einer Validierung."""
|
||||
is_valid: bool
|
||||
errors: list = field(default_factory=list)
|
||||
warnings: list = field(default_factory=list)
|
||||
info: dict = field(default_factory=dict)
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
"""Input-Datei-Parser: liest monatliche Sheets und erzeugt EmployeeRecords."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
from .config import is_month_sheet
|
||||
from .models import EmployeeRecord, FieldSchema
|
||||
|
||||
|
||||
def _to_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_float(value: Any, field_name: str) -> float:
|
||||
if value is None or str(value).strip() == "":
|
||||
return 0.0
|
||||
text = str(value).strip().replace(",", ".")
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _to_int_or_none(value: Any) -> Optional[int]:
|
||||
if value is None or str(value).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_column_map(schema: list[FieldSchema]) -> dict[str, int]:
|
||||
"""Baut ein Mapping von field_name → 0-basierter Spaltenindex."""
|
||||
col_map: dict[str, int] = {}
|
||||
for field in schema:
|
||||
col_map[field.field_name] = column_index_from_string(field.column) - 1
|
||||
return col_map
|
||||
|
||||
|
||||
def parse_input_file(
|
||||
filepath: Path,
|
||||
schema: list[FieldSchema],
|
||||
month_sheet_pattern: str = "* 20??",
|
||||
skip_empty_name: bool = True,
|
||||
skip_empty_antragsnummer: bool = True,
|
||||
) -> list[EmployeeRecord]:
|
||||
"""
|
||||
Öffnet die Input-Datei, iteriert über alle Monats-Sheets
|
||||
und gibt eine Liste von EmployeeRecords zurück.
|
||||
"""
|
||||
wb = load_workbook(filepath, data_only=True)
|
||||
col_map = _build_column_map(schema)
|
||||
records: list[EmployeeRecord] = []
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if not is_month_sheet(sheet_name, month_sheet_pattern):
|
||||
continue
|
||||
|
||||
ws = wb[sheet_name]
|
||||
# Header ist Zeile 1, Daten ab Zeile 2
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True):
|
||||
name = _to_text(row[col_map.get("Name", 0)]) if len(row) > col_map.get("Name", 0) else ""
|
||||
antragsnummer = _to_text(row[col_map.get("Antragsnummer", 0)]) if len(row) > col_map.get("Antragsnummer", 0) else ""
|
||||
|
||||
if skip_empty_name and not name:
|
||||
continue
|
||||
if skip_empty_antragsnummer and not antragsnummer:
|
||||
continue
|
||||
|
||||
qualifikation = _to_text(row[col_map.get("Qualifikation", 0)]) if len(row) > col_map.get("Qualifikation", 0) else ""
|
||||
funktion = _to_text(row[col_map.get("Funktion", 0)]) if len(row) > col_map.get("Funktion", 0) else ""
|
||||
stellenanteil = _to_float(row[col_map.get("Stellenanteil in %", 0)], "Stellenanteil in %") if len(row) > col_map.get("Stellenanteil in %", 0) else 0.0
|
||||
monat = _to_text(row[col_map.get("Monat (MM.JJ)", 0)]) if len(row) > col_map.get("Monat (MM.JJ)", 0) else ""
|
||||
taetigkeit = _to_text(row[col_map.get("Tätigkeit", 0)]) if len(row) > col_map.get("Tätigkeit", 0) else ""
|
||||
record_id = _to_int_or_none(row[col_map.get("ID", 0)]) if len(row) > col_map.get("ID", 0) else None
|
||||
kommentar = _to_text(row[col_map.get("Kommentar", 1)]) if "Kommentar" in col_map and len(row) > col_map["Kommentar"] else None
|
||||
|
||||
records.append(EmployeeRecord(
|
||||
id=record_id,
|
||||
name=name,
|
||||
antragsnummer=antragsnummer,
|
||||
qualifikation=qualifikation,
|
||||
funktion=funktion,
|
||||
stellenanteil=stellenanteil,
|
||||
monat=monat,
|
||||
taetigkeit=taetigkeit,
|
||||
kommentar=kommentar,
|
||||
))
|
||||
|
||||
wb.close()
|
||||
return records
|
||||
|
||||
|
||||
def group_by_employee(records: list[EmployeeRecord]) -> dict[str, list[EmployeeRecord]]:
|
||||
"""Gruppiert Records nach Mitarbeitername."""
|
||||
groups: dict[str, list[EmployeeRecord]] = {}
|
||||
for record in records:
|
||||
if record.name not in groups:
|
||||
groups[record.name] = []
|
||||
groups[record.name].append(record)
|
||||
return groups
|
||||
|
||||
|
||||
def get_unique_antragsnummern(records: list[EmployeeRecord]) -> list[str]:
|
||||
"""Gibt eine sortierte Liste aller eindeutigen Antragsnummern zurück."""
|
||||
return sorted(set(r.antragsnummer for r in records if r.antragsnummer))
|
||||
|
||||
|
||||
def get_unique_employees(records: list[EmployeeRecord]) -> list[str]:
|
||||
"""Gibt eine sortierte Liste aller eindeutigen Mitarbeiternamen zurück."""
|
||||
return sorted(set(r.name for r in records if r.name))
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Validierung von Input-Dateien und Templates."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
from .models import FieldSchema, OutputMapping, ValidationResult
|
||||
|
||||
|
||||
def validate_input_file(
|
||||
filepath: Path,
|
||||
schema: list[FieldSchema],
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validiert eine Input-Datei:
|
||||
- Datei existiert
|
||||
- Erwartete Spalten sind vorhanden (Header in Zeile 1)
|
||||
- Mindestens ein Daten-Sheet enthält Daten
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
info: dict = {}
|
||||
|
||||
if not filepath.exists():
|
||||
errors.append(f"Input-Datei nicht gefunden: {filepath}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
try:
|
||||
wb = load_workbook(filepath, data_only=True)
|
||||
except Exception as e:
|
||||
errors.append(f"Datei konnte nicht geöffnet werden: {e}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
sheet_count = len(wb.sheetnames)
|
||||
info["sheet_count"] = sheet_count
|
||||
info["sheet_names"] = wb.sheetnames
|
||||
|
||||
# Prüfe, ob mindestens ein Sheet die erwarteten Spalten hat
|
||||
sheets_with_data = 0
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
if ws.max_row < 2:
|
||||
continue # Kein Header + Daten
|
||||
|
||||
header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
||||
missing_cols = []
|
||||
for field in schema:
|
||||
col_idx = column_index_from_string(field.column) - 1
|
||||
if col_idx >= len(header_row):
|
||||
missing_cols.append(f"{field.column} ({field.field_name})")
|
||||
continue
|
||||
header_val = str(header_row[col_idx]).strip() if header_row[col_idx] is not None else ""
|
||||
if header_val != field.field_name:
|
||||
warnings.append(
|
||||
f"Sheet '{sheet_name}', Spalte {field.column}: "
|
||||
f"Erwartet '{field.field_name}', gefunden '{header_val}'"
|
||||
)
|
||||
|
||||
if not missing_cols:
|
||||
sheets_with_data += 1
|
||||
|
||||
# Zähle Datenzeilen
|
||||
data_rows = ws.max_row - 1
|
||||
info[f"rows_{sheet_name}"] = data_rows
|
||||
|
||||
info["sheets_with_data"] = sheets_with_data
|
||||
wb.close()
|
||||
|
||||
if sheets_with_data == 0:
|
||||
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
info=info,
|
||||
)
|
||||
|
||||
|
||||
def validate_template(
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validiert ein Template:
|
||||
- Datei existiert
|
||||
- Alle target_cells aus dem Mapping sind im Template vorhanden
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
info: dict = {}
|
||||
|
||||
if not template_path.exists():
|
||||
errors.append(f"Template-Datei nicht gefunden: {template_path}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
try:
|
||||
wb = load_workbook(template_path, data_only=True)
|
||||
except Exception as e:
|
||||
errors.append(f"Template konnte nicht geöffnet werden: {e}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
info["sheet_names"] = wb.sheetnames
|
||||
|
||||
# Wir validieren gegen das erste Sheet (üblicherweise das Haupt-Template)
|
||||
if not wb.sheetnames:
|
||||
errors.append("Template enthält keine Sheets.")
|
||||
wb.close()
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
|
||||
found_cells = 0
|
||||
missing_cells = []
|
||||
for m in mapping:
|
||||
try:
|
||||
cell = ws[m.target_cell]
|
||||
found_cells += 1
|
||||
except (ValueError, KeyError):
|
||||
missing_cells.append(m.target_cell)
|
||||
|
||||
info["found_target_cells"] = found_cells
|
||||
info["total_target_cells"] = len(mapping)
|
||||
|
||||
if missing_cells:
|
||||
errors.append(f"Zielfelder nicht im Template gefunden: {', '.join(missing_cells)}")
|
||||
|
||||
wb.close()
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
info=info,
|
||||
)
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
"""Template-basierte Activity-Report-Generierung."""
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.cell.cell import MergedCell
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
from .models import EmployeeRecord, OutputMapping
|
||||
|
||||
|
||||
def _split_name_for_filename(name: str) -> tuple[str, str]:
|
||||
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
|
||||
cleaned = str(name or "").strip()
|
||||
if "," in cleaned:
|
||||
last_name, first_name = [p.strip() for p in cleaned.split(",", 1)]
|
||||
return first_name or "Unknown", last_name or "Unknown"
|
||||
parts = cleaned.split()
|
||||
if len(parts) >= 2:
|
||||
return parts[0], parts[-1]
|
||||
if len(parts) == 1:
|
||||
return parts[0], "Unknown"
|
||||
return "Unknown", "Unknown"
|
||||
|
||||
|
||||
def _format_value(value, fmt: str | None) -> str:
|
||||
"""Formatiert einen Wert gemäß Format-String."""
|
||||
if value is None:
|
||||
return ""
|
||||
if fmt is None:
|
||||
return str(value)
|
||||
# Numerische Formatierung (z.B. "%.2f")
|
||||
if "%" in fmt:
|
||||
try:
|
||||
return fmt % float(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _set_cell_value(ws, target_cell: str, value) -> None:
|
||||
"""Setzt einen Wert in einer Zelle des Worksheets.
|
||||
Behandelt MergedCells, indem in die Top-Left-Zelle des Merge-Bereichs geschrieben wird."""
|
||||
try:
|
||||
cell = ws[target_cell]
|
||||
# Wenn es eine MergedCell ist, finde die Top-Left-Zelle des Merge-Bereichs
|
||||
if isinstance(cell, MergedCell):
|
||||
for merged_range in ws.merged_cells.ranges:
|
||||
if cell.coordinate in merged_range:
|
||||
top_left = merged_range.min_row, merged_range.min_col
|
||||
ws.cell(row=top_left[0], column=top_left[1]).value = value
|
||||
return
|
||||
else:
|
||||
cell.value = value
|
||||
except (ValueError, KeyError):
|
||||
pass # Zelle existiert nicht
|
||||
|
||||
|
||||
def _format_month_for_template(monat_str: str) -> str:
|
||||
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ' (z.B. '01.2025' → '01.2025', '01.25' → '01.2025')."""
|
||||
monat_str = monat_str.strip()
|
||||
parts = monat_str.split(".")
|
||||
if len(parts) == 2:
|
||||
month = parts[0]
|
||||
year = parts[1]
|
||||
if len(year) == 2:
|
||||
year = "20" + year
|
||||
return f"{month}.{year}"
|
||||
return monat_str
|
||||
|
||||
|
||||
def generate_activity_reports(
|
||||
employee_records: dict[str, list[EmployeeRecord]],
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
output_dir: Path,
|
||||
filename_pattern: str,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Generiert für jeden Mitarbeiter eine Excel-Datei.
|
||||
Jeder Monat wird als eigenes Sheet im Template-Stil erstellt.
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
generated_files: list[Path] = []
|
||||
|
||||
# Build source_field → mapping lookup
|
||||
field_map: dict[str, OutputMapping] = {m.source_field: m for m in mapping}
|
||||
|
||||
for employee_name, records in employee_records.items():
|
||||
if not records:
|
||||
continue
|
||||
|
||||
first_name, last_name = _split_name_for_filename(employee_name)
|
||||
safe_first = first_name.replace(" ", "_").replace("/", "_")
|
||||
safe_last = last_name.replace(" ", "_").replace("/", "_")
|
||||
|
||||
filename = filename_pattern.format(
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
)
|
||||
output_path = output_dir / filename
|
||||
|
||||
# Lade Template
|
||||
wb = load_workbook(template_path)
|
||||
template_sheet = wb.worksheets[0]
|
||||
|
||||
# Sortiere Records nach Monat
|
||||
sorted_records = sorted(records, key=lambda r: r.month_index)
|
||||
|
||||
for idx, record in enumerate(sorted_records):
|
||||
if idx == 0:
|
||||
sheet = template_sheet
|
||||
else:
|
||||
sheet = wb.copy_worksheet(template_sheet)
|
||||
|
||||
# Sheet-Name: Monatsname
|
||||
month_names = [
|
||||
"", "Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember"
|
||||
]
|
||||
mi = record.month_index
|
||||
sheet_name = f"{mi:02d}_{month_names[mi]}" if 1 <= mi <= 12 else f"Monat_{idx + 1}"
|
||||
sheet.title = sheet_name[:31]
|
||||
|
||||
# Befülle Zielfelder
|
||||
for source_field, m in field_map.items():
|
||||
if source_field == "Monat (MM.JJ)":
|
||||
value = _format_month_for_template(record.monat)
|
||||
elif source_field == "Stellenanteil in %":
|
||||
value = record.stellenanteil
|
||||
elif hasattr(record, source_field.lower().replace(" ", "_")):
|
||||
value = getattr(record, source_field.lower().replace(" ", "_"))
|
||||
elif source_field == "Name":
|
||||
value = employee_name
|
||||
elif source_field == "Antragsnummer":
|
||||
value = record.antragsnummer
|
||||
elif source_field == "Qualifikation":
|
||||
value = record.qualifikation
|
||||
elif source_field == "Funktion":
|
||||
value = record.funktion
|
||||
elif source_field == "Tätigkeit":
|
||||
value = record.taetigkeit
|
||||
else:
|
||||
continue
|
||||
|
||||
formatted = _format_value(value, m.format)
|
||||
_set_cell_value(sheet, m.target_cell, formatted)
|
||||
|
||||
# Setze aktuelles Datum für das Erstelldatum
|
||||
today = date.today().strftime("%d.%m.%Y")
|
||||
_set_cell_value(sheet, "K94", today)
|
||||
|
||||
wb.save(output_path)
|
||||
wb.close()
|
||||
generated_files.append(output_path)
|
||||
|
||||
return generated_files
|
||||
Reference in new issue
Block a user