Background Job — QuickBooks Posting Engine (picks → QB)
Background Job — QuickBooks Posting Engine
Turns a completed pick/issue session into QuickBooks documents. Code: app/ro_post.py (generated from skills/requirements_orders/scripts/post_pick_to_qb.py, verified live against production QuickBooks 2026-09-02).
Trigger / schedule
Not scheduled — it is request-driven. There is no timer, cron or queue. Entry points in app/ro_pick.py:
POST /ro/picks/{pick_id}/post→picks_post_one→_do_postPOST /ro/picks/postwith{"pick_ids": [...]}→picks_post_bulk→_do_postper id (errors collected, batch never crashes)- both call
post_pick_to_qb(pick_id)→ro_post.post()
Global lockout: ro_pick.POSTING_ENABLED = False ("Posting is not allowed during test." — Dave, 2026-09-02). Until that flag is flipped, every post attempt returns 403/ok:false and no QuickBooks document is created. Permission is additionally gated by _require_post(user).
Inputs
ro_pick(session header, must bestatus='OPEN', locked withFOR UPDATE OF p),ro_pick_line, joined toro_detailonline_keyforqty_source/line_type, andro_header(LEFT JOIN — a no-RO direct issue hasorder_no IS NULLand carries its own customer + class on the pick row, which win).im2.items.qb_item_idfor the SKU → QB item map (separate connection onIM2_DSN).- Connections:
RO_DSN(RealDictCursor) andIM2_DSN.
Rules
- Pick documents post at $0 — quantity carries the inventory movement,
UnitPrice = 0. - Positive picked qty → Invoice line; negative → CreditMemo line. A mixed pick creates two QB documents (Option A, Dave 2026-09-02).
- Explicit zero /
zeroed→ no QB line; writesro_detail.qty_adj = -qty_source(plusqty_source_at_adj,adj_by,adj_at,adj_note). Never touchesqty_requiredorqty_source. SURPLUSlines consume the RO line and create no QB document, only anro_txnrow.qty_picked IS NULL= untouched line, ignored.- Class is mandatory.
COALESCE(pick.class_ref, header.class_ref), else derived fromro_type(service 1202540 / residential 1202519 / commercial 1202520). Service ROs with no customer are forced to QB customer SA:Admin (15326) with the Service class. A no-RO issue with no class raisesPostError. - Customer resolution is exact match only —
FullyQualifiedNamethenDisplayName; anything other than exactly one hit raisesPostErrorrather than guessing the job. - Any SKU with no
qb_item_idaborts the whole post ("No QB item id for SKUs: …").
Tables written
| Table | Change |
|---|---|
ro_txn | one row per posted line: pick_id, order_no, line_key, sku, direction ('Issue'/'Return'), qty, qb_doc_type, qb_doc_id, qb_doc_number, txn_date, operator, source='Workbench'. Surplus rows get qb_doc_type = NULL. |
ro_pick | on success status='POSTED', posted_by, posted_at, updated_at, qb_invoice_id/_number, qb_credit_id/_number. On failure _do_post writes post_error in a separate small transaction. |
ro_detail | zero-adjustment columns for explicitly zeroed lines. |
ro_header | back-fills qb_customer_id, qb_customer_name, class_ref when they were resolved during the post. |
External calls
QuickBooks Online via app/qb.py (QBO, direct Intuit API, credentials in /opt/im2/env): POST Invoice and/or POST CreditMemo. dry_run=True returns the line counts and makes no QB call.
Failure behaviour / idempotency
- All DB work happens inside one
with conn:transaction; the QB POST happens inside it too, so a DB failure after a successful QB post would roll backro_txnwhile the QB document remains — the known hole in this design. There is no compensating delete. PostError(bad class, ambiguous customer, unmapped SKU) is raised before any QB call.- Not idempotent by itself; re-posting is prevented by state: the session must be
OPEN, and_do_postrejectsPOSTED(400 "already posted") andVOIDsessions. A failed attempt leaves the session OPEN withpost_errorset — that is the error state (there is nopost_statuscolumn) — and it can be retried from the Pick Review screen. - Bulk posting reports
{posted, failed, results, errors}and never aborts on one failure.
Logs to look at
ro_pick.post_error(truncated to 400 chars) — first place to look.journalctl -u im2on the droplet for the uvicorn traceback.ro_txnrows joined toro_pickto confirm what actually reached QuickBooks.
Known limits
- Posting is disabled (
POSTING_ENABLED = False) — nothing reaches QB today. - No automatic reversal/void path: a posted document must be corrected in QuickBooks.
- Customer match is exact-string only, so a QB job renamed after the RO was created will fail the post until the RO's
job_nameor the QB job is corrected. - Requires both
RO_DSNandIM2_DSNin the environment; the SKU→QB item map lives in a different database than the pick data.