diff --git a/rowbridge_m4_4_patch/src/rowbridge/static/app.css b/rowbridge_m4_4_patch/src/rowbridge/static/app.css deleted file mode 100644 index 5e7cecd..0000000 --- a/rowbridge_m4_4_patch/src/rowbridge/static/app.css +++ /dev/null @@ -1,192 +0,0 @@ -:root { - font-family: "Segoe UI", Arial, sans-serif; - color: #1f2522; - background: #f4f5f3; - --bg: #f4f5f3; - --panel: #ffffff; - --panel-2: #f8f9f7; - --ink: #1f2522; - --muted: #69716d; - --faint: #909792; - --line: #d8ddd9; - --line-strong: #c7cec9; - --navy: #17243d; - --navy-2: #223555; - --green: #1f7758; - --amber: #a16b17; - --red: #a3454b; -} - -* { box-sizing: border-box; } -html, body { margin: 0; min-width: 320px; min-height: 100%; background: var(--bg); color: var(--ink); } -body { overflow: hidden; } -a { color: inherit; } -button, input, select { font: inherit; } -h1, h2, h3, p, dl, dd { margin: 0; } - -.app-header { height: 50px; background: #fff; border-bottom: 1px solid var(--line); } -.app-bar { width: min(1440px, calc(100% - 40px)); height: 100%; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 20px; } -.brand { display: inline-flex; align-items: center; gap: 9px; text-decoration: none; } -.brand-mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; background: var(--navy); color: white; font: 700 9px/1 Consolas, monospace; } -.brand-copy { display: grid; line-height: 1.05; } -.brand-copy strong { font-size: 14px; } -.brand-copy small { margin-top: 2px; color: var(--muted); font-size: 10px; } -.app-nav { display: flex; align-items: center; gap: 18px; font-size: 12px; font-weight: 600; } -.app-nav a { text-decoration: none; color: #4b544f; } -.local-state { display: inline-flex; align-items: center; gap: 6px; color: var(--green); } -.local-state i { width: 6px; height: 6px; border-radius: 50%; background: #2c9870; } - -.app-main { height: calc(100vh - 50px); overflow: hidden; } -.page { width: min(1440px, calc(100% - 40px)); height: 100%; margin: 0 auto; } -.kicker { color: #68716c; font: 700 9px/1.2 Consolas, monospace; text-transform: uppercase; letter-spacing: .09em; } -.page-toolbar { min-height: 86px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); } -.page-toolbar h1 { margin-top: 4px; font-size: 24px; line-height: 1.1; letter-spacing: -.02em; } -.page-toolbar p:not(.kicker) { margin-top: 5px; color: var(--muted); font-size: 12px; } -.compact-toolbar { min-height: 76px; } - -.stepbar { list-style: none; display: flex; align-items: center; gap: 0; margin: 0; padding: 0; border: 1px solid var(--line); background: #fff; } -.stepbar li { min-width: 74px; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 6px; padding: 8px 10px; border-left: 1px solid var(--line); color: #858d88; font-size: 10px; } -.stepbar li:first-child { border-left: 0; } -.stepbar b { color: #969d99; font: 700 9px/1 Consolas, monospace; } -.stepbar li.current { color: var(--ink); background: #f3f5f4; font-weight: 700; } -.stepbar li.current b { color: var(--navy); } -.stepbar li.done { color: var(--green); } - -.button { min-height: 34px; display: inline-flex; align-items: center; justify-content: center; padding: 0 13px; border: 1px solid var(--line-strong); border-radius: 4px; background: #fff; color: var(--ink); font-size: 11px; font-weight: 700; text-decoration: none; cursor: pointer; } -.button:hover { background: #f7f8f6; } -.button-primary { border-color: var(--navy); background: var(--navy); color: #fff; } -.button-primary:hover { background: var(--navy-2); } -.button:disabled { cursor: not-allowed; opacity: .45; } - -/* Start */ -.page-start { display: grid; grid-template-rows: auto 1fr; } -.file-workspace { min-height: 0; align-self: start; margin-top: 20px; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--line-strong); background: #fff; } -.file-pane { min-height: 255px; display: flex; flex-direction: column; padding: 24px; } -.file-pane + .file-pane { border-left: 1px solid var(--line); } -.file-pane-title { display: flex; align-items: center; gap: 10px; } -.side-id { width: 25px; height: 25px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line-strong); background: var(--panel-2); color: #36403b; font: 700 10px/1 Consolas, monospace; } -.file-pane h2 { margin-top: 2px; font-size: 18px; } -.file-help { margin-top: 16px; max-width: 520px; color: var(--muted); font-size: 12px; line-height: 1.45; } -.file-row { margin-top: 32px; display: flex; align-items: center; gap: 11px; } -.selected-file { min-width: 0; overflow: hidden; color: #68716c; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.file-facts { margin-top: auto; padding-top: 22px; display: grid; grid-template-columns: repeat(3, 1fr); } -.file-facts div { padding: 0 12px; border-left: 1px solid var(--line); } -.file-facts div:first-child { padding-left: 0; border-left: 0; } -.file-facts dt { color: #8a928d; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; } -.file-facts dd { margin-top: 3px; font-size: 11px; font-weight: 650; } -.file-actions { grid-column: 1 / -1; min-height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 9px 12px 9px 18px; border-top: 1px solid var(--line); background: #fafbfa; } -.file-actions p { color: #747d78; font-size: 10px; } - -/* Mapping */ -.page-map { display: grid; grid-template-rows: auto 1fr; } -.map-layout { min-height: 0; display: grid; grid-template-rows: 220px minmax(0, 1fr); gap: 12px; padding: 12px 0 16px; } -.preview-grid { min-height: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.preview-pane { min-width: 0; display: grid; grid-template-rows: auto 1fr; border: 1px solid var(--line-strong); background: #fff; overflow: hidden; } -.preview-pane > header { padding: 12px 14px 10px; border-bottom: 1px solid var(--line); } -.preview-pane > header > div { display: flex; align-items: center; gap: 9px; } -.preview-pane h2 { margin-top: 2px; font-size: 14px; } -.preview-pane > header > p { margin-top: 7px; color: var(--muted); font-size: 10px; } -.preview-scroll { min-height: 0; overflow: auto; } -.preview-scroll table { width: 100%; border-collapse: collapse; white-space: nowrap; } -.preview-scroll th { position: sticky; top: 0; padding: 6px 8px; background: #f6f7f5; border-bottom: 1px solid var(--line); color: #616b65; text-align: left; font: 700 8px/1.2 Consolas, monospace; text-transform: uppercase; } -.preview-scroll td { padding: 7px 8px; border-top: 1px solid #edf0ed; font-size: 10px; } -.mapping-panel { min-height: 0; border: 1px solid var(--line-strong); background: #fff; overflow: hidden; } -.mapping-panel form { height: 100%; display: grid; grid-template-rows: auto repeat(4, 1fr) auto; } -.mapping-head, .mapping-line { display: grid; grid-template-columns: minmax(280px, .9fr) minmax(220px, .55fr) minmax(220px, .55fr); gap: 14px; align-items: center; padding: 0 14px; } -.mapping-head { min-height: 34px; background: #f7f8f6; border-bottom: 1px solid var(--line); color: #66706a; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; } -.mapping-line { min-height: 54px; border-bottom: 1px solid #edf0ed; } -.mapping-line > div { display: grid; gap: 3px; } -.mapping-line b { font-size: 11px; } -.mapping-line small { color: var(--muted); font-size: 9px; } -select, input[type="number"] { height: 32px; width: 100%; border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; color: var(--ink); padding: 0 9px; font-size: 10px; } -.mapping-actions { min-height: 52px; display: flex; align-items: end; gap: 10px; padding: 8px 10px; background: #fafbfa; } -.mapping-actions label { display: grid; gap: 4px; width: 170px; color: #5d6761; font-size: 9px; font-weight: 700; } -.mapping-actions .button { margin-left: auto; } -.unit-input { position: relative; } -.unit-input input { padding-right: 38px; } -.unit-input span { position: absolute; right: 9px; top: 9px; color: #78817c; font-size: 9px; } - -/* Results */ -.page-results { display: grid; grid-template-rows: auto auto minmax(0, 1fr); padding: 14px 0 16px; } -.results-topbar { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } -.results-topbar h1 { margin-top: 3px; font-size: 23px; letter-spacing: -.02em; } -.results-topbar p:not(.kicker) { margin-top: 4px; color: var(--muted); font-size: 10px; } -.results-topbar p span { padding: 0 5px; color: #9ba29e; } -.export-actions { display: flex; gap: 7px; } -.summary-strip { height: 56px; display: grid; grid-template-columns: repeat(5, 1fr); border: 1px solid var(--line-strong); background: #fff; } -.summary-strip span { display: flex; align-items: baseline; gap: 7px; padding: 9px 12px; border-left: 1px solid var(--line); } -.summary-strip span:first-child { border-left: 0; } -.summary-strip b { font-size: 20px; } -.summary-strip small { color: var(--muted); font-size: 9px; } -.summary-strip .summary-review b { color: var(--amber); } -.results-layout { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 10px; padding-top: 10px; } -.results-panel { min-height: 0; display: grid; grid-template-rows: 34px minmax(0, 1fr); border: 1px solid var(--line-strong); background: #fff; } -.results-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 10px; border-bottom: 1px solid var(--line); background: #fafbfa; } -.results-panel-head > span { color: #8b938e; font-size: 8.5px; } -.tabs { height: 100%; display: flex; gap: 16px; align-items: center; } -.tabs a { height: 100%; display: inline-flex; align-items: center; position: relative; color: #69726d; font-size: 10px; font-weight: 650; text-decoration: none; } -.tabs a.active { color: var(--ink); } -.tabs a.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--navy); } -.tabs span { margin-left: 3px; color: var(--amber); } -.results-table-scroll { min-height: 0; overflow: auto; } -.results-table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; } -.results-table th { position: sticky; top: 0; z-index: 2; padding: 7px 8px; background: #f7f8f6; border-bottom: 1px solid var(--line); color: #616a65; text-align: left; font: 700 8px/1.2 Consolas, monospace; text-transform: uppercase; } -.results-table th:nth-child(1) { width: 92px; } .results-table th:nth-child(2) { width: 58px; } .results-table th:nth-child(3), .results-table th:nth-child(4) { width: 105px; } .results-table th:nth-child(6) { width: 112px; } -.results-table td { padding: 8px; border-top: 1px solid #edf0ed; vertical-align: top; font-size: 10px; line-height: 1.35; } -.result-review { background: #fffdf8; } -.status { display: inline-flex; align-items: center; gap: 5px; color: #616a65; font: 700 8px/1 Consolas, monospace; text-transform: uppercase; } -.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: #9aa29d; } -.status.auto_matched, .status.confirmed, .status.manual_matched { color: var(--green); } .status.auto_matched::before, .status.confirmed::before, .status.manual_matched::before { background: #368b68; } -.status.review { color: var(--amber); } .status.review::before { background: #cc9a34; } -.status.unmatched { color: var(--red); } .status.unmatched::before { background: #cc5f66; } -.record strong { display: block; overflow-wrap: anywhere; font-size: 10px; } .record small { display: block; margin-top: 2px; color: #8c948f; font-size: 8px; } -.evidence-list { display: grid; gap: 2px; } -.evidence-list div { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 5px; } -.evidence-list dt { font-weight: 700; color: #505a55; } .evidence-list dd { color: #606a64; } .evidence-list em { margin-left: 3px; color: #87908b; font-size: 8px; font-style: normal; } -.row-actions { display: flex; gap: 5px; } .row-actions form { margin: 0; } -.mini-button { min-height: 25px; padding: 0 7px; border: 1px solid var(--line-strong); border-radius: 3px; background: #fff; font-size: 9px; font-weight: 700; cursor: pointer; } -.mini-primary { border-color: var(--navy); background: var(--navy); color: #fff; } .mini-danger { color: var(--red); } -.muted { color: #919994; } -.empty-state { padding: 24px !important; text-align: center; } -.review-sidebar { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--line-strong); background: #fff; } -.review-sidebar section { min-height: 0; padding: 12px; } -.review-sidebar section + section { border-top: 1px solid var(--line); } -.review-sidebar h2 { margin-top: 3px; font-size: 14px; } -.manual-link-form { margin-top: 10px; display: grid; gap: 8px; } -.manual-link-form label { display: grid; gap: 4px; color: #5d6761; font-size: 9px; font-weight: 700; } -.manual-link-form .button { justify-self: start; } -.history-section { display: grid; grid-template-rows: auto minmax(0, 1fr); } -.history-scroll { min-height: 0; margin-top: 8px; overflow: auto; } -.history-item { padding: 8px 0; border-top: 1px solid #edf0ed; } .history-item:first-child { border-top: 0; } -.history-item strong { color: #59635e; font-size: 9px; text-transform: uppercase; } .history-item p { margin-top: 3px; font-size: 9px; } .history-item small { display: block; margin-top: 4px; color: #929994; font-size: 8px; } -.sidebar-empty { margin-top: 10px; font-size: 10px; line-height: 1.4; } - -@media (max-width: 1050px) { - body { overflow: auto; } - .app-main { height: auto; overflow: visible; } - .page { height: auto; min-height: calc(100vh - 50px); } - .file-workspace { grid-template-columns: 1fr; } - .file-pane + .file-pane { border-left: 0; border-top: 1px solid var(--line); } - .preview-grid, .results-layout { grid-template-columns: 1fr; } - .map-layout { grid-template-rows: auto auto; } - .preview-pane { height: 220px; } - .results-table-scroll { height: 540px; } - .review-sidebar { grid-template-columns: 1fr 1fr; grid-template-rows: none; } - .review-sidebar section + section { border-top: 0; border-left: 1px solid var(--line); } -} - -@media (max-width: 760px) { - .app-bar, .page { width: min(100% - 20px, 1440px); } - .brand-copy small, .app-nav a { display: none; } - .page-toolbar, .results-topbar { align-items: stretch; flex-direction: column; justify-content: center; } - .stepbar { overflow-x: auto; } - .mapping-head { display: none; } - .mapping-line { grid-template-columns: 1fr 1fr; padding: 10px; } - .mapping-line > div { grid-column: 1 / -1; } - .mapping-actions { flex-wrap: wrap; } - .summary-strip { overflow-x: auto; } - .summary-strip span { min-width: 135px; } - .review-sidebar { grid-template-columns: 1fr; } - .review-sidebar section + section { border-left: 0; border-top: 1px solid var(--line); } -} -.history-detail { color: #747d78; } diff --git a/rowbridge_m4_4_patch/src/rowbridge/static/app.js b/rowbridge_m4_4_patch/src/rowbridge/static/app.js deleted file mode 100644 index 43499ef..0000000 --- a/rowbridge_m4_4_patch/src/rowbridge/static/app.js +++ /dev/null @@ -1,46 +0,0 @@ -const fileInputs = ["rb-file-a", "rb-file-b"]; - -function updateFileFormState() { - const submit = document.querySelector("[data-submit-files]"); - if (!(submit instanceof HTMLButtonElement)) { - return; - } - - submit.disabled = !fileInputs.every((id) => { - const input = document.getElementById(id); - return input instanceof HTMLInputElement && Boolean(input.files?.length); - }); -} - -document.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Element)) { - return; - } - - const trigger = target.closest("button[data-file-trigger]"); - if (!(trigger instanceof HTMLButtonElement)) { - return; - } - - const input = document.getElementById(trigger.dataset.fileTrigger ?? ""); - if (input instanceof HTMLInputElement && input.type === "file") { - input.click(); - } -}); - -document.addEventListener("change", (event) => { - const input = event.target; - if (!(input instanceof HTMLInputElement) || input.type !== "file" || !input.id) { - return; - } - - const filename = document.querySelector(`[data-file-name-for="${CSS.escape(input.id)}"]`); - if (filename instanceof HTMLElement) { - filename.textContent = input.files?.[0]?.name ?? "No file selected"; - } - - updateFileFormState(); -}); - -updateFileFormState(); diff --git a/rowbridge_m4_4_patch/src/rowbridge/templates/base.html b/rowbridge_m4_4_patch/src/rowbridge/templates/base.html deleted file mode 100644 index 2dd49f2..0000000 --- a/rowbridge_m4_4_patch/src/rowbridge/templates/base.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - {% block title %}RowBridge{% endblock %} - - - - -
-
- - - RowBridgeData reconciliation - - -
-
-
- {% block content %}{% endblock %} -
- - diff --git a/rowbridge_m4_4_patch/src/rowbridge/templates/index.html b/rowbridge_m4_4_patch/src/rowbridge/templates/index.html deleted file mode 100644 index 2d96bb3..0000000 --- a/rowbridge_m4_4_patch/src/rowbridge/templates/index.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %}New reconciliation | RowBridge{% endblock %} -{% block content %} -
-
-
-

