mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-12 00:52:11 +01:00
feat: implement paperless import with config auto-update and new mapping structure (#10)
* Add mapping-based configuration for document processing - Create new config structure with company-type mappings - Add date extraction regex patterns to config - Update config.py to support new mapping-based configuration - Create pipeline.py with full document processing workflow - Enforce strict validation: unknown companies/types route to review - Period rules now come from (company, type) mappings, not templates - Remove document_family field from templates as requested Implements workflow: 1. Extract first page text 2. Find emission date using regex from config 3. Find company/type using ML, validated against config lists 4. Look up (company, type) in mappings for template + period rule 5. If no mapping found -> route to review 6. Apply period rule to calculate period 7. Apply template key_pattern to generate filename Closes #config-review Co-authored-by: napnap75 <napnap75@users.noreply.github.com> * Add field_groups support for additional fields in configuration - Add field_groups section to templates.yaml with predefined lists: - bank_accounts: for bank statements - properties: for building-related documents - contract_references: for contracts and invoices - customer_numbers: for customer identification - Add field_groups to CompanyTypeMapping dataclass - Add get_field_groups(), get_field_group_values(), get_field_groups_for_mapping() methods - Update pipeline to extract additional field values from predefined lists using ML - Additional fields are extracted for each field_group in the mapping - If field values cannot be determined -> route to review Implements workflow: 1. Extract first page text 2. Find emission date using regex patterns from config 3. Find company and document_type using ML, validated against config lists 4. Look up (company, document_type) in mappings for template, period rule, and field groups 5. For each field group, find value using ML from predefined list 6. If no mapping found or field values cannot be determined -> route to review 7. Apply period rule to calculate period from emission date 8. Apply template key_pattern to generate filename with all fields Closes #field-groups-review Co-authored-by: napnap75 <napnap75@users.noreply.github.com> * Update documentation and configuration for new structure - Move configuration from config/templates.yaml to config.jsonc at root - Rename field_groups to additional_fields throughout - Update PRD section 4.1 to reference new period format configuration - Update PRD FR4.1 and FR4.2 to reference JSONC and additional_fields - Update Arch.md to reference JSONC configuration file - Update module layout comments to reference JSONC - Update processing pipeline diagram to reference companies list and additional fields - Update configuration section to describe new JSONC structure The new config.jsonc includes: - companies: predefined list of valid companies - document_types: predefined list of valid document types - additional_fields: predefined value groups (bank_accounts, properties, etc.) - date_extraction: regex patterns for finding emission date - period_formats: customizable period formats with granularity, format template, and offset - mappings: (company, document_type) -> template + period_format + additional_fields - templates: template definitions with key patterns Closes #config-review Co-authored-by: napnap75 <napnap75@users.noreply.github.com> * feat: implement JSONC config with wildcard mappings and period formats - Add JSONC configuration file at root (config.jsonc) - Support simplified date_extraction as ordered list of regex strings - Implement wildcard mappings (company OR document_type can be None) - Add period_formats with granularity, custom format templates, and offsets - Rename field_groups to additional_fields - Update config.py: JSONC loading, PeriodFormatConfig, CompanyTypeMapping with optional fields - Update period.py: format_period_from_emission_date with custom template support - Update pipeline.py: use new config structure with strict validation Closes #10 Co-authored-by: napnap75 <napnap75@users.noreply.github.com> * feat: implement new mapping structure with match/set rules - New mapping structure: each rule has 'match' (criteria) and 'set' (actions) - match: can contain company, document_type, both, or neither (empty = matches all) - set: can contain template, period_format, additional_fields - Rules processed in order, later matches override earlier ones - Added default rule that applies to all documents - Added MappingRule, MappingMatch, MappingSet dataclasses - Updated resolve_mapping() to process all rules and apply overrides - Added 'default' template for fallback Closes #10 Co-authored-by: napnap75 <napnap75@users.noreply.github.com> * feat: implement paperless import with config auto-update and new mapping structure - New mapping structure with match/set sections - Auto-update config.jsonc with missing companies, document_types, additional_fields - Tag-to-field matching with strict validation (unmatched/ambiguous tags route to pending) - Route documents to pending/{paperless_id} when validation fails - Save config at end of run if changes detected - Use created_date from Paperless as emission_date - Write sidecars with errors for pending documents - Clear files/ and pending/ at start of each run Closes #10 Co-authored-by: napnap75 <napnap75@users.noreply.github.com> * Various corrections following real life tests * Various corrections following real life tests --------- Co-authored-by: Vibe Nuage Agent <vibe@mistral.ai> Co-authored-by: napnap75 <napnap75@users.noreply.github.com>
This commit is contained in:
co-authored by
napnap75
Vibe Nuage Agent
parent
72f8254e10
commit
911ed1c027
@@ -2,6 +2,12 @@
|
||||
|
||||
Provides period parsing from filenames and computation from emission dates
|
||||
per template rules (PRD section 4.1, FR4.2, FR4.3).
|
||||
|
||||
Supports custom period format templates with placeholders:
|
||||
- {year}, {year_next}, {year_previous}
|
||||
- {month}, {month_name}, {month_abbr}
|
||||
- {day}
|
||||
- {quarter}, {semester}, {week}
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -17,8 +23,10 @@ class PeriodGranularity(Enum):
|
||||
"""Granularity of period computation (PRD section 4.1)."""
|
||||
|
||||
DAY = "day"
|
||||
WEEK = "week"
|
||||
MONTH = "month"
|
||||
QUARTER = "quarter"
|
||||
SEMESTER = "semester"
|
||||
YEAR = "year"
|
||||
|
||||
|
||||
@@ -26,7 +34,8 @@ class PeriodOffset(Enum):
|
||||
"""Offset for period computation (PRD section 4.1)."""
|
||||
|
||||
CURRENT = "current"
|
||||
NEXT = "next"
|
||||
PREVIOUS = "previous"
|
||||
FOLLOWING = "following"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -47,43 +56,43 @@ class PeriodRule:
|
||||
|
||||
# French month names mapping
|
||||
_FRENCH_MONTHS = {
|
||||
"janvier": 1,
|
||||
"février": 2,
|
||||
"mars": 3,
|
||||
"avril": 4,
|
||||
"mai": 5,
|
||||
"juin": 6,
|
||||
"juillet": 7,
|
||||
"août": 8,
|
||||
"septembre": 9,
|
||||
"octobre": 10,
|
||||
"novembre": 11,
|
||||
"décembre": 12,
|
||||
"Janvier": 1,
|
||||
"F\u00e9vrier": 2,
|
||||
"Mars": 3,
|
||||
"Avril": 4,
|
||||
"Mai": 5,
|
||||
"Juin": 6,
|
||||
"Juillet": 7,
|
||||
"Ao\u00fbt": 8,
|
||||
"Septembre": 9,
|
||||
"Octobre": 10,
|
||||
"Novembre": 11,
|
||||
"D\u00e9cembre": 12,
|
||||
# English fallbacks
|
||||
"january": 1,
|
||||
"february": 2,
|
||||
"march": 3,
|
||||
"april": 4,
|
||||
"may": 5,
|
||||
"june": 6,
|
||||
"july": 7,
|
||||
"august": 8,
|
||||
"september": 9,
|
||||
"october": 10,
|
||||
"november": 11,
|
||||
"december": 12,
|
||||
"January": 1,
|
||||
"February": 2,
|
||||
"March": 3,
|
||||
"April": 4,
|
||||
"May": 5,
|
||||
"June": 6,
|
||||
"July": 7,
|
||||
"August": 8,
|
||||
"September": 9,
|
||||
"October": 10,
|
||||
"November": 11,
|
||||
"December": 12,
|
||||
}
|
||||
|
||||
# Month name to full French name for output
|
||||
_FRENCH_MONTH_NAMES = {
|
||||
1: "janvier", 2: "février", 3: "mars", 4: "avril", 5: "mai", 6: "juin",
|
||||
7: "juillet", 8: "août", 9: "septembre", 10: "octobre", 11: "novembre", 12: "décembre",
|
||||
"01": "Janvier", "02": "F\u00e9vrier", "03": "Mars", "04": "Avril", "05": "Mai", "06": "Juin",
|
||||
"07": "Juillet", "08": "Ao\u00fbt", "09": "Septembre", "10": "Octobre", "11": "Novembre", "12": "D\u00e9cembre",
|
||||
}
|
||||
|
||||
# Month abbreviation mapping
|
||||
_MONTH_ABBR = {
|
||||
1: "Jan", 2: "Feb", 3: "Mar", 4: "Apr", 5: "May", 6: "Jun",
|
||||
7: "Jul", 8: "Aug", 9: "Sep", 10: "Oct", 11: "Nov", 12: "Dec",
|
||||
"01": "Jan", "02": "Feb", "03": "Mar", "04": "Apr", "05": "May", "06": "Jun",
|
||||
"07": "Jul", "08": "Aug", "09": "Sep", "10": "Oct", "11": "Nov", "12": "Dec",
|
||||
}
|
||||
|
||||
# Quarter pattern - matches Q3 2025, T3 2025, 3 2025, Q3-2025, trimestre 3 2025, 3T2025, etc.
|
||||
@@ -97,7 +106,7 @@ _YEAR_PATTERN = re.compile(r"\b(\d{4})\b")
|
||||
|
||||
# Month-year patterns (various formats)
|
||||
_MONTH_YEAR_PATTERNS = [
|
||||
# "août 2025", "August 2025" - French/English month name + year
|
||||
# "ao\u00fbt 2025", "August 2025" - French/English month name + year
|
||||
re.compile(
|
||||
rf"\b([{''.join(_FRENCH_MONTHS.keys())}]+)\s+(\d{{4}})\b",
|
||||
re.IGNORECASE,
|
||||
@@ -138,7 +147,7 @@ def parse_period_from_filename(filename: str) -> str | None:
|
||||
|
||||
Tries to extract period from the filename, looking at various positions.
|
||||
Supports formats:
|
||||
- Month-year: "août 2025", "August 2025", "Aug 2025", "2025-08"
|
||||
- Month-year: "ao\u00fbt 2025", "August 2025", "Aug 2025", "2025-08"
|
||||
- Quarter: "Q3 2025", "T3 2025", "3 2025", "Q3-2025"
|
||||
- Year: "2025"
|
||||
- Date: "2025-08-31", "31/08/2025"
|
||||
@@ -147,7 +156,7 @@ def parse_period_from_filename(filename: str) -> str | None:
|
||||
filename: The filename to parse (without path).
|
||||
|
||||
Returns:
|
||||
Parsed period string (e.g., "août 2025", "Q3 2025", "2025"), or None.
|
||||
Parsed period string (e.g., "ao\u00fbt 2025", "Q3 2025", "2025"), or None.
|
||||
"""
|
||||
# Get the filename without path
|
||||
basename = filename.split("/")[-1].split("\\")[-1]
|
||||
@@ -227,6 +236,67 @@ def parse_period_from_filename(filename: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_string(date_str: str) -> date:
|
||||
"""Parse a date string into a date object.
|
||||
|
||||
Supports various formats: YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, etc.
|
||||
|
||||
Args:
|
||||
date_str: The date string to parse.
|
||||
|
||||
Returns:
|
||||
date object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the date string cannot be parsed.
|
||||
"""
|
||||
if isinstance(date_str, date):
|
||||
return date_str
|
||||
|
||||
# Try ISO format first
|
||||
try:
|
||||
return datetime.fromisoformat(date_str).date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try other common formats
|
||||
formats = [
|
||||
"%Y-%m-%d",
|
||||
"%d/%m/%Y",
|
||||
"%m/%d/%Y",
|
||||
"%d-%m-%Y",
|
||||
"%Y/%m/%d",
|
||||
"%Y-%m",
|
||||
"%m-%Y",
|
||||
"%Y",
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Try to parse month name + year (e.g., "August 2025", "ao\u00fbt 2025")
|
||||
month_year_match = re.match(
|
||||
rf"^([{''.join(_FRENCH_MONTHS.keys())}]+)\s+(\d{{4}})$",
|
||||
date_str.strip(),
|
||||
re.IGNORECASE
|
||||
)
|
||||
if month_year_match:
|
||||
month_name = month_year_match.group(1)
|
||||
year = int(month_year_match.group(2))
|
||||
month = _parse_french_month(month_name)
|
||||
if month:
|
||||
return date(year, month, 1)
|
||||
|
||||
# Try year only
|
||||
if re.match(r"^\d{4}$", date_str):
|
||||
return date(int(date_str), 1, 1)
|
||||
|
||||
raise ValueError(f"Cannot parse date string: {date_str}")
|
||||
|
||||
|
||||
def compute_period_from_date(
|
||||
emission_date: str | date,
|
||||
rule: PeriodRule,
|
||||
@@ -240,30 +310,18 @@ def compute_period_from_date(
|
||||
Returns:
|
||||
Human-readable period string.
|
||||
"""
|
||||
if isinstance(emission_date, str):
|
||||
# Parse ISO date
|
||||
try:
|
||||
dt = datetime.fromisoformat(emission_date).date()
|
||||
except ValueError:
|
||||
# Try other formats
|
||||
for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d-%m-%Y"]:
|
||||
try:
|
||||
dt = datetime.strptime(emission_date, fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
raise ValueError(f"Cannot parse emission_date: {emission_date}")
|
||||
else:
|
||||
dt = emission_date
|
||||
dt = _parse_date_string(emission_date)
|
||||
|
||||
if rule.granularity == PeriodGranularity.DAY:
|
||||
return f"{dt.day} {_FRENCH_MONTH_NAMES[dt.month]} {dt.year}"
|
||||
return f"{dt.day:02d} {_FRENCH_MONTH_NAMES[dt.month]} {dt.year:04d}"
|
||||
|
||||
elif rule.granularity == PeriodGranularity.MONTH:
|
||||
if rule.offset == PeriodOffset.CURRENT:
|
||||
return format_month_year(dt.month, dt.year)
|
||||
else: # NEXT
|
||||
elif rule.offset == PeriodOffset.PREVIOUS:
|
||||
prev_month = dt + relativedelta(months=-1)
|
||||
return format_month_year(prev_month.month, prev_month.year)
|
||||
else: # FOLLOWING
|
||||
next_month = dt + relativedelta(months=1)
|
||||
return format_month_year(next_month.month, next_month.year)
|
||||
|
||||
@@ -271,7 +329,14 @@ def compute_period_from_date(
|
||||
quarter = (dt.month - 1) // 3 + 1
|
||||
if rule.offset == PeriodOffset.CURRENT:
|
||||
return format_quarter(quarter, dt.year)
|
||||
else: # NEXT
|
||||
elif rule.offset == PeriodOffset.PREVIOUS:
|
||||
prev_quarter = quarter - 1
|
||||
prev_year = dt.year
|
||||
if prev_quarter < 1:
|
||||
prev_quarter = 4
|
||||
prev_year -= 1
|
||||
return format_quarter(prev_quarter, prev_year)
|
||||
else: # FOLLOWING
|
||||
next_quarter = quarter + 1
|
||||
next_year = dt.year
|
||||
if next_quarter > 4:
|
||||
@@ -279,16 +344,223 @@ def compute_period_from_date(
|
||||
next_year += 1
|
||||
return format_quarter(next_quarter, next_year)
|
||||
|
||||
elif rule.granularity == PeriodGranularity.SEMESTER:
|
||||
semester = (dt.month - 1) // 6 + 1
|
||||
if rule.offset == PeriodOffset.CURRENT:
|
||||
return f"S{semester} {dt.year:04d}"
|
||||
elif rule.offset == PeriodOffset.PREVIOUS:
|
||||
prev_semester = semester - 1
|
||||
prev_year = dt.year
|
||||
if prev_semester < 1:
|
||||
prev_semester = 2
|
||||
prev_year -= 1
|
||||
return f"S{prev_semester} {prev_year}"
|
||||
else: # FOLLOWING
|
||||
next_semester = semester + 1
|
||||
next_year = dt.year
|
||||
if next_semester > 2:
|
||||
next_semester = 1
|
||||
next_year += 1
|
||||
return f"S{next_semester} {next_year:04d}"
|
||||
|
||||
elif rule.granularity == PeriodGranularity.WEEK:
|
||||
# ISO week number
|
||||
iso_year, iso_week, _ = dt.isocalendar()
|
||||
if rule.offset == PeriodOffset.CURRENT:
|
||||
return f"Week {iso_week:02d} {iso_year:04d}"
|
||||
elif rule.offset == PeriodOffset.PREVIOUS:
|
||||
prev_date = dt - relativedelta(weeks=1)
|
||||
prev_year, prev_week, _ = prev_date.isocalendar()
|
||||
return f"Week {prev_week:02d} {prev_year:04d}"
|
||||
else: # FOLLOWING
|
||||
next_date = dt + relativedelta(weeks=1)
|
||||
next_year, next_week, _ = next_date.isocalendar()
|
||||
return f"Week {next_week:02d} {next_year:04d}"
|
||||
|
||||
elif rule.granularity == PeriodGranularity.YEAR:
|
||||
if rule.offset == PeriodOffset.CURRENT:
|
||||
return str(dt.year)
|
||||
else: # NEXT
|
||||
elif rule.offset == PeriodOffset.PREVIOUS:
|
||||
return str(dt.year - 1)
|
||||
else: # FOLLOWING
|
||||
return str(dt.year + 1)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown granularity: {rule.granularity}")
|
||||
|
||||
|
||||
def format_period_from_emission_date(
|
||||
emission_date: str | date,
|
||||
period_format: dict[str, Any],
|
||||
) -> str:
|
||||
"""Format period from emission date using custom period format configuration.
|
||||
|
||||
The period_format dict contains:
|
||||
- granularity: day, week, month, quarter, semester, year
|
||||
- format: Custom template string with placeholders
|
||||
- offset: current, previous, following
|
||||
|
||||
Supported placeholders in format template:
|
||||
- {year}: Current year
|
||||
- {year_next}: Next year
|
||||
- {year_previous}: Previous year
|
||||
- {month}: Month number (1-12)
|
||||
- {month_name}: Full month name (French)
|
||||
- {month_abbr}: 3-letter month abbreviation
|
||||
- {day}: Day of month (1-31)
|
||||
- {quarter}: Quarter number (1-4)
|
||||
- {semester}: Semester number (1-2)
|
||||
- {week}: ISO week number (1-53)
|
||||
|
||||
Args:
|
||||
emission_date: The document emission date (string or date object).
|
||||
period_format: Dict with granularity, format, and offset keys.
|
||||
|
||||
Returns:
|
||||
Formatted period string with placeholders replaced.
|
||||
|
||||
Raises:
|
||||
ValueError: If granularity is unknown or format template is invalid.
|
||||
"""
|
||||
dt = _parse_date_string(emission_date)
|
||||
|
||||
granularity = period_format.get("granularity", "month")
|
||||
format_template = period_format.get("format", "")
|
||||
offset = period_format.get("offset", "current")
|
||||
|
||||
# Determine the base date based on offset
|
||||
if offset == "previous":
|
||||
# Date is at the end of the period, so we need the previous period
|
||||
if granularity == "day":
|
||||
base_date = dt - relativedelta(days=1)
|
||||
elif granularity == "week":
|
||||
base_date = dt - relativedelta(weeks=1)
|
||||
elif granularity == "month":
|
||||
base_date = dt - relativedelta(months=1)
|
||||
elif granularity == "quarter":
|
||||
quarter = (dt.month - 1) // 3 + 1
|
||||
if quarter == 1:
|
||||
base_date = date(dt.year - 1, 12, 1)
|
||||
else:
|
||||
base_date = date(dt.year, (quarter - 1) * 3, 1)
|
||||
elif granularity == "semester":
|
||||
semester = (dt.month - 1) // 6 + 1
|
||||
if semester == 1:
|
||||
base_date = date(dt.year - 1, 12, 1)
|
||||
else:
|
||||
base_date = date(dt.year, 6, 1)
|
||||
elif granularity == "year":
|
||||
base_date = date(dt.year - 1, 1, 1)
|
||||
else:
|
||||
base_date = dt
|
||||
elif offset == "following":
|
||||
# Date is at the beginning of the period, so we need the following period
|
||||
if granularity == "day":
|
||||
base_date = dt + relativedelta(days=1)
|
||||
elif granularity == "week":
|
||||
base_date = dt + relativedelta(weeks=1)
|
||||
elif granularity == "month":
|
||||
base_date = dt + relativedelta(months=1)
|
||||
elif granularity == "quarter":
|
||||
quarter = (dt.month - 1) // 3 + 1
|
||||
if quarter == 4:
|
||||
base_date = date(dt.year + 1, 1, 1)
|
||||
else:
|
||||
base_date = date(dt.year, (quarter + 1) * 3, 1)
|
||||
elif granularity == "semester":
|
||||
semester = (dt.month - 1) // 6 + 1
|
||||
if semester == 2:
|
||||
base_date = date(dt.year + 1, 1, 1)
|
||||
else:
|
||||
base_date = date(dt.year, 6, 1)
|
||||
elif granularity == "year":
|
||||
base_date = date(dt.year + 1, 1, 1)
|
||||
else:
|
||||
base_date = dt
|
||||
else: # current
|
||||
# Date is in the middle of the period, use the date as-is
|
||||
base_date = dt
|
||||
|
||||
# Calculate period values based on granularity
|
||||
period_values: dict[str, Any] = {}
|
||||
|
||||
if granularity == "day":
|
||||
period_values["year"] = f"{base_date.year:04d}"
|
||||
period_values["month"] = f"{base_date.month:02d}"
|
||||
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{base_date.month:02d}"]
|
||||
period_values["month_abbr"] = _MONTH_ABBR[f"{base_date.month:02d}"]
|
||||
period_values["day"] = f"{base_date.day:02d}"
|
||||
period_values["quarter"] = (base_date.month - 1) // 3 + 1
|
||||
period_values["semester"] = (base_date.month - 1) // 6 + 1
|
||||
_, period_values["week"], _ = base_date.isocalendar()
|
||||
|
||||
elif granularity == "week":
|
||||
_, period_values["week"], _ = base_date.isocalendar()
|
||||
period_values["year"] = f"{base_date.year:04d}"
|
||||
period_values["month"] = f"{base_date.month:02d}"
|
||||
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{base_date.month:02d}"]
|
||||
period_values["month_abbr"] = _MONTH_ABBR[f"{base_date.month:02d}"]
|
||||
period_values["day"] = f"{base_date.day:02d}"
|
||||
period_values["quarter"] = (base_date.month - 1) // 3 + 1
|
||||
period_values["semester"] = (base_date.month - 1) // 6 + 1
|
||||
|
||||
elif granularity == "month":
|
||||
period_values["year"] = f"{base_date.year:04d}"
|
||||
period_values["month"] = f"{base_date.month:02d}"
|
||||
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{base_date.month:02d}"]
|
||||
period_values["month_abbr"] = _MONTH_ABBR[f"{base_date.month:02d}"]
|
||||
period_values["day"] = f"{1:02d}" # Default to first day of month
|
||||
period_values["quarter"] = (base_date.month - 1) // 3 + 1
|
||||
period_values["semester"] = (base_date.month - 1) // 6 + 1
|
||||
_, period_values["week"], _ = base_date.isocalendar()
|
||||
|
||||
elif granularity == "quarter":
|
||||
quarter = (base_date.month - 1) // 3 + 1
|
||||
period_values["quarter"] = quarter
|
||||
period_values["year"] = f"{base_date.year:04d}"
|
||||
period_values["month"] = f"{(quarter - 1) * 3 + 1:02d}" # First month of quarter
|
||||
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{period_values['month']}"]
|
||||
period_values["month_abbr"] = _MONTH_ABBR[f"{period_values['month']}"]
|
||||
period_values["day"] = f"{1:02d}"
|
||||
period_values["semester"] = (quarter - 1) // 2 + 1
|
||||
_, period_values["week"], _ = base_date.isocalendar()
|
||||
|
||||
elif granularity == "semester":
|
||||
semester = (base_date.month - 1) // 6 + 1
|
||||
period_values["semester"] = semester
|
||||
period_values["year"] = f"{base_date.year:04d}"
|
||||
period_values["month"] = f"{(semester - 1) * 6 + 1:02d}" # First month of semester
|
||||
period_values["month_name"] = _FRENCH_MONTH_NAMES[f"{period_values['month']}"]
|
||||
period_values["month_abbr"] = _MONTH_ABBR[f"{period_values['month']}"]
|
||||
period_values["day"] = f"{1:02d}"
|
||||
period_values["quarter"] = (period_values["month"] - 1) // 3 + 1
|
||||
_, period_values["week"], _ = base_date.isocalendar()
|
||||
|
||||
elif granularity == "year":
|
||||
period_values["year"] = f"{base_date.year:04d}"
|
||||
period_values["month"] = f"{1:02d}"
|
||||
period_values["month_name"] = "janvier"
|
||||
period_values["month_abbr"] = "Jan"
|
||||
period_values["day"] = f"{1:02d}"
|
||||
period_values["quarter"] = 1
|
||||
period_values["semester"] = 1
|
||||
_, period_values["week"], _ = base_date.isocalendar()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown granularity: {granularity}")
|
||||
|
||||
# Add year_next and year_previous
|
||||
period_values["year_next"] = f"{int(period_values['year']) + 1:04d}"
|
||||
period_values["year_previous"] = f"{int(period_values['year']) - 1:04d}"
|
||||
|
||||
# Format the template
|
||||
try:
|
||||
return format_template.format(**period_values)
|
||||
except KeyError as e:
|
||||
missing = str(e).strip("'")
|
||||
raise ValueError(f"Unknown placeholder '{missing}' in format template: {format_template}")
|
||||
|
||||
|
||||
def format_month_year(month: int, year: int, use_french: bool = True) -> str:
|
||||
"""Format month and year as a human-readable string.
|
||||
|
||||
@@ -300,7 +572,7 @@ def format_month_year(month: int, year: int, use_french: bool = True) -> str:
|
||||
use_french: If True, use French month names; otherwise use English.
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "août 2025" or "August 2025").
|
||||
Formatted string (e.g., "ao\u00fbt 2025" or "August 2025").
|
||||
"""
|
||||
if month < 1 or month > 12:
|
||||
raise ValueError(f"Invalid month: {month}")
|
||||
@@ -308,7 +580,7 @@ def format_month_year(month: int, year: int, use_french: bool = True) -> str:
|
||||
month_name = _FRENCH_MONTH_NAMES[month]
|
||||
else:
|
||||
month_name = _MONTH_ABBR[month]
|
||||
return f"{month_name} {year}"
|
||||
return f"{month_name} {year:04d}"
|
||||
|
||||
|
||||
def format_quarter(quarter: int, year: int) -> str:
|
||||
@@ -323,7 +595,7 @@ def format_quarter(quarter: int, year: int) -> str:
|
||||
"""
|
||||
if quarter < 1 or quarter > 4:
|
||||
raise ValueError(f"Invalid quarter: {quarter}")
|
||||
return f"Q{quarter} {year}"
|
||||
return f"Q{quarter} {year:04d}"
|
||||
|
||||
|
||||
def get_period(
|
||||
@@ -361,20 +633,7 @@ def get_period(
|
||||
|
||||
# If we have emission_date but no rule, use month-year as fallback
|
||||
if emission_date:
|
||||
if isinstance(emission_date, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(emission_date).date()
|
||||
except ValueError:
|
||||
for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d-%m-%Y"]:
|
||||
try:
|
||||
dt = datetime.strptime(emission_date, fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
raise ValueError(f"Cannot parse emission_date: {emission_date}")
|
||||
else:
|
||||
dt = emission_date
|
||||
dt = _parse_date_string(emission_date)
|
||||
return (format_month_year(dt.month, dt.year), False)
|
||||
|
||||
raise ValueError("Cannot determine period: no filename, emission_date, or rule provided")
|
||||
@@ -396,20 +655,7 @@ def validate_period_consistency(
|
||||
Tuple of (is_consistent, error_message).
|
||||
If consistent, error_message is None.
|
||||
"""
|
||||
if isinstance(emission_date, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(emission_date).date()
|
||||
except ValueError:
|
||||
for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d-%m-%Y"]:
|
||||
try:
|
||||
dt = datetime.strptime(emission_date, fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
return (False, f"Cannot parse emission_date: {emission_date}")
|
||||
else:
|
||||
dt = emission_date
|
||||
dt = _parse_date_string(emission_date)
|
||||
|
||||
# Compute expected period from rule
|
||||
expected_period = compute_period_from_date(dt, rule)
|
||||
|
||||
Reference in New Issue
Block a user