Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ServiceCatalogue,
|
||||
ServiceDueDateExtension,
|
||||
ServiceDueDateRule,
|
||||
)
|
||||
|
||||
DUE_PERIOD_TYPES = [
|
||||
("yearly", "Yearly / Annual"),
|
||||
("monthly", "Monthly"),
|
||||
("quarterly", "Quarterly"),
|
||||
("one_time", "One Time"),
|
||||
("event_based", "Event Based"),
|
||||
("renewal_based", "Renewal Before Expiry"),
|
||||
("custom", "Custom / Manual"),
|
||||
]
|
||||
|
||||
DUE_YEAR_BASIS_CHOICES = [
|
||||
("assessment_year_start", "Assessment year start year"),
|
||||
("financial_year_start", "Financial year start year"),
|
||||
("financial_year_end", "Financial year end year"),
|
||||
("calendar_year", "Calendar year from period"),
|
||||
]
|
||||
|
||||
DUE_DATE_SOURCE_RULE = "rule"
|
||||
DUE_DATE_SOURCE_EXTENSION = "extension"
|
||||
DUE_DATE_SOURCE_MANUAL = "manual"
|
||||
|
||||
|
||||
def parse_optional_date(value: str | None) -> date | None:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return date.fromisoformat(value)
|
||||
|
||||
|
||||
def _parse_year_pair(value: str | None) -> tuple[int, int] | None:
|
||||
value = (value or "").strip()
|
||||
match = re.match(r"^(\d{4})\s*-\s*(\d{2}|\d{4})$", value)
|
||||
if not match:
|
||||
return None
|
||||
start = int(match.group(1))
|
||||
end_raw = match.group(2)
|
||||
end = int(end_raw) if len(end_raw) == 4 else int(str(start)[:2] + end_raw)
|
||||
return start, end
|
||||
|
||||
|
||||
def _month_add(year: int, month: int, offset: int) -> tuple[int, int]:
|
||||
index = (year * 12 + (month - 1)) + int(offset or 0)
|
||||
return index // 12, index % 12 + 1
|
||||
|
||||
|
||||
def _safe_date(year: int, month: int, day: int) -> date | None:
|
||||
try:
|
||||
return date(int(year), int(month), int(day))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _period_month_from_label(financial_year: str | None, period_label: str | None) -> tuple[int, int] | None:
|
||||
label = (period_label or "").strip().lower()
|
||||
fy = _parse_year_pair(financial_year)
|
||||
|
||||
iso_match = re.match(r"^(\d{4})[-/](\d{1,2})$", label)
|
||||
if iso_match:
|
||||
return int(iso_match.group(1)), int(iso_match.group(2))
|
||||
|
||||
month_names = {
|
||||
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
|
||||
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10, "october": 10,
|
||||
"nov": 11, "november": 11, "dec": 12, "december": 12, "jan": 1, "january": 1,
|
||||
"feb": 2, "february": 2, "mar": 3, "march": 3,
|
||||
}
|
||||
if label in month_names and fy:
|
||||
month = month_names[label]
|
||||
year = fy[0] if month >= 4 else fy[1]
|
||||
return year, month
|
||||
return None
|
||||
|
||||
|
||||
def _quarter_end_from_label(financial_year: str | None, period_label: str | None) -> tuple[int, int] | None:
|
||||
label = (period_label or "").strip().lower().replace(" ", "")
|
||||
fy = _parse_year_pair(financial_year)
|
||||
if not fy:
|
||||
return None
|
||||
mapping = {
|
||||
"q1": (fy[0], 6), "quarter1": (fy[0], 6), "apr-jun": (fy[0], 6),
|
||||
"q2": (fy[0], 9), "quarter2": (fy[0], 9), "jul-sep": (fy[0], 9),
|
||||
"q3": (fy[0], 12), "quarter3": (fy[0], 12), "oct-dec": (fy[0], 12),
|
||||
"q4": (fy[1], 3), "quarter4": (fy[1], 3), "jan-mar": (fy[1], 3),
|
||||
}
|
||||
return mapping.get(label)
|
||||
|
||||
|
||||
def calculate_due_date(
|
||||
rule: ServiceDueDateRule | None,
|
||||
*,
|
||||
financial_year: str | None,
|
||||
assessment_year: str | None = None,
|
||||
period_label: str | None = None,
|
||||
expiry_date: date | None = None,
|
||||
) -> date | None:
|
||||
"""Calculate a statutory due date from a catalogue due-date rule.
|
||||
|
||||
The function is intentionally conservative. If the rule needs a period label
|
||||
and the engagement does not yet carry one, it returns None instead of
|
||||
guessing. This preserves existing engagement creation behaviour.
|
||||
"""
|
||||
if not rule or not getattr(rule, "is_active", True):
|
||||
return None
|
||||
day = getattr(rule, "due_day", None)
|
||||
|
||||
period_type = (getattr(rule, "period_type", None) or "yearly").strip().lower()
|
||||
due_month = getattr(rule, "due_month", None)
|
||||
month_offset = int(getattr(rule, "due_month_offset", None) or 0)
|
||||
|
||||
if period_type in {"renewal_based", "before_expiry", "expiry_based"}:
|
||||
if not expiry_date:
|
||||
return None
|
||||
days_before = int(getattr(rule, "renewal_days_before_expiry", None) or 0)
|
||||
from datetime import timedelta
|
||||
return expiry_date - timedelta(days=days_before)
|
||||
|
||||
if not day:
|
||||
return None
|
||||
|
||||
if period_type in {"yearly", "one_time"}:
|
||||
if not due_month:
|
||||
return None
|
||||
basis = (getattr(rule, "due_year_basis", None) or "assessment_year_start").strip().lower()
|
||||
fy = _parse_year_pair(financial_year)
|
||||
ay = _parse_year_pair(assessment_year)
|
||||
if basis == "financial_year_start" and fy:
|
||||
year = fy[0]
|
||||
elif basis == "financial_year_end" and fy:
|
||||
year = fy[1]
|
||||
elif ay:
|
||||
year = ay[0]
|
||||
elif fy:
|
||||
year = fy[1]
|
||||
else:
|
||||
return None
|
||||
return _safe_date(year, int(due_month), int(day))
|
||||
|
||||
if period_type == "monthly":
|
||||
period = _period_month_from_label(financial_year, period_label)
|
||||
if not period:
|
||||
return None
|
||||
year, month = _month_add(period[0], period[1], month_offset)
|
||||
return _safe_date(year, month, int(day))
|
||||
|
||||
if period_type == "quarterly":
|
||||
period = _quarter_end_from_label(financial_year, period_label)
|
||||
if not period:
|
||||
return None
|
||||
year, month = _month_add(period[0], period[1], month_offset)
|
||||
return _safe_date(year, month, int(day))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_active_due_rule_for_catalogue(db: Session, *, catalogue_id: int) -> ServiceDueDateRule | None:
|
||||
return db.execute(
|
||||
select(ServiceDueDateRule)
|
||||
.where(
|
||||
ServiceDueDateRule.service_catalogue_id == catalogue_id,
|
||||
ServiceDueDateRule.is_active.is_(True),
|
||||
)
|
||||
.order_by(ServiceDueDateRule.sort_order.asc(), ServiceDueDateRule.id.asc())
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def list_due_rules(db: Session, *, catalogue_id: int, include_inactive: bool = True) -> list[ServiceDueDateRule]:
|
||||
query = select(ServiceDueDateRule).where(ServiceDueDateRule.service_catalogue_id == catalogue_id)
|
||||
if not include_inactive:
|
||||
query = query.where(ServiceDueDateRule.is_active.is_(True))
|
||||
return db.execute(query.order_by(ServiceDueDateRule.sort_order.asc(), ServiceDueDateRule.id.asc())).scalars().all()
|
||||
|
||||
|
||||
def get_due_rule(db: Session, *, rule_id: int, catalogue_id: int | None = None) -> ServiceDueDateRule | None:
|
||||
query = select(ServiceDueDateRule).where(ServiceDueDateRule.id == rule_id)
|
||||
if catalogue_id:
|
||||
query = query.where(ServiceDueDateRule.service_catalogue_id == catalogue_id)
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_due_extensions(db: Session, *, catalogue_id: int, tenant_id: int | None = None, limit: int = 20) -> list[ServiceDueDateExtension]:
|
||||
query = (
|
||||
select(ServiceDueDateExtension)
|
||||
.options(selectinload(ServiceDueDateExtension.due_rule))
|
||||
.where(ServiceDueDateExtension.service_catalogue_id == catalogue_id)
|
||||
)
|
||||
if tenant_id:
|
||||
query = query.where(ServiceDueDateExtension.tenant_id == tenant_id)
|
||||
return db.execute(
|
||||
query.order_by(ServiceDueDateExtension.id.desc()).limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def apply_due_date_rule_to_subscription(
|
||||
db: Session,
|
||||
subscription: ClientServiceSubscription,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> date | None:
|
||||
if getattr(subscription, "is_locked", False):
|
||||
return getattr(subscription, "current_due_date", None)
|
||||
rule = get_active_due_rule_for_catalogue(db, catalogue_id=subscription.service_catalogue_id)
|
||||
rule_type = (getattr(rule, "period_type", None) or "").strip().lower() if rule else ""
|
||||
# For normal statutory rules, preserve an already calculated/extended due date.
|
||||
# For renewal-based rules, recalculate when expiry_date changes, unless the due date was manually overridden/extended.
|
||||
if getattr(subscription, "current_due_date", None) and not force:
|
||||
if rule_type not in {"renewal_based", "before_expiry", "expiry_based"}:
|
||||
return subscription.current_due_date
|
||||
if getattr(subscription, "due_date_source", None) in {DUE_DATE_SOURCE_EXTENSION, DUE_DATE_SOURCE_MANUAL}:
|
||||
return subscription.current_due_date
|
||||
calculated = calculate_due_date(
|
||||
rule,
|
||||
financial_year=subscription.financial_year,
|
||||
assessment_year=subscription.assessment_year,
|
||||
period_label=getattr(subscription, "period_label", None),
|
||||
expiry_date=getattr(subscription, "expiry_date", None),
|
||||
)
|
||||
if calculated:
|
||||
subscription.due_date_rule_id = rule.id if rule else None
|
||||
subscription.original_due_date = calculated
|
||||
subscription.current_due_date = calculated
|
||||
subscription.due_date_source = DUE_DATE_SOURCE_RULE
|
||||
return calculated
|
||||
|
||||
|
||||
def _matching_extension_query(
|
||||
*,
|
||||
tenant_id: int,
|
||||
catalogue_id: int,
|
||||
rule_id: int | None,
|
||||
financial_year: str,
|
||||
assessment_year: str | None,
|
||||
period_label: str | None,
|
||||
):
|
||||
query = select(ServiceDueDateExtension).where(
|
||||
ServiceDueDateExtension.tenant_id == tenant_id,
|
||||
ServiceDueDateExtension.service_catalogue_id == catalogue_id,
|
||||
ServiceDueDateExtension.financial_year == financial_year,
|
||||
)
|
||||
if rule_id:
|
||||
query = query.where(ServiceDueDateExtension.due_date_rule_id == rule_id)
|
||||
if assessment_year:
|
||||
query = query.where(ServiceDueDateExtension.assessment_year == assessment_year)
|
||||
if period_label:
|
||||
query = query.where(ServiceDueDateExtension.period_label == period_label)
|
||||
else:
|
||||
query = query.where((ServiceDueDateExtension.period_label.is_(None)) | (ServiceDueDateExtension.period_label == ""))
|
||||
return query
|
||||
|
||||
|
||||
def create_due_date_extension(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
catalogue_id: int,
|
||||
due_date_rule_id: int | None,
|
||||
financial_year: str,
|
||||
assessment_year: str | None,
|
||||
period_label: str | None,
|
||||
extended_due_date: date,
|
||||
notification_reference: str | None,
|
||||
notification_date: date | None,
|
||||
remarks: str | None,
|
||||
user_id: int,
|
||||
) -> tuple[ServiceDueDateExtension, int, int]:
|
||||
rule = get_due_rule(db, rule_id=due_date_rule_id, catalogue_id=catalogue_id) if due_date_rule_id else get_active_due_rule_for_catalogue(db, catalogue_id=catalogue_id)
|
||||
latest = db.execute(
|
||||
_matching_extension_query(
|
||||
tenant_id=tenant_id,
|
||||
catalogue_id=catalogue_id,
|
||||
rule_id=rule.id if rule else None,
|
||||
financial_year=financial_year,
|
||||
assessment_year=assessment_year,
|
||||
period_label=period_label,
|
||||
).order_by(ServiceDueDateExtension.extension_sequence.desc(), ServiceDueDateExtension.id.desc())
|
||||
).scalars().first()
|
||||
|
||||
base_due = calculate_due_date(rule, financial_year=financial_year, assessment_year=assessment_year, period_label=period_label)
|
||||
previous_due = latest.extended_due_date if latest else base_due
|
||||
sequence = int((latest.extension_sequence if latest else 0) or 0) + 1
|
||||
|
||||
extension = ServiceDueDateExtension(
|
||||
tenant_id=tenant_id,
|
||||
service_catalogue_id=catalogue_id,
|
||||
due_date_rule_id=rule.id if rule else None,
|
||||
financial_year=financial_year,
|
||||
assessment_year=assessment_year,
|
||||
period_label=(period_label or "").strip() or None,
|
||||
previous_due_date=previous_due,
|
||||
extended_due_date=extended_due_date,
|
||||
extension_sequence=sequence,
|
||||
notification_reference=(notification_reference or "").strip() or None,
|
||||
notification_date=notification_date,
|
||||
remarks=(remarks or "").strip() or None,
|
||||
created_by_user_id=user_id,
|
||||
updated_by_user_id=user_id,
|
||||
)
|
||||
db.add(extension)
|
||||
db.flush()
|
||||
|
||||
updated = skipped_locked = 0
|
||||
sub_query = select(ClientServiceSubscription).where(
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
ClientServiceSubscription.service_catalogue_id == catalogue_id,
|
||||
ClientServiceSubscription.financial_year == financial_year,
|
||||
)
|
||||
if assessment_year:
|
||||
sub_query = sub_query.where(ClientServiceSubscription.assessment_year == assessment_year)
|
||||
subscriptions = db.execute(sub_query).scalars().all()
|
||||
for sub in subscriptions:
|
||||
if getattr(sub, "is_locked", False):
|
||||
skipped_locked += 1
|
||||
continue
|
||||
if rule:
|
||||
sub.due_date_rule_id = rule.id
|
||||
if not getattr(sub, "original_due_date", None):
|
||||
sub.original_due_date = previous_due or base_due
|
||||
sub.current_due_date = extended_due_date
|
||||
sub.due_date_source = DUE_DATE_SOURCE_EXTENSION
|
||||
sub.updated_by_user_id = user_id
|
||||
updated += 1
|
||||
return extension, updated, skipped_locked
|
||||
Reference in New Issue
Block a user