forked from hesabix/arc
602 lines
20 KiB
Python
Executable file
602 lines
20 KiB
Python
Executable file
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
from datetime import datetime, date
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import and_
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from adapters.db.models.document_numbering import (
|
|
BusinessDocumentNumberingSetting,
|
|
DocumentNumberCounter,
|
|
)
|
|
from app.core.calendar import CalendarConverter
|
|
|
|
|
|
def _get_default_setting_for_type(document_type: str) -> dict:
|
|
"""
|
|
برگرداندن تنظیمات پیشفرض برای هر نوع سند به صورت dict
|
|
"""
|
|
defaults = {
|
|
"invoice_sales": {
|
|
"prefix": "INV",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"invoice_sales_return": {
|
|
"prefix": "INV-RET",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"invoice_purchase": {
|
|
"prefix": "INV-PUR",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"invoice_purchase_return": {
|
|
"prefix": "INV-PUR-RET",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"invoice_direct_consumption": {
|
|
"prefix": "INV-CON",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"invoice_production": {
|
|
"prefix": "INV-PROD",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"invoice_waste": {
|
|
"prefix": "INV-WASTE",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"receipt": {
|
|
"prefix": "RC",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"payment": {
|
|
"prefix": "PY",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"transfer": {
|
|
"prefix": "TR",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"expense": {
|
|
"prefix": "EXP",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"income": {
|
|
"prefix": "INC",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"goods_expense": {
|
|
"prefix": "GEX",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"goods_income": {
|
|
"prefix": "GIN",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
# شماره سند عملیاتی (جدول goods_expense_income_documents) جدا از سند دفتر کل
|
|
"goods_expense_voucher": {
|
|
"prefix": "GXV",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"goods_income_voucher": {
|
|
"prefix": "GIV",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"crm_lead": {
|
|
"prefix": "L",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"crm_deal": {
|
|
"prefix": "D",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"crm_activity": {
|
|
"prefix": "A",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"warehouse_document": {
|
|
"prefix": "WH",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"warehouse_location": {
|
|
"prefix": "LOC",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
"received_loan_facility": {
|
|
"prefix": "LOAN",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
}
|
|
|
|
default = defaults.get(
|
|
document_type,
|
|
{
|
|
"prefix": "DOC",
|
|
"include_date": True,
|
|
"calendar_type": "gregorian",
|
|
"date_format": "YYYYMMDD",
|
|
"separator": "-",
|
|
"start_number": 1,
|
|
"number_padding": 4,
|
|
"reset_period": "never",
|
|
},
|
|
)
|
|
|
|
return default
|
|
|
|
|
|
def _format_date(document_date: date, date_format: str, calendar_type: str) -> str:
|
|
"""
|
|
فرمتبندی تاریخ بر اساس نوع تقویم (شمسی یا میلادی)
|
|
"""
|
|
# تبدیل date به datetime
|
|
dt = datetime.combine(document_date, datetime.min.time())
|
|
|
|
# تبدیل به تقویم مورد نظر
|
|
if calendar_type == "jalali":
|
|
cal_data = CalendarConverter.to_jalali(dt)
|
|
year = cal_data["year"]
|
|
month = cal_data["month"]
|
|
day = cal_data["day"]
|
|
else: # gregorian
|
|
cal_data = CalendarConverter.to_gregorian(dt)
|
|
year = cal_data["year"]
|
|
month = cal_data["month"]
|
|
day = cal_data["day"]
|
|
|
|
# فرمتبندی بر اساس الگو
|
|
formatted = date_format
|
|
|
|
# جایگزینی سال
|
|
formatted = formatted.replace("YYYY", f"{year:04d}")
|
|
formatted = formatted.replace("YY", f"{year % 100:02d}")
|
|
|
|
# جایگزینی ماه
|
|
formatted = formatted.replace("MM", f"{month:02d}")
|
|
formatted = formatted.replace("M", f"{month}")
|
|
|
|
# جایگزینی روز
|
|
formatted = formatted.replace("DD", f"{day:02d}")
|
|
formatted = formatted.replace("D", f"{day}")
|
|
|
|
return formatted
|
|
|
|
|
|
def _build_bucket_key(document_date: date, calendar_type: str, reset_period: Optional[str]) -> str:
|
|
period = (reset_period or "never").lower()
|
|
dt = datetime.combine(document_date, datetime.min.time())
|
|
if calendar_type == "jalali":
|
|
cal = CalendarConverter.to_jalali(dt)
|
|
else:
|
|
cal = CalendarConverter.to_gregorian(dt)
|
|
year = cal["year"]
|
|
month = cal["month"]
|
|
day = cal["day"]
|
|
|
|
if period == "daily":
|
|
return f"{calendar_type}-D-{year:04d}{month:02d}{day:02d}"
|
|
if period == "monthly":
|
|
return f"{calendar_type}-M-{year:04d}{month:02d}"
|
|
if period == "yearly":
|
|
return f"{calendar_type}-Y-{year:04d}"
|
|
return f"{calendar_type}-ALL"
|
|
|
|
|
|
def _get_next_number(
|
|
db: Session,
|
|
business_id: int,
|
|
document_type: str,
|
|
start_number: int,
|
|
padding: int,
|
|
reset_period: Optional[str],
|
|
document_date: date,
|
|
calendar_type: str = "gregorian",
|
|
counter_scope_suffix: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
دریافت شماره بعدی با استفاده از جدول شمارنده.
|
|
counter_scope_suffix: برای جدا کردن شمارنده (مثلاً به ازای هر انبار در کد محل).
|
|
"""
|
|
bucket_key = _build_bucket_key(document_date, calendar_type, reset_period)
|
|
if counter_scope_suffix:
|
|
bucket_key = f"{bucket_key}|{counter_scope_suffix}"
|
|
|
|
counter_query = db.query(DocumentNumberCounter).filter(
|
|
and_(
|
|
DocumentNumberCounter.business_id == business_id,
|
|
DocumentNumberCounter.document_type == document_type,
|
|
DocumentNumberCounter.date_bucket == bucket_key,
|
|
)
|
|
)
|
|
|
|
counter: DocumentNumberCounter | None = None
|
|
max_attempts = 5
|
|
for _ in range(max_attempts):
|
|
counter = counter_query.with_for_update().first()
|
|
if counter:
|
|
break
|
|
try:
|
|
with db.begin_nested():
|
|
counter = DocumentNumberCounter(
|
|
business_id=business_id,
|
|
document_type=document_type,
|
|
date_bucket=bucket_key,
|
|
last_number=start_number - 1,
|
|
)
|
|
db.add(counter)
|
|
db.flush()
|
|
break
|
|
except IntegrityError:
|
|
continue
|
|
|
|
if not counter:
|
|
raise RuntimeError("Failed to initialize document counter.")
|
|
|
|
next_num = counter.last_number + 1
|
|
if next_num < start_number:
|
|
next_num = start_number
|
|
|
|
counter.last_number = next_num
|
|
counter.updated_at = datetime.utcnow()
|
|
db.flush()
|
|
|
|
return f"{next_num:0{padding}d}"
|
|
|
|
|
|
def _numbering_layout(db: Session, business_id: int, document_type: str) -> dict:
|
|
setting = (
|
|
db.query(BusinessDocumentNumberingSetting)
|
|
.filter(
|
|
and_(
|
|
BusinessDocumentNumberingSetting.business_id == business_id,
|
|
BusinessDocumentNumberingSetting.document_type == document_type,
|
|
BusinessDocumentNumberingSetting.is_active == True,
|
|
)
|
|
)
|
|
.first()
|
|
)
|
|
if not setting:
|
|
default_dict = _get_default_setting_for_type(document_type)
|
|
return {
|
|
"prefix": default_dict.get("prefix", "DOC"),
|
|
"separator": default_dict.get("separator", "-"),
|
|
"include_date": default_dict.get("include_date", True),
|
|
"date_format": default_dict.get("date_format", "YYYYMMDD"),
|
|
"calendar_type": default_dict.get("calendar_type", "gregorian"),
|
|
"start_number": default_dict.get("start_number", 1),
|
|
"number_padding": default_dict.get("number_padding", 4),
|
|
"reset_period": default_dict.get("reset_period", "never"),
|
|
}
|
|
return {
|
|
"prefix": setting.prefix or "DOC",
|
|
"separator": setting.separator or "-",
|
|
"include_date": setting.include_date,
|
|
"date_format": setting.date_format or "YYYYMMDD",
|
|
"calendar_type": setting.calendar_type or "gregorian",
|
|
"start_number": setting.start_number or 1,
|
|
"number_padding": setting.number_padding or 4,
|
|
"reset_period": setting.reset_period,
|
|
}
|
|
|
|
|
|
def _compose_document_code(layout: dict, document_date: date, sequence_number: int) -> str:
|
|
prefix = layout.get("prefix", "DOC")
|
|
separator = layout.get("separator", "-")
|
|
include_date = layout.get("include_date", True)
|
|
date_format = layout.get("date_format", "YYYYMMDD")
|
|
calendar_type = layout.get("calendar_type", "gregorian")
|
|
number_padding = int(layout.get("number_padding") or 4)
|
|
date_part = ""
|
|
if include_date:
|
|
date_part = _format_date(document_date, date_format, calendar_type)
|
|
number_part = f"{sequence_number:0{number_padding}d}"
|
|
if date_part:
|
|
return f"{prefix}{separator}{date_part}{separator}{number_part}"
|
|
return f"{prefix}{separator}{number_part}"
|
|
|
|
|
|
def _code_is_committed(db: Session, business_id: int, code: str) -> bool:
|
|
"""شماره وقتی مصرفشده است که سند ثبتشده یا رزرو فعال/استفادهشده داشته باشد."""
|
|
from adapters.db.models.document import Document
|
|
from adapters.db.models.document_numbering import DocumentCodeReservation
|
|
|
|
if (
|
|
db.query(Document.id)
|
|
.filter(and_(Document.business_id == business_id, Document.code == code))
|
|
.first()
|
|
is not None
|
|
):
|
|
return True
|
|
reservation = (
|
|
db.query(DocumentCodeReservation.id)
|
|
.filter(
|
|
and_(
|
|
DocumentCodeReservation.business_id == business_id,
|
|
DocumentCodeReservation.code == code,
|
|
DocumentCodeReservation.status.in_(("active", "used")),
|
|
)
|
|
)
|
|
.first()
|
|
)
|
|
return reservation is not None
|
|
|
|
|
|
def reclaim_trailing_unused_document_codes(
|
|
db: Session,
|
|
*,
|
|
business_id: int,
|
|
document_type: str,
|
|
document_date: date,
|
|
) -> int:
|
|
"""اگر آخرین شماره(های) دنباله سند/رزرو معتبر نداشته باشند، شمارنده را عقب میکشد.
|
|
|
|
برای انصراف از فرم ایجاد فاکتور: شماره رزروشده دوباره قابل استفاده میشود،
|
|
بدون دست زدن به شمارهای که سند ثبتشده یا رزرو فعال دیگری دارد.
|
|
"""
|
|
layout = _numbering_layout(db, business_id, document_type)
|
|
try:
|
|
start_number = int(layout.get("start_number") or 1)
|
|
except (TypeError, ValueError):
|
|
start_number = 1
|
|
calendar_type = layout.get("calendar_type") or "gregorian"
|
|
if not isinstance(calendar_type, str):
|
|
calendar_type = "gregorian"
|
|
reset_period = layout.get("reset_period")
|
|
bucket_key = _build_bucket_key(document_date, calendar_type, reset_period)
|
|
|
|
counter = (
|
|
db.query(DocumentNumberCounter)
|
|
.filter(
|
|
and_(
|
|
DocumentNumberCounter.business_id == business_id,
|
|
DocumentNumberCounter.document_type == document_type,
|
|
DocumentNumberCounter.date_bucket == bucket_key,
|
|
)
|
|
)
|
|
.with_for_update()
|
|
.first()
|
|
)
|
|
if not isinstance(counter, DocumentNumberCounter):
|
|
return 0
|
|
|
|
try:
|
|
last_number = int(counter.last_number)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
floor = start_number - 1
|
|
reclaimed = 0
|
|
while last_number > floor:
|
|
code = _compose_document_code(layout, document_date, last_number)
|
|
if _code_is_committed(db, business_id, code):
|
|
break
|
|
last_number -= 1
|
|
reclaimed += 1
|
|
if reclaimed:
|
|
counter.last_number = last_number
|
|
counter.updated_at = datetime.utcnow()
|
|
db.flush()
|
|
return reclaimed
|
|
|
|
|
|
def generate_document_code(
|
|
db: Session,
|
|
business_id: int,
|
|
document_type: str,
|
|
document_date: date,
|
|
) -> str:
|
|
"""
|
|
تولید شماره سند بر اساس تنظیمات کسب و کار یا پیشفرض
|
|
"""
|
|
layout = _numbering_layout(db, business_id, document_type)
|
|
number_part = _get_next_number(
|
|
db,
|
|
business_id,
|
|
document_type,
|
|
int(layout.get("start_number") or 1),
|
|
int(layout.get("number_padding") or 4),
|
|
layout.get("reset_period"),
|
|
document_date,
|
|
layout.get("calendar_type") or "gregorian",
|
|
)
|
|
sequence_number = int(number_part)
|
|
return _compose_document_code(layout, document_date, sequence_number)
|
|
|
|
|
|
def generate_warehouse_location_code(
|
|
db: Session,
|
|
business_id: int,
|
|
warehouse_id: int,
|
|
document_date: date,
|
|
) -> str:
|
|
"""
|
|
تولید کد محل انبار بر اساس تنظیمات نوع warehouse_location؛ شمارنده جدا برای هر انبار.
|
|
"""
|
|
document_type = "warehouse_location"
|
|
setting = (
|
|
db.query(BusinessDocumentNumberingSetting)
|
|
.filter(
|
|
and_(
|
|
BusinessDocumentNumberingSetting.business_id == business_id,
|
|
BusinessDocumentNumberingSetting.document_type == document_type,
|
|
BusinessDocumentNumberingSetting.is_active == True,
|
|
)
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not setting:
|
|
default_dict = _get_default_setting_for_type(document_type)
|
|
prefix = default_dict.get("prefix", "LOC")
|
|
separator = default_dict.get("separator", "-")
|
|
include_date = default_dict.get("include_date", True)
|
|
date_format = default_dict.get("date_format", "YYYYMMDD")
|
|
calendar_type = default_dict.get("calendar_type", "gregorian")
|
|
start_number = default_dict.get("start_number", 1)
|
|
number_padding = default_dict.get("number_padding", 4)
|
|
reset_period = default_dict.get("reset_period", "never")
|
|
else:
|
|
prefix = setting.prefix or "LOC"
|
|
separator = setting.separator or "-"
|
|
include_date = setting.include_date
|
|
date_format = setting.date_format or "YYYYMMDD"
|
|
calendar_type = setting.calendar_type or "gregorian"
|
|
start_number = setting.start_number or 1
|
|
number_padding = setting.number_padding or 4
|
|
reset_period = setting.reset_period
|
|
|
|
date_part = ""
|
|
if include_date:
|
|
date_part = _format_date(document_date, date_format, calendar_type)
|
|
|
|
scope_suffix = f"wh-{warehouse_id}"
|
|
number_part = _get_next_number(
|
|
db,
|
|
business_id,
|
|
document_type,
|
|
start_number,
|
|
number_padding,
|
|
reset_period,
|
|
document_date,
|
|
calendar_type,
|
|
scope_suffix,
|
|
)
|
|
|
|
if date_part:
|
|
return f"{prefix}{separator}{date_part}{separator}{number_part}"
|
|
return f"{prefix}{separator}{number_part}"
|
|
|