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.
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.