New reconciliation

-

Compare two data exports

-

Choose one CSV or Excel file for each side, then map the fields that describe the same record.

-
-
    -
  1. 1Files
  2. -
  3. 2Map
  4. -
  5. 3Review
  6. -
  7. 4Export
  8. -
-
- -
-
-
- A -

First source

Source file

-
-

Orders, ledger, CRM export, vendor list, or another table.

-
- - - No file selected -
-
-
Formats
CSV, XLSX
-
Limit
5 MB
-
Rows
50,000
-
-
- -
-
- B -

Second source

Source file

-
-

The export that should be reconciled against Side A.

-
- - - No file selected -
-
-
Formats
CSV, XLSX
-
Limit
5 MB
-
Columns
200
-
-
- - -
-
-{% endblock %} diff --git a/rowbridge_m4_4_patch/src/rowbridge/templates/mapping.html b/rowbridge_m4_4_patch/src/rowbridge/templates/mapping.html deleted file mode 100644 index 9f3dfa8..0000000 --- a/rowbridge_m4_4_patch/src/rowbridge/templates/mapping.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "base.html" %} -{% block title %}Map fields | RowBridge{% endblock %} -{% block content %} -
-
-
-

Field mapping

-

Map comparable fields

-

Confirm the detected structure and choose only fields that represent the same kind of value.

