dodil/erp-brick-inventory

DODIL ERP brick: inventory

src/brick_inventory/domain/location.py

"""Warehouses, zones and bins: the tree stock has an address in.

A location's code is its full path (WH1/A/01), fixed once a move names it. A subtree is every location whose code starts with
the parent's code and a slash, so on hand per zone is one prefix query, no graph walk. Virtual locations
({company}:SUPPLIER, :CUSTOMER, :LOSS, :TRANSIT, :OPENING, :CONSUMPTION) exist once per company and are not stock.
"""
import re
from typing import Optional

from brick_inventory.domain.errors import BadInput, RuleBroken
from brick_inventory.domain.values import (
    BLOCKED,
    COMPANY_VIRTUALS,
    HOLDING,
    QUALITY,
    STOCK_TYPES,
    UNRESTRICTED,
)

CODE = re.compile(r"^[A-Z0-9][A-Z0-9_./-]{0,62}$")
COMPANY = re.compile(r"^[A-Z0-9][A-Z0-9_-]{0,15}$")


def virtual_id(company_code: str, name: str) -> str:
    return f"{company_code}:{name}"


def consignment_id(company_code: str, bp_id: int) -> str:
    return f"{company_code}:CONSIGNMENT:{bp_id}"


def subcontract_id(company_code: str, bp_id: int) -> str:
    return f"{company_code}:SUBCONTRACT:{bp_id}"


def in_subtree(code: str, root: str) -> bool:
    return code == root or code.startswith(root + "/")


def check_code(code: str, what: str = "a location code") -> str:
    c = (code or "").strip()
    if not CODE.match(c):
        raise BadInput(f"{what} is upper-case letters, digits and - _ . / (at most 63), such as WH1/A/01: {code!r}")
    return c


def check_company(code: str) -> str:
    c = (code or "").strip()
    if not COMPANY.match(c):
        raise BadInput(f"a company code is upper-case letters, digits, - and _ (at most 16): {code!r}")
    return c


def check_status(stock_status: Optional[str], allowed: Optional[str]) -> tuple:
    """(stock_status, allowed types): a status is the DEFAULT type of what is put there, and the types it accepts are its limit."""
    status = stock_status or UNRESTRICTED
    if status not in STOCK_TYPES:
        raise BadInput(f"a stock status is one of {', '.join(STOCK_TYPES)}")
    if allowed in (None, ""):
        types = list(STOCK_TYPES) if status == UNRESTRICTED else [status]
    else:
        types = [t.strip() for t in allowed.split(",") if t.strip()]
        for t in types:
            if t not in STOCK_TYPES:
                raise BadInput(f"{t} is not a stock type ({', '.join(STOCK_TYPES)})")
    if status not in types:
        raise BadInput(f"a location whose status is {status} must accept {status} stock")
    return status, ",".join(t for t in STOCK_TYPES if t in types)


def new_location(location_id: str, warehouse: dict, parent: Optional[dict], kind: str) -> None:
    """Rules for adding a zone or a bin to a warehouse."""
    if kind not in ("view", "internal"):
        raise RuleBroken(f"{kind} locations are made by the system, not added to a warehouse by hand")
    if parent is None:
        raise RuleBroken(f"{location_id} needs a parent: the warehouse {warehouse['warehouse_id']} or one of its zones")
    if parent["warehouse_id"] != warehouse["warehouse_id"]:
        raise RuleBroken(f"{parent['location_id']} belongs to warehouse {parent['warehouse_id']}, not {warehouse['warehouse_id']}")
    if not in_subtree(location_id, parent["location_id"]) or location_id == parent["location_id"]:
        raise RuleBroken(f"{location_id} is not under {parent['location_id']}: a location's code is its full path")
    if parent["kind"] != "view":
        raise RuleBroken(f"{parent['location_id']} is a bin: stock is kept in it, so it holds no sub-locations")
    if parent.get("status") == "archived":
        raise RuleBroken(f"{parent['location_id']} is archived")


def usable_for_moves(loc: dict) -> None:
    if loc["status"] == "archived":
        raise RuleBroken(f"{loc['location_id']} is archived: no new move may name it")
    if loc["kind"] == "view":
        raise RuleBroken(f"{loc['location_id']} is a zone: stock is kept in its bins")


def accepts_type(loc: dict, stock_type: str) -> None:
    if loc["kind"] != "internal":
        return
    allowed = (loc.get("allowed_stock_types") or "").split(",") if loc.get("allowed_stock_types") else list(STOCK_TYPES)
    if stock_type not in allowed:
        raise RuleBroken(f"{loc['location_id']} takes {' and '.join(allowed)} stock only, not {stock_type}")


def holds_stock(loc: dict) -> bool:
    return loc["kind"] in HOLDING


def is_virtual_name(name: str) -> bool:
    return name in COMPANY_VIRTUALS


def location_status_view(loc: dict) -> str:
    return {UNRESTRICTED: "unrestricted", QUALITY: "quality inspection", BLOCKED: "blocked"}[loc.get("stock_status") or UNRESTRICTED]