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))