← All manuals Operator Guide for this screen IM2

Saved Buy Lists

GET /po/buylists, GET /po/buylist/{list_id}
Technical Spec P2 Updated 2026-09-07 Matches the live screen

Saved Buy Lists — technical

Tables

po_buy_list / po_buy_line / po_count_list / po_count_list_line — DDL in ddl/po_workbench_p1.sql, with the placement/shape assumptions called out at the top of that file (short version: all four tables live in the ro database via ro_db(); po_buy_line has no order_no/line_key FK since it aggregates by SKU/description, matching the Workbench grid; the count-list header/line split is this module's read of an ambiguous single column list in the brief). No physical deletes anywhere in this file — po_buy_list.status flips to Cancelled, never a DELETE.

Save (POST /po/workbench/save)

Rows arrive as repeated form fields (row_key, sku, qty, cost, …), one value per checked grid row, index-aligned — see po_workbench.html's save handler for exactly what it appends. One po_buy_list row (status='Saved') plus one po_buy_line per checked row. qty_edited is set whenever the posted qty differs from the posted rec_qty (the value the grid showed at render time) — this is the one flag that gates whether a later recompute may touch qty (see below). Checked rows with cc=1 get line_status='Pending Count'; a po_count_list + one po_count_list_line per such row is created in the same transaction, bin taken from items.primary_bin at save time.

Reopen / drift (_refresh_drift(), called from buylist_detail() whenever status='Saved')

Re-reads items.qty_on_hand and _on_po_by_sku() for every SKU on the list and compares against the line's oh_at_save / on_po_at_save snapshots; any difference sets drift_flag and a human-readable drift_note ("OH 40 → 30"), persisted immediately (_persist_drift()).

Separately, for any Pending Count line: checks for a cycle_counts row on that SKU with counted_at > po_buy_list.saved_at. If found, recomputes rec qty via calc_rec_qty() using that count's counted_qty as the fresh OH input — assumption, flag to Viktor: items.qty_on_hand only updates once a count posts to QuickBooks (im2.py: post_counts()), but the brief says Pending Count clears when a count is "entered," not posted, so this reads the still-unposted cycle_counts.counted_qty directly rather than waiting on items.qty_on_hand. If Viktor confirms it should wait for the QB post instead, swap the counted_qty read in _refresh_drift() for a plain re-read of items.qty_on_hand.

Recompute outcomes:

Line edit (POST /po/buylist/{list_id}/line/{n}/edit)

One field at a time (qty | cost | vendor | delivery_date | cc), same blur-to-save pattern as grid.html. Editing qty here also sets qty_edited=true, so a manual edit made after reopening a list is protected on the next reopen too. Unchecking cc while Pending Count is explicitly allowed (brief section 5: "operator may uncheck").

Displayed status (buy_list_display_status()) [P2, 2026-09-06]

po_buy_list.status only ever holds Saved or Cancelled; the three words operators see are derived in one helper used by both /po/buylists and /po/buylist/{id} so they can never disagree:

status == 'Cancelled' -> Cancelled released_at is not null -> Ordered (dormant: nothing writes released_at yet) pending_count > 0 -> Pending Count else -> Ready to Order

The raw word Saved is never shown. Line-level po_buy_line.line_status is rendered as Pending Count / Dropped / blank (the DB's own Ready is not shown as a word).

CC Complete (POST /po/buylist/{list_id}/cc_complete) [P2, 2026-09-06]

Brief item 14, added 2026-09-05. require_write(), and 400 unless po_buy_list.status == 'Saved'. _recompute_cc_complete() runs over every non-Dropped line: re-reads items.qty_on_hand, _on_po_by_sku() and _qty_on_buy_lists_by_sku(exclude_list_id=list_id), recomputes rec_qty via calc_rec_qty() off req_qty_at_save. Then per line: cc_requested = false, line_status = 'Dropped' when the new rec is 0 (no qty_edited exception here — unlike the passive drift path — because this is a deliberate operator action), else 'Ready'; drift_flag/drift_note reused to mark a changed Qty to Order; qty resynced only when the line was not operator-edited. Stamps po_buy_list.cc_completed_at / cc_completed_by (columns flagged UNVERIFIED against the live schema in the route docstring). Returns {ok, cleared, changed[], dropped[]}. It never reads cycle_counts and never writes items.qty_on_hand.

Count-list bin fallback [P2, 2026-09-06]

POST /po/workbench/save takes each count-list line's bin from items.primary_bin, falling back to items.gen_loc when there is no primary bin (Dave/Viktor 2026-09-05 — a blank read as "nowhere" on the printed sheet); the print query still sorts coalesce(bin,'~') last when both are blank.

Cancel / export / count-list print

POST .../cancel flips status to Cancelled (idempotent). GET .../export streams a CSV in the same io.StringIO + csv.writer + StreamingResponse pattern as ro.py's exports. GET .../countlist renders a standalone print page (po_countlist.html, not extending base.html — same shape as doc.html) listing po_count_list_line rows in bin sequence for that save.

No release path

No route in this file writes qb_po_id, qb_po_line_id, post_error, released_at, or released_by — those columns exist only so Project 2 can add to this schema without a migration. There is no import qb anywhere in app/po.py.

Ver 2 - 2026-09-07 (Dave's PO testing round)

Ver 2 — 2026-09-07 (Dave's PO testing round)

SA_Call_Center_Routing_SOP_v2.pdf SA_Inbound_Customer_Contact_SOP.pdf __pycache__ agent_runs app app_visible artifacts ben_training_deck.py boms browser_sharepoint.py call_center_sop_v3.py camp_minden_temp channels check_download.py check_follow_up_attachments.py check_integration.py check_integration2.py check_integration3.py check_integration4.py close_browser.py company credentials crons data debug_dropbox.py docs download_all_boms.py download_approach.py download_approach2.py download_approach3.py download_boms.py download_boms2.py download_boms3.py download_browser.py download_by_id.py download_files.py download_sharepoint.py download_via_links.py download_xl.py download_xl2.py download_xl3.py download_xl4.py download_xl5.py download_xl6.py download_xl7.py download_xl8.py downloads emails engineering extract_boms.py fetch_customer.py fetch_customer2.py fetch_customer3.py fetch_customer4.py fetch_job.py files find_boms.py find_plans.py find_plans2.py fix_revenue.py ks_jobs.py logs mci_testing_procedure.pdf notes operations out output outputs pull_fp_schedule.py pull_lost_quick.py pull_schedule_efficient.py purchase_order_procedure.pdf pyproject.toml qb_lookup.py read_backlog.py reports repos resources scheduled_jobs_this_week.json scripts sdk search_ks_broad.py search_ks_solar.py secrets shared shawn_routing_analysis.md sitecustomize.py skills slack slack_visible sps_v3_p1.png sps_v3_p2.png sps_v3_p3.png sps_v3_p4.png state team temp temp_hbscan.txt temp_michael_candidate_id.txt temp_michael_followup.json temp_viktor_mentions.txt test_outlook2c.py test_outlook_calendar.py test_outlook_connection.py test_outlook_connection2.py test_outlook_connection3.py test_outlook_detailed.py test_outlook_direct.py test_outlook_list_tools.py test_outlook_send.py tmp_check_fp4.py tmp_fp_leads_results.json tmp_jude_est.py tmp_jude_est2.py tmp_pending_material_data.json tmp_quick_evse.py tmp_search_disconnect.py tmp_search_disconnect2.py tmp_search_disconnect3.py tmp_search_disconnect4.py tmp_search_disconnect5.py tmp_search_disconnect6.py try_sharepoint2.py try_sharepoint3.py try_sharepoint4.py uv.lock viktor-spaces vtracer-0.6.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl. Sorting is done in Python because is derived, not a column ( in ).

Ver 3 - 2026-09-07 - the screen updates itself

po_buylist.html Ver 6. Every .edit commit now also updates the DOM instead of leaving the page showing pre-edit server-rendered values:

Ver 4 - 2026-09-07 - hand-keyed release retired

Dave: "I'm not sure we should have a manual enter PO# option. This is supposed to be the screen to auto-create POs in QB and I don't want to break that. If the QB link is down they can enter the PO# for non-inv in the RO screen."

Shared table sort (2026-09-07)

Column sorting is one implementation in templates/base.html (Dave, 2026-09-07: "all screens that have tables like this need sorts on the appropriate columns"). A table opts in with class="sorttable"; every thead th becomes sortable except those with class="nosort"/class="toggle", an empty heading, or a checkbox in the heading. Client-side only, over the rows already rendered. Details: tech/table_sorting.md.

Ver 5 - 2026-09-07 - PO Cancelled + Open/In Process/Completed

Dave, 20:26: "Any reason not to make the status PO Cancelled? Maybe make the buy list status Open, In Process or Completed since the list lines can have different statuses."

Line level. po_buy_line_line_status_check now allows Ready | Pending Count | Dropped | Ordered | PO Cancelled. po_watch._cancel_buy_lines(cur, release_id=None, doc=None) runs at both sites that stamp po_release.status='Deleted in QB' (_mark_deleted and the inventory-only branch of scan_quickbooks) and moves that release's lines Ordered -> PO Cancelled, matched on list_id + vendor_id = po_release.vendor_name so a part-released list only cancels the deleted vendor's lines. Terminal: added to the exclusion lists in po.py (qty-on-buy-lists, ~line 460) and po_rel.py:101 (preflight), so the line cannot be re-released and the quantity reappears on the workbench. Backfill for the three existing list-26 lines is in the Viktor-side DDL script.

Header level. buy_list_display_status():

status = 'Cancelled' -> Cancelled released_at is null -> Open released, buyable lines remain -> In Process released, nothing buyable remains -> Completed

_STATUS_ORDER = {Open:0, In Process:1, Completed:2, Cancelled:3} drives the Status sort. Line-level pills are unchanged apart from the new red PO Cancelled one. Templates: po_buylist.html Ver 14, po_buylists.html.

Known and accepted (Dave, 20:36 "leave it"): a list whose POs were all deleted reads Completed, since nothing on it is left to buy. The line pills say what happened.