Saved Buy Lists
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:
qty_editedisFalseand new rec qty is0→line_status='Dropped',qtyset to0(brief section 5: "a recompute to 0 drops the line with a note").qty_editedisFalseand new rec qty is>0→line_status='Ready',qtyresynced to the new rec qty.qty_editedisTrue(rule 3, "operator edits are sacred") →line_statusstill clears toReady(or the line still drops out of Pending Count status), butqtyis never touched — onlyrec_qtyand the drift note update, so the buyer sees the system's new recommendation without their own number being overwritten.
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)
- GET /po/buylists takes sort (list_id | saved_at | buy_count | status | total) and dir. Sorting happens in Python because display_status is derived, not a column (BUYLIST_SORTS in po.py).
- po_buy_list.cancelled_at added; buylist_cancel() stamps it (record only).
- show=cancelled is a plain toggle: cancelled lists are filtered out of the index regardless of age and the toggle brings them back. The first cut aged them off after CANCELLED_VISIBLE_DAYS, which made the button look broken when every cancelled list was new (Dave, 2026-09-07). No physical delete anywhere.
- The workbench parks its own query string in a wb_q cookie (path /po, 8h); _wb_back() reads it so the back link restores filters and sort.
- Cost inputs on the workbench and the buy list are type=text inputmode=decimal - no steppers, numeric keypad on a phone.
- Pre-flight suggests the nearest QuickBooks vendor name (_closest_vendor(): prefix or substring first, then difflib at 0.75). Hint only, nothing is auto-corrected.
Ver 2 — 2026-09-07 (Dave's PO testing round)
- takes ( | | | | ) and LEARNINGS.md
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 ).
- added; stamps it. Cancelled lists older than (7) are filtered out of the index unless . Rows without (cancelled before this deploy) fall back to . Still no physical delete anywhere.
- The workbench stores its own query string in a cookie (path , 8h); reads it so the back link restores filters and sort.
- Cost inputs on the workbench and the buy list are — no steppers, numeric keypad on a phone.
- Pre-flight now suggests the nearest QuickBooks vendor name (, prefix/substring then difflib at 0.75). Hint only; nothing is auto-corrected.
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:
- recalcRow() / recalcTotal() recompute the row's td.ext and #listTotal from the live qty and cost inputs. Dropped rows are excluded, same as the server-side sum.
- paintStatus() repaints td.statuscell when CC changes (Pending Count / Ready to Order).
- scheduleRelease() re-runs loadRelease() 250ms after the last commit, so a corrected vendor or class clears its own pre-flight error. Debounced because the check-all box fires one commit per row. loadRelease() preserves anything already typed into the panel's inputs across the re-render.
- All table inputs carry autocomplete=off: browsers restore form state on a reload, which can show a checkbox state that no longer matches the database.
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."
po_buylist.htmlVer 7: the release card renders vendor groups, an expected date per vendor, an optional note, and one Create POs in QuickBooks button. No PO number inputs. The button is disabled unlessrelease_panelreportsauto_po_enabled.POST /po/buylist/{id}/release/auto(po_rel Ver 2) is the new target. Stub for now: refuses with a plain reason, writes nothing. Slice B fills it in using the existingpreflight_release()and the samepo_release/ro_po_linkrows.POST /po/buylist/{id}/release/manualstill exists as break-glass but returns 400 unless settingallow_manual_po_entryis on. That setting is deliberately not on the Settings screen - it is set in the database if it is ever genuinely needed.
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.