-
-
    -
  1. 1Files
  2. -
  3. 2Map
  4. -
  5. 3Review
  6. -
  7. 4Export
  8. -
-
- -
-
-
-
-
A

Source A

{{ table_a.filename }}

-

{{ table_a.rows|length }} rows · {{ table_a.headers|length }} columns · {{ details_a }}

-
-
{% for header in table_a.headers %}{% endfor %}{% for row in preview_a %}{% for header in table_a.headers %}{% endfor %}{% endfor %}
{{ header }}
{{ row[header] }}
-
-
-
-
B

Source B

{{ table_b.filename }}

-

{{ table_b.rows|length }} rows · {{ table_b.headers|length }} columns · {{ details_b }}

-
-
{% for header in table_b.headers %}{% endfor %}{% for row in preview_b %}{% for header in table_b.headers %}{% endfor %}{% endfor %}
{{ header }}
{{ row[header] }}
-
-
- -
-
- -
Matching rulesSide ASide B
- -
-
Primary identityReference, email, SKU, name, or another identifying value.
- - -
-
-
Secondary textOptional supporting text such as customer, company, email, or description.
- - -
-
-
AmountOptional numeric value with a configurable tolerance.
- - -
-
-
DateOptional date value with a configurable day window.
- - -
- - -
-
-
-
-{% endblock %} diff --git a/rowbridge_m4_4_patch/src/rowbridge/templates/results.html b/rowbridge_m4_4_patch/src/rowbridge/templates/results.html deleted file mode 100644 index c23c80c..0000000 --- a/rowbridge_m4_4_patch/src/rowbridge/templates/results.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "base.html" %} -{% block title %}Results | RowBridge{% endblock %} -{% block content %} -
-
-
-

