Create New Inventory Item — Technical Spec
1. Routes
Handlers in app/im2.py, template newitem.html.
| Method | Path | Purpose | Permission |
|---|---|---|---|
| GET | /new | The form | action item_create |
| POST | /api/items/new | Create the IM2 row + push to QB | action item_create |
| GET | /api/items/pending | Items with qb_status in ('pending','error','sent') | signed in |
| POST | /api/items/retry-qb | Re-push pending + error rows to QB | action item_qb_sync |
ACTION_DEFS registers item_create as "Create New Item — The /new screen and saving a new item." Permissions are data (role_action_perms, defaults in ACTION_DEFAULTS), editable on /perms.
2. GET context
next_sku(cur) = max(sku::bigint) where sku ~ '^[0-9]+$' + 1, floor 50001 — computed for display only, outside any lock. Context also carries setting_uoms(cfg), income_gl, expense_gl, default_count_group, list values for gen_loc / item_group / manufacturer / count_group, and pending (count of qb_status in ('pending','error','sent') on GET; the count returned by the create and retry endpoints counts ('pending','error') only).
Fixed bug retained in comments: the lead-time field used to be pre-filled from the PO Workbench syscon default_lead_time_days; it now renders blank and that syscon is left to po.py default_lead_time().
3. POST /api/items/new
Form fields: item_name, description_po, uom, primary_bin, secondary_bin, gen_loc, item_group, manufacturer, mfr_part_no, min_qty, max_qty, count_group, notes, primary_vendor, purchase_cost, lead_time_days.
Validation, in order:
require_action(user, "item_create").item_nameanddescription_poboth non-blank → else 400.uommust be insetting_uoms(settings())→ else 400 (UOMSis EA/FT today).num()onpurchase_cost,min_qty,max_qty,lead_time_days: blank → NULL, non-numeric → 400.- Min/Max rules (Dave, 2026-09-06): either value below 0 → 400
Min qty cannot be negative/Max qty cannot be negative; both present andmin > max→ 400Min qty (n) cannot be more than Max qty (n). Either may be blank. pg_advisory_xact_lock(SKU_LOCK), then a case-insensitiveitem_nameuniqueness check → 400 if taken.next_sku(cur)inside the lock — the authoritative SKU, which may differ from the one rendered.INSERT INTO itemswithqty_on_hand = 0,description_so = description_po,qb_status='pending',qb_active=false,income_gl/expense_glfrom settings,count_groupdefaulting tocfg['default_count_group'],created_by/updated_by= username, timestampsnow(). Bin/UOM normalisation (uppercase/trim) is the DB triggeritems_normalize_trg.audit_logrow:field_name='item',new_value=item_name,source='new item screen'.
The IM2 transaction commits before QuickBooks is contacted.
4. QuickBooks push
_push_new_item_to_qb(sku, item_name, description, cost, vendor, reorder_point) → qb.QBO().create_inventory_item(...): Type='Inventory', TrackQtyOnHand=True, QtyOnHand=0, InvStartDate=today, Sku, Name, Description+PurchaseDesc, PurchaseCost (if given), ReorderPoint = min_qty, income/expense/asset account refs from settings (qb_income_account_id, qb_expense_account_id, qb_asset_account_id), preferred vendor resolved by vendor_id(vendor).
- Success →
items.qb_status='in_qb',qb_active=true,qb_item_id,qb_error=null,qb_synced_at=now(). - Failure →
qb_status='error',qb_error = str(exc)[:500]; never raises. Response{"ok": true, "sku", "next_sku", "pending", "qb_ok": false, "qb_message": <Intuit's wording>}.
POST /api/items/retry-qb re-pushes every row with qb_status in ('pending','error') and returns created/failed/pending; 400 when nothing is waiting. qb_status='sent' rows are not re-sent — the new-item QB sync sheet endpoint /api/items/new-sync was removed 2026-09-06 (Dave: "we're not using sync sheets"), and re-sending 'sent' rows was what could create a duplicate QB item.
Note GET /api/items/pending still lists qb_status in ('pending','error','sent'), so any historical 'sent' row remains visible on the screen even though Retry no longer touches it.
5. What syncs back
scripts/qb_refresh.py mirrors QB-owned fields (name, description, cost, active flag, qb_item_id) onto items and archives QB deactivations. Item name is read-only in IM2 by design — a rename happens in QB and arrives on the next refresh. IM2 never writes QB except through this screen's create call.
6. Known limits / gaps
- The reserved SKU shown on GET is advisory; the committed SKU is recomputed under the advisory lock. The form's success message is the only reliable record of the assigned SKU.
- No duplicate detection beyond exact
lower(item_name); near-duplicate names and duplicatemfr_part_noare allowed (the historical 15 duplicate-SKU problem in the spreadsheet is not guarded here). lead_time_days,item_group,gen_loc,count_group,notes, bins, min/max andmfr_part_noare IM2-only — none of them reach QuickBooks (min_qtyreaches it only asReorderPoint).- No
uomis sent to QuickBooks at all (QB units of measure are not touched by this build). - The IM2 insert commits before the QB call, so a QB failure always leaves a real IM2 row in
qb_status='error'— deliberate, but it means IM2 can hold items QuickBooks has never seen.
Client-side validation (added 2026-09-06)
Dave, 2026-09-06: "minimize the checks at save that could fail." newitem.html now mirrors every server-side rule on blur: checkNumber('purchase_cost'), checkMinMax() (numeric, non-negative, min <= max) and checkBin() for both bin boxes (upper-cased in place, ^[A-Z0-9][A-Z0-9 ./-]*$). Messages render in a .fmsg div per field and the field gets .bad; validateAll() gates the submit handler with "Fix the fields marked in red first". The server checks in create_item are unchanged and still authoritative — this only stops the round trip. Remaining save-time failures are by nature server-only: duplicate item name and whatever QuickBooks rejects. The bin pattern is client-only; there is no server bin format rule, so it is deliberately permissive.