dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
src/brick_ar/domain/numbering.py
"""Document numbers: a format with a counter ("INV-{yyyy}-{seq:4}"), the series it counts in, and Odoo's names read back.
The series is the format up to its counter, with the year in it when numbers restart each year ("INV-2026"), so a
changed format starts a new series from its valid_from and a migrated "INV/2026/00012" continues "INV/2026".
"""
import re
_SEQ = re.compile(r"\{seq(?::(\d+))?\}")
def render(fmt: str, year: int, seq: int) -> str:
out = fmt.replace("{yyyy}", str(year))
return _SEQ.sub(lambda m: str(seq).zfill(int(m.group(1) or 1)), out)
def prefix(fmt: str, year: int) -> str:
"""The text every number of the format in that year starts with ("INV-2026-" for "INV-{yyyy}-{seq:4}")."""
m = _SEQ.search(fmt)
return (fmt[:m.start()] if m else fmt).replace("{yyyy}", str(year))
def _tidy(prefix: str) -> str:
return re.sub(r"[^A-Za-z0-9]+$", "", prefix)
def series_id(fmt: str, year: int, restarts_yearly: bool = True) -> str:
m = _SEQ.search(fmt)
prefix = fmt[:m.start()] if m else fmt
return _tidy(prefix.replace("{yyyy}", str(year) if restarts_yearly else "*"))
def check_pair(fmt: str, restarts_yearly: bool) -> None:
"""The format and the restart rule must agree: {yyyy} in the text of a number that never restarts would render the same
text in the series of a year that has already used it, and there must be a counter. Raises ValueError in words."""
if not _SEQ.search(fmt):
raise ValueError("a number format needs a counter such as {seq:4}")
if "{yyyy}" in fmt and not restarts_yearly:
raise ValueError("a number format with {yyyy} restarts each year: set number_restarts_yearly to true, or take {yyyy} out "
"of the format")
def match(fmt: str, number: str, restarts_yearly: bool = True):
"""(series id, counter) when `number` is text the format renders, else None: the series the format actually resolves to."""
pattern = "".join(
r"(?P<yyyy>\d{4})" if part == "{yyyy}" else r"(?P<seq>\d+)" if _SEQ.fullmatch(part) else re.escape(part)
for part in re.split(r"(\{yyyy\}|\{seq(?::\d+)?\})", fmt) if part)
try:
m = re.fullmatch(pattern, number or "")
except re.error: # a repeated {yyyy} or {seq}: not a format this module understands
return None
if not m or "seq" not in m.groupdict():
return None
year = int(m.group("yyyy")) if "yyyy" in m.groupdict() else 0
return series_id(fmt, year, restarts_yearly), int(m.group("seq"))
def credit_format(fmt: str) -> str:
"""A credit note takes CN in place of INV; a format with no INV in it is prefixed."""
return fmt.replace("INV", "CN", 1) if "INV" in fmt else "CN-" + fmt
def request_format(fmt: str) -> str:
"""A payment request takes DPR in place of INV (its own series); a format with no INV in it is prefixed."""
return fmt.replace("INV", "DPR", 1) if "INV" in fmt else "DPR-" + fmt
def parse_number(name: str):
"""(series id, counter) of a carried number such as 'INV/2026/00012', else None."""
m = re.match(r"^(.*?)(\d+)$", name or "")
if not m or not _tidy(m.group(1)):
return None
return _tidy(m.group(1)), int(m.group(2))