chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared utilities (Jalali dates, ...)."""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Unified DateService — single source of truth for date handling across PM.
|
||||
|
||||
Design invariants:
|
||||
- Internally: Gregorian datetime.date (UTC-safe, no timezone shift).
|
||||
- API input/output: ISO 8601 (YYYY-MM-DD) or Jalali (YYYY/MM/DD) when convenient.
|
||||
- UI: Jalali (Shamsi) display with English/Persian digit support.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any
|
||||
|
||||
from app.utils.jalali import (
|
||||
days_in_month,
|
||||
gregorian_to_jalali,
|
||||
is_leap_jalali,
|
||||
jalali_to_gregorian,
|
||||
parse_flexible_date,
|
||||
to_jalali_str,
|
||||
)
|
||||
|
||||
|
||||
class DateService:
|
||||
@staticmethod
|
||||
def parse(value: Any) -> _dt.date | None:
|
||||
"""Parse ISO, Jalali, or datetime into a standard Gregorian date."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, _dt.date) and not isinstance(value, _dt.datetime):
|
||||
return value
|
||||
if isinstance(value, _dt.datetime):
|
||||
return value.date()
|
||||
return parse_flexible_date(str(value))
|
||||
|
||||
@staticmethod
|
||||
def to_iso(value: _dt.date | None) -> str | None:
|
||||
"""Format date as ISO 8601 string (YYYY-MM-DD)."""
|
||||
if value is None:
|
||||
return None
|
||||
return value.isoformat()
|
||||
|
||||
@staticmethod
|
||||
def today() -> _dt.date:
|
||||
"""Return today's Gregorian date."""
|
||||
return _dt.date.today()
|
||||
|
||||
@staticmethod
|
||||
def to_jalali(value: _dt.date | None, sep: str = "/") -> str | None:
|
||||
"""Format date as Jalali string (YYYY/MM/DD)."""
|
||||
return to_jalali_str(value, sep=sep)
|
||||
|
||||
@staticmethod
|
||||
def calculate_duration(start_date: _dt.date | None, end_date: _dt.date | None) -> int:
|
||||
"""Duration in days = the GAP between the two dates (end date not counted).
|
||||
|
||||
This is the convention the UI uses, so the number a user types is the
|
||||
number displayed back to them:
|
||||
|
||||
16 → 20 Mordad = 4 days
|
||||
start == end = 0 days (same day, no span)
|
||||
|
||||
Returns 0 when either date is missing or the range is inverted.
|
||||
"""
|
||||
if not start_date or not end_date:
|
||||
return 0
|
||||
diff = (end_date - start_date).days
|
||||
return max(0, diff)
|
||||
|
||||
@staticmethod
|
||||
def compute_end_date(start_date: _dt.date | None, duration_days: int) -> _dt.date | None:
|
||||
"""End date for a start date and a gap-based duration.
|
||||
|
||||
Exclusive, the exact inverse of calculate_duration:
|
||||
|
||||
compute_end_date(d, calculate_duration(d, e)) == e
|
||||
calculate_duration(d, compute_end_date(d, n)) == n
|
||||
|
||||
A duration of 0 (or 1) means the task starts and ends the same day.
|
||||
"""
|
||||
if not start_date:
|
||||
return None
|
||||
if duration_days <= 0:
|
||||
return start_date
|
||||
return start_date + _dt.timedelta(days=duration_days)
|
||||
|
||||
@staticmethod
|
||||
def today() -> _dt.date:
|
||||
"""Return current Gregorian date."""
|
||||
return _dt.date.today()
|
||||
|
||||
@staticmethod
|
||||
def today_jalali(sep: str = "/") -> str:
|
||||
"""Return today as Jalali string."""
|
||||
return to_jalali_str(_dt.date.today(), sep=sep) or ""
|
||||
|
||||
|
||||
date_service = DateService()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Jalali (Shamsi) ↔ Gregorian conversion — the ONLY date-conversion module.
|
||||
|
||||
Ported from the well-known `jalaali-js` algorithm (MIT), dependency-free.
|
||||
|
||||
Design notes:
|
||||
- Conversions work on plain (year, month, day) integers — no timezone or
|
||||
clock involvement at all, which is what makes date-only fields safe
|
||||
(no off-by-one-day bugs).
|
||||
- PM stores standard ISO dates (Gregorian) and converts only
|
||||
at the boundaries: parsing user input (Jalali → ISO) and rendering responses
|
||||
(`*_jalali` convenience fields). The frontend has its own mirrored copy of
|
||||
this algorithm in `frontend/src/lib/jalali.ts` for pickers/display.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import re
|
||||
|
||||
_BREAKS = [
|
||||
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
|
||||
1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178,
|
||||
]
|
||||
|
||||
|
||||
def _div(a: int, b: int) -> int:
|
||||
"""Truncating integer division (matches the JS reference implementation)."""
|
||||
q = abs(a) // abs(b)
|
||||
return q if (a >= 0) == (b >= 0) else -q
|
||||
|
||||
|
||||
def _mod(a: int, b: int) -> int:
|
||||
"""Remainder with the sign of the dividend (JS `%` semantics)."""
|
||||
return a - _div(a, b) * b
|
||||
|
||||
|
||||
def _jal_cal(jy: int, without_leap: bool = False) -> tuple[int, int, int]:
|
||||
"""(leap, gy, march) for a Jalali year."""
|
||||
bl = len(_BREAKS)
|
||||
gy = jy + 621
|
||||
leap_j = -14
|
||||
jp = _BREAKS[0]
|
||||
jump = 0
|
||||
if jy < jp or jy >= _BREAKS[bl - 1]:
|
||||
raise ValueError(f"سال جلالی نامعتبر: {jy}")
|
||||
for i in range(1, bl):
|
||||
jm = _BREAKS[i]
|
||||
jump = jm - jp
|
||||
if jy < jm:
|
||||
break
|
||||
leap_j += _div(jump, 33) * 8 + _div(_mod(jump, 33), 4)
|
||||
jp = jm
|
||||
n = jy - jp
|
||||
leap_j += _div(n, 33) * 8 + _div(_mod(n, 33) + 3, 4)
|
||||
if _mod(jump, 33) == 4 and jump - n == 4:
|
||||
leap_j += 1
|
||||
leap_g = _div(gy, 4) - _div((_div(gy, 100) + 1) * 3, 4) - 150
|
||||
march = 20 + leap_j - leap_g
|
||||
leap = 0
|
||||
if not without_leap:
|
||||
if jump - n < 6:
|
||||
n = n - jump + _div(jump + 4, 33) * 33
|
||||
leap = _mod(_mod(n + 1, 33) - 1, 4)
|
||||
if leap == -1:
|
||||
leap = 4
|
||||
return leap, gy, march
|
||||
|
||||
|
||||
def _g2d(gy: int, gm: int, gd: int) -> int:
|
||||
"""Gregorian date → Julian Day Number."""
|
||||
d = (
|
||||
_div((gy + _div(gm - 8, 6) + 100100) * 1461, 4)
|
||||
+ _div(153 * _mod(gm + 9, 12) + 2, 5)
|
||||
+ gd
|
||||
- 34840408
|
||||
)
|
||||
d = d - _div(_div(gy + 100100 + _div(gm - 8, 6), 100) * 3, 4) + 752
|
||||
return d
|
||||
|
||||
|
||||
def _d2g(jdn: int) -> tuple[int, int, int]:
|
||||
"""Julian Day Number → Gregorian date."""
|
||||
j = 4 * jdn + 139361631
|
||||
j = j + _div(_div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908
|
||||
i = _div(_mod(j, 1461), 4) * 5 + 308
|
||||
gd = _div(_mod(i, 153), 5) + 1
|
||||
gm = _mod(_div(i, 153), 12) + 1
|
||||
gy = _div(j, 1461) - 100100 + _div(8 - gm, 6)
|
||||
return gy, gm, gd
|
||||
|
||||
|
||||
def _j2d(jy: int, jm: int, jd: int) -> int:
|
||||
"""Jalali date → Julian Day Number."""
|
||||
_, gy, march = _jal_cal(jy, True)
|
||||
return _g2d(gy, 3, march) + (jm - 1) * 31 - _div(jm, 7) * (jm - 7) + jd - 1
|
||||
|
||||
|
||||
def _d2j(jdn: int) -> tuple[int, int, int]:
|
||||
"""Julian Day Number → Jalali date."""
|
||||
gy, _, _ = _d2g(jdn)
|
||||
jy = gy - 621
|
||||
leap, _, march = _jal_cal(jy, False)
|
||||
jdn1f = _g2d(gy, 3, march)
|
||||
k = jdn - jdn1f
|
||||
if k >= 0:
|
||||
if k <= 185:
|
||||
return jy, 1 + _div(k, 31), _mod(k, 31) + 1
|
||||
k -= 186
|
||||
else:
|
||||
jy -= 1
|
||||
k += 179
|
||||
if leap == 1:
|
||||
k += 1
|
||||
return jy, 7 + _div(k, 30), _mod(k, 30) + 1
|
||||
|
||||
|
||||
# --- public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
def gregorian_to_jalali(value: _dt.date) -> tuple[int, int, int]:
|
||||
"""date(2026, 9, 14) → (1405, 6, 23)"""
|
||||
return _d2j(_g2d(value.year, value.month, value.day))
|
||||
|
||||
|
||||
def is_leap_jalali(jy: int) -> bool:
|
||||
return _jal_cal(jy, False)[0] == 0
|
||||
|
||||
|
||||
def days_in_month(jy: int, jm: int) -> int:
|
||||
if jm <= 6:
|
||||
return 31
|
||||
if jm <= 11:
|
||||
return 30
|
||||
return 30 if is_leap_jalali(jy) else 29
|
||||
|
||||
|
||||
def jalali_to_gregorian(jy: int, jm: int, jd: int) -> _dt.date:
|
||||
"""(1405, 6, 23) → date(2026, 9, 14)"""
|
||||
if not 1 <= jm <= 12:
|
||||
raise ValueError(f"ماه جلالی نامعتبر است: {jm}")
|
||||
if not 1 <= jd <= days_in_month(jy, jm):
|
||||
raise ValueError(f"روز جلالی نامعتبر است: {jy}/{jm:02d}/{jd:02d}")
|
||||
gy, gm, gd = _d2g(_j2d(jy, jm, jd))
|
||||
return _dt.date(gy, gm, gd)
|
||||
|
||||
|
||||
def to_jalali_str(value: _dt.date | None, sep: str = "/") -> str | None:
|
||||
"""date → «1405/06/23» (Latin digits, zero-padded)."""
|
||||
if value is None:
|
||||
return None
|
||||
jy, jm, jd = gregorian_to_jalali(value)
|
||||
return f"{jy:04d}{sep}{jm:02d}{sep}{jd:02d}"
|
||||
|
||||
|
||||
_ISO_RE = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})$")
|
||||
_JALALI_RE = re.compile(r"^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$")
|
||||
_JALALI_COMPACT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$")
|
||||
|
||||
# Gregorian years PM can plausibly see; anything lower is treated as Jalali.
|
||||
_GREGORIAN_MIN_YEAR = 1700
|
||||
|
||||
|
||||
def parse_flexible_date(value: str | None) -> _dt.date | None:
|
||||
"""Accept ISO (`2026-09-14`), Jalali (`1405/06/23`, `1405-6-23`) or Jalali
|
||||
compact (`14050623`) and return a Gregorian date.
|
||||
|
||||
Disambiguation: a 4-digit year >= 1700 is Gregorian, otherwise Jalali.
|
||||
ISO datetime strings (`2026-09-14T10:00:00Z`) are truncated to the date.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# ISO datetime → date part
|
||||
if "T" in text:
|
||||
text = text.split("T", 1)[0]
|
||||
|
||||
match = _ISO_RE.match(text)
|
||||
if match:
|
||||
year, month, day = (int(g) for g in match.groups())
|
||||
if year >= _GREGORIAN_MIN_YEAR:
|
||||
return _dt.date(year, month, day)
|
||||
|
||||
match = _JALALI_RE.match(text) or _JALALI_COMPACT_RE.match(text)
|
||||
if match:
|
||||
jy, jm, jd = (int(g) for g in match.groups())
|
||||
if jy >= _GREGORIAN_MIN_YEAR: # e.g. 2026/09/14 written with slashes
|
||||
return _dt.date(jy, jm, jd)
|
||||
return jalali_to_gregorian(jy, jm, jd)
|
||||
|
||||
try:
|
||||
return _dt.date.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"قالب تاریخ نامعتبر است: {value}") from exc
|
||||
Reference in New Issue
Block a user