YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
PO Extractor
Offline purchase-order extraction API for Nexora UBL MT.
It takes a list of PO file URLs (PDF, scanned PDF, photo/CamScanner image, XLSX, XLS, CSV), works out
every purchase order inside each file, and returns clean JSON following
PO Data Extraction Guideline.xlsx.
Supported clients:
Client name (send this in client_name) |
account_name in output |
Formats seen |
|---|---|---|
ACI Logistics Ltd |
ACI Logistics Limited |
SAP PDF print (1 or many POs), SAP Excel export (many POs) |
ALFAMART TRADING BANGLADESH LIMITED DC TONGI |
ATBL DC TONGI |
JasperReports PDF |
AponTech Ltd |
APONTECH LIMITED |
Image-only PDF (screenshot) |
CSD Exclusive |
CSD Exclusive |
Word-generated PDF, scanned PDF, CamScanner JPG |
CSD General |
CSD Super Shop |
same 3S format as CSD Exclusive (scanned PDF) |
A2Y Service Uposhohor Sylhet |
a2y service |
spreadsheet printed to PDF |
AIR FORCE EXCLUSIVE SUPER SHOP |
AFESS |
"Purchase Order Details" Excel only |
Akij Lifecare Ltd. |
AKIJ LIFECARE LTD |
Akij Pharmacy PDF (two layouts) |
Direct Fresh Limited |
Direct Fresh Limited |
"Demand" Excel (no PO no / price) |
Khulshi Mart |
Khulshi Mart |
AponTech web-app PDF, header drawn as images |
Masala Bazaar |
Masala Bazaar |
web PO PDF with barcode |
Shopping Bag Super Shop / THE BASKET / Utshab Super Market |
Shopping Bag / The Basket / Utshab Super Market |
Mediasoft " PURCHASE ORDER" PDF |
Wholesale Club Ltd |
Wholesale Club Ltd |
Mediasoft "Purchase Order" PDF (two layouts) |
Later phase (guideline rows in red; requests return UNSUPPORTED_CLIENT): A K SUPER SHOP CHATTOGRAM,
AFESS Kurmitola PDF, MOSHAL BAZAR, Shihab Mart Ltd Keranigonj, Shihab Mart Ltd rangpur, Unique Mart Limited.
Their documents are rejected by content too, when sent under a supported client's name.
BIN is out of scope and not in the output.
Similar spellings are accepted (aci logistics limited, Apon Tech Limited, β¦).
Contents
- How it works
- Installation (conda) β or Docker
- Running
- API
- Output fields
- Field mapping per client
- Accuracy checks and
needs_review - Testing
- Configuration
- Project layout
- Adding a new client
- Troubleshooting
1. How it works
Backend ββPOST /extractβββΆ FastAPI βββΆ Redis queue βββΆ Worker (one file at a time)
βββ 202 {job_id} βββ β
Backend ββGET /jobs/{id}ββΆ FastAPI βββ job + results in Redis ββ
For every file in a job, the worker:
- Checks the client name from the request (unknown / not-yet-supported β
UNSUPPORTED_CLIENT). - Downloads the URL and detects the real file type from the bytes (the URL needs no extension).
- Reads each page:
- page has a usable text layer (system-generated or printed-to-PDF) β the text is read directly, no OCR, exact values;
- page has no text / garbled text (scan, CamScanner, screenshot PDF, photo) β rendered at scan resolution and read with PaddleOCR PP-OCRv5 on the GPU, with automatic rotation and skew correction;
- spreadsheets β cells read directly. Decided page by page, so mixed PDFs work.
- Checks whose document it is (
app/clients.py check_content; spreadsheets:check_spreadsheet). The document is always read with the rules of the client named in the request:- the requested client's own company text β no remark;
- a later-phase company's text (
other_clients, e.g. a Shihab Mart PO sent as "Utshab Super Market") βUNSUPPORTED_CLIENT, never processed; - another supported client's company text (e.g. a CSD General PO sent as "CSD Exclusive", a Khulshi Mart PO sent as "AponTech Ltd") β read with the requested client's rules, flagged;
- only the PO software's text (a scan with the letterhead cut off) β a warning;
- nothing identifying (PDF / image) β flagged. If no PO is found the file returns
NO_PO_FOUND.
- Parses into one or more POs (a new PO starts whenever the PO number changes, or when a new item table follows one that already ended). Only Shwapno (ACI) may send several POs in one file; any other client's file with 2+ POs returns every PO, each flagged.
- Scanned pages only β safety net:
- math repair: a row whose
qty Γ price β line totalis corrected only when exactly one single-number fix makes it consistent and it resembles what OCR read; - vision model: rows that are still wrong, and rows OCR missed (gaps in SL numbers), are cropped at full resolution and transcribed by a local vision model (Ollama
qwen2.5vl:7b); its numbers are used only if they are internally consistent. Descriptions always come from OCR when OCR has them.
- math repair: a row whose
- Merges duplicates (same PO number + same delivery address β one PO; different address β kept separate with a warning).
- Validates every PO (line math, item sum vs printed total, missing fields, SL gaps) and sets
quality.needs_review.
Everything runs locally; no document ever leaves the machine.
1.1 Architecture diagram β the whole system in one picture
Read it top to bottom: a file goes in at the top, and by the bottom it has become a validated PO with a pass/fail flag. Rounded boxes are people/systems, rectangles are code steps, diamonds are decisions, and cylinders are where things wait or are configured.
flowchart TD
Backend(["π’ Backend system<br/>(the caller)"]) -->|"POST /extract<br/>{client_name, url} per file"| API["FastAPI app<br/>(app/main.py)"]
API -->|"202 Accepted<br/>{job_id}"| Backend
API -->|"job queued"| Redis[("Redis queue<br/>(app/jobs.py)")]
Redis -->|"one file at a time"| Worker["Worker process<br/>(app/worker.py)"]
Backend -.->|"GET /jobs/{id}<br/>poll until completed"| API
API -.->|"job status + results"| Redis
Worker --> P0["app/pipeline.py β process_file()"]
P0 --> S1["β Resolve client<br/>app/clients.py<br/>match client_name / aliases"]
S1 -->|"unknown client"| E1["β UNSUPPORTED_CLIENT"]
S1 -->|"known"| S2["β‘ Download file<br/>app/fetch.py"]
S2 -->|"http error / timeout / too big"| E2["β DOWNLOAD_FAILED / FILE_TOO_LARGE"]
S2 --> S3["β’ Detect real file type from BYTES<br/>app/filetypes.py<br/>(no extension needed)"]
S3 -->|"not PDF/image/XLSX/XLS/CSV"| E3["β UNSUPPORTED_FILE_TYPE"]
S3 -->|"PDF / image"| R1["Read pages<br/>app/documents/pdf.py, image.py"]
S3 -->|"XLSX / XLS / CSV"| R2["Read cells directly<br/>app/documents/spreadsheet.py<br/>(exact values, no OCR)"]
R1 --> D1{"Page has a usable<br/>text layer?"}
D1 -->|"yes β born-digital or printed-to-PDF"| T1["Read text directly<br/>exact values, NO OCR"]
D1 -->|"no β scan / photo / CamScanner / screenshot"| O1["π€ PaddleOCR PP-OCRv5 (GPU)<br/>app/documents/ocr.py<br/>+ auto rotation & skew correction"]
T1 --> PG["Page list<br/>(decided page-by-page β mixed PDFs OK)"]
O1 --> PG
PG --> V1["β£ Whose document is it?<br/>app/clients.py check_content()<br/>signatures in config/clients.yaml<br/>(another client's β its parser, flagged)"]
R2 --> V1
V1 --> CFG[("config/clients.yaml<br/>client_name, aliases, signatures,<br/>parser name, required_fields,<br/>po_amount rule, mrp_default")]
CFG --> REG["app/registry.py<br/>parser name β ParserSpec<br/>(one parser per PO format)"]
REG --> S5["β€ Parse into RawPO(s)<br/>app/parsers/*.py<br/>values found by LABEL + column position,<br/>never by what a value 'looks like';<br/>a new PO starts when PO No. changes"]
V1 --> S5
S5 --> D2{"Any page read<br/>by OCR?"}
D2 -->|"no β fully digital"| S7
D2 -->|"yes"| S6["β₯ Scanned-page safety net<br/>app/reconcile.py"]
S6 --> M1["Math repair (deterministic)<br/>fix a row ONLY if exactly one<br/>single-number correction makes<br/>qty Γ price = line total"]
M1 --> M2{"Row still wrong,<br/>or row missing<br/>(gap in SL numbers)?"}
M2 -->|"no"| S7
M2 -->|"yes"| VLM["π€ Local vision model<br/>Ollama qwen2.5vl:7b<br/>app/vlm.py<br/>crop the row at full resolution β transcribe"]
VLM --> M3{"Model's own numbers<br/>internally consistent?<br/>(qty Γ price = total)"}
M3 -->|"yes β use the number<br/>(description still from OCR if OCR has it)"| S7
M3 -->|"no β leave it, flag for review"| S7
S7["β¦ Merge duplicates<br/>app/assemble.py merge_duplicates()<br/>same PO No. + same delivery address β merge<br/>same PO No. + different address β keep separate + warn"]
S7 --> S8["β§ Validate + build quality block<br/>app/assemble.py to_purchase_order()"]
S8 --> Q1["Check every line:<br/>qty Γ price = line total?"]
S8 --> Q2["Check item sum vs<br/>printed control total"]
S8 --> Q3["Check required fields present<br/>(per config/clients.yaml)"]
S8 --> Q4["Check SL numbers:<br/>no gaps, no duplicates, start at 1"]
Q1 & Q2 & Q3 & Q4 --> D3{"Anything wrong<br/>or missing?"}
D3 -->|"yes"| NR["needs_review = true<br/>+ human-readable warnings<br/>(PO is still returned β never dropped)"]
D3 -->|"no"| OK["needs_review = false<br/>(safe to auto-accept)"]
NR --> OUT["PurchaseOrder JSON<br/>account_name, po_no, po_date, po_amount,<br/>items[], damage_items, quality{}"]
OK --> OUT
OUT --> Redis
Redis --> API
API -->|"GET /jobs/{id} β completed<br/>{purchase_orders, quality, warnings}"| Backend
Backend --> Route{"needs_review?"}
Route -->|"false"| Auto["β
Auto-accept into ERP/system"]
Route -->|"true"| Human["π§ Route to a person<br/>with the warnings shown"]
E1 & E2 & E3 & E4 --> OUT2["error_code + error_message<br/>for THIS file only β<br/>other files in the job continue"]
OUT2 --> Redis
What each shape means, in plain terms:
- Backend β your system, e.g. Nexora UBL MT: sends file links in, polls for results.
- Rounded boxes with π€ β the two AI/ML models in the system: PaddleOCR PP-OCRv5 (reads text out of a scanned image, like a very accurate photo-to-text tool) and Ollama
qwen2.5vl:7b(a vision-language model used only as a second opinion on numbers OCR could not read reliably, and only on scanned documents). - Diamonds β a yes/no decision the code makes automatically, on every page and every row, without a human involved.
- Cylinders β a place where data sits: the Redis job queue (files waiting their turn) and
config/clients.yaml(the rules for each company, editable without touching code). - Nothing is ever silently dropped β a bad row is flagged (
needs_review: true) with a plain-English reason, not discarded; a bad file gets an error code but the rest of the job keeps going.
Every client sends the same format every time, but every value changes (PO numbers, dates, outlet codes, article codes, barcodes, quantities, prices, item counts, number of POs per file). The parsers are written so that no value is ever recognised by what it looks like:
- Header values are read as "whatever is printed after the label" (
PO. No.,Order Date:,Please send to :β¦) up to the next label on that line. Any PO numbering scheme, date style or language (09 Maret 2026), BIN or outlet code format is accepted and returned exactly as printed. - Item rows are found by the table structure (a line with numbers in the numeric columns, the SL
mark, the
AxB=Cquantity), and each value is taken from its column position, which is derived from the printed table header on every page β not from fixed coordinates or code lengths. - Wrapped text (descriptions over several lines, across page breaks) follows the row layout.
- Several POs in one file are split whenever a PO header appears β at the top of a page or in the middle of one.
- Client names, aliases, identifying text and the guideline rules live in
config/clients.yaml(section 9.1), not in code.
This is verified by the variation tests (section 8): the real sample pages are rewritten with every value changed at random, numbers made longer and rows removed, and the extraction must return exactly the changed values.
2. Installation (conda)
Tested on Ubuntu 26.04, NVIDIA RTX 3060 12 GB, driver 595 (CUDA 13.2), conda 26.
2.1 Create the environment (Python, Redis, Ollama)
conda create -n po-extractor -c conda-forge --override-channels python=3.12 redis-server "ollama=0.9.6=cuda_129*"
conda activate po-extractor
Python 3.12 is required: PaddlePaddle has no wheels for 3.13/3.14 yet. CPU-only machine: use
ollama=0.9.6=cpu*instead (the vision model will be very slow; setVLM_ENABLED=falseif needed).
2.2 Install PaddlePaddle (GPU)
PaddlePaddle GPU is not on PyPI in a usable version; install it from Paddle's own index before requirements.txt:
pip install paddlepaddle-gpu==3.3.1 -i https://www.paddlepaddle.org.cn/packages/stable/cu129/
(~3.5 GB with its CUDA libraries.) CPU-only machine: pip install paddlepaddle==3.3.1 -i https://www.paddlepaddle.org.cn/packages/stable/cpu/ and set OCR_USE_GPU=false.
Check it:
python -c "import paddle; paddle.utils.run_check()"
2.3 Install the Python packages
cd po-extractor
pip install -r requirements.txt
OpenCV: PaddleX installs
opencv-contrib-python. Never installopencv-pythonoropencv-python-headlessinto the same env β two OpenCV packages overwrite each other's files. Ifpython -c "import cv2; print(cv2.__version__)"fails, repair it withpip install --force-reinstall --no-deps opencv-contrib-python==4.10.0.84.
2.4 Download the models (one time, needs internet)
# Vision model (~6 GB). Needs a running Ollama server: either the system service
# (sudo systemctl start ollama) or `ollama serve` in another terminal.
ollama pull qwen2.5vl:7b
# PaddleOCR models (~200 MB) download automatically on first use; trigger it now:
python -c "from app.documents import ocr; ocr.warm_up()"
After this the system works fully offline.
Ollama keeps models per server: the system service (user
ollama) stores them under/usr/share/ollama/.ollama, whileollama serverun as yourself stores them in~/.ollama. Pull the model into the server you will actually use.
2.5 Configuration file (optional)
cp .env.example .env # defaults are fine for a single machine
Docker
Alternative to sections 2β3: everything (Redis, Ollama with qwen2.5vl:7b, API, worker, PaddleOCR models)
runs in containers. Only Docker is needed; a GPU host additionally needs the NVIDIA driver (>= 575) and
the NVIDIA Container Toolkit.
./scripts/docker-setup.sh # once per machine: creates .env, picks GPU or CPU
docker compose up -d --build # start
docker compose logs -f # logs (add a service: worker, api, ollama)
docker compose down # stop (-v also drops the vision model and uploads)
docker-setup.sh writes COMPOSE_FILE into .env, which docker compose reads by itself β that is why
the plain commands above need no -f flags. Re-run it with --cpu / --gpu to switch, or edit that line.
| File | Purpose |
|---|---|
Dockerfile |
one image for API + worker. COMPUTE=cpu|gpu picks paddlepaddle / paddlepaddle-gpu (CUDA 12.9); PaddleOCR models are baked in; INSTALL_TORCH=true adds PyTorch (not used by the app) |
docker-compose.yml |
CPU stack: redis, ollama, ollama-pull (one-shot model download into the ollama_models volume), api on port 8000, worker |
docker-compose.gpu.yml |
override: GPU image, OCR_USE_GPU=true, GPU reserved for api, worker and ollama |
docker-compose.files.yml |
override: serve FILES_DIR_HOST read-only at /files (like run.py --files-dir); set FILES_DIR_HOST in .env before re-running docker-setup.sh |
scripts/docker-smoke-test.sh, scripts/lint-docker.sh |
build + health + one extraction; hadolint + compose validation (also run in .github/workflows/docker-ci.yml) |
Notes:
- The first
uppulls the vision model (~6 GB); later starts reuse the volume.VLM_ENABLED=falseskips it. - Inside Docker the worker downloads files by URL from its own network:
localhostmeans the container. Files uploaded withPOST /uploadgethttp://api:8000/uploads/...URLs, which the worker can reach. - On CPU, scanned files are slow (OCR and especially the vision model); digital PDFs and spreadsheets are unaffected.
- Scaling means more worker containers (
--scale worker=N), each loading its own OCR models; on one GPU keep N=1.
3. Running
3.1 One command (recommended)
conda activate po-extractor
cd po-extractor
python run.py
Swagger UI: http://localhost:8000/docs
run.py starts and owns everything:
| Part | What happens |
|---|---|
| Redis | a private, in-memory Redis on a free localhost port β never touches another Redis you may run |
| Ollama | reuses an Ollama server already running at OLLAMA_URL; otherwise starts ollama serve itself |
| Worker | python -m app.worker (OCR, extraction, vision model) |
| API | uvicorn in the foreground |
| Uploads | a temporary folder under /tmp/po-extractor-* for files sent to /upload |
Stopping (Ctrl+C, kill <pid>, closing the terminal) stops the worker, Redis and an Ollama started
by run.py, and deletes the temporary folder with all uploads and jobs. After kill -9 the kernel
still stops the child processes; the leftover temp folder is removed the next time run.py starts.
An Ollama system service (systemctl) is not owned by run.py and keeps running; its loaded model
is freed automatically after 60 s of no use. To have run.py own Ollama too:
sudo systemctl disable --now ollama (then pull the model again as yourself, see 2.4).
Options:
python run.py --port 8080
python run.py --no-vlm # skip the vision model
python run.py --files-dir "/home/rakib/Desktop/Nexora UBL MT" # also serve a folder at /files/...
3.2 Testing with file upload (Swagger)
Upload a file β get its URL β extract it β all in http://localhost:8000/docs:
POST /uploadβ Try it out β Choose File (any PO on your computer) β Execute.{"file_name": "Aus Bangla.pdf", "size_bytes": 5007290, "url": "http://127.0.0.1:8000/uploads/8c795be1292047da9b984eee0784e9bd/Aus%20Bangla.pdf"}POST /extractβ Try it out β paste theurl:β{"files": [{"ref_id": "test-1", "client_name": "AponTech Ltd", "url": "http://127.0.0.1:8000/uploads/8c795be1292047da9b984eee0784e9bd/Aus%20Bangla.pdf"}]}{"job_id": "9a6f...", "status": "queued", "file_count": 1}GET /jobs/{job_id}β repeat until"status": "completed".
Repeat step 1 for more files and put several entries (even for different clients) in one /extract request.
Shortcut: POST /upload/extract takes client_name plus one or more files and queues the job
directly (steps 1 + 2 in one call).
Same with curl:
curl -s -X POST http://localhost:8000/upload -F "file=@/path/to/PO.pdf"
curl -s -X POST http://localhost:8000/extract -H "Content-Type: application/json" \
-d '{"files":[{"client_name":"AponTech Ltd","url":"<url from upload>"}]}'
curl -s http://localhost:8000/jobs/<job_id> | python -m json.tool
Uploaded files disappear when the server stops. The URLs use PUBLIC_BASE_URL
(default http://127.0.0.1:<port>), which is also where the worker downloads them from.
3.3 Testing a whole folder of POs
python run.py --files-dir "/home/rakib/Desktop/Nexora UBL MT"
then, in a second terminal:
python scripts/make_request.py "/home/rakib/Desktop/Nexora UBL MT" \
--base-url http://localhost:8000/files --only-supported --submit http://localhost:8000
make_request.py finds every PO file under the folder, builds its URL, guesses the client from the
folder name (3. ACI Logistics Limited β ACI Logistics Ltd), and checks the URLs are reachable
before submitting. Without --submit it prints the request body (paste it into POST /extract).
Use --client "AponTech Ltd" to force one client for every file.
3.4 Extract a local file without the API (debugging)
python scripts/extract_local.py "AponTech Ltd" "../OLD/8. AponTech Ltd/Agami aparels.pdf" --summary
python scripts/extract_local.py "ACI Logistics Ltd" "../Final/PO SAMPLE/3. ACI Logistics Limited/09.08.2026.pdf" --out aci.json
3.5 Running the parts separately (production / several machines)
run.py is convenient for one machine. For a long-running deployment you can run the parts under
systemd or Docker instead:
redis-server --port 6379 --bind 127.0.0.1 --save "" --appendonly no
ollama serve
python -m app.worker # REDIS_URL=redis://localhost:6379/0
uvicorn app.main:app --host 0.0.0.0 --port 8000
Redis commands that help when running it yourself:
| Task | Command |
|---|---|
alive? (PONG) |
redis-cli -p 6379 ping |
| jobs stored | redis-cli -p 6379 --scan --pattern 'poex:job:*' | wc -l |
| jobs waiting | redis-cli -p 6379 llen poex:queue |
| clear all jobs | redis-cli -p 6379 --scan --pattern 'poex:*' | xargs -r redis-cli -p 6379 del |
| stop | redis-cli -p 6379 shutdown nosave |
Redis startup warnings "Memory overcommit must be enabled" (only matters for saving to disk, disabled here) and "does not require authentication" (fine on localhost) can be ignored.
4. API
POST /extract
{
"files": [
{"ref_id": "66f1a2b3c4", "client_name": "ACI Logistics Ltd", "url": "https://files.example.com/po/09.08.2026.pdf"},
{"ref_id": "66f1a2b3c5", "client_name": "CSD Exclusive", "url": "https://files.example.com/po/66f1a2b3c5"}
]
}
ref_idβ optional, your own id; echoed back per file.client_nameβ required.urlβ required, plainhttp(s).
Response 202 Accepted:
{"job_id": "b1946ac92492d2347c6235b4d2611184", "status": "queued", "file_count": 2}
GET /jobs/{job_id}
status: queued β processing β completed (or failed if the worker crashed mid-job).
While processing, files_done counts finished files and results fills up in input order.
404 = unknown job (or the server was restarted β jobs are not persisted).
{
"job_id": "b1946ac92492d2347c6235b4d2611184",
"status": "completed",
"created_at": "2026-09-13T10:15:02.114Z",
"started_at": "2026-09-13T10:15:02.130Z",
"finished_at": "2026-09-13T10:15:05.902Z",
"file_count": 2,
"files_done": 2,
"results": [
{
"ref_id": "66f1a2b3c4",
"client_name": "ACI Logistics Ltd",
"url": "https://files.example.com/po/09.08.2026.pdf",
"status": "success",
"file_type": "pdf",
"po_count": 71,
"purchase_orders": [
{
"account_name": "ACI Logistics Limited",
"delivery_address_code": "E003",
"delivery_address": "Phultala Bazar, Khulna, Jashore Road, Phultala Bazar, Phultala, 9210 Khulna",
"po_no": "4007075571",
"po_date": "09.08.2026",
"po_amount": 60879.39,
"items": [
{"sl": 1, "client_item_code": "2303465", "item_description": "Junior Horlicks Stage2 450Β±50g (Jar)",
"trade_price": 514.02, "mrp": null, "qty_pcs": 3.0}
],
"damage_items": null,
"quality": {
"extraction_method": "text_layer",
"source_pages": [1, 2, 3, 4],
"items_total_label": "Net PO. Value",
"items_total_printed": 60879.39,
"items_total_computed": 60879.39,
"items_total_matches": true,
"line_math_errors": [],
"missing_fields": [],
"warnings": [],
"needs_review": false
}
}
],
"error_code": null,
"error_message": null,
"processing_ms": 1420
},
{
"ref_id": "66f1a2b3c5",
"client_name": "AIR FORCE EXCLUSIVE SUPER SHOP",
"url": "https://files.example.com/po/66f1a2b3c5",
"status": "error",
"file_type": "pdf",
"po_count": 0,
"purchase_orders": [],
"error_code": "UNSUPPORTED_FILE_TYPE",
"error_message": "PDF / image POs are not supported for 'AIR FORCE EXCLUSIVE SUPER SHOP' (Excel expected).",
"processing_ms": 40
}
]
}
Error codes (per file)
| Code | Meaning |
|---|---|
UNSUPPORTED_CLIENT |
client_name unknown, or a client not supported in this phase, or the document is a later-phase company's PO sent under a supported name |
INVALID_URL |
not an http(s) URL |
DOWNLOAD_FAILED |
HTTP error, timeout, empty file |
FILE_TOO_LARGE |
over MAX_FILE_MB |
UNSUPPORTED_FILE_TYPE |
not PDF / image / XLSX / XLS / CSV, or a file type the client does not send (guideline "Acceptable Format") |
CORRUPT_FILE |
file cannot be opened |
CLIENT_MISMATCH |
not returned any more: a document of another supported company is read with the requested client's rules and flagged |
NO_PO_FOUND |
no purchase order recognised in the file |
INTERNAL_ERROR |
unexpected bug (logged by the worker) |
Other endpoints
POST /uploadβ multipartfile; stores one file and returns its URL for/extract(testing).POST /upload/extractβ multipartclient_name+files; uploads and queues a job in one step (testing).GET /uploads/...β uploaded files;GET /files/...β the folder given torun.py --files-dir.GET /clientsβ supported client names.GET /healthβ API + Redis status, queued job count, vision model availability.
5. Output fields
| Field | Type | Notes |
|---|---|---|
account_name |
string | fixed value from the guideline |
delivery_address_code |
string | null | |
delivery_address |
string | null | multi-line addresses joined with ", " |
po_no |
string | |
po_date |
string | exactly as printed (29.08.2026, 09 Maret 2026, 2026-06-02, 06-Aug-2026 06:38 PM) |
po_amount |
float | null | |
items[].sl |
integer | row number (running 1..n for ACI Excel) |
items[].client_item_code |
string | null | strings keep leading zeros |
items[].item_description |
string | clients with check_descriptions (AponTech, A2Y, Masala Bazaar): checked against the product master, see 7.1 |
items[].trade_price |
float | |
items[].mrp |
float | null | AponTech: printed MRP (0) |
items[].qty_pcs |
float | |
damage_items |
list | null | AponTech only: sl, item_name, item_code, damage_qty, rate, value |
quality |
object | see section 7 |
Fields the guideline marks N/A are always present with null.
6. Field mapping per client
ACI Logistics Ltd β PDF
| Output | Source |
|---|---|
delivery_address_code |
code line under Delivery Address: (D007, E003, F352β¦) |
delivery_address |
following lines up to the table; the Company placeholder line is skipped |
po_no / po_date |
PO. No. : / PO. Date : |
po_amount |
Net PO. Value (if the PDF leaves it blank: sum of Total Amount, with a warning) |
| items | Sl. No., Article, Article Description (wrapped lines incl. across pages), Net Price (Price + VAT), PO Qty |
The text layer's broken ‘à is restored to ±.
ACI Logistics Ltd β Excel (SAP export)
| Output | Source |
|---|---|
po_no |
Pur. Doc. (rows grouped per PO) |
po_date |
Doc. Date |
delivery_address_code |
Plnt |
delivery_address |
null (not in the export) |
po_amount |
sum of Net Value |
| items | running SL 1..n, Material, Short Text, Net Price, Quantity |
ALFAMART TRADING BANGLADESH LIMITED DC TONGI
| Output | Source |
|---|---|
delivery_address |
Please send to : β ATBL TONGI [DCTGI] (code null) |
po_no / po_date |
Number F P P / PO Date |
po_amount |
TOTAL SUPPLIER INVOICE |
| items | #n; code = barcode under the description; description = first line text; PRICE; Q_Pcs |
Check total: sum of TOTAL BASE vs TOTAL PURCHASE PRICE.
AponTech Ltd
| Output | Source |
|---|---|
delivery_address |
Shipping Address: text (code null) |
po_no / po_date |
Purchase Order No: / Order Date: |
po_amount |
Total row under the item table |
| items | SL, ITEM, RATE, MRP (0), QTY.; no item code |
damage_items |
the Damage Items table |
CSD Exclusive / CSD General
| Output | Source |
|---|---|
delivery_address |
Delivery Location: full text, e.g. CS15-CS15-Rajshahi Warehouse |
po_no / po_date |
PO No. / PO Date: including the time |
po_amount |
last value on the Total: line |
| items | SL.; Code-Product split at the first -; Cost/Unit; PO Qty = number after = |
Grid-table clients
a2y, Akij, Masala Bazaar, Shopping Bag / The Basket / Utshab and
Wholesale Club share app/parsers/table.py: each format module lists its printed column labels, and
rows are read by column position under those labels. The label β field mapping of each client is in
the docstring at the top of its parser module (app/parsers/<format>.py). Where the guideline gives
the client's own name as the delivery address, delivery_address_default in config/clients.yaml
supplies it. Khulshi Mart uses the AponTech parser (apontech_ship_to) with its BARCODE column and
Ship To:.
7. Accuracy checks and needs_review
Every PO carries a quality block:
| Field | Meaning |
|---|---|
extraction_method |
text_layer, spreadsheet, ocr, ocr+vlm, text_layer+ocr |
source_pages |
pages the PO came from |
items_total_label / items_total_printed / items_total_computed / items_total_matches |
printed control total vs. the sum of the item line totals |
line_math_errors |
SL numbers where qty Γ price β line total |
missing_fields |
required fields that could not be read (po_date, items[4].trade_price, β¦) |
warnings |
human-readable notes: corrections made, merged duplicates, unprinted totals, gaps in SL numbers, β¦ |
needs_review |
true when anything above is wrong or missing |
Recommendation for the backend: auto-accept POs with needs_review: false; route the rest to a person with the warnings shown. POs are never dropped for being imperfect β whatever was read is returned.
Digital PDFs and spreadsheets are exact. Scanned documents are as good as the scan: faint or handwritten-over digits can still fail; that is exactly what needs_review is for.
7.1 Item descriptions checked against the product master
Clients whose POs carry no item code (check_descriptions: true: AponTech, A2Y, Masala Bazaar) are
recognised by description only, so each description is checked against the backend's product master
(PRODUCT_MASTER_URL, read-only). The API process (for POST /extract/sync) and the worker (for
POST /extract, POST /upload/extract) each pull it at start-up and log
Product master pulled from MongoDB successfully for the β¦ : 595 rows, 255 products; it is reloaded in the
background every PRODUCT_MASTER_REFRESH_MIN (60) minutes, and GET /health shows product_master.
The client's wording is kept; only what differs from the master in form is changed:
| Step | Example |
|---|---|
| every line: upper case, single spaces, sizes in one form | Shampoo 340 ml β SHAMPOO 340ML, 90 Gm β 90G, 1 Ltr β 1L, OCR 170 MI β 170ML, White135g β WHITE 135G, 475Β±25ml β 475Β±25ML |
| the product is identified by brand + size, then by its variant words | BABY TOOTH PASTE PEPSODENT KIDS ORANGE 45 GM β PEPSODENT TOOTHPASTE KIDS ORANGE 45G |
| identified β brand, size and misspelt words take that product's spelling | Pepsodnet Germicheck 200 Gm β PEPSODENT GERMICHECK 200G; CloseUp β CLOSE-UP; Fair & Lovely β GLOW & LOVELY; 1 KG β 1000G when the master writes 1000G |
not identified β line left as in step 1, PO needs_review |
BEAUTY SOAP-DOVE-135 GM (pink or white?), SUNSILK HAIR FALL SOLUTION 340 ML (not in the master) |
A product is identified only when
- exactly one product of that brand and size explains every variant word of the line: a word the brand
uses for products of the same
productFormat(PINK,GUM,HAIR FALL SOLUTION=HFS). Master abbreviations in both descriptions count (SKN,CLNSNG,JNR,LQ HW= liquid hand wash); the client's own words (BEAUTY,BUY 3 GET 1 FREE, origin marksBD,IN) are ignored; - the line uses no word that names another brand's product of that format (
INTENSIVE,HANDSOME); - the line names the product, not just its kind: a word that is not a format word (
TOTAL,WHITE,STD JARβ notSHAMPOO,BODY LOTION) and that the other products of this brand and size do not all have, or the product explains the whole line.
On the sample folder (AponTech, A2Y, Masala Bazaar: 1,012 lines) 402 lines are identified, every one
checked by hand; the rest are flagged, mostly because the master has no product of that brand and size
(Lux 90G, Sunsilk Hair Fall, Fair & Lovely men's linesβ¦) or the line does not say the variant. Old brand
names are mapped in config/product_master.yaml (brand_aliases). A word is respelt only when the master
never uses it, so real words are not rewritten. Warnings list the corrected SL numbers and, with the
reason, the unidentified ones. If the product API cannot be reached the descriptions are returned as
printed with a warning (no needs_review).
8. Testing
pytest -q # all 108 tests (~75 s with GPU OCR)
pytest -q tests/test_units.py # fast unit tests only
pytest -q tests/test_variations.py # value-independence tests
| Test file | What it proves |
|---|---|
test_units.py |
client-name matching, file-type detection, duplicate merging, math repair never guesses |
test_digital_samples.py |
exact field values on the real ACI (PDF, 71-PO PDF, Excel), Alfamart and CSD PDFs |
test_scanned_samples.py |
the real AponTech / CSD scans and photo, incl. rejecting a CSD General scan |
test_variations.py |
value independence, on every real sample (digital and scanned): |
test_variations.py builds new documents from the real pages while keeping the format:
- every value changed (5 random seeds): every digit and letter of every value β PO numbers, dates, BINs, outlet codes, addresses, article codes / barcodes, descriptions, quantities, prices, totals, damage items β is substituted; printed labels and SL numbers stay. The parsers must return exactly the substituted values.
- bigger numbers: every number in the item tables gets an extra digit (wider, as a right-aligned column prints it).
- fewer rows: random item rows are deleted; the remaining rows must be read unchanged.
- spreadsheet values changed: the same for the ACI Excel export.
These tests already caught and fixed real issues, e.g. an address word starting with "Dlv" or "Pur" being mistaken for a header label, and a wrapped description line interleaving with the next row on a curved photo.
Results on the provided samples (RTX 3060, one API job, 12 files / 108 POs, 23 s total)
| File | Type | POs | Items | Time | needs_review |
|---|---|---|---|---|---|
ACI 09.08.2026.pdf (296 pages) |
text layer | 71 | 3402 | 1.2 s | 0 |
ACI 200055 ARC Distribution (8).pdf |
text layer | 1 | 72 | 0.03 s | 0 |
ACI ACI Category P.O. 01-SEP-2026.xlsx |
spreadsheet | 27 | 883 | 0.06 s | 0 |
Alfamart AZ01POC26000225.pdf |
text layer | 1 | 15 | 0.01 s | 0 |
CSD Exclusive ARC DISTRIBUTION UNILEVER(1).pdf |
text layer | 1 | 35 | 0.2 s | 0 |
AponTech Aus Bangla.pdf (image PDF) |
OCR | 1 | 38 (+11 damage) | 5.4 s | 0 |
AponTech Agami aparels.pdf (5 pages) |
OCR | 1 | 81 | 10.2 s | 0 |
CSD Exclusive 202608101541.pdf (scan) |
OCR | 1 | 65 | 3.9 s | 1 β row 35 is cut off the scan |
CSD Exclusive CamScanner β¦.jpg |
OCR | 1 | 35 | 1.8 s | 0 (page 1 of 3 only: PO amount computed, with a warning) |
For every text-layer / spreadsheet PO, each line satisfies qty Γ price = line total and the items add up to the printed total. The CSD General scan sent as "CSD Exclusive" is read with CSD General's rules and flagged.
Known limitations
- Marks written on the paper become part of the text when they touch it. The sample scans have
orange annotation numbers; one fused with a PO number (
PO-APON-JUN26-83+3.ββ¦-833). Clean production documents do not have these. - Faint or smudged numbers that neither OCR nor the vision model can read are left unverified; the PO is flagged instead of guessed.
- Handwriting is not read (handwritten POs are out of scope for this phase).
- Rounded documents: when a digital form prints whole taka (Akij) or one decimal (Masala), the line check allows exactly that rounding; scanned numbers get no such allowance.
- Scanned ACI and Alfamart POs use the same parsers as their digital versions but no scanned
sample existed to tune against β watch
needs_reviewon the first real ones. - A changed format (a label renamed, a column added or moved) is not a value change: the client's
parser in
app/parsers/has to be updated. The printed labels each parser relies on are listed as constants at the top of its file.
The sample-based tests read the real PO files from ../Final/PO SAMPLE and ../OLD
(override with PO_SAMPLES_DIR / PO_OLD_SAMPLES_DIR); missing files are skipped.
OCR tests are skipped when PaddleOCR is not installed.
9. Configuration
9.1 Clients β config/clients.yaml
Everything client-specific that is not a format rule is configuration:
clients:
- key: csd_exclusive
client_name: CSD Exclusive # guideline "Clint Name"
account_name: CSD Exclusive # guideline "Account Name", written into every PO
parser: csd_exclusive # format rules (PARSERS in app/registry.py)
aliases: [CSD Exclusive, CSD Exclusive Rajshahi] # accepted spellings of client_name
signatures: [CSD EXCLUSIVE, EXCLUSIVE WAREHOUSE] # text proving a document is this client's
weak_signatures: [PURCHASE ORDER-DRAFT, 3s-bd.com] # format only (letterhead cut) -> read with a warning
required_fields: [delivery_address, po_no, po_date, po_amount]
item_code_required: true
po_amount_is_items_total: true # PO amount = sum of line totals (computed if not printed)
mrp_default: null # AponTech: 0.0
has_damage_items: false
# also: multiple_pos_per_file (Shwapno only), trade_price_required, delivery_address_default,
# check_descriptions (no item code: match descriptions to the product master, see 7.1)
other_clients: # later phase; UNSUPPORTED_CLIENT by name and by document text (company names, never places)
Unique Mart Limited: [UNIQUE MART]
Restart run.py after editing. Another file can be used with CLIENTS_CONFIG=/path/clients.yaml.
A second client that sends one of the existing formats (e.g. another CSD branch on the same 3S
software) only needs a new entry here with parser: csd_exclusive.
9.2 Environment variables
Environment variables (or .env):
| Variable | Default | |
|---|---|---|
REDIS_URL |
redis://localhost:6379/0 |
|
REDIS_PREFIX |
poex |
key prefix |
RESET_JOBS_ON_STARTUP |
true |
API wipes all jobs on start |
UPLOAD_DIR |
/tmp/po-extractor-uploads |
set by run.py to its temporary folder |
PUBLIC_BASE_URL |
http://127.0.0.1:8000 |
base of upload URLs (set by run.py from --port) |
FILES_DIR |
β | folder served at /files (run.py --files-dir) |
DOWNLOAD_TIMEOUT_S |
120 |
|
MAX_FILE_MB |
100 |
|
OCR_USE_GPU |
true |
|
OCR_RENDER_DPI |
300 |
used when a PDF page is not a single embedded scan |
OCR_DET_MAX_SIDE |
2560 |
image size for text detection; 4000 needs more than 12 GB VRAM |
VLM_ENABLED |
true |
second reader for scanned rows |
OLLAMA_URL |
http://localhost:11434 |
|
OLLAMA_MODEL |
qwen2.5vl:7b |
|
OLLAMA_TIMEOUT_S |
600 |
|
PRODUCT_MASTER_URL |
https://bridge.hedigital.net/api/v1/products |
backend product API (GET, read-only) for 7.1; empty = off |
PRODUCT_MASTER_REFRESH_MIN |
60 |
how often the cached product list is reloaded (in the background) |
PRODUCT_MASTER_TIMEOUT_S |
30 |
per page request |
CLIENTS_CONFIG |
config/clients.yaml |
client configuration file |
AUDIT_LOG_ENABLED |
true |
write requests.log / responses.log (see 9.4) |
AUDIT_LOG_DIR |
logs |
folder for those two files (Docker: /var/lib/po-extractor/logs) |
AUDIT_LOG_MAX_MB |
50 |
rotate each file at this size |
AUDIT_LOG_BACKUPS |
10 |
how many rotated files to keep |
AUDIT_LOG_MAX_REQUEST_MB |
2 |
larger request bodies are logged by size only (uploads) |
9.3 Console log
The API, the worker, run.py and every module they import share one format (app/logging_setup.py):
15:40:15.991 INFO uvicorn Started server process [350689]
15:40:15.993 INFO api Job store reset on startup (0 keys removed)
15:40:22.593 INFO access 127.0.0.1 - "POST /extract HTTP/1.1" 202
15:40:23.114 INFO worker Job 9569ec86: 1 file(s)
15:40:41.802 WARN ocr OCR warm-up failed (scanned files will fail until fixed)
Time, level and source sit in fixed columns, so messages line up and a traceback stays indented
under its own message. Levels are coloured on a terminal and plain everywhere else β a redirected
file, a pipe, docker compose logs β and NO_COLOR=1 turns colour off anywhere (FORCE_COLOR=1
forces it on).
9.4 Request / response log
Every API call is written to two files of its own (app/audit.py), separate from the console log:
logs/requests.log one record per request β the full input body
logs/responses.log one record per response β the full response body, never truncated
Each record is a header line you can scan and an indented JSON object you can read, with a blank line between records:
βββ REQUEST Β· POST /extract Β· 05:37:18.923 UTC Β· 9569ec86 βββ
{
"request_id": "9569ec865f0540f0b114deb9e5dcee6a",
"ts": "2026-09-20T05:37:18.923805+00:00",
"method": "POST",
"path": "/extract",
"query": null,
"client": "10.0.0.7",
"content_type": "application/json",
"body": {
"files": [
{
"ref_id": "r1",
"client_name": "ACI Logistics Ltd",
"url": "https://β¦/po.pdf"
}
]
}
}
βββ RESPONSE 202 Β· POST /extract Β· 05:37:18.925 UTC Β· 3 ms Β· 9569ec86 βββ
{
"request_id": "9569ec865f0540f0b114deb9e5dcee6a",
"status": 202,
"duration_ms": 3,
"size_bytes": 95,
"body": {
"job_id": "β¦",
"status": "queued",
"file_count": 1
}
}
The request_id in the header pairs the two files and is also returned to the caller as the
X-Request-ID response header.
Queued jobs are answered by GET /jobs/{job_id}, so the extracted POs are in the response record of
that call (/extract/sync has them in its own). Everything the API ever returned is therefore in
responses.log, in full β a single completed job can be several MB, hence the generous rotation
defaults.
Not logged: the static mounts (/uploads, /files), /docs, /openapi.json, and file bytes β
a multipart upload is recorded as <multipart/form-data, 1234567 bytes not logged>; its JSON
response (file name, size, URL) is logged as usual.
Find one call:
grep "POST /extract" logs/requests.log | tail -5 # the headers alone: what came in, when
grep -A 30 9569ec86 logs/responses.log # the start of what was sent back for it
Back to objects (the headers are skipped):
python -c "from app import audit; print(len(audit.read('logs/responses.log')))"
Turn it off with AUDIT_LOG_ENABLED=false. In Docker the files live in the audit_logs volume:
docker compose exec api tail -f /var/lib/po-extractor/logs/responses.log.
10. Project layout
po-extractor/
βββ app/
β βββ main.py FastAPI app (routes)
β βββ worker.py job worker (python -m app.worker)
β βββ jobs.py Redis job store + queue
β βββ pipeline.py one file: download β type β pages β verify β parse β validate
β βββ fetch.py URL download
β βββ filetypes.py type detection from file bytes
β βββ clients.py loads config/clients.yaml; name matching, content verification
β βββ registry.py parser name -> parser functions + guideline rules
β βββ assemble.py merge duplicates, validation, quality block
β βββ reconcile.py math repair + vision-model re-read for scanned rows
β βββ descriptions.py item descriptions checked / respelt against the product master
β βββ product_master.py loads + caches the backend product API (read-only, hourly background reload)
β βββ vlm.py Ollama client
β βββ schemas.py request/response models (the JSON contract)
β βββ config.py settings
β βββ logging_setup.py console log format (shared by API, worker and run.py)
β βββ audit.py request / response log (logs/requests.log, logs/responses.log)
β βββ errors.py per-file error codes
β βββ textutil.py number/text helpers
β βββ documents/
β β βββ layout.py Token / Line / Page model shared by text layer and OCR
β β βββ pdf.py text layer + page rendering
β β βββ ocr.py PaddleOCR + rotation/skew correction
β β βββ image.py JPG/PNG/TIFF loading
β β βββ spreadsheet.py XLSX / XLS / CSV
β βββ parsers/
β βββ base.py shared table-reading helpers
β βββ aci.py ACI PDF + Excel
β βββ alfamart.py
β βββ apontech.py
β βββ csd_exclusive.py
βββ config/clients.yaml client names, aliases, signatures, guideline rules
βββ run.py one-command launcher (Redis + Ollama + worker + API, cleans up on exit)
βββ scripts/extract_local.py extract a local file without the API
βββ scripts/make_request.py build /extract requests for a folder of files
βββ tests/
β βββ variations.py generates value variations of real pages
β βββ test_*.py
βββ requirements.txt
βββ .env.example
βββ README.md
11. Adding a new client
Same format as an existing client (e.g. another branch on the same software): add an entry to
config/clients.yaml with the existing parser, and remove the name from other_clients. No code.
New format:
app/parsers/<format>.pyβparse(pages) -> list[RawPO](andparse_sheetsfor spreadsheets). Start from the closest parser: CSD for one-line rows with wrapped text, ACI for SAP-style blocks, AponTech for grid tables, Alfamart for two-line rows. Uselabel_value/find_label_valuefor header values and column positions from the table header for items β never value patterns.app/registry.pyβ register it inPARSERS.app/reconcile.pyβ add a row-start regex and a transcript row parser for the vision model.config/clients.yamlβ add the client withparser: <format>.- Tests β add the sample to
tests/test_digital_samples.py/test_scanned_samples.py, its label words toLABELSintests/variations.py, and the file toDIGITAL/SCANNEDintests/test_variations.py.
12. Troubleshooting
| Symptom | Fix |
|---|---|
GET /jobs/.. stays queued |
the worker is not running, or points to another Redis (with run.py check the terminal for worker errors) |
DOWNLOAD_FAILED ... Connection refused |
the file server behind the URL is not running; use /upload/extract or run.py --files-dir |
run.py: "model ... is not downloaded" |
pull it into the Ollama server that is running (see 2.4) |
| every scanned file fails with OCR errors | python -c "import paddle; paddle.utils.run_check()"; check nvidia-smi; try OCR_USE_GPU=false |
| warning "local vision model is not available" | start ollama serve and ollama pull qwen2.5vl:7b |
| CUDA out of memory | run only one worker; close other GPU apps; the 7B vision model needs ~6 GB |
404 for a job right after restart |
expected: jobs are not persisted |
| a large PO (e.g. the ACI JBP DSD PDF: 2,564 pages, 582 POs, ~4.7 MB of JSON) works on one machine but not another | 1) same code: git fetch && git status shows "up to date with origin/dev", then restart run.py / the containers (a running server keeps the old code); 2) compare .env with .env.example (.env is not in git); 3) the file takes ~15β30 s on a desktop CPU and longer on a slower one, so a caller of POST /extract/sync needs an HTTP timeout of β₯ 120 s (or use POST /extract + GET /jobs/{id}, which has no request timeout); 4) Swagger UI can hang the browser on multi-MB responses even with highlighting off; check logs/responses.log or call the API with curl instead |