Run {{ run.id[:8] }}

-

Reconciliation results

-

{{ run.filename_a }} ↔ {{ run.filename_b }}

-
- -
- -
- {{ summary.auto_matched }}Auto matched - {{ summary.human_matched }}Human matched - {{ summary.review }}Needs review - {{ summary.unmatched }}Unmatched - {{ run.total_a + run.total_b }}Source rows -
- -
-
-
- - Scores are suggestions until confirmed. -
- -
- - - - {% for match in matches %} - - - - - - - - - {% else %}{% endfor %} - -
StatusScoreSide ASide BEvidenceAction
{{ match.status.value|replace('_', ' ') }}{% if match.status.value == 'manual_matched' %}Manual{% elif match.score %}{{ "%.0f"|format(match.score * 100) }}%{% else %}-{% endif %}{% if match.a_payload %}{{ display_value(match, 'a', run.mapping.primary_a) }}row {{ match.a_row_number }}{% else %}No row{% endif %}{% if match.b_payload %}{{ display_value(match, 'b', run.mapping.primary_b) }}row {{ match.b_row_number }}{% else %}No row{% endif %}{% if match.evidence %}
{% for item in match.evidence %}
{{ item.field|replace('_', ' ') }}
{{ item.detail }}{% if item.score %}+{{ "%.0f"|format(item.score * 100) }}{% endif %}
{% endfor %}
{% else %}No candidate above review threshold.{% endif %}
{% if match.status.value == 'review' %}
{% else %}No action{% endif %}
No rows in this view.
-
-
- - -
-
-{% endblock %} diff --git a/rowbridge_m4_4_patch/tests/test_web.py b/rowbridge_m4_4_patch/tests/test_web.py deleted file mode 100644 index 509505e..0000000 --- a/rowbridge_m4_4_patch/tests/test_web.py +++ /dev/null @@ -1,298 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -from fastapi.testclient import TestClient - -from rowbridge.config import Settings -from rowbridge.web import create_app - -A_CSV = ( - b"invoice_ref,customer,amount,date\n" - b"INV-1,Acme Ltd,10.00,2026-10-01\n" - b"INV-2,Blue Finch GmbH,20.00,2026-10-02\n" -) -B_CSV = ( - b"reference,payer,total,paid_at\n" - b"INV1,ACME Limited,10.01,2026-10-02\n" - b"INV-9,Blue Finch GmbH,20.00,2026-10-02\n" -) - - -def make_client(tmp_path: Path) -> TestClient: - app = create_app(Settings(data_dir=tmp_path / "data")) - return TestClient(app) - - -def create_sample_run(client: TestClient) -> str: - prepared = client.post( - "/prepare", - files={ - "file_a": ("orders.csv", A_CSV, "text/csv"), - "file_b": ("payments.csv", B_CSV, "text/csv"), - }, - ) - assert prepared.status_code == 200 - assert "Secondary text" in prepared.text - stage_match = re.search(r'data-stage-id="([a-f0-9]+)"', prepared.text) - assert stage_match is not None - - created = client.post( - "/runs", - data={ - "stage_id": stage_match.group(1), - "primary_a": "invoice_ref", - "primary_b": "reference", - "secondary_a": "customer", - "secondary_b": "payer", - "amount_a": "amount", - "amount_b": "total", - "date_a": "date", - "date_b": "paid_at", - "amount_tolerance": "0.05", - "date_window_days": "2", - }, - follow_redirects=False, - ) - assert created.status_code == 303 - return created.headers["location"] - - -def review_match_id(html: str) -> int: - match = re.search(r'/matches/(\d+)/accept', html) - assert match is not None - return int(match.group(1)) - - -def test_vertical_slice_upload_map_persist_render_and_export(tmp_path: Path) -> None: - client = make_client(tmp_path) - location = create_sample_run(client) - - results = client.get(location) - assert results.status_code == 200 - assert "Reconciliation results" in results.text - assert "INV-1" in results.text - assert "INV1" in results.text - assert "Blue Finch GmbH" in results.text - assert ">Accept<" in results.text - assert "Link unmatched rows" in results.text - - fresh_client = make_client(tmp_path) - persisted = fresh_client.get(location) - assert persisted.status_code == 200 - assert "INV-1" in persisted.text - - export = fresh_client.get(f"{location}/export.csv") - assert export.status_code == 200 - assert "status,score,side_a_row" in export.text - assert "INV-1,INV1" in export.text - - -def test_accept_review_persists_confirmation_and_audit_event(tmp_path: Path) -> None: - client = make_client(tmp_path) - location = create_sample_run(client) - results = client.get(location) - match_id = review_match_id(results.text) - - accepted = client.post( - f"{location}/matches/{match_id}/accept", - follow_redirects=False, - ) - assert accepted.status_code == 303 - - fresh_client = make_client(tmp_path) - persisted = fresh_client.get(location) - assert persisted.status_code == 200 - assert "confirmed" in persisted.text - assert "Accepted proposed match." in persisted.text - assert "accept" in persisted.text - assert "0Needs review" in persisted.text - assert "1Human matched" in persisted.text - - repeated = fresh_client.post( - f"{location}/matches/{match_id}/accept", - follow_redirects=False, - ) - assert repeated.status_code == 409 - - -def test_reject_review_then_manually_link_unmatched_rows(tmp_path: Path) -> None: - client = make_client(tmp_path) - location = create_sample_run(client) - results = client.get(location) - match_id = review_match_id(results.text) - - rejected = client.post( - f"{location}/matches/{match_id}/reject", - follow_redirects=False, - ) - assert rejected.status_code == 303 - - after_reject = client.get(location) - assert "Rejected proposed match" in after_reject.text - assert "0Needs review" in after_reject.text - assert "2Unmatched" in after_reject.text - - a_option = re.search(r'', after_reject.text) - b_option = re.search(r'', after_reject.text) - assert a_option is not None - assert b_option is not None - - linked = client.post( - f"{location}/manual-links", - data={ - "a_match_id": a_option.group(1), - "b_match_id": b_option.group(1), - }, - follow_redirects=False, - ) - assert linked.status_code == 303 - - fresh_client = make_client(tmp_path) - persisted = fresh_client.get(location) - assert "manual matched" in persisted.text - assert "Linked two previously unmatched rows manually." in persisted.text - assert "1Human matched" in persisted.text - assert "0Unmatched" in persisted.text - - export = fresh_client.get(f"{location}/export.csv") - assert "manual_matched,,3,3,INV-2,INV-9" in export.text - - -def test_result_filters_reject_unknown_view(tmp_path: Path) -> None: - client = make_client(tmp_path) - location = create_sample_run(client) - - review = client.get(f"{location}?view=review") - assert review.status_code == 200 - assert "INV-2" in review.text - assert "INV-1" not in review.text - - invalid = client.get(f"{location}?view=wat") - assert invalid.status_code == 400 - - - -def test_home_file_picker_only_uses_explicit_browse_labels(tmp_path: Path) -> None: - client = make_client(tmp_path) - response = client.get("/") - assert response.status_code == 200 - assert 'id="rb-file-a"' in response.text - assert 'data-file-trigger="rb-file-a"' in response.text - assert 'id="rb-file-b"' in response.text - assert 'data-file-trigger="rb-file-b"' in response.text - assert 'hidden id="rb-file-a"' in response.text - assert 'hidden id="rb-file-b"' in response.text - assert 'app.css?v=m4.4' in response.text - assert 'app.js?v=m4.4' in response.text - assert 'class="file-input"' not in response.text - -def test_prepare_rejects_unsupported_files(tmp_path: Path) -> None: - client = make_client(tmp_path) - response = client.post( - "/prepare", - files={ - "file_a": ("a.txt", A_CSV, "text/plain"), - "file_b": ("b.csv", B_CSV, "text/csv"), - }, - ) - assert response.status_code == 400 - - -def xlsx_bytes(headers: list[str], rows: list[list[object]]) -> bytes: - import io - - from openpyxl import Workbook - - workbook = Workbook() - sheet = workbook.active - sheet.title = "Data" - sheet.append(headers) - for row in rows: - sheet.append(row) - output = io.BytesIO() - workbook.save(output) - workbook.close() - return output.getvalue() - - -def test_prepare_accepts_mixed_csv_and_xlsx_and_shows_preview(tmp_path: Path) -> None: - client = make_client(tmp_path) - side_b = xlsx_bytes( - ["reference", "payer", "total", "paid_at"], - [["INV1", "ACME Limited", 10.01, "2026-10-02"]], - ) - - response = client.post( - "/prepare", - files={ - "file_a": ("orders.csv", A_CSV, "text/csv"), - "file_b": ( - "payments.xlsx", - side_b, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ), - }, - ) - - assert response.status_code == 200 - assert "Map comparable fields" in response.text - assert "CSV · utf-8 · comma delimiter" in response.text - assert "Excel workbook · sheet Data" in response.text - assert "ACME Limited" in response.text - - -def test_export_workbook_contains_reconciliation_and_audit_sheets(tmp_path: Path) -> None: - import io - - from openpyxl import load_workbook - - client = make_client(tmp_path) - location = create_sample_run(client) - results = client.get(location) - match_id = review_match_id(results.text) - accepted = client.post(f"{location}/matches/{match_id}/accept", follow_redirects=False) - assert accepted.status_code == 303 - - exported = client.get(f"{location}/export.xlsx") - assert exported.status_code == 200 - assert "spreadsheetml.sheet" in exported.headers["content-type"] - - workbook = load_workbook(io.BytesIO(exported.content), read_only=True, data_only=True) - try: - assert workbook.sheetnames == [ - "Summary", - "Reconciliation", - "Unmatched A", - "Unmatched B", - "Review history", - ] - reconciliation = workbook["Reconciliation"] - headers = [cell.value for cell in next(reconciliation.iter_rows())] - assert headers[:6] == [ - "status", - "score", - "side_a_row", - "side_b_row", - "side_a_primary", - "side_b_primary", - ] - review = workbook["Review history"] - review_values = list(review.values) - assert any(row[1] == "accept" for row in review_values[1:]) - finally: - workbook.close() - - -def test_prepare_rejects_unsupported_file_type_with_clear_error(tmp_path: Path) -> None: - client = make_client(tmp_path) - response = client.post( - "/prepare", - files={ - "file_a": ("a.txt", A_CSV, "text/plain"), - "file_b": ("b.csv", B_CSV, "text/csv"), - }, - ) - assert response.status_code == 400 - assert "Use a .csv or .xlsx file" in response.text