diff --git a/src/rowbridge/__init__.py b/src/rowbridge/__init__.py index 8520559..d2d18db 100644 --- a/src/rowbridge/__init__.py +++ b/src/rowbridge/__init__.py @@ -1,3 +1,8 @@ """RowBridge package.""" -__version__ = "0.5.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("rowbridge") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/src/rowbridge/matching.py b/src/rowbridge/matching.py index 0408205..1343c1c 100644 --- a/src/rowbridge/matching.py +++ b/src/rowbridge/matching.py @@ -31,17 +31,13 @@ class _RuleResult: def build_rules(mapping: FieldMapping, settings: MatchSettings) -> tuple[ComparisonRule, ...]: - has_secondary = bool(mapping.secondary_a and mapping.secondary_b) - primary_weight = 0.55 if has_secondary else 0.70 - amount_weight = 0.15 if has_secondary else 0.20 - rules = [ ComparisonRule( field="primary", column_a=mapping.primary_a, column_b=mapping.primary_b, kind=RuleKind.FUZZY_TEXT, - weight=primary_weight, + weight=0.55, ) ] if mapping.secondary_a and mapping.secondary_b: @@ -61,7 +57,7 @@ def build_rules(mapping: FieldMapping, settings: MatchSettings) -> tuple[Compari column_a=mapping.amount_a, column_b=mapping.amount_b, kind=RuleKind.NUMERIC_TOLERANCE, - weight=amount_weight, + weight=0.15, tolerance=settings.amount_tolerance, ) ) diff --git a/src/rowbridge/matching_utils.py b/src/rowbridge/matching_utils.py index e25f6ae..e3fd359 100644 --- a/src/rowbridge/matching_utils.py +++ b/src/rowbridge/matching_utils.py @@ -5,26 +5,72 @@ from datetime import date, datetime from decimal import Decimal, InvalidOperation -_NON_ALNUM = re.compile(r"[^a-z0-9]+") _DATE_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d.%m.%Y") def normalize_text(value: str) -> str: - decomposed = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") - return _NON_ALNUM.sub("", decomposed.casefold()) + normalized = unicodedata.normalize("NFKC", value).casefold() + return "".join(character for character in normalized if character.isalnum()) + + +def _valid_grouping(value: str, separator: str) -> bool: + unsigned = value.lstrip("+-") + groups = unsigned.split(separator) + return ( + len(groups) > 1 + and groups[0].isdigit() + and 1 <= len(groups[0]) <= 3 + and all(group.isdigit() and len(group) == 3 for group in groups[1:]) + ) def parse_amount(value: str) -> Decimal | None: - cleaned = value.strip().replace(" ", "") - if not cleaned: + stripped = value.strip() + if not stripped: return None - if any(character.isalpha() for character in cleaned): + if any(character.isalpha() for character in stripped): return None - if cleaned.count(",") == 1 and "." not in cleaned: + + parenthesized_negative = stripped.startswith("(") and stripped.endswith(")") + if ("(" in stripped or ")" in stripped) and not parenthesized_negative: + return None + if parenthesized_negative: + stripped = stripped[1:-1].strip() + + cleaned = stripped.replace(" ", "") + cleaned = re.sub(r"[^0-9,.\-]", "", cleaned) + comma_count = cleaned.count(",") + dot_count = cleaned.count(".") + + if comma_count and dot_count: + decimal_separator = "," if cleaned.rfind(",") > cleaned.rfind(".") else "." + grouping_separator = "." if decimal_separator == "," else "," + integer_part, fractional_part = cleaned.rsplit(decimal_separator, 1) + + if decimal_separator in integer_part or grouping_separator in fractional_part: + return None + if grouping_separator in integer_part: + if not _valid_grouping(integer_part, grouping_separator): + return None + integer_part = integer_part.replace(grouping_separator, "") + + cleaned = f"{integer_part}.{fractional_part}" + elif comma_count == 1: cleaned = cleaned.replace(",", ".") - else: + elif comma_count > 1: + if not _valid_grouping(cleaned, ","): + return None cleaned = cleaned.replace(",", "") - cleaned = re.sub(r"[^0-9.\-]", "", cleaned) + elif dot_count > 1: + if not _valid_grouping(cleaned, "."): + return None + cleaned = cleaned.replace(".", "") + + if parenthesized_negative: + if cleaned.startswith("-"): + return None + cleaned = f"-{cleaned}" + try: return Decimal(cleaned) except (InvalidOperation, ValueError): @@ -35,6 +81,12 @@ def parse_date(value: str) -> date | None: stripped = value.strip() if not stripped: return None + + try: + return datetime.fromisoformat(stripped).date() + except ValueError: + pass + for format_string in _DATE_FORMATS: try: return datetime.strptime(stripped, format_string).date() diff --git a/src/rowbridge/web.py b/src/rowbridge/web.py index 73a0f70..f401131 100644 --- a/src/rowbridge/web.py +++ b/src/rowbridge/web.py @@ -16,6 +16,7 @@ from starlette.middleware.base import RequestResponseEndpoint from starlette.middleware.trustedhost import TrustedHostMiddleware +from rowbridge import __version__ from rowbridge.config import Settings from rowbridge.exports import build_reconciliation_csv, build_reconciliation_xlsx from rowbridge.ingestion import ( @@ -126,7 +127,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: repository.initialize() templates = Jinja2Templates(directory=PACKAGE_DIR / "templates") - app = FastAPI(title="RowBridge", version="0.6.0") + app = FastAPI(title="RowBridge", version=__version__) app.add_middleware( TrustedHostMiddleware, allowed_hosts=["127.0.0.1", "localhost", "testserver"], diff --git a/tests/test_matching.py b/tests/test_matching.py index 3d895e5..eada21b 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from rowbridge.candidates import generate_candidates from rowbridge.matching import ( build_rules, @@ -5,7 +7,7 @@ reconcile_with_diagnostics, score_pair, ) -from rowbridge.matching_utils import normalize_text +from rowbridge.matching_utils import normalize_text, parse_amount from rowbridge.models import CsvTable, FieldMapping, MatchSettings, MatchStatus, RuleKind @@ -14,6 +16,110 @@ def test_normalize_text_removes_case_spacing_and_punctuation() -> None: assert normalize_text("ACME, Ltd.") == "acmeltd" +def test_normalize_text_preserves_unicode_letters() -> None: + assert normalize_text( + "\u0422\u041e\u0412 \u00ab\u041a\u0438\u0457\u0432-2026\u00bb" + ) == "\u0442\u043e\u0432\u043a\u0438\u0457\u04322026" + assert normalize_text( + "Caf\u00e9, S\u00e3o Paulo!" + ) == "caf\u00e9s\u00e3opaulo" + assert normalize_text("Cafe\u0301") == "caf\u00e9" + + +def test_reconcile_matches_cyrillic_primary_values() -> None: + table_a = CsvTable( + filename="a.csv", + headers=("name",), + rows=( + { + "name": ( + "\u0422\u041e\u0412 " + "\u00ab\u0420\u043e\u043c\u0430\u0448\u043a\u0430\u00bb" + ) + }, + ), + ) + table_b = CsvTable( + filename="b.csv", + headers=("name",), + rows=( + { + "name": ( + "\u0442\u043e\u0432 " + "\u0440\u043e\u043c\u0430\u0448\u043a\u0430" + ) + }, + ), + ) + + decisions = reconcile( + table_a, + table_b, + FieldMapping(primary_a="name", primary_b="name"), + MatchSettings(), + ) + + assert len(decisions) == 1 + assert decisions[0].status == MatchStatus.AUTO_MATCHED + assert decisions[0].score == 1.0 + + +def test_parse_amount_supports_common_decimal_and_grouping_formats() -> None: + assert parse_amount("1234.56") == Decimal("1234.56") + assert parse_amount("1234,56") == Decimal("1234.56") + assert parse_amount("1,234.56") == Decimal("1234.56") + assert parse_amount("1.234,56") == Decimal("1234.56") + assert parse_amount("12,345,678.90") == Decimal("12345678.90") + assert parse_amount("12.345.678,90") == Decimal("12345678.90") + + +def test_parse_amount_preserves_accounting_parentheses_as_negative() -> None: + assert parse_amount("(1,234.56)") == Decimal("-1234.56") + assert parse_amount("(1.234,56)") == Decimal("-1234.56") + assert parse_amount("($1,234.56)") == Decimal("-1234.56") + assert parse_amount("-(1,234.56)") is None + + +def test_score_pair_matches_amounts_with_different_locale_separators() -> None: + mapping = FieldMapping( + primary_a="ref", + primary_b="reference", + amount_a="amount", + amount_b="total", + ) + + score, evidence = score_pair( + {"ref": "INV-001", "amount": "1.234,56"}, + {"reference": "INV001", "total": "1,234.56"}, + mapping, + MatchSettings(), + ) + + assert score == 1.0 + amount_evidence = next(item for item in evidence if item.field == "amount") + assert "difference 0.00" in amount_evidence.detail + + +def test_score_pair_accepts_excel_datetime_text_for_date_rule() -> None: + mapping = FieldMapping( + primary_a="ref", + primary_b="reference", + date_a="date", + date_b="paid_at", + ) + + score, evidence = score_pair( + {"ref": "INV-001", "date": "2026-10-02"}, + {"reference": "INV001", "paid_at": "2026-10-02 00:00:00"}, + mapping, + MatchSettings(), + ) + + assert score == 1.0 + date_evidence = next(item for item in evidence if item.field == "date") + assert "difference 0 day(s)" in date_evidence.detail + + def test_build_rules_uses_typed_comparators_and_expected_weights() -> None: mapping = FieldMapping( primary_a="ref", @@ -36,6 +142,44 @@ def test_build_rules_uses_typed_comparators_and_expected_weights() -> None: assert sum(rule.weight for rule in rules) == 1.0 +def test_optional_rules_keep_base_weights_before_normalization() -> None: + mapping = FieldMapping( + primary_a="ref", + primary_b="reference", + amount_a="amount", + amount_b="total", + date_a="date", + date_b="paid", + ) + + rules = build_rules(mapping, MatchSettings()) + + assert [(rule.field, rule.weight) for rule in rules] == [ + ("primary", 0.55), + ("amount", 0.15), + ("date", 0.10), + ] + + +def test_score_pair_normalizes_remaining_rule_weights() -> None: + mapping = FieldMapping( + primary_a="ref", + primary_b="reference", + amount_a="amount", + amount_b="total", + ) + + score, evidence = score_pair( + {"ref": "INV-001", "amount": "10.00"}, + {"reference": "INV001", "total": "11.00"}, + mapping, + MatchSettings(), + ) + + assert score == 0.785714 + assert [item.score for item in evidence] == [0.785714, 0.0] + + def test_score_pair_uses_primary_secondary_amount_and_date_evidence() -> None: mapping = FieldMapping( primary_a="ref", diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..a196b6d --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path + +from rowbridge import __version__ + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_runtime_version_matches_project_version() -> None: + pyproject = tomllib.loads( + (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + + assert __version__ == pyproject["project"]["version"]