Compare commits

...

193 Commits

Author SHA1 Message Date
gsinghpal
43b2edcbb5 @
docs(billing): session handoff — core on main, sub-project #2 (NexaCloud) next

Captures resume state for the centralized-billing initiative: core engine done
and on main, the 4-chunk decomposition of sub-project #2 (NexaCloud adapter +
dual-run reconciliation), the pending "where to start" decision, open questions,
and the test/branch workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
2026-05-27 08:56:28 -04:00
gsinghpal
d770c0c3a9 fix(billing): resolve code-review findings (authz, cross-billing, validation, webhook integrity)
- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
       controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
       caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints

Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
a5db0fe71e feat(billing): usage-rating + webhook-dispatch crons
- SaleOrder._fc_rate_usage: aggregates usage, computes overage via
  charge._compute_billable, upserts sale.order.line for the overage product
- FusionBillingUsage._cron_rate_open_periods: hourly cron iterates active
  charges × in-progress subscriptions, calls _fc_rate_usage
- data/ir_cron.xml: two crons — rate usage (hourly), dispatch webhooks (2 min)
- __manifest__.py: registers data/ir_cron.xml in data list
- test_usage.py: test_rate_open_period_creates_overage_line (TDD, FCB_EXIT=0)

Reference: _create_recurring_invoice / _get_invoiceable_lines confirmed in
Enterprise sale_subscription/models/sale_order.py — overage line goes onto
sale.order so native invoicing picks it up via _get_invoiceable_lines.
2026-05-27 08:42:08 -04:00
gsinghpal
c44fd89ed1 feat(billing): wire HTTP controllers to API handlers 2026-05-27 08:42:08 -04:00
gsinghpal
6c395709cf feat(billing): outbound webhook engine (HMAC + retry/backoff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
0754d0b101 feat(billing): subscription creation handler (sale.order is_subscription)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
2435096f32 feat(billing): inbound API handlers (customer/usage/catalog)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
25952cf226 feat(billing): period usage aggregation by metric function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
eb1ee85d24 feat(billing): idempotent usage ingestion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
1e34a67384 feat(billing): metered charge math (quota + overage)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
a1cfab6fe9 feat(billing): identity resolution external account -> partner 2026-05-27 08:42:08 -04:00
gsinghpal
a46e31e710 feat(billing): service API-key generation + matching
Add _match_api_key() class method to fusion.billing.service, with a
TDD test suite (TestServiceApiKey) covering key generation, hash storage,
positive match, and rejection of bad/inactive keys. Also fix
fcb_test_on_trial.sh to use --http-port 8070, as Odoo 19 forces
http_spawn() even under --no-http when --test-enable is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
032b10752e test(billing): odoo-trial Enterprise test runner + plan test-env fix
Local dev Odoo is Community (can't install the module). Add a guest-exec runner
that syncs the module to the odoo-trial Enterprise sandbox (VM 316, db trial) and
runs --test-enable there; pass = FCB_EXIT=0. Scaffold verified installing on
Odoo 19.0 Enterprise (7 fusion_billing_* tables created).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:40:51 -04:00
gsinghpal
e7d63a3859 docs(billing): core engine implementation plan (TDD, 11 tasks) 2026-05-27 08:40:51 -04:00
gsinghpal
2b47bd8b10 feat(billing): design + scaffold fusion_centralize_billing
Centralize billing for all NexaSystems services (NexaCloud, NexaDesk,
NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance,
replacing Lago. The module adds only the metering + integration layer;
native sale_subscription / account_accountant / payment_stripe do all the
financial work (invoicing, HST, dunning, portal, credit notes, Stripe).

Includes:
- Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md):
  6 locked decisions, architecture, data model, usage engine, Lago-shaped
  API, webhook control loop, NexaCloud pilot, phased dual-run migration.
- Module scaffold: 7 fusion.billing.* models (service, account.link, metric,
  charge, usage, webhook, reconciliation), bearer-auth API controller shell,
  security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs
  pending the implementation plan.
- CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription
  is a sale.order(is_subscription) + sale.subscription.plan (verified live).

Task 0 verified: a single Stripe account is shared across NexaCloud and all
Lago providers, so no Stripe account/card migration is required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:40:51 -04:00
gsinghpal
5764d439c3 changes 2026-05-26 19:17:57 -04:00
gsinghpal
5f372b462a changes 2026-05-25 20:11:03 -04:00
gsinghpal
67af54b46e docs(CLAUDE.md): note Windows-side browser preview limitation
User is on Mac via Tailscale into this Windows host. Browser previews
bound to Windows localhost are unreachable from the Mac browser. Default
to text-based design discussion on this host instead of spinning up the
brainstorming visual companion. Has bitten three times now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:58:20 -04:00
gsinghpal
5a699de1ca docs: Express Orders brainstorm handoff to Mac session
Captures all clarifying-question answers + exploration findings so a
fresh Claude Code session on Mac can resume at 'propose architectural
approaches' without re-running the discovery work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:57:44 -04:00
gsinghpal
1b473a7873 fix(tablet_pin_reset): manifest data slot + drop notif wrapper (deploy fixes)
Two bugs caught by entech battle test on first deploy:

1. Manifest entry landed in the 'demo' list instead of 'data' because
   my anchor (fp_demo_shopfloor_data.xml) was already in 'demo' —
   the entry pattern-matched into the wrong section. Demo data
   doesn't load on entech (no --load demo), so the mail.template
   never existed. Moved fp_tablet_pin_reset_template.xml to 'data'.

2. The fp.notification.template wrapper record referenced a model
   that doesn't exist until fusion_plating_notifications loads;
   fusion_plating_shopfloor doesn't depend on notifications, so
   the data load ParseError'd. Removed the wrapper — the controller
   calls mail_template.send_mail() directly anyway, not via the
   notification dispatcher. Added an inline comment explaining why
   the wrapper isn't here.

Battle test updated to drop the (now removed) wrapper xmlid check.
Battle test ALL PASS on entech after fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:02:18 -04:00
gsinghpal
9223f8da7c test(bt): tablet PIN self-service entech smoke (Task 7)
10-step smoke via odoo-shell:
  1. Pick real no-PIN shop user
  2. _generate_for_user -> assert 4-digit code + active row
  3. Wrong code -> assert rejected + attempt_count incremented
  4. Correct code -> assert ok + used_at set
  5. _sign_reset_token + _verify_reset_token roundtrip
  6. set_tablet_pin (mirrors set_pin endpoint reset_token branch)
  7. verify_tablet_pin -> assert new PIN works
  8. mail.template ref resolves
  9. fp.notification.template ref resolves
  10. Cleanup cron ref resolves

Cleans up: reverts PIN + deletes reset rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:54:46 -04:00
gsinghpal
8c9b645196 feat(tablet_lock): PIN self-service wizard (Task 6)
4 new state-machine modes on FpTabletLock, reusing the existing
FpPinPad 4-cell component:
  - request_code    : 'Send temp PIN' button screen (no-PIN tile OR
                      after 3-fail Forgot button)
  - enter_temp_code : 4-cell pad for the emailed code
  - set_new_pin     : 4-cell pad — choose new PIN
  - confirm_new_pin : 4-cell pad — confirm new PIN

Trigger paths (per D1 + D2):
  - Tap no-PIN tile -> goes straight to request_code mode
    (onTileClick dispatches via tile.has_pin)
  - Wrong PIN 3 times -> 'Forgot? Reset PIN via email' button appears
    below the pad (gated by state.failedAttempts >= 3)

Client-side failedAttempts counter (resets on tile re-select per D14).
Server-side x_fc_tablet_pin_failed_count keeps incrementing to the
existing 5-fail lockout per D13.

After Confirm New PIN succeeds, auto-login fires unlock_session with
the new PIN. If unlock_session fails for any reason, falls back to
'PIN set, tap your tile to log in.' status.

SCSS reuses $lock-* tokens from _tablet_lock_tokens.scss — light +
dark handled by the existing token system (no new tokens needed).
Hand-Off gold gradient repeated for the primary 'Send temporary PIN'
button to match the existing tablet visual language.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:54:09 -04:00
gsinghpal
2aa4bce089 feat(tablet): mail template + notification + cleanup cron (Task 5)
Mail template renders the 4-digit code in both subject (mobile
notification glance) and body (big bold display). Per Rule 25 only
core res.users fields referenced; the code itself comes from ctx.

fp.notification.template wrapper enables admin UI customization of
the body without touching code. tablet_pin_reset_requested added to
TRIGGER_EVENTS selection.

Daily ir.cron purges used/expired rows > 7 days old (audit trail
lives in fp.tablet.session.event, not here, so aggressive cleanup
is safe).

Manifest bump 19.0.34.2.0 -> 19.0.35.0.0 (triggers asset cache
invalidation on -u so the new template + SCSS load cleanly).

Phase 1 backend complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:51:25 -04:00
gsinghpal
46c62ebefa feat(tablet): request/verify reset code endpoints + set_pin token (Tasks 2-4)
Three controller changes in one commit (tight code coupling):

1. /fp/tablet/request_reset_code (Task 2) — generates 4-digit code,
   emails it, returns masked_email. Specific error codes for the
   frontend to switch on (no_email + manager_name, rate_limited +
   wait_minutes, user_not_found, no_role, inactive). Shop-branch
   role check matches existing _check_credentials per Rule 13l + 23
   (all_group_ids transitive — Owners reach Technician through
   implication).

2. /fp/tablet/verify_reset_code (Task 3) — verifies the emailed
   code, on success mints a 5-min HMAC reset_token. Error responses
   are specific (no_active_code / expired / too_many_attempts /
   wrong_code with attempts_left).

3. set_pin extended to accept reset_token (Task 4) — three auth
   paths now: old_pin (existing), reset_token (new), or neither
   (existing — only for users with no current hash). reset_token
   path is the only one that operates on a user OTHER than env.user;
   token proves the legit user just verified their email.

Failure audit reuses existing failed_unlock event_type with a notes
field describing the reset-code-specific reason. Success audit uses
the new pin_reset_requested / pin_reset_code_verified /
pin_set_after_reset event_type values.

_mask_email helper added for the no-email-on-file edge case.

3 more tests cover: valid token roundtrip + set_pin, expired token
rejection, and lockout-cleared-on-reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:50:18 -04:00
gsinghpal
152e6d4328 feat(tablet_pin_reset): new model + hash helpers + token sign (Task 1)
fp.tablet.pin.reset stores hashed 4-digit codes emailed for self-
service PIN create/reset. Per CLAUDE.md Rule 24 + Rule 13l it follows
the defensive patterns established elsewhere in the shopfloor module:
  - PBKDF2-SHA256 hashing (200k iterations, matches ResUsers PIN)
  - 72h TTL per D4
  - 5 wrong-attempt cap per D5 (invalidates code, used_at set)
  - 3 requests/60min rate limit per D6 (raises UserError)
  - SQL EXCLUDE constraint enforces one-active-row-per-user per D7
  - HMAC-SHA256 reset_token (300s TTL, single-use) for step 3 of
    the flow (set_pin via reset_token alternative to old_pin)

Audit event_type extended with 3 new values (pin_reset_requested,
pin_reset_code_verified, pin_set_after_reset). Manager-only ACL on
the new model; sudo when endpoints need access.

10 model-level tests cover generate / replace-active / rate-limit /
verify-correct / verify-wrong / 5-attempt-cap / expired / token sign
roundtrip / tampered-sig / purpose-mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:48:45 -04:00
gsinghpal
33fff5acba docs(plan): tablet PIN self-service implementation plan
8 tasks across 3 phases:
  Phase 1 — Backend foundation (Tasks 1-5)
    T1: New model fp.tablet.pin.reset + ACL + event_type extension
        + 10 model tests (hash helpers, lifecycle, rate limit,
        attempt cap, expired, token sign roundtrip + tamper checks)
    T2: /fp/tablet/request_reset_code endpoint
    T3: /fp/tablet/verify_reset_code endpoint
    T4: /fp/tablet/set_pin accepts reset_token alternative
        (+ 3 more tests)
    T5: mail.template + fp.notification.template + cleanup cron
  Phase 2 — Frontend (Task 6)
    T6: FpTabletLock wizard — 4 new state-machine modes
        (request_code, enter_temp_code, set_new_pin, confirm_new_pin),
        reuses FpPinPad 4-cell component, auto-login chain,
        client-side 3-fail counter for 'Forgot?' button
  Phase 3 — Deploy (Tasks 7-8)
    T7: Manifest bump 19.0.34.2.0 -> 19.0.35.0.0 + bt_pin_reset
        entech smoke
    T8: Sync 14 files + upgrade + asset bust + smoke + 8-step
        manual QA + tag deploy

Implements: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:42:04 -04:00
gsinghpal
2ae1c867b5 docs(brainstorm): tablet PIN self-service (create + reset via email)
User goal: from the Shop Floor Terminal lock screen, a user with no
PIN (or who forgot their PIN) should be able to set / reset their
own PIN without a manager's help. Today, FpPinSetup runs only from
Preferences which requires being logged in — there's no path from
the lock screen.

Design (approved, with user-picked defaults):
- Tap tile of no-PIN user -> 'Send temporary PIN' button -> email
  4-digit code, valid 72 hours -> enter code -> choose new PIN ->
  auto-login.
- For existing-PIN users: 3 failed PIN entries -> 'Forgot? Reset
  PIN via email' button appears below keypad -> same email flow.
- Both flows merge at: enter temp code -> set new PIN.
- Email goes to res.users.login (or partner_id.email fallback).
  No-email-on-file -> 'Contact your manager: <owner>' message.
- Rate limit: 3 requests per user per rolling 60 min.
- Per-code cap: 5 wrong attempts invalidates the code.
- New model fp.tablet.pin.reset stores hashed code + expires_at
  with SQL constraint enforcing one-active-row-per-user.
- 2 new endpoints (request_reset_code, verify_reset_code) + extend
  existing /fp/tablet/set_pin to accept reset_token alternative
  to old_pin.
- Audit: 3 new event_type values on fp.tablet.session.event.
- Reuses existing PBKDF2 helpers, FpPinPad component (mode prop),
  fp.notification.template dispatch, mail.template pattern.

Per CLAUDE.md Rule 25 the mail template references ONLY core
res.users fields (object.name, object.email, object.login,
object.company_id) — ctx.code is dispatched as extra_context, not
a model field. Safe at parse-time.

Self-review fixed 2 issues:
- event_kind -> event_type (real field name on fp.tablet.session.event)
- Listed existing event_type values explicitly for context

Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:30:36 -04:00
gsinghpal
c990110646 chore: gitignore .claude/ preview-tooling state
The Claude Preview MCP writes launch.json + throwaway HTML mockups
to .claude/ during brainstorming sessions. Not project source.
2026-05-25 12:30:05 -04:00
gsinghpal
5872583fbb fix(quality_dashboard): correct kanban xmlids per battle test (Task 9 fix)
Plan-time xmlids were wrong — entech battle test caught all 5
non-cert kanban xmlids missing. Real xmlids (queried via
ir.model.data on entech):
  hold:  action_fp_quality_hold     (was action_fusion_plating_quality_hold)
  ncr:   action_fp_ncr              (was action_fusion_plating_ncr)
  rma:   action_fp_rma              (was action_fusion_plating_rma)
  capa:  action_fp_capa             (was action_fusion_plating_capa)
  check: action_fp_quality_check    (was action_fusion_plating_quality_check)
cert stays unchanged — action_fp_certificate was already correct.

After fix: battle test ALL PASS — 6 sections in canonical order,
all xmlids resolve, 3 banner items pulled from real entech data
(5 draft certs, 3 of them overdue past 24h).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:28:52 -04:00
gsinghpal
c8db3915ea feat(quality_dashboard): manifest bump + battle test (Tasks 7-8)
Version 19.0.7.0.0 → 19.0.8.0.0 (triggers asset cache invalidation
on -u so the new template + SCSS load cleanly).

Battle test script: 6-check entech smoke. Validates snapshot shape,
canonical section order, required section keys, open_kanban_xmlid
resolves to act_window, banner item shape when items exist. Summary
prints per-section counts so you can eyeball the entech state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:25:31 -04:00
gsinghpal
547e7d66a9 feat(quality_dashboard): rewrite OWL component + template + SCSS (Task 6)
JS: single FpQualityDashboard component + BannerCard / BannerItem /
SectionCard / SectionRow sibling sub-components in the same file.
Fetches /fp/quality/dashboard/snapshot, 60s poll, deep-link
?tab=certificates scrolls to section-cert via scrollIntoView.

XML: outer wrapper + banner + 6 sections (t-foreach over
state.snapshot.sections). Each section has id='section-<type>' so
the deep-link target works. SectionRow has overdue-conditional
class for red subtitle highlight.

SCSS: local tokens for urgent/good/section-head with light+dark via
$o-webclient-color-scheme branch. 135deg gradients matching the
plant kanban polish. Mobile breakpoint at 900px collapses banner
grid to 1 col and stacks row Open button.

OLD TABS array, selectTab, openTab, totalOpen, totalOverdue all
deleted. Old template's tab tiles + per-tab panels deleted. Existing
per-model kanbans untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:24:52 -04:00
gsinghpal
bfeca0ac32 test(quality_dashboard): defensive guard tests (Task 5)
Covers: missing-field critical-customer check returns empty without
crashing; computed_at is a valid ISO timestamp; every section ships
a non-empty open_kanban_xmlid in module.xmlid format.

(missing-model test from the plan omitted — patching env.__contains__
was unsafe; the in-self.env guard is already exercised by Tasks 2-4
in production behavior. The other 3 defensive tests still cover the
missing-field path, which is the more common scenario.)

Phase 1 backend complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:22:25 -04:00
gsinghpal
40d563801a feat(quality_dashboard): banner with overdue + critical (Task 4)
_fetch_banner_candidates collects (overdue) OR (critical-customer +
open) records per type. _critical_customer_ids reuses partner.x_fc_rush
and partner.x_fc_vip flags when defined (gracefully no-ops when
absent). _critical_badge returns RUSH/VIP/AEROSPACE/AS9100 label
when the banner reason is critical-customer (no badge when overdue).
_build_banner ranks: overdue first by oldest, then critical-customer
by oldest, takes top 6, reports total_matching.

build() now collects banner candidates from every section in one
pass + invokes _build_banner once.

Tests cover overdue hold pickup, 6-cap with overflow count, and
all_clear when DB is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:22:01 -04:00
gsinghpal
e271908109 feat(quality_dashboard): populate section items (Task 3)
_fetch_section_items pulls top-5 open records per type, ranked
overdue-first by oldest create_date. _build_item shapes each row
with id/name/customer/subtitle/urgency/open_action. _resolve_partner
defensively walks partner_id -> job_id.partner_id -> ncr_id.partner_id
per type. _build_subtitle generates the human-readable second line.

Tests cover empty list, 5-cap on 8-record set, and required item
keys (id/name/customer/subtitle/urgency/open_action).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:20:50 -04:00
gsinghpal
72f75fe754 feat(quality_dashboard): snapshot endpoint scaffold (Task 2)
Replaces /counts with /snapshot. Helper class FpQualityDashboardSnapshot
returns response with correct shape — banner placeholder + per-type
sections with open/overdue counts (reuses old counts endpoint
thresholds). Items + critical-customer banner come in Tasks 3-5.

Per CLAUDE.md Rule 13m, Model.sudo() on cross-module reads. Per
Rule 24 the in-self.env check guards missing-model paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:19:48 -04:00
gsinghpal
6cb352629a test(quality_dashboard): scaffold + shape tests (Task 1)
Tests for empty-DB all-clear, canonical section order, and required
keys on each section. All fail until Task 2 lands the snapshot helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:19:06 -04:00
gsinghpal
d53bb73055 docs(plan): quality dashboard redesign implementation plan
9 tasks across 3 phases:
  Phase 1 — Backend snapshot endpoint (Tasks 1-5)
    T1: Test scaffold + shape tests
    T2: Minimal helper + endpoint making shape tests pass
    T3: Section items population + tests
    T4: Banner with overdue + critical-customer ranking + tests
    T5: Defensive guards + missing-model tests
  Phase 2 — Frontend (Task 6)
    T6: Wholesale rewrite of JS + XML + SCSS (banner card + 6
        section cards, sibling sub-components in same JS file,
        deep-link scrollIntoView, 60s poll, dark mode via
        compile-time SCSS branch)
  Phase 3 — Polish + deploy (Tasks 7-9)
    T7: Manifest version bump 19.0.7.0.0 → 19.0.8.0.0
    T8: Entech smoke battle test (7 checks)
    T9: Sync + upgrade + asset bust + smoke + manual QA

Implements: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:15:41 -04:00
gsinghpal
ff51035494 docs(brainstorm): quality dashboard redesign — action surface
User goal: 'all quality related updates at glance, all the flagged
tasks need to show right here so the manager can quickly follow up
and complete the task'. Current dashboard is a tab-router (6 numeric
tiles + click-to-drill) — flagged tasks aren't visible without
navigation.

Design (Hybrid layout, approved):
- Red 'Needs Attention Today' banner on top (up to 6 items, 2x3 grid)
  showing items that are overdue OR from critical customers
  (x_fc_rush / x_fc_vip / aerospace). Green 'all caught up' when zero.
- Per-type sections below in QM-urgency order: Certs / Holds / NCRs /
  RMAs / CAPAs / Checks. Each shows top 5 items inline + Open all
  link to the existing kanban.
- Single 'Open ->' button per row -> opens record form via act_window.
  No one-click action shortcuts (cert form is where Fischerscope +
  sign-off prereqs are validated).
- Drop the existing 'Quality Overview' header strip entirely.
- 60s poll cadence preserved.
- ?tab=certificates deep-link from awaiting-cert notification email
  preserved as scrollIntoView on the certs section.

Backend: replace /fp/quality/dashboard/counts with /snapshot. New
helper class FpQualityDashboardSnapshot builds banner + 6 sections in
one response. Cross-module reads sudo'd per Rule 13m; missing fields
gracefully degrade per Rule 13j defensive pattern.

Frontend: rewrite the OWL component. BannerCard + 6 SectionCards as
sub-components in the same JS file (not reused elsewhere yet).
Existing per-model kanbans untouched.

Self-review fixed 4 issues:
- _critical_customer_domain made per-type (was contradictory)
- OVERDUE_THRESHOLDS gained explicit use_due_date flag (CAPA branch)
- Template requirement called out: id='section-<type>' on each card
  for the deep-link scrollIntoView to work
- doAction call shape disambiguated for xmlid vs full dict

Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:07:38 -04:00
gsinghpal
0ed4f88da2 feat(job_workspace): polish header buttons + workflow bar + next button
Same polish pass as the plant kanban — bigger touch targets, gradient
backgrounds (light + dark via existing token system), more readable
typography.

Header (top row):
  - Back: bigger padding, hover lift
  - Hand Off: was btn-sm \u2192 full size; gold gradient + shadow
    matching the plant kanban .toolbar-btn.handoff treatment
  - WO #: 1.1rem \u2192 1.3rem
  - Pills (qty done, due date, holds): bigger padding + font, subtle
    gradient bg, hold pills get tinted gradients
  - Customer / part name: 0.95rem (more readable)

Workflow bar (step dots + next button):
  - Step dots: 14px \u2192 18px, current scales 1.15x with bigger
    halo, gradient fills (green for done, blue for current)
  - Step labels: 0.65rem \u2192 0.8rem (the original was unreadable
    at arm's length on a tablet)
  - Connector links: 2px \u2192 3px, rounded
  - Next button: prominent green gradient (was generic btn-primary),
    bigger padding + font, hover lift + shadow

Manifest: fusion_plating_shopfloor 19.0.34.1.0 \u2192 19.0.34.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:03:06 -04:00
gsinghpal
caeba27846 feat(plant_kanban): polish KPI strip + chips + toolbar buttons
User feedback after first deploy: 7 KPI tiles wrapped to second line
(grid was repeat(5, 1fr) but I had added 2 new ones), and the
controls felt cramped.

Layout fix:
  - .kpi-strip grid: repeat(5, 1fr) → repeat(8, 1fr) so the row stays
    one line and there's room for the new Awaiting QC tile.

Missing KPI added:
  - Awaiting QC — fp.job.card_state='awaiting_qc' count. Operators
    couldn't see when QC was blocking job close from the KPI strip
    (only visible inside the column). Server-side count + filter
    clause + matching filter chip.

Visual polish (all light + dark via existing token system):
  - KPI tiles: padding 6→10px, value font 20→26px, label font 9→10px,
    subtle 135deg linear-gradient bg per kind (urgent/warn/good/qc),
    hover lifts the tile with translateY + shadow.
  - Filter chips: padding 4/12→7/16px, font 11→13px, gradient bg,
    active state has gradient blue + shadow.
  - Search input: padding 5/10→9/14px, font 12→14px, focus ring.
  - Toolbar buttons (Station/All Plant/Manager/Scan QR/Hand Off):
    padding 5/10→8/14px, font 12→14px, gradients, hover lift.

Dark mode handled automatically — all gradients reference
$plant-* tokens which already have @if $o-webclient-color-scheme ==
dark global overrides in _plant_tokens.scss.

Version bump fusion_plating_shopfloor 19.0.34.0.0 → 19.0.34.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:58:11 -04:00
gsinghpal
a2e254b934 fix(fp.job): post-shop state machine entech smoke fixes (Task 23)
Three bugs caught + fixed during entech battle test:

1. _fp_check_finish_gates calling button_mark_done triggered the
   step-completion gate prematurely (step still in_progress at
   pre-super time). Pass fp_skip_step_gate=True alongside
   fp_check_gates_only — we know the operator is about to finish
   the last open step.

2. _fp_schedule_cert_activity used env.get('fp.notification.template')
   for presence check. env.get returns an EMPTY recordset (falsy),
   not None — 'if not Template: return' silently exited and no
   activity was ever scheduled. Switch to 'in self.env' check
   pattern + explicit indexing. CLAUDE.md Rule 24.

3. _fp_check_advance_after_cert_issue + _fp_check_regress_after_cert_void
   used 'state != issued' as outstanding-cert count. This made
   voided certs count as outstanding forever, so void+re-issue
   cycles never re-advanced. Switch to per-type coverage check:
   each required cert TYPE needs at least one issued cert.
   Regress mirrors: only fire if a type loses all issued certs.

CLAUDE.md gains Rule 24 (env.get falsy empty recordset trap).
Rule 25 (mail.template parse-time validation) renumbered.

Battle test ALL PASS on entech admin DB:
  10/10 steps green — auto-advance, kanban placement, activity
  schedule + auto-resolve, ACL guard, cert issue advance, void
  regress, re-issue advance, manual ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:45:35 -04:00
gsinghpal
8b14466da2 fix(notifications): mail.template only refs core fp.job fields
Entech deploy of 5a039ae3 hit:
  ParseError: Failed to render inline_template template
  AttributeError('fp.job' object has no attribute 'display_wo_name')

Root cause: mail.template data files are parse-time validated by
Odoo (template rendered against sample object). fusion_plating_notifications
loads BEFORE fusion_plating_jobs in dep order, so jobs-module fields
(display_wo_name, part_catalog_id) aren't on the Python class yet
even though the DB columns exist from previous installs.

Fix: strip display_wo_name → name and remove the Part row.
Recipe / qty_done / partner_id stay (all in fusion_plating core).

Logged as CLAUDE.md Rule #24 — same trap will bite anyone else
adding cross-module mail templates. Includes structural alternatives
for callers that really need downstream fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:35:29 -04:00
gsinghpal
5a039ae369 test(bt): post-shop state machine end-to-end smoke (Task 22)
10-step battle test covering: auto-advance on last step finish,
kanban placement, QM activity, ACL guard, cert issue advance,
activity auto-resolve, cert void regress, re-issue, manual ship.

Tolerant of partial state — branches around the awaiting_cert path
when partner doesn't require certs (uses awaiting_ship path instead),
SKIPs subsequent steps when prerequisites fail, rolls back at end so
the DB stays clean.

Run on entech via odoo-shell after deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:54:53 -04:00
gsinghpal
aab6b9275b feat(fp.job): migration 19.0.11.0.0 — backfill new states (Task 21)
Idempotent post-migrate that moves mid-flight in_progress jobs whose
recipe steps are all terminal into the appropriate new state:
  - draft cert exists → awaiting_cert
  - no cert required  → awaiting_ship
done jobs left alone (historically completed, already shipped).

Card_state + mini_timeline_json recomputed for affected rows so the
plant kanban renders correctly on first page load.

Version bump 19.0.10.31.0 → 19.0.11.0.0 triggers the migration on -u.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:53:57 -04:00
gsinghpal
26a1086623 feat(notifications): cert authority events + QM activity (Tasks 17-20)
TRIGGER_EVENTS extended with three new events:
  - cert_awaiting_issuance — fires on in_progress → awaiting_cert
  - cert_voided_re_notify  — fires on awaiting_ship → awaiting_cert
                             regress (cert voided post-issue)
  - job_shipped            — fires on button_mark_shipped

_dispatch routes cert events through new internal-recipient resolver
(QM/Manager/Owner via all_group_ids, transitive per Rule 13l)
instead of the partner-based stream lookup. Other events unchanged.

Mail templates (fp_cert_authority_templates.xml): two new
mail.template records bound to fp.job. Amber accent bar for awaiting,
red accent bar for void-re-issue. Deep-link to
/odoo/action-...?tab=certificates so QM lands on the right tab.

Activity type (fp_activity_types_data.xml): mail.activity.type
activity_type_issue_coc — bound to fp.job, 1-day delay, certificate
icon.

fp.job helpers:
  _fp_schedule_cert_activity: round-robin by oldest login_date,
    idempotent on existing open activity, soft-fails if helpers
    are missing.
  _fp_resolve_cert_activities: auto-resolves on awaiting_ship,
    soft-fails on per-activity exceptions.

Manifest bumps:
  fusion_plating_notifications 19.0.6.6.1 → 19.0.7.0.0
  fusion_plating_jobs: data list gains fp_activity_types_data.xml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:53:09 -04:00
gsinghpal
c00831a72a feat(quality_dashboard): sixth 'Certificates' tab (Tasks 14-16)
Counts endpoint: certificates block — open=draft, overdue=draft+>24h.
Falls back to {open:0, overdue:0} when fp.certificate isn't installed.

JS: TABS array gains the 6th entry. Existing data-driven OWL template
auto-renders both the header tile and the body panel. Tab opens the
fp.certificate kanban grouped by state, filtered to draft by default.

Deep-link: setup() reads action.context.params.tab. The
cert_awaiting_issuance notification email links to
/odoo/action-fp_quality_dashboard?tab=certificates and lands the QM
on the right tab automatically.

Template: 'Open across all 5' → 'Open across all <tabs.length>' so
it stays correct if more tabs are added later.

Manifest: fusion_plating_quality 19.0.6.6.6 → 19.0.7.0.0
(fusion_plating_certificates already in depends — no change needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:49:10 -04:00
gsinghpal
3a120dd400 feat(plant_kanban): post-shop states visible on board (Tasks 9-13)
Controller (plant_kanban.py):
  - Widen domain: state IN (confirmed, in_progress, awaiting_cert,
    awaiting_ship). Done jobs still drop off.
  - _resolve_card_area: state=awaiting_cert → 'inspection' column,
    state=awaiting_ship → 'shipping' column. State drives column
    regardless of recipe shape.
  - _state_chip: 🏷️ Awaiting CoC (amber) + 📦 Ready to ship (green).
  - _SORT_PRIORITY: awaiting_cert=3.5, awaiting_ship=8.5.
  - KPI dict: awaiting_cert + awaiting_ship counts.
  - Filter clauses for the two new chips.

Model (fp_job.py):
  - _compute_card_state handles new states in BOTH branches: the
    no-active-step early return (where awaiting_cert/ship cards
    land — all steps terminal) AND the per-step branch (defensive).
  - _compute_mini_timeline_json: awaiting_cert paints inspection
    dot 'current'; awaiting_ship paints shipping dot 'current'.
    All earlier dots show 'done'.

SCSS (_plant_tokens.scss + _plant_card.scss):
  - New tokens for amber (cert) + green (ship), light + dark variants
    via the existing $o-webclient-color-scheme compile-time branch.
  - .state-awaiting_cert / .state-awaiting_ship modifier classes
    match the existing border-left pattern.

XML (plant_kanban.xml):
  - Two new KPI tiles + two new filter chips wired to the state
    filter clauses.

Manifest: fusion_plating_shopfloor 19.0.33.2.0 → 19.0.34.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:47:17 -04:00
gsinghpal
4dc0a7cca5 feat(fp.certificate): ACL guard + state hooks + age field (Tasks 6-8)
action_issue gated to Manager/QM/Owner via Python AccessError +
view-level groups= on the Issue button (two-layer enforcement).
Manager bypass via fp_skip_cert_authority_gate=True context flag
with chatter audit.

action_issue post-callback calls job._fp_check_advance_after_cert_issue
so the job auto-advances awaiting_cert → awaiting_ship when every
required cert is issued.

write({'state':'voided'}) override calls
job._fp_check_regress_after_cert_void so a previously-issued cert
being voided slides the job back to awaiting_cert and re-notifies
the QM.

x_fc_age_hours non-stored Float drives the Quality Dashboard age
chip + overdue filter.

Version bump 19.0.7.9.3 → 19.0.8.0.0 (spec said 19.0.6.0.0 but
current is already higher; bumped to next major instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:43:08 -04:00
gsinghpal
4930a89970 feat(fp.job): button_mark_shipped + milestone cascade integration (Task 5)
button_mark_shipped: manual transition awaiting_ship → done. Does
not re-run the bake/qty/QC gates — those passed at the in_progress
→ awaiting_cert/ship transition. Just the 'yes, shipped' stamp.

Milestone cascade (_compute_next_milestone_action) extended to
recognize the two new states:
  - awaiting_cert → 'issue_certs' button
  - awaiting_ship → 'mark_shipped' button
Legacy state='done' branch preserved for historical jobs.

action_advance_next_milestone now dispatches 'mark_shipped' via
_action_mark_shipped_dispatch which routes:
  awaiting_ship → button_mark_shipped (new path)
  done + active delivery → _action_mark_active_delivery_delivered
    (legacy, unchanged)

View: 'Mark Shipped' milestone button gated on Manager/Owner groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:26:03 -04:00
gsinghpal
72f0f182a6 feat(fp.job.step): wrap button_finish with gate + advance (Task 4)
Pre-super: when finishing the last open step on an in_progress job,
run the bake/qty/QC gates from button_mark_done so failures surface
as UserError on the click (per spec D12). Without this the
auto-advance would silently fail with no error path.

Post-super: trigger _fp_check_advance_post_shop so the state
auto-advances cleanly (in_progress → awaiting_cert / awaiting_ship).

Added _fp_check_finish_gates helper on fp.job and a
fp_check_gates_only context flag honored by button_mark_done so the
gate logic is single-sourced (DRY).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:22:33 -04:00
gsinghpal
5173554281 feat(fp.job): post-shop transition helpers (Tasks 2+3)
_fp_check_advance_post_shop: in_progress + all steps terminal →
  awaiting_cert (cert required) or awaiting_ship. Auto-spawns cert
  + delivery and fires notifications. Idempotent. Does NOT raise —
  gate failures bubble up via fp.job.step.button_finish (Task 4).
_fp_check_advance_after_cert_issue: awaiting_cert → awaiting_ship
  when every required cert is state=issued.
_fp_check_regress_after_cert_void: awaiting_ship → awaiting_cert
  when a previously-issued cert is voided. Re-notifies QM.

hasattr guards on _fp_schedule_cert_activity + _fp_resolve_cert_activities
keep this safe during incremental rollout — those land in Task 20.

Test scaffolding added covering helper existence + idempotency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:21:22 -04:00
gsinghpal
c2b693c97e feat(fp.job): extend state with awaiting_cert + awaiting_ship
Per spec docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md.
Selection extension only; transitions wired in subsequent tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:20:12 -04:00
gsinghpal
051094813e docs(plan): post-shop cert + shipping job states implementation plan
23 tasks across 8 phases:
  Phase 1 — State machine foundation (extend Selection, advance helpers)
  Phase 2 — Step-level gating hooks (button_finish gates + advance)
  Phase 3 — Certificate-side hooks + ACL (action_issue guard + cert
            void regress + x_fc_age_hours + view groups gating)
  Phase 4 — Plant Kanban visibility (domain, _resolve_card_area,
            chips, SCSS, KPI tiles + filter chips, mini-timeline)
  Phase 5 — Quality Dashboard sixth tab (Certificates)
  Phase 6 — Notification + Activity (events, resolver, templates,
            mail.activity schedule + auto-resolve)
  Phase 7 — Migration 19.0.11.0.0 — backfill mid-flight jobs
  Phase 8 — Battle test + entech deploy

Implements: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:15:27 -04:00
gsinghpal
edf3f95854 docs(brainstorm): post-shop cert + shipping job states spec
Trigger: WO-30058 (SO-30058) finished all recipe steps on entech and
disappeared from the Shop Floor kanban with a draft CoC and no QM
notification. Operators reported jobs feeling lost; risk that a job
could leave the building without paperwork.

Design (Approach A, approved):
- Two new fp.job.state values between in_progress and done:
  awaiting_cert + awaiting_ship
- Auto-advance on last step finish; auto-advance on cert issue
- Plant kanban widens domain, renders the two states in the existing
  Final inspection / Shipping columns
- 6th tab 'Certificates' on Quality Dashboard with kanban + filters
- ACL gate on fp.certificate.action_issue restricted to
  Manager / QM / Owner (transitive via all_group_ids)
- Email + mail.activity notification to QM authority group
- Migration script backfills mid-flight jobs

Shipping label printing, BoL, carrier dispatch are explicitly
out of scope; awaiting_ship is a parking column with a manual
Mark Shipped button.

Self-review pass found and fixed:
- round-robin field ambiguity (last_activity_at vs login_date)
- unstated behavior for button_mark_done gates (now in step.finish)
- placeholder version inlined (19.0.11.0.0)
- dead reference replaced with inline body

Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:03:31 -04:00
gsinghpal
80887d6098 changes 2026-05-25 08:17:29 -04:00
gsinghpal
5d5964a327 fix(plant-kanban): full-height bordered columns + viewport-pinned scrollbar
Two layout polish fixes after persona-walk feedback on the new Plant
Kanban surface (`fp_plant_kanban`).

1. Columns now run full board height with visible borders
   Was: `.col` had `background: $plant-bg` (= page bg, invisible) and
   no border, so only the header card (`.o_fp_col_header`) drew any
   outline. Empty columns (BAKING / DE-RACKING / SHIPPING) looked
   unbounded — operators couldn't tell where one column ended and
   another began.

   Now: `.col` is the bordered white card (Trello / Asana style),
   stretches full height via grid + flex. `.o_fp_col_header` drops
   its standalone border / radius / background and is just a bottom-
   divider band inside the column card.

2. Horizontal scrollbar pinned to viewport bottom
   Was: `.o_fp_plant_kanban` was `min-height: 100vh` (block flow) +
   `.board` had `min-height: 520px; overflow-x: auto`. Scrollbar
   showed at the bottom of the .board element (~520px from top of
   board), floating mid-page below the empty columns.

   Now: parent is `height: 100vh; display: flex; flex-direction:
   column`. Header is `flex: 0 0 auto`; `.board` is `flex: 1 1 auto;
   min-height: 0` so it fills all remaining vertical and its
   scrollbar sits at the viewport bottom.

`.col-scroll` switched from `max-height: calc(100vh - 260px)` to
`flex: 1 1 auto; min-height: 0` so it expands inside the now-full-
height column instead of being capped at a magic number.

Version: fusion_plating_shopfloor 19.0.33.1.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:41 -04:00
gsinghpal
80f80fb707 fix(tablet): ACL, loading hang, timer offset + FP-tz clock
Four fixes shipped together — all surfaced during tablet UX walkthrough
on entech.

1. sale.order ACL on step completion
   Technicians hit "Access Denied... sale.order" when starting/finishing
   any step. _fp_check_receiving_gate + the serial-promotion helpers +
   _fp_resolve_contract_review_part read step.job_id.sale_order_id (and
   sale_order_line_ids) without sudo. Per Rule 13m, denormalized cross-
   module reads in tablet controllers must sudo() the source recordset.

2. Workspace stuck on "Loading Job Workspace…" after Hand Off + relogin
   Action params aren't URL-encoded, so the workspace remounts with
   jobId=null. refresh() exited early, state.data stayed null, "Loading"
   shown indefinitely. onMounted now redirects to the plant kanban
   when jobId is null or the initial load returns no data.

3. 4-hour timer offset on active steps
   workspace_controller used fp_format() to serialize date_started —
   which converts naive UTC to user tz wall time first. JS then
   appended 'Z' and parsed as UTC, so timer was offset by the user's
   tz (4h on EDT). Switched to fp_isoformat_utc() (proper +00:00 ISO)
   and dropped the Z-append in formatActiveStepElapsed +
   isActiveStepOvertime.

4. Lock-screen clock follows FP regional setting
   tablet_lock.js used d.getHours() / d.toLocaleDateString() — browser
   tz. Now /fp/tablet/tiles returns tz_name (fp_user_tz resolution:
   user.tz → company.x_fc_default_tz → UTC) and the formatters use
   Intl.DateTimeFormat with the explicit timeZone option. plant_overview
   now consumes server_time (already fp_format'd) instead of toLocaleTime
   String. Same chain Odoo backend uses, so PDF / view / tablet all
   agree on what time it is.

Versions: fusion_plating_jobs 19.0.10.30.0,
fusion_plating_shopfloor 19.0.33.1.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:31:25 -04:00
gsinghpal
bfc138251a feat(fusion_plating): hide back-office menus from Plating Technicians
Per user request: technicians on the tablet should only see Discuss,
To-do, Plating, AI, Maintenance, Time Off. Every other top-level app
menu (Calendar, Contacts, CRM, Sales, Dashboards, RC, Faxes, Field
Service, Fusion Clock, Invoicing, Accounting, Project, Timesheets,
Planning, Shipping, Website, Purchase, Inventory, Sign, HR, Payroll,
Attendances, Recruitment, Expenses, IoT, Link Tracker, Apps) is now
restricted to a new group_fp_office_user.

Architecture:
- New group_fp_office_user (security/fp_menu_visibility.xml) — a
  marker group that controls back-office menu visibility.
- Owner / Manager / Quality Manager / Shop Manager / Sales Rep all
  imply office_user via implied_ids — they see everything they did
  before.
- Pure Technicians do NOT imply office_user — they see only the
  tablet-friendly menus.
- A "!technician" filter would have hit managers too (because Manager
  → ... → Technician via implication), so office_user is the inverse
  pattern that gets the right scoping.

Implementation:
- post_init_hook + migrations/19.0.21.4.0/post-migrate.py both call
  _fp_apply_office_user_menu_visibility(env) which iterates a curated
  list of menu xmlids and sets group_ids = [office_user] on each.
- Uses env.ref(..., raise_if_not_found=False) so menus from
  uninstalled modules silently skip — no hard depends added.
- ir.ui.menu uses `group_ids` in Odoo 19 (was groups_id pre-18 — same
  rename pattern as res.users; CLAUDE.md Rule 13c).
- Settings / Apps / Tests left untouched (already admin-restricted).
- Some menus (Field Service) end up with office_user OR their original
  group — that's correct behavior: Plating Techs have neither so still
  don't see them; explicit Field Technicians keep access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:00:15 -04:00
gsinghpal
7dab5fb9c6 feat(record-inputs): tap-to-adjust steppers + inputmode keypad hint
Adds [-] / [+] buttons around every numeric input in the Record Inputs
dialog (single-value, dual-entry, and pass_fail+range branches). Tap
to increment / decrement by the recipe-author-derived step size
(stepFor() already computes this from target_min/target_max precision,
falling back to input-type defaults).

- Decrement clamps at 0 (typical qty/time/temp on a plating floor
  doesn't go negative; if needed, operator can still tap the input
  and type a negative value)
- Increment uses _stepRound() to avoid floating-point fuzz on decimals
- Center-aligned monospace-ish input between the buttons for clarity
- inputmode='decimal' (or 'numeric' for time fields) hint so when the
  operator does tap the input, the iPad shows a number keypad instead
  of the full keyboard

Touches single-value, dual-entry (min/max), and pass_fail+range. Other
multi-field widgets (multi-point thickness, bath chemistry panel) still
use plain inputs — separate request if they need steppers too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:43:00 -04:00
gsinghpal
8d4c85cc52 fix(workspace): drop native confirm() on Close Receiving
Native browser confirm popups look out of place in the tablet UI.
Mark Counted is already a deliberate prior step, so requiring a
second confirmation on Close Receiving was just friction. If a
receiver hits Close prematurely, action_reset_to_counted on
fp.receiving from the back office is the recovery path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:36:48 -04:00
gsinghpal
fc17754996 fix(workspace): required-inputs gate fires + manager bypass dialog
Two bugs:

1. Gate silently passed when step.recipe_node_id was NULL — happened
   to every WO-30057 step after this morning's clone delete (the FK
   ON DELETE SET NULL wiped the link). _fp_missing_required_step_inputs
   returned an empty recordset when node was None, so the gate had
   nothing to fail on and button_finish succeeded with zero audit.
   Fix: _fp_check_step_inputs_complete now treats NULL recipe_node_id
   as an explicit "no recipe link" hard block. Operator can't finish;
   manager bypass posts chatter audit.

2. No tablet UI for the manager bypass. The gate's bypass was a
   Python context flag — invisible from the JS layer, so managers
   were stuck behind the same hard error as operators.
   Fix: new /fp/workspace/finish_step endpoint returns structured
   errors (gate type, missing_prompts list, bypass_available bool).
   Server-side enforces manager group when bypass=True (can't trust
   the client). New FpFinishBlockDialog OWL modal renders:
   - Non-manager: Cancel + Record Inputs
   - Manager:     Cancel + Record Inputs + ⚠ Bypass & Finish (audit)

JobWorkspace.onFinishStep routes plain finishes through the new
endpoint; signature-required steps still go through /fp/workspace/sign_off
(separate gate). Added is_manager to /fp/workspace/load payload so
the JS knows which dialog variant to render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:30:39 -04:00
gsinghpal
0371624afb feat(workspace): live HH:MM:SS timer on active step
Pure client-side tick — 1s setInterval bumps state.tickNow which the
template reads via formatActiveStepElapsed(step). No RPC per tick.
Reads step.date_started_iso (UTC) from the existing payload, parses
to ms, displays elapsed since.

- Green pill (#d1fae5 bg, monospace tabular-nums) on the ACTIVE badge
- Flips red (#fee2e2 + pulse animation) when elapsed > 1.5x
  duration_expected — visual cue for the operator that the step is
  running long against the recipe target

Cleanup interval on onWillUnmount alongside the existing 15s refresh
interval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:18:49 -04:00
gsinghpal
eed1c4619d feat(workspace): pre-recipe receiving card with box count + damage log
Adds the receiver workflow to the Job Workspace tablet view (was the
gap behind WO-30057 sitting in Receiving with no way to advance).
Receivers no longer need to go to the backend form.

Workspace card (renders above the step list when fp.receiving in
state draft/counted on the linked SO):
- Draft state: numeric box-count input + per-line received_qty /
  condition picker (good/damaged/mixed) + Damage Log panel + Mark
  Counted button. Autosaves on input blur.
- Counted state: read-only summary (boxes, parts, who/when) +
  Damage Log still editable + Close Receiving button.
- Closed: card disappears, recipe takes over.

New FpDamageDialog OWL modal:
- Severity pill picker (Cosmetic / Functional / Rejected) with
  color-coded active state
- Required description textarea
- Action Required pill picker (None / Notify / Return / As-Is)
- Photo capture: both "Take Photo" (input capture="environment"
  triggers tablet camera) AND "Upload" (file picker fallback).
  Multi-photo with preview grid + per-photo remove.

5 new endpoints on workspace_controller.py:
- receiving_save_lines (autosave box_count_in + per-line qty/cond)
- receiving_mark_counted (wraps action_mark_counted)
- receiving_close (wraps action_close)
- damage_create (creates fp.receiving.damage + attaches base64 photos)
- damage_delete (removes a damage row)

No model changes — wraps existing fp.receiving actions and damage
CRUD. C3 (outbound shipping carrier/label) is a separate spec.

Spec: in-conversation brainstorm (C1+C2) following the 2026-05-24
workspace step actions spec; no standalone doc since scope is small.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:08:30 -04:00
gsinghpal
170398ab6f feat(workspace): per-kind step action buttons in Job Workspace
Fix: in the Job Workspace tablet view, the Start button was buried
inside a parent t-if that required the step to already be in_progress
or blocked. So ready/paused steps showed no buttons at all -
operators couldn't advance the WO from this screen (the reason the
user couldn't complete anything on WO-30057).

Template restructure (job_workspace.xml):
- Always-visible line 1 (icon + step# + name + ACTIVE/PAUSED badge + meta)
- Non-terminal detail panel (chips + instructions + opt-out + GateViz)
  visible on every non-done step so operator reads ahead
- Action row dispatched per-kind via getStepActions() helper

Per-kind action dispatcher (job_workspace.js):
- in_progress -> Record Inputs, Pause, Finish (or Finish & Sign Off)
- paused      -> Resume, Record Inputs, Finish
- contract_review (ready) -> Open QA-005 Form
- gating (ready)          -> Mark Passed (1-click start+finish)
- requires_rack_assignment -> Start (Assign Rack) - opens FpRackPartsDialog
- else (ready)            -> Start

5 new handlers: onPauseStep / onResumeStep / onMarkPassed /
onOpenContractReview / onStartWithRack. Pause and Resume use ORM RPC
(button_pause/button_resume) since no HTTP endpoint exists.

New model method (fp.job.step.action_mark_gating_passed):
- 1-click pass for gating steps - does button_start + button_finish
  in one transaction, posts chatter "Gate X marked passed by Y"
- Raises UserError if called on a non-gating step (defensive)
- Bypasses S21 required-inputs gate (gating steps have no inputs)

Controller: workspace_controller.py adds requires_rack_assignment to
the step payload so the JS dispatcher can route correctly.

Spec: docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md
Sub-B (Record Inputs tablet polish: inputmode/prefill/date pickers/
signature pad/camera) is brainstormed but deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:38:22 -04:00
gsinghpal
d4e95dcd47 docs(plating): spec + plan for recipe cleanup + receiving enforcement
Root causes documented:
1. Recipe 3620 ENP-ALUM-BASIC had duplicate sequences (Contract
   Review + Masking both at seq 10; Incoming Inspection + Racking
   both at seq 20). Clones inherited the ambiguity and resolved by
   id ordering, putting Masking before Incoming Inspection — which
   meant new jobs went straight to Plating column after the
   contract-review auto-complete.
2. 24 per-part clone recipes accumulated, all carrying the broken
   ordering.
3. ~10 kind=other stragglers across the base recipes (Blasting,
   Adhesion Test Coupon, Strip Process, Chemical Conversion etc.)
4. Recipe duplication had no kind safety net.

Implementation shipped in commits referenced from the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:08:46 -04:00
gsinghpal
e1fedf7231 fix(fusion_plating): wet_process passthrough + per-clone unlink safety
Two follow-up fixes caught during the entech deploy of recipe cleanup:

1. RESOLVER_KIND_TO_ACTIVE_KIND was missing a self-pass entry for
   'wet_process'. The new aliases added in 19.0.21.3.0 (Chemical
   Conversion, Trivalent Chromate Conversion, Strip Process - AL,
   Plug The Threaded Holes via mask) directly return 'wet_process'
   from the resolver — without the passthrough they didn't translate
   to any active kind and stayed as 'other'. Added 'wet_process':
   'wet_process' so the migration's Phase 2 backfill catches them.

2. Migration 19.0.10.26.0 Phase 3 was using batch unlink
   (clone_recipes.unlink()) which tripped a PostgreSQL FK cascade
   ordering bug on entech ("insert or update on parent_id violates
   FK ..." during the CASCADE chain). Rewrote Phase 3 to delete one
   clone at a time with SAVEPOINT per clone — slower but immune to
   the batching bug, and one failed clone doesn't roll back the
   whole transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:08:35 -04:00
gsinghpal
9a2975b154 feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix
Migration 19.0.10.26.0/post-migrate.py runs in 5 phases:
1. Resequence recipe 3620 ENP-ALUM-BASIC ops to fix the duplicate-
   sequence bug (Contract Review=10, Incoming Inspection=20,
   Masking=30, Racking=40, then the rest). Also delete the empty
   duplicate ENP-Alum Line sub_process (id 4056).
2. Backfill kind on all kind=other nodes via the extended resolver
   from fusion_plating 19.0.21.3.0
3. Delete all per-part clone recipes (name contains em-dash)
4. Recompute fp.job.step.area_kind on all steps
5. Recompute fp.job.active_step_id + card_state on in-flight jobs

Plant kanban: no_parts cards now always land in the Receiving column
regardless of active_step area_kind. The receiver works Receiving;
that's where the card belongs when parts haven't arrived.

Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:59:33 -04:00
gsinghpal
271a995455 feat(fusion_plating): extend resolver + auto-classify hook on process node
Resolver (fp_resolve_step_kind) extensions:
- New aliases: blasting/bead blast/media blast variants, adhesion
  testing, corrosion testing, lab testing, strip process, chemical
  conversion, trivalent chromate, plug the threaded holes, air dry,
  desmut, soak clean, cleaner, nickel strike/strip
- Parenthetical suffix stripping - "Masking (If Required)" resolves
  through "masking", "Incoming Inspection (Standard)" through
  "incoming inspection", "Trivalent Chromate Conversion (A-14 / A)"
  through "trivalent chromate conversion"
- New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's
  vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test
  -> wet_process) so the resolver output lands on active kinds only

Auto-classify hook on fusion.plating.process.node:
- _fp_autoclassify_kind() upgrades kind_id when current is 'other'
  AND name resolves via the resolver. Idempotent - never overrides
  a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify
- Wired into create() and write() (only fires when name or kind_id
  changed on write)
- Side-effects: recipe duplication via copy() auto-corrects newly
  copied nodes; Simple/Tree editor authoring auto-classifies as soon
  as the name is saved

Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:57:35 -04:00
gsinghpal
056178b433 fix(jobs): store=True on fp.job.active_step_id
Required because fp.job.card_state (stored) has @api.depends including
active_step_id.area_kind. When step.area_kind changes, Odoo's trigger
chain searches fp.job by active_step_id — non-stored fields can't be
queried in WHERE clauses, raising ValueError("Cannot convert ... to
SQL because it is not stored").

Caught during entech deploy of 19.0.10.25.0/post-migrate.py Phase 3
(steps._compute_area_kind() failed on first run). store=True makes
the column searchable and the trigger chain works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:14:19 -04:00
gsinghpal
2285c9def1 docs(plating): spec + plan for Shop Floor live-step + library cleanup
Spec documents the 4 code defects + structural vocabulary mismatch
between fp.step.kind taxonomy and the legacy _STEP_KIND_TO_AREA dict,
plus the 30 library templates missing metadata. Plan breaks the work
into 15 bite-sized tasks across 2 phases.

Implementation shipped in:
- c75d2bde (Odoo 19 session.authenticate signature fix — separate)
- 7b90f210 (Phase 1: fusion_plating)
- b06d28e7 (Phase 2: jobs + shopfloor)
- 6afc9e3c (follow-up tracking + pattern anchor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:58 -04:00
gsinghpal
6afc9e3c0d fix(fusion_plating): tracking warning + De-Masking pattern anchor
- fp.step.kind.area_kind: drop tracking=True (model doesn't inherit
  mail.thread; tracking was a no-op emitting a startup warning).
- Migration 19.0.10.25.0: anchor the De-Masking ILIKE so it doesn't
  wildcard-match "Ready For De-Masking" (which the earlier "Ready %"
  rule already routes to gating). Also drop the cur_code='mask' filter
  so the 4 De-Masking nodes still classified as 'other' get picked up
  on fresh re-runs too.

Direct SQL applied the 4-row fix on entech (post-migrate doesn't
re-run for already-applied versions); this commit keeps fresh
installs and any future re-runs consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:42 -04:00
gsinghpal
b06d28e7f6 feat(jobs+shopfloor): live-step priority chain + done-job filter
Fix the Shop Floor plant kanban so cards land in the right column:
- fp.job._compute_active_step_id walks priority chain
  (in_progress > paused > ready > pending), not just in_progress
- fp.job._compute_card_state edge case respects job.state='done'
  (no more bogus 'contract_review' label on done jobs)
- fp.job.step._compute_area_kind reads kind.area_kind directly;
  legacy _STEP_KIND_TO_AREA dict removed (50+ lines deleted)
- /fp/landing/plant_kanban filters out done/cancelled jobs from
  the live board

Migration 19.0.10.25.0 backfills template metadata (codes,
descriptions, icons, kind_id) on 30 unfinished library templates
and repoints recipe nodes for 6 unambiguous name patterns
(Blasting -> blast, Ready For X -> gating, De-Masking -> demask,
Scheduling -> gating, Nickel Strip -> wet_process,
Pre-Meas/Check Sulfamate -> inspect).

Battle test bt_s24_between_steps.py covers between-step routing,
paused step lifecycle, and done-job board filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:06:53 -04:00
gsinghpal
7b90f210b9 feat(fusion_plating): kind.area_kind drives Shop Floor column routing
Add required area_kind Selection to fp.step.kind so each kind
self-declares which plant-view column its steps belong in. Replaces
the hardcoded _STEP_KIND_TO_AREA dict (removed in fp_job_step.py
in the follow-up commit).

- New `blast` kind for the Blasting column (sequence=35)
- 26 existing kind records seeded with area_kind in XML
- Pre-migrate 19.0.21.2.0 seeds existing rows BEFORE NOT NULL hits
  the schema; also activates derack/demask/gating that were
  deactivated in 19.0.20.6.0 but are needed for the full taxonomy
- Step Kind form + list views surface area_kind (badge + chip)
- Step Kind search adds Group By Shop Floor Column
- Simple Editor kind picker shows "Masking — Masking column"
  suffix so authors see the routing at pick time
- Add Hot Water Porosity Test (A-15) + Final Inspection / Packaging
  templates (used by 7+3 recipe nodes that previously had no
  library entry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:02:02 -04:00
gsinghpal
c75d2bde5a fix(shopfloor): session.authenticate signature update for Odoo 19
Odoo 19's Session.authenticate(env, credential) takes an Environment as
the first arg, not a db-name string. Passing request.db triggered
TypeError: 'str' object is not callable on the internal
env(user=None, su=False) reset.

Fixes the "Odoo Server Error" dialog operators saw when trying to PIN
unlock from the tablet. Same fix applies to lock_session (which was
silently masked by its broad except Exception).

Bumps fusion_plating_shopfloor to 19.0.33.1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:56:05 -04:00
gsinghpal
9e6b88f60e chore(shopfloor): bump to 19.0.33.1.1 for lock_session kiosk-xmlid fix
Pure code change (no DB schema), but bumping the patch version
keeps repo manifest aligned with the deployed state so the next
-u doesn't no-op due to version match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:27:30 -04:00
gsinghpal
dc6afdd021 fix(shopfloor): lock_session resolves kiosk login via xmlid
The kiosk_login in /fp/tablet/lock_session was hardcoded to the
data XML's original value ('fp_tablet_kiosk@enplating.local'). The
data record is noupdate='1', so admins can (and on entech, did)
rename the kiosk user on the form for memorability — the rename
persists through -u, but the hardcoded string in the controller
silently breaks the re-auth-as-kiosk path.

Fix: resolve the kiosk login dynamically via env.ref of the xmlid
'fusion_plating_shopfloor.user_fp_tablet_kiosk'. Robust against any
future rename. CLAUDE.md updated to make 'identify by xmlid, never
by login string' an explicit convention for the tablet flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:26:46 -04:00
gsinghpal
978cd5953e docs(plating): update Shop-floor attribution section for Phase G
Tablet PIN session redesign Phase G removed all tablet_tech_id
plumbing. CLAUDE.md still documented the old session-pool + kwarg
flow which would mislead future-Claude. Updated to describe the
new per-tech-session attribution + kiosk re-auth flow, plus the
gotcha about keeping ir.config_parameter['fp.tablet.kiosk_password']
in sync with the actual user-record password.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:00:37 -04:00
gsinghpal
b869c31ba3 chore(shopfloor): bump to 19.0.33.1.0 after Phase G cleanup
Records the legacy-tablet-flow-removed state. Triggers -u so the
module's installed version reflects the post-cleanup code (the
ir_module_module row shows 19.0.33.1.0 after deploy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:38:03 -04:00
gsinghpal
67fc22237b cleanup(shopfloor): session_swap is the only tablet flow
Frontend cleanup completing Phase G of the tablet PIN session
redesign:

- tablet_lock.js: removed sessionMode branching (no legacy path).
  unlock() always calls /fp/tablet/unlock_session + reloads.
  handOff() always calls tabletSessionManager.lockBack('manual').
  isLocked uses currentUid vs kioskUid exclusively. _checkIdle
  still drives the warning UI via activity_tracker; the actual
  lock RPC is owned by tablet_session_manager.

- fp_rpc.js: simplified to a thin async pass-through around @web/core
  network rpc. tech_store-based tablet_tech_id injection is gone
  (the session uid IS the tech).

- tech_store.js: DELETED (replaced by per-session backend attribution
  + tablet_session_manager for lock state). Removed from manifest.

- Wrapper components (shopfloor_landing, job_workspace,
  manager_dashboard, plant_kanban): swapped useService('fp_shopfloor_tech_store')
  for useService('fp_tablet_session_manager'); techStore.lock() ->
  tabletSessionManager.lockBack('manual'). plant_kanban's defensive
  try/catch on the tech_store lookup is no longer needed.

- tablet_lock.xml: Hand-Off button no longer gated on sessionMode;
  always rendered.

- Tests: removed legacy TestTabletUnlock class from test_tablet_pin.py
  (covered the deleted /fp/tablet/unlock route). Dropped session_mode
  assertion from test_tiles_bootstrap_fields.py (the return key is
  gone post-Phase-G). kiosk_uid + current_uid assertions retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:36:12 -04:00
gsinghpal
d9f2983ea7 cleanup(shopfloor): remove legacy /fp/tablet/unlock + _tablet_audit helper
Session-swap is now the only flow. Legacy /fp/tablet/unlock endpoint
deleted. _tablet_audit.py (env_for_tablet_tech helper) deleted with
its last caller gone. /fp/tablet/ping no longer takes current_tech_id
(session uid IS the tech). /fp/tablet/tiles drops tablet_session_mode
return key (kiosk_uid + current_uid retained for OWL isLocked logic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:31:21 -04:00
gsinghpal
3120612e35 cleanup(shopfloor): strip tablet_tech_id from 17 endpoints
Session swap makes attribution automatic via request.env.user — the
tablet_tech_id plumbing is dead code after the kiosk + per-tech-session
architecture lands. Removed kwarg from 3 endpoints in
manager_controller, 11 in shopfloor_controller, 3 in
workspace_controller. _tablet_audit.env_for_tablet_tech import gone
from all 3 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:29:51 -04:00
gsinghpal
2a93ece4ba feat(shopfloor): per-user 7-day tablet event smart button
Owner-only smart button on res.users form. Click opens the audit log
filtered to that user (both user_id and attempted_user_id, so
failed unlock attempts against a tile show up too).

Compute is non-stored: search_count on the audit model per user on
demand. Sudo'd because the audit model has Owner-only ACL — the
compute fires for the form-viewing user (Owner) who would see the
results anyway via the menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:34:45 -04:00
gsinghpal
b26fa13044 feat(shopfloor): audit log list+form views, Owner-only menu
Plating > Configuration > Tablet Audit Log. Read-only list with
decoration (green=unlock, red=failed, warning=ceiling/force,
muted=manual/idle). Form shows full forensic detail incl. ip/ua.
Owner-only via groups=fusion_plating.group_fp_owner on the menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:34:00 -04:00
gsinghpal
7ff46af192 fix(shopfloor): Phase D review findings — defensive cleanups + bootstrap test
Important I1: tablet_session_manager.beginSession() now calls
_removeListeners() (and clears any pending _tickHandle) defensively
at start. Prevents DOM listener leak on dev hot-reload or any path
that re-bootstraps without a clean endSession() first.

Important I2: tablet_lock._checkIdle() early-returns in session_swap
mode. The tablet_session_manager owns idle tracking there (5s poll,
calls /fp/tablet/lock_session directly). Was previously dormant by
accident because session_swap never populates the legacy techStore;
explicit guard makes the decoupling intentional.

Minor M5: session_swap unlock success now resets selectedTileUserId
before window.location.reload(), matching the legacy path''s
cleanup pattern. Cosmetic before reload kicks in.

Minor M9: New test_tiles_bootstrap_fields with 3 HttpCase tests
asserting /fp/tablet/tiles returns tablet_session_mode, kiosk_uid,
and current_uid. The OWL lock screen branches on all three — a
contract regression would silently break session_swap.

Minor M10: Added inline comment near _sessionModeCache declaration
in fp_rpc.js explaining the page-reload-invalidates-cache lifecycle.

Deferred (for future polish, NOT in this commit):
- I3 (_getSessionMode ACL gap for tech users — functionally correct,
  just suboptimal; cache fallback to ''legacy'' kicks in)
- M6 (wrapper component Hand-Off buttons no-op in session_swap)
- M7 (hardcoded idle/ceiling thresholds — server-configurable later)
- M8 (timer divergence vs activity_tracker — unify later)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:30:29 -04:00
gsinghpal
6d4b6284ad feat(shopfloor): fpRpc skips tablet_tech_id injection in session_swap mode
When tablet_session_mode='session_swap', the server attributes every
write via request.env.user — there's no need to pass tablet_tech_id
in the RPC params. Caches the mode lookup at module level so we don't
round-trip on every RPC.

Legacy mode unchanged — fpRpc still injects tablet_tech_id from
techStore.currentTechId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:18:33 -04:00
gsinghpal
d8456fb9a3 feat(shopfloor): tablet_lock branches on tablet_session_mode
When ir.config_parameter[fp.shopfloor.tablet_session_mode]='session_swap',
PIN submit calls /fp/tablet/unlock_session and reloads the page; the
new session manager service kicks in on next mount. handOff() calls
lockBack('manual') which destroys the tech session server-side and
re-auths as kiosk.

Legacy mode unchanged — same /fp/tablet/unlock + techStore flow.

The feature flag, kiosk_uid, and current_uid arrive via the existing
/fp/tablet/tiles bootstrap response (Task D0).

Adds a tablet_lock-owned Hand-Off button visible only in session_swap
mode (in legacy mode wrapper components own their own buttons that hit
techStore.lock(); session_swap renders our own button so the manual
hand-off goes through lockBack() + page reload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:17:53 -04:00
gsinghpal
b41d9629e1 feat(shopfloor): tablet_session_manager OWL service
Tracks idle + ceiling timers for an unlocked tech session. Fires
/fp/tablet/lock_session when either trips, then reloads the page so
the browser re-bootstraps under the fresh kiosk session.

Defaults: 10min idle, 8hr ceiling, 5s tick interval. Listens for
click/touchstart/keydown/mousemove as activity signals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:15:28 -04:00
gsinghpal
10477a7c8f feat(shopfloor): /fp/tablet/tiles returns session_mode + kiosk_uid
OWL lock screen needs to know (a) the active session mode (legacy or
session_swap) to branch between endpoints, and (b) the kiosk uid to
determine 'is the current browser session the kiosk?' Both come from
the bootstrap response so no extra round-trips on every render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:14:41 -04:00
gsinghpal
8f6302b446 fix(shopfloor): Phase C review findings — lock_session closes unlock event + cron test
Important 1: lock_session now closes the original unlock event's
session_ended_at via the same parameterized-SQL bypass pattern used
by the force-lock cron. Without this, every Hand-Off click became
a duplicate force_lock event 8 hours later (cron saw the unlock still
open and re-processed).

Important 2: test_unlock_lock_session_endpoints setUp now
unconditionally overrides the kiosk password (was gated on
'if not get_param(...)' which broke on entech where the post-migrate
hook already generated a random password — tests failed against the
real value). HttpCase rolls back per test so no persistence.

Minor 4: _cron_force_lock_stale_sessions now routes the force_lock
create through write_event helper for consistency (single audit-write
path; helper captures acting_uid/ip/ua uniformly).

Minor 5: Hoisted local imports inside method bodies to top-of-file
in tablet_controller.py (AccessDenied, _tablet_session_audit) and
fp_tablet_session_event.py (timedelta, write_event).

Minor 6: New test_force_lock_cron.py with 3 tests: stale session
emits force_lock + closes original; recent session unaffected;
already-closed session not re-processed. Would have caught
Important 1 if it had existed during Phase C review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:08:30 -04:00
gsinghpal
87e924d1d8 test(shopfloor): HTTP tests for unlock_session + lock_session
5 tests covering correct/wrong PIN, audit event writes, manual/idle
lock reasons. Uses HttpCase to actually drive the JSONRPC endpoint
end-to-end with session cookie handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:55:19 -04:00
gsinghpal
7fab01e5cb feat(shopfloor): force-lock cron for stale tablet sessions
Every 5 minutes, find active unlock events past 8-hour ceiling and
mark them force-locked. SQL bypass of the model's read-only ACL is
the only path that can update existing rows (no Python write() works
because the model override blocks even sudo writes without the
explicit fp_tablet_audit_admin_write context flag).

Ceiling configurable via ir.config_parameter[fp.tablet.session_ceiling_hours].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:54:44 -04:00
gsinghpal
4911088dc1 feat(shopfloor): /fp/tablet/lock_session destroys tech session
Writes lock event (manual/idle/ceiling) with duration computed from
the open unlock event. Then logout + re-authenticate as kiosk via
the password stored in ir.config_parameter['fp.tablet.kiosk_password'].

Falls back to 'needs_kiosk_relogin' if the kiosk password is missing
(sysadmin must log in manually). Logs every event for forensic
review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:54:08 -04:00
gsinghpal
086ff512b6 feat(shopfloor): /fp/tablet/unlock_session mints real Odoo session
PIN verify -> request.session.authenticate(type=fp_tablet_pin) -> new
session sid, cookie swap, audit event written. Failed attempts also
written to audit log (failed_unlock, failure_reason=wrong_pin or
locked_out or no_pin_set or user_inactive).

OLD /fp/tablet/unlock stays alive during the 1-week overlap window
per spec Section 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:53:36 -04:00
gsinghpal
96e33834bd feat(shopfloor): _tablet_session_audit helper for audit-log writes
Single source for sha256(session sid), ua trim, ip/acting_uid capture
from request. Used by unlock_session, lock_session, and force-lock cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:53:03 -04:00
gsinghpal
765b095035 fix(shopfloor): Phase B review findings — C1/I1/I2/I3/M1
C1: Add placeholder fp_tablet_cron.xml + fp_tablet_session_event_views.xml
so the module is installable now (real content lands in Phase C task C4
and Phase E task E1 respectively).

I1: test_tablet_pin_auth_manager now passes {} (not self.env) as the
env arg to _check_credentials — matches what request.session.authenticate
provides and what the base implementation expects.

I2: Auth manager role check now uses user_sudo.all_group_ids (transitive)
instead of group_ids (direct) per CLAUDE.md rules 13l + 23. Owner users
who hold Owner directly still match all 5 shop-branch xmlids via the
implication chain.

I3: fp.tablet.session.event gains Python-layer write() + unlink()
overrides that always raise AccessError unless the explicit
fp_tablet_audit_admin_write / fp_tablet_audit_admin_purge context flag
is set. Closes the gap between the model's append-only docstring and
its actual enforcement (ACL-only previously).

M1: Hoisted 'from odoo.exceptions import AccessDenied' to top-of-file
imports next to existing UserError import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:47:26 -04:00
gsinghpal
358b90516b test(shopfloor): fp_tablet_pin auth manager handles all cases
8 tests: correct/wrong/missing PIN, missing/unknown login, inactive
user, no shop-branch role, and pass-through of other credential types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:30:56 -04:00
gsinghpal
dd0dc26232 feat(shopfloor): fp_tablet_pin custom auth manager
Validates PIN hash + shop-branch role membership when the credential
type is fp_tablet_pin. Goes through Odoo's standard _check_credentials
chain so future 2FA / IP-gate modules layer cleanly on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:30:24 -04:00
gsinghpal
1dea752a29 test(shopfloor): fp.tablet.session.event is append-only
Owner reads. Technician cannot read. Owner cannot write or unlink.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:29:52 -04:00
gsinghpal
9f3edd60ae feat(shopfloor): fp.tablet.session.event append-only audit log
Captures unlock / failed_unlock / manual_lock / idle_lock /
ceiling_lock / force_lock / admin_reset events with session hash,
ip, user-agent, duration, failure reason, acting uid.

Read-only ACL granted to Owner in Phase A; no write/unlink anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:29:22 -04:00
gsinghpal
0b92294586 fix(shopfloor-sec): narrow kiosk ir.config_parameter scope + doc accuracy
Code-review findings on Phase A (Tablet PIN Session Redesign):

I1: Security XML comment now honestly describes the kiosk as Internal
User + explicit reads, not 'near-zero ACL'. base.group_user is kept
(required for auth='user' HTTP routes to function) but the comment
no longer overstates how locked-down the kiosk is.

I2: New ir.rule scopes the kiosk's ir.config_parameter read to keys
matching 'fp.tablet.%' or 'fp.shopfloor.%'. Combined with the
existing model-level read ACL, kiosk can no longer enumerate
third-party secrets (e.g. fusion_tasks.vapid_private_key) or
arbitrary API keys stored in ICP.

I3: post-migrate docstring now advises sysadmins to unlink the
plaintext ICP password row after kiosk tablets are paired, to
minimise plaintext-in-backups risk. Rotation procedure documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:22:40 -04:00
gsinghpal
a52ef29a84 test(shopfloor): kiosk user ACL has near-zero access
7 tests covering allowed reads (res.users, ir.config_parameter)
and forbidden everything else (fp.job, sale.order, fp.certificate,
fp.part.catalog, res.users write).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:06:52 -04:00
gsinghpal
97deb93ee7 feat(shopfloor): post-migrate hook for kiosk password init
Generates a random kiosk password on first deploy, stores in
ir.config_parameter for sysadmin retrieval. Idempotent — re-runs
on subsequent -u leave the password alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:06:22 -04:00
gsinghpal
b67186a25b feat(shopfloor): create fp_tablet_kiosk user
Kiosk holds the tablet session when no tech is PIN-unlocked.
Password is auto-generated by the post-migrate hook (Task A5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:06:00 -04:00
gsinghpal
258782e3c3 feat(shopfloor-sec): kiosk ACL — read res.users + ir.config_parameter
Owner gets read on fp.tablet.session.event (audit log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:05:42 -04:00
gsinghpal
acc95d8ee0 feat(shopfloor-sec): group_fp_tablet_kiosk for tablet kiosk session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:05:14 -04:00
gsinghpal
e9b82fbe9d chore(shopfloor): bump to 19.0.33.0.0 for tablet PIN session redesign
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:04:55 -04:00
gsinghpal
c3bcb4b99d docs(plating): tablet PIN session redesign implementation plan
7 phases (A-G), ~25 tasks. Phase A-E build the new auth flow,
audit model, endpoints, OWL service, and audit UI. Phase F is the
entech rollout (manual, inline by main session per hybrid pattern).
Phase G is the post-overlap cleanup (rip out tablet_tech_id,
delete legacy endpoint, archive shopfloor service user).

Bakes in 7 known gotchas from the permissions overhaul (rules
13c, 13i, 13k, 13m, 13d, AUDIT-1, always-push-to-main) so the
implementer doesn't repeat them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:53:23 -04:00
gsinghpal
cfaf4657ce docs(plating): tablet PIN session redesign spec
Real per-tech Odoo sessions on PIN unlock (not just attribution).
Closes the audit-trail gap from Phase 1 permissions overhaul: today
the tablet runs as a persistent 'shopfloor service' user and the PIN
is just an OWL overlay — every action is attributed to whoever the
session user is, not the tech who tapped their tile.

Locked decisions:
1. Real per-tech sessions (impersonation, cookie swap)
2. Idle timeout 10min + manual lock + 8hr hard ceiling
3. Dedicated kiosk user (fp_tablet_kiosk, near-zero ACL)
4. No manager override — Mgr/Owner PIN in as themselves
5. Two-step deploy with 1-week overlap; OLD endpoint removed after
   successful rollout

Audit: fp.tablet.session.event append-only log captures unlock /
manual_lock / idle_lock / ceiling_lock / force_lock / failed_unlock
/ admin_reset events with ip, ua, session hash, duration.

Effort: ~4 dev days + 1 week observation. Plan via writing-plans
skill next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:42:00 -04:00
gsinghpal
7966f8d505 fix(shopfloor): tablet tiles domain uses group_ids (Odoo 19 rename)
Same mistake as the original implementer wave — used the deprecated
groups_id field name on res.users in the search domain. Odoo 19 raises
ValueError: Invalid field res.users.groups_id. Should be group_ids.

CLAUDE.md rule 13l example also fixed so future-Claude doesn't copy
the bug from the documentation.

Module version: 19.0.32.0.12 -> 19.0.32.0.13

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:48:29 -04:00
gsinghpal
839a7f0abc fix(shopfloor): tablet tile grid includes shop-branch role holders
Previously only direct Technicians appeared on the lock-screen tile
grid because env.ref('group_fp_technician').user_ids returns DIRECT
members only — Odoo's implication chain (Owner -> ... -> Technician)
is read-time only, not stored in res_groups_users_rel.

Search res.users with ('groups_id', 'in', shop_branch_ids) where
shop_branch_ids covers all 5 shop-branch role groups (Technician,
Shop Manager v2, Manager, Quality Manager, Owner). Sales branch
intentionally excluded — they don't operate the tablet.

Verified on entech: 18 technicians + 1 shop_manager + 2 managers
+ 1 quality_manager + 2 owners = 24 tiles (was 18).

CLAUDE.md rule 13l corrected — previous version wrongly claimed
res.groups.user_ids surfaced implied members. Now documents the
search-based query as the canonical 'enumerate role X or higher'
pattern.

Module version: 19.0.32.0.11 -> 19.0.32.0.12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:47:01 -04:00
gsinghpal
0f751d82cc feat(shopfloor): add Record Inputs button to Job Workspace step row
Operators trying to Finish a step with required step_input prompts
got the S21 gate error telling them to 'Click Record Inputs on the
step row' — but the workspace UI never exposed that button. Only the
job-form view had it.

Adds a 'Record Inputs' secondary button next to Finish/Finish & Sign
Off when the step is active. Click opens the fp_record_inputs_dialog
(via action_open_input_wizard on fp.job.step). On dialog close the
workspace refreshes so the step's progress chip updates.

Module version: 19.0.32.0.10 -> 19.0.32.0.11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:34:09 -04:00
gsinghpal
aa8161f764 fix(shopfloor): sudo job recordset in /fp/workspace/load (rule 13m)
Same pattern as plant_kanban — workspace payload denormalizes
cross-module fields Technician can't read directly (sale.order,
fp.part.catalog, customer_spec, etc.). job.sudo() at the top so
the whole render path is sudo'd.

Job Workspace was stuck on 'Loading...' with a server-error toast
because the route returned {ok:false, error:'...'} (27-byte response)
when the first cross-module field access AccessError'd.

Module version: 19.0.32.0.9 -> 19.0.32.0.10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:28:58 -04:00
gsinghpal
31740b3949 fix(shopfloor): sudo cross-module reads in Plant Kanban _render_card
Post-migration, Technicians (now group_fp_technician) have read on
fp.job but NOT on sale.order / fp.part.catalog / fusion.plating.customer.spec.
The kanban render path tries to access job.sale_order_id.x_fc_po_number
and AccessErrors silently — kanban returns empty, user sees blank
'Shop Floor' page.

Fix: `job = job.sudo()` at the top of _render_card. The output is
denormalized display data, no security concerns; ACL gating is still
enforced by the caller's access to fp.job (which Technician does have).

CLAUDE.md rule 13m documents the broader pattern: any dashboard /
tablet / kanban controller surfacing cross-module data to low-priv
roles needs this sudo at the helper top.

Module version: 19.0.32.0.8 -> 19.0.32.0.9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:19:39 -04:00
gsinghpal
e99cf20887 style(shopfloor): tablet lock clock 24h -> 12h with AM/PM
Operators read phone-style clocks; 24-hour was off-norm for North
American shop. Hour no longer zero-padded (1:05 PM, not 01:05 PM)
to match the iPhone/Android idiom.

Module version: 19.0.32.0.7 -> 19.0.32.0.8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:16:17 -04:00
gsinghpal
cc5542833f style(shopfloor): tablet lock screen tile grid 3 -> 5 columns
Wider tablets fit 5 tiles per row comfortably; 3 was too sparse with
a 20-person operator roster (forced a long vertical scroll). Bumped
.o_fp_lock_tiles max-width from 480px to 800px so the tiles don't
stretch wide at 5 columns.

Module version: 19.0.32.0.6 -> 19.0.32.0.7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:13:52 -04:00
gsinghpal
0568d8ae87 fix(plating-perms): widen settings landing field to ir.actions.actions
res.config.settings.x_fc_default_landing_action_id is related= to
res.company.x_fc_default_landing_action_id, which was widened from
ir.actions.act_window to ir.actions.actions in the Phase I post-deploy
fixes (so the picker accepts both window AND client actions). The
settings field's comodel was left at the old type and tripped on
opening Settings: 'Wrong value for ...: ir.actions.actions()' when
the related compute tried to write the client-action value into the
narrower settings field.

Module version: 19.0.21.1.2 -> 19.0.21.1.3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:11:40 -04:00
gsinghpal
c2180d3691 Merge: Fusion Plating Permissions Overhaul Phase 1
Consolidates 12 res.groups into 8 clean roles:
  Owner -> Quality Manager -> Manager -> [Shop Manager, Sales Manager]
       -> [Technician, Sales Rep], plus implicit 'No' (no plating group).

Phase A — 7 new res.groups with implied_ids chains + backward-compat;
old groups marked [DEPRECATED] and queued for 30-day cron purge.
Phase B — mechanical ACL sweep across 24 ir.model.access.csv files.
Phase C — Manager/QM quality permission split + FAIR/Nadcap ir.rule.
Phase D — 3-layer menu/submenu/field/button visibility hardening.
Phase E — role-based landing-page dispatch (Owner -> Manager Desk,
QM -> Quality Dashboard, Sales Rep -> Quotations, Tech -> Plant
Kanban, etc.) + picker domain over ir.actions.actions so window AND
client actions are both pickable.
Phase F — Owner-only Plating > Configuration > Team kanban for
drag-and-drop role assignment, plus Designated Officials (CGP DO +
Nadcap Authority) fields on res.company.
Phase G — Sales Manager + required to confirm SO; fixed the
audit-finding-11 _administrator typo that had made the account-hold
bypass dead code; swept all Python has_group() refs to new xmlids.
Phase H — dry-run + Owner-approval migration workflow with
fp.migration.preview model, mail.activity notification, 30-day
rollback window, daily purge cron.
Phase 9 — final-reviewer fixes (groups_id->group_ids, server-action
wiring, migrations/19.0.21.1.0/post-migrate.py for -u dispatch,
Odoo 19 kanban card template, FAIR/Nadcap cert_type field name,
user_has_groups removed from invisible attrs).
Phase I — pre-deploy backup, entech deploy (5 cascade fixes
discovered live), Owner approval of migration #1 (25 users
migrated cleanly), post-approval SQL verification, sample login
tests, deprecated-group picker cleanup (Option A SQL UPDATE),
and 11 post-deploy bug fixes (picker model swap to ir.actions.actions,
ACL grant for ir.actions.actions read to plating users, SELF_WRITEABLE_FIELDS
extension for non-admin Preferences save, res.users.message_post ->
partner_id.message_post, tablet lock screen group ref swap,
PIN-pad dark-mode dot contrast, lock-screen logo plate dark mode).

Spec: docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md
Plan: docs/superpowers/plans/2026-05-24-permissions-overhaul-phase1-plan.md
CLAUDE.md rules added: 13b-13l (Odoo 19 gotchas surfaced during build/deploy)

Live state on entech: 25 users migrated, 30-day rollback open
until 2026-06-23, deprecated groups hidden from picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:05:25 -04:00
gsinghpal
42036c23ab fix(plating-perms): Phase I post-deploy fixes (live entech test catches)
8 distinct bugs caught + fixed while testing the live admin DB on entech
after the migration was approved. Each surfaced a real Odoo 19 gotcha
now codified in CLAUDE.md (rules 13b-13l).

Picker architecture:
- res.users.x_fc_plating_landing_action_id and res.company.x_fc_default_landing_action_id
  now Many2one('ir.actions.actions') instead of ('ir.actions.act_window'),
  so the picker accepts BOTH window actions (Sale Orders / Quotations /
  Process Recipes) AND client actions (Manager Desk / Plant Kanban /
  Quality Dashboard). Picker went from 3 entries to 6.
- x_fc_pickable_landing field moved from the two subclasses to the
  ir.actions.actions base. Single source of truth.
- _render_resolved on the base dispatches to the correct subclass by
  action type.

Non-admin Preferences access:
- Added ACL grant: group_fp_technician (and all higher roles via
  implication) get read on ir.actions.actions. Without this, opening
  Preferences raised AccessError on the picker domain evaluation.
- Removed the accessible_landing_action_ids Many2many compute (failed
  for non-admins because field assignment requires write access on
  the comodel relation, even with sudo'd search). Picker now shows all
  6 entries to all users; resolver falls through gracefully if the
  user picks an action they can't reach.
- res.users SELF_WRITEABLE_FIELDS / SELF_READABLE_FIELDS extended via
  @property + super() (NOT class attribute — Odoo 19 changed the
  pattern). Non-admin users can now save the Preferences dialog with
  plating fields without hitting the standard write ACL.

Migration workflow:
- res.groups.users -> .user_ids (Odoo 19 rename; deprecated alias
  removed). Was crashing _fp_notify_owners and _cron_purge_expired.
- user.message_post -> user.partner_id.message_post (res.users uses
  _inherits delegation which doesn't expose mail.thread methods).
  Was crashing the Owner approval click.

Tablet lock screen:
- /fp/tablet/tiles points at group_fp_technician instead of the old
  group_fusion_plating_operator. Post-migration nobody holds the old
  group directly (only via implication), so res.groups.user_ids on
  the old xmlid returned empty — 'No operators configured' shown
  even with PIN set.
- PIN pad dots dark mode: empty dot now dark gray (#424245), filled
  dot now pure white. Previous version had both at light shades so
  user couldn't see PIN entry progress.
- Lock-screen logo frame dark mode: near-opaque white plate
  (rgba 0.95) so company logos designed for light backgrounds
  render correctly. Previous 0.08 alpha let the dark page bleed
  through.

Pre-deploy collision fix (already committed before deploy but
documented here for completeness):
- pre-migrate.py to rename old configurator's 'Shop Manager' group
  display name before new fp_security_v2.xml loads the new
  group_fp_shop_manager_v2 with the same display name (avoids
  res_groups_name_uniq violation).

Module versions bumped:
  fusion_plating: 19.0.21.1.0 -> 19.0.21.1.2
  fusion_plating_shopfloor: 19.0.32.0.4 -> 19.0.32.0.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:02:32 -04:00
gsinghpal
7bcbcb4008 fix(plating-perms): deploy-time cascade fixes from entech I3
5 fixes discovered during the live deploy to entech LXC 111:

1. pre-migrate.py to rename old configurator's 'Shop Manager' group BEFORE
   new core 'Shop Manager v2' XML loads (cross-module name collision on
   res_groups_name_uniq).

2. res_company_views.xml: dropped ref() inside <field domain=> attribute
   (Odoo 19 view validator interprets it as a field name).

3. sale_order_views.xml: replaced 3 separate xpaths for amount_total /
   amount_untaxed / amount_tax with a single xpath on tax_totals widget
   (Odoo 19 sale.view_order_form uses one widget instead of separate fields).

4. fp_cert_security.xml: certificate_type field, not cert_type. FAIR is a
   separate model so the rule only restricts cert_type='nadcap_cert' now.

5. fp_certificate_views.xml + fp_capa_views.xml + fp_customer_spec_views.xml:
   stripped user_has_groups() from invisible= / readonly= attrs (Odoo 19
   view validator interprets as field name). Model-layer ACLs and ir.rules
   already enforce the same restrictions.

Also fixed res.groups.users -> user_ids in fp_migration.py (Odoo 19 rename,
caught when manually invoking _fp_notify_owners post-deploy).

CLAUDE.md updated with 4 new rules (13e cross-module name collisions,
13f ref() in domain, 13g tax_totals widget, 13h user_has_groups in attrs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:07:13 -04:00
gsinghpal
0047f49d2c fix(plating-perms): address final-reviewer critical + important issues
Pre-deploy fixes for Phase 1 permissions overhaul branch (catches by
final-reviewer subagent + main session).

CRITICAL FIXES:

C1: groups_id -> group_ids (Odoo 19 field rename). Affected ~30 sites
    across 4 model files, 1 view, 7 test files. Documented project
    gotcha (feedback_odoo19_groups_id_renamed.md) that the implementer
    subagents missed because they don't see user memory.

C2: action_fp_resolve_plating_landing server action now calls
    env['ir.actions.act_window'].sudo()._fp_resolve_landing_for_current_user()
    instead of the old inline priority chain. Phase E's role-based
    dispatch was previously dead code.

C3: New migrations/19.0.21.1.0/post-migrate.py triggers
    _fp_post_init_role_migration(env) on -u. post_init_hook only fires
    on INSTALL in Odoo 19, not UPGRADE -- so Phase H's preview creation
    wouldn't have auto-fired on entech without this script. Module
    version bumped to 19.0.21.1.0 to match the migration directory.

C4: Team kanban template rewritten for Odoo 19 (<t t-name='card'> with
    semantic <aside>/<main>) instead of legacy <t t-name='kanban-box'>.
    Previous template threw 'Missing card template' at render.

IMPORTANT FIXES:

I1: SO state=sent Confirm button (id='action_confirm') now also gated
    to group_fp_sales_manager. Previously only the state=draft button
    was gated; Sales Reps could send-and-confirm via the secondary path.

I2: Designated Officials picker domain uses all_group_ids (transitive)
    instead of group_ids (explicit only). Owner users now correctly
    appear as eligible CGP DO candidates via the implied_ids chain.

I3: test_menu_visibility.py compliance hub xmlid corrected to
    fusion_plating.menu_fp_compliance_hub (was
    fusion_plating_compliance.menu_fp_compliance_hub which doesn't exist
    -- the hub menu is defined in core's fp_menu.xml). Tests were
    silently skipTest-ing.

I4: _inverse_plating_role chatter audit reads old role from DB via SQL
    (bypasses cache) so 'old -> new' displays actual values, and
    short-circuits no-op writes.

I5: _FP_ROLE_MAPPING_RULES reordered: cgp_designated_official fires
    BEFORE admin/uid_1_or_2 so admin+DO users keep the capability_delta
    marker that triggers res.company.x_fc_cgp_designated_official_id
    auto-set during migration.

I6: _cron_purge_expired_migrations skips groups with active users
    instead of unlink-ing unconditionally. Defense against rollback
    safety being bypassed by manual role assignments post-migration.

CLAUDE.md updated with 3 new durable rules (13b kanban card template,
13c group_ids vs all_group_ids, 13d post_init_hook only on install).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:37:13 -04:00
gsinghpal
5cc1117f75 feat(plating-migration): dry-run + Owner-approval workflow
Phase H of permissions overhaul (LAST subagent phase).

New models:
- fp.migration.preview (state: pending/approved/cancelled/rolled_back)
- fp.migration.preview.line (one per active internal user)

On -u, post_init_hook creates a preview in 'pending' state, walks all
active non-share users through the 12-rule mapping predicate chain
(first match wins, highest precedence first), and schedules a
mail.activity on every Owner.

Mapping table (per spec Section 5):
  uid 1/2 / Administrator   -> owner
  CGP DO (existing)          -> owner + res.company DO field set
  CGP Officer                -> quality_manager
  Manager / Shop Mgr (old)   -> manager
  Accounting                 -> manager
  Estimator-without-Manager  -> sales_rep (flagged: loses confirm)
  Supervisor / Receiving     -> shop_manager
  Operator                   -> technician
  catchall                   -> 'no'

Owner clicks 'Approve & Run' on the preview form -> sudo write removes
old plating groups, adds new role's group, posts Markup chatter audit.
Optionally sets res.company.x_fc_cgp_designated_official_id for the DO.

30-day rollback window via JSON snapshot of groups_id per line. Daily
cron (Fusion Plating: Purge Expired Role Migrations) clears snapshots
+ unlinks old [DEPRECATED] groups after 30 days.

ACL: fp.migration.preview + .line both Owner-only (CRUD).
Menu: Plating > Configuration > Role Migrations (Owner-only).

Tests cover: only-Owner-can-approve, approve advances state, cancel
blocks after approval, rollback restores groups_id, Estimator warning
flagged, uid 2 maps to owner, rollback blocked after 30 days.

Per CLAUDE.md: ir.cron uses only Odoo-19-valid fields (no numbercall,
no doall). Post-init hook is idempotent — won't double-create previews
or re-fire if all users already migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:21:43 -04:00
gsinghpal
de3ec7d97a feat(plating-sec): SO confirm gate + fix _administrator typo + Python sweep
Phase G of permissions overhaul.

G2: sale.order.action_confirm now requires group_fp_sales_manager
(spec Section 2.B). Sales Reps can save drafts but cannot move SOs
to 'sale' state. UserError raised with clear message if attempted.

G3: Fixed audit-finding-11 typo bug in 2 files. The original code
checked has_group('fusion_plating.group_fusion_plating_administrator'),
an xmlid that has NEVER existed - so the gate always returned False
and only the Manager-side check actually fired. Fixed both:
  - fusion_plating_invoicing/models/res_partner.py:34
  - fusion_plating_configurator/wizard/fp_direct_order_wizard.py:467
Both now check has_group('fusion_plating.group_fp_manager') which
transitively includes Owner via implied_ids.

G4: Swept all Python has_group() calls to reference new group xmlids.
Backward-compat keeps old refs working today (Phase A's implied_ids),
but the sweep ensures correctness after the 30-day rollback window
deletes old groups. Replacements:
  group_fusion_plating_operator    -> group_fp_technician
  group_fusion_plating_supervisor  -> group_fp_shop_manager_v2
  group_fusion_plating_manager     -> group_fp_manager
  group_fusion_plating_admin       -> group_fp_owner
  group_fusion_plating_cgp_officer -> group_fp_quality_manager
  group_fusion_plating_cgp_designated_official -> group_fp_owner
  group_fp_estimator               -> group_fp_sales_rep
  group_fp_accounting              -> group_fp_manager
  group_fp_receiving               -> group_fp_shop_manager_v2
  group_fp_shop_manager (legacy)   -> group_fp_manager

G1: test_sales_manager_gate.py covers the new confirm gate (SR
blocked, SMg allowed, Manager allowed via diamond implication).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:11:35 -04:00
gsinghpal
89a937fb32 feat(plating-team): Owner-only Team kanban + Designated Official fields
Phase F of permissions overhaul.

Adds res.users.x_fc_plating_role Selection field (8 options matching
the role hierarchy). Compute reads highest plating group from
groups_id (precedence: owner > QM > manager > sales_manager >
shop_manager > sales_rep > technician). Inverse uses sudo().write()
to clear all plating-role groups (additive-by-default m2m (3, id))
then adds the chosen one, and posts a Markup-wrapped chatter audit
naming the actor.

New Owner-only menu: Plating > Configuration > Team. Standard
res.users kanban grouped by x_fc_plating_role with records_draggable
for drag-and-drop role changes. Domain hides shared/portal users
and archived users.

res.company gains two Designated Official fields:
- x_fc_cgp_designated_official_id (CGP DO per Defence Production Act §22)
- x_fc_nadcap_authority_user_id (Nadcap signer)

Both tracking=True for audit. View-level domain restricts picker to
Owner or Quality Manager users via [(ref('...'), ref('...'))] xmlid
domains. New 'Plating Designated Officials' page on res.company form,
Owner-only visibility.

Tests in test_team_page.py cover compute/inverse/chatter/menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:03:44 -04:00
gsinghpal
830b29ce49 feat(plating-landing): role-based dispatch resolver + picklist expansion
Phase E of permissions overhaul. The landing resolver now dispatches
based on the user's highest role (per spec Section 3):

  Owner          -> Manager Desk
  Quality Mgr    -> Quality Dashboard
  Manager        -> Manager Desk
  Sales Manager  -> Sale Orders
  Shop Manager   -> Plant Kanban (v2 layout) or Workstation (legacy)
  Sales Rep      -> Quotations
  Technician     -> Plant Kanban / Workstation

User override (x_fc_plating_landing_action_id) still wins; company
default and hardcoded Sale Orders are fallbacks. Layout-flag-aware via
ir.config_parameter['fusion_plating_shopfloor.layout'] (v2 vs legacy).

x_fc_pickable_landing field added to BOTH ir.actions.act_window AND
ir.actions.client (Manager Desk / Plant Kanban / Quality Dashboard
are client actions). Resolver helper polymorphically calls
_render_resolved() on either model.

Tagged 3 of 4 new actions pickable: Manager Desk, Plant Kanban,
Quality Dashboard. (action_fp_shopfloor_landing doesn't exist as an
XML record — it's a JS component name only; legacy layout falls
through to company default gracefully via raise_if_not_found=False.)

Tightened picklist domain to filter by user accessibility (Technician
no longer sees Manager Desk in the dropdown). New compute field
res.users.accessible_landing_action_ids runs check_access_rights on
each pickable action.

Tests in fusion_plating/tests/test_landing_resolver.py.

CLAUDE.md updated with two durable rules:
  - x_fc_pickable_landing lives on BOTH act_window and actions.client
  - Role-based dispatch precedence and helper API

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:56:37 -04:00
gsinghpal
269f9984ef feat(plating-views): Layer 3 — field/button gates per role
Phase D Task D5 of permissions overhaul. Adds explicit groups= to
form-level elements so non-matching roles don't even SEE the buttons
they can't use:

- SO Confirm button → group_fp_sales_manager (Sales Rep sees the SO
  in draft but no Confirm button — matches model-level gate from Phase G)
- SO pricing fields (price_unit/subtotal/total/untaxed/tax) →
  group_fp_sales_rep (Technician/Shop Manager don't see pricing if
  they navigate to an SO)
- Partner Account Hold tab → group_fp_manager (was the fold-in
  group_fp_accounting; the audit-finding-11 _administrator typo lives
  in res_partner.py and is Phase G's fix)
- CAPA Close + all state-transition buttons → group_fp_quality_manager;
  edit fields use readonly="not user_has_groups(...)" so Manager
  retains read+comment per spec section 2.C
- Audit Start/Findings/Close buttons → group_fp_quality_manager
- AVL Approve/Suspend/Reinstate/Remove → group_fp_quality_manager
  (model uses Suspend+Remove instead of spec's literal 'Disqualify';
  both surfaces gated, semantics match)
- Customer Spec edit fields → readonly for non-QM (Manager keeps
  read access per spec; only inputs lock)
- FAIR Approve/Reject buttons → group_fp_quality_manager (Submit-
  for-Review and Reset stay open to whoever created the FAIR)
- Certificate Issue button — Strategy B chosen: single button hidden
  when cert_type=nadcap_cert AND user is not QM. Cleaner than splitting
  into two buttons; no separate action_sign exists on fp.certificate
  (Issue is the sign+publish action). FAIR lives in its own model;
  fp.certificate only has nadcap_cert as a special type. The ir.rule
  from Phase C enforces model-level writes independently.
- CGP form buttons (7 view files: ai, controlled_good, psa,
  receipt_shipment, registration, security_incident, visitor) →
  group_fp_quality_manager on every action button

Defense in depth: ir.rules and ACLs (from Phases B + C) already
restrict model access. These view gates are the UI layer that
matches.

Concerns:
- Spec line 192 names 'sale.order view — x_fc_account_hold_override'
  but no such field exists in the codebase. Closest practical match
  was the partner-side Account Hold management tab, which already had
  a group= attribute. Re-gated there; no SO-side field to gate.
- AVL model has no action_disqualify per spec; uses suspend+remove.
  Both gated to QM.
- fp.certificate has no action_sign (only action_issue). FAIR's
  approve/reject covers the FAIR side; nadcap-cert Issue covers the
  cert side via Strategy B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:45:39 -04:00
gsinghpal
9e5c23f37d fix(plating-tests): correct menu xmlids (menu_fp_sales, menu_fp_shopfloor)
Implementer concern from D1-D4 dispatch: plan template referenced
menu_fp_sales_root / menu_fp_shopfloor_root but actual xmlids drop
the _root suffix. Tests were silently skipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:36:28 -04:00
gsinghpal
36cd4341a7 feat(plating-menu): Layer 1+2 — explicit groups on top-level menus + submenus
Phase D Tasks D1-D4 of permissions overhaul. Adds explicit groups=
attributes to:
- 9 top-level Plating menus (matrix per spec Section 2.E)
- Quality submenus: Audits, Customer Specs, AVL → QM-only
- Compliance hub child submenus (CGP, General, Safety, Aerospace,
  Nuclear) → QM-only
- Operations submenus: Maintenance, Move Log, Labor History → Shop
  Manager+; Replenishment Suggestions → Manager+

Replaces fragile inheritance + action-ACL-based visibility with
explicit per-menu gates. Now every role's menu tree is deterministic.

Also adds fusion_plating/tests/test_menu_visibility.py — per-role
matrix tests using ir.ui.menu.search_count with the test user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:35:11 -04:00
gsinghpal
c34dfce6c3 fix(plating-tests): correct AVL model name (fusion.plating.avl)
Implementer-flagged concern: plan template referenced fp.approved.vendor.list
but actual model id is fusion.plating.avl. Tests were silently skipping
instead of exercising the AVL split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:24:24 -04:00
gsinghpal
84ed406c8e feat(plating-quality): split Manager vs Quality Manager permissions
Phase C of permissions overhaul (spec Section 2.C).

Manager keeps reactive Quality (NCR/Hold/Check/Cert/RMA — already gated
via Phase B sweep). QM gains exclusive write/create/unlink on strategic
Quality records:

- fusion.plating.capa: Manager → read-only (1,0,0,0); QM → full
- fusion.plating.audit: same split (if model present)
- fp.approved.vendor.list: same split (if model present)
- fusion.plating.customer.spec: same split
- Doc Control models: same split

Plus FAIR/Nadcap cert restriction via two new ir.rule records on
fp.certificate:
- Manager: write/create/unlink on certs where cert_type NOT in
  ('fair', 'nadcap')
- QM: write/create/unlink on all certs (overrides via OR within group)
- Read access unchanged for both (perm_read=False on the rules)

Tests in fusion_plating/tests/test_quality_split.py verify each side
of the split. Models that may not exist on all DBs (audit, AVL) use
skipTest gracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:23:32 -04:00
gsinghpal
f4e1f9d218 refactor(plating-sec): extend ACL sweep to 13 missed modules
The Phase B plan (commit 8eb2c2de) listed 12 modules to sweep, but the
codebase has 13 more modules with ACL CSVs referencing the old role
group xmlids. Backward-compat (Phase A's implied_ids chains) keeps
these working today, but the old groups will be deleted after the
30-day rollback window — so the sweep must cover ALL modules with
plating-group ACL refs to avoid post-rollback breakage.

Sweeps: batch, bridge_documents, bridge_maintenance, bridge_mrp
(uninstalled but file present), bridge_quality (planned removal),
bridge_sign, compliance, culture (retired), kpi, logistics,
notifications, portal, reports.

Pattern matches the original sweep:
  group_fusion_plating_operator → group_fp_technician
  group_fusion_plating_supervisor → group_fp_shop_manager_v2
  group_fusion_plating_manager → group_fp_manager
  group_fusion_plating_admin → group_fp_owner
  group_fp_accounting → group_fp_manager
  group_fp_receiving → group_fp_shop_manager_v2
  group_fp_estimator → group_fp_sales_rep
  group_fp_shop_manager (legacy) → group_fp_manager
  cgp_officer → group_fp_quality_manager
  cgp_designated_official → group_fp_owner

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:18:52 -04:00
gsinghpal
8eb2c2de95 refactor(plating-sec): sweep all ACL CSVs to new role group xmlids
Phase B of permissions overhaul. Mechanical text replacement across
11 ir.model.access.csv files:
  - group_fusion_plating_operator    -> fusion_plating.group_fp_technician
  - group_fusion_plating_supervisor  -> fusion_plating.group_fp_shop_manager_v2
  - group_fusion_plating_manager     -> fusion_plating.group_fp_manager
  - group_fusion_plating_admin       -> fusion_plating.group_fp_owner
  - group_fp_estimator (configurator)-> fusion_plating.group_fp_sales_rep
  - group_fp_accounting              -> fusion_plating.group_fp_manager
  - group_fp_receiving               -> fusion_plating.group_fp_shop_manager_v2
  - group_fp_shop_manager (legacy)   -> fusion_plating.group_fp_manager
  - group_fusion_plating_cgp_officer -> fusion_plating.group_fp_quality_manager
  - group_fusion_plating_cgp_designated_official -> fusion_plating.group_fp_owner

Backward-compat: old group xmlids still resolve (Phase A's implied_ids
chains keep old ACLs working for users still holding old groups).
This sweep ensures future-state correctness: when old groups are deleted
after the 30-day rollback window, ACLs continue resolving via the new
group xmlids.

Also adds fusion_plating/tests/test_acl_migration.py with sample-based
per-role access checks. The 2 CAPA tests are expected to fail until
Phase C implements the Manager/QM quality split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:14:02 -04:00
gsinghpal
bdf676e05a test(plating-sec): verify 8-role hierarchy + implied_ids chains
Group-structure tests for Phase 1 permissions overhaul. Covers:
- All 7 new res.groups records present (8th role "No" is implicit)
- Owner transitively implies base.group_system + every old group
- Manager forms the diamond (implies both Shop Manager and Sales Manager)
- Sales and Shop branches remain orthogonal at the leaf (Tech != Sales Rep)
- uid 1/2 auto-assigned to Owner
- Sequence numbers unique (renders dropdown predictably)
- New groups imply old for backward-compat (30-day rollback safety)
- Cross-module backward-compat chain works (e.g., Owner -> CGP DO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:03:48 -04:00
gsinghpal
6c7e11db4d fix(plating-sec): move cross-module implied_ids out of fp_security_v2.xml
The previous commit (a53b0326) added implied_ids in fp_security_v2.xml
that referenced 5 xmlids from downstream modules (configurator/receiving/
invoicing/cgp). Since fusion_plating is the BASE module and loads first
at fresh install, those refs raised External-ID-not-found at install.

Fix: relocate the 5 cross-module implications into each downstream module's
own security file via additive (4, ref()) writes to the core group's
implied_ids. Odoo's XML data loader treats these as additive updates so
they stack cleanly across install + -u cycles.

Also: drop redundant <data noupdate="0"> wrapper in fp_security_v2.xml
to match sibling fp_security.xml's bare <odoo> shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:59:20 -04:00
gsinghpal
a53b03265d feat(plating-sec): add 8 consolidated role groups + mark old groups deprecated
Phase A of permissions overhaul (see docs/superpowers/specs/2026-05-23-*).
New groups (technician/sales_rep/shop_manager_v2/sales_manager/manager/
quality_manager/owner) defined in fp_security_v2.xml with implied_ids
chains that include old groups for backward-compat during 30-day rollback
window. Old groups display as [DEPRECATED] in user form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:47:54 -04:00
gsinghpal
560ffa2cdf docs(plating): permissions overhaul Phase 1 — spec + implementation plan
Spec describes consolidation of 12 res.groups into 8 roles (No / Technician /
Sales Rep / Shop Manager / Sales Manager / Manager / Quality Manager / Owner),
role-based landing-page defaults, Owner-only Team management page, and
dry-run + Owner-approval migration workflow.

Plan breaks the work into 9 phases (A through I), ~40 TDD tasks, with
explicit file lists and entech deploy commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:43:00 -04:00
gsinghpal
d89546bec7 fix(shopfloor): Back button + logo frame shape
Two fixes from live testing of the 2026-05-24 redesign:

1. Job Workspace Back button routed to deprecated component.
   onBack() hardcoded tag: 'fp_shopfloor_landing' so tapping a card on
   the new plant kanban -> opening the workspace -> clicking Back
   dropped the user into the OLD per-step kanban (the legacy OWL
   component the data-record redirects don't reach because doAction
   bypasses the data layer).
   Fix: change the hardcoded tag to 'fp_plant_kanban'. Grep
   confirmed it's the only such reference in JS.

2. Logo frame shape — wider, shorter, logo bigger.
   140x140 square -> 280x110 rectangle. Better fit for horizontal
   company logos (mark + name + tagline laid out left-to-right).
   Uniform 18px padding on all sides so the image breathes evenly.
   Image area is ~244x74 (vs old ~104x104), so a typical horizontal
   logo renders ~50% wider. border-radius 28->22 for the flatter
   rect; letter-mark placeholder font 52->44 to fit the shorter
   frame.

Also augmented CLAUDE.md 'Legacy-action redirect' rule with a new
'grep JS for hardcoded doAction' clause — the XML-record redirect
trick only covers ir.actions.client data; OWL components with inline
this.action.doAction({tag: ...}) calls bypass the data layer entirely
and need a separate sweep.

Asset cache cleared (3 stale attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:11:51 -04:00
gsinghpal
818dfa3882 fix(shopfloor): bigger logo frame on the tablet lock screen
User feedback after live testing: the 84px logo frame felt too small
and the image inside used only a fraction of the frame area.

Bumped the frame to 140px (1.67x) — image scales with the container
via the existing max-width/max-height: 100% rule. Proportional
adjustments to padding (14→18), border-radius (20→28), margin-bottom
(12→16), and the letter-mark placeholder font (32→52).

SCSS-only change. Asset cache cleared (3 stale attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:06:17 -04:00
gsinghpal
772107d25b feat(shopfloor): tablet lock-screen redesign — frontend + manifest
LS-T2..T6 of the tablet lock-screen redesign (LS-T1 backend shipped
separately in c6137100).

Files:
  - _tablet_lock_tokens.scss  (new — design tokens, dark/light branches
                               via $o-webclient-color-scheme, registered
                               first in manifest per project rule 8)
  - tablet_lock.scss          (full rewrite — gradient bg, glassmorphic
                               tiles, 4 entrance keyframes, hover lift,
                               click press, clocked-in pulse,
                               prefers-reduced-motion gate)
  - tablet_lock.xml           (extended — logo + clock + prompt blocks
                               wrapping the existing tile loop; tile
                               inner shape updated for avatar gradient,
                               has_photo conditional, is_clocked_in
                               modifier)
  - tablet_lock.js            (extended — state.clockText / dateText /
                               company, setInterval(60s) clock tick,
                               _formatTime / _formatDate / tileStyle /
                               avatarClass helpers per project rule 20)
  - __manifest__.py           (19.0.31.0.0 -> 19.0.32.0.0, registered
                               new tokens SCSS BEFORE tablet_lock.scss)

Verified live on entech:
  - Module upgrade clean, registry loaded in 15.5s
  - 6 stale asset attachments cleared
  - Helpers in tablet_controller.py emit company payload + initials +
    gradients correctly (Garry Singh -> GS, EN Tech -> ET, uid=5 ->
    pink gradient)
  - res.company.logo present (has_logo: True)
  - All animations gated by prefers-reduced-motion per spec §6

CLAUDE.md updated with new Critical Rule 22 about Odoo 19 HTML fields
auto-wrapping plain-string writes — caught during Task 1 testing when
the original 'tagline equality' test failed because res.company.report
_header is an HTML field that wraps writes with <p> tags.

Closes the 6-task plan in
  docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md
Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:56:32 -04:00
gsinghpal
c61371005a feat(shopfloor): extend /fp/tablet/tiles payload with company block
LS-T1 of the tablet lock-screen redesign.

Adds 3 module-level helpers in tablet_controller.py:
  _initials_from(name)       — first/last initials for letter-mark fallback
  _avatar_gradient_for(uid)  — deterministic per-user color (8 gradients)
  _lock_company_payload(env) — company name + tagline + logo URL block

Endpoint /fp/tablet/tiles now returns:
  {ok, company:{id,name,tagline,logo_url,has_logo,initials},
   tiles:[{user_id, name, initials, avatar_url, has_photo,
           avatar_gradient, is_clocked_in, has_pin}, ...]}

Tagline reuses res.company.report_header (the existing invoice-letterhead
field) — no new model field. Falls back to 'Shop Floor Terminal' when
empty.

10 tests pass (initials edge cases, gradient determinism, payload shape).
The 'tagline matches input string' assertion was intentionally NOT added
— see new CLAUDE.md Critical Rule 22 about Odoo 19 HTML field
auto-wrapping that makes such an equality test brittle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:52:17 -04:00
gsinghpal
7a0bd67fc0 docs(shopfloor): implementation plan for tablet lock-screen redesign
6 tasks covering the visual + interaction redesign:

  Task 1 — Backend: 3 module-level helpers in tablet_controller.py
           (_initials_from, _avatar_gradient_for, _lock_company_payload)
           + extended /fp/tablet/tiles payload + 3 test classes (TDD)
  Task 2 — Create _tablet_lock_tokens.scss design tokens (light + dark
           branches via $o-webclient-color-scheme)
  Task 3 — Full rewrite of tablet_lock.scss (gradient bg, glassmorphic
           tiles, 4 entrance keyframes, hover lift, click press,
           clocked-in pulse, prefers-reduced-motion gate)
  Task 4 — Extend tablet_lock.xml with logo + clock + prompt blocks
           wrapping the existing tile loop
  Task 5 — Extend tablet_lock.js with state.clockText / state.dateText /
           state.company + setInterval clock tick + _formatTime /
           _formatDate / tileStyle / avatarClass helpers (all per
           project rule 20 — coercion lives in JS, not in templates)
  Task 6 — Register the new tokens SCSS in manifest BEFORE
           tablet_lock.scss (per rule 8), bump version 19.0.32.0.0,
           deploy + verify

Each task has TDD-style steps with full code blocks. Self-review
confirms 1-to-1 coverage of every spec section + correct deferral of
every §12 Phase 2 item.

Plan: docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md
Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:44:24 -04:00
gsinghpal
efc420b4ce docs(shopfloor): tablet lock-screen redesign spec
Hybrid Industrial Bold + Premium Glassmorphism direction approved
during brainstorming. Adds company branding (logo from
res.company.logo with letter-mark fallback), real-time clock, tighter
3-column tile grid for ~10-15 operator small shops, dual dark/light
mode via compile-time $o-webclient-color-scheme branch, 7-animation
catalogue gated by prefers-reduced-motion.

Backend touch: extend /fp/tablet/tiles payload with company block +
per-tile initials/avatar_gradient/has_photo. Two small helper
functions in tablet_controller. No DB migration.

Frontend touch: new _tablet_lock_tokens.scss (loads first), full
rewrite of tablet_lock.scss, extend XML + JS for clock + company.

Mockup: .superpowers/brainstorm/1983-1779585812/content/lock-final.html
(in-repo since the brainstorm session used --project-dir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:38:11 -04:00
gsinghpal
fd2b2908f3 fix(shopfloor): plant-view card sizing — match the mockup proportions
User feedback after live testing: cards were too cramped on the 9-column
board. Restoring the Variant C mockup proportions and letting the board
scroll horizontally on smaller viewports (user explicitly accepted
side-scrolling).

Changes:
  - .board grid: repeat(9, 1fr) → repeat(9, minmax(300px, 1fr))
    plus overflow-x: auto. Each column ~300px so the card has room to
    breathe. ~6 columns visible on 1920px desktop, ~4 on 1366px tablet,
    smooth horizontal scroll for the rest.
  - .col-scroll: gap 4→8, max-height eased so cards aren't packed.
  - .o_fp_plant_card: padding 8/10→12, gap 4→6, base font 11→12.
  - card-wo: 13→16 (matches mockup header size).
  - card-step: 12→14.
  - chips: padding 1/6→2/8, font 10→11, radius 10→12.
  - mini-timeline blocks: 8→16px tall (current step 11→22px), labels
    8→9px. Matches the mockup's punchy timeline strip.
  - progress bar: max-width 60→100, height 3→4.
  - operator pill / icon-row: bumped to match card scale.

No backend changes. SCSS-only. Asset cache cleared (3 attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:20:01 -04:00
gsinghpal
eb1fd50add fix(shopfloor): legacy client actions redirect to fp_plant_kanban
The plant-view rollout left two legacy ir.actions.client data records
still claiming tag='fp_shopfloor_landing':
  - action_fp_plant_overview        (Plant Overview)
  - action_fp_shopfloor_tablet      (Shop Floor — Tablet Station)

The landing-action resolver dispatched the new view correctly when the
user clicked the Plating root menu, but bookmarks / breadcrumbs /
QR-scan landings / direct URLs still routed through these legacy
actions and loaded the per-step kanban (OWL component is still
registered for back-compat).

Flipping their tag to fp_plant_kanban means every entry point now
opens the new view. The legacy fp_shopfloor_landing OWL component
stays registered (no code removed) but no XMLID points at it
anymore — safe to delete in a future cleanup.

Also documented this as a durable convention in CLAUDE.md under
'Legacy-action redirect (general rule for OWL component swaps)'.

Verified on entech:
  - action 1129 (Shop Floor)     tag: fp_shopfloor_landing → fp_plant_kanban
  - action 1133 (Plant Overview) tag: fp_shopfloor_landing → fp_plant_kanban
  - 3 stale asset bundles cleared
  - Module re-upgraded clean, registry rebuilt in 15.7s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:14:33 -04:00
gsinghpal
a60506a645 feat(shopfloor): Phase 5 — flip default to v2 plant view + docs
PV-Phase5 of the plant-view redesign. Final phase — flips the default
of x_fc_shopfloor_layout from 'legacy' to 'v2' and updates CLAUDE.md
with the new architecture rule.

Verified on entech:
  - HTTP 200 on /web/login
  - Shopfloor module loads cleanly with all 19 new frontend files
  - /fp/landing/plant_kanban returns the assembled payload with 9
    columns + denormalized cards
  - Card state distribution: 22 contract_review + 8 no_parts + 1 running
    (sample data only — dev system)
  - Asset bundle re-compiled (9 stale attachments cleared)
  - ir.config_parameter['fusion_plating_shopfloor.layout'] = 'v2' set

To switch back to legacy: Settings → Fusion Plating → Shop Floor
Layout, or UPDATE ir_config_parameter SET value='legacy' WHERE
key='fusion_plating_shopfloor.layout'.

CLAUDE.md gets a new ~80-line section documenting:
  - Why the redesign (per-step kanban produced duplicate cards)
  - 9-column layout + step-kind → area mapping (spec D3, D4, D5)
  - 13-state catalog + precedence dispatch in _compute_card_state
  - Backend single-endpoint payload shape (/fp/landing/plant_kanban)
  - Frontend OWL component tree + critical implementation gotchas
    (rule 20 OWL scope, rule 8 SCSS @import, dark-mode compile-time)
  - How to switch back to legacy

Closes the 20-task plan in
  docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md

Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:59:44 -04:00
gsinghpal
8b9b4d60ad feat(shopfloor): Phase 4 — plant-view kanban frontend (OWL + SCSS + XML)
PV-Phase4 of the plant-view redesign. 19 new files implementing the
6-component OWL tree plus design tokens.

Components (each = JS + XML + SCSS triple):
  - FpMiniTimeline    — 9-step bar consuming mini_timeline_json
  - FpPlantCard       — Variant C card; 13 state-* CSS classes; tap
                        opens fp_job_workspace
  - FpColumnHeader    — column label + count badge + 'You're here'
                        badge when paired
  - FpKpiTile         — clickable KPI button with urgent/warn/good
                        variants and active state
  - FpFilterChip      — toggleable chip
  - FpPlantKanban     — top-level orchestrator: 10s polling, mode
                        toggle, search + 6 filter chips, board with
                        9 fixed columns, localStorage filter persistence

SCSS:
  - _plant_tokens.scss (loads first, exposes $plant-* vars to every
    later file — required because Odoo 19 forbids @import in custom
    SCSS, manifest order IS the concat order)
  - Dark mode via $o-webclient-color-scheme compile-time branch

Manifest registers all assets in dependency order: tokens → component
SCSS → component XML → leaf JS → top-level JS. Mirrors the existing
project pattern.

Critical patterns honored:
  - Project rule 20 (no String/Number/parseInt in OWL templates):
    all coercion in JS, string literals in foreach arrays.
  - No t-out without markup() (none in this batch — all card text is
    pre-formatted by the controller).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:57:55 -04:00
gsinghpal
a90eace4d0 feat(shopfloor): Phase 3 — plant_kanban endpoint + dispatch
PV-Phase3 of the plant-view redesign.

- /fp/landing/plant_kanban JSONRPC endpoint returns {kpis, columns,
  cards} in one payload. One card per fp.job; cards denormalized so
  the OWL component doesn't fan out RPCs. Server-side filter handling
  for All / Mine / Running / Blocked / Overdue / FAIR. Within-column
  sort by (overdue, _SORT_PRIORITY[card_state], due_date).
- fusion_plating_shopfloor.action_fp_plant_kanban client action
  registered alongside the existing fp_shopfloor_landing action.
- fp_landing_data.xml resolver extended to read the layout flag and
  dispatch to v2 when x_fc_shopfloor_layout='v2' (default still legacy).

Card payload (23 fields): WO, customer, PN+rev, qty, PO, recipe, spec,
tags, current step + work centre, state chip, mini_timeline, operator,
icons (signoff / bake / tracking / etc.), progress.

State-chip mapping per spec §6.1 — one map keyed by card_state with
running-time elapsed, idle-hours, and operator-name interpolation.

Verified live — card payload sample on WO-30036 (contract_review state)
produces all expected keys + 9-element mini_timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:51:36 -04:00
gsinghpal
7c2ae84e32 feat(jobs): Phase 2 — card_state + mini_timeline + precedence helpers
PV-Phase2 of the plant-view redesign.

Implements the 13-state classifier on fp.job:
  - card_state Char field, stored + indexed for fast filtering
  - _compute_card_state with explicit precedence dispatch matching
    spec §6.2 / §9.3 exactly (no_parts → on_hold → awaiting_signoff
    → awaiting_qc → bake_due → predecessor_locked → idle_warning →
    done → contract_review → running/_mine → ready/_mine)

Six precedence helpers, each isolated for testability:
  _fp_inbound_not_received, _fp_has_open_hold, _fp_has_pending_qc,
  _fp_bake_window_due_soon, _fp_is_mine + _fp_has_unfinished_predecessors
  on fp.job.step.

mini_timeline_json compute: 9-element array (one per column) with
state in {done, current, upcoming} and an optional 'variant' on the
current marker keyed to card_state for renderer color mapping.

Verified live:
  - 14 jobs in contract_review (no active step yet)
  - 8 in no_parts (confirmed + draft fp.receiving)
  - 1 running (WO-30051 with Pre-Measurements at Plating column)
  - mini_timeline JSON renders the full 9-area structure with the
    plating slot marked current+variant=running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:48:14 -04:00
gsinghpal
63d692b322 feat(plating): Phase 1 — plant-view kanban data model foundation
PV-T1: fp.work.centre.area_kind Selection (9 floor columns)
PV-T2: fp.job.step.area_kind compute + _STEP_KIND_TO_AREA fallback
       (covers all 30+ step kinds in the project library, plus the
       spec D4 rule that de_mask folds into de_racking)
PV-T3: fp.job.step.last_activity_at + write hook + message_post
       override + fp.job.step.move.create() hook + _fp_is_idle helper
PV-T4: res.users.paired_work_centre_ids M2M (single-station for MVP,
       forward-compatible for Phase 2 multi-station picker)
PV-T5: res.config.settings.x_fc_shopfloor_layout feature flag backed
       by ir.config_parameter for the landing-action resolver

Migrations:
  fusion_plating 19.0.21.0.0      — backfill area_kind from kind
  fusion_plating_jobs 19.0.10.24.0 — backfill last_activity_at

Deployed + verified on entech:
  - 9/9 fp.work.centre rows have area_kind set
  - 400/400 fp.job.step rows have area_kind + last_activity_at
  - paired_work_centre_ids M2M relation table created
  - All 271 modules loaded cleanly, registry rebuilt in 27s

Part of the 2026-05-23 Shop Floor plant-view kanban redesign.
Plan: docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md
Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:43:15 -04:00
gsinghpal
1a3ca8704e feat(plating): session 2026-05-23 deploys — F1/F7/S22/S23 + UI fixes
Consolidated commit of session work already deployed to entech and
verified via the deep audit + the persona walk:

S22 — Signoff gate (fp.job.step.requires_signoff was 100% unenforced,
42/42 done steps had NULL signoff_user_id). Three-piece fix:
_fp_autosign_if_required (captures finisher on button_finish),
_fp_check_signoff_complete (raises UserError if NULL after autosign),
action_signoff (explicit supervisor pre-sign). Bypass:
fp_skip_signoff_gate=True.

S23 — Transition-form gate (same dormant-field shape as S22, caught
preventively before recipe authors flipped requires_transition_form
on). Model helpers on fp.job.step.move + controller gate in
move_controller (parts commit) + pre-reject in rack commit.

F7 — Chatter standardization: _fp_create_qc_check_if_needed,
_fp_fire_notification, _fp_create_delivery silent failures now also
post to job chatter instead of only logging to file.

UI fixes:
- Critical Rule 20 documented + applied: OWL templates only expose
  Math as a global. Calling String(d) inside t-on-click throws
  'v2 is not a function'. Fixed pin_pad.xml (string array instead of
  number array with String() coercion). Also swept parseInt/
  parseFloat in recipe_tree_editor + simple_recipe_editor.
- Notes panel HTML escape fix: chatter messages off /fp/workspace/load
  were rendered via t-out, escaping the HTML. Wrap with markup() in
  job_workspace.js refresh() before assigning to state.

Versions:
  fusion_plating         19.0.20.8.0 → 19.0.20.9.0
  fusion_plating_jobs    19.0.10.20.0 → 19.0.10.23.0
  fusion_plating_shopfloor 19.0.30.2.0 → 19.0.30.5.0

All deployed to entech (LXC 111) and verified live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:37:17 -04:00
gsinghpal
d6ebcb6233 docs(shopfloor): implementation plan for plant-view kanban redesign
20 tasks across 5 phases:
  1. Data model foundation (area_kind, last_activity_at, paired
     work centres, feature flag) — 5 tasks
  2. Card state computation + mini-timeline (precedence helpers,
     card_state compute, mini_timeline_json) — 3 tasks
  3. Backend endpoint + landing dispatch — 2 tasks
  4. Frontend components bottom-up (tokens, mini-timeline, card,
     column header, KPI tile, filter chip, top-level action) —
     7 tasks
  5. QA + flip default — 3 tasks

Each task has TDD-style steps (write failing test → run → implement
→ run → commit) with full code blocks and exact file paths. Bakes
in project-specific patterns from CLAUDE.md (OWL template scope
rule 20, t-out markup wrap, no SCSS @import, dark-mode compile-
time branch).

Self-review pass confirms 1-to-1 coverage of every spec section
and explicit deferral of every §13 Phase 2 item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:34:42 -04:00
gsinghpal
48805b5988 docs(shopfloor): plant-view kanban redesign spec
Replaces per-step-grouped kanban with department-grouped (9 fixed columns).
One card per fp.job; recipe step count no longer drives layout width.

- 9 fixed columns in process sequence: Receiving / Masking / Blasting /
  Racking / Plating / Baking / De-Racking / Final inspection / Shipping
- new fp.work.centre.area_kind Selection + step_id.area_kind related
- 13 mutually-exclusive card states with explicit precedence list and
  matching _compute_card_state dispatcher
- Variant C card: WO header, customer/PN/qty/PO, recipe/spec, tag chips,
  current step + tank + state chip, 9-step mini-timeline, progress +
  operator pill + icon row
- /fp/landing/plant_kanban endpoint returns columns + denormalized cards
- MVP uses existing single-station pairing UX; M2M field structure is
  forward-compatible for cross-trained operators (Phase 2)
- Feature flag x_fc_shopfloor_layout for parallel rollout

Deferred to Phase 2: drag-drop, sibling grouping, bottleneck heatmap,
manager-specific KPIs, phone breakpoint, sort customization,
quick-action sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:22:17 -04:00
gsinghpal
005daade55 changes 2026-05-23 07:53:41 -04:00
gsinghpal
27e12dd544 chore(shopfloor): register fp_rpc.js asset + bump to 19.0.30.2.0 (P6.3.6)
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Adds the Phase 6.3 fpRpc wrapper to the web.assets_backend bundle.
Placed before its consumers so the `import { fpRpc } from "./services/fp_rpc"`
calls in job_workspace, shopfloor_landing, manager_dashboard, and
hold_composer resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:51 -04:00
gsinghpal
5f03080374 feat(shopfloor): switch action-path RPCs to fpRpc + wire plant_overview/move_card (P6.3.5)
JobWorkspace, ShopfloorLanding, ManagerDashboard, and the embedded
FpHoldComposer now call fpRpc() for write-path endpoints (start/finish
step, hold create, sign-off, milestone advance, work-centre move,
assign-worker, assign-tank, manager takeover). fpRpc auto-injects
tablet_tech_id from the tech_store so the server can rebind env via
env_for_tablet_tech() and credit the right user.

Read-path RPCs (workspace/load, landing/kanban, manager/overview,
manager/funnel, manager/approval_inbox, manager/at_risk, shopfloor/scan)
stay as plain rpc() — no audit benefit, no need for the extra plumbing.

Also wires tablet_tech_id into /fp/shopfloor/plant_overview/move_card
which I missed in P6.3.3 — surfaced when grepping JS for write callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:20 -04:00
gsinghpal
efaf16dffb feat(shopfloor): propagate tablet_tech_id to shopfloor + manager action endpoints (P6.3.3 + P6.3.4)
10 endpoints in shopfloor_controller (log_chemistry, start_bake, end_bake,
start_wo, stop_wo, bump_qty_done, bump_qty_scrapped, log_thickness_reading,
quality_hold, mark_gate) and 3 in manager_controller (assign_worker,
assign_tank, take_over) now accept a `tablet_tech_id` kwarg. Each rebinds
env via env_for_tablet_tech() so writes carry the correct uid even when
the OS session belongs to the persistent tablet user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:43:44 -04:00
gsinghpal
e4000374ca feat(fusion_plating_shopfloor): wire tablet_tech_id into workspace endpoints (P6.3.2)
hold, sign_off, advance_milestone each accept tablet_tech_id and
rebind env via env_for_tablet_tech. Writes (Hold.create, button_finish,
action_advance_next_milestone) now carry the tech-of-record's uid.
load endpoint is read-only and untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:58 -04:00
gsinghpal
fee4219703 feat(fusion_plating_shopfloor): fpRpc wrapper + env_for_tablet_tech helper (P6.3.1)
Client-side fpRpc() is a drop-in for rpc() that automatically injects
tablet_tech_id from the tech_store into every action call. Read-only
endpoints can keep using plain rpc().

Server-side env_for_tablet_tech(env, tablet_tech_id) returns an env
scoped via with_user() when the id is a valid active user; otherwise
returns the original env unchanged. Controllers call this at the top
of action methods so all subsequent writes carry the right uid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:02 -04:00
gsinghpal
6ca9a58a8c chore(fusion_plating_shopfloor): bump 19.0.30.1.0 for Phase 6.2 — lock screen
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Frontend lock screen ships:
- tech_store + activity_tracker shared OWL services
- FpPinPad, FpIdleWarning, FpPinSetup components
- FpTabletLock outer wrapper
- Wired into Landing/Workspace/Manager + Hand-Off button in each header
- fp_tablet_pin_setup client action for Preferences self-service
2026-05-23 00:33:42 -04:00
gsinghpal
d86c120969 feat(fusion_plating_shopfloor): FpPinSetup client action for self-service PIN (P6.2.6)
Registers fp_tablet_pin_setup as an ir.actions.client tag. Triggered
from res.users preferences via action_open_tablet_pin_setup (added
to res_users.py in P6.1.1). Three-stage flow:

  loading → check if user has existing PIN via search_count
  old     → enter current PIN (skipped if first-time)
  new     → choose new PIN
  confirm → enter new PIN again
  done    → success toast + auto-close 1.5s later

Each stage reuses FpPinPad with a different onSubmit + title. On
mismatch / server error, resets to the first stage with a notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:33:28 -04:00
gsinghpal
85609f99cd feat(fusion_plating_shopfloor): wire FpTabletLock + Hand-Off into Landing/Workspace/Manager (P6.2.5)
Three OWL client actions all wrap their root in <FpTabletLock>:

  ShopfloorLanding   wraps o_fp_landing
  JobWorkspace       wraps o_fp_ws
  ManagerDashboard   wraps o_fp_manager

Each adds FpTabletLock to static components, imports tech_store, and
gains a handOff() method that calls techStore.lock(). The Hand-Off
button (yellow, lock icon) lands next to the scan/QR controls in each
header — pressing it instantly returns the tablet to the tile grid
without waiting for the idle timer.

Component composition (per spec §6.5):
  FpTabletLock
    if isLocked → tile grid + FpPinPad
    else → existing client action (via <t t-slot="default"/>) + FpIdleWarning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:32:52 -04:00
gsinghpal
29821bd541 feat(fusion_plating_shopfloor): FpTabletLock outer wrapper component (P6.2.4)
Top-level wrapper that renders lock screen (tile grid + PIN pad) when
no tech is signed in, and renders <t t-slot="default"/> otherwise.
Drives the auto-lock countdown via the activity_tracker service +
sends a /fp/tablet/ping heartbeat every 60s while a tech is signed in.

Tiles fetch from /fp/tablet/tiles using the localStorage station id
(set by ShopfloorLanding on QR pair / station picker selection).

State machine for the lock screen body:
  loadingTiles → tiles list → tile tapped → PinPad → unlock RPC
                                          ↑
                                          onPinCancel → back to tiles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:29:24 -04:00
gsinghpal
1fdafd34d1 feat(fusion_plating_shopfloor): FpIdleWarning overlay (P6.2.3)
Fixed-position yellow-border overlay + countdown toast shown during
the last N (default 30) seconds before auto-lock. Pure props-driven —
secondsRemaining is the only input; parent (FpTabletLock) decides
when to mount and unmount. Box-shadow pulse animation runs CSS-only
so OWL doesn't need to re-render every tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:28:30 -04:00
gsinghpal
9584953467 feat(fusion_plating_shopfloor): FpPinPad numeric keypad component (P6.2.2)
Reusable 4-digit PIN pad. Auto-submits on the 4th digit via the
onSubmit prop. On wrong PIN, shake animation + dots clear + error
banner (caller controls the message via the returned {ok:false, error}).

Used by FpTabletLock (unlock flow) and FpPinSetup (set/change flow).

Dark-mode SCSS branch follows the same $o-webclient-color-scheme
pattern as the rest of the shopfloor components.

Also registers tech_store + activity_tracker services in the asset
bundle (assets/web.assets_backend) before the pin_pad files, since
the pin_pad/tablet_lock components consume them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:28:01 -04:00
gsinghpal
52097ca59b feat(fusion_plating_shopfloor): tech_store + activity_tracker OWL services (P6.2.1)
Two registry-level services:

tech_store    Shared reactive state holding currentTechId after a
              successful PIN unlock. Other components subscribe via
              useService("fp_shopfloor_tech_store") and read
              currentTechId to inject into action RPCs. setTech(id, name)
              on unlock; lock() on auto-lock / Hand-Off.

activity_tracker  Document-level event tracker for pointerdown / touchstart
              / keydown / visibilitychange. Mouse-move alone deliberately
              EXCLUDED — a tool resting on a tablet would otherwise keep
              the session alive indefinitely. Public API:
                bump(), getSecondsUntilLock(), getWarnThresholdSec()
              Reads thresholds from ir.config_parameter at start +
              every 5 min (so manager edits propagate within a shift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:27:13 -04:00
gsinghpal
1d6184dd2f chore(fusion_plating_shopfloor): bump 19.0.30.0.0 for Phase 6.1 — PIN backend
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Backend foundation for the tablet PIN gate:
- res.users PIN fields + hash helpers (PBKDF2-SHA256, 200k iter, salted)
- 5 endpoints: /fp/tablet/{tiles,unlock,set_pin,reset_pin_for,ping}
- Per-user lockout (5 fails → 5 min, both configurable)
- Station roster + per-station idle override
- ir.config_parameter defaults
- Preferences Set/Change PIN button + Manager Reset button

Phase 6.2 (frontend lock screen) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:17:37 -04:00
gsinghpal
88a473e7eb feat(fusion_plating_shopfloor): Preferences Set/Change PIN + Manager Reset button (P6.1.7)
Two view inheritances on res.users:

(a) Preferences form — adds a 'Tablet PIN' group with a 'Set / Change
    Tablet PIN' button that triggers action_open_tablet_pin_setup → the
    fp_tablet_pin_setup OWL client action (Phase 6.2). Shows PIN Last
    Set as read-only context.

(b) Standard res.users form — header button 'Reset Tablet PIN' visible
    only to the fusion_plating manager group; hidden when no PIN is set
    (via the set_date invisible field reference). Confirms before clearing.
    Calls the clear_tablet_pin method from the model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:17:20 -04:00
gsinghpal
08ababc2c7 feat(fusion_plating_shopfloor): station roster + idle override + tablet config defaults (P6.1.6)
Adds two fields to fusion.plating.shopfloor.station:
- x_fc_authorised_user_ids (Many2many → res.users): restricts the
  tablet lock-screen tile grid to a specific roster per station.
  Empty = all operator-group users shown.
- x_fc_idle_lock_minutes (Integer, nullable): per-station override
  for the auto-lock idle threshold; null = use system parameter.

Plus data/fp_tablet_config_data.xml registers four ir.config_parameter
defaults (noupdate=1 — manager can override via Settings → Technical
→ Parameters):
  fp.shopfloor.tablet_idle_lock_minutes = 5
  fp.shopfloor.tablet_pin_fail_threshold = 5
  fp.shopfloor.tablet_pin_fail_lockout_minutes = 5
  fp.shopfloor.tablet_warn_seconds_before_lock = 30

Form view surfaces both new fields in a dedicated 'Tablet PIN Gate'
group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:16:52 -04:00
gsinghpal
59ad77839a feat(fusion_plating_shopfloor): /fp/tablet/tiles + /fp/tablet/ping endpoints (P6.1.4-P6.1.5)
Tiles returns the lock-screen grid: operator-group users, sorted
clocked-in-first then alphabetical, with avatar URL + has_pin flag.
Honours station.x_fc_authorised_user_ids when non-empty (Phase 6.1.6
adds that field). Ping is a lightweight ack used by FpTabletLock as
a heartbeat — logs current_tech_id at DEBUG for forensic visibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:15:40 -04:00
gsinghpal
a594431eb6 feat(fusion_plating_shopfloor): /fp/tablet/unlock with per-user lockout (P6.1.3)
Verifies PIN, resets failure counter on success, increments + locks out
on 5 consecutive failures (configurable via ir.config_parameter
fp.shopfloor.tablet_pin_fail_threshold + tablet_pin_fail_lockout_minutes,
both defaulting to 5).

Returns informative payloads:
  ok=true            current_tech_id, current_tech_name
  needs_setup=true   user has no PIN yet
  locked_until       lockout in effect (rejects even correct PIN)
  attempts_remaining failed but not yet locked

Logs INFO on success, WARNING on failure (with running counter +
locked flag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:15:01 -04:00
gsinghpal
58d02598da feat(fusion_plating_shopfloor): /fp/tablet/set_pin + /fp/tablet/reset_pin_for endpoints (P6.1.2)
set_pin is self-service: requires old PIN if a hash exists, validates
4-digit format. reset_pin_for is manager-only (enforced server-side
via has_group); clears the hash + posts to chatter.

Both endpoints log INFO on success and WARNING on access-control denials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:14:18 -04:00
gsinghpal
395bd4949e feat(fusion_plating_shopfloor): res.users tablet PIN fields + hash helpers (P6.1.1)
PBKDF2-SHA256 + 16-byte salt + 200k iterations on res.users. Format
of the stored hash string is <salt_hex>$<digest_hex>. Field is
manager-readable only (groups=group_fusion_plating_manager); helpers
that need to read or write it use .sudo() internally so operator-level
callers can still set/verify their own PIN.

Adds set_tablet_pin / verify_tablet_pin / clear_tablet_pin model
methods + action_open_tablet_pin_setup that triggers the OWL setup
modal (Phase 6.2). Tests cover hash uniqueness, verify, clear with
chatter post, and the 4-digit format guard.

Tests verified on entech: -u fusion_plating_shopfloor --test-tags fp_tablet_pin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:13:33 -04:00
gsinghpal
a6546ac858 docs(fusion_plating_shopfloor): implementation plan for Phase 6 PIN gate
3-sub-phase TDD plan executing the spec at
docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md:

- Phase 6.1 (Backend): res.users PIN fields + PBKDF2-SHA256 hash
  helpers, 5 /fp/tablet/* endpoints (tiles/unlock/set_pin/reset_pin_for/
  ping), per-user lockout after 5 failures, station roster +
  idle-override fields, ir.config_parameter defaults, Preferences
  Set/Change PIN button, manager Reset PIN header button. Tests
  cover hash safety, lockout edge cases, manager-only enforcement,
  tile filtering.

- Phase 6.2 (Frontend lock screen): tech_store + activity_tracker
  OWL services, FpPinPad + FpIdleWarning + FpPinSetup components,
  FpTabletLock outer wrapper, wire into Landing/Workspace/Manager
  Dashboard with Hand-Off button injection.

- Phase 6.3 (Audit propagation): fpRpc wrapper auto-injects
  tablet_tech_id, env_for_tablet_tech server helper, all action
  endpoints (workspace + shopfloor + manager) accept the kwarg and
  rebind env via env.with_user() so writes carry the right operator.

Each sub-phase ships independently per spec §9. Plan follows the
established workflow: write tests + commit, verify on entech (local
docker doesn't have fusion_plating mounted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:05:45 -04:00
gsinghpal
233e5e6e72 docs(fusion_plating_shopfloor): Phase 6 PIN gate + auto-lock spec
Sequel to the 2026-05-22 tablet redesign (Phases 1-5). Adds a tile-grid
lock screen + 4-digit PIN per tech + 5-min auto-lock + audit propagation
so multiple techs sharing one tablet get correctly-attributed actions.

Key design choices:
- 4-digit PIN (industry norm), PBKDF2-SHA256 with 200k iterations
- Per-user lockout after 5 failures (not per-tablet)
- Single Odoo session + tablet_tech_id kwarg for audit (no JS reload on
  every tech switch)
- Manager-side reset only (no SMS/email infra)
- Server-side step timer keeps running on lock (auto-pause cron is
  the upper-bound safety net)

Three sub-phases (6.1 backend / 6.2 frontend lock / 6.3 audit kwarg
propagation), each independently deployable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:48:46 -04:00
gsinghpal
b06a5b2d12 fix(fusion_plating_jobs): delete orphan Plant Overview menu record
Phase 3 removed the menu_fp_shopfloor_plant_overview menuitem from
fp_menu.xml, but Odoo doesn't auto-delete orphan records when XML
disappears — the menu stayed in the database. Combined with P3.5's
action retarget (action_fp_plant_overview tag → fp_shopfloor_landing),
clicking it landed on the same Landing component as Workstation —
hence the duplicate menu items both opening the same screen.

Adds <delete model='ir.ui.menu' id='...'> in legacy_menu_hide.xml so
future -u runs scrub the orphan. Drops the now-defunct group_ids
block for the deleted menu. The action record stays (bookmark
back-compat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:07:34 -04:00
gsinghpal
3ef67c6beb fix(fusion_plating_shopfloor): import fields in manager_controller
The Phase 4 endpoints (/fp/manager/funnel, approval_inbox, at_risk)
all use fields.Datetime.now() but the controller only imported http
+ request. Hitting the Workflow Funnel tab on Manager Desk threw:

  NameError: name 'fields' is not defined

Funnel auto-loads on dashboard mount → infinite spinner + 'Funnel:
Odoo Server Error' notification. Same bug would have hit at_risk
and approval_inbox on first navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:01:44 -04:00
gsinghpal
4a304e02f3 fix(fusion_plating_jobs): restore quick_look_qty + instruction_attachment_ids
Same regression as the previous commit — b0070afc removed all 4 quick_look
related fields, my first fix only caught 2 of them. Restoring the remaining
2 so the quick-look view fully validates.
2026-05-22 22:47:43 -04:00
gsinghpal
0d08d2d135 fix(fusion_plating_jobs): restore quick_look_partner_id + quick_look_part_catalog_id
Commit b0070afc removed these two related fields from fp.job.step but
the view fp_job_step_quick_look_views.xml still references them. The
mismatch was dormant because entech never ran -u between b0070afc
and the 2026-05-22 deploy. Re-running -u during the Phase 1-4 deploy
caught it:

  Field "quick_look_part_catalog_id" does not exist in model
  "fp.job.step"

Restoring both as related fields (zero-cost, fixes the view without
touching XML).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:46:18 -04:00
gsinghpal
f9cb1b11ce fix(fusion_plating_jobs): drop ir.cron numbercall/doall — removed in Odoo 19
Caught during entech deploy of the Phase 2 auto-pause cron. Odoo 19
ir.cron no longer accepts numbercall or doall fields; the load fails
with:

  ValueError: Invalid field 'numbercall' in 'ir.cron'

Removed both from ir_cron_autopause_stale_steps. The other crons in
the same file (nudge stale paused / in_progress) already used the
minimal field set — matching that pattern now.

Also added a CLAUDE.md section so future-Claude doesn't reintroduce
the speculative fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:43:34 -04:00
gsinghpal
1122f84007 docs(fusion_plating): document tablet redesign architecture in CLAUDE.md (P5.1)
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Replaces the stale "Plant Overview Dashboard" section with a current
"Shop Floor Architecture" section covering the Phase 1-4 deliverables:

  - 3 OWL client actions (Landing / JobWorkspace / Manager Dashboard)
  - 5 shared OWL services
  - Backend endpoints (workspace / landing / manager)
  - Auto-pause cron config knob (ir.config_parameter name)
  - Key new model fields with their purpose
  - Operator ACL lift summary
  - Deprecated-but-still-live legacy surfaces (Phase 5 cleanup pending)
  - Old patterns to avoid

Links to the spec + plan docs as the authoritative reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:23:23 -04:00
gsinghpal
2cdb2e3d0b chore(fusion_plating): bump versions for Phase 4 — Manager Desk refactor
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
fusion_plating            19.0.20.8.0  (bottleneck_score on fp.work.centre)
  fusion_plating_shopfloor  19.0.29.0.0  (3 new endpoints + 4-tab manager dashboard
                                          + 2 new KPI tiles)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:22:18 -04:00
gsinghpal
f00dda2abd feat(fusion_plating_shopfloor): Manager Desk 4-tab refactor (P4.5-P4.10)
Plan tasks P4.5 through P4.10 batched. Existing 3-column Plant Board
becomes one tab of four; adds Workflow Funnel (default), Approval
Inbox, and At-Risk siblings. Adds 2 new KPI tiles for Pending Cert +
At-Risk.

  WORKFLOW FUNNEL (default tab)
    Calls /fp/manager/funnel. Renders one row per fp.job.workflow.state
    with stage chip + count + top 5 WO cards. Tap a card → JobWorkspace.
    Bar chart bar behind each row scales with stage count.

  APPROVAL INBOX
    Calls /fp/manager/approval_inbox. Three strips: Holds to Release,
    Certs to Issue, Scrap to Review. Per-row open + Open Workspace
    buttons. Tab badge shows total pending count.

  PLANT BOARD (existing — relocated as one tab)
    The 3-column Needs Worker / In Progress / Team layout that already
    exists, wrapped in t-if="activeTab === 'plant_board'". No behaviour
    change — still uses /fp/manager/overview with 8s refresh.

  AT-RISK
    Calls /fp/manager/at_risk. 3 sub-panels: Trending Late (sorted by
    late_risk_ratio desc), Hold Reasons (read_group), Bottleneck heatmap
    (bottleneck_score from P4.1 with red/yellow/green bars).

  KPI STRIP (new conditional tiles)
    Pending Cert  — count from inbox.certs_to_issue, click to open Inbox tab.
    At-Risk       — count from at_risk.trending_late, click to open At-Risk.

Auto-refresh: 8s for /fp/manager/overview (existing); the active tab's
data also refreshes every 8s via refreshActiveTab().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:21:53 -04:00
gsinghpal
3b7b2477cf feat(fusion_plating_shopfloor): 3 new manager endpoints — funnel, inbox, at_risk (P4.2-P4.4)
Plan tasks P4.2 + P4.3 + P4.4 batched. Adds the backend data layer
for the Manager Desk's 3 new sibling tabs (Phase 4 tablet redesign).

  POST /fp/manager/funnel
      Workflow funnel: jobs grouped by fp.job.workflow.state. Returns
      stages[] with count + top 5 WO cards per stage. Drives the
      default tab on the refactored dashboard.

  POST /fp/manager/approval_inbox
      Four buckets: holds_to_release (state=on_hold|under_review),
      certs_to_issue (all_steps_terminal + draft cert), scrap_to_review
      (last 24h mark_for_scrap holds), override_requests (deferred —
      empty placeholder).

  POST /fp/manager/at_risk
      Three panels: trending_late (top 20 by late_risk_ratio desc),
      hold_reasons (read_group on hold_reason), bottleneck (top 10
      work centres by bottleneck_score from P4.1).

All endpoints respect optional facility_id scope. Cheap implementations
— no caching yet; performance can be added if entech load demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:17:53 -04:00
gsinghpal
e762ee4b68 feat(fusion_plating): fp.work.centre bottleneck_score + avg_wait_minutes (P4.1)
Computes for the Manager At-Risk heatmap (Phase 4 tablet redesign).
Non-stored — recomputed on /fp/manager/at_risk read; that endpoint
caches its full payload for 60s so the cost is bounded.

  bottleneck_score = active_step_count * avg_wait_minutes
  avg_wait_minutes = rolling-7-day avg of (date_started - create_date)

Work centres with high score show red in the heatmap — combination
of queue length AND average wait time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:16:37 -04:00
gsinghpal
5d086c7f27 chore(fusion_plating_shopfloor): bump 19.0.28.0.0 for Phase 3 — Landing refactor
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Phase 3 ships:
- /fp/landing/kanban endpoint (P3.1)
- ShopfloorLanding OWL client action with Station/All-Plant toggle,
  KPI strip, search, kanban with DnD, QR scan, card-tap to Workspace (P3.2-P3.4)
- Menu rewire: 'Tablet Station' + 'Plant Overview' → single 'Workstation'
  entry; legacy actions retargeted to fp_shopfloor_landing for bookmark
  back-compat (P3.5)
- DEPRECATED markers on legacy /fp/shopfloor/tablet_overview, plant_overview,
  queue endpoints (P3.6 — pragmatic deviation: bodies kept intact for the
  still-registered legacy OWL components)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:12:06 -04:00
gsinghpal
3eba80bb31 docs(fusion_plating_shopfloor): deprecation markers on legacy endpoints (P3.6)
Plan task P3.6 — pragmatic deviation. The plan called for stubs that
internally route to /fp/landing/kanban + reshape; in practice the
legacy fp_shopfloor_tablet OWL component (still registered, just
unhooked from the menu) consumes a much richer payload (my_queue,
active_wo, baths, bake_windows, gates, holds, pending_qcs, stations)
than /fp/landing/kanban returns. Gutting tablet_overview to a stub
would break that legacy component.

Instead: add explicit DEPRECATED markers + INFO log lines on the three
endpoints (tablet_overview, plant_overview, queue). Bodies stay intact
so the legacy components keep working until Phase 5 cleanup retires
both endpoints AND the legacy OWL components together.

Note: /fp/shopfloor/plant_overview/move_card is NOT deprecated — the
new Landing component still uses it for drag-and-drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:11:49 -04:00
gsinghpal
2a0d1862df feat(fusion_plating_shopfloor): rewire menus to Shop Floor Landing (P3.5)
Plan task P3.5. Single 'Workstation' menu item replaces both the
legacy 'Tablet Station' and 'Plant Overview' entries. The new
fp_shopfloor_landing component has a Station/All-Plant toggle so
one menu covers both old surfaces.

Old action records redirected for back-compat (so existing bookmarks
+ smart-button references keep working):

  action_fp_shopfloor_tablet  tag → fp_shopfloor_landing
  action_fp_plant_overview    tag → fp_shopfloor_landing
                              params → {'mode': 'all_plant'}

The legacy OWL components (fp_shopfloor_tablet, fp_plant_overview)
remain registered — no code removed, just no menu points at them.
Phase 5 cleanup will remove the OWL components after a release of
soak time on entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:10:36 -04:00
gsinghpal
7f70785b79 feat(fusion_plating_shopfloor): ShopfloorLanding client action (P3.2-P3.4)
Plan tasks P3.2 + P3.3 + P3.4 batched. Full ShopfloorLanding OWL
client action — replaces fp_shopfloor_tablet AND folds in
fp_plant_overview.

  Header strip          Title, station chip, station picker dropdown,
                        Station/All-Plant mode toggle, QR scan controls,
                        last-refresh indicator.
  KPI strip             4 tech-relevant tiles: Ready · Running ·
                        Bakes Due (warning) · Holds (red when > 0).
  Search                Live debounced (200ms) across WO# + customer +
                        part. ESC clears.
  Kanban board          Columns = work centres from /fp/landing/kanban.
                        Cards = FpKanbanCard (Phase 1 — P1.7).
                        Drag-and-drop reuses existing
                        /fp/shopfloor/plant_overview/move_card.
  Card tap              doAction → fp_job_workspace with
                        {job_id, focus_step_id}.
  QR scan               FP-STATION pairs, FP-JOB / FP-STEP jump to the
                        Workspace.

Mode + station_id persist in localStorage (LS_STATION_ID, LS_MODE).
Auto-refresh every 15s; suppressed during a drop and for 5s after.

Registers client action `fp_shopfloor_landing`. Menu rewire + endpoint
stubs land in P3.5 + P3.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:09:09 -04:00
gsinghpal
9dcd00d9b2 feat(fusion_plating_shopfloor): /fp/landing/kanban endpoint
Plan task P3.1. New JSON-RPC endpoint for the Shop Floor Landing
client action (Phase 3). Two modes:

  station    — paired WC + Unassigned + next 1-2 WCs in recipe flow
  all_plant  — every active WC, recipe-flow order (replaces the data
               path for the standalone fp_plant_overview action)

Returns {columns: [{work_center_id, work_center_name, cards}], kpis:
{ready, running, bakes_due, holds}, stations: [...], facility_name,
server_time}. Card payload matches the KanbanCard OWL component
(P1.7) — same shape, no client-side adapter needed.

Light implementation — no urgency scoring or batch prefetch yet.
Both can be ported from plant_overview if performance demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:06:40 -04:00
gsinghpal
5a28c7e90f chore(fusion_plating): bump versions for Phase 2 — cron + ACL + supporting computes
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
fusion_plating              19.0.20.7.0  (long_running on process node)
  fusion_plating_jobs         19.0.10.20.0 (late_risk_ratio, active_step_id, autopause cron)
  fusion_plating_shopfloor    19.0.27.1.0  (no code change; data-version bump for Phase 2)
  fusion_plating_certificates 19.0.7.9.0   (ACL lift — bumped in P2.6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:26 -04:00
gsinghpal
3c2efae951 feat(fusion_plating): lift operator ACL for cert write + thickness create + override read
Plan task P2.6. Per the spec's "techs wear multiple hats" rule, lift
gates so technicians can do their work without permission walls:

  fp.certificate         operator: read → read+write
                         (flip draft→issued from tablet)
  fp.thickness.reading   operator: read → read+write+create
                         (capture Fischerscope readings from tablet)
  fp.job.node.override   operator: NEW read-only
                         (see opt-out badges on steps)

Supervisor-only operations (step Skip, hold Release, override
Re-include) remain enforced in workspace_controller, not ACL — so the
ACL stays minimal and the controller centralizes the gate logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:05 -04:00
gsinghpal
c06d3d442a feat(fusion_plating_jobs): auto-pause cron for stale in-progress steps
Plan tasks P2.4 + P2.5 batched.

Adds _cron_autopause_stale_steps method on fp.job.step + 30-min cron
registration. Flips in_progress steps idle > threshold to paused with
a chatter audit ("Auto-paused after Nh idle. Resume from the tablet
when work continues.").

Threshold from ir.config_parameter:
    fp.shopfloor.autopause_threshold_hours  (default 8.0)

Recipe nodes opt out via fusion.plating.process.node.long_running
(added in P2.1) — useful for 24h bakes and multi-shift soaks.

Fixes the 411-hour ghost timer that motivated the redesign. Doesn't
replace the existing nudge crons — those still notify the supervisor;
this one actually pauses the timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:03:20 -04:00
gsinghpal
c76eb94724 feat(fusion_plating_jobs): late_risk_ratio + active_step_id computes on fp.job
Plan tasks P2.2 + P2.3 batched (both small additive computes on fp.job;
local tests not run between them — entech verifies).

  late_risk_ratio  — stored Float, remaining_planned / minutes_to_deadline.
                     Drives the Manager At-Risk view (Phase 4).
                     Recomputes on step state, duration, deadline changes.

  active_step_id   — non-stored Many2one. Currently in_progress step
                     (lowest sequence if multiple — defensive).
                     Drives JobWorkspace landing focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:58 -04:00
gsinghpal
06dc6a62b9 feat(fusion_plating): long_running flag on process node (auto-pause opt-out)
Plan task P2.1. Boolean on fusion.plating.process.node that exempts
steps generated from this node from the shop-floor auto-pause cron
(added in P2.4/P2.5). Use for 24h bakes, multi-shift soaks, and
similar long-but-legitimate operations.

Toggle visible on the process-node form for operation/step types,
grouped with parallel_start in the Behaviour section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:13 -04:00
366 changed files with 49266 additions and 1824 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -16,6 +16,8 @@
7. **Search views**: NO `group expand="0"` syntax.
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
## Card Styling — Copy Odoo's Kanban Pattern
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
```css
@@ -79,8 +81,15 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
## Workflow
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
- Local URL: http://localhost:8069
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
- Local URL: http://localhost:8082
- **Running module tests requires ephemeral ports.** The dev container's main Odoo process holds 8069 and 8072; a `docker exec ... odoo --test-enable` will die with `Address already in use` unless you also pass `--http-port=0 --gevent-port=0`. This is because Odoo 19 forces `http_spawn()` when `--test-enable` is set, even when `--no-http` is passed. Canonical test invocation:
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /<module> \
-u <module> --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
- **`fusion_centralize_billing` tests run on odoo-trial (VM 316).** Local dev is Community and cannot install this module. Use `bash scripts/fcb_test_on_trial.sh` from the repo root. The script uses `--http-port 8070` to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = `FCB_EXIT=0`. Takes ~1-2 min.
- **Python deps not bundled with `odoo:19` image:** `user_agents` (used by `fusion_login_audit`), and likely others. Install ephemerally with `docker exec -u 0 odoo-modsdev-app pip install <pkg> --break-system-packages`. The install is LOST when the container is recreated (e.g. `docker compose up -d` after a compose edit). When this happens, the symptom is `ModuleNotFoundError` deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet.
- Test before deploying. Edit existing files — don't create unnecessary new ones.
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab

View File

@@ -0,0 +1,75 @@
# fusion_centralize_billing — Session Handoff (2026-05-27)
Resume point for the centralized-billing initiative. Read this first, then continue
from **"Decision pending"** below.
## Where we are
- **Sub-project #1 (core billing engine): DONE and on `main`** (tip `d770c0c3`, pushed to
GitHub + Gitea).
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
(cross-billing cron → match by `plan_id`; `/usage` authz vs IDOR; input validation →
4xx not 500; correct billing-period window; idempotency scoped to `(sub, metric, key)`;
webhook sign-exact-bytes + event-id + SSRF guard).
- **39 tests green on Odoo 19 Enterprise.**
- Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and
landed cleanly on `main`. `fusion_login_audit` was deliberately **not** landed — it
stays on `feat/fusion-login-audit`. A concurrent `feat/helpdesk-customer-followup`
session still carries a pre-landing copy of the billing commits; when it merges, replay
its helpdesk-only commits onto `main`.
- **Reference docs (on `main`):**
- Spec: `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`
- Core plan: `docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md`
## Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation
Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into
four chunks (dependency order):
| Chunk | What | Risk |
|-------|------|------|
| **2a — Mapping + importer** | Read `nexacloud` DB → create `res.partner` + `account.link`, `product.template` + subscription plans, one subscription `sale.order` per deployment | **Low** — read-only on NexaCloud, writes only into Odoo |
| **2b — Usage metering wiring** | NexaCloud `usage_metering.py` pushes CPU-seconds → Odoo `/usage`; verify aggregation → draft invoice w/ quota + overage + HST | Edits NexaCloud code |
| **2c — Control loop** | NexaCloud consumes Odoo's outbound webhooks (`invoice.payment_failed` → suspend via existing `network_isolation`/`throttle_checker`; `subscription.terminated` → deprovision) | Edits NexaCloud code |
| **2d — Dual-run reconciliation** | `fusion.billing.reconciliation` diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip | Safety gate before flipping real billing |
The core engine already built the *receiving* side (`/usage`, webhook engine, charge math).
#2 is about **connecting NexaCloud to it and proving the numbers match before flipping.**
## Decision pending (resume here)
We were in the `superpowers:brainstorming` flow for #2 and stopped at: **which slice to
start with?**
- **(recommended) 2a — Mapping + importer** — lowest risk, foundation for everything else.
- 2d — Reconciliation first (front-load the trust mechanism).
- Full #2 design as one spec, then one plan.
- Just write the #2 plan, no code this session.
## Open questions to resolve before building #2
- **Spec §15 Q2 — NexaCloud billing granularity:** confirm **one subscription per
deployment** (spec leans this way) vs one subscription per customer with deployment line
items.
- **Access / environments needed:**
- Read access to the `nexacloud` DB schema (LXC 102 / its Postgres on LXC 201) to design
the importer mapping.
- A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code).
- Test target for the Odoo side stays the odoo-trial Enterprise sandbox.
- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no
account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work
off `main`.
## How to run / test
- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0`
(~12 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
`trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this
module.
## Branch hygiene (lesson from this session)
Cut each new feature branch from `main`, and land it before starting the next. For any
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
working dir's branch, because a concurrent session may be working on it.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
# fusion_centralize_billing — Centralized Billing Engine on Odoo 19
- **Date:** 2026-05-27
- **Status:** Design approved — pending written-spec review
- **Author:** Design session (Claude + Gurpreet)
- **Module:** `fusion_centralize_billing` (target: `K:\Github\Odoo-Modules\fusion_centralize_billing`)
- **Host:** odoo-nexa (Proxmox VM 315, worker1), Odoo 19 **Enterprise**, live DB `nexamain`
## 1. Goal
Make the Odoo Enterprise instance (`odoo-nexa`) the single billing brain for every
NexaSystems service — hosting (NexaCloud), live chat (NexaDesk/Fusion-Chat), the
metered maps API (NexaMaps), plus custom-app retainers, memberships, and one-off
services. It replaces Lago in the role Lago currently plays, and absorbs NexaCloud's
home-grown Stripe billing, so there is one customer ledger, one accounting system,
one place revenue is recognized.
## 2. Current state (recon, 2026-05-27)
Billing is fragmented across **three+ independent engines**:
| System | Bills for | Engine today | Data home |
|---|---|---|---|
| **NexaCloud** (LXC 102, `10.200.0.250`) | VPS/LXC hosting, Coolify apps, CPU-seconds + throttle-removal fees, snapshots, domains | Own Postgres models + **direct Stripe** (`stripe_service.py`, `billing_service.py`, `usage_metering.py`, `invoice_generator.py`) | `nexacloud` DB (LXC 201) |
| **NexaDesk / Fusion-Chat** (VM 314) | Chat plans (monthly/annual), feature + channel add-ons, message/token overage, token wallets | **Lago** v1.44.0 (VM 318) + Stripe (provider code `nexadesk`) | Lago (VM 318, `192.168.1.117`) |
| **NexaMaps** (`fusionapps.maps_*`) | Metered geocoding/routing API: monthly quota + overage per 1k | Own tables; **~189k usage events / month** for 2 clients | Supabase `fusionapps` |
| Services / memberships | Custom apps, consulting, retainers | ad-hoc / manual | — |
**Decisive fact:** `odoo-nexa` is **Odoo 19 Enterprise** and already runs the full
Lago-equivalent stack: `sale_subscription` (+ `_stock`, `_timesheet`,
`_external_tax`), `account_accountant`, `payment_stripe`, `website_sale` +
`website_sale_subscription`, `crm/project/industry_fsm_sale_subscription`, plus
custom `nexa_coa_setup`, `fusion_whitelabels`, `fusion_helpdesk_central`,
`fusion_pdf_preview`. So Odoo already does subscriptions, recurring invoicing, full
accounting/GL, Stripe, HST taxes, customer portal, credit notes, and self-serve
checkout.
**The only capability Lago has that Odoo lacks natively is usage-based metered
billing** (billable metrics → aggregation → quota/overage charges). That, plus the
integration surface, is all we build.
Prior decision on record (Supabase `fusionapps.decisions`): Lago was deployed as the
centralizer for NexaDesk + NexaCloud. This design **supersedes** that — the billing
brain moves into the Odoo Enterprise already owned and operated.
## 3. Decisions locked in this session
1. **Odoo fully replaces Lago.** Build a metered-billing engine inside `fusion_centralize_billing`; decommission Lago VM 318 at the end.
2. **One unified customer, separate invoice per service.** One `res.partner` per real client; each service bills on its own subscription/cycle. No cross-product invoice merging.
3. **Apps drive; Odoo is the billing system of record.** Each app keeps its own signup, provisioning, and entitlement enforcement, and calls Odoo's billing API (the same way it calls Lago today). Odoo invoices, charges Stripe, and emits webhooks back.
4. **Odoo owns the billing catalog; apps own entitlements.** Odoo is SoR for products, prices, recurrence, metric rate/quota/overage, taxes — keyed by a stable `plan_code`. Apps enforce feature limits (max_chatbots, CPU quota, API rate-limit) against the same code.
5. **Pilot = NexaCloud, phased dual-run cutover** (one product at a time, parallel run + reconciliation before flip).
6. **Aggregate-push usage ingestion.** Apps push periodic pre-aggregated counters; Odoo stores rollups and feeds native `sale.subscription` metered lines. No raw-event firehose into Odoo.
## 4. Architecture
```
NexaCloud NexaDesk NexaMaps (apps keep signup + provisioning + entitlements)
│ │ │
│ customers / subscriptions / usage counters (inbound REST, API-key bearer auth)
▼ ▼ ▼
┌──────────────────────────────────────────────┐
│ fusion_centralize_billing (custom Odoo 19 module) │
│ • Service registry (one row per app) │
│ • Identity links (ext acct → res.partner) │
│ • Metric + Charge catalog (quota/overage) │
│ • Usage engine (ingest → aggregate → bill) │
│ • Outbound webhook queue (HMAC + retry) │
└───────────────┬────────────────────────────────┘
│ writes billable qty onto
sale.order(is_subscription) → account.move → payment_stripe (NATIVE Odoo Enterprise)
│ invoicing, HST tax, proration,
│ invoice paid / failed / sub ended dunning, portal, credit notes
outbound webhooks ──► apps suspend / restore / deprovision
```
Principle: **build only the metering + integration layer; inherit all financial
behaviour from native Odoo Enterprise.**
## 5. Data model
### 5.1 New models (`fusion.billing.*`)
| Model | Key fields | Purpose |
|---|---|---|
| `fusion.billing.service` | `name`, `code` (nexacloud/nexadesk/nexamaps), `api_key_hash`, `webhook_url`, `webhook_secret`, `active` | One row per source app — the auth + routing boundary. |
| `fusion.billing.account.link` | `service_id`, `external_id`, `partner_id`, `external_email`; unique `(service_id, external_id)` | Identity resolution: folds each app's account into one `res.partner`. |
| `fusion.billing.metric` | `code`, `name`, `aggregation` (sum/max/last/unique_count), `unit_label`, `rounding` | Billable metric definition. |
| `fusion.billing.charge` | `plan_ref`/`product_id`, `metric_id`, `included_quota`, `price_per_unit`, `unit_batch` (e.g. per 1000), `charge_model` (standard/graduated/package/volume) | Maps a plan + metric → quota & overage pricing. Where "5M quota / $0.10 per 1k" lives. |
| `fusion.billing.usage` | `subscription_id`, `metric_id`, `period_start`, `period_end`, `quantity`, `source`, `idempotency_key`; index `(subscription, metric, period)` | **Aggregated** usage rows (rollups, not raw events). |
| `fusion.billing.webhook` | `service_id`, `event_type`, `payload` (JSON), `state` (pending/sent/failed/dead), `attempts`, `next_retry_at`, `signature` | Outbound event queue, processed by cron with backoff + HMAC. |
| `fusion.billing.reconciliation` | `service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` | Dual-run shadow-mode comparison (Odoo-computed vs app-actual). |
### 5.2 Native models reused as-is
`res.partner` (customer), **`sale.order` with `is_subscription=True`** (the subscription),
`sale.subscription.plan` (recurrence/plan), `sale.order.line` (metered lines),
`account.move` (invoice + credit note), `payment_stripe`/`payment.transaction` (Stripe),
`account.tax` (HST per province), customer portal. Catalog = `product.template` +
`sale.subscription.plan`, tagged with the shared `plan_code`.
New fields on native models use the `x_fc_*` prefix (e.g. `res.partner.x_fc_billing_external_ids`).
> **Odoo 19 modeling note (verified on live `nexamain`, 2026-05-27):** there is **no
> `sale.subscription` model**. A subscription IS a `sale.order` with `is_subscription=True`,
> `plan_id` → `sale.subscription.plan`, plus `subscription_state` / `next_invoice_date` /
> `recurring_monthly`. Every "subscription" reference in this spec means that. The usage
> engine links `fusion.billing.usage.subscription_id` → `sale.order`.
### 5.3 Relationship to `fusion_api` (reuse, don't duplicate)
The existing **`fusion_api`** module (`fusion.api.key` / `.consumer` / `.service` /
`.usage` / `.usage.daily`) centralizes **outbound** provider keys (OpenAI, Anthropic,
Google Maps, Twilio) with cost/usage tracking + rate limiting — i.e. what **Nexa pays
providers** (COGS). It is **complementary**, not a substitute:
`fusion_centralize_billing` tracks what **customers owe Nexa**. Two concrete ties:
(a) feed `fusion.api.usage.daily` cost into margin reporting against billed revenue;
(b) mirror its daily-rollup aggregation pattern for `fusion.billing.usage`. The
customer-facing metered billing and the inbound API remain ours to build.
## 6. Usage engine (aggregate-push)
1. Apps `POST /usage` with periodic counters and an `idempotency_key`
(e.g. `service:metric:subscription:window`). NexaCloud pushes CPU-seconds per
deployment hourly; NexaMaps pushes api_calls per client daily; NexaDesk pushes
messages/tokens. Upsert into `fusion.billing.usage` keyed by `idempotency_key` so
retries never double-bill.
2. A **pre-invoice cron** (runs ahead of each subscription's invoice date) sums the
period's `fusion.billing.usage` per metric, applies the matching
`fusion.billing.charge` (quota → free, overage → priced by `charge_model`), and
writes the billable quantity/amount onto the subscription's draft invoice line
(usage product).
3. Native subscription invoicing issues the invoice, applies HST, and charges Stripe.
Quota resets per period.
At ~189k Maps events/month pushed as daily counters, Odoo stores ≈30 rows per client
per metric per month — trivial volume.
## 7. Inbound API (Lago-shaped, drop-in)
Base path `/api/billing/v1/*`. Odoo 19 routing: `type="http"`, `auth="none"`,
`csrf=False`, manual **Bearer** API-key check against `fusion.billing.service`
(hashed), JSON request/response via `request.make_json_response`, per-service rate
limiting. (`type="jsonrpc"` is for Odoo session RPC — not used here, because external
apps authenticate with bearer tokens, not Odoo sessions.)
Endpoints intentionally mirror `Fusion-Chat/src/lib/billing/lago-client.ts` so the
NexaDesk swap is ≈ one file, and NexaCloud's integration is a thin client:
| Method · Path | Maps to |
|---|---|
| `POST /customers` | upsert `res.partner` + `account.link` (identity resolution) |
| `POST /subscriptions` · `PUT /subscriptions/:id` · `DELETE /subscriptions/:id` | create / change-upgrade / cancel subscription `sale.order` |
| `POST /usage` | batch aggregated counters (hot path → 202 Accepted) |
| `POST /invoices` | one-off invoice (token packs, throttle-removal fee) |
| `GET /invoices` · `GET /invoices/:id` · `POST /invoices/:id/download` | list / fetch / PDF |
| `POST /invoices/:id/retry_payment` · `POST /invoices/:id/void` | payment retry / void |
| `POST /credit_notes` | refund via `account.move` reversal |
| `GET /plans` · `GET /catalog` | apps fetch pricing (as NexaDesk fetches from Lago) |
| `GET /customers/:id/checkout_url` | Stripe payment-method setup |
## 8. Outbound webhooks (control loop)
Odoo → app, HMAC-SHA256 signed, retried with exponential backoff, dead-lettered after
N attempts (reuse the proven pattern in `Fusion-Chat/src/lib/billing/lago-payment-retry-job.ts`):
| Event | App reaction |
|---|---|
| `invoice.payment_failed` (after dunning) | **suspend** — NexaCloud throttle/network-isolate; NexaDesk suspend tenant; NexaMaps disable API key |
| `invoice.payment_succeeded` / `subscription.reactivated` | **restore** service |
| `subscription.terminated` | **deprovision** |
| `usage.threshold_reached` (80% / 100%, optional) | warn / cap |
## 9. NexaCloud pilot
- **Identity & catalog mapping:** `nexacloud.users``res.partner` via `account.link`;
`nexacloud.products`/`plans``product.template` + subscription plans
(`plan_code` = NexaCloud plan id/slug, prices from `price_monthly`/`price_yearly`);
`nexacloud.deployments` + `subscriptions` → one subscription `sale.order` per deployment
(NexaCloud bills per deployment).
- **Metering:** CPU-seconds → `fusion.billing.metric` `cpu_seconds` (sum) + `charge`
(included = plan quota, overage priced). Throttle-removal fee → one-off invoice
(or add-on product). `nexacloud/.../usage_metering.py` pushes counters to `/usage`.
- **Control loop:** `invoice.payment_failed` → NexaCloud suspends using its existing
`network_isolation` / `throttle_checker` / `resource_manager`; `subscription.terminated`
→ NexaCloud deprovisions.
## 10. Dual-run + migration (phased)
1. **Import** NexaCloud customers + active subscriptions into Odoo (script reads the
`nexacloud` DB → creates partners / links / subscriptions / charges).
2. **Shadow mode ≥ 1 billing cycle:** Odoo computes invoices while NexaCloud keeps
charging via its own Stripe. `fusion.billing.reconciliation` diffs Odoo-computed vs
NexaCloud-actual per customer/period; investigate every delta.
3. **Flip** when deltas are within tolerance: NexaCloud calls Odoo's API as SoR and
stops its internal Stripe billing. Past invoices stay archived (PDF / opening
balances) — not re-issued.
4. **Repeat** for NexaDesk (retire Lago for chat) → NexaMaps → then decommission
Lago VM 318.
## 11. Risks & open items
- **🟢 Stripe account unification — RESOLVED (2026-05-27).** All systems share ONE Stripe
account: **`acct_1ShlA9IkwUB1dVox`** (Nexa Systems Inc, CA, live). Verified live:
NexaCloud's direct `sk_live` key resolves to that account, and Lago has three Stripe
providers (`nexasystems`, `nexadesk`, `nexamaps`) that **all** resolve to the same
account. Therefore **no Stripe account migration is needed** — Odoo's `payment_stripe`
connects to that single account and **reuses existing Stripe customers + saved payment
methods** (map each Stripe `provider_customer_id``res.partner`). This removes what
was the biggest migration risk.
- **Idempotency** on usage counters is mandatory (dedupe key) to prevent double billing on retries.
- **Entitlement sync SLA:** on plan change, Odoo webhook informs the app; define how
fast app-side limits must update (and the reconciliation if a webhook is missed).
- **Odoo 19 correctness:** implementation MUST read live reference files from the
container (`docker exec odoo-nexa-app cat …`) before coding subscription/API/account
internals — never from memory (per `K:\Github\CLAUDE.md`).
- **Tax:** HST/GST per Canadian province via `account.tax`; confirm tax codes align
with current Lago `hst_on` usage.
- **Auth hardening:** API keys hashed at rest, per-service scoping, rate limiting,
request audit log; webhook secrets rotated.
## 12. Phasing — spec sequence
Each is its own spec → plan → build cycle:
1. **`fusion_centralize_billing` core** — service registry, identity links, metric/charge catalog,
usage engine, inbound API, outbound webhook engine. *(detailed below — first deliverable)*
2. **NexaCloud adapter + dual-run reconciliation** *(the pilot — coupled to #1)*
3. NexaDesk adapter (swap the Lago client for the Odoo billing client)
4. NexaMaps adapter
5. Lago decommission + memberships/services onboarding + portal polish
## 13. First-deliverable scope (sub-projects #1 + #2)
**In scope**
- `fusion_centralize_billing` module skeleton (manifest, security/ACLs + record rules, README) following the `nexa_coa_setup` layout.
- Models in §5.1; new native fields use `x_fc_*`.
- Aggregate-push usage engine (§6) incl. pre-invoice cron + idempotent upsert.
- Inbound API (§7) with bearer auth, and outbound webhook engine (§8).
- NexaCloud mapping + importer + shadow-mode reconciliation (§9, §10).
- Manifest `depends`: `sale_subscription`, `account_accountant`, `payment_stripe`,
`sale_management` (+ `nexa_coa_setup` if COA dependencies apply).
**Out of scope (YAGNI for now)**
- NexaDesk / NexaMaps adapters (specs #3/#4).
- Raw-event ingestion / per-event audit in Odoo (apps retain raw events).
- Lago decommission (spec #5) — Lago stays running until NexaDesk is migrated.
- Customer-portal redesign — use native portal as-is initially.
## 14. Success criteria (first deliverable)
- A NexaCloud deployment can be created as an Odoo subscription `sale.order` via the API,
with one `res.partner` resolving the NexaCloud user.
- CPU-seconds counters pushed to `/usage` aggregate correctly and produce a draft
invoice with quota + overage applied, taxed (HST), and charged through `payment_stripe`.
- A simulated `invoice.payment_failed` delivers a signed webhook NexaCloud can act on.
- Shadow-mode reconciliation report shows Odoo-computed vs NexaCloud-actual within
tolerance for ≥ 1 cycle before any flip.
- No double billing under usage-counter retries (idempotency verified).
## 15. Open questions for review
1. ~~Stripe: one account across all products, or separate?~~ **ANSWERED (2026-05-27):** one
account `acct_1ShlA9IkwUB1dVox` for everything (NexaCloud direct + Lago's
`nexasystems`/`nexadesk`/`nexamaps` providers). No account migration; reuse existing
Stripe customers + payment methods.
2. NexaCloud billing granularity — confirm **one subscription per deployment** (vs one per customer with deployment line items).
3. Membership model — Odoo native `membership` module, or model memberships as plain recurring subscriptions?
4. Spec/module commit target — confirm branch strategy in `Odoo-Modules` (currently on `feat/fusion-login-audit`).

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
]
}
}

View File

@@ -0,0 +1,70 @@
# Fusion Centralized Billing (`fusion_centralize_billing`)
Centralized billing engine that makes this Odoo 19 **Enterprise** instance the single
billing brain for every NexaSystems service — **NexaCloud** hosting, **NexaDesk** chat,
**NexaMaps** API, custom apps, and memberships. It replaces Lago and absorbs NexaCloud's
home-grown Stripe billing into one customer ledger and one accounting system.
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
>
> **Status:** **SCAFFOLD.** Models + security + the API auth shell are in place and the
> module installs. The usage engine, full inbound API, and webhook processor are stubs
> to be implemented from the writing-plans output.
## Why this module is small
We build **only** the metering + integration layer. Everything financial — recurring
invoicing, HST tax, proration, dunning, customer portal, credit notes, Stripe — is
**native Odoo Enterprise** (`sale_subscription`, `account_accountant`, `payment_stripe`),
already installed and running.
## Design decisions (locked)
1. Odoo fully replaces Lago (we build the metered-billing engine; Lago is decommissioned last).
2. One unified `res.partner` per client; **separate invoice per service**.
3. **Apps drive**, Odoo is the billing system of record — apps call the inbound API (as they call Lago today); Odoo bills and webhooks back.
4. Odoo owns the **billing catalog**; apps own **feature entitlements** (shared `plan_code`).
5. Pilot = **NexaCloud**, phased dual-run cutover.
6. **Aggregate-push** usage ingestion (periodic counters, not a raw-event firehose).
## Models (`fusion.billing.*`)
| Model | Purpose |
|---|---|
| `fusion.billing.service` | One source app; bearer API key (hashed) + webhook config. |
| `fusion.billing.account.link` | External account id → one `res.partner` (identity resolution). |
| `fusion.billing.metric` | Billable metric + aggregation (sum/max/last/unique). |
| `fusion.billing.charge` | Plan + metric → included quota + overage pricing. |
| `fusion.billing.usage` | Aggregated per-period usage rollups (idempotent). |
| `fusion.billing.webhook` | Outbound lifecycle event queue (HMAC + retry). |
| `fusion.billing.reconciliation` | Dual-run Odoo-vs-app delta during cutover. |
> **Odoo 19 note (verified):** a subscription is a `sale.order` with `is_subscription=True`
> (`plan_id` → `sale.subscription.plan`). There is **no** `sale.subscription` model.
> `fusion.billing.usage.subscription_id` therefore points at `sale.order`.
## Inbound API
Lago-shaped REST under `/api/billing/v1/*`, bearer auth. Endpoints mirror NexaDesk's
existing `lago-client.ts` so migration is a thin client swap. `/health` works today;
the rest return `501` until implemented.
## Relationship to `fusion_api`
`fusion_api` manages **outbound** provider keys (OpenAI, Maps, Twilio) + cost tracking —
i.e. COGS. This module tracks **customer** revenue. Complementary: feed `fusion_api`
cost into margin reporting; reuse its daily-rollup aggregation pattern.
## Dependencies
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
## Local dev
```bash
docker exec odoo-nexa-app odoo -d nexamain -u fusion_centralize_billing --stop-after-init
# tests (once added):
docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init
```
Canadian English, CAD, HST via `account.tax`. New fields on native models use the `x_fc_*` prefix.

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
"name": "Fusion Centralized Billing",
"version": "19.0.1.0.0",
"category": "Accounting/Subscriptions",
"summary": "Centralized billing engine for all NexaSystems services — metered usage, "
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",
"description": """
Fusion Centralized Billing
==========================
Makes this Odoo Enterprise instance the single billing brain for every NexaSystems
service (NexaCloud hosting, NexaDesk chat, NexaMaps API, custom apps, memberships).
It adds ONLY the metering + integration layer; all financial behaviour (invoicing,
HST tax, proration, dunning, portal, credit notes, Stripe) is native Odoo Enterprise.
Capabilities
------------
* Service registry — one record per source app (NexaCloud / NexaDesk / NexaMaps) with
bearer API key + webhook config.
* Identity links — fold each app's external account into one ``res.partner``.
* Metric + Charge catalog — billable metrics with quota + overage pricing, keyed by a
shared ``plan_code`` (apps own feature entitlements; Odoo owns money).
* Usage engine — aggregate-push: apps send periodic counters; a pre-invoice cron feeds
billable quantities onto the subscription ``sale.order``.
* Inbound API — Lago-shaped REST (``/api/billing/v1/*``), bearer auth.
* Outbound webhooks — HMAC-signed lifecycle events (payment failed/succeeded,
subscription terminated) so apps suspend / restore / deprovision.
Design spec: docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md
Status: SCAFFOLD. Model fields are in place; engine/API/webhook bodies are stubs to be
implemented via the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
reference files from the container before implementing subscription/account internals.
""",
"author": "Nexa Systems Inc.",
"website": "https://nexasystems.ca",
"license": "OPL-1",
"depends": [
"account_accountant",
"sale_subscription",
"sale_management",
"payment_stripe",
],
"data": [
"security/ir.model.access.csv",
"data/ir_cron.xml",
],
"installable": True,
"application": False,
"auto_install": False,
}

View File

@@ -0,0 +1 @@
from . import api

View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Inbound, Lago-shaped billing API (spec §7).
Auth: bearer API key matched (by SHA-256 hash) against ``fusion.billing.service``.
Routing: ``type="http"`` + ``auth="none"`` + ``csrf=False`` — external apps present
bearer tokens, not Odoo sessions (so NOT ``type="jsonrpc"``).
STATUS: SCAFFOLD. Only auth + /health are wired. Endpoint bodies are stubs (HTTP 501)
to be implemented from the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
references (sale.order subscription flow, account.move, payment_stripe) before
implementing — do NOT code those internals from memory.
"""
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
API_BASE = "/api/billing/v1"
class FusionBillingApi(http.Controller):
# ── helpers ──────────────────────────────────────────────────────────
def _authenticate(self):
"""Return the active fusion.billing.service for the bearer key, else None."""
auth = request.httprequest.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None
def _json(self, payload, status=200):
return request.make_json_response(payload, status=status)
def _read_json(self):
try:
raw = request.httprequest.get_data(as_text=True) or "{}"
return json.loads(raw)
except Exception:
return None
# ── routes ───────────────────────────────────────────────────────────
@http.route(f"{API_BASE}/health", type="http", auth="none", methods=["GET"], csrf=False)
def health(self, **kw):
return self._json({"status": "ok", "service": "fusion_centralize_billing"})
@http.route(f"{API_BASE}/customers", type="http", auth="none", methods=["POST"], csrf=False)
def post_customer(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
payload = self._read_json()
if payload is None:
return self._json({"error": "invalid json"}, status=400)
result = service._api_upsert_customer(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result)
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
def post_usage(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
payload = self._read_json()
if payload is None:
return self._json({"error": "invalid json"}, status=400)
result = service._api_record_usage(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result, status=202)
@http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False)
def get_plans(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
return self._json(service._api_catalog())
@http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False)
def post_subscription(self, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
payload = self._read_json()
if payload is None:
return self._json({"error": "invalid json"}, status=400)
result = service._api_create_subscription(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result)

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fc_rate_usage" model="ir.cron">
<field name="name">Fusion Billing: Rate usage before invoicing</field>
<field name="model_id" ref="model_fusion_billing_usage"/>
<field name="state">code</field>
<field name="code">model._cron_rate_open_periods()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
</record>
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
<field name="model_id" ref="model_fusion_billing_webhook"/>
<field name="state">code</field>
<field name="code">model._cron_dispatch()</field>
<field name="interval_number">2</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -0,0 +1,8 @@
from . import service
from . import account_link
from . import metric
from . import charge
from . import usage
from . import webhook
from . import reconciliation
from . import sale_order

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, fields, models
class FusionBillingAccountLink(models.Model):
"""Identity resolution: maps an app's external account id to one res.partner.
Folds the NexaCloud user / NexaDesk tenant / NexaMaps client for the same
real-world client onto a single partner (the unified customer). See spec §5.1.
"""
_name = "fusion.billing.account.link"
_description = "Fusion Billing — External Account → Partner Link"
_order = "service_id, external_id"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
external_id = fields.Char(
required=True, index=True,
help="The app's own account id (NexaCloud user, NexaDesk tenant, Maps client).",
)
external_email = fields.Char()
partner_id = fields.Many2one(
"res.partner", required=True, ondelete="restrict", index=True,
)
_service_external_uniq = models.Constraint(
"unique(service_id, external_id)",
"An external account can only link to one partner per service.",
)
@api.model
def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None):
"""Return the link for (service, external_id), creating partner+link if needed.
Unifies customers: if a link for this external_id exists, reuse it; else if a
partner with the same email already exists (possibly from another service),
link to it; else create a new partner.
"""
existing = self.search(
[('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1)
if existing:
return existing
partner = self.env['res.partner']
if email:
partner = partner.search([('email', '=', email)], limit=1)
if not partner:
partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})})
return self.create({
'service_id': service.id,
'external_id': external_id,
'external_email': email,
'partner_id': partner.id,
})

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import math
from odoo import api, fields, models
class FusionBillingCharge(models.Model):
"""Maps a plan + metric to quota + overage pricing.
This is where "5,000,000 included / $0.10 per 1k overage" (NexaMaps) or a
NexaCloud CPU-seconds quota lives. Keyed by the shared ``plan_code`` the app
references; Odoo owns the money, the app owns feature entitlements. See spec §5.1.
"""
_name = "fusion.billing.charge"
_description = "Fusion Billing — Metered Charge (quota + overage)"
_order = "plan_code, name"
name = fields.Char(required=True)
plan_code = fields.Char(
required=True, index=True,
help="Shared plan_code the source app references (matches a sale.subscription.plan).",
)
plan_id = fields.Many2one(
"sale.subscription.plan",
help="Optional link to the Odoo recurrence/plan for this charge.",
)
metric_id = fields.Many2one(
"fusion.billing.metric", required=True, ondelete="restrict",
)
product_id = fields.Many2one(
"product.product", help="Usage product invoiced for overage.",
)
included_quota = fields.Float(
default=0.0, help="Units included before overage applies, per period.",
)
price_per_unit = fields.Monetary(help="Overage price per unit_batch.")
unit_batch = fields.Float(
default=1.0, help="Batch size for overage pricing, e.g. 1000 = priced per 1k.",
)
charge_model = fields.Selection(
[
("standard", "Standard (per unit)"),
("package", "Package"),
],
default="standard", required=True,
)
currency_id = fields.Many2one(
"res.currency", required=True,
default=lambda self: self.env.company.currency_id,
)
active = fields.Boolean(default=True)
_price_non_negative = models.Constraint(
"CHECK (price_per_unit >= 0)", "Overage price per unit cannot be negative.",
)
_unit_batch_positive = models.Constraint(
"CHECK (unit_batch > 0)", "Unit batch must be greater than zero.",
)
def _compute_billable(self, total_quantity):
"""Return (overage_units, amount) for total period usage under this charge.
- overage_units = usage above included_quota (never negative)
- 'standard': price the overage in (rounded-up) `unit_batch` blocks.
- 'package': price whole packages over the RAW quantity (quota ignored for
package counting); a partial package rounds up.
"""
self.ensure_one()
overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0))
batch = self.unit_batch or 1.0
if self.charge_model == 'package':
# whole packages over the RAW quantity (quota ignored for package counting)
blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0
return overage, round(blocks * (self.price_per_unit or 0.0), 2)
# standard: price the overage in (rounded-up) batches
blocks = math.ceil(overage / batch) if overage > 0 else 0
return overage, round(blocks * (self.price_per_unit or 0.0), 2)

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingMetric(models.Model):
"""A billable metric (CPU-seconds, API calls, messages, tokens ...).
Defines how raw usage is aggregated within a billing period. See spec §5.1 / §6.
"""
_name = "fusion.billing.metric"
_description = "Fusion Billing — Billable Metric"
_order = "code"
name = fields.Char(required=True)
code = fields.Char(required=True, index=True)
aggregation = fields.Selection(
[
("sum", "Sum"),
("max", "Max"),
("last", "Last value"),
("unique_count", "Unique count"),
],
default="sum", required=True,
)
unit_label = fields.Char(help="e.g. CPU-seconds, API calls, messages, tokens.")
rounding = fields.Float(default=1.0)
active = fields.Boolean(default=True)
_code_uniq = models.Constraint("unique(code)", "Metric code must be unique.")

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingReconciliation(models.Model):
"""Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing.
During phased cutover (NexaCloud first), Odoo computes invoices while the app
keeps charging. This row records the per-customer, per-period delta so we only
flip once deltas are within tolerance. See spec §10.
"""
_name = "fusion.billing.reconciliation"
_description = "Fusion Billing — Dual-Run Reconciliation"
_order = "period desc, service_id"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
odoo_amount = fields.Monetary()
external_amount = fields.Monetary(string="App-actual Amount")
delta = fields.Monetary(help="odoo_amount - external_amount.")
currency_id = fields.Many2one(
"res.currency", required=True,
default=lambda self: self.env.company.currency_id,
)
status = fields.Selection(
[
("match", "Within tolerance"),
("delta", "Delta — investigate"),
("resolved", "Resolved"),
],
default="delta", required=True, index=True,
)
note = fields.Text()

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = "sale.order"
def _fc_rate_usage(self, charge, period_start, period_end):
"""Aggregate this subscription's usage for `charge`'s metric in the period,
compute the overage amount, and upsert a matching overage order line.
Returns the amount.
A zero amount never *creates* a new line (no $0.00 overage clutter); if a
line already exists it is still updated so a dropped-to-zero overage clears.
"""
self.ensure_one()
Usage = self.env['fusion.billing.usage']
total = Usage._aggregate(self, charge.metric_id, period_start, period_end)
_overage, amount = charge._compute_billable(total)
if charge.product_id:
line = self.order_line.filtered(lambda l: l.product_id == charge.product_id)
if not line and amount == 0:
return amount
vals = {'product_uom_qty': 1, 'price_unit': amount}
if line:
line.write(vals)
else:
self.env['sale.order.line'].create(
{'order_id': self.id, 'product_id': charge.product_id.id, **vals})
return amount

View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import hashlib
import ipaddress
import secrets
from urllib.parse import urlparse
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class FusionBillingService(models.Model):
"""A source app that pushes billing data (NexaCloud / NexaDesk / NexaMaps).
The bearer API key is shown ONCE on generation and stored only as a SHA-256
hash. This record is the auth + routing boundary for the inbound API and the
target for outbound webhooks. See spec §5.1 / §7 / §8.
"""
_name = "fusion.billing.service"
_description = "Fusion Billing — Source Service"
_order = "name"
name = fields.Char(required=True)
code = fields.Char(
required=True, index=True,
help="Stable code the app identifies itself with, e.g. nexacloud / nexadesk / nexamaps.",
)
active = fields.Boolean(default=True)
api_key_hash = fields.Char(
string="API Key (SHA-256)",
help="Hash of the bearer key. The raw key is displayed once at generation time.",
)
webhook_url = fields.Char(help="Endpoint this app exposes to receive billing webhooks.")
webhook_secret = fields.Char(help="Shared secret for HMAC-SHA256 webhook signatures.")
account_link_ids = fields.One2many(
"fusion.billing.account.link", "service_id", string="Customer Links",
)
account_link_count = fields.Integer(compute="_compute_account_link_count")
_code_uniq = models.Constraint("unique(code)", "Service code must be unique.")
@api.depends("account_link_ids")
def _compute_account_link_count(self):
for rec in self:
rec.account_link_count = len(rec.account_link_ids)
@api.constrains("webhook_url")
def _check_webhook_url(self):
"""Reject SSRF-prone webhook targets: a non-empty URL must be https and must
not point at localhost or a private / link-local / loopback IP literal. Empty
is allowed (no webhook configured)."""
for rec in self:
url = (rec.webhook_url or "").strip()
if not url:
continue
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValidationError("Webhook URL must use https.")
host = parsed.hostname or ""
if not host or host.lower() in ("localhost", "ip6-localhost", "ip6-loopback"):
raise ValidationError(
"Webhook URL must not target localhost or a private address.")
try:
ip = ipaddress.ip_address(host)
except ValueError:
ip = None
if ip is not None and (
ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_unspecified or ip.is_multicast
):
raise ValidationError(
"Webhook URL must not target a private or loopback address.")
def action_generate_api_key(self):
"""Generate a fresh bearer key, store only its hash, return the raw key.
TODO(spec §7): surface the raw key once in the UI (wizard/notification).
"""
self.ensure_one()
raw = secrets.token_urlsafe(32)
self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest()
return raw
@api.model
def _match_api_key(self, raw_key):
"""Return the active service whose stored hash matches raw_key, else empty recordset."""
if not raw_key:
return self.browse()
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
def _api_upsert_customer(self, payload):
"""Resolve/create the partner link for an external account.
Defensive: a non-dict payload or a missing/empty ``external_id`` returns a
4xx-shaped error instead of raising (C3).
"""
self.ensure_one()
if not isinstance(payload, dict):
return {'status': 'error', 'error': 'invalid payload'}
ext = payload.get('external_id')
if not ext:
return {'status': 'error', 'error': 'external_id required'}
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
self, ext, name=payload.get('name'), email=payload.get('email'))
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
def _api_record_usage(self, payload):
"""Ingest a batch of usage events.
Authorization (C2/C4): each event must target a subscription sale.order that
(a) exists, (b) is actually a subscription, and (c) belongs to a customer THIS
service is linked to. Any failing event is rejected and stops processing for
that event without writing a usage row.
Validation (C3): a non-dict payload, a non-list ``events``, missing required
keys, or non-numeric ``quantity``/ids return a 4xx-shaped error instead of
raising (no 500s).
"""
self.ensure_one()
if not isinstance(payload, dict):
return {'status': 'error', 'error': 'invalid payload'}
events = payload.get('events')
if events is None:
events = []
if not isinstance(events, list):
return {'status': 'error', 'error': 'events must be a list'}
Usage = self.env['fusion.billing.usage']
linked_partners = self.account_link_ids.mapped('partner_id')
accepted = 0
for ev in events:
if not isinstance(ev, dict):
return {'status': 'error', 'error': 'invalid event'}
for key in ('subscription_external_id', 'metric_code', 'quantity',
'period_start', 'period_end'):
if ev.get(key) in (None, ''):
return {'status': 'error', 'error': 'missing %s' % key}
try:
sub_id = int(ev['subscription_external_id'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid subscription_external_id'}
try:
quantity = float(ev['quantity'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid quantity'}
sub = self.env['sale.order'].browse(sub_id)
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}
try:
Usage._record_usage(
sub, ev['metric_code'], quantity,
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
except ValueError as e:
return {'status': 'error', 'error': str(e)}
accepted += 1
return {'status': 'ok', 'accepted': accepted}
def _api_catalog(self):
self.ensure_one()
charges = self.env['fusion.billing.charge'].search([('active', '=', True)])
return {'status': 'ok', 'charges': [{
'plan_code': c.plan_code, 'metric': c.metric_id.code,
'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit,
'unit_batch': c.unit_batch, 'charge_model': c.charge_model,
} for c in charges]}
def _api_create_subscription(self, payload):
"""Create and confirm a subscription sale.order for an external customer.
The product on each line must have recurring_invoice=True so that
Odoo recognises the order as a subscription with has_recurring_line and
action_confirm() reaches subscription_state='3_progress'.
Validation (C3): a non-dict payload, a missing/unknown customer, a missing
``plan_id``, a non-list ``lines``, or a non-numeric product id/quantity
return a 4xx-shaped error instead of raising (no 500s).
"""
self.ensure_one()
if not isinstance(payload, dict):
return {'status': 'error', 'error': 'invalid payload'}
if not payload.get('external_customer_id'):
return {'status': 'error', 'error': 'external_customer_id required'}
if not payload.get('plan_id'):
return {'status': 'error', 'error': 'plan_id required'}
try:
plan_id = int(payload['plan_id'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid plan_id'}
link = self.env['fusion.billing.account.link'].search([
('service_id', '=', self.id),
('external_id', '=', payload.get('external_customer_id')),
], limit=1)
if not link:
return {'status': 'error', 'error': 'unknown customer'}
lines = payload.get('lines')
if lines is None:
lines = []
if not isinstance(lines, list):
return {'status': 'error', 'error': 'lines must be a list'}
order_lines = []
for line in lines:
if not isinstance(line, dict) or line.get('product_id') in (None, ''):
return {'status': 'error', 'error': 'invalid line'}
try:
product_id = int(line['product_id'])
quantity = float(line.get('quantity', 1))
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid line'}
order_lines.append((0, 0, {
'product_id': product_id,
'product_uom_qty': quantity,
}))
sub = self.env['sale.order'].sudo().create({
'partner_id': link.partner_id.id,
'plan_id': plan_id,
'order_line': order_lines,
})
sub.action_confirm()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, fields, models
class FusionBillingUsage(models.Model):
"""Aggregated usage rollup for a (subscription, metric, period).
Aggregate-push model: apps send periodic counters (not raw events). The
``idempotency_key`` makes re-sent counters safe — they never double-count.
A pre-invoice cron sums these and feeds billable quantity onto the subscription.
NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with
``is_subscription=True`` — there is no ``sale.subscription`` model. See spec §5.2.
"""
_name = "fusion.billing.usage"
_description = "Fusion Billing — Aggregated Usage (period rollup)"
_order = "period_start desc"
subscription_id = fields.Many2one(
"sale.order", required=True, ondelete="cascade", index=True,
string="Subscription", domain=[("is_subscription", "=", True)],
)
metric_id = fields.Many2one(
"fusion.billing.metric", required=True, ondelete="restrict", index=True,
)
period_start = fields.Datetime(required=True)
period_end = fields.Datetime(required=True)
quantity = fields.Float(default=0.0)
source = fields.Char(default="push")
idempotency_key = fields.Char(
index=True, help="Dedupe key so re-sent counters never double-count.",
)
_idempotency_uniq = models.Constraint(
"unique(subscription_id, metric_id, idempotency_key)",
"Usage idempotency key must be unique per subscription and metric.",
)
@api.model
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
"""Upsert one aggregated usage row. Same idempotency key (scoped to the same
subscription + metric) updates in place (no double-count)."""
metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1)
if not metric:
raise ValueError("Unknown metric code: %s" % metric_code)
vals = {
'subscription_id': subscription.id,
'metric_id': metric.id,
'period_start': period_start,
'period_end': period_end,
'quantity': quantity,
'idempotency_key': idem,
}
if idem:
existing = self.search([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('idempotency_key', '=', idem),
], limit=1)
if existing:
existing.write({'quantity': quantity})
return existing
return self.create(vals)
@api.model
def _cron_rate_open_periods(self):
"""Hourly cron: for every active charge, aggregate usage and upsert overage lines
on the in-progress subscriptions that are on the charge's own plan.
A charge only rates subscriptions whose ``plan_id`` matches the charge's
``plan_id`` — never every subscription against every charge (C1/H4). The
billing-period window is the subscription's real open period
``[last_invoice_date or start_date, next_invoice_date)`` (H1)."""
Charge = self.env['fusion.billing.charge'].search([('active', '=', True)])
SaleOrder = self.env['sale.order']
for charge in Charge:
if not charge.plan_id:
continue
subs = SaleOrder.search([
('is_subscription', '=', True),
('subscription_state', '=', '3_progress'),
('plan_id', '=', charge.plan_id.id),
])
for sub in subs:
if not sub.next_invoice_date:
continue
period_end = fields.Datetime.to_datetime(sub.next_invoice_date)
period_start = fields.Datetime.to_datetime(
sub.last_invoice_date or sub.start_date)
if not period_start:
continue
sub._fc_rate_usage(charge, period_start, period_end)
@api.model
def _aggregate(self, subscription, metric, period_start, period_end):
"""Aggregate stored usage for a subscription+metric over the half-open window
``[period_start, period_end)``, anchored on each rollup's ``period_start``,
using the metric's aggregation function."""
rows = self.search([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('period_start', '>=', period_start),
('period_start', '<', period_end),
])
qtys = rows.mapped('quantity')
if not qtys:
return 0.0
agg = metric.aggregation
if agg == 'sum':
return sum(qtys)
if agg == 'max':
return max(qtys)
if agg == 'last':
return rows.sorted('period_start')[-1].quantity
if agg == 'unique_count':
return float(len(set(qtys)))
return sum(qtys)

View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import hashlib
import hmac
import json
import logging
from datetime import timedelta
import requests
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
MAX_ATTEMPTS = 8
class FusionBillingWebhook(models.Model):
"""Outbound webhook queue: lifecycle events delivered to source apps.
Processed by a cron with exponential backoff + HMAC-SHA256 signing, dead-lettered
after N attempts (mirror the proven retry pattern in NexaDesk's
lago-payment-retry-job). Apps react: suspend / restore / deprovision. See spec §8.
TODO(spec §8): cron processor, HMAC signing, backoff schedule.
"""
_name = "fusion.billing.webhook"
_description = "Fusion Billing — Outbound Webhook Event"
_order = "create_date desc"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
event_type = fields.Char(
required=True, index=True,
help="invoice.payment_failed / invoice.payment_succeeded / "
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
)
payload = fields.Json()
body = fields.Text(
help="Canonical JSON body that was signed and is POSTed verbatim "
"(so the signature always matches the bytes on the wire).",
)
state = fields.Selection(
[
("pending", "Pending"),
("sent", "Sent"),
("failed", "Failed"),
("dead", "Dead-lettered"),
],
default="pending", required=True, index=True,
)
attempts = fields.Integer(default=0)
next_retry_at = fields.Datetime()
signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.")
last_error = fields.Text()
@api.model
def _sign(self, secret, body):
return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest()
@api.model
def _enqueue(self, service, event_type, payload):
# Serialize the canonical body ONCE, store it, and sign that exact string so
# the dispatched bytes always match the signature (no re-serialization drift).
body = json.dumps(payload, sort_keys=True, separators=(',', ':'))
return self.create({
'service_id': service.id,
'event_type': event_type,
'payload': payload,
'body': body,
'signature': self._sign(service.webhook_secret, body),
'state': 'pending',
'next_retry_at': fields.Datetime.now(),
})
@api.model
def _cron_dispatch(self):
now = fields.Datetime.now()
due = self.search([
('state', 'in', ('pending', 'failed')),
('next_retry_at', '<=', now),
], limit=100)
for wh in due:
# POST the exact bytes that were signed at enqueue time. Fall back to
# re-serializing the payload only for legacy rows enqueued before `body`
# existed (the signature was computed over the same canonical form).
body = wh.body or json.dumps(wh.payload, sort_keys=True, separators=(',', ':'))
try:
resp = requests.post(
wh.service_id.webhook_url,
data=body,
headers={'Content-Type': 'application/json',
'X-Fusion-Signature': wh.signature,
'X-Fusion-Event': wh.event_type,
'X-Fusion-Event-Id': str(wh.id)},
timeout=10,
)
ok = 200 <= resp.status_code < 300
except Exception as e: # noqa: BLE001 - record and retry
ok = False
wh.last_error = str(e)[:500]
wh.attempts += 1
if ok:
wh.state = 'sent'
elif wh.attempts >= MAX_ATTEMPTS:
wh.state = 'dead'
else:
wh.state = 'failed'
# Cap the exponential backoff so the interval can't overflow.
wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10))

View File

@@ -0,0 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_billing_service_admin,fusion.billing.service admin,model_fusion_billing_service,base.group_system,1,1,1,1
access_fusion_billing_account_link_admin,fusion.billing.account.link admin,model_fusion_billing_account_link,base.group_system,1,1,1,1
access_fusion_billing_metric_admin,fusion.billing.metric admin,model_fusion_billing_metric,base.group_system,1,1,1,1
access_fusion_billing_charge_admin,fusion.billing.charge admin,model_fusion_billing_charge,base.group_system,1,1,1,1
access_fusion_billing_usage_admin,fusion.billing.usage admin,model_fusion_billing_usage,base.group_system,1,1,1,1
access_fusion_billing_webhook_admin,fusion.billing.webhook admin,model_fusion_billing_webhook,base.group_system,1,1,1,1
access_fusion_billing_reconciliation_admin,fusion.billing.reconciliation admin,model_fusion_billing_reconciliation,base.group_system,1,1,1,1
access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_billing_metric,account.group_account_manager,1,1,1,0
access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0
access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_billing_service_admin fusion.billing.service admin model_fusion_billing_service base.group_system 1 1 1 1
3 access_fusion_billing_account_link_admin fusion.billing.account.link admin model_fusion_billing_account_link base.group_system 1 1 1 1
4 access_fusion_billing_metric_admin fusion.billing.metric admin model_fusion_billing_metric base.group_system 1 1 1 1
5 access_fusion_billing_charge_admin fusion.billing.charge admin model_fusion_billing_charge base.group_system 1 1 1 1
6 access_fusion_billing_usage_admin fusion.billing.usage admin model_fusion_billing_usage base.group_system 1 1 1 1
7 access_fusion_billing_webhook_admin fusion.billing.webhook admin model_fusion_billing_webhook base.group_system 1 1 1 1
8 access_fusion_billing_reconciliation_admin fusion.billing.reconciliation admin model_fusion_billing_reconciliation base.group_system 1 1 1 1
9 access_fusion_billing_metric_acct fusion.billing.metric accountant model_fusion_billing_metric account.group_account_manager 1 1 1 0
10 access_fusion_billing_charge_acct fusion.billing.charge accountant model_fusion_billing_charge account.group_account_manager 1 1 1 0
11 access_fusion_billing_reconciliation_acct fusion.billing.reconciliation accountant model_fusion_billing_reconciliation account.group_account_manager 1 1 1 0

View File

@@ -0,0 +1,5 @@
from . import test_identity
from . import test_charge
from . import test_usage
from . import test_api
from . import test_webhook

View File

@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestApiHandlers(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaMaps', 'code': 'nexamaps'})
self.env['fusion.billing.metric'].sudo().create(
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
def test_api_upsert_customer(self):
res = self.service._api_upsert_customer(
{'external_id': 'client-9', 'name': 'Globex', 'email': 'billing@globex.test'})
self.assertEqual(res['status'], 'ok')
link = self.env['fusion.billing.account.link'].search(
[('service_id', '=', self.service.id), ('external_id', '=', 'client-9')])
self.assertEqual(link.partner_id.name, 'Globex')
def test_api_record_usage_batch(self):
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
partner = self.env['fusion.billing.account.link'].search(
[('external_id', '=', 'client-9')]).partner_id
sub = self.env['sale.order'].sudo().create(
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
res = self.service._api_record_usage({'events': [{
'subscription_external_id': str(sub.id), 'metric_code': 'api_calls',
'quantity': 1234.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
'idempotency_key': 'maps:client-9:2026-05-01',
}]})
self.assertEqual(res['accepted'], 1)
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
self.assertEqual(usage.quantity, 1234.0)
def test_api_catalog_lists_active_charges(self):
self.env['fusion.billing.charge'].sudo().create({
'name': 'Maps overage', 'plan_code': 'maps-business',
'metric_id': self.env['fusion.billing.metric'].search([('code', '=', 'api_calls')]).id,
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0})
cat = self.service._api_catalog()
codes = [c['plan_code'] for c in cat['charges']]
self.assertIn('maps-business', codes)
def test_api_create_subscription(self):
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
product = self.env['product.product'].sudo().create(
{'name': 'Maps Business', 'type': 'service', 'recurring_invoice': True,
'list_price': 249.0})
res = self.service._api_create_subscription({
'external_customer_id': 'client-9',
'plan_id': self.plan.id,
'lines': [{'product_id': product.id, 'quantity': 1}],
})
self.assertEqual(res['status'], 'ok')
sub = self.env['sale.order'].browse(res['subscription_id'])
self.assertTrue(sub.is_subscription)
self.assertEqual(sub.plan_id, self.plan)
self.assertEqual(sub.subscription_state, '3_progress')
# ── item 4 (C3): malformed input returns a 4xx-shaped error, never raises ──
def test_record_usage_missing_metric_code_returns_error(self):
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
partner = self.env['fusion.billing.account.link'].search(
[('external_id', '=', 'client-9')]).partner_id
sub = self.env['sale.order'].sudo().create(
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
# metric_code intentionally omitted — must return an error dict, not raise
res = self.service._api_record_usage({'events': [{
'subscription_external_id': str(sub.id),
'quantity': 10.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
}]})
self.assertEqual(res['status'], 'error')
# no usage row written
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
self.assertFalse(usage)
@tagged('post_install', '-at_install')
class TestUsageAuthorization(TransactionCase):
"""/usage must only accept events for subscriptions the calling service is linked
to, and reject unknown / non-subscription targets (items 3/C2/C4)."""
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.service_a = self.env['fusion.billing.service'].sudo().create(
{'name': 'Service A', 'code': 'svc_a'})
self.service_b = self.env['fusion.billing.service'].sudo().create(
{'name': 'Service B', 'code': 'svc_b'})
# Service A owns customer + subscription
self.service_a._api_upsert_customer({'external_id': 'cust-a', 'name': 'Cust A'})
self.partner_a = self.env['fusion.billing.account.link'].search(
[('service_id', '=', self.service_a.id), ('external_id', '=', 'cust-a')]).partner_id
self.sub_a = self.env['sale.order'].sudo().create(
{'partner_id': self.partner_a.id, 'is_subscription': True, 'plan_id': self.plan.id})
self.Usage = self.env['fusion.billing.usage'].sudo()
def _event(self, sub_id, idem):
return {'events': [{
'subscription_external_id': str(sub_id), 'metric_code': 'api_calls',
'quantity': 42.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
'idempotency_key': idem,
}]}
def test_cross_service_usage_rejected(self):
"""Service B pushing usage onto Service A's subscription is rejected, no row."""
res = self.service_b._api_record_usage(self._event(self.sub_a.id, 'cross-1'))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertFalse(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
def test_same_service_usage_accepted(self):
"""Positive control: Service A pushing onto its own subscription is accepted."""
res = self.service_a._api_record_usage(self._event(self.sub_a.id, 'ok-1'))
self.assertEqual(res['status'], 'ok')
self.assertEqual(res['accepted'], 1)
self.assertTrue(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
def test_nonexistent_subscription_rejected(self):
res = self.service_a._api_record_usage(self._event(999_999_999, 'ghost-1'))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
def test_non_subscription_order_rejected(self):
"""A plain (non-subscription) sale.order owned by the linked customer is rejected."""
plain = self.env['sale.order'].sudo().create({'partner_id': self.partner_a.id})
self.assertFalse(plain.is_subscription)
res = self.service_a._api_record_usage(self._event(plain.id, 'plain-1'))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertFalse(self.Usage.search([('subscription_id', '=', plain.id)]))

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from psycopg2 import IntegrityError
from odoo.tests.common import TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('post_install', '-at_install')
class TestChargeMath(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
def _charge(self, **kw):
vals = {
'name': 'Maps overage', 'plan_code': 'maps-business',
'metric_id': self.metric.id, 'charge_model': 'standard',
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
}
vals.update(kw)
return self.env['fusion.billing.charge'].sudo().create(vals)
def test_under_quota_is_free(self):
charge = self._charge()
overage_units, amount = charge._compute_billable(4_000_000.0)
self.assertEqual(overage_units, 0.0)
self.assertEqual(amount, 0.0)
def test_standard_overage_per_1k(self):
charge = self._charge()
# 6,000,000 used - 5,000,000 quota = 1,000,000 overage = 1000 batches * $0.10
overage_units, amount = charge._compute_billable(6_000_000.0)
self.assertEqual(overage_units, 1_000_000.0)
self.assertAlmostEqual(amount, 100.0, places=2)
def test_partial_batch_rounds_up(self):
charge = self._charge(included_quota=0.0)
# 1,500 units, batch 1000 -> 2 batches -> $0.20
_, amount = charge._compute_billable(1_500.0)
self.assertAlmostEqual(amount, 0.20, places=2)
def test_package_model_charges_whole_packages(self):
charge = self._charge(charge_model='package', included_quota=0.0, unit_batch=1000.0, price_per_unit=2.0)
# 2,001 units -> 3 packages -> $6.00
_, amount = charge._compute_billable(2_001.0)
self.assertAlmostEqual(amount, 6.0, places=2)
# ── item 10 (M7): only the two implemented charge models remain ──
def test_charge_model_selection_limited(self):
field = self.env['fusion.billing.charge']._fields['charge_model']
keys = [k for k, _label in field.selection]
self.assertEqual(sorted(keys), ['package', 'standard'])
self.assertNotIn('graduated', keys)
self.assertNotIn('volume', keys)
# ── item 11 (L1): currency is required and defaults to company currency ──
def test_currency_required_and_defaulted(self):
field = self.env['fusion.billing.charge']._fields['currency_id']
self.assertTrue(field.required)
charge = self._charge()
self.assertEqual(charge.currency_id, self.env.company.currency_id)
# ── item 12 (L2): non-negative price + positive batch DB constraints ──
def test_negative_price_rejected(self):
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self._charge(price_per_unit=-1.0)
def test_zero_batch_rejected(self):
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self._charge(unit_batch=0.0)

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceApiKey(TransactionCase):
def setUp(self):
super().setUp()
self.Service = self.env['fusion.billing.service'].sudo()
self.service = self.Service.create({'name': 'NexaCloud', 'code': 'nexacloud'})
def test_generate_and_match_api_key(self):
raw = self.service.action_generate_api_key()
self.assertTrue(raw and len(raw) >= 20)
self.assertTrue(self.service.api_key_hash)
self.assertNotEqual(raw, self.service.api_key_hash) # only the hash is stored
matched = self.Service._match_api_key(raw)
self.assertEqual(matched, self.service)
def test_match_api_key_rejects_unknown_and_inactive(self):
raw = self.service.action_generate_api_key()
self.assertFalse(self.Service._match_api_key('nope-not-a-key'))
self.service.active = False
self.assertFalse(self.Service._match_api_key(raw))
@tagged('post_install', '-at_install')
class TestIdentityResolution(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaDesk', 'code': 'nexadesk'})
self.Link = self.env['fusion.billing.account.link'].sudo()
def test_creates_partner_first_time(self):
link = self.Link._resolve_or_create_partner(
self.service, external_id='tenant-1', name='Acme Inc', email='ar@acme.test')
self.assertTrue(link.partner_id)
self.assertEqual(link.partner_id.name, 'Acme Inc')
self.assertEqual(link.external_id, 'tenant-1')
def test_idempotent_same_external_id(self):
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
b = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme Renamed', 'ar@acme.test')
self.assertEqual(a, b) # same link row
self.assertEqual(a.partner_id, b.partner_id) # same partner
def test_reuses_partner_by_email_across_services(self):
other = self.env['fusion.billing.service'].sudo().create({'name': 'Maps', 'code': 'nexamaps'})
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
b = self.Link._resolve_or_create_partner(other, 'client-9', 'Acme', 'ar@acme.test')
self.assertEqual(a.partner_id, b.partner_id) # one unified customer
self.assertNotEqual(a, b) # but distinct link rows

View File

@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestRatingCron(TransactionCase):
"""The rating cron must only rate a subscription against charges on its OWN plan
(items 1/C1/H4) and over the subscription's real open billing period (item 5/H1)."""
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.plan_b = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Plan B', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
self.recurring_product = self.env['product.product'].sudo().create(
{'name': 'Plan seat', 'type': 'service', 'recurring_invoice': True,
'list_price': 10.0})
self.overage_product = self.env['product.product'].sudo().create(
{'name': 'CPU overage', 'type': 'service', 'list_price': 0.0})
self.Usage = self.env['fusion.billing.usage'].sudo()
def _confirmed_sub(self, plan):
sub = self.env['sale.order'].sudo().create({
'partner_id': self.partner.id, 'plan_id': plan.id,
'order_line': [(0, 0, {'product_id': self.recurring_product.id,
'product_uom_qty': 1})],
})
sub.action_confirm()
# widen the computed billing window so usage in May is in-period
sub.write({'start_date': '2026-05-01', 'next_invoice_date': '2026-06-01'})
return sub
def test_cron_rates_only_matching_plan(self):
sub_a = self._confirmed_sub(self.plan_a)
sub_b = self._confirmed_sub(self.plan_b)
self.assertEqual(sub_a.subscription_state, '3_progress')
self.assertEqual(sub_b.subscription_state, '3_progress')
# one charge, linked to Plan A only
charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'CPU overage', 'plan_code': 'plan-a', 'plan_id': self.plan_a.id,
'metric_id': self.metric.id, 'product_id': self.overage_product.id,
'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
'charge_model': 'standard'})
# usage recorded on BOTH subs, in the open period
self.Usage._record_usage(sub_a, 'cpu_seconds', 1100.0,
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='a1')
self.Usage._record_usage(sub_b, 'cpu_seconds', 1100.0,
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='b1')
self.Usage._cron_rate_open_periods()
# Plan A sub IS rated (window captured the usage → overage line present)
line_a = sub_a.order_line.filtered(lambda l: l.product_id == self.overage_product)
self.assertTrue(line_a, "Plan A subscription should be rated by the Plan A charge")
self.assertAlmostEqual(line_a.price_unit, 0.10, places=2)
# Plan B sub is NOT rated by the Plan A charge
line_b = sub_b.order_line.filtered(lambda l: l.product_id == self.overage_product)
self.assertFalse(line_b, "Plan B subscription must NOT be rated by the Plan A charge")
@tagged('post_install', '-at_install')
class TestUsageIngestion(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
self.sub = self.env['sale.order'].sudo().create({
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
})
self.Usage = self.env['fusion.billing.usage'].sudo()
def test_record_usage_creates_row(self):
u = self.Usage._record_usage(
self.sub, 'cpu_seconds', 120.0,
'2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01')
self.assertEqual(u.quantity, 120.0)
self.assertEqual(u.metric_id, self.metric)
def test_idempotent_key_updates_not_duplicates(self):
k = 'nexacloud:cpu:sub1:2026-05-01'
self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k)
self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k)
rows = self.Usage.search([('idempotency_key', '=', k)])
self.assertEqual(len(rows), 1) # no duplicate
self.assertEqual(rows.quantity, 175.0) # last value wins for the same key
def test_aggregate_sum(self):
for i, q in enumerate([10.0, 20.0, 30.0]):
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
'2026-05-01', '2026-06-01', idem='cpu-%d' % i)
total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01')
self.assertEqual(total, 60.0)
def test_aggregate_max(self):
self.metric.aggregation = 'max'
for i, q in enumerate([10.0, 55.0, 30.0]):
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
'2026-05-01', '2026-06-01', idem='m-%d' % i)
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0)
def test_aggregate_excludes_other_periods(self):
self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr')
self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may')
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0)
def test_rate_open_period_creates_overage_line(self):
product = self.env['product.product'].sudo().create(
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
'product_id': product.id, 'included_quota': 100.0,
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0,
'2026-05-01', '2026-06-01', idem='r1')
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
# 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10
self.assertAlmostEqual(amount, 0.10, places=2)
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
self.assertTrue(line)
# ── item 6 (H2): half-open aggregation window anchored on period_start ──
def test_aggregate_daily_rollups_in_window(self):
"""Three DAILY rollups (period_start 05-01/-08/-15, each period_end +1 day)
sum correctly for the half-open window ['2026-05-01', '2026-06-01')."""
rollups = [
('2026-05-01 00:00:00', '2026-05-02 00:00:00', 3.0),
('2026-05-08 00:00:00', '2026-05-09 00:00:00', 5.0),
('2026-05-15 00:00:00', '2026-05-16 00:00:00', 7.0),
]
for i, (ps, pe, q) in enumerate(rollups):
self.Usage._record_usage(self.sub, 'cpu_seconds', q, ps, pe, idem='daily-%d' % i)
total = self.Usage._aggregate(
self.sub, self.metric, '2026-05-01 00:00:00', '2026-06-01 00:00:00')
self.assertEqual(total, 15.0) # 3 + 5 + 7
# ── item 7 (H3): idempotency key is scoped per (subscription, metric) ──
def test_same_idempotency_key_distinct_subscriptions(self):
"""The SAME idempotency key on two DIFFERENT subscriptions creates TWO rows."""
sub2 = self.env['sale.order'].sudo().create({
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
})
key = 'shared-idem-key'
a = self.Usage._record_usage(self.sub, 'cpu_seconds', 10.0, '2026-05-01', '2026-06-01', idem=key)
b = self.Usage._record_usage(sub2, 'cpu_seconds', 20.0, '2026-05-01', '2026-06-01', idem=key)
self.assertNotEqual(a, b) # distinct rows, no collision
rows = self.Usage.search([('idempotency_key', '=', key)])
self.assertEqual(len(rows), 2)
self.assertEqual(a.quantity, 10.0)
self.assertEqual(b.quantity, 20.0)
# ── item 2 (C1): zero aggregated usage creates no overage line ──
def test_zero_usage_creates_no_line(self):
product = self.env['product.product'].sudo().create(
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
'product_id': product.id, 'included_quota': 100.0,
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
# no usage recorded → aggregate is 0 → amount 0 → no line created
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
self.assertEqual(amount, 0.0)
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
self.assertFalse(line)

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
import hashlib
import hmac
import json
from unittest.mock import patch
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestWebhookEngine(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create({
'name': 'NexaCloud', 'code': 'nexacloud',
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
'webhook_secret': 'whsec_test',
})
self.Webhook = self.env['fusion.billing.webhook'].sudo()
def test_enqueue_signs_payload(self):
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'})
self.assertEqual(wh.state, 'pending')
body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':'))
expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest()
self.assertEqual(wh.signature, expected)
def test_dispatch_marks_sent_on_2xx(self):
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'})
class _Resp:
status_code = 200
text = 'ok'
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
return_value=_Resp()) as mock_post:
self.Webhook._cron_dispatch()
self.assertTrue(mock_post.called)
self.assertEqual(wh.state, 'sent')
def test_dispatch_retries_then_deadletters(self):
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'})
wh.write({'attempts': 7}) # already past max
class _Resp:
status_code = 500
text = 'err'
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
return_value=_Resp()):
self.Webhook._cron_dispatch()
self.assertEqual(wh.state, 'dead')
# ── item 8 (H5): dispatch POSTs the stored body verbatim + event-id header ──
def test_dispatch_posts_stored_body_and_event_id(self):
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-9'})
class _Resp:
status_code = 200
text = 'ok'
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
return_value=_Resp()) as mock_post:
self.Webhook._cron_dispatch()
self.assertTrue(mock_post.called)
_args, kwargs = mock_post.call_args
# the exact stored body is POSTed (not a re-serialized payload)
self.assertEqual(kwargs['data'], wh.body)
self.assertEqual(wh.body, json.dumps(
{'invoice': 'INV-9'}, sort_keys=True, separators=(',', ':')))
# signature matches the bytes on the wire
expected = hmac.new(b'whsec_test', wh.body.encode(), hashlib.sha256).hexdigest()
self.assertEqual(kwargs['headers']['X-Fusion-Signature'], expected)
# event id header present and correct
self.assertEqual(kwargs['headers']['X-Fusion-Event-Id'], str(wh.id))
# ── item 9 (H6): SSRF guard on webhook_url ──
def test_webhook_url_rejects_loopback(self):
with self.assertRaises(ValidationError):
self.env['fusion.billing.service'].sudo().create({
'name': 'Evil', 'code': 'evil', 'webhook_url': 'http://127.0.0.1/x'})
def test_webhook_url_rejects_private_and_http(self):
for bad in ('http://10.0.0.5/hook', # private + non-https
'https://192.168.1.10/hook', # private
'https://localhost/hook', # localhost host
'https://169.254.169.254/latest', # link-local metadata
'http://api.example.com/hook'): # non-https
with self.assertRaises(ValidationError):
self.env['fusion.billing.service'].sudo().create({
'name': 'Bad', 'code': 'bad-%s' % bad, 'webhook_url': bad})
def test_webhook_url_allows_public_https(self):
svc = self.env['fusion.billing.service'].sudo().create({
'name': 'Good', 'code': 'good',
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook'})
self.assertTrue(svc.id)

358
fusion_clock/CLAUDE.md Normal file
View File

@@ -0,0 +1,358 @@
# Fusion Clock - Claude Code Instructions
> Read together with the repo-root `../CLAUDE.md` for global Odoo 19 rules, asset-cache handling, Supabase KB notes, and shared Fusion conventions. This file is only for the `fusion_clock` module.
## 1. What This Module Is
- **Name**: Fusion Clock.
- **Version**: `19.0.3.3.0`.
- **Category**: Human Resources/Attendances.
- **License**: OPL-1, Nexa Systems Inc.
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
- **Top-level menu**: `Fusion Clock`.
- **Main surfaces**:
- Portal clock page at `/my/clock`.
- Portal timesheets at `/my/clock/timesheets`.
- Portal reports at `/my/clock/reports`.
- Shared PIN kiosk at `/fusion_clock/kiosk`.
- NFC tap kiosk at `/fusion_clock/kiosk/nfc`.
- Backend systray clock widget.
- Backend manager/team-lead dashboard client action.
Core behaviours: geofenced clock-in/out, IP whitelist fallback, shift scheduling, break deduction, penalties, overtime, auto clock-out, absence detection, leave requests, correction workflow, payroll CSV export, PDF reports, weekly summaries, shared kiosk, NFC kiosk with photo capture, and activity audit logs.
## 2. Dependencies
Declared in `__manifest__.py`:
```
hr_attendance, hr, portal, mail, resource
```
External Python used directly:
- `pytz` for timezone-safe local day boundaries.
- `requests` for Google Geocoding, OpenStreetMap/Nominatim fallback, and IP metadata.
- `dateutil.relativedelta` inside pay-period calculations.
External browser APIs:
- Browser geolocation.
- `ipapi.co` fallback geolocation in frontend/backend clock widgets.
- Google Maps/Places when `fusion_clock.google_maps_api_key` is configured.
- Web NFC and camera APIs for the NFC kiosk.
## 3. Naming And Field Prefixes
This module uses the module-specific prefix **`x_fclk_*`** on inherited Odoo models, not `x_fc_*`.
Examples:
- `hr.employee.x_fclk_enable_clock`
- `hr.employee.x_fclk_nfc_card_uid`
- `hr.attendance.x_fclk_clock_source`
- `res.company.x_fclk_nfc_kiosk_location_id`
New inherited fields in this module should keep the `x_fclk_*` prefix unless there is a strong migration reason not to.
## 4. Model Map
Custom models:
| Model | File | Purpose |
|---|---|---|
| `fusion.clock.location` | `models/clock_location.py` | Geofenced/IP-whitelisted clock locations. |
| `fusion.clock.shift` | `models/clock_shift.py` | Shift start/end/break schedule assigned to employees. |
| `fusion.clock.penalty` | `models/clock_penalty.py` | Late clock-in / early clock-out penalty records. |
| `fusion.clock.activity.log` | `models/clock_activity_log.py` | Append-style audit log for clock activity, geofence misses, absences, NFC enrolment, corrections. |
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
Inherited models:
- `hr.employee`: enable clock, default location, shift, kiosk PIN, NFC UID, pending reason flag, streaks, absence/overtime counters, and One2many links.
- `hr.attendance`: clock source, location, distances, photos, break minutes, net hours, penalties, auto clock-out flag, overtime fields.
- `res.config.settings`: all `fusion_clock.*` settings.
- `res.company`: NFC kiosk location binding.
Timezone helpers live in `models/tz_utils.py`. Use `get_local_today()` and `get_local_day_boundaries()` for attendance domains instead of comparing UTC dates directly.
## 5. Clocking Flow
Primary API endpoint: `/fusion_clock/clock_action` in `controllers/clock_api.py`.
Clock-in flow:
1. Resolve current user to `hr.employee`.
2. Block if `x_fclk_enable_clock` is false.
3. If `x_fclk_pending_reason` is true, return `requires_reason`.
4. Verify location against allowed active `fusion.clock.location` records.
5. Call Odoo's `_attendance_action_change()`.
6. Write location, distance, source, and optional photo to `hr.attendance`.
7. Log `clock_in`.
8. Create `late_in` penalty when outside grace.
9. Increment/reset on-time streak; log milestone at 5, 10, 20, 50, 100.
10. Notify office user for very-late clock-ins.
Clock-out flow:
1. Verify location again.
2. Call `_attendance_action_change()`.
3. Write out-distance.
4. Apply break deduction when configured.
5. Create `early_out` penalty when outside grace.
6. Log `clock_out`.
7. Log overtime if computed overtime is positive.
Location verification uses GPS when coordinates are available and geocoded locations exist. IP whitelist matching is attempted when a client IP is available. Error types include `no_locations`, `gps_unavailable`, `no_geocoded`, and `outside`.
## 6. Kiosk And NFC
Classic kiosk:
- Page: `/fusion_clock/kiosk`
- JSON routes:
- `/fusion_clock/kiosk/search`
- `/fusion_clock/kiosk/verify_pin`
- `/fusion_clock/kiosk/clock`
- Requires `fusion_clock.group_fusion_clock_manager`.
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
- Uses `hr.employee.x_fclk_kiosk_pin`.
NFC kiosk:
- Page: `/fusion_clock/kiosk/nfc`
- JSON routes:
- `/fusion_clock/kiosk/nfc/enroll`
- `/fusion_clock/kiosk/nfc/tap`
- `/fusion_clock/kiosk/nfc/employee_search`
- Requires `fusion_clock.group_fusion_clock_manager`.
- Controlled by:
- `fusion_clock.enable_nfc_kiosk`
- `fusion_clock.nfc_photo_required`
- `fusion_clock.nfc_enroll_password`
- `fusion_clock.nfc_kiosk_debug`
- `res.company.x_fclk_nfc_kiosk_location_id`
- Card UID canonical format is uppercase colon-separated hex, e.g. `04:A2:B5:62:C1:80`.
- Normalization lives in `FusionClockNfcKiosk._normalize_uid()` and is reused by the backend wizard.
- Tap debounce is module-level memory in `controllers/clock_nfc_kiosk.py`: same UID within 5 seconds returns `debounce`.
- Photo data URLs are stripped before writing binary fields.
- NFC clock-ins write `x_fclk_check_in_photo`; NFC clock-outs write `x_fclk_check_out_photo`.
Important: unknown-card taps currently return `card_unknown`; the `unknown_card_tap` log type exists but is not written by the endpoint.
## 7. Reports And Payroll Export
`fusion.clock.report` supports:
- Employee reports when `employee_id` is set.
- Batch reports when `employee_id` is empty.
- PDF generation through QWeb reports:
- `fusion_clock.action_report_clock_employee`
- `fusion_clock.action_report_clock_batch`
- CSV export via `action_export_csv()`.
- Custom CSV headings via JSON in `fusion_clock.csv_column_mapping`.
- Email send with generated PDF attached.
Pay period types:
```
weekly, biweekly, semi_monthly, monthly
```
The anchor date setting is `fusion_clock.pay_period_start` as a string in `YYYY-MM-DD` format.
Historical report generation is exposed through the `Generate Historical Reports` menu action and creates draft reports for completed attendance periods. The scheduled report cron only generates when yesterday is the period end.
## 8. Scheduled Automation
Configured in `data/ir_cron_data.xml`:
| Cron | Model method | Frequency |
|---|---|---|
| Fusion Clock: Auto Clock-Out | `hr.attendance._cron_fusion_auto_clock_out()` | Every 15 minutes |
| Fusion Clock: Generate Period Reports | `fusion.clock.report._cron_generate_period_reports()` | Daily |
| Fusion Clock: Daily Absence Check | `hr.attendance._cron_fusion_check_absences()` | Daily |
| Fusion Clock: Employee Reminders | `hr.attendance._cron_fusion_employee_reminders()` | Every 15 minutes |
| Fusion Clock: Weekly Summary | `hr.attendance._cron_fusion_weekly_summary()` | Daily, internally sends Mondays |
Auto clock-out closes open attendances after scheduled end plus grace, capped by max shift hours. It sets `x_fclk_pending_reason` so the employee must explain before clocking in again.
Absence detection checks enabled employees, skips weekends and global resource calendar leaves, and logs `absent` when no attendance or leave request exists.
## 9. Security
Groups:
- `group_fusion_clock_user`
- `group_fusion_clock_team_lead`
- `group_fusion_clock_manager`
Admin is auto-assigned to manager in `security/security.xml`.
Access pattern:
- Users and portal users can read their own clock data.
- Team leads can read direct reports for penalties, activity logs, corrections, and dashboard data.
- Managers have full model access and all configuration/kiosk/report menus.
- Portal rules are defined for `hr.attendance`, `fusion.clock.location`, `fusion.clock.report`, `fusion.clock.penalty`, `fusion.clock.activity.log`, `fusion.clock.leave.request`, `fusion.clock.correction`, and `fusion.clock.shift`.
Backend dashboard access is checked in `/fusion_clock/dashboard_data`: manager sees all enabled employees; team lead sees employees where `parent_id` is the current user's employee.
## 10. Frontend Assets
Frontend bundle:
- `static/src/css/portal_clock.css`
- `static/src/scss/nfc_kiosk.scss`
- `static/src/js/fusion_clock_portal.js`
- `static/src/js/fusion_clock_kiosk.js`
- `static/src/js/fusion_clock_nfc_kiosk.js`
Backend bundle:
- `static/src/scss/fusion_clock.scss`
- `static/src/js/fusion_clock_systray.js`
- `static/src/xml/systray_clock.xml`
- `static/src/js/fusion_clock_dashboard.js`
- `static/src/xml/fusion_clock_dashboard.xml`
- `static/src/js/fusion_clock_location_map.js`
- `static/src/js/fusion_clock_location_places.js`
- `static/src/xml/fusion_clock_location.xml`
Patterns:
- Public portal/kiosk JS should use `Interaction` from `@web/public/interaction` and register in `registry.category("public.interactions")`.
- Backend OWL client actions and field widgets use standalone `rpc()` from `@web/core/network/rpc`.
- `fusion_clock_systray.js` is a systray OWL component registered as `fusion_clock.ClockSystray`.
- `fusion_clock_dashboard.js` is a client action registered as `fusion_clock.Dashboard`.
- Location widgets are registered field widgets: `fclk_location_map` and `fclk_places_autocomplete`.
Known technical debt:
- `static/src/js/fusion_clock_nfc_kiosk.js` is currently an isolated IIFE. If touching it, prefer migrating to an Odoo 19 `Interaction` instead of expanding the IIFE pattern.
- `static/src/css/portal_clock.css` and `static/src/scss/fusion_clock.scss` contain runtime dark-mode selectors/media rules. For backend SCSS changes, follow the repo-root Odoo 19 compile-time dark bundle guidance.
- `fusion_clock.scss` uses some Bootstrap CSS vars for status accents. Avoid relying on Bootstrap vars for card/background/border surfaces in new dashboard work.
## 11. Settings Keys
Important `ir.config_parameter` keys:
```
fusion_clock.default_clock_in_time
fusion_clock.default_clock_out_time
fusion_clock.default_break_minutes
fusion_clock.auto_deduct_break
fusion_clock.break_threshold_hours
fusion_clock.enable_auto_clockout
fusion_clock.grace_period_minutes
fusion_clock.max_shift_hours
fusion_clock.enable_penalties
fusion_clock.penalty_grace_minutes
fusion_clock.penalty_deduction_minutes
fusion_clock.enable_overtime
fusion_clock.daily_overtime_threshold
fusion_clock.weekly_overtime_threshold
fusion_clock.office_user_id
fusion_clock.very_late_threshold_minutes
fusion_clock.max_monthly_absences
fusion_clock.enable_employee_notifications
fusion_clock.reminder_before_shift_minutes
fusion_clock.reminder_before_end_minutes
fusion_clock.send_weekly_summary
fusion_clock.enable_ip_fallback
fusion_clock.enable_photo_verification
fusion_clock.google_maps_api_key
fusion_clock.enable_kiosk
fusion_clock.kiosk_pin_required
fusion_clock.enable_correction_requests
fusion_clock.enable_sounds
fusion_clock.pay_period_type
fusion_clock.pay_period_start
fusion_clock.auto_generate_reports
fusion_clock.send_employee_reports
fusion_clock.report_recipient_user_ids
fusion_clock.report_recipient_emails
fusion_clock.csv_column_mapping
fusion_clock.enable_nfc_kiosk
fusion_clock.nfc_photo_required
fusion_clock.nfc_enroll_password
fusion_clock.nfc_kiosk_debug
```
`fclk_report_recipient_user_ids` is a Many2many on settings but is persisted manually as comma-separated user IDs in `fusion_clock.report_recipient_user_ids`.
## 12. Routes
HTTP pages:
```
/my/clock
/my/clock/timesheets
/my/clock/reports
/my/clock/reports/<report_id>/download
/fusion_clock/kiosk
/fusion_clock/kiosk/nfc
```
JSON-RPC endpoints:
```
/fusion_clock/verify_location
/fusion_clock/clock_action
/fusion_clock/submit_reason
/fusion_clock/request_leave
/fusion_clock/request_correction
/fusion_clock/get_status
/fusion_clock/get_locations
/fusion_clock/get_settings
/fusion_clock/dashboard_data
/fusion_clock/kiosk/search
/fusion_clock/kiosk/verify_pin
/fusion_clock/kiosk/clock
/fusion_clock/kiosk/nfc/enroll
/fusion_clock/kiosk/nfc/tap
/fusion_clock/kiosk/nfc/employee_search
```
All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
## 13. Gotchas
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`.
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present.
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.
- If CSS/assets change, bump `__manifest__.py` version so Odoo rebuilds bundles.
## 14. Tests
Tests are post-install tagged:
```
@tagged('-at_install', 'post_install', 'fusion_clock')
```
Coverage currently focuses on NFC:
- `tests/test_nfc_models.py`: employee UID uniqueness, attendance NFC source/photo fields, company kiosk location field.
- `tests/test_clock_nfc_kiosk.py`: kiosk page gating, UID normalization, enroll endpoint, tap happy path, tap errors, photo-required handling, employee search.
Run locally:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --test-tags fusion_clock --stop-after-init
```
For a normal module upgrade:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init
```

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.3.0',
'version': '19.0.3.5.6',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_correction_views.xml',
'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml',
'views/clock_schedule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml',
'views/clock_menus.xml',
@@ -89,15 +90,22 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
],
'web.assets_backend': [
'fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss',
'fusion_clock/static/src/scss/fusion_clock_shift_planner.scss',
'fusion_clock/static/src/scss/fusion_clock.scss',
'fusion_clock/static/src/js/fusion_clock_systray.js',
'fusion_clock/static/src/xml/systray_clock.xml',
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
'fusion_clock/static/src/js/fusion_clock_shift_planner.js',
'fusion_clock/static/src/xml/fusion_clock_shift_planner.xml',
'fusion_clock/static/src/js/fusion_clock_location_map.js',
'fusion_clock/static/src/js/fusion_clock_location_places.js',
'fusion_clock/static/src/xml/fusion_clock_location.xml',
],
'web.assets_web_dark': [
'fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss',
],
},
'installable': True,
'auto_install': False,

Binary file not shown.

View File

@@ -4,3 +4,4 @@ from . import portal_clock
from . import clock_api
from . import clock_kiosk
from . import clock_nfc_kiosk
from . import shift_planner

View File

@@ -5,6 +5,7 @@
import base64
import math
import logging
import pytz
from datetime import datetime, timedelta
from odoo import http, fields, _
from odoo.http import request
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
@@ -161,7 +165,16 @@ class FusionClockAPI(http.Controller):
worked = attendance.worked_hours or 0.0
if worked >= threshold:
break_min = employee._get_fclk_break_minutes()
local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = {
'latitude': latitude,
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
source=source,
)
if is_scheduled_off:
self._log_activity(
employee, 'unscheduled_shift',
f"Clocked in on a scheduled OFF day at {location.name}.",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source=source,
)
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if office_user_id:
request.env['hr.attendance'].sudo()._fclk_notify_office(
office_user_id,
f"Unscheduled Shift: {employee.name}",
f"{employee.name} clocked in on a scheduled OFF day.",
'hr.attendance',
attendance.id,
)
return {
'success': True,
'action': 'clock_in',
'attendance_id': attendance.id,
'check_in': fields.Datetime.to_string(attendance.check_in),
'location_name': location.name,
'location_address': location.address or '',
'message': f'Clocked in at {location.name} (unscheduled shift)',
'streak': employee.x_fclk_ontime_streak,
}
# Check for late clock-in penalty
scheduled_in, _ = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
@@ -359,8 +402,9 @@ class FusionClockAPI(http.Controller):
self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty
_, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
# Log clock-out
self._log_activity(
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
'pending_reason': employee.x_fclk_pending_reason,
'ontime_streak': employee.x_fclk_ontime_streak,
}
local_today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(local_today)
result.update({
'scheduled_shift': day_plan.get('label') or '',
'scheduled_hours': round(day_plan.get('hours') or 0.0, 2),
'scheduled_off': bool(day_plan.get('is_off')),
})
if is_checked_in:
att = request.env['hr.attendance'].sudo().search([
@@ -533,7 +584,6 @@ class FusionClockAPI(http.Controller):
'location_id': att.x_fclk_location_id.id or False,
})
local_today = get_local_today(request.env, employee)
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),

View File

@@ -5,6 +5,7 @@
import logging
from odoo import http, fields, _
from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__)
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = now.date()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = {
'latitude': latitude,
@@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller):
source='kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source='kiosk',
)
else:
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
@@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller):
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',

View File

@@ -8,6 +8,7 @@ import time
import threading
from odoo import fields, http
from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__)
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
@@ -183,7 +184,9 @@ class FusionClockNfcKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = now.date()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = {
'latitude': 0,
@@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller):
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
else:
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
'action': 'clock_in',
@@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",

View File

@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
], limit=1)
# Today stats
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
today = get_local_today(request.env, employee)
today_schedule = employee._get_fclk_day_plan(today)
today_start, _ = get_local_day_boundaries(request.env, today, employee)
today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_in', '>=', today_start),
@@ -109,7 +111,6 @@ class FusionClockPortal(CustomerPortal):
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
# Week stats
today = get_local_today(request.env, employee)
week_start = today - timedelta(days=today.weekday())
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
week_atts = request.env['hr.attendance'].sudo().search([
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
'current_attendance': current_attendance,
'today_hours': round(today_hours, 1),
'week_hours': round(week_hours, 1),
'today_schedule': today_schedule,
'recent_attendances': recent,
'google_maps_key': google_maps_key,
'enable_sounds': enable_sounds,

View File

@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import io
from collections import defaultdict
from datetime import timedelta
from odoo import fields, http, _
from odoo.exceptions import ValidationError
from odoo.http import request
class FusionClockShiftPlanner(http.Controller):
"""Backend JSON-RPC API for the Excel-style weekly shift planner."""
def _check_manager(self):
return request.env.user.has_group('fusion_clock.group_fusion_clock_manager')
def _week_start(self, week_start=None):
date_obj = fields.Date.to_date(week_start) if week_start else fields.Date.today()
return date_obj - timedelta(days=date_obj.weekday())
def _manager_employees(self):
return request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
], order='department_id, name')
def _load_week_data(self, week_start=None):
start = self._week_start(week_start)
days = [start + timedelta(days=i) for i in range(7)]
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
schedules = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', days[-1]),
])
schedule_map = {
(schedule.employee_id.id, schedule.schedule_date): schedule
for schedule in schedules
}
grouped = defaultdict(list)
for employee in employees:
grouped[employee.department_id.id or 0].append(employee)
departments = []
employee_rows = []
for department_id, department_employees in grouped.items():
department = department_employees[0].department_id
departments.append({
'id': department_id,
'name': department.name if department else _('No Department'),
'employee_ids': [emp.id for emp in department_employees],
})
for employee in department_employees:
cells = {}
for day in days:
cells[str(day)] = Schedule.fclk_cell_payload(
employee,
day,
schedule_map.get((employee.id, day)),
)
employee_rows.append({
'id': employee.id,
'name': employee.name,
'department_id': department_id,
'department_name': department.name if department else _('No Department'),
'job_title': employee.job_title or '',
'cells': cells,
})
shifts = request.env['fusion.clock.shift'].sudo().search([
('active', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
], order='sequence, name')
return {
'week_start': str(start),
'week_end': str(days[-1]),
'days': [{
'date': str(day),
'weekday': day.strftime('%a').upper(),
'label': day.strftime('%d-%b'),
} for day in days],
'departments': departments,
'employees': employee_rows,
'shifts': [{
'id': shift.id,
'name': shift.name,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': shift.scheduled_hours,
'hours_display': Schedule.fclk_hours_display(shift.scheduled_hours),
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
'option_label': '%s (%s - %s)' % (
shift.name,
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
} for shift in shifts],
}
@http.route('/fusion_clock/shift_planner/load', type='jsonrpc', auth='user', methods=['POST'])
def load(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
return self._load_week_data(week_start)
@http.route('/fusion_clock/shift_planner/save', type='jsonrpc', auth='user', methods=['POST'])
def save(self, week_start=None, changes=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
employees = self._manager_employees()
employee_map = {employee.id: employee for employee in employees}
Schedule = request.env['fusion.clock.schedule'].sudo()
errors = []
saved = 0
for change in changes or []:
employee_id = int(change.get('employee_id') or 0)
employee = employee_map.get(employee_id)
date_str = change.get('date')
if not employee:
errors.append({
'employee_id': employee_id,
'date': date_str,
'message': 'Employee not found or not allowed.',
})
continue
try:
Schedule.fclk_apply_planner_cell(employee, date_str, change, request.env.user)
saved += 1
except ValidationError as exc:
errors.append({
'employee_id': employee_id,
'date': date_str,
'message': str(exc.args[0] if exc.args else exc),
})
if errors:
return {'success': False, 'saved': saved, 'errors': errors}
return {
'success': True,
'saved': saved,
'data': self._load_week_data(week_start),
}
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
def copy_previous_week(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
start = self._week_start(week_start)
prev_start = start - timedelta(days=7)
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
prev_schedules = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', prev_start),
('schedule_date', '<=', prev_start + timedelta(days=6)),
])
prev_map = {
(schedule.employee_id.id, schedule.schedule_date): schedule
for schedule in prev_schedules
}
before_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
for employee in employees:
for offset in range(7):
source_date = prev_start + timedelta(days=offset)
target_date = start + timedelta(days=offset)
source = prev_map.get((employee.id, source_date))
if not source:
payload = {'input': ''}
elif source.is_off:
payload = {'input': 'OFF'}
elif source.shift_id:
payload = {'shift_id': source.shift_id.id, 'input': source.fclk_display_value()}
else:
payload = {
'input': source.fclk_display_value(),
'start_time': source.start_time,
'end_time': source.end_time,
'break_minutes': source.break_minutes,
}
Schedule.fclk_apply_planner_cell(employee, target_date, payload, request.env.user)
after_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
return {
'success': True,
'changed': after_count - before_count,
'data': self._load_week_data(start),
}
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
def export_xlsx(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
data = self._load_week_data(week_start)
output = io.BytesIO()
import xlsxwriter
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet('Shift Planner')
fmt_day = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#b7dff5', 'border': 1})
fmt_sub = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#d8e9bd', 'border': 1})
fmt_employee = workbook.add_format({'bold': True, 'border': 1})
fmt_shift = workbook.add_format({'border': 1})
fmt_hours = workbook.add_format({'border': 1, 'align': 'center', 'bg_color': '#f5d39b'})
fmt_department = workbook.add_format({'bold': True, 'bg_color': '#eeeeee', 'border': 1})
sheet.set_column(0, 0, 22)
for col in range(1, 15, 2):
sheet.set_column(col, col, 24)
sheet.set_column(col + 1, col + 1, 9)
sheet.write(0, 0, 'EMPLOYEE', fmt_day)
col = 1
for day in data['days']:
sheet.merge_range(0, col, 0, col + 1, day['weekday'], fmt_day)
sheet.merge_range(1, col, 1, col + 1, day['label'], fmt_day)
sheet.write(2, col, 'Shift', fmt_sub)
sheet.write(2, col + 1, 'Hours', fmt_sub)
col += 2
sheet.write(2, 0, 'EMPLOYEE', fmt_sub)
row = 3
employee_by_id = {emp['id']: emp for emp in data['employees']}
for department in data['departments']:
sheet.merge_range(row, 0, row, 14, department['name'], fmt_department)
row += 1
for employee_id in department['employee_ids']:
employee = employee_by_id[employee_id]
sheet.write(row, 0, employee['name'], fmt_employee)
col = 1
for day in data['days']:
cell = employee['cells'][day['date']]
sheet.write(row, col, cell.get('label') or '', fmt_shift)
sheet.write(row, col + 1, cell.get('hours_display') or '0:00', fmt_hours)
col += 2
row += 1
workbook.close()
output.seek(0)
filename = 'shift_planner_%s.xlsx' % data['week_start']
attachment = request.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(output.read()),
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
return {
'success': True,
'attachment_id': attachment.id,
'filename': filename,
'url': '/web/content/%s?download=true' % attachment.id,
}

View File

@@ -9,5 +9,6 @@ from . import res_config_settings
from . import clock_activity_log
from . import clock_leave_request
from . import clock_shift
from . import clock_schedule
from . import clock_correction
from . import res_company

View File

@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'),
('unscheduled_shift', 'Unscheduled Shift'),
('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'),
],
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
'correction_request': 'Correction Request',
'ip_fallback': 'IP Fallback Used',
'streak_milestone': 'Streak Milestone',
'unscheduled_shift': 'Unscheduled Shift',
}
@api.depends('latitude', 'longitude')

View File

@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import re
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class FusionClockSchedule(models.Model):
_name = 'fusion.clock.schedule'
_description = 'Clock Shift Schedule Entry'
_order = 'schedule_date, employee_id'
_rec_name = 'display_name'
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Date',
required=True,
index=True,
)
shift_id = fields.Many2one(
'fusion.clock.shift',
string='Shift Template',
ondelete='set null',
)
is_off = fields.Boolean(
string='Off',
default=False,
index=True,
)
start_time = fields.Float(
string='Start Time',
default=9.0,
)
end_time = fields.Float(
string='End Time',
default=17.0,
)
break_minutes = fields.Float(
string='Break (min)',
default=30.0,
)
planned_hours = fields.Float(
string='Hours',
compute='_compute_planned_hours',
store=True,
)
note = fields.Char(string='Note')
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
store=True,
readonly=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
related='employee_id.department_id',
store=True,
readonly=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
'Only one shift schedule is allowed per employee per day.',
)
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
def _compute_planned_hours(self):
for rec in self:
if rec.is_off:
rec.planned_hours = 0.0
continue
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
date_str = str(rec.schedule_date) if rec.schedule_date else ''
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
def _check_schedule_times(self):
for rec in self:
if rec.break_minutes < 0:
raise ValidationError(_("Break minutes cannot be negative."))
if rec.is_off:
continue
if rec.start_time < 0 or rec.start_time >= 24:
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
if rec.end_time <= 0 or rec.end_time > 24:
raise ValidationError(_("End time must be between 00:01 and 24:00."))
if rec.end_time <= rec.start_time:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
shift_minutes = (rec.end_time - rec.start_time) * 60.0
if rec.break_minutes >= shift_minutes:
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
@api.onchange('shift_id')
def _onchange_shift_id(self):
for rec in self:
if rec.shift_id:
rec.is_off = False
rec.start_time = rec.shift_id.start_time
rec.end_time = rec.shift_id.end_time
rec.break_minutes = rec.shift_id.break_minutes
@api.model
def fclk_float_to_display(self, value):
value = float(value or 0.0)
hour = int(value)
minute = int(round((value - hour) * 60))
if minute == 60:
hour += 1
minute = 0
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
display_hour = hour % 12
if display_hour == 0:
display_hour = 12
return f"{display_hour}:{minute:02d} {suffix}"
def fclk_display_value(self):
self.ensure_one()
if self.is_off:
return 'OFF'
return (
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
)
@api.model
def fclk_hours_display(self, hours):
hours = float(hours or 0.0)
whole = int(hours)
minutes = int(round((hours - whole) * 60))
if minutes == 60:
whole += 1
minutes = 0
return f"{whole}:{minutes:02d}"
@api.model
def _fclk_parse_time_part(self, raw):
text = (raw or '').strip().lower().replace('.', '')
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
if not match:
raise ValidationError(_("Could not understand time '%s'.") % raw)
hour = int(match.group(1))
minute = int(match.group(2) or 0)
meridiem = match.group(3)
if minute < 0 or minute > 59:
raise ValidationError(_("Minutes must be between 00 and 59."))
if meridiem:
if hour < 1 or hour > 12:
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
if meridiem == 'am':
hour = 0 if hour == 12 else hour
else:
hour = 12 if hour == 12 else hour + 12
elif hour > 24:
raise ValidationError(_("Hours must be between 0 and 24."))
return hour + (minute / 60.0)
@api.model
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
text = (input_value or '').strip()
if not text:
return {'clear': True}
if text.upper() == 'OFF':
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
normalized = (
text.replace('', '-')
.replace('', '-')
.replace(' to ', '-')
.replace(' TO ', '-')
)
parts = [p.strip() for p in normalized.split('-', 1)]
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
start = self._fclk_parse_time_part(parts[0])
end = self._fclk_parse_time_part(parts[1])
if end <= start and end + 12 <= 24:
end += 12
if end <= start:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': start,
'end_time': end,
'break_minutes': float(default_break_minutes or 0.0),
}
@api.model
def fclk_values_from_planner_payload(self, payload, employee):
payload = payload or {}
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
if payload.get('is_off'):
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': float(payload.get('start_time') or 0.0),
'end_time': float(payload.get('end_time') or 0.0),
'break_minutes': float(payload.get('break_minutes') or 0.0),
}
shift_id = int(payload.get('shift_id') or 0)
if shift_id:
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
if not shift.exists():
raise ValidationError(_("Selected shift template no longer exists."))
return {
'clear': False,
'shift_id': shift.id,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
}
default_break = employee._get_fclk_break_minutes() if employee else 30.0
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
@api.model
def fclk_snapshot(self, schedule):
if not schedule:
return ''
return schedule.fclk_display_value()
@api.model
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
self = self.sudo()
employee = employee.sudo()
date_obj = fields.Date.to_date(schedule_date)
if not employee.exists() or not date_obj:
raise ValidationError(_("Invalid employee or schedule date."))
existing = self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
old_value = self.fclk_snapshot(existing)
parsed = self.fclk_values_from_planner_payload(payload, employee)
if parsed.get('clear'):
if existing:
existing.unlink()
new_schedule = self.browse()
new_value = ''
else:
vals = {
'employee_id': employee.id,
'schedule_date': date_obj,
'shift_id': parsed.get('shift_id') or False,
'is_off': bool(parsed.get('is_off')),
'start_time': parsed.get('start_time') or 0.0,
'end_time': parsed.get('end_time') or 0.0,
'break_minutes': parsed.get('break_minutes') or 0.0,
'note': payload.get('note') or False,
}
if existing:
existing.write(vals)
new_schedule = existing
else:
new_schedule = self.create(vals)
new_value = new_schedule.fclk_display_value()
if old_value != new_value:
self.env['fusion.clock.schedule.audit'].sudo().create({
'schedule_id': new_schedule.id if new_schedule else False,
'employee_id': employee.id,
'schedule_date': date_obj,
'old_value': old_value,
'new_value': new_value,
'changed_by_id': (user or self.env.user).id,
'changed_at': fields.Datetime.now(),
'company_id': employee.company_id.id,
'department_id': employee.department_id.id,
})
return new_schedule
@api.model
def fclk_cell_payload(self, employee, date_obj, schedule=None):
schedule = schedule or self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
Schedule = self.env['fusion.clock.schedule']
if schedule:
return {
'schedule_id': schedule.id,
'source': 'schedule',
'input': schedule.fclk_display_value(),
'label': schedule.fclk_display_value(),
'is_off': schedule.is_off,
'shift_id': schedule.shift_id.id or False,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
'break_minutes': schedule.break_minutes,
'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'note': schedule.note or '',
}
plan = employee._get_fclk_day_plan(date_obj)
return {
'schedule_id': False,
'source': plan.get('source') or 'fallback',
'input': plan.get('label') or '',
'label': plan.get('label') or '',
'is_off': plan.get('is_off', False),
'shift_id': False,
'start_time': plan.get('start_time') or 0.0,
'end_time': plan.get('end_time') or 0.0,
'break_minutes': plan.get('break_minutes') or 0.0,
'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '',
}
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'
_description = 'Clock Schedule Change Audit'
_order = 'changed_at desc, id desc'
_rec_name = 'display_name'
schedule_id = fields.Many2one(
'fusion.clock.schedule',
string='Schedule',
ondelete='set null',
index=True,
)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Schedule Date',
required=True,
index=True,
)
old_value = fields.Char(string='Old Value')
new_value = fields.Char(string='New Value')
changed_by_id = fields.Many2one(
'res.users',
string='Changed By',
required=True,
ondelete='restrict',
)
changed_at = fields.Datetime(
string='Changed At',
default=fields.Datetime.now,
required=True,
index=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
index=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
index=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
def _compute_display_name(self):
for rec in self:
rec.display_name = "%s - %s: %s -> %s" % (
rec.employee_id.name or '',
rec.schedule_date or '',
rec.old_value or 'blank',
rec.new_value or 'blank',
)

View File

@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
continue
employee = att.employee_id
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
scheduled_hours = daily_threshold
if employee:
local_date = get_local_today(self.env, employee)
if att.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(att.check_in).astimezone(pytz.timezone(tz_name)).date()
scheduled_hours = employee._get_fclk_scheduled_hours(local_date)
net = att.x_fclk_net_hours or 0.0
if net > scheduled_hours:
@@ -264,11 +275,14 @@ class HrAttendance(models.Model):
employee = att.employee_id
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
max_deadline = check_in + timedelta(hours=max_shift)
effective_deadline = min(deadline, max_deadline)
day_plan = employee._get_fclk_day_plan(check_in_date)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
effective_deadline = max_deadline
else:
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
effective_deadline = min(deadline, max_deadline)
if now > effective_deadline:
clock_out_time = min(effective_deadline, now)
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
# Apply break deduction
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
if (att.worked_hours or 0) >= threshold:
break_min = employee._get_fclk_break_minutes()
break_min = employee._get_fclk_break_minutes(check_in_date)
att.sudo().write({'x_fclk_break_minutes': break_min})
att.sudo().message_post(
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
if yesterday.weekday() >= 5:
continue
day_plan = emp._get_fclk_day_plan(yesterday)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
if today.weekday() >= 5:
continue
day_plan = emp._get_fclk_day_plan(today)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
if emp.x_fclk_last_reminder_date == today:
continue

View File

@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
help="Tracks the last date a reminder was sent to avoid duplicates.",
)
def _get_fclk_break_minutes(self):
"""Return effective break minutes for this employee.
Priority: employee override > shift > global setting.
def _get_fclk_schedule_for_date(self, date):
"""Return this employee's dated Fusion Clock schedule for a local date."""
self.ensure_one()
date_obj = fields.Date.to_date(date)
if not date_obj:
return self.env['fusion.clock.schedule']
return self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.id),
('schedule_date', '=', date_obj),
], limit=1)
def _get_fclk_day_plan(self, date):
"""Return the effective plan for a local date.
Dated schedules are the source of truth. If none exists, the legacy
employee shift/global settings remain the fallback.
"""
self.ensure_one()
Schedule = self.env['fusion.clock.schedule'].sudo()
schedule = self._get_fclk_schedule_for_date(date)
if schedule:
return {
'source': 'schedule',
'schedule_id': schedule.id,
'is_off': schedule.is_off,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
'break_minutes': schedule.break_minutes,
'hours': schedule.planned_hours,
'label': schedule.fclk_display_value(),
}
if self.x_fclk_shift_id:
shift = self.x_fclk_shift_id
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'schedule_id': False,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': hours,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
}
ICP = self.env['ir.config_parameter'].sudo()
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'schedule_id': False,
'is_off': False,
'start_time': start_time,
'end_time': end_time,
'break_minutes': break_minutes,
'hours': hours,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(start_time),
Schedule.fclk_float_to_display(end_time),
),
}
def _get_fclk_break_minutes(self, date=None):
"""Return effective break minutes for this employee.
Priority: dated schedule > employee override > shift > global setting.
"""
self.ensure_one()
if date:
plan = self._get_fclk_day_plan(date)
if plan.get('source') == 'schedule' and not plan.get('is_off'):
return plan.get('break_minutes') or 0.0
if self.x_fclk_break_minutes > 0:
return self.x_fclk_break_minutes
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
@@ -138,7 +209,7 @@ class HrEmployee(models.Model):
def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
Uses employee shift if assigned, otherwise global settings.
Uses dated schedule first, employee shift second, then global settings.
The configured hours are interpreted in the employee's local
timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``.
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
import pytz
self.ensure_one()
if self.x_fclk_shift_id:
in_hour = self.x_fclk_shift_id.start_time
out_hour = self.x_fclk_shift_id.end_time
else:
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
plan = self._get_fclk_day_plan(date)
in_hour = plan.get('start_time') or 0.0
out_hour = plan.get('end_time') or 0.0
in_h = int(in_hour)
in_m = int((in_hour - in_h) * 60)
@@ -179,16 +246,13 @@ class HrEmployee(models.Model):
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
return scheduled_in, scheduled_out
def _get_fclk_scheduled_hours(self):
def _get_fclk_scheduled_hours(self, date=None):
"""Return the expected work hours for this employee's shift."""
self.ensure_one()
if self.x_fclk_shift_id:
return self.x_fclk_shift_id.scheduled_hours
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_hrs = self._get_fclk_break_minutes() / 60.0
return max((out_hour - in_hour) - break_hrs, 0.0)
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
if plan.get('is_off'):
return 0.0
return plan.get('hours') or 0.0
def _compute_absence_counts(self):
ActivityLog = self.env['fusion.clock.activity.log'].sudo()

View File

@@ -11,6 +11,9 @@ access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fus
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_schedule_user,fusion.clock.schedule.user,model_fusion_clock_schedule,group_fusion_clock_user,1,0,0,0
access_fusion_clock_schedule_manager,fusion.clock.schedule.manager,model_fusion_clock_schedule,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_schedule_audit_manager,fusion.clock.schedule.audit.manager,model_fusion_clock_schedule_audit,group_fusion_clock_manager,1,0,0,0
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
@@ -22,4 +25,5 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fusion_clock_leave_request_manager fusion.clock.leave.request.manager model_fusion_clock_leave_request group_fusion_clock_manager 1 1 1 1
12 access_fusion_clock_shift_user fusion.clock.shift.user model_fusion_clock_shift group_fusion_clock_user 1 0 0 0
13 access_fusion_clock_shift_manager fusion.clock.shift.manager model_fusion_clock_shift group_fusion_clock_manager 1 1 1 1
14 access_fusion_clock_schedule_user fusion.clock.schedule.user model_fusion_clock_schedule group_fusion_clock_user 1 0 0 0
15 access_fusion_clock_schedule_manager fusion.clock.schedule.manager model_fusion_clock_schedule group_fusion_clock_manager 1 1 1 1
16 access_fusion_clock_schedule_audit_manager fusion.clock.schedule.audit.manager model_fusion_clock_schedule_audit group_fusion_clock_manager 1 0 0 0
17 access_fusion_clock_correction_user fusion.clock.correction.user model_fusion_clock_correction group_fusion_clock_user 1 0 0 0
18 access_fusion_clock_correction_manager fusion.clock.correction.manager model_fusion_clock_correction group_fusion_clock_manager 1 1 1 1
19 access_fusion_clock_location_portal fusion.clock.location.portal model_fusion_clock_location base.group_portal 1 0 0 0
25 access_hr_attendance_portal hr.attendance.portal hr_attendance.model_hr_attendance base.group_portal 1 0 0 0
26 access_hr_employee_portal_clock hr.employee.portal.clock hr.model_hr_employee base.group_portal 1 0 0 0
27 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 access_fusion_clock_nfc_enrollment_wizard_manager fusion.clock.nfc.enrollment.wizard.manager model_fusion_clock_nfc_enrollment_wizard group_fusion_clock_manager 1 1 1 1

View File

@@ -174,6 +174,49 @@
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<!-- ================================================================
Record Rules - Dated Schedules
================================================================ -->
<record id="rule_schedule_user" model="ir.rule">
<field name="name">Schedule: User sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_team_lead" model="ir.rule">
<field name="name">Schedule: Team Lead sees direct reports</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_manager" model="ir.rule">
<field name="name">Schedule: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<record id="rule_schedule_audit_manager" model="ir.rule">
<field name="name">Schedule Audit: Manager reads all</field>
<field name="model_id" ref="model_fusion_clock_schedule_audit"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================
Record Rules - Correction Request
================================================================ -->
@@ -286,4 +329,15 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_portal" model="ir.rule">
<field name="name">Schedule: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>

View File

@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
opacity: 0.5;
}
/* ---- Scheduled Shift Card ---- */
.fclk-schedule-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 14px;
padding: 14px 16px;
margin: -14px 0 28px;
box-shadow: var(--fclk-shadow);
}
.fclk-schedule-icon {
width: 38px;
height: 38px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(59, 130, 246, 0.12);
color: var(--fclk-blue);
font-size: 16px;
}
.fclk-schedule-info {
min-width: 0;
flex: 1;
}
.fclk-schedule-label {
color: var(--fclk-text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fclk-schedule-value {
color: var(--fclk-text);
font-size: 14px;
font-weight: 650;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fclk-schedule-hours {
color: var(--fclk-text);
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ---- Timer Section ---- */
.fclk-timer-section {
text-align: center;

View File

@@ -0,0 +1,741 @@
/** @odoo-module **/
import { Component, onPatched, onWillStart, useExternalListener, useRef, useState } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class FusionClockShiftPlanner extends Component {
static template = "fusion_clock.ShiftPlanner";
static props = [];
setup() {
this.notification = useService("notification");
this.dirtyCells = {};
this.root = useRef("root");
this.editorRef = useRef("shiftEditor");
this.activeCellAnchor = null;
this.activeEditorEmployee = null;
this.activeEditorDay = null;
this.timeOptions = this._buildTimeOptions();
this.state = useState({
loading: true,
saving: false,
weekStart: "",
weekEnd: "",
days: [],
departments: [],
employees: [],
shifts: [],
error: "",
dirtyCount: 0,
invalidCount: 0,
collapsed: {},
editor: {
open: false,
employeeId: false,
employeeName: "",
date: "",
dayLabel: "",
startValue: "9.00",
endValue: "17.00",
breakMinutes: 30,
hoursDisplay: "7:30",
error: "",
top: 0,
left: 0,
},
});
onWillStart(async () => {
await this.loadWeek();
});
useExternalListener(
window,
"click",
(ev) => this.onGlobalClick(ev),
{ capture: true }
);
useExternalListener(window, "resize", () => this._positionActiveEditor());
useExternalListener(window, "scroll", () => this._positionActiveEditor(), true);
onPatched(() => {
this._positionActiveEditor();
});
}
async loadWeek(weekStart = null) {
this.state.loading = true;
this.state.error = "";
try {
const data = await rpc("/fusion_clock/shift_planner/load", { week_start: weekStart });
if (data.error) {
this.state.error = data.error;
} else {
this._applyData(data);
}
} catch (error) {
this.state.error = error.message || "Failed to load shift planner.";
}
this.state.loading = false;
}
_applyData(data) {
this.dirtyCells = {};
this.state.weekStart = data.week_start;
this.state.weekEnd = data.week_end;
this.state.days = data.days || [];
this.state.departments = data.departments || [];
this.state.employees = data.employees || [];
this.state.shifts = data.shifts || [];
this.state.dirtyCount = 0;
this.state.invalidCount = 0;
this.state.error = "";
this.closeCellEditor();
}
get weekTitle() {
if (!this.state.weekStart || !this.state.weekEnd) {
return "";
}
return `${this.state.weekStart} to ${this.state.weekEnd}`;
}
getDepartmentEmployees(department) {
const ids = new Set(department.employee_ids || []);
return this.state.employees.filter((employee) => ids.has(employee.id));
}
isCollapsed(department) {
return !!this.state.collapsed[department.id];
}
toggleDepartment(department) {
this.state.collapsed[department.id] = !this.state.collapsed[department.id];
this.closeCellEditor();
}
async previousWeek() {
await this.loadWeek(this._dateAdd(this.state.weekStart, -7));
}
async nextWeek() {
await this.loadWeek(this._dateAdd(this.state.weekStart, 7));
}
async currentWeek() {
await this.loadWeek();
}
async copyPreviousWeek() {
if (!window.confirm("Copy the previous week into this week? Current saved cells for the week may be replaced.")) {
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/copy_previous_week", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(`Copied previous week (${result.changed || 0} changes).`, { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not copy previous week.", { type: "danger" });
}
this.state.saving = false;
}
async save() {
this._recountInvalid();
if (this.state.invalidCount) {
this.notification.add("Fix invalid shift cells before saving.", { type: "danger" });
return;
}
const changes = Object.values(this.dirtyCells);
if (!changes.length) {
this.notification.add("No shift changes to save.", { type: "info" });
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/save", {
week_start: this.state.weekStart,
changes,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else if (!result.success) {
this._markServerErrors(result.errors || []);
this.notification.add("Some shift cells could not be saved.", { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(`Saved ${result.saved || 0} shift changes.`, { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not save shift planner.", { type: "danger" });
}
this.state.saving = false;
}
async exportXlsx() {
try {
const result = await rpc("/fusion_clock/shift_planner/export_xlsx", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
return;
}
window.location = result.url;
} catch (error) {
this.notification.add(error.message || "Could not export shift planner.", { type: "danger" });
}
}
openCellEditor(employee, day, ev) {
if (this.state.loading || this.state.saving) {
return;
}
const anchor = ev.currentTarget.closest(".fclk-planner__shift-cell") || ev.currentTarget;
this.activeCellAnchor = anchor;
this.activeEditorEmployee = employee;
this.activeEditorDay = day;
const cell = employee.cells[day.date] || {};
const fallback = this._defaultTimes(employee, day);
const start = cell.is_off ? fallback.start : (cell.start_time || fallback.start);
const end = cell.is_off ? fallback.end : (cell.end_time || fallback.end);
const breakMinutes = cell.is_off ? 0 : (cell.break_minutes || fallback.breakMinutes || 30);
const hours = cell.is_off ? 0 : Math.max(end - start - breakMinutes / 60, 0);
this.state.editor.open = true;
this.state.editor.employeeId = employee.id;
this.state.editor.employeeName = employee.name;
this.state.editor.date = day.date;
this.state.editor.dayLabel = `${day.weekday} ${day.label}`;
this.state.editor.startValue = this._timeValue(start);
this.state.editor.endValue = this._timeValue(end);
this.state.editor.breakMinutes = breakMinutes;
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
this.state.editor.error = cell.error || "";
this._positionActiveEditor(anchor);
}
closeCellEditor() {
this.state.editor.open = false;
this.activeCellAnchor = null;
this.activeEditorEmployee = null;
this.activeEditorDay = null;
}
onGlobalClick(ev) {
if (!this.state.editor.open) {
return;
}
const target = ev.target;
const clickedEditor = this.editorRef.el && this.editorRef.el.contains(target);
const clickedCell = this.activeCellAnchor && this.activeCellAnchor.contains(target);
if (!clickedEditor && !clickedCell) {
this.closeCellEditor();
}
}
isActiveCell(employee, day) {
return this.state.editor.open
&& this.state.editor.employeeId === employee.id
&& this.state.editor.date === day.date;
}
onCellInput(employee, day, ev) {
this._setCellFromInput(employee, day, ev.target.value, ev.target);
}
onCellKeydown(employee, day, ev) {
if (ev.key === "Escape") {
ev.preventDefault();
this.closeCellEditor();
return;
}
if (ev.key === "Tab") {
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
this.closeCellEditor();
return;
}
if (ev.key === "Enter") {
ev.preventDefault();
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
if (!employee.cells[day.date]?.error) {
this.closeCellEditor();
this._focusRelativeCell(ev.currentTarget, ev.shiftKey ? -this.state.days.length : this.state.days.length);
}
}
}
selectQuickShift(option) {
const context = this._activeEditorContext();
if (!context) {
return;
}
let parsed;
if (option.type === "template") {
parsed = {
is_off: false,
shift_id: option.shiftId,
start_time: option.start,
end_time: option.end,
break_minutes: option.breakMinutes,
hours: option.hours,
hours_display: option.hoursDisplay,
label: option.input,
normalized_input: option.input,
};
} else {
parsed = this._parseInput(option.input, context.cell);
}
this._applyParsedToCell(context.employee, context.day, parsed, option.input);
this._syncEditorFromCell(context.employee, context.day);
this.closeCellEditor();
}
clearActiveCell() {
const context = this._activeEditorContext();
if (!context) {
return;
}
this._setCellFromInput(context.employee, context.day, "");
this.closeCellEditor();
}
onEditorStartChange(ev) {
this.state.editor.startValue = ev.target.value;
this.applyEditorRange(false);
}
onEditorEndChange(ev) {
this.state.editor.endValue = ev.target.value;
this.applyEditorRange(false);
}
applyEditorRange(close = true) {
const context = this._activeEditorContext();
if (!context) {
return;
}
const start = Number(this.state.editor.startValue);
let end = Number(this.state.editor.endValue);
if (end <= start) {
end = Math.min(start + 0.5, 24);
this.state.editor.endValue = this._timeValue(end);
}
const parsed = this._rangeToParsed(start, end, this.state.editor.breakMinutes || 0);
if (parsed.error) {
context.cell.error = parsed.error;
this.state.editor.error = parsed.error;
} else {
this._applyParsedToCell(context.employee, context.day, parsed, parsed.label);
this._syncEditorFromCell(context.employee, context.day);
}
this._recountInvalid();
if (close && !parsed.error) {
this.closeCellEditor();
}
}
_setCellFromInput(employee, day, input, target = null) {
const cell = employee.cells[day.date];
cell.input = input;
const parsed = this._parseInput(input, cell);
this._applyParsedToCell(employee, day, parsed, input);
if (!parsed.error && target && parsed.normalized_input !== undefined) {
target.value = parsed.normalized_input;
}
this._syncEditorFromCell(employee, day);
}
_applyParsedToCell(employee, day, parsed, input) {
const cell = employee.cells[day.date];
cell.error = parsed.error || "";
if (parsed.error) {
cell.input = input;
this.state.editor.error = parsed.error;
this._markDirty(employee, day);
this._recountInvalid();
return;
}
cell.is_off = parsed.is_off || false;
cell.shift_id = parsed.shift_id || false;
cell.start_time = parsed.start_time || 0;
cell.end_time = parsed.end_time || 0;
cell.break_minutes = parsed.break_minutes || 0;
cell.hours = parsed.hours || 0;
cell.hours_display = parsed.hours_display || "0:00";
cell.label = parsed.label || "";
cell.input = parsed.normalized_input !== undefined ? parsed.normalized_input : input;
this.state.editor.error = "";
this._markDirty(employee, day);
this._recountInvalid();
}
_markDirty(employee, day) {
const cell = employee.cells[day.date];
const key = `${employee.id}:${day.date}`;
const payload = {
employee_id: employee.id,
date: day.date,
input: cell.input,
shift_id: cell.shift_id || false,
note: cell.note || "",
};
if ((cell.input || "").trim()) {
payload.is_off = !!cell.is_off;
payload.start_time = cell.start_time || 0;
payload.end_time = cell.end_time || 0;
payload.break_minutes = cell.break_minutes || 0;
}
this.dirtyCells[key] = payload;
this.state.dirtyCount = Object.keys(this.dirtyCells).length;
}
_markServerErrors(errors) {
for (const error of errors) {
const employee = this.state.employees.find((emp) => emp.id === error.employee_id);
const cell = employee && employee.cells[error.date];
if (cell) {
cell.error = error.message;
}
}
this._recountInvalid();
}
_recountInvalid() {
let invalid = 0;
for (const employee of this.state.employees) {
for (const day of this.state.days) {
if (employee.cells[day.date]?.error) {
invalid++;
}
}
}
this.state.invalidCount = invalid;
}
_parseInput(value, currentCell = {}) {
const text = (value || "").trim();
if (!text) {
return {
is_off: false,
shift_id: false,
start_time: 0,
end_time: 0,
break_minutes: 0,
label: "",
hours: 0,
hours_display: "0:00",
normalized_input: "",
};
}
if (text.toUpperCase() === "OFF") {
return {
is_off: true,
shift_id: false,
start_time: 0,
end_time: 0,
break_minutes: 0,
hours: 0,
hours_display: "0:00",
label: "OFF",
normalized_input: "OFF",
};
}
const lowerText = text.toLowerCase();
const template = this.state.shifts.find((shift) =>
[shift.option_label, shift.label, shift.name].some((value) => (value || "").toLowerCase() === lowerText)
);
if (template) {
return {
is_off: false,
shift_id: template.id,
start_time: template.start_time,
end_time: template.end_time,
break_minutes: template.break_minutes,
hours: template.hours,
hours_display: template.hours_display,
label: template.label,
normalized_input: template.label,
};
}
try {
const parsed = this._parseTypedShift(text, currentCell);
return parsed;
} catch (error) {
return { error: error.message };
}
}
_parseTypedShift(value, currentCell = {}) {
const normalized = value.replaceAll("", "-").replaceAll("—", "-").replace(/\s+to\s+/i, "-");
const parts = normalized.split("-");
if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
throw new Error("Use 9-5, 9:00-5:30, 9:00 am - 5:30 pm, or OFF.");
}
const start = this._parseTimePart(parts[0]);
let end = this._parseTimePart(parts[1]);
if (end <= start && end + 12 <= 24) {
end += 12;
}
if (end <= start) {
throw new Error("End must be after start.");
}
const breakMinutes = currentCell.break_minutes || 30;
const hours = Math.max(end - start - breakMinutes / 60, 0);
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
return {
is_off: false,
shift_id: false,
start_time: start,
end_time: end,
break_minutes: breakMinutes,
hours,
hours_display: this._formatHours(hours),
label,
normalized_input: label,
};
}
_rangeToParsed(start, end, breakMinutes) {
if (Number.isNaN(start) || Number.isNaN(end)) {
return { error: "Choose a start and end time." };
}
if (end <= start) {
return { error: "End must be after start." };
}
const hours = Math.max(end - start - breakMinutes / 60, 0);
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
return {
is_off: false,
shift_id: false,
start_time: start,
end_time: end,
break_minutes: breakMinutes,
hours,
hours_display: this._formatHours(hours),
label,
normalized_input: label,
};
}
_parseTimePart(raw) {
const text = raw.trim().toLowerCase().replaceAll(".", "");
const match = text.match(/^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$/);
if (!match) {
throw new Error(`Could not read "${raw.trim()}".`);
}
let hour = Number(match[1]);
const minute = Number(match[2] || 0);
const meridiem = match[3];
if (minute < 0 || minute > 59) {
throw new Error("Minutes must be 00-59.");
}
if (meridiem) {
if (hour < 1 || hour > 12) {
throw new Error("Use 1-12 with am/pm.");
}
if (meridiem === "am") {
hour = hour === 12 ? 0 : hour;
} else {
hour = hour === 12 ? 12 : hour + 12;
}
}
if (hour < 0 || hour > 24) {
throw new Error("Hours must be 0-24.");
}
return hour + minute / 60;
}
_formatFloatTime(value) {
let hour = Math.floor(value);
let minute = Math.round((value - hour) * 60);
if (minute === 60) {
hour += 1;
minute = 0;
}
const suffix = hour < 12 || hour === 24 ? "am" : "pm";
let displayHour = hour % 12;
if (displayHour === 0) {
displayHour = 12;
}
return `${displayHour}:${String(minute).padStart(2, "0")} ${suffix}`;
}
_formatHours(value) {
let hour = Math.floor(value);
let minute = Math.round((value - hour) * 60);
if (minute === 60) {
hour += 1;
minute = 0;
}
return `${hour}:${String(minute).padStart(2, "0")}`;
}
_timeValue(value) {
const rounded = Math.round(Number(value || 0) * 4) / 4;
return rounded.toFixed(2);
}
_buildTimeOptions() {
const options = [];
for (let minutes = 0; minutes <= 24 * 60; minutes += 15) {
const value = minutes / 60;
options.push({
value: this._timeValue(value),
label: this._formatFloatTime(value),
});
}
return options;
}
_defaultTimes(employee, day) {
const dayIndex = this.state.days.findIndex((item) => item.date === day.date);
if (dayIndex > 0) {
const previousDay = this.state.days[dayIndex - 1];
const previousCell = employee.cells[previousDay.date];
if (previousCell && !previousCell.is_off && previousCell.start_time && previousCell.end_time) {
return {
start: previousCell.start_time,
end: previousCell.end_time,
breakMinutes: previousCell.break_minutes || 30,
};
}
}
const firstShift = this.state.shifts[0];
if (firstShift) {
return {
start: firstShift.start_time,
end: firstShift.end_time,
breakMinutes: firstShift.break_minutes || 30,
};
}
return { start: 9, end: 17, breakMinutes: 30 };
}
get quickShiftOptions() {
const options = [{
key: "off",
type: "input",
input: "OFF",
label: "OFF",
detail: "0:00",
}];
const seen = new Set(["OFF"]);
for (const shift of this.state.shifts) {
if (seen.has(shift.label)) {
continue;
}
seen.add(shift.label);
options.push({
key: `shift-${shift.id}`,
type: "template",
shiftId: shift.id,
input: shift.label,
label: shift.name || shift.label,
detail: `${shift.label} - ${shift.hours_display}`,
start: shift.start_time,
end: shift.end_time,
breakMinutes: shift.break_minutes,
hours: shift.hours,
hoursDisplay: shift.hours_display,
});
}
for (const input of ["9:00 am - 5:00 pm", "7:00 am - 3:30 pm", "8:00 am - 4:30 pm", "11:00 am - 7:30 pm", "12:00 pm - 8:30 pm"]) {
if (seen.has(input)) {
continue;
}
const parsed = this._parseInput(input, { break_minutes: 30 });
seen.add(input);
options.push({
key: `common-${input}`,
type: "input",
input,
label: input,
detail: parsed.hours_display || "0:00",
});
}
return options.slice(0, 10);
}
_activeEditorContext() {
if (!this.state.editor.open || !this.activeEditorEmployee || !this.activeEditorDay) {
return null;
}
return {
employee: this.activeEditorEmployee,
day: this.activeEditorDay,
cell: this.activeEditorEmployee.cells[this.activeEditorDay.date],
};
}
_syncEditorFromCell(employee, day) {
if (!this.isActiveCell(employee, day)) {
return;
}
const cell = employee.cells[day.date] || {};
if (!cell.is_off && cell.start_time && cell.end_time) {
this.state.editor.startValue = this._timeValue(cell.start_time);
this.state.editor.endValue = this._timeValue(cell.end_time);
}
this.state.editor.breakMinutes = cell.break_minutes || 0;
this.state.editor.hoursDisplay = cell.hours_display || "0:00";
this.state.editor.error = cell.error || "";
}
_focusRelativeCell(input, offset) {
const inputs = Array.from(document.querySelectorAll(".fclk-planner__shift-input"));
const index = inputs.indexOf(input);
const next = inputs[index + offset];
if (next) {
next.focus();
next.select();
}
}
_positionActiveEditor(anchor = null) {
if (!this.state.editor.open) {
return;
}
const target = anchor || this.activeCellAnchor;
if (!target || !target.isConnected) {
this.closeCellEditor();
return;
}
const rect = target.getBoundingClientRect();
const editorWidth = Math.min(380, window.innerWidth - 16);
const editorHeight = this.editorRef.el?.offsetHeight || 300;
let left = Math.max(8, Math.min(rect.left, window.innerWidth - editorWidth - 8));
let top = rect.bottom + 8;
if (top + editorHeight > window.innerHeight - 8) {
top = Math.max(8, rect.top - editorHeight - 8);
}
left = Math.round(left);
top = Math.round(top);
if (this.state.editor.left !== left) {
this.state.editor.left = left;
}
if (this.state.editor.top !== top) {
this.state.editor.top = top;
}
}
_dateAdd(dateString, days) {
const date = new Date(`${dateString}T12:00:00`);
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
}
registry.category("actions").add("fusion_clock.ShiftPlanner", FusionClockShiftPlanner);

View File

@@ -0,0 +1,77 @@
$o-webclient-color-scheme: bright !default;
$_fclk-planner-page: #f3f4f6;
$_fclk-planner-panel: #eef1f4;
$_fclk-planner-card: #ffffff;
$_fclk-planner-text: #1f2937;
$_fclk-planner-muted: #6b7280;
$_fclk-planner-border: #d8dadd;
$_fclk-planner-border-strong: #9ca3af;
$_fclk-planner-day: #b7dff5;
$_fclk-planner-subhead: #d8e9bd;
$_fclk-planner-hours: #f5d39b;
$_fclk-planner-fallback: #fff8e5;
$_fclk-planner-row-hover: #f9fafb;
$_fclk-planner-error: #dc2626;
$_fclk-planner-focus: #2563eb;
$_fclk-planner-shadow: rgba(15, 23, 42, 0.08);
$_fclk-planner-editor: #111827;
$_fclk-planner-editor-text: #f9fafb;
$_fclk-planner-editor-muted: #cbd5e1;
$_fclk-planner-editor-border: #374151;
$_fclk-planner-editor-control: #ffffff;
$_fclk-planner-editor-control-text: #111827;
$_fclk-planner-editor-chip: #1f2937;
$_fclk-planner-editor-chip-hover: #334155;
@if $o-webclient-color-scheme == dark {
$_fclk-planner-page: #171a1f !global;
$_fclk-planner-panel: #20242b !global;
$_fclk-planner-card: #262b33 !global;
$_fclk-planner-text: #f3f4f6 !global;
$_fclk-planner-muted: #a3aab8 !global;
$_fclk-planner-border: #3b424c !global;
$_fclk-planner-border-strong: #647082 !global;
$_fclk-planner-day: #21465f !global;
$_fclk-planner-subhead: #394b2d !global;
$_fclk-planner-hours: #6f4f22 !global;
$_fclk-planner-fallback: #393326 !global;
$_fclk-planner-row-hover: #2b313a !global;
$_fclk-planner-error: #f87171 !global;
$_fclk-planner-focus: #60a5fa !global;
$_fclk-planner-shadow: rgba(0, 0, 0, 0.32) !global;
$_fclk-planner-editor: #0f172a !global;
$_fclk-planner-editor-text: #f9fafb !global;
$_fclk-planner-editor-muted: #cbd5e1 !global;
$_fclk-planner-editor-border: #475569 !global;
$_fclk-planner-editor-control: #1f2937 !global;
$_fclk-planner-editor-control-text: #f9fafb !global;
$_fclk-planner-editor-chip: #1e293b !global;
$_fclk-planner-editor-chip-hover: #334155 !global;
}
:root {
--fclk-planner-page: #{$_fclk-planner-page};
--fclk-planner-panel: #{$_fclk-planner-panel};
--fclk-planner-card: #{$_fclk-planner-card};
--fclk-planner-text: #{$_fclk-planner-text};
--fclk-planner-muted: #{$_fclk-planner-muted};
--fclk-planner-border: #{$_fclk-planner-border};
--fclk-planner-border-strong: #{$_fclk-planner-border-strong};
--fclk-planner-day: #{$_fclk-planner-day};
--fclk-planner-subhead: #{$_fclk-planner-subhead};
--fclk-planner-hours: #{$_fclk-planner-hours};
--fclk-planner-fallback: #{$_fclk-planner-fallback};
--fclk-planner-row-hover: #{$_fclk-planner-row-hover};
--fclk-planner-error: #{$_fclk-planner-error};
--fclk-planner-focus: #{$_fclk-planner-focus};
--fclk-planner-shadow: #{$_fclk-planner-shadow};
--fclk-planner-editor: #{$_fclk-planner-editor};
--fclk-planner-editor-text: #{$_fclk-planner-editor-text};
--fclk-planner-editor-muted: #{$_fclk-planner-editor-muted};
--fclk-planner-editor-border: #{$_fclk-planner-editor-border};
--fclk-planner-editor-control: #{$_fclk-planner-editor-control};
--fclk-planner-editor-control-text: #{$_fclk-planner-editor-control-text};
--fclk-planner-editor-chip: #{$_fclk-planner-editor-chip};
--fclk-planner-editor-chip-hover: #{$_fclk-planner-editor-chip-hover};
}

View File

@@ -0,0 +1,25 @@
:root {
--fclk-planner-page: #171a1f;
--fclk-planner-panel: #20242b;
--fclk-planner-card: #262b33;
--fclk-planner-text: #f3f4f6;
--fclk-planner-muted: #a3aab8;
--fclk-planner-border: #3b424c;
--fclk-planner-border-strong: #647082;
--fclk-planner-day: #21465f;
--fclk-planner-subhead: #394b2d;
--fclk-planner-hours: #6f4f22;
--fclk-planner-fallback: #393326;
--fclk-planner-row-hover: #2b313a;
--fclk-planner-error: #f87171;
--fclk-planner-focus: #60a5fa;
--fclk-planner-shadow: rgba(0, 0, 0, 0.32);
--fclk-planner-editor: #0f172a;
--fclk-planner-editor-text: #f9fafb;
--fclk-planner-editor-muted: #cbd5e1;
--fclk-planner-editor-border: #475569;
--fclk-planner-editor-control: #1f2937;
--fclk-planner-editor-control-text: #f9fafb;
--fclk-planner-editor-chip: #1e293b;
--fclk-planner-editor-chip-hover: #334155;
}

View File

@@ -0,0 +1,447 @@
.fclk-planner {
min-height: 100%;
background: var(--fclk-planner-page, #f3f4f6);
color: var(--fclk-planner-text, #1f2937);
display: flex;
flex-direction: column;
}
.fclk-planner__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
background: var(--fclk-planner-card, #ffffff);
border-bottom: 1px solid var(--fclk-planner-border, #d8dadd);
box-shadow: 0 1px 3px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
}
.fclk-planner__title {
margin: 0;
font-size: 20px;
font-weight: 650;
line-height: 1.2;
}
.fclk-planner__subtitle {
color: var(--fclk-planner-muted, #6b7280);
font-size: 13px;
margin-top: 3px;
}
.fclk-planner__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.fclk-planner__warning {
margin: 12px 16px 0;
padding: 10px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 6px;
color: #9a3412;
font-size: 13px;
}
.fclk-planner__loading {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 340px;
color: var(--fclk-planner-muted, #6b7280);
}
.fclk-planner__table-wrap {
flex: 1;
margin: 16px;
overflow: auto;
background: var(--fclk-planner-panel, #eef1f4);
border: 1px solid var(--fclk-planner-border, #d8dadd);
border-radius: 6px;
box-shadow: 0 6px 20px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
}
.fclk-planner__table {
--fclk-planner-shift-width: 135px;
--fclk-planner-hours-width: 55px;
--fclk-planner-days-width: 1330px;
width: 100%;
min-width: 1600px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: var(--fclk-planner-card, #ffffff);
font-size: 13px;
}
.fclk-planner__employee-col {
width: calc(100% - var(--fclk-planner-days-width));
}
.fclk-planner__shift-col {
width: var(--fclk-planner-shift-width);
}
.fclk-planner__hours-col {
width: var(--fclk-planner-hours-width);
}
.fclk-planner__table th,
.fclk-planner__table td {
border-right: 1px solid var(--fclk-planner-border-strong, #9ca3af);
border-bottom: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__employee-head,
.fclk-planner__day-head,
.fclk-planner__sub-head {
position: sticky;
top: 0;
z-index: 6;
color: var(--fclk-planner-text, #1f2937);
}
.fclk-planner__employee-head {
left: 0;
z-index: 8;
width: calc(100% - var(--fclk-planner-days-width));
background: var(--fclk-planner-day, #b7dff5);
text-align: left;
padding: 10px 12px;
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__day-head {
background: var(--fclk-planner-day, #b7dff5);
text-align: center;
padding: 6px 8px;
font-weight: 700;
}
.fclk-planner__sub-head {
top: 47px;
background: var(--fclk-planner-subhead, #d8e9bd);
text-align: left;
padding: 5px 8px;
font-weight: 650;
}
.fclk-planner__hours-head {
width: var(--fclk-planner-hours-width);
text-align: center;
padding-left: 2px;
padding-right: 2px;
}
.fclk-planner__weekday {
font-size: 14px;
line-height: 1.1;
}
.fclk-planner__date {
font-size: 12px;
font-weight: 500;
margin-top: 2px;
}
.fclk-planner__department-row td {
background: var(--fclk-planner-panel, #eef1f4);
padding: 0;
position: sticky;
left: 0;
z-index: 5;
}
.fclk-planner__department-toggle {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: var(--fclk-planner-text, #1f2937);
font-weight: 650;
padding: 7px 12px;
text-align: left;
}
.fclk-planner__department-count {
color: var(--fclk-planner-muted, #6b7280);
font-weight: 500;
font-size: 12px;
}
.fclk-planner__employee-row {
background: var(--fclk-planner-card, #ffffff);
}
.fclk-planner__employee-row:hover {
background: var(--fclk-planner-row-hover, #f9fafb);
}
.fclk-planner__employee-cell {
position: sticky;
left: 0;
z-index: 4;
width: calc(100% - var(--fclk-planner-days-width));
background: inherit;
padding: 8px 12px;
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__employee-name {
font-weight: 650;
line-height: 1.2;
}
.fclk-planner__employee-role {
margin-top: 2px;
color: var(--fclk-planner-muted, #6b7280);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fclk-planner__shift-cell {
width: var(--fclk-planner-shift-width);
min-height: 42px;
padding: 4px;
vertical-align: top;
background: var(--fclk-planner-card, #ffffff);
}
.fclk-planner__shift-cell--fallback {
background: var(--fclk-planner-fallback, #fff8e5);
}
.fclk-planner__shift-cell--error {
background: #fef2f2;
}
.fclk-planner__shift-cell--active {
box-shadow: inset 0 0 0 2px var(--fclk-planner-focus, #2563eb);
}
.fclk-planner__shift-input {
width: 100%;
height: 32px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--fclk-planner-text, #1f2937);
padding: 4px 6px;
font-size: 13px;
line-height: 1.2;
outline: none;
white-space: nowrap;
}
.fclk-planner__shift-input:focus {
background: var(--fclk-planner-card, #ffffff);
border-color: var(--fclk-planner-focus, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.16);
}
.fclk-planner__cell-error {
color: var(--fclk-planner-error, #dc2626);
font-size: 11px;
line-height: 1.2;
padding: 3px 5px 0;
}
.fclk-planner__hours-cell {
width: var(--fclk-planner-hours-width);
background: var(--fclk-planner-hours, #f5d39b);
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 650;
vertical-align: middle;
padding: 6px 2px;
}
.fclk-planner__cell-editor {
position: fixed;
z-index: 1080;
width: calc(100vw - 16px);
max-width: 380px;
padding: 14px;
color: var(--fclk-planner-editor-text, #f9fafb);
background: var(--fclk-planner-editor, #111827);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 8px;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.32);
}
.fclk-planner__cell-editor::before {
content: "";
position: absolute;
top: -7px;
left: 28px;
width: 14px;
height: 14px;
background: var(--fclk-planner-editor, #111827);
border-left: 1px solid var(--fclk-planner-editor-border, #374151);
border-top: 1px solid var(--fclk-planner-editor-border, #374151);
transform: rotate(45deg);
}
.fclk-planner__editor-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.fclk-planner__editor-name {
font-size: 14px;
font-weight: 700;
line-height: 1.2;
}
.fclk-planner__editor-day {
margin-top: 2px;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 12px;
}
.fclk-planner__editor-hours {
min-width: 56px;
padding: 5px 8px;
text-align: center;
color: #111827;
background: var(--fclk-planner-hours, #f5d39b);
border-radius: 6px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.fclk-planner__quick-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.fclk-planner__quick-chip {
min-height: 46px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
padding: 7px 9px;
color: var(--fclk-planner-editor-text, #f9fafb);
background: var(--fclk-planner-editor-chip, #1f2937);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 6px;
text-align: left;
}
.fclk-planner__quick-chip:hover,
.fclk-planner__quick-chip:focus {
background: var(--fclk-planner-editor-chip-hover, #334155);
outline: none;
}
.fclk-planner__quick-label {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
line-height: 1.15;
}
.fclk-planner__quick-detail {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 11px;
line-height: 1.15;
}
.fclk-planner__time-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.fclk-planner__time-field {
display: flex;
flex-direction: column;
gap: 5px;
margin: 0;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 12px;
font-weight: 650;
}
.fclk-planner__time-field select {
width: 100%;
height: 34px;
color: var(--fclk-planner-editor-control-text, #111827);
background: var(--fclk-planner-editor-control, #ffffff);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
}
.fclk-planner__editor-error {
position: relative;
z-index: 1;
margin-top: 10px;
padding: 7px 8px;
color: #991b1b;
background: #fee2e2;
border-radius: 6px;
font-size: 12px;
line-height: 1.25;
}
.fclk-planner__editor-actions {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
@media (max-width: 900px) {
.fclk-planner__toolbar {
align-items: flex-start;
flex-direction: column;
}
.fclk-planner__actions {
justify-content: flex-start;
}
.fclk-planner__table-wrap {
margin: 10px;
}
.fclk-planner__cell-editor {
width: calc(100vw - 16px);
}
}

View File

@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock.ShiftPlanner">
<div class="o_action fclk-planner" t-ref="root">
<div class="fclk-planner__toolbar">
<div>
<h2 class="fclk-planner__title">Shift Planner</h2>
<div class="fclk-planner__subtitle"><t t-esc="weekTitle"/></div>
</div>
<div class="fclk-planner__actions">
<button class="btn btn-light" t-on-click="() => this.previousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-left"/>
</button>
<button class="btn btn-light" t-on-click="() => this.currentWeek()" t-att-disabled="state.loading or state.saving">This Week</button>
<button class="btn btn-light" t-on-click="() => this.nextWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-right"/>
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.copyPreviousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-copy me-1"/> Copy Previous Week
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.exportXlsx()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-file-excel-o me-1"/> Export XLSX
</button>
<button class="btn btn-primary" t-on-click="() => this.save()" t-att-disabled="state.loading or state.saving or !state.dirtyCount">
<t t-if="state.saving"><i class="fa fa-spinner fa-spin me-1"/></t>
<t t-else=""><i class="fa fa-save me-1"/></t>
Save
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
</button>
</div>
</div>
<t t-if="state.error">
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
</t>
<t t-if="state.invalidCount">
<div class="fclk-planner__warning">
<i class="fa fa-exclamation-triangle me-1"/>
<t t-esc="state.invalidCount"/> invalid cells need attention.
</div>
</t>
<t t-if="state.loading">
<div class="fclk-planner__loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<span>Loading shift planner...</span>
</div>
</t>
<t t-if="!state.loading and !state.error">
<div class="fclk-planner__table-wrap">
<table class="fclk-planner__table">
<colgroup>
<col class="fclk-planner__employee-col"/>
<t t-foreach="state.days" t-as="day" t-key="'col_' + day.date">
<col class="fclk-planner__shift-col"/>
<col class="fclk-planner__hours-col"/>
</t>
</colgroup>
<thead>
<tr>
<th class="fclk-planner__employee-head" rowspan="2">Employee</th>
<t t-foreach="state.days" t-as="day" t-key="day.date">
<th class="fclk-planner__day-head" colspan="2">
<div class="fclk-planner__weekday"><t t-esc="day.weekday"/></div>
<div class="fclk-planner__date"><t t-esc="day.label"/></div>
</th>
</t>
</tr>
<tr>
<t t-foreach="state.days" t-as="day" t-key="'sub_' + day.date">
<th class="fclk-planner__sub-head">Shift</th>
<th class="fclk-planner__sub-head fclk-planner__hours-head">Hours</th>
</t>
</tr>
</thead>
<tbody>
<t t-foreach="state.departments" t-as="department" t-key="department.id">
<tr class="fclk-planner__department-row">
<td t-att-colspan="1 + state.days.length * 2">
<button class="fclk-planner__department-toggle" t-on-click="() => this.toggleDepartment(department)">
<i t-att-class="isCollapsed(department) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
<span><t t-esc="department.name"/></span>
<span class="fclk-planner__department-count">
<t t-esc="department.employee_ids.length"/> employees
</span>
</button>
</td>
</tr>
<t t-if="!isCollapsed(department)">
<t t-foreach="getDepartmentEmployees(department)" t-as="employee" t-key="employee.id">
<tr class="fclk-planner__employee-row">
<td class="fclk-planner__employee-cell">
<div class="fclk-planner__employee-name"><t t-esc="employee.name"/></div>
<div class="fclk-planner__employee-role" t-if="employee.job_title">
<t t-esc="employee.job_title"/>
</div>
</td>
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
<t t-set="cell" t-value="employee.cells[day.date]"/>
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
<input class="fclk-planner__shift-input"
t-att-value="cell.input"
t-att-title="cell.error || cell.label"
t-on-focus="(ev) => this.openCellEditor(employee, day, ev)"
t-on-change="(ev) => this.onCellInput(employee, day, ev)"
t-on-keydown="(ev) => this.onCellKeydown(employee, day, ev)"/>
<div class="fclk-planner__cell-error" t-if="cell.error">
<t t-esc="cell.error"/>
</div>
</td>
<td class="fclk-planner__hours-cell">
<t t-esc="cell.hours_display || '0:00'"/>
</td>
</t>
</tr>
</t>
</t>
</t>
</tbody>
</table>
</div>
<div t-if="state.editor.open"
t-ref="shiftEditor"
class="fclk-planner__cell-editor"
t-att-style="'top: ' + state.editor.top + 'px; left: ' + state.editor.left + 'px;'">
<div class="fclk-planner__editor-head">
<div class="fclk-planner__editor-person">
<div class="fclk-planner__editor-name"><t t-esc="state.editor.employeeName"/></div>
<div class="fclk-planner__editor-day"><t t-esc="state.editor.dayLabel"/></div>
</div>
<div class="fclk-planner__editor-hours">
<span><t t-esc="state.editor.hoursDisplay"/></span>
</div>
</div>
<div class="fclk-planner__quick-grid">
<t t-foreach="quickShiftOptions" t-as="option" t-key="option.key">
<button type="button"
class="fclk-planner__quick-chip"
t-on-click="() => this.selectQuickShift(option)">
<span class="fclk-planner__quick-label"><t t-esc="option.label"/></span>
<span class="fclk-planner__quick-detail"><t t-esc="option.detail"/></span>
</button>
</t>
</div>
<div class="fclk-planner__time-row">
<label class="fclk-planner__time-field">
<span>Start</span>
<select t-on-change="(ev) => this.onEditorStartChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'start_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.startValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
<label class="fclk-planner__time-field">
<span>End</span>
<select t-on-change="(ev) => this.onEditorEndChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'end_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.endValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
</div>
<div class="fclk-planner__editor-error" t-if="state.editor.error">
<t t-esc="state.editor.error"/>
</div>
<div class="fclk-planner__editor-actions">
<button type="button"
class="btn btn-sm btn-light"
t-on-click="() => this.clearActiveCell()">
<i class="fa fa-eraser me-1"/> Clear
</button>
<button type="button"
class="btn btn-sm btn-primary"
t-on-click="() => this.applyEditorRange(true)">
<i class="fa fa-check me-1"/> Done
</button>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -2,3 +2,4 @@
from . import test_nfc_models
from . import test_clock_nfc_kiosk
from . import test_shift_planner

View File

@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
import json
from datetime import date, timedelta
from psycopg2 import IntegrityError
from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tests.common import HttpCase, TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestShiftPlannerModels(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Schedule = cls.env['fusion.clock.schedule'].sudo()
cls.Shift = cls.env['fusion.clock.shift'].sudo()
cls.employee = cls.env['hr.employee'].sudo().create({
'name': 'Planner Model Employee',
'company_id': cls.env.company.id,
'x_fclk_enable_clock': True,
})
cls.default_shift = cls.Shift.create({
'name': 'Default Planner Shift',
'start_time': 8.0,
'end_time': 16.5,
'break_minutes': 30,
'company_id': cls.env.company.id,
})
cls.employee.x_fclk_shift_id = cls.default_shift.id
cls.schedule_date = date(2026, 1, 5)
def test_unique_employee_date_schedule(self):
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'is_off': True,
})
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'is_off': True,
})
def test_off_schedule_has_zero_hours(self):
schedule = self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 6),
'is_off': True,
})
self.assertEqual(schedule.planned_hours, 0)
self.assertEqual(schedule.fclk_display_value(), 'OFF')
def test_working_schedule_computes_hours_minus_break(self):
schedule = self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 7),
'start_time': 9.0,
'end_time': 17.5,
'break_minutes': 30,
})
self.assertEqual(schedule.planned_hours, 8.0)
self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00')
def test_invalid_same_day_range_is_rejected(self):
with self.assertRaises(ValidationError):
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 8),
'start_time': 17.0,
'end_time': 9.0,
'break_minutes': 30,
})
def test_apply_planner_cell_creates_audit(self):
schedule_date = date(2026, 1, 9)
self.Schedule.fclk_apply_planner_cell(
self.employee,
schedule_date,
{'input': '9:00 am - 5:30 pm'},
self.env.user,
)
audit = self.env['fusion.clock.schedule.audit'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', schedule_date),
], limit=1)
self.assertTrue(audit)
self.assertFalse(audit.old_value)
self.assertEqual(audit.new_value, '9:00 am - 5:30 pm')
def test_dated_schedule_overrides_employee_shift_and_fallback_remains(self):
planned_date = date(2026, 1, 12)
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': planned_date,
'start_time': 10.0,
'end_time': 18.0,
'break_minutes': 60,
})
planned = self.employee._get_fclk_day_plan(planned_date)
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
self.assertEqual(planned['source'], 'schedule')
self.assertEqual(planned['start_time'], 10.0)
self.assertEqual(planned['hours'], 7.0)
self.assertEqual(fallback['source'], 'fallback')
self.assertEqual(fallback['start_time'], 8.0)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestShiftPlannerApi(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
manager_group = cls.env.ref('fusion_clock.group_fusion_clock_manager')
user_group = cls.env.ref('fusion_clock.group_fusion_clock_user')
cls.manager_user = cls.env['res.users'].sudo().create({
'name': 'Planner Manager',
'login': 'planner-manager',
'password': 'plannerpass',
'company_id': cls.env.company.id,
'company_ids': [(6, 0, [cls.env.company.id])],
'group_ids': [(6, 0, [manager_group.id])],
})
cls.employee_user = cls.env['res.users'].sudo().create({
'name': 'Planner Employee User',
'login': 'planner-employee-user',
'password': 'plannerpass',
'company_id': cls.env.company.id,
'company_ids': [(6, 0, [cls.env.company.id])],
'group_ids': [(6, 0, [user_group.id])],
'tz': 'UTC',
})
cls.employee = cls.env['hr.employee'].sudo().create({
'name': 'Planner API Employee',
'user_id': cls.employee_user.id,
'company_id': cls.env.company.id,
'x_fclk_enable_clock': True,
})
cls.shift = cls.env['fusion.clock.shift'].sudo().create({
'name': 'API Morning',
'start_time': 7.0,
'end_time': 15.5,
'break_minutes': 30,
'company_id': cls.env.company.id,
})
cls.week_start = '2026-01-19'
def _json_call(self, route, payload, login='planner-manager'):
self.authenticate(login, 'plannerpass')
response = self.url_open(
route,
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_manager_can_load_save_and_export_planner(self):
load_result = self._json_call('/fusion_clock/shift_planner/load', {
'week_start': self.week_start,
})
self.assertIn(self.employee.id, [row['id'] for row in load_result['employees']])
save_result = self._json_call('/fusion_clock/shift_planner/save', {
'week_start': self.week_start,
'changes': [{
'employee_id': self.employee.id,
'date': self.week_start,
'input': '9-5',
'shift_id': False,
}],
})
self.assertTrue(save_result.get('success'))
schedule = self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', fields.Date.to_date(self.week_start)),
], limit=1)
self.assertTrue(schedule)
self.assertEqual(schedule.start_time, 9.0)
self.assertEqual(schedule.end_time, 17.0)
export_result = self._json_call('/fusion_clock/shift_planner/export_xlsx', {
'week_start': self.week_start,
})
self.assertTrue(export_result.get('success'))
self.assertTrue(export_result.get('url', '').startswith('/web/content/'))
self.assertTrue(self.env['ir.attachment'].sudo().browse(export_result['attachment_id']).exists())
def test_copy_previous_week(self):
previous_monday = fields.Date.to_date(self.week_start) - timedelta(days=7)
self.env['fusion.clock.schedule'].sudo().create({
'employee_id': self.employee.id,
'schedule_date': previous_monday,
'shift_id': self.shift.id,
'start_time': self.shift.start_time,
'end_time': self.shift.end_time,
'break_minutes': self.shift.break_minutes,
})
result = self._json_call('/fusion_clock/shift_planner/copy_previous_week', {
'week_start': self.week_start,
})
self.assertTrue(result.get('success'))
copied = self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', fields.Date.to_date(self.week_start)),
], limit=1)
self.assertEqual(copied.shift_id, self.shift)
def test_non_manager_cannot_mutate_planner(self):
result = self._json_call('/fusion_clock/shift_planner/save', {
'week_start': self.week_start,
'changes': [],
}, login='planner-employee-user')
self.assertEqual(result.get('error'), 'Access denied.')
def test_off_day_clock_in_succeeds_and_logs_unscheduled_shift(self):
today = fields.Date.today()
location = self.env['fusion.clock.location'].sudo().create({
'name': 'Planner Test Location',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
'company_id': self.env.company.id,
'all_employees': True,
})
self.env['fusion.clock.schedule'].sudo().create({
'employee_id': self.employee.id,
'schedule_date': today,
'is_off': True,
})
result = self._json_call('/fusion_clock/clock_action', {
'latitude': location.latitude,
'longitude': location.longitude,
'source': 'portal',
}, login='planner-employee-user')
self.assertTrue(result.get('success'))
self.assertEqual(result.get('action'), 'clock_in')
self.assertIn('unscheduled', result.get('message', ''))
log = self.env['fusion.clock.activity.log'].sudo().search([
('employee_id', '=', self.employee.id),
('log_type', '=', 'unscheduled_shift'),
], limit=1)
self.assertTrue(log)

View File

@@ -16,6 +16,34 @@
sequence="5"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Scheduling -->
<menuitem id="menu_fusion_clock_scheduling"
name="Scheduling"
parent="menu_fusion_clock_root"
sequence="8"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_shift_planner"
name="Shift Planner"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_shift_planner"
sequence="5"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_scheduled_shifts"
name="Scheduled Shifts"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule"
sequence="10"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_schedule_audit"
name="Schedule Audit"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule_audit"
sequence="20"
groups="group_fusion_clock_manager"/>
<!-- Attendance Sub-Menu -->
<menuitem id="menu_fusion_clock_attendance"
name="Attendance"

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_fusion_clock_shift_planner" model="ir.actions.client">
<field name="name">Shift Planner</field>
<field name="tag">fusion_clock.ShiftPlanner</field>
</record>
<record id="view_fusion_clock_schedule_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.list</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<list>
<field name="schedule_date"/>
<field name="employee_id"/>
<field name="department_id"/>
<field name="is_off"/>
<field name="shift_id"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.form</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="is_off"/>
<field name="shift_id"/>
</group>
<group>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours" readonly="1"/>
</group>
</group>
<group>
<field name="note"/>
<field name="department_id" readonly="1"/>
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fusion_clock_schedule_search" model="ir.ui.view">
<field name="name">fusion.clock.schedule.search</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<search>
<field name="employee_id"/>
<field name="department_id"/>
<field name="schedule_date"/>
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
</search>
</field>
</record>
<record id="action_fusion_clock_schedule" model="ir.actions.act_window">
<field name="name">Scheduled Shifts</field>
<field name="res_model">fusion.clock.schedule</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_fusion_clock_schedule_audit_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.list</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<list create="0" edit="0" delete="0">
<field name="changed_at"/>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="old_value"/>
<field name="new_value"/>
<field name="changed_by_id"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_audit_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.form</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<form create="0" edit="0" delete="0">
<sheet>
<group>
<group>
<field name="changed_at"/>
<field name="changed_by_id"/>
<field name="employee_id"/>
<field name="schedule_date"/>
</group>
<group>
<field name="old_value"/>
<field name="new_value"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_schedule_audit" model="ir.actions.act_window">
<field name="name">Schedule Audit</field>
<field name="res_model">fusion.clock.schedule.audit</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -142,6 +142,28 @@
</div>
</div>
<!-- Scheduled Shift -->
<div class="fclk-schedule-card">
<div class="fclk-schedule-icon">
<i class="fa fa-calendar-check-o"/>
</div>
<div class="fclk-schedule-info">
<div class="fclk-schedule-label">Today's Shift</div>
<div class="fclk-schedule-value">
<t t-if="today_schedule.get('is_off')">OFF</t>
<t t-else="">
<t t-esc="today_schedule.get('label') or 'Not scheduled'"/>
</t>
</div>
</div>
<div class="fclk-schedule-hours">
<t t-if="today_schedule.get('is_off')">0:00</t>
<t t-else="">
<t t-esc="'%.1f' % (today_schedule.get('hours') or 0.0)"/>h
</t>
</div>
</div>
<!-- Timer Section -->
<div class="fclk-timer-section">
<div class="fclk-timer-label" id="fclk-timer-label">

BIN
fusion_plating/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -2,6 +2,10 @@
# The companion server saves files here; not project source.
.superpowers/
# Claude Code preview-tooling state (launch.json for preview_start,
# throwaway HTML mockups from brainstorming sessions).
.claude/
# Local Odoo dev artifacts
*.pyc
__pycache__/

View File

@@ -166,6 +166,100 @@ These modules have **source code in this repo** but are **intentionally NOT inst
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
## Shop-floor action endpoints — attribution is automatic via `request.env.user`
As of `fusion_plating_shopfloor 19.0.33.1.0` (2026-05-24 Tablet PIN session redesign, Phase G cleanup), tablet writes are attributed via **real per-tech Odoo sessions**, not via a `tablet_tech_id` kwarg.
**How it works now:**
- The tablet browser holds a session as the kiosk user (xmlid `fusion_plating_shopfloor.user_fp_tablet_kiosk`, id 141 on entech) when nobody is unlocked.
- PIN unlock POSTs to `/fp/tablet/unlock_session`, which calls `request.session.authenticate(type='fp_tablet_pin', login, pin)`. The custom `_check_credentials` override on `res.users` validates the PIN hash + `all_group_ids` shop-branch membership and mints a real session AS the tech. Browser cookie swaps.
- Subsequent writes use `request.env.user` (= the tech) automatically. `create_uid` / `write_uid` / chatter authorship are correct with zero plumbing.
- Lock-back (`/fp/tablet/lock_session`) destroys the tech's session and re-auths the browser as the kiosk via the password stored in `ir.config_parameter['fp.tablet.kiosk_password']`.
**When writing a NEW shop-floor controller endpoint that writes:**
1. Use `env = request.env` directly. No `tablet_tech_id` kwarg, no `env_for_tablet_tech` helper.
2. Read-only endpoints — same thing, `request.env` is fine.
**Gone post-Phase-G** — do NOT re-introduce:
- `tablet_tech_id` kwarg on any HTTP route
- `env_for_tablet_tech(...)` helper (the `_tablet_audit.py` file is deleted)
- `fp_shopfloor_tech_store` OWL service (the `services/tech_store.js` file is deleted)
- Legacy `/fp/tablet/unlock` route (the new one is `/fp/tablet/unlock_session`)
- `fp.shopfloor.tablet_session_mode` feature flag (`session_swap` is the only flow; flag was for the 1-week overlap window during rollout — now retired)
**Kiosk password lives in TWO places — keep them in sync:**
The kiosk user's actual `res.users.password` AND `ir.config_parameter['fp.tablet.kiosk_password']` must match. The lock_session endpoint reads ICP to re-auth as kiosk after the tech session is destroyed. If they diverge (e.g. someone resets the password on the user form without updating ICP), lock-back fails and the endpoint returns `needs_kiosk_relogin=True` — the tablet then needs a manual login. Two valid states:
- **Both set to the same value** — kiosk password is plaintext-readable in DB but lock-back works automatically.
- **ICP key deleted entirely** — `DELETE FROM ir_config_parameter WHERE key = 'fp.tablet.kiosk_password';` — accepts manual re-login after every lock event in exchange for no plaintext in DB or backups.
**Identify the kiosk user by xmlid, NEVER by login string:**
The kiosk login (`fp_tablet_kiosk@enplating.local` at creation time) is a `noupdate="1"` data record — admins can rename it on the user form for memorability (entech's actual kiosk login is `tablet@enplating.ca` as of 2026-05-24), and the rename PERSISTS through `-u`. Any code that hardcoded `'fp_tablet_kiosk@enplating.local'` as a string silently breaks after a rename — caught when Phase G's `lock_session` had the login hardcoded and broke after the user renamed the kiosk; fixed by resolving via `env.ref('fusion_plating_shopfloor.user_fp_tablet_kiosk').sudo().login`. Same pattern applies to any other user/group/record an admin might rename on the form. The xmlid is the stable identity; the display fields are not.
**Audit log** (`fp.tablet.session.event`): append-only model with Owner-only read ACL + Python `write`/`unlink` overrides (only the force-lock cron + retention crons bypass via context flags `fp_tablet_audit_admin_write` / `fp_tablet_audit_admin_purge`). Captures every unlock / failed_unlock / manual_lock / idle_lock / ceiling_lock / force_lock / admin_reset event with sha256(session sid), ip, user-agent, acting_uid, duration. View under Plating → Configuration → Tablet Audit Log (Owner-only menu). Per-user 7-day count smart button on `res.users` form.
## Mail templates: `email_from` MUST match the active mail server's `from_filter` (or M365 greylists)
Entech relays through Gmail OAuth as `orders@enplating.ca` (the mail server's `from_filter`). When a `mail.template` renders `email_from` to ANY other address (e.g. `{{ object.company_id.email }}``sales@enplating.ca`), Odoo logs `WARNING ir_mail_server: No mail server matches the from_filter, using <X> as fallback` and ships the message anyway — but the message has misaligned authentication:
- SMTP-AUTH = `orders@enplating.ca`
- `From:` header = `sales@enplating.ca`
- DKIM signs the `mail-from` domain, NOT the `From:` domain
- DMARC alignment check at recipient FAILS
Recipients on Microsoft 365 (like nexasystems.ca) react to DMARC fail by **greylisting for 515 minutes** before delivery — or routing straight to junk. The user feels this as "the email takes a while" or "I never got it."
**Rule:** every mail.template's `email_from` must resolve to an address inside the mail server's `from_filter`. Easiest pattern — add a helper on the template's target model that picks the active mail server's `from_filter` dynamically, then reference it from the template:
```xml
<field name="email_from">{{ object._fp_resolve_from_header() }}</field>
<field name="reply_to">{{ object._fp_resolve_from_header() }}</field>
```
`res.users._fp_resolve_from_header()` (in `fusion_plating_shopfloor/models/res_users.py`) is the reference implementation — sudo-search `ir.mail_server`, prefer `from_filter` (if it's an `addr@domain` form, not a wildcard), then `smtp_user`, then fall back to `company.email`. Reuse it on other models by either inheriting `res.users`-style helpers or duplicating the same lookup pattern (the lookup is 6 lines).
**Also avoid emojis in subject lines for cross-provider mail.** M365's spam classifier bumps emoji-containing subjects ~1.52 points; combined with cross-provider routing it pushes mail to junk or delay. PIN-reset codes / invoice notifications / shipment alerts — keep the subject plain.
If you must use a different From for branding reasons, the proper fix is multi-step (add the From address as a verified "Send as" alias on the Gmail account, ensure SPF lists Gmail's IPs for that domain, set up DKIM signing for the From domain). That's a config-side change, not a code change — flag it for the admin instead of working around it in the template.
## `send_mail(force_send=False)` is broken for interactive flows on entech
Entech's `Mail: Email Queue Manager` cron (id 3) runs every **1 hour**, not the per-minute default that vanilla Odoo demos use. A controller that queues an email with `force_send=False` for an interactive flow (PIN reset code, password reset, "click here for a one-time link", any flow where the user is staring at the screen waiting for the email) will sit in the outbox for up to 60 minutes. The mail row stays at `state='outgoing'`, no error is logged, the user thinks it never sent. Bit us 2026-05-25 on tablet PIN reset — codes 2253 and 7780 sat queued for 36+ minutes before we noticed.
**Rule:** for any interactive email flow, use `force_send=True` in `template.send_mail(res_id, force_send=True)`. The synchronous send adds ~1s of latency but the user gets the email before they can tab to their inbox. The cron is for batch / fire-and-forget notifications where the user isn't watching (NCR escalations, daily digests, etc.).
**Don't change the cron interval to "fix" this** — the hourly schedule is intentional on entech (Gmail SMTP daily quota mitigation + reduced relay overhead for the 95% of notifications that aren't time-sensitive). Per-flow `force_send=True` is the right knob.
When `force_send=True`, errors propagate (Gmail SMTP refusal, from_filter mismatch, etc.). Wrap with try/except and log, but consider returning a user-visible `{ok: false, error: 'email_send_failed'}` so the operator knows to retry or ask the manager — better than silent success that never arrives.
## Brainstorming visual previews — the user is on Mac, this Windows host can't show them
The user runs Claude Code from a **Mac** via Tailscale into this Windows host (`Home`). Any browser preview server bound to `localhost` on the Windows side (`http://localhost:8765`, the brainstorm script's preview server, `python -m http.server`, etc.) is unreachable from the Mac browser. Has bitten us three times now — Quality Dashboard redesign (2026-05-23), and twice during the Express Orders brainstorm (2026-05-25).
**Rule:** when running on this Windows host, do NOT spin up the `superpowers:brainstorming` visual companion (or any other browser-preview-style server) unless the user explicitly asks for it. Default to text-based design discussion — ASCII tables, structured lists, reference to existing files. The Excel mockup or screenshot the user provides is plenty of reference. If a visual companion IS requested anyway, the only path that works is binding to the Windows host's Tailscale IP (`100.87.38.59` on `Home`) — but even that requires firewall coordination and isn't worth the friction.
**Mac-side sessions:** localhost previews work fine; this rule doesn't apply. The user typically switches to a native Mac Claude Code session for visual-heavy work.
## Deleting an OWL component — also audit the localStorage / shared state it wrote
When you delete an OWL component (delete .js/.xml/.scss + drop manifest entries), the component's code is gone, but **any localStorage keys it wrote remain on every browser that ever rendered it**. If another live component reads those keys (with the deleted component's name in the key), the stale value still feeds into requests.
Concrete failure 2026-05-25: deleted `fp_shopfloor_landing` (which used `localStorage.fp_landing_station_id` to pair the tablet to a station). `tablet_lock.js` was reading the same key to scope the lock-screen tile query (`/fp/tablet/tiles?station_id=…`). After the delete, every tablet that had ever paired via the old component kept sending that stale id; the kiosk session can't read `fusion.plating.shopfloor.station` (locked-down ACL), so the endpoint hit AccessError and returned an empty tile list. The lock screen rendered "no operators." Took us ten minutes of "but my code didn't break anything" before finding it.
**Mandatory grep before deleting an OWL component:** `grep -rn '<key-the-component-wrote>' --include='*.js' static/src/`. For every hit in OTHER files: decide (a) read a different source, (b) clear-on-read and read a different source, or (c) keep the key and add a server-side endpoint that writes it. Also clear the key from the surviving components on next load so existing tablets self-heal — don't make the user clear browser storage.
Same audit applies to: window globals the component attached (`window.fpFoo = …`), CustomEvents it dispatched, IndexedDB stores it created, ServiceWorker registrations, BroadcastChannel topics.
## Removing menus/records — Odoo does NOT auto-delete orphans
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
```xml
<delete model="ir.ui.menu" id="module_name.menu_xmlid_to_remove"/>
```
Caught 2026-05-22 when the Phase 3 Plant Overview menu kept showing alongside the new Workstation menu after deploy.
**`<delete>` is single-use — remove it after the deploy that fires it.** Subsequent `-u` runs against a missing xmlid raise `ValueError: External ID not found in the system: <module>.<xmlid>` because the XML loader evaluates the `id="..."` ref at parse time. The error is non-fatal (load continues), but it bloats the log on every restart and obscures real failures. Workflow: ship the `<delete>` directive in deploy N, then DELETE the directive itself in deploy N+1 (or replace with a comment noting when the row was removed). The `<delete>` is not idempotent against an already-missing row. Caught 2026-05-25 when `<delete model="ir.ui.menu" id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview"/>` in legacy_menu_hide.xml had been firing this error for weeks after the menu was already gone.
## Odoo 19 ir.cron — `numbercall` and `doall` are gone
The legacy `numbercall=-1` (run-forever) and `doall=False` (catch-up-missed) fields were removed from `ir.cron` in Odoo 19. Including them in `<record model="ir.cron">` data XML produces:
```
ValueError: Invalid field 'numbercall' in 'ir.cron'
```
Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval_number`, `interval_type`, `active`. Caught during the 2026-05-22 entech deploy of the auto-pause cron.
## Critical Rules — Odoo 19
1. **NEVER code from memory** — Read reference files from the server first.
2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`.
@@ -181,6 +275,104 @@ These modules have **source code in this repo** but are **intentionally NOT inst
11. **XML data ordering**: Window actions must be defined BEFORE `<menuitem>` elements that reference them in the same file.
12. **Module install on new modules**: Use `--update=base` alongside `-i MODULE` to ensure Odoo rescans the addons path and finds the new module directory.
13. **Implied group cascade**: `implied_ids` on `res.groups` does NOT reliably propagate to users on module install. Always include `user_ids` to explicitly assign admin, or fix via SQL post-install.
13b. **Kanban template name — Odoo 19 wants `<t t-name="card">`, NOT `<t t-name="kanban-box">`**. Old name silently fails at render: `Error: Missing 'card' template`. Use the new structure with semantic `<aside>` + `<main>`:
```xml
<templates>
<t t-name="card" class="flex-row align-items-center">
<aside><field name="image_128" widget="image"/></aside>
<main class="ms-2"><field name="name"/></main>
</t>
</templates>
```
Reference: `/usr/lib/python3/dist-packages/odoo/addons/web/static/src/views/kanban/kanban_arch_parser.js`. Pre-existing `fp_rack_views.xml` still uses the old name and would also fail at render — fix when next touched. Caught 2026-05-24 by final reviewer of permissions-overhaul branch.
13m. **Tablet / kanban / dashboard controllers that surface DENORMALIZED cross-module data must `sudo()` the source recordset** at the top of the rendering helper. Low-privilege roles (Technician / Sales Rep) can read `fp.job` but NOT the cross-module fields it links to (sale.order, fp.part.catalog, fusion.plating.customer.spec, etc.) — naive `job.sale_order_id.x_fc_po_number` AccessErrors at render time and the kanban returns empty. The output is safe-to-expose display data; ACL gating is enforced by the CALLER's access to fp.job itself. Pattern:
```python
def _render_card(job, paired):
job = job.sudo() # cross-module reads now bypass ACL
so = job.sale_order_id # was AccessError for Technician
...
```
Caught 2026-05-24 when Technicians saw an empty Shop Floor kanban post-migration (log: `Access Denied by ACLs ... model: sale.order`). Same pattern likely needed in any controller returning a job-centric card payload to a non-Manager user.
13l. **`res.groups.user_ids` returns DIRECT members only — implied/transitive memberships are NOT stored in `user.groups_id`**. When you `user.write({'groups_id': [(4, owner_group.id)]})`, Odoo adds JUST the Owner group to the user — it does NOT cascade and write the implied Manager/Shop Manager/Technician group rows into res_groups_users_rel. The implication chain is resolved at READ time by `has_group()` and `_get_trans_implied_groups()`, but NOT materialized in storage. So:
- `env.ref('fusion_plating.group_fp_technician').user_ids` returns ONLY the 18 direct Technicians, NOT the Owners/QMs/Managers/Shop Managers who reach Technician via implication.
- `env.ref('fusion_plating.group_fusion_plating_operator').user_ids` (the deprecated group) returns EMPTY post-migration for the same reason — no user holds it directly.
**Fix for "enumerate everyone with role X or higher":** search `res.users` directly with the union of group ids:
```python
shop_branch_ids = [env.ref(x).id for x in (
'fusion_plating.group_fp_technician',
'fusion_plating.group_fp_shop_manager_v2',
'fusion_plating.group_fp_manager',
'fusion_plating.group_fp_quality_manager',
'fusion_plating.group_fp_owner',
)]
users = env['res.users'].sudo().search([
('group_ids', 'in', shop_branch_ids), # NB: group_ids not groups_id in Odoo 19, see rule 13c
('share', '=', False), ('active', '=', True),
])
```
`('groups_id', 'in', [...])` is the right operator — it matches the DIRECT membership rel table without trying to follow implications. Since each user has exactly one primary plating role (Phase F `x_fc_plating_role` Selection enforces this), this returns every shop-branch user with no duplicates.
For `has_group`-style intent ("does this single user have role X or any role that implies X"), use `user.has_group('fusion_plating.group_fp_X')` — that DOES follow the implication chain at read time.
Caught 2026-05-24 in two waves: (1) tablet lock screen showed "No operators configured" because it queried the deprecated `group_fusion_plating_operator.user_ids`; (2) after fixing to `group_fp_technician.user_ids`, it still missed Owners/Managers because implication chains don't populate `user_ids`. Final fix: search-based query across the 5 shop-branch role ids. Audit for other instances: `grep -rn "env\.ref.*\.user_ids\b" --include='*.py'` (skip test files which intentionally exercise backward-compat).
13k. **Custom fields on `res.users` must be added to `SELF_WRITEABLE_FIELDS` (and often `SELF_READABLE_FIELDS`) or non-admin users can't save their own Preferences dialog**. Odoo 19's User Preferences dialog goes through `res.users.write` on the user's own record — Odoo bypasses the standard write ACL ONLY IF every field being written is in `SELF_WRITEABLE_FIELDS`. Any unknown field forces fallback to the standard ACL (admin-only on entech) → `AccessError: You are not allowed to modify 'User' records. Required group: Access Rights`.
**In Odoo 19, `SELF_WRITEABLE_FIELDS` and `SELF_READABLE_FIELDS` are `@property`-decorated methods, NOT class attributes.** Extend via super(), not list concatenation on `models.Model.SELF_*` (that AttributeErrors at module load — Model base doesn't define them, only res.users does). Canonical pattern (matches hr/res_users.py and mail/res_users.py):
```python
class ResUsers(models.Model):
_inherit = 'res.users'
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + [
'x_fc_plating_landing_action_id', 'x_fc_signature_image',
]
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + [
'x_fc_plating_role', 'x_fc_tablet_pin_set_date', ...
]
```
Readonly fields on the preferences form ALSO need SELF_READABLE_FIELDS (the form fetches them before the user clicks Save). Methods invoked by buttons that do their own `sudo().write()` bypass this — only DIRECT form-level writes hit the check. Caught 2026-05-24 when Technician tried to save their preferences after the plating landing field was added; the initial fix used the wrong class-attribute syntax and crashed odoo at module load.
13j. **Non-stored Many2many computes STILL require user-level read access on the comodel** for field-assignment cache fill, even when the compute body is wrapped in `sudo()`. The `user.field = [(6, 0, ids)]` assignment populates the cache by relating to comodel records the CURRENT USER must be able to read — `sudo()` on the lookup doesn't help because the assignment is per-record-context. If the comodel is admin-only (like `ir.actions.actions` / `ir.actions.act_window` on entech), a non-admin opening their own preferences will fail with `Failed to write field X. You are not allowed to access 'Y' records.` Two fixes: (a) drop the Many2many compute and use a static domain filter instead, plus add an ACL row granting read on the comodel to whichever role group needs to evaluate the picker domain; (b) replace the Many2many with a Json/Char that stores IDs, lose the auto-validation. Option (a) is simpler — Odoo's design assumes pickers' comodels are user-readable. Caught 2026-05-24 when a Technician tried to open their Preferences after the per-user `accessible_landing_action_ids` field was added.
13i. **`res.users` does NOT have `message_post()`** — chatter posting must go through `user.partner_id.message_post(...)`. `res.users` uses `_inherits = {'res.partner': 'partner_id'}` (delegation), which proxies FIELDS through partner_id but NOT METHODS. `user.message_post(...)` raises `AttributeError: 'res.users' object has no attribute 'message_post'`. Note that mail's tracking IS recorded on the user record (via partner) — the chatter widget on user form displays partner's chatter — but the post call itself targets the partner. Caught 2026-05-24 during Owner approval click on the migration preview screen.
13c. **`res.users.group_ids` NOT `groups_id`**: Odoo 19 renamed the m2m field. Old name doesn't resolve; `@api.depends('groups_id')` raises `ValueError` at module load. Also: domain on relational pickers should use `all_group_ids` (transitive set incl. implied) instead of `group_ids` (only directly-assigned) — otherwise an Owner user won't match a domain looking for QM members. See `feedback_odoo19_groups_id_renamed.md`.
13d. **`post_init_hook` ONLY fires on INSTALL, not UPGRADE** in Odoo 19. For logic that must run on `-u` of an existing install (entech case), add a `migrations/<version>/post-migrate.py` with a `migrate(cr, version)` function that calls the same helper. The hook still works on fresh install; the migration script bridges the gap on `-u`. Both should be idempotent so re-runs are safe.
13g. **Odoo 19's `sale.view_order_form` uses a single `<field name="tax_totals" widget="account-tax-totals-field"/>` widget instead of separate `amount_total` / `amount_untaxed` / `amount_tax` fields**. Inheriting xpaths targeting any of the three separate fields will fail at view load: `Element '<xpath expr="//field[@name='amount_total']">' cannot be located in parent view`. To gate or modify totals, target the `tax_totals` widget (one xpath hides the whole totals block). Other views in the file (kanban, list, pivot) DO still have the individual fields — only the FORM view consolidated to the widget. Same likely applies to `purchase.purchase_order_form` and `account.view_move_form` — verify per-view before porting Odoo 17/18 xpaths. Caught 2026-05-24.
13h. **`user_has_groups('xmlid')` is NOT available inside Odoo 19's `invisible=`/`readonly=`/`required=` attribute expressions**. The view validator parses `user_has_groups` as a field name on the host model and fails: `field 'user_has_groups' does not exist in model 'X'`. Group-based UI gating must use the `groups=` attribute on the element instead. To combine group-AND-state logic, EITHER split into two elements with mutually-exclusive `invisible` AND different `groups=`, OR enforce one half at the model layer (ir.rule / @api.constrains) and the other in the view. Caught 2026-05-24 when a single button used `invisible="state != 'draft' or (cert_type == 'nadcap' and not user_has_groups(...))"` — rewrote as a single button with `groups="group_fp_manager"` + `invisible="state != 'draft'"` and let the ir.rule enforce the Nadcap-write restriction (Manager clicking Issue on a Nadcap cert now raises AccessError).
13f. **Odoo 19 view validator rejects `ref('xmlid')` inside `<field domain="...">`**: the validator parses `ref(...)` as a field-access on the host model and fails with `field 'ref' does not exist in model 'X'`. Even though `ref()` IS resolved at runtime by the client, validation fires first and aborts module load. Workarounds (pick one):
- **Drop the domain** and enforce eligibility via `@api.constrains` on the Python side (simplest — used for `res.company.x_fc_cgp_designated_official_id` in this project; the Owner makes a deliberate choice and Python validates at save time).
- **Pre-compute eligible IDs** in a stored `Many2many` compute on the host model, then `domain="[('id', 'in', eligible_ids_field)]"`.
- Move the domain into the field definition in Python (`fields.Many2one(..., domain="[...]")`) — but Python-side domains have the same `ref()` limitation, so this isn't always an escape.
Caught 2026-05-24 deploying permissions-overhaul to entech.
13e. **`res_groups_name_uniq` constraint is `(privilege_id, name)` — cross-module display-name collisions during `-u` need a `pre-migrate.py` rename**. If a base module's new XML defines a group with the same display name as a DOWNSTREAM module's existing group (e.g. core adds new `Shop Manager (v2)` while configurator already has old `Shop Manager`), the new INSERT collides with the still-named-the-same downstream row, because Odoo loads modules in dep order and the downstream rename via XML hasn't happened yet. The fix is a `migrations/<version>/pre-migrate.py` in the BASE module that SQL-renames the downstream row before the new XML loads:
```python
def migrate(cr, version):
cr.execute(\"\"\"
UPDATE res_groups
SET name = jsonb_build_object('en_US', '[DEPRECATED] Shop Manager (...)')
WHERE id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_configurator'
AND name = 'group_fp_shop_manager'
AND model = 'res.groups'
)
AND (name IS NULL OR name->>'en_US' NOT LIKE '[DEPRECATED]%');
\"\"\")
```
Pre-migrate scripts run BEFORE the module's data files reload, so the constraint is clear by the time the new group XML INSERTs. Caught 2026-05-24 during permissions-overhaul deploy — `fp_security_v2.xml` claimed `'Shop Manager'` while old configurator's `group_fp_shop_manager` still held that display name in the DB. Same pattern applies to ANY base-module XML adding groups with names that overlap downstream-module groups.
13a. **Cross-module xmlid refs — base modules CANNOT forward-ref downstream xmlids**: A BASE module's data XML cannot `ref('downstream_module.some_xmlid')` because at fresh install, the base module loads FIRST and `ir.model.data` has no row for the downstream xmlid yet → `ValueError: External ID not found`. This bites on entech (existing DB has the row) but breaks fresh CI/test/demo/new-client installs. **Fix pattern: relocate the cross-module link to the downstream module's own security/data file, using an additive write to the BASE module's record:**
```xml
<!-- In downstream module's security XML -->
<record id="fusion_plating.group_fp_sales_rep" model="res.groups">
<field name="implied_ids" eval="[(4, ref('fusion_plating_configurator.group_fp_estimator'))]"/>
</record>
```
Odoo's XML loader treats `id="other_module.xmlid"` as an additive update to the existing record, and `(4, ref(...))` (Command.link) stacks idempotently across install/-u cycles. Use this whenever a base module group/record needs to imply or reference something defined in a downstream module. Caught 2026-05-24 when `fusion_plating/security/fp_security_v2.xml` referenced groups from configurator/receiving/invoicing/cgp — worked on entech, would have broken fresh installs.
14a. **FP report palette + border rendering**: `fusion_plating_reports/report/report_base_styles.xml` uses **`#c1c1c1`** for section-header backgrounds and **`#1d1f1e`** (th text on grey) / **`#4e4e4e`** (h2/h4 on white) — NOT `res.company.primary_color`. Per-customer request (2026-05-17) the FP reports stopped following the company brand colour so every shop gets the same neutral look. The `fp_primary` template variable is still computed in the styles block so per-report templates can opt back in if needed, but the default `.fp-report` / `.fp-landscape` rules use the hardcoded greys. **Don't "fix" this back to `fp_primary` without confirming.**
**Border-rendering gotcha** (entech wkhtmltopdf): with the standard `border-collapse: collapse` + `border: 1px solid #000` pattern, vertical borders can render slightly softer than horizontal borders because of how wkhtmltopdf rounds sub-pixels in its collapse-adjudication. Cells with a `background-color` also paint over the border edge unless clipped. Mitigations in place:
@@ -201,6 +393,20 @@ These modules have **source code in this repo** but are **intentionally NOT inst
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
20. **OWL templates expose `Math` but NOT `String` / `Number` / `Array` / `Object` / `Boolean` / `JSON` / `parseInt` / `parseFloat`**: writing `t-on-click="() => this._press(String(d))"` (or similar coercion inside any template expression) throws `Uncaught TypeError: v2 is not a function` at click time — `v2` is OWL's compiled reference to a global that doesn't exist in template scope. The click handler dies before its body runs, so the bug looks like "nothing happens when I press" (no error in the UI, only DevTools shows the trace). **Fixes, in order of preference**: (a) eliminate the coercion entirely — store data in the right type up front, e.g. `t-foreach="['1','2','3']"` instead of `[1,2,3]` so `d` is already a string. (b) Use a JS-side coercion: pass the raw value to the handler and call `String(digit)` inside the component method. (c) Use a pure-expression workaround like string concatenation: `'' + d` does work because `+` is an operator, not a function. **Do NOT try to monkey-patch `String` onto the component (e.g. `this.String = String`) or onto `env` — leaks the global into every component and is fragile across OWL upgrades.** Bit us 2026-05-23 on `pin_pad.xml` — operators couldn't tap PIN digits at all because the click handler died on `String(d)`; the SCSS, reactivity, and `_press` method were all fine, the template scope was the entire bug. Same trap applies to OWL templates anywhere in the codebase: `move_parts_dialog.xml`, `manager_dashboard.xml`, `fp_record_inputs_dialog.xml`, etc. — grep all `t-on-click`, `t-att-*`, and `t-out` expressions for `String(`, `Number(`, `Array(`, `parseInt(`, `parseFloat(`, `JSON.` before merging.
21. **`ir.actions.act_window_close` is a no-op when the current action was opened with `target: "current"`**: replacing the current action wipes the breadcrumb backstack, so there's nothing to close back to. The user clicks "Back" and nothing happens (no error, no navigation). This bites every OWL client-action surface that calls another client action via `doAction({..., target: "current"})` — the destination has no way to return to the source. **Fix pattern for "Back" buttons in OWL client actions**: navigate EXPLICITLY to the landing/parent action by tag, e.g. `this.action.doAction({ type: "ir.actions.client", tag: "fp_plant_kanban", target: "current" })` — works regardless of how the action was reached (kanban tap, QR scan, smart button, direct URL). **Do NOT rely on `act_window_close`, `history.back()`, or `this.env.config.breadcrumbs`** — all three are unreliable across navigation paths. Bit us 2026-05-23 on the Job Workspace Back button after the kanban opened the workspace with `target: "current"`. The same pattern applies to every other "Back" button in shopfloor / manager / portal OWL surfaces — explicit destination via `tag:` is the only robust answer.
22. **Odoo 19 HTML fields auto-wrap plain-string writes**: writing `co.report_header = 'Plating & Finishing'` to an HTML field (like `res.company.report_header`, `res.partner.comment`, `mail.template.body_html`, `product.template.description_sale`) stores `<p>Plating &amp; Finishing</p>` after Odoo's HTML sanitizer runs. Equality tests against the raw input string FAIL (`payload['tagline'] != 'Plating & Finishing'`). **Three implications**: (a) **In tests**, don't `assertEqual` against the literal string you wrote — strip tags first, OR write the wrapped form (`<p>Plating & Finishing</p>`), OR write an explicit `Markup('<p>...</p>')` so the round-trip stays stable. (b) **In display code**, render HTML fields with `t-out` (QWeb) or `markup(...)` (OWL) — `t-esc` would render the literal `<p>` tags as text. (c) **In comparison logic**, normalize first: `from markupsafe import escape; escape(input_str)` produces the same shape the field stores. Bit us 2026-05-24 testing the lock-screen tagline source (`_lock_company_payload` reads `res.company.report_header`); the test that wrote a plain string and asserted equality failed because the value came back wrapped. The fix was to delete the brittle equality test — the helper's responsibility is just "use the field's value when present, else fall back," which is covered by the empty-field test. Generalizes to ANY HTML-typed Odoo field. Distinct from the `mail.template.body_html is Markup + jsonb` gotcha noted earlier in this file — that's about Markup objects vs strings; this is about the sanitizer wrapping plain strings on write.
23. **`res.users.group_ids` vs `all_group_ids` for domain filters**: in Odoo 19, `res.users` carries TWO M2M-to-`res.groups` fields and they have different membership semantics. `group_ids` is the user's DIRECTLY-assigned groups (what the user record literally wrote). `all_group_ids` is the TRANSITIVE set — direct groups PLUS every group implied via `implied_ids` chains. **For domain filters on user pickers** (e.g. "show users who can act as a Quality Manager"), ALWAYS use `all_group_ids`, never `group_ids`. An Owner user only carries `group_fp_owner` directly; the QM capability comes via `implied_ids → group_fp_quality_manager`, so a `domain="[('group_ids', 'in', [ref('...quality_manager')])]"` excludes Owners and the picker looks empty. Use `domain="[('all_group_ids', 'in', [ref('...quality_manager'), ref('...owner')])]"` instead. Compute helpers (`@api.depends('group_ids')`) and write vals (`{'group_ids': [(4, gid)]}`) still use `group_ids` because those operate on direct assignments — only domain filters need the transitive set. Bit us 2026-05-24 on the CGP DO + Nadcap Authority pickers on `res.company`. Same gotcha applies to ANY domain that needs "does this user effectively have role X" semantics across user-facing pickers, ACL rules, server actions, and search filters.
24. **`env.get('model.name')` returns an EMPTY recordset (falsy), NOT None — never use it as a presence check**: `self.env.get('fp.notification.template')` returns `fp.notification.template()` (empty recordset) when the model IS registered. Empty recordsets are falsy in Python, so `if not Template: return` silently exits even when the model exists and the call should proceed. Same gotcha for `env.get('any.model')` — they all return empty recordsets. **Fix: use the membership check first, then index:**
```python
if 'fp.notification.template' not in self.env:
return # model not installed
Template = self.env['fp.notification.template']
# now Template is the model class; use it
```
The `Template.sudo()._some_classmethod()` call works on the empty recordset because `@api.model` methods run on the class. The breakage is purely the truthy-check. Bit us 2026-05-25 deploying `_fp_schedule_cert_activity` — the helper hit `env.get(...)` and immediately returned without ever attempting `activity_schedule`, so the QM never got their Issue-CoC activity. Took a monkey-patch trace through the helper to surface, because the function was silently no-oping with no exception. Same pattern likely scattered in any code that gates on `if env.get(...): ...` — grep for it.
25. **`mail.template` data files validate templates at PARSE time — only reference CORE-module fields on the target model**: when Odoo loads a `<record model="mail.template">` from XML, it eagerly RENDERS the `subject`/`body_html` once against a sample `object` to validate the inline_template renders cleanly. If the template references a field defined in a DOWNSTREAM module (one that loads AFTER the data-file's home module), the field isn't on the model yet and you get `AttributeError: 'fp.job' object has no attribute 'X'``ParseError: Failed to render inline_template template` → module install/upgrade ABORTS. Bit us 2026-05-25 deploying the cert authority templates: `fusion_plating_notifications` loads BEFORE `fusion_plating_jobs` in dep order, and the templates referenced `object.display_wo_name` and `object.part_catalog_id` (both added by `fusion_plating_jobs` via `_inherit`). Even though the columns exist in the DB from previous installs, the Python class hadn't registered the field yet at parse time. **Fix:** mail.template files in upstream modules must only reference fields defined in the SAME module's classes or earlier-loading deps. For `fp.job` references in `fusion_plating_notifications/data/`, that means CORE-only fields: `name`, `partner_id`, `qty_done`, `recipe_id`, `state`, `date_*`, `company_id` — NOT `display_wo_name`, `part_catalog_id`, `customer_spec_id`, `delivery_id`, `portal_job_id` (all jobs-module fields). Same trap for any other cross-module template (`account.move`, `sale.order`, `stock.picking`). **Two structural alternatives** if you really need downstream fields: (a) move the mail.template + fp.notification.template data records into the downstream module so they load after the field is registered (cleanest); (b) compute the value in the calling Python code and pass via `email_values` to the dispatch — no template-time rendering.
## Naming
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
@@ -339,13 +545,199 @@ POST /fp/recipe/duplicate — deep-copy recipe
### Client Recipes Created
- `ENP-ALUM-BASIC` — Electroless Nickel Plating Aluminium Basic (9 operations, 15 steps). Data file: `fusion_plating/data/fp_recipe_enp_alum_basic.xml`
## Plant Overview Dashboard
- OWL client action: `fp_plant_overview` in `fusion_plating_shopfloor`
- Kanban columns = work centres, cards = active `mrp.workorder` records
- Drag & drop between columns (writes `workcenter_id` on the work order)
- Endpoint: `POST /fp/shopfloor/plant_overview`
- Move endpoint: `POST /fp/shopfloor/plant_overview/move_card`
- Auto-refreshes every 30s
## Shop Floor Architecture (2026-05-22 tablet redesign — Phases 1-4)
Spec: [docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md](docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md)
Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md)
**Three OWL client actions** (registered under `registry.category("actions")`):
- `fp_plant_kanban` — sole Shop Floor surface as of 2026-05-25. One card per `fp.job` grouped into 9 fixed columns. Inline QR scanner (camera + wedge text drawer) + station pairing via `/fp/landing/pair_work_centre`. Tap a card → JobWorkspace. (The legacy `fp_shopfloor_landing` component was deleted entirely on 2026-05-25 — its inline QR feature was ported here. The earlier `fp_shopfloor_tablet` and `fp_plant_overview` xmlids still exist but their `tag` re-points at `fp_plant_kanban` for bookmark back-compat.)
- `fp_job_workspace` — Full-screen single-WO surface. Sticky header (WO #, customer, qty, workflow chip), sticky 9-stage workflow bar, step list with GateViz blockers, side panel (spec/attachments/chatter), sticky action rail (Hold/Note/Milestone). Opens from kanban tap, smart button, QR scan, or manager card tap.
- `fp_manager_dashboard` — Manager Desk with 4 sibling tabs: **Workflow Funnel** (default), **Approval Inbox**, **Plant Board** (existing 3-column), **At-Risk** (trending late + hold reasons + bottleneck heatmap).
**Five shared OWL services** in `fusion_plating_shopfloor/static/src/js/components/`:
- `WorkflowChip` — workflow.state pill + optional next-action hint
- `GateViz` — "Can't start because…" explainer (reads `fp.job.step.blocker_kind`/`reason`)
- `FpSignaturePad` — Dialog canvas signature capture
- `FpHoldComposer` — Dialog hold-create form with reason picker + qty + photo
- `FpKanbanCard` — standard WO/step card with embedded WorkflowChip + blocker badge
**Backend endpoints (Phase 1-4):**
- Workspace: `/fp/workspace/{load,hold,sign_off,advance_milestone}`
- Landing: `/fp/landing/kanban` (mode=station|all_plant)
- Manager: `/fp/manager/{overview,funnel,approval_inbox,at_risk}`
**Auto-pause cron — fixes 411-hour ghost timers:**
- `_cron_autopause_stale_steps()` runs every 30 min
- Threshold from `ir.config_parameter` **`fp.shopfloor.autopause_threshold_hours`** (default 8.0)
- Recipe nodes opt out via `fusion.plating.process.node.long_running=True` for 24h bakes etc.
- Flips `state=in_progress` idle > threshold to `paused` with chatter audit
**Key model fields added (Phase 1-4):**
- `fp.job.display_wo_name` — "WO # 00001" formatter for tablet/dashboard (model `name` field stays `WH/JOB/…`; system-wide sequence rename deferred)
- `fp.job.late_risk_ratio` — stored Float (remaining_planned / minutes_to_deadline) driving At-Risk view
- `fp.job.active_step_id` — computed M2o to currently in_progress step
- `fp.job.step.blocker_kind` / `blocker_reason` / `blocker_jump_target_*` — drives GateViz
- `fp.work.centre.bottleneck_score` / `avg_wait_minutes` — drives At-Risk bottleneck heatmap
**Operator ACL lift (per "techs wear multiple hats" rule):**
- `fp.certificate` — operator gained write (flip draft → issued from tablet)
- `fp.thickness.reading` — operator gained create+write (Fischerscope capture from tablet)
- `fp.job.node.override` — operator gained read (see opt-out badges on steps)
- Supervisor-only ops (step Skip, hold Release) enforced in `workspace_controller.py`, not ACL
**Deprecated but still live** (cleanup is Phase 5):
- OWL components: `fp_shopfloor_tablet`, `fp_plant_overview` — registered but no menu points at them
- Endpoints: `/fp/shopfloor/tablet_overview`, `plant_overview`, `queue` — marked DEPRECATED with INFO log lines, bodies intact for back-compat
- `/fp/shopfloor/plant_overview/move_card` is **NOT** deprecated — the new plant kanban uses it for drag-and-drop
**Retired entirely 2026-05-25** (do NOT re-introduce):
- OWL component `fp_shopfloor_landing` + its JS / XML / SCSS files — deleted. The inline QR scanner (text/wedge drawer + camera component) was ported into `plant_kanban`. The landing resolver always returns `action_fp_plant_kanban` for technicians + shop managers regardless of the orphaned `fusion_plating_shopfloor.layout` ir.config_parameter.
- The `/fp/landing/kanban` endpoint is no longer used by any live client (was only consumed by `fp_shopfloor_landing`). The new endpoint is `/fp/landing/plant_kanban`. Don't accidentally bind a new client to the old one.
- Station pairing via `localStorage[fp_landing_station_id]` is gone — pairing now writes `res.users.paired_work_centre_ids` server-side via the new `/fp/landing/pair_work_centre` endpoint, and the kanban reads it back via `request.env.user.paired_work_centre_ids[:1]`. Per-tablet localStorage pairing won't survive a browser cache wipe; per-user server-side pairing does.
**Old patterns to avoid:**
- Don't read `fp.job.name` for display — use `display_wo_name` everywhere on tablet/dashboard
- Don't add SCSS that uses raw hex without an `@if $o-webclient-color-scheme == dark` branch (dark mode breaks otherwise; see existing `_workflow_chip.scss` as the template)
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
## Shop Floor — Plant View kanban (2026-05-23 redesign)
**Sole Shop Floor surface** for every install as of 2026-05-25. The
legacy per-step kanban (`fp_shopfloor_landing`) was deleted the same
day, after porting its inline QR scanner into plant_kanban. The
`ir.config_parameter['fusion_plating_shopfloor.layout']` flag is now
orphaned — flipping it has no effect on the landing surface. The
setting UI stays for one release cycle so it can be ripped out in a
separate sweep without breaking migrations.
**Why redesign:** the per-step kanban produced one card per recipe step
per column, so a 14-step recipe spawned 9+ cards for ONE job across the
board. With 17 active jobs the board showed 100+ duplicate cards across
narrow columns. The new design is **one card per `fp.job`** at the
**department level** — recipe step count no longer drives layout width.
**Spec:** `docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md`
**Plan:** `docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md`
### Layout — 9 fixed columns in process sequence
`Receiving → Masking → Blasting → Racking → Plating → Baking →
De-Racking → Final inspection → Shipping`
Columns are first-class — they always render in this exact order, never
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
(stored) from `work_centre.area_kind` with a fallback to a step-kind
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
Drying) roll up into the **Plating** column. The tank chip on the card
distinguishes them.
**Spec D4:** De-Masking folds into De-Racking (no separate column).
**Spec D5:** Contract Review (paperwork) cards live in Receiving with a
purple "📋 QA-005" chip — they're admin gates, not physical work.
### Card state catalog — 13 mutually-exclusive states
`fp.job.card_state` is a stored Char computed in `_compute_card_state`
(see `fusion_plating_jobs/models/fp_job.py`). Explicit precedence
dispatch matching spec §6.2 — first match wins:
`no_parts → on_hold → awaiting_signoff → awaiting_qc → bake_due →
predecessor_locked → idle_warning → done → contract_review →
running_mine/running → ready_mine/ready`
Each state has a distinct background tint + left-border color + chip +
mini-timeline marker color. See `_plant_card.scss` for the mapping. The
"mine" variants (`ready_mine`, `running_mine`) light up only when the
active step's work centre is in `res.users.paired_work_centre_ids` (the
M2M holds one row in MVP, mirrors the existing single-station picker).
### Backend — single endpoint, denormalized payload
`/fp/landing/plant_kanban` (controller in
`fusion_plating_shopfloor/controllers/plant_kanban.py`) returns
`{ok, mode, paired_station, kpis, columns, cards}` in one JSONRPC call.
Frontend has zero per-card RPCs — every card field comes pre-formatted
from the controller's `_render_card`. State-chip text (with elapsed
times, operator names, hours-idle) is interpolated server-side.
### Frontend — OWL component tree
```
FpPlantKanban (client action 'fp_plant_kanban')
└── FpTabletLock (PIN gate wrapper)
├── PlantHeader (KPIs + filter chips + mode toggle + station picker)
└── Board (9 × Column)
├── FpColumnHeader (with 'You're here' badge for paired column)
└── FpPlantCard[] (each with FpMiniTimeline)
```
Polls every 10s. Filter state persists in localStorage. All 13 card
states styled via `.state-<name>` CSS modifier classes on a single
shared `.o_fp_plant_card` base. The mini-timeline renders 9 colored
dots driven by `fp.job.mini_timeline_json` (Python emits the array
shape — frontend just maps state → CSS class).
### Critical implementation gotchas (project rules applied)
- **OWL templates only expose `Math` as a JS global** (Rule 20). All
coercion (String, Number, parseInt) MUST happen in JS — `tag_chip_class()`
/ `progress_style` etc. live in plant_card.js, not in the XML.
- **SCSS @import is forbidden** (Rule 8). `_plant_tokens.scss` loads
FIRST in the manifest's `web.assets_backend`; subsequent component
partials get the `$plant-*` vars via the concatenated bundle.
- **Dark mode** via `$o-webclient-color-scheme == dark` compile-time
branch in `_plant_tokens.scss` (NOT runtime class selectors).
### How to switch back to legacy
```sql
UPDATE ir_config_parameter SET value = 'legacy'
WHERE key = 'fusion_plating_shopfloor.layout';
```
Or use Settings → Fusion Plating → Shop Floor Layout. Both surfaces
write the same `ir.config_parameter` key.
### Legacy-action redirect (general rule for OWL component swaps)
When replacing an OWL client-action component with a new one, **don't
just register the new action's XMLID**. There are usually 2-5 legacy
`ir.actions.client` data records scattered across the module pointing
at the old tag (`action_fp_plant_overview`, `action_fp_shopfloor_tablet`,
etc. — every "old bookmarks keep working" record). The landing-action
resolver only sees one entry point. Bookmarks, breadcrumbs, QR-scan
landings, and "Plant Overview" / "Tablet Station" menu items go
through the OTHER actions and load the old component.
**Fix: change every legacy data record's `tag` to the new tag.** Grep
the views/ and data/ dirs for the old tag, and update each `<field
name="tag">` to the new one. The old OWL component stays registered
(no code removed), but no `ir.actions.client` row points at it
anymore. Caught 2026-05-23 when the plant-view rollout dispatched
the resolver correctly but a user clicking via the legacy "Shop Floor"
menu still saw the per-step kanban — `action_fp_shopfloor_tablet`
and `action_fp_plant_overview` were both still hard-coded to
`fp_shopfloor_landing` tag.
**Also grep JS for hardcoded `doAction({tag: ...})` calls** — XML
data records are only half the story. OWL components that wire up
"Back" buttons / navigation often hardcode the destination tag in
JS (e.g. `this.action.doAction({type: "ir.actions.client", tag:
"fp_shopfloor_landing", target: "current"})`). These bypass the
data layer entirely, so the redirect trick above doesn't cover
them. Caught 2026-05-24 — the Job Workspace `onBack()` still
pointed at `fp_shopfloor_landing`, so tapping a card in the new
plant kanban → opening the workspace → clicking Back dropped the
user into the deprecated per-step kanban. Fix: `grep -rn
'tag: ["\x27]<old_tag>' static/src/js/` before considering the
swap complete; rewrite every match to point at the new tag.
## Deployment
@@ -1061,6 +1453,9 @@ Each script is self-contained — builds a fresh SO + job, walks the scenario, a
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
| **S20** | Tablet Station UX hardening — three real-world UX gaps surfaced during a persona walk on the Tablet + Manager Desk client actions | (a) **Scrap reason dropped**: `/fp/shopfloor/bump_qty_scrapped` accepted operator's typed reason via `window.prompt`, passed it through context as `fp_scrap_reason` — but `fp.job.write` never read it, so the auto-spawned Hold's description had the generic "OPERATOR: replace this text with the actual reason" placeholder instead of what Carlos typed. Audit trail lost what just happened on the floor. (b) **KPI/panel mismatch**: tablet KPI strip showed plant-wide totals ("Quality Holds: 12") but the Holds panel below was scoped to the operator's own jobs (might show 0). Operator stares at a big red 12, scrolls down, sees nothing — confused/distrustful. (c) **UserError stack-trace leak**: when `start_wo` hit an S14 predecessor lock (or any other `button_start`-side guard), the raw `UserError` propagated through the JSON-RPC handler and operator got a Python stack-trace dialog instead of the nice `setMessage("...", "danger")` flash. Same hole on `stop_wo`, `start_bake`, `end_bake`, `mark_gate`, `bump_qty_done`, `bump_qty_scrapped`. | (a) `fp.job.write` now reads `self.env.context.get('fp_scrap_reason')` and prepends `Operator reason: <text>` to the Hold description so the audit row captures what the operator actually typed. (b) Tablet KPI strip now reuses `my_job_ids_for_kpi` (the operator's own steps) for `awaiting_bakes`, `bake_in_progress`, `missed`, `open_holds` — same scope as the panels below, so the strip never lies. Manager dashboard keeps its own plant-wide KPI set. (c) Wrapped every action endpoint in `try: ... except UserError as e: return {'ok': False, 'error': str(e.args[0])}` — operator now gets the clean `setMessage` flash with the real guard text ("Step 'X' requires predecessors done first…") instead of a stack-trace popup. | `fusion_plating_jobs 19.0.6.22.0`, `fusion_plating_shopfloor 19.0.24.4.0` | persona walk via `sim_tablet_actions.py` + `sim_reverify.py` (asserts: typed reason ends up in hold.description, KPI=panel for holds, `start_wo` returns `{ok:False, error:"..."}` for locked step) |
| **S20** | **Tablet usability pass** — operators were squinting at the tablet, scanning back-and-forth between recipe binders and the screen because the tablet showed step names but no targets, no live timer, no predecessor visibility. QC fail left parts in limbo with no Hold record. Manager Desk showed feel-good KPIs but hid the compliance bombs (missed bakes, stale steps, locked steps, holds, pending QC missing PDF) | Tablet `My Queue` rows had no `instructions`, `thickness_target`, `dwell_time_minutes`, `bake_setpoint_temp`, `requires_signoff` — operators kept scanning the QR code just to read the bake temperature. Steps with `requires_predecessor_done=True` (S14) showed a green Start that always failed with a UserError. Active step "duration" was a stale number that only refreshed every 30s. Holds and bake windows showed plant-wide noise from other crews. **No banner alerted Carlos when his job had a pending QC** (Lisa was not called → QC sat for hours). **No way to bump qty_done or scrap from the tablet** → S17 hold auto-spawn never fired because operators didn't update the field. **`action_fail` on QC marked the check failed but spawned no Hold** — AS9100 disposition trail broken. **Manager Desk KPIs were missing 7 compliance metrics**: stale paused/in-progress steps (cron data), missed bake windows, open holds, predecessor-locked steps, pending QCs, QCs missing Fischerscope PDF, draft cert pipeline | **Carlos's Shopfloor Tablet** — every queue row now carries the recipe-author fields (instructions snippet, thickness target chip, dwell-time chip, bake-temp chip, sign-off badge) so operators read the targets inline. Predecessor-blocked steps render with a 🔒 lock icon, an "Awaiting [step name]" notice, and a disabled `Locked` button (no more Start-then-fail). Active step now shows a **live ticking HH:MM:SS clock** (1s interval, computed from `date_started_iso` JS-side; flips to red on >1.5× planned duration) plus `+1 Done` and `Scrap` buttons that hit two new endpoints (`/fp/shopfloor/bump_qty_done`, `/fp/shopfloor/bump_qty_scrapped` — scrap prompts for reason and S17 auto-spawns the Hold). New **Pending QC banner** lists open QCs for my jobs with line-progress + Fischerscope-PDF status badge, and a tap deep-links into Lisa's mobile QC checklist. Holds and bake windows are now **scoped to my jobs first** (fall back to facility-wide for managers). **QC checklist** — `action_fail` now auto-creates a `fusion.plating.quality.hold` with `hold_reason='qc_failure'` (new selection value), populated description listing the failed checks, idempotent on retry. **Manager Desk** — 7 new clickable compliance KPI tiles: Missed Bakes (S15), Open Holds (S17 + QC fail), Stale Steps (S10/S16 cron data), Locked Steps (S14), Pending QC + "X need PDF" (S19 + missing-Fischerscope), Draft Certs + "Y today" (cert pipeline). Each tile drills into a list filtered to the relevant exception | `fusion_plating_shopfloor 19.0.24.3.0`, `fusion_plating_quality 19.0.4.8.0` | `sim_tablet_walk.py`, `sim_timer_pred_test.py`, `sim_qc_fail_hold.py`, `sim_manager_qc_fail.py` (one-off persona walkthroughs) |
| **S21** | Riya finished steps on WO-30051 without filling in mandatory recipe-author prompts — Incoming Inspection skipped "Take and Upload Photos" (1/5 missed), Check Sulfamate Nickel Area skipped both masking-verification booleans (2/3 missed). AS9100 audit trail broken on a per-step basis. | (a) `_fp_has_uncaptured_step_inputs` returned False as soon as ANY move with input values existed since `date_started` — too coarse, let operators clear the dialog re-open by saving a single prompt. (b) `button_finish` had NO gate enforcing required step_input coverage — only Contract Review + Receiving gates fired. (c) OWL `Record Inputs` dialog `onSave()` had no client-side check for required prompts either, so operators got zero feedback when leaving fields blank. (d) Also caught: `fp_job_step.py` had **two `def button_finish` in the same class** — Python silently kept only the second definition, so the bake.window auto-spawn + duration-overrun warning at line 596 had been dead code for the entire WO-30051 era. | **Server gate** — new `_fp_check_step_inputs_complete()` on `fp.job.step` raises `UserError` listing every missing required step_input by name. Hooked into `button_finish` ahead of the existing Contract Review + Receiving gates. New helper `_fp_missing_required_step_inputs()` returns the recordset of required prompts with NO recorded value across any move from this step (centralised — used by both the gate and the dialog re-open helper). `_fp_has_uncaptured_step_inputs()` tightened to delegate to the new helper. **Client gate** — `onSave()` on `FpRecordInputsDialog` mirrors the server check when `advanceAfter=true` (Finish & Next path) so operators see a sticky red "Cannot finish step — N required prompts missing: ..." notification instantly rather than after a server roundtrip. Partial saves via the per-row Record button (`advanceAfter=false`) remain unblocked — operators can still capture progress and come back to fill the rest. **Manager bypass** — `fp_skip_required_inputs_gate=True` (documented deviations / paper-form catch-up); posts chatter audit naming the user. **Dead-code merge** — the duplicate `button_finish` at line 596 was deleted; its bake.window auto-spawn + duration-overrun chatter logic was folded into the canonical `button_finish` (which now runs in order: required-inputs gate → CR gate → receiving gate → `super()` → post-finish side effects). **Critical lesson — never put two `def <name>` in the same `models.Model` class body**. Python silently keeps the last one; the earlier definition becomes dead code with no warning. Always grep for duplicates after any structural edit on a long model file. | `fusion_plating_jobs 19.0.10.22.0` | Smoke: `step._fp_missing_required_step_inputs()` on any in_progress step returns the prompt recordset that would block finish. Server: try `step.button_finish()` on a step with required prompts unrecorded — should raise UserError listing them. Manager bypass: `step.with_context(fp_skip_required_inputs_gate=True).button_finish()` succeeds + posts audit. |
| **S22** | Deep-audit finding F1 (2026-05-23) — `fp.job.step.requires_signoff` was 100% unenforced on entech: 42 of 42 done steps with the field set had `signoff_user_id IS NULL`. Recipe authors believed they'd gated aerospace / Nadcap steps; reality was the field was decorative. Pre-Sub-11 the `mrp.workorder.x_fc_signoff_user_id` had working logic, but Sub 11's MRP cutout removed bridge_mrp without porting the gate. | `signoff_user_id` was defined `readonly=True` on `fp.job.step` (from `fusion_plating/models/fp_job_step.py`) but **no code anywhere wrote to it**. No autosign on finish, no UI button, no `action_signoff`. Deep audit caught this because the 42/42 = 100% NULL ratio is the dead giveaway — when a "required" field has zero non-NULL rows across 42 records, the field's enforcement code is missing entirely. | **Three-piece fix on `fp.job.step`**: (1) `_fp_autosign_if_required()` — auto-sets `signoff_user_id = env.user.id` for the user clicking Finish, idempotent (preserves a supervisor's pre-sign via `action_signoff`). (2) `_fp_check_signoff_complete()` — raises `UserError` when `requires_signoff=True` and `signoff_user_id` is still NULL after the autosign helper has run (i.e. migration scripts, background crons with no env.user). (3) `action_signoff()` — explicit sign-off action for the case where a supervisor reviews and signs BEFORE the operator clicks Finish. Same-user re-click is a no-op; a DIFFERENT user re-signing overwrites the prior signer and posts a chatter reassignment ("Sign-off on step X reassigned from A to B"). Both helpers hook into `button_finish` AFTER `_fp_check_step_inputs_complete` and BEFORE the Contract-Review gate. **Manager bypass** — `fp_skip_signoff_gate=True` (documented deviations); posts chatter naming the user. **Lesson — for ANY "required" Boolean field that gates downstream behaviour, ALWAYS deep-audit the enforcement path: search the codebase for writes to the gated field, not just the boolean.** If zero writes exist, the gate is structural / decorative only. Grep the codebase periodically for `_check_*` helpers whose triggering field has no inverse writer. | `fusion_plating_jobs 19.0.10.23.0` | Verified end-to-end on entech: autosign sets signoff_user_id, gate raises UserError with the right message, bypass posts chatter audit, action_signoff sets + posts chatter, and the S21 required-inputs gate still fires (no regression). |
| **S23 (shipped)** | Deep-audit bonus finding (2026-05-23) — `fp.job.step.requires_transition_form` had the same dormant-field shape as S22's signoff bug. The bypass context flag `fp_skip_transition_form` was already wired into the move controller's audit trail, but **no actual gate ever fired** because `_blockers_for_move` only enumerated `rack_required` + `predecessor_lock`. 0 of 286 moves on entech had this set (recipe authors hadn't enabled it), so no current audit gap — but the next recipe author who flips the toggle would discover the same cosmetic-only behaviour Riya found on S21. Caught preventively rather than reactively. NB: numbering conflicts with the open-scenarios list (also lists S23) — accept; the open list will be renumbered in a future doc-cleanup pass. | (a) `_blockers_for_move` in `fusion_plating_shopfloor/controllers/move_controller.py` had no `transition_form_required` case, only rack + predecessor. (b) The Move Rack controller `_do_move_rack_commit` didn't capture transition prompts at all — even if `requires_transition_form` were enforced on Move Parts, rack moves silently bypassed it. (c) The model layer `fp.job.step.move` had no helper to compute "missing required transition inputs", so any backend caller (wizards, scripts) had no way to enforce the contract. | **Model layer** — added two helpers to `fp.job.step.move` (canonical location): `_fp_missing_required_transition_inputs()` returns the recordset of required transition_input prompts on `to_step.recipe_node_id` that have no captured value on the move. `_fp_check_transition_inputs_complete()` raises `UserError` listing the missing prompts, manager bypass via `fp_skip_transition_form=True` (consistent with the existing audit-trail flag, NOT a new flag name), posts chatter on the move record on bypass. **Controller wiring** — `move_parts_commit` calls the gate AFTER `_capture_prompt_value` (so the operator gets credit for whatever they filled in; rollback unwinds the move + values on failure). `move_rack_commit` pre-rejects with a clear message ("use Move Parts so the form can be filled in") because rack moves have no per-batch prompt-capture UI. **Design choice** — gate is invoked explicitly by callers rather than via `create()` override; values are written in a separate call after the move row, so a model-level `create()` hook would always misfire. Future backend wizards / scripts MUST call `_fp_check_transition_inputs_complete()` after capturing prompt values, or pass `fp_skip_transition_form=True` if intentionally bypassing. **Two-layer pattern lesson** — when a recipe-author flag (here `requires_transition_form`) has BOTH a quick path (Move Rack — no form UI) AND a rich path (Move Parts — full form UI), the quick path MUST either implement the form OR reject the operation. A silent quick-path bypass defeats the whole gate. | `fusion_plating 19.0.20.9.0`, `fusion_plating_shopfloor 19.0.30.3.0` | Verified live on entech: helpers callable, move-parts commit raises on missing required prompts, move-rack commit rejects up-front when `to_step.requires_transition_form=True`, manager bypass via context flag posts move-chatter audit. |
### Manager-bypass context flags
@@ -1074,6 +1469,9 @@ When you need to override a guard (documented customer deviation, emergency rewo
| `fp_skip_bake_gate=True` | bake.window pending check on `button_mark_done` (S15) |
| `fp_skip_predecessor_check=True` | requires_predecessor_done check on `button_start` (S14) |
| `fp_skip_missed_window=True` | missed_window block on `bake.window.action_start_bake` (S6) |
| `fp_skip_required_inputs_gate=True` | required step_input prompts check on `fp.job.step.button_finish` (S21). Posts chatter audit naming the user. |
| `fp_skip_signoff_gate=True` | `requires_signoff` + `signoff_user_id` check on `fp.job.step.button_finish` (S22). Posts chatter audit naming the user. Note: button_finish auto-sets signoff_user_id to the finisher first (via `_fp_autosign_if_required`); this bypass only matters when even the autosign can't fire (migration scripts, background crons with no env.user). |
| `fp_skip_transition_form=True` | `requires_transition_form` + required transition_input coverage check on `fp.job.step.move._fp_check_transition_inputs_complete` (S23). Also drops the existing rack-vs-transition-form pre-reject on `move_rack_commit`. Posts chatter audit on the move record. Manager-only — controller checks the `fusion_plating.group_fusion_plating_manager` membership before honoring the flag. |
### Daily / hourly crons added by battle tests
@@ -1085,13 +1483,13 @@ When you need to override a guard (documented customer deviation, emergency rewo
### Open scenarios — flagged for next session
- **S21** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
- **S22** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
- **S23** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
- **S24** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
- **S25** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
- **S26** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
- **S27** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
- **S23** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step (renumbered from S22 when S22 was claimed for the signoff gate)
- **S24** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
- **S25** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
- **S26** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
- **S27** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
- **S28** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
- **S29** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict; renumbered from S21 → S28 → S29)
### Tablet UI / persona-coverage gaps (S20 audit follow-ups)
@@ -1327,6 +1725,8 @@ Customer feedback: "too many top-level menus" + "configuration is unorganized".
- Settings → Fusion Plating → Plating Landing Page block (company default).
- `fusion_plating_configurator`'s earlier menu_fp_root override (action_fp_sale_orders direct) was removed — core's resolver now owns the routing.
- Pickable list is curated via inline `<field name="x_fc_pickable_landing" eval="True"/>` on action records — currently flagged: `action_fp_sale_orders`, `action_fp_quotations`, `action_fp_process_recipe`. Add more by tagging the relevant act_window record at its source.
- **`x_fc_pickable_landing` lives on `ir.actions.actions` (BASE)** so the picker dropdown on `res.users.x_fc_plating_landing_action_id` can offer BOTH act_window records (Sale Orders, Quotations, Process Recipes) AND client-action records (Manager Desk, Plant Kanban, Quality Dashboard). The picker Many2one points at `ir.actions.actions` (not `act_window`); the domain `[('x_fc_pickable_landing', '=', True)]` filters across all action types. `_render_resolved()` on the base dispatches to the correct subclass by `type`. **Pickable accessibility compute MUST be `sudo()`'d** — non-admin users (Technician, Sales Rep) lack read access on `ir.actions.actions` and opening their own Preferences dialog would AccessError otherwise; the per-user `check_access_rights` per-action still runs unprivileged so the picklist filters correctly. Tag a new landing candidate by adding `<field name="x_fc_pickable_landing" eval="True"/>` to its `<record>` definition — works regardless of whether the model is `ir.actions.act_window` or `ir.actions.client`.
- **Role-based dispatch** (Phase E): the resolver now reads `res.users` group membership and routes by precedence — Owner → Manager Desk; QM → Quality Dashboard; Manager → Manager Desk; Sales Manager → Sale Orders; Shop Manager → Plant Kanban/Workstation; Sales Rep → Quotations; Technician → Plant Kanban/Workstation. `_fp_workstation_action_for_layout()` reads `ir.config_parameter['fusion_plating_shopfloor.layout']` (v2 vs legacy) so flipping the flag retargets every Tech/Shop Manager on next page load. Per-user override still wins. Picklist domain is tightened via `res.users.accessible_landing_action_ids` (compute that runs `check_access_rights('read')` per pickable action) so a Tech can't pick "Manager Desk" they can't see.
### Phase 2 — Configuration sub-folder grouping (`fusion_plating` 19.0.11.1.0, commits `3641b78` + `62c1315` + `4671541`)

BIN
fusion_plating/docs/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,160 @@
# Express Orders — Brainstorming Handoff (2026-05-25)
**Status:** Mid-brainstorming. Clarifying questions answered. NOT yet at "propose 2-3 architectural approaches" or "present design sections" stages.
**Why this handoff exists:** Previous session ran on a Windows machine (the user works from a Mac via Tailscale). Browser preview kept fighting cross-network localhost issues. User asked to restart natively on Mac. This doc preserves everything the brainstorming had reached so the new Mac session resumes instantly without re-asking.
**How to resume:** When the user opens Claude Code on their Mac and points it at this repo, kick off with:
> "Resume the Express Orders brainstorming from `docs/superpowers/handoffs/2026-05-25-express-orders-brainstorm-handoff.md`. Skip the visual companion entirely — design in text using the Excel mockup the user already sent. Pick up at 'propose 2-3 architectural approaches'."
---
## What "Express Orders" is
A new sale-order entry surface that will eventually replace the current Direct Order Wizard. UX inspiration: a customer-shared Excel mockup showing a spreadsheet-style flat entry — header grid on top with customer/PO/job#/material-process/lead-time/terms/delivery, line table in the middle with per-row Part-#/Description/Specification/Job#/Thickness/Masking-checkbox/Baking-pill/Notes/Qty/UOM/Price/Subtotal + per-line Upload-Drawing + Open-Part buttons, footer with sub-total/tooling/tax/currency-selector/grand-total.
Goal: faster repeat-customer order entry. Type once on the line, no jumping to a separate part screen for routine work, every column inline.
---
## Clarifying questions ANSWERED
| # | Question | Answer | Implication |
|---|---|---|---|
| 1 | Model strategy | **D — new view on existing `fp.direct.order.wizard`** | Reuse the 500+ lines of onchange / recipe-cloning / spec-auto-fill / thickness-carry / tax-seeding logic that already debugged. Add masking/baking/currency fields to the existing model. Write a new "Express" form view. Retire the old direct-order view. Open drafts seamlessly become Express Orders (same DB rows). |
| 2 | Specification text storage | **Free-text on the part**`default_specification_text` Text field on `fp.part.catalog` | Type once → saves to part. Next order for same part → auto-fills. Cell value writes to `sale.order.line.name` (customer-facing). Bypasses the structured `fusion.plating.customer.spec` model entirely — simpler. |
| 3 | Per-line Job# column (ABC/DEF/GHJ) | **NEW per-line customer sub-job-ref field** | Header keeps `x_fc_customer_job_number` (e.g. 12345). Add NEW `x_fc_customer_line_ref` Char on sale.order.line. Both print on customer docs. |
| 4 | Masking checkbox scope | **Both masking AND de-masking together** | Unchecking creates override(included=False) for every node where `default_kind` IN ('masking', 'de_masking'). Logical pairing — can't unmask what was never masked. |
| 5 | Baking field shape | **Free-text input + auto-fill from part default** | Add `default_bake_instructions` Text on `fp.part.catalog`. Type once → saves. Next order → auto-fills. Empty cell = exclude baking node (override included=False on all baking-kind nodes). Non-empty cell = include baking node + write the text to `fp.job.step.instructions` for the bake step at job-creation time. |
| 6 | Currency mechanic | **Pricelist-per-currency, labelled "Currency"** | Selector shows currencies the company has pricelists for. Picking USD → looks up company's USD pricelist → sets `sale.order.pricelist_id`. `currency_id` flows from there. Admin must configure one pricelist per currency. |
| 7-14 | 8 default interpretations | **All 8 accepted** | PO Pending = keep existing flag + chase mechanism; Material Process = new informational Char; Upload Part Drawing = per-line button to part.drawing_attachment_ids; Create Part = per-line modal opening fp.part.catalog form; Lead Time = reuse min/max days; Blanket SO = reuse boolean; Delivery Method = reuse x_fc_delivery_method; phase-out path = both menus visible initially, retire old view after Express is stable on entech. |
---
## Exploration findings (already verified by reading source — these are GROUND TRUTH)
### Existing `fp.direct.order.wizard` model
- Persistent (not transient) — state machine `draft → confirmed → cancelled`
- File: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`
- Line model: `fp.direct.order.line` in `fusion_plating_configurator/wizard/fp_direct_order_line.py`
- Creates SO in quotation state on confirm. Does NOT auto-confirm SO or auto-email.
### Existing `sale.order.line` x_fc_* fields (verified — keep using these)
- `x_fc_part_catalog_id` Many2one(fp.part.catalog)
- `x_fc_internal_description` Text REQUIRED — Notes column maps here
- `x_fc_description_template_id` Many2one
- `x_fc_serial_ids` Many2many(fp.serial) + `x_fc_serial_id` (primary, computed)
- `x_fc_thickness_range` Char — Thickness column maps here
- `x_fc_revision_snapshot` Char (frozen at line save time)
- `x_fc_process_variant_id` Many2one(fusion.plating.process.node) — recipe
- `x_fc_save_as_default_process` Boolean
- `x_fc_job_number` Char — shop's auto-sequenced ref (NOT customer's; that's the new x_fc_customer_line_ref)
- `x_fc_customer_job_number` related from order
- `x_fc_po_number` related from order
- `x_fc_part_deadline` Date + offset_days + effective computes
- `x_fc_archived` Boolean
- `name` (Odoo standard) = customer-facing description — Specification column writes here
### NEW fields the Express Orders feature must add
On `sale.order.line`:
- `x_fc_customer_line_ref` Char — per-line customer sub-job (the ABC/DEF/GHJ column)
- `x_fc_masking_enabled` Boolean default=True — Masking checkbox
- `x_fc_bake_instructions` Text — Baking free-text
On `fp.part.catalog`:
- `default_specification_text` Text — for Spec auto-fill
- `default_bake_instructions` Text — for Baking auto-fill
On `fp.direct.order.wizard` (header):
- `material_process` Char — informational order-level tag (ENP-STEEL-HP-ADVANCED)
On `fp.direct.order.line` (wizard mirror, to be carried to SO line on confirm):
- `customer_line_ref` Char
- `masking_enabled` Boolean default=True
- `bake_instructions` Text
### Existing `fp.job.node.override` model (verified — schema is exactly 3 fields)
File: `fusion_plating_jobs/models/fp_job_node_override.py`
- `job_id` Many2one(fp.job) required ondelete=cascade
- `node_id` Many2one(fusion.plating.process.node)
- `included` Boolean
**No instructions-text override field.** For the bake free-text feature, do NOT extend this model — instead write the typed text to `fp.job.step.instructions` directly at job-creation time. Simpler, less schema churn.
### Existing recipe model (`fusion.plating.process.node`)
File: `fusion_plating/models/fp_process_node.py`
- `default_kind` is a Char (line 526), not Selection — flexible
- Values seen: `masking`, `de_masking`, `baking`, plating, inspection, contract_review, racking, etc.
- `node_type` is a Selection (line 54): `opt_in`, `opt_out`, `mandatory`, `recipe`
### Job-creation hook
File: `fusion_plating_jobs/models/sale_order.py``action_confirm()` calls `_fp_auto_create_job()` which groups lines by recipe. New Express-Orders-driven overrides should be injected here:
- Walk each SO line
- Resolve the line's recipe
- If `x_fc_masking_enabled == False`, create `fp.job.node.override(included=False)` for every node in recipe where `default_kind IN ('masking', 'de_masking')`
- If `x_fc_bake_instructions` empty, create `fp.job.node.override(included=False)` for every node in recipe where `default_kind == 'baking'`
- If `x_fc_bake_instructions` non-empty, write the text to `fp.job.step.instructions` for the baking step (find by recipe_node_id.default_kind == 'baking')
### Currency
- `sale.order.currency_id` related from `pricelist_id.currency_id` (Odoo native)
- No multi-currency customisations in fusion_plating modules — using Odoo standard
- Per the answer to Q6, the Express Orders feature adds a selector on the wizard that looks up the matching pricelist by currency code
### `fp.part.catalog` (file: `fusion_plating_configurator/models/fp_part_catalog.py`)
Verified key fields:
- `x_fc_default_customer_spec_id` (added by quality module) — NOT used by Express Orders per Q2
- `x_fc_default_thickness_range` Char
- `description_template_ids` O2M(fp.sale.description.template) with internal + customer descriptions
- `drawing_attachment_ids` M2M(ir.attachment) — Upload Part Drawing button writes here
- `model_attachment_id` M2O — 3D model
- `x_fc_certificate_requirement` Selection — inherit/none/coc/coc_thickness
---
## What's LEFT to do (resume here)
1. ✅ Exploration complete
2. ✅ Clarifying questions complete
3.**Propose 2-3 architectural approaches** with the answered constraints baked in. Lead with recommendation. (Most of the architectural picture is already settled by Q1=D; this section should be brief — mostly the "where exactly does the recipe override logic live: in the wizard's `_prepare_order_vals`, or post-confirm in `_fp_auto_create_job`, or a model hook?")
4.**Present design in sections**, get approval after each:
- Section 1 — Header layout + field-to-model mapping
- Section 2 — Line widget design (the spreadsheet table behavior)
- Section 3 — Masking + baking override flow at job creation
- Section 4 — Currency switcher mechanic
- Section 5 — Inline part create + drawing upload buttons
- Section 6 — Phase-out path for direct order
5.**Write design doc** to `docs/superpowers/specs/2026-05-25-express-orders-design.md` and commit
6.**Spec self-review** + user review gate
7.**Transition to writing-plans** skill (after user approves spec)
---
## Lesson: SKIP the visual companion on this user's setup
The user runs Claude Code from their **Mac** via Tailscale into a **Windows** machine (this `Home` host). Browser previews bound to `localhost` on the Windows side are unreachable from the Mac browser. The bash-script brainstorm server (port 65170) hit this. The Python http.server (port 8765) hit this. We spent 20 minutes fighting it.
**Resolution:** the user said to switch to a native Mac Claude Code session entirely. On Mac the visual companion should "just work" — but consider whether it's necessary. The user already provided an Excel mockup as reference. Text-based design discussion using ASCII tables / structured lists is plenty for this feature. Don't push the visual companion unless the user explicitly asks for it.
---
## Recent in-flight work (NOT Express Orders, but on the same Windows host)
For context: this Windows session also shipped these things today (2026-05-25) — they're DEPLOYED on entech but may need follow-up on the Mac:
1. **Tablet PIN self-service** (cycle 4) — fully shipped, code 4018 last sent. `fusion_plating_shopfloor` 19.0.36.0.3. Three improvements during the day: SCSS undefined-variable bug fix, switch to `force_send=True`, mail template `email_from` aligned to mail-server `from_filter` (fixes M365 DMARC misalignment / delivery delay).
2. **fp_shopfloor_landing removal** — entire OWL component deleted, QR scanner ported into `fp_plant_kanban`, all references cleaned up. Same module version above.
3. **Tablet lock-screen orphan localStorage** — cleared `fp_landing_station_id` to fix empty-tiles bug.
All committed to git on `main`. Pushed via the multi-remote (GitHub + Gitea). On Mac, you'll need to `git pull origin main` from the fresh local clone before doing anything.
---
## Files written this session that the new Mac session should know exist
- `K:\Github\Odoo-Modules\fusion_plating\.claude\launch.json` — Mockups preview config (port 8765). Mac equivalent path will be the same but on local Mac filesystem.
- `K:\Github\Odoo-Modules\fusion_plating\.claude\mockups\index.html` — 15KB layout-direction mockup with A/B comparison. Can be deleted (was for the failed Windows-side preview); if you want to show it on Mac it works fine.
- `K:\Github\Odoo-Modules\fusion_plating\.superpowers\brainstorm\944-1779751836\` — dead bash-script brainstorm server directory; safe to delete.
- This handoff doc.
End of handoff.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,784 @@
# Recipe Cleanup + Receiving Enforcement Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix recipe 3620 ENP-ALUM-BASIC's duplicate-sequence bug, delete all 24 per-part clone recipes, backfill `kind=other` nodes via an extended name resolver, add an auto-classify hook on every node create/write, and make `no_parts` cards always land in the Receiving column.
**Architecture:** One migration in `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` does all the data work in 5 phases (resequence 3620 → backfill kinds → delete clones → recompute step.area_kind → recompute job.active_step_id + card_state). Two code-side changes: extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping, add `_fp_autoclassify_kind()` to `fusion.plating.process.node.create/write` so future authoring + recipe duplication self-correct.
**Spec:** [docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md](../specs/2026-05-24-recipe-cleanup-design.md)
**Tech Stack:** Odoo 19, Python (ORM/migrations), PostgreSQL.
---
## File Inventory
| Path | Responsibility |
|---|---|
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME` aliases; add parenthetical-strip to `fp_resolve_step_kind()`; expose `RESOLVER_KIND_TO_ACTIVE_KIND` map |
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + create/write hooks |
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase data migration |
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving column override in `_resolve_card_area` |
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
---
## Task 1: Extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping
**Files:**
- Modify: `fusion_plating/__init__.py:208-304`
- [ ] **Step 1: Add `re` to imports**
At the top of `fusion_plating/__init__.py`, after the existing `import logging` line, add:
```python
import re
```
- [ ] **Step 2: Extend `_STARTER_KIND_BY_NAME`**
Find the dict at line 208. Inside the dict (before the closing `}`), add the following keys (preserve the existing entries):
```python
# 2026-05-24 — Recipe cleanup additions (live-step fix follow-up).
# Blasting variants
'blasting': 'blast',
'bead blast': 'blast',
'bead blasting': 'blast',
'media blast': 'blast',
'media blasting': 'blast',
# Inspection variants the resolver didn't know
'adhesion test coupon': 'inspect',
'adhesion testing': 'inspect',
'corrosion testing': 'inspect',
'lab testing': 'inspect',
'check sulfamate nickel area': 'inspect',
'pre-measurements': 'inspect',
'pre measurements': 'inspect',
'hot water porosity': 'inspect',
# Strip / chemical conversion / plugging (wet line)
'strip process': 'wet_process',
'strip process - al': 'wet_process',
'nickel strip - aluminum line': 'wet_process',
'chemical conversion': 'wet_process',
'trivalent chromate conversion': 'wet_process',
'plug the threaded holes': 'mask',
# Misc wet line variants seen on entech recipes
'air dry': 'dry',
'desmut': 'etch',
'soak clean': 'cleaning',
'cleaner': 'cleaning',
'nickel strike': 'plate',
'nickel strip': 'plate',
```
- [ ] **Step 3: Add parenthetical stripping inside `fp_resolve_step_kind()`**
Find the function around line 288. Replace its body:
```python
def fp_resolve_step_kind(name):
"""Resolve a step name to a default_kind, tolerant of whitespace and
case. Used by both the seeder and the migration backfill so we don't
have two slightly-different lookup paths.
Handles parenthetical suffixes like "(Standard)", "(If Required)",
"(A-14 / A)" by stripping them before the second lookup attempt.
Returns the kind str or None when no match.
"""
if not name:
return None
key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key]
# Parenthetical strip — "Masking (If Required)" → "Masking",
# "Incoming Inspection (Standard)" → "Incoming Inspection".
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[bare]
# Gating "Ready for / Ready For" prefix — anything starting with that
# is a gating node regardless of the destination step name.
if key.startswith('ready for ') or key.startswith('ready '):
return 'gating'
return None
```
- [ ] **Step 4: Add `RESOLVER_KIND_TO_ACTIVE_KIND` translation map**
Right after the `fp_resolve_step_kind` function (around line 305), add:
```python
# Translates the resolver's kind output to the active fp.step.kind.code
# values. The resolver still returns the OLD vocabulary (cleaning,
# electroclean, etch, rinse, strike, dry, wbf_test) which were
# deactivated in 19.0.20.6.0 — those roll up to the active wet_process
# kind. Other codes pass through 1:1.
RESOLVER_KIND_TO_ACTIVE_KIND = {
# Wet-line kinds → wet_process (active rollup)
'cleaning': 'wet_process',
'electroclean': 'wet_process',
'etch': 'wet_process',
'rinse': 'wet_process',
'strike': 'wet_process',
'dry': 'wet_process',
'wbf_test': 'wet_process',
# 1:1 mappings (kind exists and is active)
'contract_review': 'contract_review',
'mask': 'mask',
'racking': 'racking',
'plate': 'plate',
'bake': 'bake',
'derack': 'derack',
'demask': 'demask',
'inspect': 'inspect',
'final_inspect': 'final_inspect',
'ship': 'ship',
'gating': 'gating',
'blast': 'blast',
}
```
- [ ] **Step 5: Confirm import structure (no commit yet)**
Run:
```bash
grep -n "^import re\|^from\|^import" fusion_plating/fusion_plating/__init__.py | head -5
```
Expected: `import re` appears before `from . import controllers`.
Commit happens in Task 3.
---
## Task 2: Auto-classify hook on `fusion.plating.process.node`
**Files:**
- Modify: `fusion_plating/models/fp_process_node.py`
- [ ] **Step 1: Find an insertion point near the existing `create/write/copy` methods**
In [`fusion_plating/models/fp_process_node.py`](../../fusion_plating/models/fp_process_node.py), find the `copy()` method around line 789 (it's at the bottom of the FpProcessNode class). The autoclassify helper goes near it, and the create/write overrides slot in alongside copy.
- [ ] **Step 2: Add the helper + create/write overrides**
In `FpProcessNode`, add this block right before the `copy()` method at line ~787. Insert AFTER all the other fields/methods but BEFORE `copy()`:
```python
# ---- Auto-classify kind from name (2026-05-24) ----------------------
# Safety net: when a node's kind is the catch-all 'other' AND its
# name resolves via fp_resolve_step_kind(), upgrade kind_id to the
# resolved active kind. Runs on create() and on write() when name
# or kind_id changes. Prevents recipe authoring + recipe duplication
# from silently leaving nodes as 'other' (which then routes them to
# the wrong Shop Floor column).
#
# Skip with context flag fp_skip_kind_autoclassify=True for admin
# workflows that need to keep kind=other despite a known name.
def _fp_autoclassify_kind(self):
"""Upgrade kind_id when current is 'other' and name resolves."""
if self.env.context.get('fp_skip_kind_autoclassify'):
return
from odoo.addons.fusion_plating import (
fp_resolve_step_kind,
RESOLVER_KIND_TO_ACTIVE_KIND,
)
Kind = self.env['fp.step.kind']
other = Kind.search([('code', '=', 'other')], limit=1)
if not other:
return
for node in self:
if not node.name or node.kind_id != other:
continue
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code:
continue
target = Kind.search([('code', '=', target_code)], limit=1)
if target:
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'kind_id': target.id})
@api.model_create_multi
def create(self, vals_list):
nodes = super().create(vals_list)
nodes._fp_autoclassify_kind()
return nodes
def write(self, vals):
res = super().write(vals)
if 'name' in vals or 'kind_id' in vals:
self._fp_autoclassify_kind()
return res
```
- [ ] **Step 3: Verify the file parses (no commit yet)**
Run:
```bash
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating/models/fp_process_node.py').read()); print('OK')"
```
Expected: `OK`.
Commit happens in Task 3.
---
## Task 3: Version bump fusion_plating + commit Phase 1
**Files:**
- Modify: `fusion_plating/__manifest__.py`
- [ ] **Step 1: Bump the version**
In [`fusion_plating/__manifest__.py`](../../fusion_plating/__manifest__.py), change:
```python
'version': '19.0.21.2.0',
```
to:
```python
'version': '19.0.21.3.0',
```
- [ ] **Step 2: Commit Phase 1**
```bash
git add fusion_plating/fusion_plating/__init__.py \
fusion_plating/fusion_plating/models/fp_process_node.py \
fusion_plating/fusion_plating/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(fusion_plating): extend resolver + auto-classify hook on process node
Resolver (fp_resolve_step_kind) extensions:
- New aliases: blasting/bead blast/media blast variants, adhesion
testing, corrosion testing, lab testing, strip process, chemical
conversion, trivalent chromate, plug the threaded holes, air dry,
desmut, soak clean, cleaner, nickel strike/strip
- Parenthetical suffix stripping — "Masking (If Required)" resolves
through "masking", "Incoming Inspection (Standard)" through
"incoming inspection"
- New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's
vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test
→ wet_process) so the resolver output lands on active kinds only
Auto-classify hook on fusion.plating.process.node:
- _fp_autoclassify_kind() upgrades kind_id when current is 'other'
AND name resolves via the resolver. Idempotent — never overrides
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify
- Wired into create() and write() (only fires when name or kind_id
changed on write)
- Side-effects: recipe duplication via copy() auto-corrects newly
copied nodes; Simple/Tree editor authoring auto-classifies as soon
as the name is saved
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Write the 19.0.10.26.0 migration
**Files:**
- Create: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`
- [ ] **Step 1: Create the migration directory**
```bash
mkdir -p fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0
```
- [ ] **Step 2: Write the migration file**
Create `fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.10.26.0 — Recipe cleanup + per-part clone delete.
Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
Phases (in order):
1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the
duplicate empty ENP-Alum Line sub_process (id 4056).
2. Backfill kind on all kind=other nodes via the extended
fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND
translation.
3. Delete all 24 per-part clone recipes (name ILIKE '%%').
CASCADE handles child nodes; SET NULL handles fp.job /
fp.job.step / fp.coating.config / fp.pricing.rule /
fp.part.catalog references.
4. Recompute fp.job.step.area_kind on all rows.
5. Recompute fp.job.active_step_id + card_state on in-flight jobs.
All phases idempotent — re-running -u is safe.
"""
import logging
from odoo.api import Environment, SUPERUSER_ID
_logger = logging.getLogger(__name__)
# Recipe 3620's ops in the desired final order. Maps the existing node
# id (as documented in the spec) to its target sequence. The user
# decided mask-first-then-rack per spec Section "Mask vs Rack order".
RECIPE_3620_RESEQUENCE = [
# (node_id, new_sequence, expected_name)
(3853, 10, 'Contract Review'),
(3854, 20, 'Incoming Inspection (Standard)'),
(3877, 30, 'Masking'),
(3855, 40, 'Racking'),
(3858, 50, 'Ready for processing'),
(3859, 60, 'ENP-Alum Line'),
(3861, 70, 'De-Masking'),
(3864, 80, 'Oven baking'),
(3867, 90, 'De-racking'),
(4067, 100, 'Oven bake (Post de-rack)'),
(3873, 110, 'Post-plate Inspection'),
(3876, 120, 'Final Inspection'),
]
# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no
# children — the real one is id 3859 with E-Nickel Plating as child).
RECIPE_3620_DUPLICATE_TO_DELETE = 4056
def migrate(cr, version):
env = Environment(cr, SUPERUSER_ID, {})
# ============================================================
# Phase 1 — Resequence recipe 3620 + delete duplicate sub_process
# ============================================================
Node = env['fusion.plating.process.node']
recipe_3620 = Node.browse(3620).exists()
if not recipe_3620:
_logger.warning(
'[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; '
'skipping resequence phase'
)
else:
# Verify the expected nodes exist, then resequence them.
# We do this idempotently — only update if the sequence
# differs from the target.
renumbered = 0
for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE:
node = Node.browse(node_id).exists()
if not node:
_logger.warning(
'[recipe-cleanup] Recipe 3620: expected node %s '
'("%s") not found; skipping',
node_id, expected_name,
)
continue
if node.sequence != new_seq:
# Skip the autoclassify hook on this write (nothing
# changes about kind_id; we're only touching sequence).
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'sequence': new_seq})
renumbered += 1
_logger.info(
'[recipe-cleanup] Recipe 3620: %s nodes resequenced',
renumbered,
)
# Delete the empty duplicate ENP-Alum Line sub_process.
dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists()
if dup:
if dup.child_ids:
_logger.warning(
'[recipe-cleanup] Duplicate sub_process %s has '
'%s children — NOT deleting (safety check). '
'Expected an empty node.',
dup.id, len(dup.child_ids),
)
else:
dup.unlink()
_logger.info(
'[recipe-cleanup] Deleted empty duplicate '
'ENP-Alum Line sub_process (id %s)',
RECIPE_3620_DUPLICATE_TO_DELETE,
)
# ============================================================
# Phase 2 — Backfill kind on all kind=other nodes via resolver
# ============================================================
from odoo.addons.fusion_plating import (
fp_resolve_step_kind,
RESOLVER_KIND_TO_ACTIVE_KIND,
)
Kind = env['fp.step.kind']
other_kind = Kind.search([('code', '=', 'other')], limit=1)
if not other_kind:
_logger.error(
'[recipe-cleanup] No "other" kind found; skipping kind '
'backfill phase'
)
else:
# Build a cache of code → kind.id so we don't search per-row
kind_by_code = {k.code: k.id for k in Kind.search([])}
affected_nodes = Node.search([
('kind_id', '=', other_kind.id),
('name', '!=', False),
('node_type', 'in', ('operation', 'step', 'sub_process')),
])
fixed = 0
for node in affected_nodes:
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code or target_code not in kind_by_code:
continue
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'kind_id': kind_by_code[target_code]})
fixed += 1
_logger.info(
'[recipe-cleanup] Phase 2: backfilled kind on %s nodes '
'(of %s currently kind=other)',
fixed, len(affected_nodes),
)
# ============================================================
# Phase 3 — Delete all 24 per-part clone recipes
# ============================================================
# Identify by name pattern. The configurator names clones
# "BASE_NAME — PART_NUMBER Rev X" with an em-dash separator.
# No base recipe uses em-dash in its name.
clone_recipes = Node.search([
('node_type', '=', 'recipe'),
('name', 'ilike', '%%'),
])
if clone_recipes:
# Log what we're about to delete for forensic visibility.
clone_names = [c.name for c in clone_recipes]
_logger.info(
'[recipe-cleanup] Phase 3: deleting %s clone recipes: %s',
len(clone_recipes),
', '.join(clone_names[:10])
+ ('' if len(clone_names) > 10 else ''),
)
clone_recipes.unlink()
_logger.info(
'[recipe-cleanup] Phase 3: deleted %s clone recipes '
'(CASCADE removed their child nodes; FK SET NULL applied '
'to historical fp.job + fp.job.step references)',
len(clone_recipes),
)
else:
_logger.info(
'[recipe-cleanup] Phase 3: no clone recipes found '
'(already deleted on a prior run, or none exist)'
)
# ============================================================
# Phase 4 — Recompute area_kind on all fp.job.step rows
# ============================================================
# After Phase 2, many recipe nodes have new kinds. After Phase 3,
# some fp.job.step rows have NULL recipe_node_id (FK SET NULL'd
# when the clone got deleted). Recompute picks up the new kinds
# for active recipes and falls back to catch-all 'plating' for
# orphans (all historical / terminal jobs — won't show on board).
Step = env['fp.job.step']
steps = Step.search([])
if steps:
steps._compute_area_kind()
steps.flush_recordset(['area_kind'])
_logger.info(
'[recipe-cleanup] Phase 4: recomputed area_kind on %s steps',
len(steps),
)
# ============================================================
# Phase 5 — Recompute active_step_id + card_state on in-flight jobs
# ============================================================
Job = env['fp.job']
jobs = Job.search([
('state', 'in', ('confirmed', 'in_progress')),
])
if jobs:
jobs._compute_active_step_id()
jobs._compute_card_state()
jobs.flush_recordset(['active_step_id', 'card_state'])
_logger.info(
'[recipe-cleanup] Phase 5: recomputed active_step_id + '
'card_state on %s in-flight jobs',
len(jobs),
)
```
- [ ] **Step 3: Verify the file parses**
Run:
```bash
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py').read()); print('OK')"
```
Expected: `OK`.
---
## Task 5: Version bump fusion_plating_jobs
**Files:**
- Modify: `fusion_plating_jobs/__manifest__.py`
- [ ] **Step 1: Bump the version**
In [`fusion_plating_jobs/__manifest__.py`](../../fusion_plating_jobs/__manifest__.py), change:
```python
'version': '19.0.10.25.0',
```
to:
```python
'version': '19.0.10.26.0',
```
- [ ] **Step 2: No commit yet — grouped with Task 6's commit.**
---
## Task 6: `no_parts` cards always show in Receiving column
**Files:**
- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py:165-180`
- Modify: `fusion_plating_shopfloor/__manifest__.py`
- [ ] **Step 1: Update `_resolve_card_area`**
In [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../fusion_plating_shopfloor/controllers/plant_kanban.py), find `_resolve_card_area` (around line 165). Replace its body with:
```python
def _resolve_card_area(job):
"""Pick the column a card lives in.
Active-step area_kind wins, EXCEPT for no_parts cards which always
land in Receiving regardless of active step — the receiver is who
needs to act, and they work the Receiving column. With the live-step
priority chain (see fp.job._compute_active_step_id), active_step_id
is False only when the job has NO steps at all (recipe not assigned)
OR every step is `done`. Done jobs are filtered off the board
upstream, so the orphan fallback fires only for truly orphaned cards.
See spec 2026-05-24-recipe-cleanup-design.md Change 6.
"""
# no_parts cards belong in Receiving regardless of where the active
# step is — the receiver is who acts.
if job.card_state == 'no_parts':
return 'receiving'
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
# Orphan fallback — represents a data integrity issue, not a
# normal state. Cards here have NO steps assigned at all.
return 'receiving'
```
- [ ] **Step 2: Bump fusion_plating_shopfloor manifest**
In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py):
```python
'version': '19.0.33.1.3',
```
to:
```python
'version': '19.0.33.1.4',
```
- [ ] **Step 3: Commit Phase 2 (Tasks 4-6)**
```bash
git add fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
fusion_plating/fusion_plating_jobs/__manifest__.py \
fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix
Migration 19.0.10.26.0/post-migrate.py runs in 5 phases:
1. Resequence recipe 3620 ENP-ALUM-BASIC ops (fixes the duplicate-
sequence bug that caused WO-30057 to skip Receiving)
2. Backfill kind on all kind=other nodes via the extended resolver
from fusion_plating 19.0.21.3.0
3. Delete all 24 per-part clone recipes
4. Recompute fp.job.step.area_kind on all steps
5. Recompute fp.job.active_step_id + card_state on in-flight jobs
Plant kanban: no_parts cards now always land in the Receiving column
regardless of active_step area_kind. The receiver works Receiving;
that's where the card belongs when parts haven't arrived.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Deploy to entech + verify
- [ ] **Step 1: Fetch + check concurrent commits**
```bash
git fetch origin
git log HEAD..origin/main --oneline
```
Expected: empty (we're ahead, not behind). If anything shows, rebase first.
- [ ] **Step 2: Copy modified files to entech**
```bash
for f in \
fusion_plating/__init__.py \
fusion_plating/models/fp_process_node.py \
fusion_plating/__manifest__.py \
fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
fusion_plating_jobs/__manifest__.py \
fusion_plating_shopfloor/controllers/plant_kanban.py \
fusion_plating_shopfloor/__manifest__.py; do
echo "Copying $f"
cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \"mkdir -p \\\$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f\""
done
echo "=== ALL COPIED ==="
```
(Run from `/Users/gurpreet/Github/Odoo-Modules/fusion_plating/` so the file paths line up.)
- [ ] **Step 3: Upgrade modules + restart**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -60 && systemctl start odoo && sleep 3 && systemctl is-active odoo'"
```
Expected log lines (in order):
- `[recipe-cleanup] Recipe 3620: N nodes resequenced`
- `[recipe-cleanup] Deleted empty duplicate ENP-Alum Line sub_process (id 4056)`
- `[recipe-cleanup] Phase 2: backfilled kind on N nodes …`
- `[recipe-cleanup] Phase 3: deleting 24 clone recipes: …`
- `[recipe-cleanup] Phase 3: deleted 24 clone recipes …`
- `[recipe-cleanup] Phase 4: recomputed area_kind on N steps`
- `[recipe-cleanup] Phase 5: recomputed active_step_id + card_state on N in-flight jobs`
- Service prints `active` at the end.
No tracebacks. If you see one, STOP and report it.
- [ ] **Step 4: SQL spot-check — clones deleted**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT COUNT(*) AS clones_remaining FROM fusion_plating_process_node WHERE node_type='\\''recipe'\\'' AND name ILIKE '\\''% — %'\\'';\" | sudo -u postgres psql -d admin'"
```
Expected: `clones_remaining = 0`.
- [ ] **Step 5: SQL spot-check — recipe 3620 resequenced**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT sequence, name FROM fusion_plating_process_node WHERE parent_id = 3620 AND node_type IN ('\\''operation'\\'', '\\''sub_process'\\'') ORDER BY sequence;\" | sudo -u postgres psql -d admin'"
```
Expected output (12 unique-sequence rows):
```
10 | Contract Review
20 | Incoming Inspection (Standard)
30 | Masking
40 | Racking
50 | Ready for processing
60 | ENP-Alum Line
70 | De-Masking
80 | Oven baking
90 | De-racking
100 | Oven bake (Post de-rack)
110 | Post-plate Inspection
120 | Final Inspection
```
NO duplicate sequences. NO second ENP-Alum Line row.
- [ ] **Step 6: SQL spot-check — kind=other nodes backfilled**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT n.name, COUNT(*) AS still_other FROM fusion_plating_process_node n JOIN fp_step_kind k ON k.id = n.kind_id WHERE k.code = '\\''other'\\'' AND n.node_type IN ('\\''operation'\\'', '\\''step'\\'', '\\''sub_process'\\'') GROUP BY n.name ORDER BY still_other DESC;\" | sudo -u postgres psql -d admin'"
```
Expected: very few rows, only names like `ENP-Alum Line - HP` (sub_process with no clear category) or genuinely-niche operation names. Should NOT include `Contract Review`, `Masking`, `Racking`, `Incoming Inspection`, `E-Nickel Plating`, `Final Inspection`, `Shipping`, `Bake`, `Blasting`, `De-Masking`, `De-racking`, `Hot Water Porosity`, etc.
- [ ] **Step 7: End-to-end smoke**
On the entech UI:
1. Open Plating → Sales & Quoting → Sale Orders → New
2. Add a customer + a part whose default recipe is `ENP-ALUM-BASIC` (id 3620)
3. Confirm the SO
4. Check the new WO on Plating → Operations → Plating Jobs:
a. Recipe should be a fresh clone named `ENP-ALUM-BASIC — <PART#> Rev <X>`
b. The clone's first 4 operations should be: Contract Review (10), Incoming Inspection (20), Masking (30), Racking (40)
5. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' AND/OR because Incoming Inspection is now the next live step after Contract Review auto-completes)
6. Open Plating → Configuration → Recipes & Steps → Recipes — confirm no recipe has " — " in its name (the clones are gone)
- [ ] **Step 8: Autoclassify hook smoke**
In the Simple Editor on any recipe:
1. Drop a new step, type name "Masking" without picking a kind
2. Save
3. Refresh the page
4. Confirm the step's kind reads "Masking" (not "Other")
---
## Task 8: Commit spec + plan, push to origin
- [ ] **Step 1: Stage and commit the spec + plan docs**
```bash
git add fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md \
fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md
git commit -m "$(cat <<'EOF'
docs(plating): spec + plan for recipe cleanup + receiving enforcement
Spec documents:
- Root cause 1: duplicate sequences on recipe 3620 ENP-ALUM-BASIC
- Root cause 2: 24 per-part clone recipes carrying the broken order
- Root cause 3: ~10 kind=other stragglers across base recipes
- Root cause 4: recipe duplication has no kind safety net
Implementation shipped in commits referenced from the plan's task list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 2: Final fetch + push**
```bash
git fetch origin
git log HEAD..origin/main --oneline # expect empty
git push origin main
```
---
## Rollback
If anything fails on entech:
1. `git reset --hard <prior-commit>` locally, force-copy the prior files back to entech.
2. Force-rerun the prior version's post-migrate by setting `ir_module_module.latest_version` back to `19.0.10.25.0` for fusion_plating_jobs and `19.0.21.2.0` for fusion_plating, then `-u`.
(Migration is idempotent so re-running the broken version is safe; you may need to manually re-create the deleted clones from a DB backup if rollback needed clones back — out of scope per "we don't need to worry about current data".)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
# Shop Floor PIN Gate + Auto-Lock — Design Spec
**Date:** 2026-05-22
**Status:** Awaiting user review
**Phase:** 6 (sequel to Phases 1-5 of 2026-05-22-shopfloor-tablet-redesign)
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
**Target client:** EN Technologies (Fusion Plating)
---
## 1. Context
Phases 1-5 of the tablet redesign shipped on 2026-05-22 (entech LXC 111). They assume a single user is "logged in" to a tablet for the duration of use. Real shop floors don't work that way:
- A single tablet sits at the **EN Plating tank** (or de-rack table, masking station, QC bench).
- Multiple technicians rotate through the station during a shift.
- A tech walks away mid-shift; the next tech walks up — and without a lock, the new tech is operating under the previous tech's identity.
- Every step start, finish, scrap, hold, signature, and milestone advance gets attributed to the wrong person.
- AS9100 / Nadcap audit trails break. Operators sign off on each other's work without knowing it.
The fix needs to be **fast** (PIN in < 2 seconds), **familiar** (matches iPad / debit-card UX techs already know), and **silent on the timer side** (locking the tablet must not pause a part in a tank).
## 2. Goals
- Each tech identifies themselves with a personal 4-digit PIN.
- Tablet auto-locks after a configurable idle period (default 5 min).
- Quick-switch UX: tap your face tile → enter PIN → unlocked. No typing usernames.
- All shop-floor actions (step start/finish, holds, sign-offs, milestone advances) carry the correct tech identity for audit.
- No interruption to in-progress step timers — the server keeps counting.
- Manager can reset a forgotten PIN; no SMS/email infrastructure required.
- Per-station roster: a tablet at EN Plating only shows techs trained on EN Plating.
## 3. Non-goals (v1)
- Multi-factor authentication (TOTP, SMS).
- Biometric unlock (Face ID, fingerprint).
- NFC badges or RFID readers.
- Self-service PIN reset via email/SMS (manager-side only).
- "Remember me" cross-device sessions.
- Camera-based presence detection / liveness checks.
## 4. UX
### 4.1 Lock screen — tile grid
Rendered first thing when the tablet boots, after any auto-lock, and after the Hand-Off button. Replaces the current Landing/Workspace/Dashboard view entirely.
- 3-5 tiles per row, sized for touch (~120×140 px each).
- Sort: clocked-in techs first, alphabetical within bucket.
- Each tile: avatar + name + small green dot if clocked in.
- A station with `x_fc_authorised_user_ids` configured shows only those techs; otherwise all techs in the operator group.
- "Other..." chip at the end opens a username search for off-roster cases (cross-trained tech covering an unfamiliar station).
- Tap a tile → PIN pad slides up as a modal.
### 4.2 PIN pad
- Numeric 0-9 in a 3×4 grid + Clear + Submit.
- 4 dot placeholders fill as digits are typed.
- Auto-submit on the 4th digit (no Enter required).
- Wrong PIN → quick shake animation (CSS keyframe), dots clear, tile grid stays.
- After 5 sequential failures for one tech, that tech is locked for 5 minutes. Other techs can still unlock the tablet.
- "Forgot?" link surfaces a friendly message: "Ask a manager to reset your PIN."
### 4.3 Hand-Off button
- Top-right corner of every authenticated view (Landing, Workspace, Manager Dashboard), next to QR scan.
- Big icon + label: 🔒 **Hand Off**.
- Tap → confirm dialog "Lock this tablet now?" → instant lock to tile grid.
- Confirm dialog prevents accidental locks; can be skipped in v2 with rapid double-tap.
### 4.4 Idle warning
- At 30 seconds before auto-lock, a yellow pulsing border appears around the entire viewport.
- A toast slides in: "Locking in 28s · tap anywhere to stay".
- Countdown decrements in 1s ticks until 0.
- Any pointer/touch event clears the warning and resets the timer.
- At 0s, the tile grid replaces the current view.
### 4.5 Session continuity (state preservation on lock)
| State | On lock | On unlock |
|---|---|---|
| In-progress step timer | Server-side timer keeps running. No pause event fired. | Resumes accurate elapsed time. |
| OWL state (scroll, expanded step) | Preserved in memory | Restored |
| HoldComposer modal open | Preserved (dialog still mounted under the lock overlay) | Available immediately |
| SignaturePad open mid-stroke | **Thrown away.** Signature flow restarts. | Fresh signature required. |
| QR scan drawer open | Preserved | Available |
| Refresh interval (15s/8s polling) | Paused | Resumed |
### 4.6 Profile preferences — set / change PIN
- New "Tablet PIN" group on `res.users` preferences (user-facing form).
- Single button: **Set Tablet PIN** or **Change PIN** (label flips depending on whether a hash exists).
- Tapping it opens a modal with 3 PIN inputs:
- Current PIN (only if a PIN is set; skipped on first-time set)
- New PIN
- Confirm new PIN
- All three use the same `FpPinPad` component as the unlock screen.
- Subtext shows "Last changed: 2026-05-22" or "Cleared by manager".
### 4.7 Manager-side reset
- New header button on `res.users` form: **Reset Tablet PIN** (visible only to `group_fusion_plating_manager` and above).
- Tap → confirm dialog → posts to user's chatter ("Tablet PIN reset by Manager X on 2026-05-22") + clears the hash.
- Tech sets a new PIN on next unlock attempt.
## 5. Backend
### 5.1 Model fields on `res.users` (in `fusion_plating_shopfloor/models/res_users.py` — new file)
| Field | Type | Notes |
|---|---|---|
| `x_fc_tablet_pin_hash` | Char | Hash (SHA-256 + per-user salt) of the PIN. Stored as `<salt>$<hash>`. `groups='fusion_plating.group_fusion_plating_manager'` — non-manager users cannot even read other users' hash field. |
| `x_fc_tablet_pin_set_date` | Datetime | When the current hash was set. NULL if PIN was cleared by manager. |
| `x_fc_tablet_pin_failed_count` | Integer (0) | Sequential failed attempts since last success. Resets to 0 on a correct PIN. |
| `x_fc_tablet_locked_until` | Datetime | Lockout expiry. NULL when not locked. |
### 5.2 Extras on `fusion.plating.shopfloor.station`
| Field | Type | Notes |
|---|---|---|
| `x_fc_authorised_user_ids` | Many2many → res.users | If non-empty, the tile grid restricts to these users. Empty = "all operators". |
| `x_fc_idle_lock_minutes` | Integer, nullable | Per-station override; null = use global default. |
### 5.3 `ir.config_parameter` defaults
| Key | Default | Purpose |
|---|---|---|
| `fp.shopfloor.tablet_idle_lock_minutes` | `5` | Global idle threshold |
| `fp.shopfloor.tablet_pin_fail_threshold` | `5` | Failures before lockout |
| `fp.shopfloor.tablet_pin_fail_lockout_minutes` | `5` | Lockout duration |
| `fp.shopfloor.tablet_warn_seconds_before_lock` | `30` | When the yellow border appears |
### 5.4 HTTP endpoints (`/fp/tablet/*`)
All `type='jsonrpc'`, `auth='user'`. Auth = the tablet's persistent Odoo session (a "shopfloor service" account or any non-locked user).
| Endpoint | Body | Returns |
|---|---|---|
| `POST /fp/tablet/tiles` | `{station_id?}` | `{ok, tiles: [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}`. Respects `station.x_fc_authorised_user_ids`. |
| `POST /fp/tablet/unlock` | `{user_id, pin}` | `{ok: true, current_tech_id, current_tech_name}` or `{ok: false, error, locked_until?, attempts_remaining}`. |
| `POST /fp/tablet/set_pin` | `{old_pin?, new_pin}` | Caller's PIN only. `old_pin` required if a hash exists. `{ok, error?}`. |
| `POST /fp/tablet/reset_pin_for` | `{user_id}` | Manager-only; manager group enforced server-side. Clears target user's hash + posts chatter. |
| `POST /fp/tablet/ping` | `{current_tech_id}` | Bumps a server-side "last active" timestamp for forensics. Called on every successful tech action. |
### 5.5 Hash algorithm
```python
import hashlib, secrets
def _hash_pin(pin: str, salt: bytes = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
return f"{salt.hex()}${digest.hex()}"
def _verify_pin(pin: str, stored: str) -> bool:
salt_hex, expected_hex = stored.split('$', 1)
salt = bytes.fromhex(salt_hex)
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
return digest.hex() == expected_hex
```
200,000 PBKDF2 iterations gives ~50ms verify time on entech-class hardware — fast enough for tech UX, slow enough to make a brute-force attack expensive even with the database stolen.
### 5.6 Audit propagation (Phase 6.3)
All existing shop-floor endpoints that take an action (`/fp/shopfloor/start_wo`, `stop_wo`, `bump_qty_done`, `bump_qty_scrapped`, `log_chemistry`, `log_thickness_reading`, `quality_hold`, `mark_gate`, `start_bake`, `end_bake`, `/fp/workspace/{hold,sign_off,advance_milestone}`) gain an **optional** `tablet_tech_id` kwarg.
When the OWL component passes `tablet_tech_id`:
- Server verifies the id corresponds to a recent successful `/fp/tablet/unlock` (within session's idle window).
- All chatter posts use that user's name instead of `env.uid`.
- All writes to records set `create_uid` / `write_uid` to that user (via `with_user(...)` context manager).
- If `tablet_tech_id` is missing or stale, server falls back to `env.uid` (the tablet's session user) for back-compat.
This keeps the audit trail honest without forcing a full Odoo session swap on every PIN unlock (which would clear all OWL state and JS bundle cache).
## 6. Frontend architecture
### 6.1 New OWL components
| Component | File | Purpose |
|---|---|---|
| `FpTabletLock` | `static/src/js/tablet_lock.js` | Top-level wrapper around Landing/Workspace/Manager. Renders tile grid when locked; renders children when unlocked. |
| `FpPinPad` | `static/src/js/components/pin_pad.js` | Numeric pad modal. Used by FpTabletLock unlock AND Profile set-PIN flow. |
| `FpPinSetup` | `static/src/js/components/pin_setup.js` | Modal for set/change PIN. Wraps 3 instances of FpPinPad (old + new + confirm). |
| `FpIdleWarning` | `static/src/js/components/idle_warning.js` | Yellow-border + countdown toast component shown at T-30s. |
### 6.2 Activity tracker service
- Registered as `fp_shopfloor_activity` in the OWL `services` registry.
- Tracks `lastActiveAt` (epoch ms).
- Listens at document level: `pointerdown`, `touchstart`, `keydown`, `visibilitychange`.
- Public API: `bumpActivity()`, `getSecondsUntilLock()`, `subscribe(cb)`, `lock()`.
- Bumps server-side on every `ping` (debounced to once per 30s).
### 6.3 Auto-lock flow
```js
// inside FpTabletLock setup()
this.activity = useService("fp_shopfloor_activity");
this._tick = setInterval(() => {
const remaining = this.activity.getSecondsUntilLock();
if (remaining <= 0) {
this.state.locked = true;
this.state.currentTechId = null;
} else if (remaining <= this.warnThresholdSec) {
this.state.idleWarning = remaining;
} else if (this.state.idleWarning) {
this.state.idleWarning = null; // user tapped, reset
}
}, 1000);
```
### 6.4 RPC plumbing
A tiny client-side helper wraps `rpc()` so every shop-floor call automatically includes `tablet_tech_id`:
```js
// fp_rpc.js
import { rpc as baseRpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
const techStore = registry.category("services").get("fp_shopfloor_tech_store");
export function fpRpc(url, params = {}) {
if (techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
return baseRpc(url, params);
}
```
Landing, Workspace, Manager Dashboard switch from `rpc(...)` to `fpRpc(...)` for action calls. Read-only calls (load, tiles, kanban) don't need the kwarg.
### 6.5 Component composition
```
FpTabletLock (NEW outer wrapper, mounted by every client action)
├── if locked → FpPinPad (tile grid + entry)
├── if idle warning → FpIdleWarning overlay
└── else → existing client action (Landing | Workspace | Manager Dashboard)
+ Hand-Off button injected into existing headers
```
The "tablet locked" boolean lives in a shared OWL service (`fp_shopfloor_tech_store`) — every client action checks it on mount and subscribes for changes.
## 7. Edge cases
| Case | Handling |
|---|---|
| No tech has set a PIN yet | Tile shows "PIN required" overlay. Tap tile → guided "you must set a PIN before using this tablet" → set-PIN flow → unlock. |
| Manager just reset a tech's PIN | Tile still shows; tap → "PIN was cleared by a manager — set a new one" → set-PIN flow → unlock. |
| Tablet boots with no station paired | Tile grid shows + a "Pair this station" CTA. Station QR scan works before any tech is logged in. |
| Network drop mid-unlock | Spinner + Retry button after 5s. Backend tolerates duplicate unlocks (idempotent on success — counter just stays at 0). |
| Tech mid-step when tablet locks | Step timer keeps running on server. Auto-pause cron (Phase 2) is the upper-bound safety net. |
| Tech A's PIN locked for 5 min — can tech B unlock? | Yes. Lockout is per-user, not per-tablet. |
| Tech keeps tablet active by setting a heavy weight on it | Activity = pointer/touch/key events only, not mouse-move. A weight doesn't fire those events. Still locks after 5 min. |
| Tech is mid-RPC when lock fires | RPC completes (server keeps running). Response is dropped silently — UI is already showing the tile grid. |
| Two tabs / windows on the same browser | Each tab has its own FpTabletLock state. They lock independently. Acceptable for v1; not a real shop scenario. |
| Manager wants to act AS a tech | Out of scope. Manager unlocks with their own PIN; their actions carry their own uid. |
## 8. Testing
### 8.1 Python tests (`fusion_plating_shopfloor/tests/test_tablet_pin.py`)
| Test | Verifies |
|---|---|
| `test_set_pin_first_time` | User with no hash can set PIN; resulting hash is salted and length > 32. |
| `test_set_pin_change_requires_old` | Setting a new PIN when one exists requires correct old_pin; wrong old_pin rejected. |
| `test_unlock_correct_pin_resets_failure_count` | Failed → failed → correct → counter is 0. |
| `test_unlock_5_wrong_locks_user` | 5 wrong attempts → 6th returns `locked_until`. 7th still rejected. |
| `test_lockout_expires_after_threshold` | After 5 min sim time elapsed → next attempt allowed again. |
| `test_reset_pin_for_requires_manager` | Operator → AccessError. Supervisor → AccessError. Manager → success. |
| `test_reset_pin_clears_hash_and_posts_chatter` | After reset: hash is False, set_date is False, chatter has "PIN reset by Manager X". |
| `test_tiles_filtered_by_station_roster` | Station with authorised_user_ids → tiles is subset. Empty list → all operator-group users. |
| `test_audit_kwarg_used_in_step_finish` | RPC with `tablet_tech_id=N` → step's `write_uid == N` (not env.uid). |
| `test_audit_kwarg_invalid_falls_back_to_session` | Invalid `tablet_tech_id` → write_uid == env.uid, no error. |
### 8.2 Manual QA
`docs/qa/2026-05-22-shopfloor-pin-gate-qa.md` walkthrough:
1. Tech A sets PIN via Preferences
2. Tech A unlocks tablet → starts a step
3. 5 min idle elapses → tablet locks
4. Tech B unlocks → finishes Tech A's step
5. Audit chatter shows: started by A at T+0, finished by B at T+6
6. Manager taps Reset PIN on Tech A's res.users form
7. Tech A unlocks → set-PIN flow
8. Tech A fails PIN 5 times → lockout kicks in
9. Tech A waits 5 min → unlocks successfully
## 9. Build sequence (3 sub-phases)
Each ships independently and can be rolled back independently.
| Sub-phase | Ships | Independently deployable? |
|---|---|---|
| **6.1 — Backend** | model fields on res.users + station extras + ir.config_parameter defaults + 5 `/fp/tablet/*` endpoints + Profile prefs Set/Change PIN button + Manager Reset PIN button on res.users form | Yes — works silently behind the scenes. Techs can set PINs but the lock screen doesn't render yet. |
| **6.2 — Frontend lock screen** | FpTabletLock wrapper + FpPinPad + FpIdleWarning + activity tracker service + Hand-Off button injection into existing headers | Yes — lock screen goes live. Audit credit still defaults to tablet session user without 6.3. |
| **6.3 — Audit propagation** | `tablet_tech_id` optional kwarg on all existing action endpoints + `fpRpc()` wrapper + Landing/Workspace/Manager updated to use it | Yes — refines the audit trail. Without it, actions are recorded against the tablet's session uid. |
## 10. Backwards compatibility
- Any tablet that hasn't been upgraded to Phase 6.2 continues to work unauthenticated (no lock screen). Once 6.2 lands, ALL tablets start showing the lock screen.
- Endpoints from Phases 1-5 keep their existing signatures. `tablet_tech_id` is purely additive.
- Setting / changing the PIN is opt-in per user. A tech without a PIN sees a "set one to continue" prompt; they can't dismiss it.
- No model migration required — all new fields default to NULL.
- `ir.config_parameter` defaults are read at runtime, no install-time setup needed.
## 11. Rollback strategy
| Sub-phase | Rollback |
|---|---|
| 6.1 | Disable endpoints in `controllers/__init__.py`. Model fields are additive, safe to drop. |
| 6.2 | Hide `FpTabletLock` via a feature flag (`ir.config_parameter` `fp.shopfloor.tablet_lock_enabled`, default true; set false to bypass). Existing client actions render directly again. |
| 6.3 | Stop sending `tablet_tech_id` from `fpRpc()` — server falls back to `env.uid`. |
## 12. Out of scope for v1
- Biometric (Face ID, fingerprint)
- NFC badges
- TOTP / SMS / email-based reset
- "Remember me" cross-device sessions
- Per-tech idle threshold (only per-station + global)
- Lock-screen widgets (weather, time, KPIs) — keep the tile grid focused
- Camera-based presence / liveness
- Pre-fetched tile grid (each unlock call fetches fresh)
- Different PIN lengths per tech (4 digits for everyone)
## 13. Decisions log
| Decision | Rationale |
|---|---|
| 4-digit PIN over 6-digit | Speed. Industry norm. Lockout + per-user-fail-counter makes 10,000 combos secure enough. |
| PBKDF2-SHA256, 200k iterations | ~50ms verify on entech hardware. Safe against rainbow tables; brute-force-resistant even with DB stolen. |
| Hash field is manager-readable only | Operators can't even view other users' hash. Reduces lateral attack surface. |
| Per-user lockout, not per-tablet | A bad-actor wrong-PIN'ing one user shouldn't deny service to other techs on the same tablet. |
| 5 minute idle default | Compromise: long enough for legitimate idle-watching of a tank, short enough that a walk-away is caught. Configurable per-station. |
| Server-side step timer keeps running on lock | Locking is UI; nothing should pause physical processes. Auto-pause cron is the deeper safety net. |
| Single Odoo session, PIN overlay credits via `tablet_tech_id` kwarg | No JS bundle reload, no state loss, no flicker. Audit kwarg keeps the trail honest. |
| Manager-only reset (no self-service) | Plating shops rarely have per-tech email/SMS. Manager is always present. Lower infra. |
| 30s warning before lock | Compromise: catches "I was right there" cases without being annoyingly chatty. |
| `tablet_tech_id` is opt-in additive kwarg | Lets 6.3 ship after 6.2 without breaking anything; lets older callers continue working unchanged. |
## 14. Future v2 candidates
- NFC badge tap (cheap USB readers, ~$30)
- Personal QR badge on lanyard (no hardware beyond what we already have)
- Per-tech idle threshold (long-shift senior techs vs cross-trained probationers)
- Lock-screen KPIs (shop output today, hot WOs visible without unlocking)
- "Switch tech without re-PIN" — keep both signed in for hand-off audit on the same step
- Mobile app companion with biometric unlock
---
**Next step:** user reviews this spec. Once approved, transition to `superpowers:writing-plans` to produce the phased implementation plan.

View File

@@ -0,0 +1,858 @@
# Fusion Plating — Permissions Overhaul (Phase 1)
**Date:** 2026-05-23
**Status:** Approved for implementation
**Owner module:** `fusion_plating` (with co-changes in 9 dependent modules)
**Brainstorm transcript:** session with @gsinghpal, 2026-05-23
**Linked plan:** TBD (writing-plans skill, next step)
---
## Problem Statement
The current Fusion Plating permission system has 12 `res.groups` defined across 6 modules. An audit (2026-05-23) found:
- **3 groups are zero-reference orphans** — Shop Manager, CGP Designated Official, Plating Legacy Menus
- **1 group is functionally orphaned** — Administrator (the 2 Python checks that reference it use a typo'd XML ID `_administrator` instead of `_admin`, so the gate never fires)
- The role dropdown in the user form lists 10 entries with confusing ordering (sequence ties at 50 and 60 cause Estimator/CGP Officer and Shop Manager/CGP DO to render in arbitrary alphabetical order)
- Default landing page is hardcoded to "Shop view" for everyone — Managers complain about being dumped into a Workstation tablet when they open the Plating app
- The landing-page picklist in user preferences offers only 3 options (Quotations, Sale Orders, Process Recipes) — missing Manager Desk, Plant View, Quality Dashboard
- Menu visibility relies on a mix of explicit `groups=` attributes and implicit action-level ACLs — fragile and inconsistent
This Phase 1 work consolidates the 12 groups into **8 well-defined roles**, fixes the landing-page UX with role-based defaults, and ships an Owner-only "Team" page for clean role assignment.
---
## Locked Decisions
| # | Question | Decision |
|---|---|---|
| Q1 | Quality Manager vs Manager — what quality permissions split? | **Option B** — Manager handles reactive Quality (NCR/Hold/Check/routine Cert/RMA). Quality Manager owns strategic Quality (CAPA closure, audit sign-off, FAIR/Nadcap signing, AVL approval, Customer Spec library, Doc Control approval, all CGP). |
| Q2 | CGP/Aerospace/Nuclear verticals — fold or keep as add-on flags? | **Option A** — All vertical ACLs gate on Quality Manager. CGP Officer group dropped (folds into QM). CGP Designated Official becomes `res.company.x_fc_cgp_designated_official_id` field (Many2one to res.users, domain `[Owner, QM]`). Aerospace/Nuclear/Safety unchanged (already on Manager backbone). |
| Q3 | Landing page per role — hardcoded, configurable, or seeded? | **Option B** — Hardcoded role→action mapping in the resolver. Per-user override stays in preferences. Company default stays as a final fallback. |
| Q4 | Owner-only Permissions config page — yes/no, and what does it configure? | **Yes, Interpretation A only** — Owner-only "Team" page for role assignment, designated officials, and audit log. NO permission-definition editing (Interpretation B explicitly killed — would defeat the 8-role spec). |
| Q5 | Migration of existing users — auto-map or force manual? | **Option B** — Dry-run preview + Owner approval. Auto-map runs on `-u`, creates a `fp.migration.preview` in `pending` state, schedules a `mail.activity` on every Owner. Migration only applies after Owner clicks "Approve & Run". 30-day rollback window via archived old groups. |
| Q4b | Menu/submenu/field visibility — explicit `groups=` or inherit from parent? | **Confirmed by user pre-spec-write** — All three layers (top-level menus, submenus, fields/buttons) get explicit `groups=` matching the new roles. No reliance on action-level ACLs for menu visibility. |
---
## Section 1 — Role Hierarchy & XML IDs
### The 8 new roles
All under the existing `fusion_plating.res_groups_privilege_fusion_plating` privilege block (same place in the user form). Sequence numbers picked uniquely to avoid the current audit's "tied at 50/60" rendering bug.
| Seq | Display Name | XML ID | Implies | Auto-assigned to |
|---:|---|---|---|---|
| 10 | Technician | `fusion_plating.group_fp_technician` | `base.group_user` | — |
| 20 | Sales Representative | `fusion_plating.group_fp_sales_rep` | `base.group_user` | — |
| 30 | Shop Manager | `fusion_plating.group_fp_shop_manager_v2` | Technician | — |
| 40 | Sales Manager | `fusion_plating.group_fp_sales_manager` | Sales Representative | — |
| 50 | Manager | `fusion_plating.group_fp_manager` | Shop Manager + Sales Manager | — |
| 60 | Quality Manager | `fusion_plating.group_fp_quality_manager` | Manager | — |
| 70 | Owner | `fusion_plating.group_fp_owner` | Quality Manager + `base.group_system` | uid 1, uid 2 |
| — | (No) | — implicit (no Fusion Plating group held) | — | — |
### Design notes
1. **`group_fp_shop_manager_v2` suffix** — the existing `fusion_plating_configurator.group_fp_shop_manager` (today's 0-ref label bundle) gets retired. Suffix `_v2` avoids xmlid collision during migration; we rename to `_shop_manager` in a follow-up housekeeping pass once old refs are confirmed dead.
2. **Sales branch and Shop branch are parallel** — both inherit only `base.group_user`. A Technician can't see Quotations; a Sales Rep can't see the Workstation. They cross-join at Manager.
3. **Diamond at Manager** — Manager implies BOTH Shop Manager AND Sales Manager; gets the union.
4. **"No" is the absence of any group** — no `res.groups` record needed. The Plating menu root gates on an OR of all 7 plating roles. An internal user with none sees no plating menu.
5. **Owner implies `base.group_system`** — replaces today's broken Administrator pattern. Owners get Settings access, can install modules, etc.
6. **Old groups stay defined post-migration but become "DEPRECATED" + auto-archived.** 30-day rollback window. `_cron_purge_expired_migrations` deletes them after 30 days.
7. **CGP Designated Official is no longer a group**`res.company.x_fc_cgp_designated_official_id` (Many2one to res.users, domain `[('groups_id', 'in', [QM_id, Owner_id])]`).
8. **Field Technician (`fusion_tasks.group_field_technician`) is untouched** — orthogonal to plating roles, separate privilege block.
### Hierarchy visual
```
base.group_user (Internal User)
├── (no plating group) = "No"
├── Technician [10]
│ └── Shop Manager v2 [30]
│ └── Manager [50] ←──┐ (diamond)
│ │
└── Sales Representative [20]│
└── Sales Manager [40] │
└── Manager [50] ←───┘
└── Quality Manager [60]
└── Owner [70] (also implies base.group_system)
```
---
## Section 2 — ACL Re-gating Plan
### 2.A Standard mapping pattern
Applies to ~80% of the ~475 ACL refs mechanically:
| Old gate | New gate | Why |
|---|---|---|
| `group_fusion_plating_operator` | `group_fp_technician` | Pure rename |
| `group_fusion_plating_supervisor` | `group_fp_shop_manager_v2` | Supervisor's daily-floor leadership IS Shop Manager's job |
| `group_fusion_plating_manager` | `group_fp_manager` | Pure rename |
| `group_fp_estimator` | `group_fp_sales_rep` | Pure rename, but lose order-confirm (Section 2.B) |
| `group_fp_receiving` | `group_fp_shop_manager_v2` | Receiving folds in |
| `group_fp_accounting` | `group_fp_manager` | Accounting folds in |
| `group_fusion_plating_admin` | `group_fp_owner` | Pure rename + fixes `_administrator` typo bug |
Implied-chain handles the rest (e.g., Manager auto-gets everything Shop Manager has, so a model gated on Shop Manager is automatically accessible to Manager+).
### 2.B New gates ADDED
| Action | Today | New gate |
|---|---|---|
| `sale.order.action_confirm` | Any internal user | `group_fp_sales_manager` |
| `sale.order` set `x_fc_account_hold_override` | Manager (with `_administrator` typo) | `group_fp_manager` (clean) |
| `account.move.action_post` for FP-invoiced SOs | Implicit | `group_fp_manager` |
| Owner-only Team page menu | Doesn't exist | `group_fp_owner` |
Sales Reps can still save Sale Orders in `draft`; the confirm button is hidden in the view and the model-level gate raises `UserError` if called directly: *"Only Sales Manager or higher can confirm orders."*
### 2.C Quality split — Manager vs Quality Manager
| Model | Manager rights | QM-only rights |
|---|---|---|
| `fusion.plating.ncr` | CRUD + state transitions through `closed` | — |
| `fusion.plating.capa` | **Read + comment only** | CRUD + `action_close` + effectiveness verification |
| `fusion.plating.quality.hold` | CRUD + release | — |
| `fusion.plating.quality.check` | CRUD + pass/fail | — |
| `fp.certificate` (routine CoC, thickness) | CRUD + sign + issue + send | — |
| `fp.certificate` where `cert_type='fair'` | Read + create | Sign + issue (record rule on cert_type) |
| `fp.certificate` where `cert_type='nadcap'` | Read + create | Sign + issue (record rule on cert_type) |
| `fusion.plating.rma` | CRUD + authorise + resolve | — |
| `fusion.plating.audit` | Read | CRUD + close |
| `fusion.plating.customer.spec` | Read + attach to parts | CRUD (library curator) |
| `fp.approved.vendor.list` | Read | Add / approve / disqualify |
| `fp.contract.review` (QA-005) | Complete reviews assigned to them | Set QA Manager roster + override gates |
| Doc Control + Doc Approval | Read + request approval | Approve / supersede / retire |
| Calibration equipment | Log events + view schedule | Configure equipment + set intervals + dispose out-of-tolerance |
| **All `fp.cgp.*` models** (8 ACLs + 2 ir.rules) | None | All (entire CGP fold-in lands here) |
**Implementation note:** FAIR/Nadcap cert split uses an `ir.rule` on `fp.certificate` (domain `[('cert_type', 'in', ['fair','nadcap'])]`) restricted to QM for write. Routine CoCs (cert_type = `coc` or `thickness_report`) stay open to Manager.
### 2.D Verticals (Aerospace / Nuclear / Safety)
No change. Their ACLs already gate on `group_fusion_plating_manager` (now `group_fp_manager`). The standard mapping in 2.A covers them. No new vertical-specific gates needed.
### 2.E Three-layer menu / submenu / field hiding policy
**Rule:** if a user can't use it, they don't see it. No reliance on action-level ACLs for visibility — explicit `groups=` at every layer.
#### Layer 1 — Top-level menus
| Top-level menu | `groups=` |
|---|---|
| **Plating** (root) | OR of all 7 plating roles |
| **Sales & Quoting** | `group_fp_sales_rep` |
| **Shop Floor** | `group_fp_technician` |
| **Operations** | `group_fp_technician` |
| **Receiving & Shipping** | `group_fp_shop_manager_v2` |
| **Quality** | `group_fp_manager` |
| **Compliance** (hub) | `group_fp_quality_manager` |
| **KPIs** | `group_fp_manager` |
| **Configuration** | `group_fp_manager` |
#### Layer 2 — Submenus (explicit on every child)
| Submenu | New gate |
|---|---|
| Quality > Audits | `group_fp_quality_manager` |
| Quality > Customer Specs | `group_fp_quality_manager` |
| Quality > Approved Vendor List | `group_fp_quality_manager` |
| Quality > NCRs / Holds / Checks / RMAs / Certs | `group_fp_manager` |
| Quality > CAPAs | `group_fp_manager` (visibility); QM-only for close button (Layer 3) |
| Operations > Maintenance | `group_fp_shop_manager_v2` |
| Operations > Move Log | `group_fp_shop_manager_v2` |
| Operations > Labor History | `group_fp_shop_manager_v2` |
| Operations > Replenishment Suggestions | `group_fp_manager` |
| Configuration > Team | `group_fp_owner` |
| Configuration > Settings | `group_fp_manager` (explicit) |
| Configuration > all 7 themed folders | `group_fp_manager` (explicit) |
| Sales & Quoting > Configurator | `group_fp_sales_rep` |
| Sales & Quoting > Sale Orders | `group_fp_sales_rep` (visibility); SM+ for confirm (Layer 3) |
| Receiving & Shipping > all children | `group_fp_shop_manager_v2` |
| Compliance > CGP | `group_fp_quality_manager` |
| Compliance > General / Safety / Aerospace / Nuclear | `group_fp_quality_manager` |
#### Layer 3 — Fields, buttons, smart buttons
| View element | New gate |
|---|---|
| `sale.order` view — Confirm button | `group_fp_sales_manager` |
| `sale.order` view — `x_fc_account_hold_override` | `group_fp_manager` (was broken Administrator typo) |
| `sale.order` form — pricing columns on lines | `group_fp_sales_rep` (defense in depth — Technician/Shop Manager don't see pricing) |
| `fp.certificate` form — Sign button (FAIR / Nadcap) | `group_fp_quality_manager` |
| `fusion.plating.capa` form — Close button + edit fields | `group_fp_quality_manager` |
| `fusion.plating.audit` form — all buttons | `group_fp_quality_manager` |
| `fp.approved.vendor.list` form — Approve / Disqualify | `group_fp_quality_manager` |
| `fusion.plating.customer.spec` form — edit fields | `group_fp_quality_manager` |
| All CGP form buttons | `group_fp_quality_manager` |
| Smart buttons (cross-record navigation) | Match the underlying action's visibility |
### 2.F Per-role menu visibility matrix (sanity check)
| Menu | No | Tech | SR | SM | SalesMgr | Mgr | QM | Owner |
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| Plating (root) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Sales & Quoting | — | — | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| Shop Floor | — | ✓ | — | ✓ | — | ✓ | ✓ | ✓ |
| Operations | — | ✓ | — | ✓ | — | ✓ | ✓ | ✓ |
| Receiving & Shipping | — | — | — | ✓ | — | ✓ | ✓ | ✓ |
| Quality | — | — | — | — | — | ✓ | ✓ | ✓ |
| Compliance | — | — | — | — | — | — | ✓ | ✓ |
| KPIs | — | — | — | — | — | ✓ | ✓ | ✓ |
| Configuration | — | — | — | — | — | ✓ | ✓ | ✓ |
| Configuration > Team | — | — | — | — | — | — | — | ✓ |
(SR = Sales Rep, SM = Shop Manager, SalesMgr = Sales Manager, Mgr = Manager)
### 2.G Manager-bypass context flags (no ownership change)
All 9 existing bypass flags from the battle tests remain gated on Manager+:
`fp_skip_step_gate`, `fp_skip_qc_gate`, `fp_skip_qty_reconcile`, `fp_skip_bake_gate`, `fp_skip_predecessor_check`, `fp_skip_missed_window`, `fp_skip_required_inputs_gate`, `fp_skip_signoff_gate`, `fp_skip_transition_form`.
New name in the check: `user.has_group('fusion_plating.group_fp_manager')`.
Shop Manager CANNOT bypass these gates — matches spec ("Technicians cannot override system"). Override authority sits at Manager.
### 2.H ir.rules (record rules)
| Rule | Old | New |
|---|---|---|
| `fp.cgp.psa` Officer-only | CGP Officer | `group_fp_quality_manager` |
| `fp.cgp.security.incident` Officer-only | CGP Officer | `group_fp_quality_manager` |
| `fusion.technician.task` Field-Tech-own | Field Technician | Unchanged (orthogonal) |
| **NEW:** `fp.certificate` write-gate for cert_type in ('fair','nadcap') | — | `group_fp_quality_manager` |
| **NEW:** `sale.order` write-gate for state→'sale' transition | — | `group_fp_sales_manager` |
No multi-company changes — existing multi-company rules untouched.
---
## Section 3 — Landing Resolver
### Resolver flow (server action `action_fp_resolve_plating_landing`)
```python
def _fp_resolve_landing(self):
user = self.env.user
company = self.env.company
# 1. Per-user override (set in preferences)
if user.x_fc_plating_landing_action_id:
return user.x_fc_plating_landing_action_id._render_action()
# 2. Role-based default (precedence: highest role wins)
role_landing = self._fp_role_default_landing(user, company)
if role_landing:
return role_landing._render_action()
# 3. Company default (admin fallback)
if company.x_fc_default_landing_action_id:
return company.x_fc_default_landing_action_id._render_action()
# 4. Hardcoded last-ditch
return self.env.ref('fusion_plating_configurator.action_fp_sale_orders')._render_action()
```
### Role → action mapping (Step 2)
```python
def _fp_role_default_landing(self, user, company):
workstation_action = self._fp_workstation_action_for_layout(company)
if user.has_group('fusion_plating.group_fp_owner'):
return self.env.ref('fusion_plating_shopfloor.action_fp_manager_dashboard',
raise_if_not_found=False)
if user.has_group('fusion_plating.group_fp_quality_manager'):
return self.env.ref('fusion_plating_quality.action_fp_quality_dashboard',
raise_if_not_found=False)
if user.has_group('fusion_plating.group_fp_manager'):
return self.env.ref('fusion_plating_shopfloor.action_fp_manager_dashboard',
raise_if_not_found=False)
if user.has_group('fusion_plating.group_fp_sales_manager'):
return self.env.ref('fusion_plating_configurator.action_fp_sale_orders',
raise_if_not_found=False)
if user.has_group('fusion_plating.group_fp_shop_manager_v2'):
return workstation_action
if user.has_group('fusion_plating.group_fp_sales_rep'):
return self.env.ref('fusion_plating_configurator.action_fp_quotations',
raise_if_not_found=False)
if user.has_group('fusion_plating.group_fp_technician'):
return workstation_action
return False
```
### Workstation = layout-flag aware (single source of truth)
```python
def _fp_workstation_action_for_layout(self, company):
"""Single source of truth: which Shop Floor surface is active on this DB?"""
param = self.env['ir.config_parameter'].sudo().get_param(
'fusion_plating_shopfloor.layout', 'v2')
if param == 'v2':
return self.env.ref('fusion_plating_shopfloor.action_fp_plant_kanban',
raise_if_not_found=False)
return self.env.ref('fusion_plating_shopfloor.action_fp_shopfloor_landing',
raise_if_not_found=False)
```
Flipping `ir.config_parameter['fusion_plating_shopfloor.layout']` instantly changes the default landing for every Technician and Shop Manager on the next page load.
### Pickable actions (`x_fc_pickable_landing=True`)
Adding 4 net-new (3 are already pickable). Total picklist = **7 entries**.
| Action XML ID | Display in dropdown | Default for |
|---|---|---|
| `fusion_plating_shopfloor.action_fp_manager_dashboard` | Manager Desk | Owner / QM / Manager |
| `fusion_plating_shopfloor.action_fp_plant_kanban` | Plant View Kanban | Shop Mgr / Tech (v2 layout) |
| `fusion_plating_shopfloor.action_fp_shopfloor_landing` | Workstation (Legacy) | Shop Mgr / Tech (legacy layout) |
| `fusion_plating_quality.action_fp_quality_dashboard` | Quality Dashboard | QM |
| `fusion_plating_configurator.action_fp_quotations` | Quotations | Sales Rep (already pickable) |
| `fusion_plating_configurator.action_fp_sale_orders` | Sale Orders | Sales Manager (already pickable) |
| `fusion_plating.action_fp_process_recipe` | Process Recipes | (niche option, already pickable) |
### Per-user override picklist domain
Today: `[('x_fc_pickable_landing', '=', True)]`. **Tightened**: also filter by user's accessible actions, so a Technician can't pick "Manager Desk" as their landing if they can't see it.
Domain becomes computed: `[('x_fc_pickable_landing', '=', True), ('id', 'in', user_accessible_action_ids)]`. The `user_accessible_action_ids` list comes from a compute that runs `env['ir.ui.menu']._visible_menu_ids()` mapped to action IDs.
### Edge cases
1. **Multi-role user (Manager promoted to QM):** precedence chain picks higher role. Deterministic.
2. **User in "No" state opening resolver directly:** falls through to company default → hardcoded Sale Orders → standard Odoo home.
3. **xmlref deleted or module uninstalled:** `raise_if_not_found=False` returns False, resolver falls through.
4. **First-login user with no preference / company default / roles:** lands on Sale Orders.
5. **Demo / fresh DB:** Sale Orders fallback works without any FP modules beyond core.
---
## Section 4 — Owner-only Team Page
### Implementation — standard Odoo views, not custom OWL
Single new field on `res.users` + standard kanban/form views. Zero custom JS.
```python
# fusion_plating/models/res_users.py
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_plating_role = fields.Selection([
('no', 'No'),
('technician', 'Technician'),
('sales_rep', 'Sales Representative'),
('shop_manager', 'Shop Manager'),
('sales_manager', 'Sales Manager'),
('manager', 'Manager'),
('quality_manager', 'Quality Manager'),
('owner', 'Owner'),
], compute='_compute_plating_role',
inverse='_inverse_plating_role',
store=True,
string='Fusion Plating Role')
```
- **Compute** reads `groups_id`, returns the highest-precedence plating role
- **Inverse** clears all plating groups + writes only the chosen one + posts a `Markup()` chatter audit
- **Stored** so kanban `default_group_by="x_fc_plating_role"` and drag-and-drop work
### Menu placement
```
Plating
└── Configuration (Manager+)
└── ⚡ Settings (existing)
└── 👥 Team (NEW — Owner-only)
└── (opens action_fp_team)
```
XML ID: `fusion_plating.menu_fp_team`, `groups="fusion_plating.group_fp_owner"`.
### 4 tabs
| Tab | View type | Domain | What it does |
|---|---|---|---|
| **Active Team** | Kanban grouped by `x_fc_plating_role` | `[('share','=',False), ('active','=',True)]` | 8 columns; drag-and-drop role changes; click card → user form |
| **Designated Officials** | Form on `res.company` | — | CGP DO + Nadcap Authority Many2one fields |
| **Role Reference** | QWeb static template | — | 8 cards with plain-English "can / cannot" per role |
| **Audit Log** | List on `mail.message` | `[('model','=','res.users'), ('subtype_id','=',mt_note), ('body','ilike','plating role')]` | 90-day role-change history |
All 4 tabs are separate `ir.actions.act_window` records reached via a tabbed notebook. Each has its own xmlid for direct linking.
### Active Team kanban — card layout
```
┌─────────────────────────────────┐
│ [avatar] Jane Doe │
│ jdoe@enplating.com │
│ Last seen: 2h ago │
│ ───────────────────────────── │
│ ⭐ CGP DO │ (only if user.id == company.x_fc_cgp_designated_official_id)
│ 🏆 Nadcap Authority │ (only if user.id == company.x_fc_nadcap_authority_user_id)
│ ───────────────────────────── │
│ Created: 2025-03-14 │
│ Last role change: 2026-05-01 │
└─────────────────────────────────┘
```
Columns (left-to-right by sequence):
`No` · `Technician` · `Sales Rep` · `Shop Manager` · `Sales Manager` · `Manager` · `QM` · `Owner`
Folded by default: `No`, `Sales Rep` (less common in plating shops). Owner can unfold.
Search: name / email / department. Filters: Active (default), With Archived, Has Login Last 30 Days, Has Never Logged In.
### Designated Officials tab — single form
Form on `res.company` with two fields:
- `x_fc_cgp_designated_official_id` (Many2one res.users, domain `[QM, Owner]`)
- `x_fc_nadcap_authority_user_id` (Many2one res.users, domain `[QM, Owner]`)
Save posts to `res.company` chatter for auditability:
> "CGP Designated Official changed: Jane Doe → John Smith by owner@enplating.com on 2026-05-23."
### Role Reference tab — auto-generated
Single source of truth in `fusion_plating/models/res_users.py`:
```python
PLATING_ROLE_DESCRIPTIONS = {
'technician': {
'icon': 'fa-wrench',
'tagline': 'Runs the shop floor.',
'can': [
'See and operate the Workstation tablet',
'Start/finish/pause job steps',
'Capture quality checks and step inputs',
'Issue routine Certificates of Conformance',
'Log scrap and bake events',
],
'cannot': [
'See pricing, quotations, or sales orders',
'Edit recipes or process configurations',
'Override system gates (predecessor lock, signoff, bake window, etc.)',
'Approve CAPAs or sign FAIR/Nadcap certs',
],
},
# ... 7 more entries
}
```
QWeb tab renders cards from this dict. Same dict used by the spec doc generator and by future onboarding wizards.
### What the page does NOT do
- ❌ No editing individual permissions (Q4 Interpretation B — killed)
- ❌ No custom role definitions
- ❌ No per-user exception flags
- ❌ No role hand-off workflows (transfer Bob's open jobs to Alice) — Phase 3
- ❌ No bulk import of roles — defer until 100+ employee shops
### Phase 2 hooks (designed-in, not built)
The 4-tab structure leaves room for:
- **Audit Dashboard** tab — read-only ACL matrix for CGP/Nadcap audit prep
- **Departure Handoff** tab — wizard for terminating users
---
## Section 5 — Migration Workflow
### Models
```python
# fusion_plating/models/fp_migration.py
class FpMigrationPreview(models.Model):
_name = 'fp.migration.preview'
_description = 'Fusion Plating Role Migration Preview'
_order = 'create_date desc'
name = fields.Char(default=lambda s: _('Migration %s') % fields.Datetime.now())
state = fields.Selection([
('pending', 'Pending Review'),
('approved', 'Approved & Applied'),
('cancelled', 'Cancelled'),
('rolled_back','Rolled Back'),
], default='pending', tracking=True)
line_ids = fields.One2many('fp.migration.preview.line', 'preview_id')
user_count = fields.Integer(compute='_compute_counts', store=True)
warning_count = fields.Integer(compute='_compute_counts', store=True)
approved_by_id = fields.Many2one('res.users', readonly=True)
approved_at = fields.Datetime(readonly=True)
rollback_deadline = fields.Datetime(compute='_compute_rollback_deadline')
class FpMigrationPreviewLine(models.Model):
_name = 'fp.migration.preview.line'
_description = 'Migration Preview Line'
preview_id = fields.Many2one('fp.migration.preview', required=True, ondelete='cascade')
user_id = fields.Many2one('res.users', required=True)
current_groups = fields.Char(compute='_compute_current_groups')
proposed_role = fields.Selection(_FP_ROLE_SELECTION)
capability_delta = fields.Char()
warning = fields.Boolean()
notes = fields.Text()
applied_groups_snapshot = fields.Text() # JSON of pre-migration groups_id for rollback
```
### Trigger — runs ONCE on `-u`, enters pending
```python
# fusion_plating/__manifest__.py
'post_init_hook': '_fp_post_init_role_migration',
# fusion_plating/__init__.py
def _fp_post_init_role_migration(env):
"""Idempotent: only creates a preview if one isn't already pending."""
pending = env['fp.migration.preview'].search([('state','=','pending')], limit=1)
if pending:
return
completed = env['fp.migration.preview'].search([('state','=','approved')], limit=1)
if completed:
users = env['res.users'].search(_fp_unmigrated_user_domain(env))
if not users:
return
preview = env['fp.migration.preview'].create({})
preview._fp_build_lines()
preview._fp_notify_owners()
```
Properties:
1. **Idempotent**`-u` re-runs don't duplicate previews
2. **Non-destructive** — only creates preview, never touches users
3. **Owner-gated** — actual migration only on Owner click
### Mapping table (in code)
```python
_FP_ROLE_MAPPING = [
# (predicate_fn, new_role, capability_delta_or_None)
(lambda u: u.id in (1, 2), 'owner', None),
(lambda u: u.has_group('fusion_plating.group_fusion_plating_admin'), 'owner', None),
(lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_designated_official'),
'owner', 'Was CGP DO; field set on res.company'),
(lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_officer'),
'quality_manager', None),
(lambda u: u.has_group('fusion_plating.group_fusion_plating_manager'),
'manager', None),
(lambda u: u.has_group('fusion_plating_configurator.group_fp_shop_manager'),
'manager', None),
(lambda u: u.has_group('fusion_plating_invoicing.group_fp_accounting'),
'manager', None),
(lambda u: u.has_group('fusion_plating_configurator.group_fp_estimator')
and not u.has_group('fusion_plating.group_fusion_plating_manager'),
'sales_rep', 'Loses order-confirm authority'), # ⚠️
(lambda u: u.has_group('fusion_plating.group_fusion_plating_supervisor'),
'shop_manager', None),
(lambda u: u.has_group('fusion_plating_receiving.group_fp_receiving'),
'shop_manager', None),
(lambda u: u.has_group('fusion_plating.group_fusion_plating_operator'),
'technician', None),
(lambda u: True, 'no', None),
]
```
First matching predicate wins (highest-precedence first).
### Preview screen UX
```
┌──────────────────────────────────────────────────────────────────────┐
│ Fusion Plating Role Migration — Preview │
│ Created: 2026-05-23 14:22 by system upgrade │
│ State: Pending Review │
│ │
│ Summary: │
│ • 28 users will be migrated │
│ • 2 will lose capabilities (highlighted ⚠️) │
│ • 1 will become CGP Designated Official (Jane Doe) │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐│
│ │ User │ Current Groups │ → New Role │ Notes ││
│ ├──────────────────────────────────────────────────────────────────┤│
│ │ admin │ Administrator, … │ Owner │ ││
│ │ Jane Doe │ Manager, CGP DO │ Owner │ DO set ││
│ │ John Smith │ Estimator │ Sales Rep │ ⚠️ loses││
│ │ │ │ │ confirm ││
│ │ Carlos Lopez │ Operator │ Technician │ ││
│ │ Bob Chen │ Supervisor, Receiving │ Shop Mgr │ ││
│ │ … 23 more … ││
│ └──────────────────────────────────────────────────────────────────┘│
│ │
│ [ Approve & Run ] [ Cancel ] [ Export to CSV ] │
└──────────────────────────────────────────────────────────────────────┘
```
Per-line `Edit role to:` dropdown lets Owner override any auto-mapping inline before approving.
### Approve & Run
```python
def action_approve_and_run(self):
self.ensure_one()
if not self.env.user.has_group('fusion_plating.group_fp_owner'):
raise UserError(_('Only Owners can approve role migrations.'))
for line in self.line_ids:
user = line.user_id
line.applied_groups_snapshot = json.dumps(user.groups_id.ids)
old_group_ids = self.env['res.groups'].search([
('id', 'in', _FP_OLD_GROUP_IDS(self.env))]).ids
user.write({'groups_id': [(3, gid) for gid in old_group_ids]})
target_group = self.env.ref(_NEW_ROLE_XMLID[line.proposed_role])
if target_group:
user.write({'groups_id': [(4, target_group.id)]})
user.message_post(body=Markup(_(
'Plating role assigned by migration: <b>%s</b>'
)) % line.proposed_role, message_type='notification')
if line.notes and 'CGP DO' in line.notes:
user.company_id.x_fc_cgp_designated_official_id = user.id
self.write({
'state': 'approved',
'approved_by_id': self.env.user.id,
'approved_at': fields.Datetime.now(),
})
```
### Rollback — 30-day undo
```python
def action_rollback(self):
self.ensure_one()
if self.state != 'approved':
raise UserError(_('Only approved migrations can be rolled back.'))
if fields.Datetime.now() > self.rollback_deadline:
raise UserError(_('Rollback window has expired (30 days after approval).'))
for line in self.line_ids:
if line.applied_groups_snapshot:
old_ids = json.loads(line.applied_groups_snapshot)
line.user_id.write({'groups_id': [(6, 0, old_ids)]})
self.state = 'rolled_back'
def _cron_purge_expired_migrations(self):
deadline = fields.Datetime.now() - timedelta(days=30)
expired = self.search([
('state', '=', 'approved'),
('approved_at', '<', deadline)])
for preview in expired:
preview.line_ids.write({'applied_groups_snapshot': False})
self.env['res.groups'].browse(_FP_OLD_GROUP_IDS(self.env)).unlink()
```
### Owner activity notification
```python
def _fp_notify_owners(self):
owners = self.env['res.users'].search([
('groups_id', 'in', self.env.ref('fusion_plating.group_fp_owner').ids)])
for owner in owners:
self.env['mail.activity'].create({
'res_model_id': self.env.ref('fusion_plating.model_fp_migration_preview').id,
'res_id': self.id,
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': _('Review Fusion Plating role migration'),
'note': _('A role migration is pending review. %d users affected, %d with capability changes.') % (
self.user_count, self.warning_count),
'user_id': owner.id,
'date_deadline': fields.Date.today(),
})
```
### Failure modes handled
1. Approver isn't Owner → UserError, no changes
2. Approver clicks twice → second click no-ops (state check)
3. User deleted between dry-run and approval → skipped, logged
4. New group xmlid missing → migration aborts at that line, logs warning
5. Rollback past 30 days → UserError, point Owner at Settings → Users
6. Multiple Owners approve simultaneously → record lock; second sees "Already approved"
---
## Out of Scope (Phase 2+)
| Item | Why deferred | Trigger to build |
|---|---|---|
| Read-only Team page for Manager+ | Owner-only is sufficient for now; can add later as a filter+view variant | If Manager complains about not seeing org chart |
| Audit Dashboard (ACL matrix) | Useful for compliance audits but not blocking | First CGP/Nadcap audit preparation |
| Departure Handoff wizard | Useful when shop grows; one-off manual reassignment works for now | 50+ employee shop |
| Bulk CSV import of roles | Overkill for current shop size | 100+ employee shop |
| Per-permission override page (Interpretation B from Q4) | Killed — defeats the 8-role spec | Never — fundamentally against design |
| Sales Rep "own quotes only" record rule | Sales Reps see all quotes today; adding per-rep ownership is a separate feature | If client requests it |
| Role expiration / time-bound permissions | Out of scope for the consolidation | If contract-employee workflows emerge |
| Per-customer permission overrides | Out of scope | If multi-company tenancy is added |
---
## Acceptance Criteria
Phase 1 is complete when ALL of these are true on `entech`:
1. **Group inventory**: exactly 8 plating roles defined under the Fusion Plating privilege block, in the correct sequence order (10, 20, 30, 40, 50, 60, 70). No `_administrator` typo'd references remain in Python.
2. **Old groups archived**: `group_fusion_plating_operator`, `_supervisor`, `_manager`, `_admin`, `group_fp_estimator`, `_receiving`, `_accounting`, `_shop_manager`, `_cgp_officer`, `_cgp_designated_official`, `_legacy_menus` all set `active=False`. No user holds them post-migration.
3. **ACL coverage**: every `ir.model.access.csv` row that previously referenced an old plating group now references its mapped new group. `grep` for old group xmlids in CSV files returns zero results.
4. **Menu visibility**: opening Plating as each of the 7 roles shows the expected menu tree per Section 2.F. No "ghost" menus (visible but click → error).
5. **Landing resolver**: each role lands on the correct default action:
- Owner / Manager / QM → Manager Desk
- Sales Manager → Sale Orders
- Sales Rep → Quotations
- Shop Manager / Technician → Plant View Kanban (v2 layout) or Workstation (legacy)
- "No" user → company default → Sale Orders fallback
6. **Picklist contains 7 entries**, filtered per user's accessible actions.
7. **Team page reachable** at Plating → Configuration → Team. Drag-and-drop role change posts to user chatter. Visible to Owner only.
8. **Designated Officials field** on `res.company` set; CGP records gated to QM via ir.rule.
9. **Sales Manager + gate works**: Sales Rep saves SO in draft, sees no Confirm button, can't post via API. Sales Manager can confirm.
10. **Quality split works**: Manager can create/close NCRs but CAPAs are read-only for them. QM can close CAPAs, sign FAIR/Nadcap certs.
11. **Bypass flags**: Shop Manager cannot bypass any of the 9 gates; Manager can. Bypass posts chatter audit.
12. **Migration round-trip**: on a test DB, run `-u`, see pending preview, approve, see all users migrated, run rollback within 30 days, see all users restored to original groups.
13. **CLAUDE.md updated** with the new role names + which group implies which (canonical hierarchy doc).
---
## Files Affected (high-level count)
| Module | Files changed | Type |
|---|---|---|
| `fusion_plating` | ~15 | security XML, models (res.users, fp_migration), views (team page, settings), data (role-description dict), post-init hook, migration file |
| `fusion_plating_configurator` | ~8 | ACL CSV updates, view button gates, group XMLs to archive |
| `fusion_plating_invoicing` | ~3 | ACL CSV updates, group archive |
| `fusion_plating_receiving` | ~3 | ACL CSV updates, group archive |
| `fusion_plating_cgp` | ~5 | ACL CSV updates, ir.rule updates, group archives, ResCompany field for DO |
| `fusion_plating_quality` | ~6 | ACL CSV updates for QM/Manager split, ir.rules for FAIR/Nadcap, view button gates |
| `fusion_plating_aerospace` / `_nuclear` / `_safety` | ~3 each | ACL CSV updates (mechanical rename) |
| `fusion_plating_shopfloor` | ~3 | Landing resolver updates, picklist tagging on action_fp_manager_dashboard + action_fp_plant_kanban |
| `fusion_plating_jobs` | ~4 | Legacy menus group archive, ACL CSV updates |
| `fusion_plating_certificates` | ~2 | FAIR/Nadcap signing button gates |
**Estimated total: ~55 files**. Most are mechanical CSV updates (`grep`-and-replace pattern from Section 2.A).
---
## Migration Notes for entech
### Pre-deploy checklist
1. **Backup `admin` DB on entech** — full pg_dump before `-u` (rollback safety beyond the 30-day archive)
2. **Read `_FP_OLD_GROUP_IDS(env)` count** — log expected pre-migration group membership counts
3. **Confirm no other migration running**`SELECT count(*) FROM fp_migration_preview WHERE state='pending';` returns 0
### Deploy command
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
-u fusion_plating,fusion_plating_configurator,fusion_plating_invoicing,\
fusion_plating_receiving,fusion_plating_cgp,fusion_plating_quality,\
fusion_plating_aerospace,fusion_plating_nuclear,fusion_plating_safety,\
fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating_certificates \
--stop-after-init\" && systemctl start odoo'"
```
Bump every module version to `+0.1.0` to ensure the migration scripts fire.
### Post-deploy verification
```sql
-- Pending migration?
SELECT state, user_count, warning_count, create_date
FROM fp_migration_preview ORDER BY id DESC LIMIT 1;
-- Verify Owner activity scheduled
SELECT count(*) FROM mail_activity
WHERE res_model = 'fp.migration.preview'
AND date_deadline >= CURRENT_DATE;
```
Login as Owner → see activity in the home dashboard → click → review preview → approve.
### Post-approval verification
```sql
-- All users mapped to new roles?
SELECT u.login, ARRAY_AGG(g.name) AS groups
FROM res_users u
JOIN res_groups_users_rel r ON r.uid = u.id
JOIN res_groups g ON g.id = r.gid
WHERE g.privilege_id IS NOT NULL
GROUP BY u.id, u.login
ORDER BY u.login;
-- No one still holds old groups?
SELECT count(*) FROM res_groups_users_rel r
WHERE r.gid IN (SELECT id FROM res_groups WHERE name IN (
'Operator','Supervisor','Manager','Administrator','Estimator',
'Receiving','Accounting','Shop Manager','CGP Officer','CGP Designated Official'));
-- Expected: 0 (or low number of stale rows that the migration intentionally left)
-- CGP DO set?
SELECT name, x_fc_cgp_designated_official_id FROM res_company;
```
### Rollback plan
If migration goes wrong within 30 days:
1. Login as Owner → Plating → Configuration → migrations list (or direct URL `/odoo/action-fp.migration.preview`)
2. Click most recent approved migration
3. Click "Rollback" button → all users restored to pre-migration groups
4. Old plating groups remain active (archived after 30 days; rollback un-archives them)
If migration goes wrong AFTER 30 days (cron has purged):
1. Restore from pg_dump backup taken pre-deploy
2. File a follow-up issue to extend the rollback window if this happens repeatedly
---
## Open Risks
1. **Inverse handler on `x_fc_plating_role`** must be robust against partial state. If a user holds NO plating group and gets assigned to `manager`, the inverse adds Manager group; the compute then reads `manager`. If a user holds BOTH `manager` and `technician` somehow (e.g., bug), compute should pick the higher one and the inverse should clean up. Unit tests required for: assign role with no prior role, assign role overwriting prior role, assign 'no' role (should clear all plating groups).
2. **Group rename window**: between archive of old groups and unlink (30 days), the old XMLIDs are still resolvable via `env.ref`. Code that hardcodes old xmlids will keep working accidentally — caught only when groups are finally deleted. **Mitigation:** add a deprecation log to the old groups' `_check_company_auto` or a model-load-time grep to flag any old-xmlid usage that survived the migration.
3. **Landing-page action visibility**: if a new role's hardcoded default action (e.g. `action_fp_manager_dashboard` for Manager) is itself gated by a different group, the resolver returns it but the user gets a permission error on render. **Mitigation:** the picklist domain filter (Section 3) already checks user accessibility. Apply the same check inside `_fp_role_default_landing` — if the role's default action isn't accessible, fall through to the next step instead of returning it.
4. **Mail-template references**: a few mail templates reference Manager / Estimator by xmlid (e.g., notification routing). These must be updated in the same deploy or chatter routing breaks. Grep all `mail_template_*.xml` for old group xmlids during implementation.
5. **CLAUDE.md drift**: after deploy, the role hierarchy in CLAUDE.md must be updated. If skipped, future sessions will reason from stale assumptions. Mandatory part of the implementation plan.
---
## Status & Next Steps
- ✅ Brainstorm complete (5 questions answered + Q4b menu-hiding policy)
- ✅ Design doc written
- ⏳ Self-review (next)
- ⏳ User review of this spec
- ⏳ Invoke `writing-plans` skill to create the implementation plan
- ⏳ Execute implementation per the plan
- ⏳ Deploy + verify on entech
- ⏳ Update CLAUDE.md with new role hierarchy
---
*End of design document.*

View File

@@ -0,0 +1,779 @@
# Shop Floor Plant View — Redesign
**Date:** 2026-05-23
**Status:** Design — approved through brainstorming, awaiting plan
**Replaces:** the current Shop Floor kanban (per-step grouping, one card per step)
**Affects:** `fusion_plating_shopfloor` (primary), `fusion_plating` (work centre taxonomy), `fusion_plating_jobs` (active-step + workflow-state computes)
---
## 1. Problem
The current Shop Floor kanban groups cards by individual `fp.job.step.work_centre_id`. Every ready/pending step of a job spawns a separate card in its respective column. A 14-step recipe (e.g. `ENP-ALUM-BASIC` on WO-30019) produces **9 cards across 9 columns for ONE job**. With 17 active jobs on the floor, the board shows 100+ cards across 10+ narrow columns, most of which contain duplicates of the same WO.
Confirmed by the user via screenshots taken 2026-05-23:
> "the same job is appearing in multiple places, there can be 20 steps in any job and we cannot just make 20 columns for those jobs"
Net effect:
- Operators can't scan the board — duplicates drown the signal
- Recipes with many steps (15+) make the board explode horizontally
- "Where is WO-30019 right now?" is impossible to answer at a glance
- The mode toggle (Station / All Plant) is cosmetic — both produce the same cluttered output
The redesign re-anchors the kanban on **one card per job** at the **department level**, and scales to any recipe step count.
---
## 2. Goals & non-goals
### Goals
1. **Every active fp.job appears in EXACTLY ONE column** at all times. No duplication.
2. **Fixed 9-column layout** that doesn't grow with recipe step count.
3. **Columns always render in process sequence** (Receiving → … → Shipping), regardless of card distribution. Empty columns still show.
4. **Operator paired to a station sees their work highlighted** but can also see the whole plant — "Where is everything right now?" is the central operator question.
5. **Every floor state the audit + battle-test catalog exposes is visually distinguishable on the card** (13 states total).
6. **Scales infinitely**: a 5-step recipe and a 30-step recipe both produce single cards moving across the same 9 columns.
7. **Tablet-first** — readable on a 1080p wall-mounted tablet without horizontal scroll.
### Non-goals
- **Replacing the Job Workspace** (the full-screen single-WO surface). The kanban is the entry point; the Workspace remains the place where work happens. Card tap opens the Workspace.
- **Replacing the Manager Dashboard** (`fp_manager_dashboard` with workflow funnel + at-risk + heatmap). The kanban's "Manager" mode is a filter on the same board; the dedicated dashboard stays separate.
- **Drag-and-drop step advancement** from the kanban. State transitions happen inside the Workspace or via Move dialogs. The kanban reflects state, doesn't drive it.
- **Per-tank columns**. Tanks are surfaced as chips on the card, not as columns.
---
## 3. Decisions locked during brainstorming (2026-05-23)
| # | Decision |
|---|---|
| D1 | **Plant-wide view with mine highlighted** is the operator default (over "filter to my station only"). Operators help each other and cover stations; visibility matters more than filtering. |
| D2 | **9 fixed columns** by process area (Receiving, Masking, Blasting, Racking, Plating, Baking, De-Racking, Final inspection, Shipping). |
| D3 | **All wet steps roll up into Plating** — Soak Clean, Electroclean, Acid Dip, Etch, Desmut, Zincate, Rinse, Water Break Test, E-Nickel Plating, Chrome, Anodize, Black Oxide, Drying. The tank chip on the card distinguishes them. |
| D4 | **De-Masking folds into De-Racking** — same operator action in this shop's workflow; no separate column. |
| D5 | **Contract Review (paperwork) cards live in Receiving** with a purple paperwork chip. Same for any pre-physical-work admin gate. |
| D6 | **Variant C card design** — full-width vertical card with WO header, customer/PN/qty/PO line, recipe + spec, tag chips (Rush/FAIR/VIP), current step name, tank + state chip row, 9-column mini-timeline, progress bar + operator pill + icons. |
| D7 | **13 card states** distinguishable by background tint, left-border color, state chip text/color, and timeline marker color. Full catalog in §6. |
| D8 | **Columns appear in sequence and never reorder** — even empty columns show. The sequence is the visual mental model of the floor. |
---
## 4. Column layout
### 4.1 Fixed column sequence
The board always renders these 9 columns in this exact order, left-to-right:
```
1. Receiving 2. Masking 3. Blasting 4. Racking 5. Plating
6. Baking 7. De-Racking 8. Final inspection 9. Shipping
```
Columns are first-class entities, not derived from data. If no jobs are in Blasting, the column still appears with a "0" badge — it's a placeholder reminding the operator where Blasting sits in the flow.
### 4.2 Step-kind → column mapping
Each `fp.job.step` routes to exactly one column based on its `recipe_node_id.default_kind`. The mapping table:
| Column | Step kinds routed here |
|---|---|
| **Receiving** | `incoming_inspection`, `contract_review`, `gating`, `ready_for_processing`, any step where `state = 'pending'` and the job's first physical step hasn't started |
| **Masking** | `masking` |
| **Blasting** | `blasting`, `bead_blast`, `media_blast` |
| **Racking** | `racking` |
| **Plating** | `soak_clean`, `electroclean`, `acid_dip`, `etch`, `desmut`, `zincate`, `rinse`, `water_break_test`, `e_nickel_plate`, `chrome`, `anodize`, `black_oxide`, `drying`, `activation`, any step whose `work_centre.kind = 'wet_line'` |
| **Baking** | `bake`, `oven_bake`, `post_bake_relief` |
| **De-Racking** | `de_rack`, `de_mask`, `unrack` |
| **Final inspection** | `post_plate_inspection`, `final_inspection`, `thickness_qc`, `fair`, `dimensional_check`, any step whose `work_centre.kind = 'inspect'` |
| **Shipping** | `shipping`, `pack_ship` |
### 4.3 Implementation — `area_kind` field
Add a new Selection field on `fp.work.centre`:
```python
area_kind = fields.Selection([
('receiving', 'Receiving'),
('masking', 'Masking'),
('blasting', 'Blasting'),
('racking', 'Racking'),
('plating', 'Plating'),
('baking', 'Baking'),
('de_racking', 'De-Racking'),
('inspection', 'Final inspection'),
('shipping', 'Shipping'),
], string='Floor Column', help='Which Shop Floor column this work centre belongs to. Drives the plant-view kanban.')
```
`fp.job.step` already carries a `recipe_node_id` and (optionally) a `work_centre_id`. The kanban grouping resolves a step's column via:
```
step.area_kind = step.work_centre_id.area_kind
or _DEFAULT_KIND_BY_RECIPE_KIND.get(step.recipe_node_id.default_kind)
or 'plating' # safe catch-all for unmapped wet steps
```
A `post_init_hook` backfills `area_kind` on existing `fp.work.centre` records by matching their `kind` (`wet_line`/`bake`/`mask`/`rack`/`inspect`) against the new taxonomy. Unmapped centres get flagged for manual review.
### 4.4 Column visibility rules
- Always show all 9 columns in order.
- Show the column-header count even when zero (`0` in grey, less prominent).
- The operator's paired-station column gets a yellow tint + "📍 You're here" badge — see §7.
---
## 5. Card design — Variant C
### 5.1 Anatomy
```
┌─────────────────────────────────────────────┐
│ WO-30049 ⭐ Due May 16 · 3d │ ← WO + due
│ ABC Manufacturing │ ← customer
│ PN 9876699373 Rev A · Qty 5 · PO 4501882 │ ← part/qty/PO
│ Recipe: ENP-ALUM-BASIC · AMS-2404 Type II │ ← recipe + spec
│ [RUSH] [FAIR] │ ← tag chips
│ Racking │ ← current step name
│ [Rack Station 1] [● Ready] │ ← tank + state chips
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← mini-timeline
│ Rec Mask Blast [Rack] Plat Bake D-R Insp Ship│ ← timeline labels
├─────────────────────────────────────────────┤
│ Step 4/14 ▓▓░░░░░░░░░ [GS] 🔏 │ ← progress + operator + icons
└─────────────────────────────────────────────┘
```
### 5.2 Field-by-field
| Element | Source | Notes |
|---|---|---|
| WO # | `fp.job.display_wo_name` | Big, bold. `⭐` suffix appears when card is at operator's paired station. Tappable — opens Job Workspace. |
| Due date | `fp.job.commitment_date` | Format "Due May 16 · 3d" (relative). Turns red + `⚠` when overdue. |
| Customer | `fp.job.partner_id.name` | Single line; truncate with ellipsis if too long. |
| PN / Qty / PO | `fp.job.part_catalog_id.part_number` + `.revision` · `fp.job.qty` · `fp.job.sale_order_id.x_fc_po_number` | One line, comma-separated. |
| Recipe + spec | `fp.job.recipe_id.name` · `fp.job.customer_spec_id.code` | Muted small text. |
| Tag chips | derived | Multi: Rush (partner flag) / FAIR (customer_spec.x_fc_requires_first_article) / VIP (partner flag) / AS9100 (job aerospace flag). Only renders when applicable. |
| Current step name | `fp.job.active_step_id.name` or first ready step | Operator-facing label of the step the job is at. |
| Tank chip | `fp.job.active_step_id.work_centre_id.code` or `.tank_id.code` | Blue chip. Specific tank/station. |
| State chip | computed (§6) | One of 13 states. Color matches state. |
| Mini-timeline | derived (§8) | 9-step bar showing the journey across columns. |
| Step X / Y | `fp.job.active_step_id.sequence` / `count(fp.job.step_ids)` | Recipe progress, not the same as the 9-col timeline. |
| Progress bar | computed | Filled to `active_step.sequence / total_steps`. Color matches state. |
| Operator pill | `fp.job.active_step_id.assigned_user_id` | Initials avatar. Hidden when ready (no operator engaged yet). |
| Icon row | derived | Compact status flags (see §5.3). |
### 5.3 Icon row catalog
| Icon | Meaning | Trigger |
|---|---|---|
| 🔏 | Sign-off required | `step.requires_signoff` AND not yet signed |
| ⏰ | Bake window approaching | upstream wet step done, `bake_required_by - now < 1h` |
| 🔥 | Bake compliance gate | active step kind = `bake` |
| 💬 | Recent chatter activity | `job.message_post` in last 24h |
| 🔒 | Predecessor locked | `step.requires_predecessor_done` AND upstream not done |
| 📋 | Required inputs unrecorded | `step._fp_missing_required_step_inputs()` returns non-empty |
| 📷 | Photo required but missing | step has a `photo` input prompt unrecorded |
| 🚚 | Inbound shipment tracking | `state = no_parts` AND `x_fc_receiving.x_fc_carrier_tracking` present |
| 📜 | Cert ready / issued | `state = done` AND `fp.certificate.state in ('issued','sent')` |
| ↳ | Jump to blocker | tappable; navigates to the predecessor step in the Workspace |
Icons only render when their condition is true. Max 3-4 visible per card; overflow into a `⋯` tooltip.
---
## 6. Card states — exhaustive catalog
13 mutually-exclusive states, computed server-side per job. Each card carries exactly one state; precedence rules below resolve conflicts.
### 6.1 State definitions
| # | State | Background | Left border | State chip | Timeline marker | Triggered when |
|---|---|---|---|---|---|---|
| 1 | `ready_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "● Ready to start" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND `active_step.work_centre_id IN operator_paired_stations` |
| 2 | `running_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "▶ Running 8m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND `active_step.work_centre_id IN operator_paired_stations` |
| 3 | `ready` | `#ffffff` (white) | none | "● Ready" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND NOT mine |
| 4 | `running` | `#ffffff` (white) | none | "▶ Running 3m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND NOT mine |
| 5 | `on_hold` | `#fff5f5` (red) | `#dc3545` (red, 4px) | "🔴 Quality Hold" (red) | `current.hold` (red) | `fusion.plating.quality.hold` exists on the job with `state = 'open'` |
| 6 | `predecessor_locked` | `#f8f9fa` (grey) | none | "🔒 Waiting on Blasting" (grey) | `current.locked` (grey) | `step._fp_should_block_predecessors()` returns True AND any earlier-sequence step not done/skipped/cancelled |
| 7 | `bake_due` | `#fff8e1` (orange) | `#ff9800` (orange, 4px) | "⏰ Bake window in 23m" (orange) | `current.bake` (orange) | `fusion.plating.bake.window` for this job has `bake_required_by - now < 1h` AND `state = 'awaiting_bake'` |
| 8 | `awaiting_signoff` | `#f5f0ff` (purple) | `#6f42c1` (purple, 4px) | "🔏 Awaiting QA sign-off" (purple) | `current.signoff` (purple) | `step.requires_signoff` AND `step.state = 'done'` AND `step.signoff_user_id IS NULL` (S22 gate) |
| 9 | `idle_warning` | `#fef9e7` (amber) | `#e6a800` (amber, 4px) | "⏸ Idle 14h · Carlos" (amber) | `current.idle` (amber) | `step.state = 'in_progress'` AND `now - step.last_activity_at > 8h` (S16 cron) |
| 10 | `awaiting_qc` | `#e7f5fc` (cyan) | `#17a2b8` (cyan, 4px) | "🔬 QC pending · 2/6 items" (cyan) | `current.qc` (cyan) | `fusion.plating.quality.check` exists with `state IN ('draft','in_progress')` AND no other higher-precedence state |
| 11 | `no_parts` | `#f5f5f5` (grey, dashed) | `#6c757d` (grey, 4px, dashed) | "📦 Parts in transit · 2d" (grey) | `current.no_parts` (grey) | `fp.job.state = 'confirmed'` AND inbound `fp.receiving.state = 'draft'` AND no step has started yet |
| 12 | `contract_review` | `#ffffff` (white) | none | "📋 QA-005 Awaiting QA Manager" (purple) | `current.paperwork` (purple) | `active_step.recipe_node_id.default_kind = 'contract_review'` AND not complete |
| 13 | `done` | `#f0f9f4` (green) | `#28a745` (green, 4px) | "✓ Ready for pickup" (green) | `current.done` (green) | active step is in `Shipping` column AND `fp.job.state = 'done'` |
### 6.2 Precedence rules
When multiple state triggers fire simultaneously, the resolver iterates through this **explicit precedence list** and takes the first match:
```
1. no_parts (can't co-occur with anything else; checked first)
2. on_hold (compliance bomb — always wins over operational states)
3. awaiting_signoff (S22 gate — blocks advancement even when step.state='done')
4. awaiting_qc (quality gate — sticky until QC closes)
5. bake_due (time-sensitive compliance window)
6. predecessor_locked (soft block on a step that's data-ready but workflow-locked)
7. idle_warning (long-running supersedes plain running)
8. done (terminal state — only reached if none of the above apply)
9. contract_review (paperwork — used at job entry before physical work)
10. running_mine (more specific than running)
11. ready_mine (more specific than ready)
12. running (operational default for active work)
13. ready (operational default for next-up work)
```
The numeric ordering here is the dispatch order in `_fp_resolve_card_state`, not a severity ranking. Examples:
- Job both on-hold AND awaiting-signoff → `on_hold` (rule 2 fires before rule 3)
- Job both bake-due AND running_mine → `bake_due` (rule 5 fires before rule 10)
- Job in_progress for 14h at the operator's station → `idle_warning` (rule 7 fires before rule 10)
Implementation in §9.3 mirrors this list exactly — keep them synchronized.
### 6.3 Mine resolution
A card is "mine" when **any of the following** is true:
1. `active_step.work_centre_id.id IN operator.paired_work_centre_ids` (operator paired to that specific station)
2. `active_step.assigned_user_id == operator.id` (job is personally assigned to operator)
3. `active_step.area_kind == operator.preferred_area_kind` (operator's profile lists this department, for cross-trained operators)
For **MVP, use rule 1 only**. Rules 2-3 are post-MVP enhancements.
The `res.users.paired_work_centre_ids` is a Many2many on the data model (so it's forward-compatible with cross-trained operators), but the **MVP pairing UX keeps the existing single-station dropdown** (`fp_shopfloor_tech_store.currentStationId`). On unlock, the M2M holds exactly one record — the selected station. A Phase 2 enhancement adds a multi-select picker so cross-trained operators can pair to 2-4 stations at once; the resolver above already supports that without further code change.
---
## 7. Sticky header
The header pins to the top of the kanban and remains visible during scroll.
### 7.1 Layout
```
┌────────────────────────────────────────────────────────────────────────────┐
│ 🏭 Shop Floor [📍 Racking — Garry Singh ▾] [Station|All Plant|Manager]│
│ [📷 Scan QR] [🔓 Hand Off] [⚙] │
├────────────────────────────────────────────────────────────────────────────┤
│ [17 Active] [3 At My Station] [2 Bakes Due ≤2h] [1 On Hold] [2 Overdue]│
├────────────────────────────────────────────────────────────────────────────┤
│ [🔎 Search WO #, customer, part #, PO…] │
│ [All] [My Station] [Running] [Blocked] [Overdue] [FAIR] │
└────────────────────────────────────────────────────────────────────────────┘
```
### 7.2 KPI strip — 5 tiles, clickable filters
| Tile | Source | Click behavior |
|---|---|---|
| **Active Jobs** | `count(fp.job WHERE state IN ('confirmed','in_progress'))` | Filter chip "All" → shows everything |
| **At My Station** | count of cards with `state IN ('ready_mine','running_mine')` | Filter chip "My Station" → only mine |
| **Bakes Due ≤2h** | count of cards with `state = 'bake_due'` AND `bake_required_by - now < 2h` | Highlights orange cards |
| **On Hold** | count of cards with `state = 'on_hold'` | Filter to red cards; clicking opens Quality Holds list |
| **Overdue** | count of cards where `commitment_date < today` AND `state != 'done'` | Filter to overdue |
Each tile is a button. Active tile shows a darker border + filled chip indicator.
### 7.3 Filter chips
Below KPIs, a row of toggleable filter chips. Multiple can be active (intersected with AND):
- **All** (default; clears others)
- **My Station** (cards where `state IN ('ready_mine','running_mine')`)
- **Running** (`active_step.state = 'in_progress'`)
- **Blocked** (`state IN ('on_hold','predecessor_locked','awaiting_signoff','awaiting_qc','no_parts')`)
- **Overdue** (`commitment_date < today` AND `state != 'done'`)
- **FAIR** (partner or spec requires FAIR; flagged via tag)
Chip state persists per operator per browser session (localStorage), so an operator who always filters to "My Station" doesn't have to re-set it each shift.
### 7.4 Station picker
The `[📍 Racking — Garry Singh ▾]` button:
- Shows the operator's current paired station + their name
- Dropdown lets them switch to a different station they're certified on (from their `paired_work_centre_ids`)
- "All stations" option clears pairing
- Disabled when the operator hasn't signed in (lock screen takes precedence)
### 7.5 Mode toggle
Three modes:
| Mode | Behavior |
|---|---|
| **Station** | Cards at the paired station's column get the yellow `mine` treatment. Column header shows "📍 You're here". Other columns visible but neutral. |
| **All Plant** | No "mine" highlight anywhere. Pure plant overview. Use case: supervisor walking the floor without paired station. |
| **Manager** | Same as All Plant + adds bottleneck heatmap row at top (`fp.work.centre.bottleneck_score` driven). KPI strip swaps to manager-specific tiles (Late Risk, Avg Wait, etc.). |
Manager mode is gated by `fusion_plating.group_fusion_plating_manager`.
---
## 8. Mini-timeline derivation
The 9-step bar on each card is **not** the recipe step count — it's a fixed 9-element array keyed by the 9 columns. Logic:
```python
def _compute_mini_timeline(self):
"""Returns list of 9 dicts, one per column, with state in {'done','current','upcoming','hold','locked','bake','signoff','idle','qc','no_parts','done','paperwork'}."""
timeline = []
job_steps = self.step_ids.sorted('sequence')
active = self.active_step_id
active_area = active.area_kind if active else None
for area in COLUMN_SEQUENCE: # ['receiving', 'masking', 'blasting', ...]
steps_in_area = job_steps.filtered(lambda s: s.area_kind == area)
if not steps_in_area:
# area not used by this recipe — still show as 'upcoming' to keep alignment
timeline.append({'area': area, 'state': 'upcoming'})
continue
if all(s.state in ('done', 'skipped') for s in steps_in_area):
timeline.append({'area': area, 'state': 'done'})
elif area == active_area:
# The card's state determines the current marker color
timeline.append({'area': area, 'state': 'current', 'variant': self.card_state})
else:
timeline.append({'area': area, 'state': 'upcoming'})
return timeline
```
Notes:
- Recipes that skip a column (e.g. a job that doesn't need Masking) still render that column slot as "upcoming" grey — visual alignment matters more than perfect accuracy.
- The `variant` field on the current marker tells the renderer which color to use (matches the card-state color: yellow / red / orange / purple / etc.).
---
## 9. Backend changes
### 9.1 New / modified fields
| Model | Field | Type | Purpose |
|---|---|---|---|
| `fp.work.centre` | `area_kind` | Selection (9 values) | Routes each work centre to one of the 9 columns |
| `fp.job.step` | `area_kind` | Char, computed, stored, indexed | Related from `work_centre_id.area_kind` with fallback to `recipe_node_id.default_kind` lookup |
| `fp.job` | `card_state` | Char, computed, stored, indexed | The 13-state classifier; computed via `_compute_card_state` with the precedence rules in §6.2 |
| `fp.job` | `mini_timeline_json` | Text, computed | JSON-serialized output of `_compute_mini_timeline` |
| `fp.job.step` | `last_activity_at` | Datetime, indexed | Updated on any state transition / move / chatter post; drives idle-warning detection (S16) |
| `res.users` | `paired_work_centre_ids` | M2M `fp.work.centre` | Operator's certified stations; resolved on PIN unlock |
`area_kind` Selection values (used by both `fp.work.centre` and `fp.job.step`):
```python
COLUMN_SEQUENCE = [
('receiving', 'Receiving'),
('masking', 'Masking'),
('blasting', 'Blasting'),
('racking', 'Racking'),
('plating', 'Plating'),
('baking', 'Baking'),
('de_racking', 'De-Racking'),
('inspection', 'Final inspection'),
('shipping', 'Shipping'),
]
```
### 9.2 New endpoint — `/fp/landing/plant_kanban`
Replaces the existing `/fp/landing/kanban`. Returns:
```json
{
"ok": true,
"mode": "station",
"paired_station": {"id": 12, "name": "Rack Station 1", "area_kind": "racking"},
"kpis": {
"active_jobs": 17,
"at_my_station": 3,
"bakes_due_soon": 2,
"on_hold": 1,
"overdue": 2
},
"columns": [
{
"area_kind": "receiving",
"label": "Receiving",
"is_mine": false,
"card_ids": [2885, 2886, 2887]
},
{
"area_kind": "masking",
"label": "Masking",
"is_mine": false,
"card_ids": [2884]
},
...
],
"cards": {
"2885": {
"wo_name": "WO-30049",
"is_mine": true,
"card_state": "ready_mine",
"due_date": "2026-05-16",
"due_label": "Due May 16 · 3d",
"is_overdue": false,
"customer": "ABC Manufacturing",
"part_number": "9876699373",
"part_revision": "A",
"qty": 5,
"po_number": "4501882",
"recipe_name": "ENP-ALUM-BASIC",
"spec_code": "AMS-2404 Type II",
"tags": ["rush", "fair"],
"step_name": "Racking",
"step_seq": 4,
"step_total": 14,
"tank_label": "Rack Station 1",
"state_chip": {"label": "● Ready to start", "kind": "ready"},
"operator": {"id": null, "name": null, "initials": null},
"duration_label": null,
"icons": ["signoff_required"],
"mini_timeline": [
{"area": "receiving", "state": "done"},
{"area": "masking", "state": "done"},
{"area": "blasting", "state": "done"},
{"area": "racking", "state": "current", "variant": "ready_mine"},
...
]
},
...
}
}
```
Design choices:
- **Two-tier structure** (`columns` + `cards`) keeps payload small when 2 cards happen to be at the same step — no per-column-per-card duplication.
- **`card_state` is server-computed** — frontend just maps state → CSS class.
- **`mini_timeline` is server-computed** — frontend renders the 9 dots without knowing the recipe shape.
- **Operator info is denormalized** — initials, name, color hash all in the payload so the frontend doesn't fan out RPCs.
### 9.3 State computation — `_compute_card_state`
Matches the precedence list in §6.2 exactly. Both must stay in sync.
```python
def _compute_card_state(self):
for job in self:
# Edge: job has no active step (all pending or all done)
if not job.active_step_id:
# rule 1
if job.state == 'confirmed' and job._fp_inbound_not_received():
job.card_state = 'no_parts'
else:
# Fallback to first pending step's kind; otherwise contract_review
job.card_state = 'contract_review'
continue
step = job.active_step_id
# rule 1 — no_parts (even with an active step, if inbound is still draft)
if job._fp_inbound_not_received():
job.card_state = 'no_parts'
continue
# rule 2 — on_hold
if job._fp_has_open_hold():
job.card_state = 'on_hold'
continue
# rule 3 — awaiting_signoff (S22)
if (step.requires_signoff and step.state == 'done'
and not step.signoff_user_id):
job.card_state = 'awaiting_signoff'
continue
# rule 4 — awaiting_qc
if job._fp_has_pending_qc():
job.card_state = 'awaiting_qc'
continue
# rule 5 — bake_due
if job._fp_bake_window_due_soon():
job.card_state = 'bake_due'
continue
# rule 6 — predecessor_locked
if (step._fp_should_block_predecessors()
and step._fp_has_unfinished_predecessors()):
job.card_state = 'predecessor_locked'
continue
# rule 7 — idle_warning (S16)
if step.state == 'in_progress' and step._fp_is_idle(threshold_hours=8):
job.card_state = 'idle_warning'
continue
# rule 8 — done (terminal, only reached when nothing above fires)
if step.area_kind == 'shipping' and job.state == 'done':
job.card_state = 'done'
continue
# rule 9 — contract_review
if step.recipe_node_id.default_kind == 'contract_review':
job.card_state = 'contract_review'
continue
# rules 10/12 — running (mine vs not)
if step.state == 'in_progress':
job.card_state = ('running_mine' if job._fp_is_mine()
else 'running')
continue
# rules 11/13 — ready (mine vs not)
if step.state == 'ready':
job.card_state = ('ready_mine' if job._fp_is_mine()
else 'ready')
continue
# Safe default
job.card_state = 'ready'
```
Each `_fp_*` helper is a small method on `fp.job` (or `fp.job.step`) that encapsulates one precedence check. Centralizing them this way means future audits can extend the catalog without touching the dispatch.
### 9.4 Helpers
| Helper | Returns | Source data |
|---|---|---|
| `_fp_inbound_not_received()` | bool | `fp.receiving` linked via SO; `state = 'draft'` |
| `_fp_has_open_hold()` | bool | `fusion.plating.quality.hold` with `state = 'open'` linked via `job_id` |
| `_fp_has_pending_qc()` | bool | `fusion.plating.quality.check` with `state IN ('draft','in_progress')` linked via `job_id` |
| `_fp_bake_window_due_soon()` | bool | `fusion.plating.bake.window` linked, `bake_required_by - now < 1h`, `state = 'awaiting_bake'` |
| `step._fp_is_idle(threshold_hours=8)` | bool | `now - last_activity_at > threshold` |
| `_fp_is_mine()` | bool | `active_step.work_centre_id IN env.user.paired_work_centre_ids` |
---
## 10. Frontend changes
### 10.1 OWL component structure
New / modified files in `fusion_plating_shopfloor/static/src/`:
```
js/
plant_kanban.js (new — replaces shopfloor_landing.js)
components/
plant_card.js (new — Variant C card component)
mini_timeline.js (new — 9-step horizontal bar)
column_header.js (new — column header with "📍 You're here" badge)
kpi_tile.js (new — clickable KPI button)
filter_chip.js (new — toggleable filter chip)
xml/
plant_kanban.xml (new)
components/
plant_card.xml (new)
mini_timeline.xml (new)
column_header.xml (new)
kpi_tile.xml (new)
filter_chip.xml (new)
scss/
plant_kanban.scss (new — board layout + sticky header)
components/
_plant_card.scss (new — 13 card-state styles)
_mini_timeline.scss (new — timeline dots)
_column_header.scss (new)
_kpi_tile.scss (new)
_filter_chip.scss (new)
```
### 10.2 Component tree
```
FpPlantKanban (top-level client action)
├── FpTabletLock (existing wrapper for PIN gate)
└── (when unlocked)
├── PlantHeader
│ ├── StationPicker
│ ├── ModeToggle
│ ├── ToolbarButtons (Scan / Hand Off / Settings)
│ ├── KpiStrip (5 × KpiTile)
│ └── FilterRow (search input + 6 × FilterChip)
└── Board
└── 9 × Column
├── ColumnHeader
└── PlantCard[]
├── CardHeader (WO, due)
├── CardBody (customer, PN, recipe, tags)
├── CardStep (step name + chips)
├── MiniTimeline
└── CardFooter (progress + operator + icons)
```
### 10.3 Card state CSS
All 13 states share the base `.plant-card` class with state-specific modifier classes:
```scss
.plant-card {
background: $card-bg;
border: 1px solid $border-color;
border-radius: 8px;
// ... base layout
&.state-ready_mine, &.state-running_mine {
background: #fffaeb;
border-left: 4px solid #f0a500;
padding-left: 9px;
}
&.state-on_hold {
background: #fff5f5;
border-left: 4px solid #dc3545;
padding-left: 9px;
}
&.state-bake_due {
background: #fff8e1;
border-left: 4px solid #ff9800;
padding-left: 9px;
}
&.state-awaiting_signoff {
background: #f5f0ff;
border-left: 4px solid #6f42c1;
padding-left: 9px;
}
&.state-idle_warning {
background: #fef9e7;
border-left: 4px solid #e6a800;
padding-left: 9px;
}
&.state-awaiting_qc {
background: #e7f5fc;
border-left: 4px solid #17a2b8;
padding-left: 9px;
}
&.state-predecessor_locked {
background: #f8f9fa;
}
&.state-no_parts {
background: #f5f5f5;
border: 1px dashed #999;
border-left: 4px solid #6c757d;
padding-left: 9px;
}
&.state-done {
background: #f0f9f4;
border-left: 4px solid #28a745;
padding-left: 9px;
}
// state-ready, state-running, state-contract_review: default neutral white
}
```
Dark-mode SCSS branch follows the project pattern (`$o-webclient-color-scheme == dark` block) with adjusted hex values.
### 10.4 Auto-refresh
Polling every 10s via `setInterval`. On each tick:
1. Fetch `/fp/landing/plant_kanban` with current mode + filter state in the request payload.
2. Diff against current state.
3. Apply changes to OWL reactive state — cards that moved columns animate the transition (fade-out from old column, fade-in at new column over 200ms).
Hand-Off, mode toggle, station-picker, and filter chip changes trigger an immediate refresh.
### 10.5 Card tap behavior
Single tap on a card → opens Job Workspace (`fp_job_workspace` client action) with the WO pre-loaded. No quick-action sheet on tablet (would compete with the Workspace's own action rail).
Card has a small "" icon in the top-right that opens a quick-info popover (for supervisor walk-bys who want details without leaving the kanban). Post-MVP.
---
## 11. Migration & rollout
### 11.1 Database migration
```python
# fusion_plating/migrations/19.0.21.0.0/post-migrate.py
def migrate(cr, version):
"""Backfill fp.work.centre.area_kind from existing kind values."""
cr.execute("""
UPDATE fp_work_centre
SET area_kind = CASE kind
WHEN 'wet_line' THEN 'plating'
WHEN 'bake' THEN 'baking'
WHEN 'mask' THEN 'masking'
WHEN 'rack' THEN 'racking'
WHEN 'inspect' THEN 'inspection'
ELSE 'plating'
END
WHERE area_kind IS NULL
""")
# Log unmapped centres for manual review
cr.execute("""
SELECT id, name FROM fp_work_centre WHERE area_kind IS NULL
""")
for row in cr.fetchall():
_logger.warning("Work centre %s (%s) has no area_kind — defaulted to 'plating'", row[0], row[1])
```
### 11.2 Feature flag
New config setting `x_fc_shopfloor_layout` on `res.config.settings`:
- `legacy` (default during rollout) — existing landing
- `v2` — new plant view
Once validated on entech, default flips to `v2` and legacy code can be removed in a follow-up cleanup.
The client action `fp_shopfloor_landing` resolver chooses which OWL component to mount based on this setting.
### 11.3 Rollout sequence
1. Ship migration + backend (`area_kind`, `card_state`, `mini_timeline_json`, helpers, endpoint) under the v2 flag.
2. Ship OWL components under the v2 flag. Both screens coexist.
3. QA on entech: flip `x_fc_shopfloor_layout = 'v2'`, validate end-to-end.
4. Run battle-test scenarios (S1-S23) against the new view to confirm no regression.
5. Flip default to `v2` site-wide.
6. After 2 weeks of stable v2, remove legacy code.
---
## 12. Testing strategy
### 12.1 Unit tests
- `test_card_state_computation` — for each of the 13 states, construct an `fp.job` in that exact data shape, assert `card_state` resolves correctly
- `test_card_state_precedence` — overlay multiple triggers (e.g. on-hold + bake-due), assert precedence rules produce the documented winner
- `test_area_kind_routing` — for each step kind in the mapping table, assert it routes to the correct column
- `test_mini_timeline` — for a 14-step recipe at various points, assert the 9-element output matches expectations (including skipped columns rendered as upcoming)
- `test_one_card_per_job_invariant` — across a realistic 17-job board, assert no two entries in `cards{}` share the same `fp.job.id`
### 12.2 Persona walks
Re-run the battle-test scenarios that drove this redesign:
- **S20 walk** — operator persona traversal of the tablet. Confirm: card density readable, "mine" highlight obvious, can find a specific WO in <5s via search.
- **S22 / S23 simulations** — finish a step that needs sign-off / transition form, confirm the card transitions to `awaiting_signoff` / `awaiting_qc` state correctly.
- **20-step-recipe regression** — load a synthetic job with 25+ recipe steps, confirm it occupies one and only one card on the board.
### 12.3 Visual snapshot tests
Per state, a Playwright/headless-chromium snapshot of a single card at fixed viewport. Diff against checked-in golden images on every PR. Catches accidental CSS regressions.
---
## 13. Open questions (deferred)
These don't block MVP but should be tracked for the follow-up plan.
| # | Question | Suggested resolution |
|---|---|---|
| Q1 | Drag-and-drop card between columns? | **No for MVP.** State transitions happen via the Workspace action rail or Move dialogs. The kanban reflects state, doesn't drive it. |
| Q2 | Empty-column auto-collapse? | **No.** Column position = mental model. Collapsing breaks the sequence. |
| Q3 | Sort within column? | **MVP: most urgent first** — overdue → bake-due → ready → running → idle → locked → done. Post-MVP: operator-toggleable. |
| Q4 | Card tap → quick-action sheet vs. open Workspace? | **MVP: open Workspace.** Quick-action sheet is a post-MVP enhancement. |
| Q5 | Manager mode KPI tile swap? | **Phase 2.** MVP ships with the same 5 KPI tiles in all modes. Phase 2 adds manager-specific tiles (late-risk %, avg wait per station, bottleneck score). |
| Q6 | Sibling jobs (WO-30029-01 / -02) visual grouping? | **No special treatment for MVP.** Each is its own card. If siblings clutter, post-MVP adds a "group siblings" toggle. |
| Q7 | Bottleneck heatmap row in manager mode? | **Phase 2.** Reuses existing `fp.work.centre.bottleneck_score`. |
| Q8 | Mobile (phone) breakpoint? | **Phase 2.** MVP optimized for 1080p tablet. Phone view = collapse to single-column scroll. |
---
## 14. Summary
| Question | Answer |
|---|---|
| Layout | 9 fixed columns in sequence (Receiving → … → Shipping) |
| Card model | One card per `fp.job`, always in the column matching the active step's `area_kind` |
| Card density | Variant C — full info with mini-timeline |
| State catalog | 13 mutually-exclusive states with precedence rules |
| Operator focus | Plant-wide view, paired-station column + "mine" cards highlighted |
| Backend touch | New `area_kind` Selection, new `card_state` compute, new `/fp/landing/plant_kanban` endpoint |
| Frontend touch | New OWL component tree under `fp_plant_kanban` client action |
| Rollout | Feature flag `x_fc_shopfloor_layout`, parallel deployment, flip default after entech validation |
| Recipe-step scaling | Doesn't matter — 5-step or 50-step recipes both produce one card moving across 9 fixed columns |
The redesign solves the "one job in N columns" problem by re-anchoring grouping at the department level and decoupling the kanban from recipe step count. Every floor scenario in the audit + battle-test catalog (S1-S23) maps to one of the 13 documented states.
Implementation plan to follow.

View File

@@ -0,0 +1,384 @@
# Recipe Cleanup + Receiving Enforcement
**Date:** 2026-05-24
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
**Status:** Approved, awaiting implementation plan.
---
## Problem
User created SO-30057, confirmed it, and the resulting WO-30057 went **straight to the Plating column** on the Shop Floor board — skipping Receiving entirely. The card-state was `no_parts` (correctly: parts hadn't arrived yet) but the column resolved to `plating`, so:
- The receiver, who watches the Receiving column, never sees the job
- The Masking operator sees a card they can't start
- The parts physically can't move forward because nobody knows they need to be received
The auto-complete contract-review logic (`_fp_autocomplete_repeat_order_contract_review`) is **NOT the bug** — it correctly marks Contract Review as done when the part has a complete QA-005 history. The real problems are deeper.
## Root causes
### Root cause 1 — `ENP-ALUM-BASIC` (id 3620) has DUPLICATE SEQUENCES
```
seq 10: Contract Review (id 3853, kind=contract_review)
seq 10: Masking (id 3877, kind=mask) ← TIE
seq 20: Incoming Insp. (id 3854, kind=receiving)
seq 20: Racking (id 3855, kind=racking) ← TIE
seq 40: ENP-Alum Line (id 3859, sub_process, has E-Nickel Plating child)
seq 40: ENP-Alum Line (id 4056, sub_process, empty) ← DUPLICATE
seq 50: De-Masking
seq 60: Oven baking
...
```
When this base recipe is cloned per-part by the configurator (`fp.process.node.copy()`), tied sequences resolve by id. So in the clone:
- Position 10: Contract Review (id 3853 < id 3877 → wins)
- Position 20: Masking (the second one at 10 → promoted to 20)
- Position 30: Incoming Inspection (one of the seq-20 ties → promoted to 30)
- Position 40: Racking (the other seq-20 → promoted to 40)
After Contract Review auto-completes, the live step is **Masking** (kind=mask, area=masking) — which our prior live-step fix routes to the Masking column, not Receiving. The clone for WO-30057 (recipe 4649) followed exactly this pattern.
### Root cause 2 — 24 per-part clone recipes accumulated, all carrying the broken ordering
Each clone is its own `fusion.plating.process.node` row with `node_type='recipe'` and a name like `BASE_NAME — PART_NUMBER Rev X`. There are 24 such clones on entech. Several are referenced by historical jobs (24 cancelled + 7 done jobs use them), but all those jobs are terminal — none are in-flight.
### Root cause 3 — ~10 nodes across base recipes still have `kind=other`
Mostly niche names the existing `fp_resolve_step_kind()` resolver doesn't know:
| Recipe | Node | Currently | Should be |
|---|---|---|---|
| 3645 ENP-STEEL-MP-BASIC | Blasting (If Required) | other | blast |
| 3645 ENP-STEEL-MP-BASIC | Adhesion Test Coupon | other | inspect |
| 3689 ENP-SP | Adhesion Test Coupon | other | inspect |
| 3689 ENP-SP | Adhesion Testing | other | inspect |
| 3689 ENP-SP | Corrosion Testing | other | inspect |
| 3689 ENP-SP | Lab Testing | other | inspect |
| 3945 ENP ALUM BASIC HP SC2 | ENP-Alum Line - HP | other | other (intentional — sub_process) |
| 3782 Chemical Conversion Process | Strip Process - AL | other | wet_process |
| 3782 Chemical Conversion Process | Plug The Threaded Holes | other | mask |
| 3782 Chemical Conversion Process | Chemical Conversion (sub_process) | other | wet_process |
| 3782 Chemical Conversion Process | Trivalent Chromate Conversion (A-14 / A) | other | wet_process |
### Root cause 4 — Recipe duplication has no kind safety net
`fp.process.node.copy()` uses the standard Odoo deep-copy which inherits all fields including `kind_id`. So if the source has bad kinds, the clone inherits bad kinds. Even after we fix the base recipes, future authoring mistakes will propagate.
---
## Approved fix
### Change 1 — Delete all 24 per-part clone recipes
Identify clones by name pattern (em-dash with spaces — the configurator's separator): `name ILIKE '% — %' AND node_type='recipe'`.
FK constraints verified:
- `fp.job.recipe_id` → SET NULL (historical job loses recipe ref, step data persists)
- `fp.job.start_at_node_id` → SET NULL
- `fp.job.step.recipe_node_id` → SET NULL
- `fusion.plating.process.node.parent_id` → CASCADE (child nodes auto-deleted)
- `fp.coating.config.recipe_id` → SET NULL
- `fp.pricing.rule.recipe_id` → SET NULL
- `fp.part.catalog.default_process_id` → SET NULL
- Zero rows in the 2 RESTRICT FKs (`fp.quote.configurator.recipe_id`, `fp.job.node.override.node_id`) point at clones → no blockers
One DELETE statement:
```sql
DELETE FROM fusion_plating_process_node
WHERE node_type = 'recipe'
AND name ILIKE '% — %';
```
CASCADE handles all child operations + steps + sub_processes via the `parent_id` chain. SET NULL handles all the historical job references.
### Change 2 — Fix recipe 3620 ENP-ALUM-BASIC
**a. Resequence operations** so each has a unique sequence and Receiving precedes physical work:
| New sequence | Operation | id | Was at |
|---|---|---|---|
| 10 | Contract Review | 3853 | 10 |
| 20 | Incoming Inspection (Standard) | 3854 | 20 (tied) |
| 30 | Masking | 3877 | 10 (tied) |
| 40 | Racking | 3855 | 20 (tied) |
| 50 | Ready for processing | 3858 | 30 |
| 60 | ENP-Alum Line | 3859 | 40 (tied) |
| 70 | De-Masking | 3861 | 50 |
| 80 | Oven baking | 3864 | 60 |
| 90 | De-racking | 3867 | 70 |
| 100 | Oven bake (Post de-rack) | 4067 | 80 |
| 110 | Post-plate Inspection | 3873 | 90 |
| 120 | Final Inspection | 3876 | 120 |
Per the user decision (mask first, then rack — matches the existing De-Masking step's position between Plating and Bake; de-mask before de-rack would be illogical).
**b. Delete duplicate empty ENP-Alum Line sub_process** (id 4056, no children). The real one (id 3859, contains E-Nickel Plating) survives.
### Change 3 — Extend `fp_resolve_step_kind()`
In [`fusion_plating/__init__.py`](../../../fusion_plating/__init__.py):
**a. Add aliases to `_STARTER_KIND_BY_NAME`:**
```python
# Blasting variants
'blasting': 'blast',
'bead blast': 'blast',
'bead blasting': 'blast',
'media blast': 'blast',
'media blasting': 'blast',
# Inspection variants the resolver didn't know
'adhesion test coupon': 'inspect',
'adhesion testing': 'inspect',
'corrosion testing': 'inspect',
'lab testing': 'inspect',
# Strip + chemical conversion + plugging (mostly wet line)
'strip process': 'wet_process',
'strip process - al': 'wet_process',
'nickel strip - aluminum line': 'wet_process',
'chemical conversion': 'wet_process',
'trivalent chromate conversion': 'wet_process',
'plug the threaded holes': 'mask',
```
**b. Add parenthetical stripping** to `fp_resolve_step_kind()` so `"Incoming Inspection (Standard)"`, `"Blasting (If Required)"`, `"Trivalent Chromate Conversion (A-14 / A)"` etc. resolve through their base name. Strip first, look up second, fall through to the resolver's other rules:
```python
def fp_resolve_step_kind(name):
if not name:
return None
key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key]
# NEW: strip parenthetical suffixes — "Masking (If Required)" →
# "Masking", "Incoming Inspection (Standard)" → "Incoming
# Inspection".
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[bare]
if key.startswith('ready for ') or key.startswith('ready '):
return 'gating'
return None
```
**c. Translate resolver kinds to active `fp.step.kind.code` values.** Several resolver outputs (`cleaning`, `electroclean`, `etch`, `rinse`, `strike`, `dry`, `wbf_test`) map to kinds that are inactive in the dropdown — those should roll up to the active `wet_process` kind. Add a translation in the migration:
```python
RESOLVER_KIND_TO_ACTIVE_KIND = {
# Wet-line kinds → wet_process (active rollup)
'cleaning': 'wet_process',
'electroclean': 'wet_process',
'etch': 'wet_process',
'rinse': 'wet_process',
'strike': 'wet_process',
'dry': 'wet_process',
'wbf_test': 'wet_process',
# 1:1 mappings (kind exists and is active)
'contract_review': 'contract_review',
'mask': 'mask',
'racking': 'racking',
'plate': 'plate',
'bake': 'bake',
'derack': 'derack',
'demask': 'demask',
'inspect': 'inspect',
'final_inspect': 'final_inspect',
'ship': 'ship',
'gating': 'gating',
'blast': 'blast',
}
```
### Change 4 — Backfill `kind=other` nodes via the extended resolver
For every `fusion.plating.process.node` where `kind.code='other'` and `name` is set:
- Call `fp_resolve_step_kind(name)`
- Translate via `RESOLVER_KIND_TO_ACTIVE_KIND`
- If a match: look up `fp.step.kind` by code, write `kind_id`
- If no match: leave as-is (admin can pick later)
Idempotent — only affects nodes currently at `kind=other`.
### Change 5 — Auto-classify hook on `fusion.plating.process.node`
In [`fusion_plating/models/fp_process_node.py`](../../../fusion_plating/models/fp_process_node.py), add a post-write helper that runs after `create()` and `write()`:
```python
def _fp_autoclassify_kind(self):
"""If kind_id is 'other' AND name resolves via fp_resolve_step_kind,
upgrade to the resolved active kind. Idempotent — never overrides
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify=True.
"""
if self.env.context.get('fp_skip_kind_autoclassify'):
return
from odoo.addons.fusion_plating import fp_resolve_step_kind
Kind = self.env['fp.step.kind']
other = Kind.search([('code', '=', 'other')], limit=1)
if not other:
return
for node in self:
if not node.name or node.kind_id != other:
continue
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code:
continue
target = Kind.search([('code', '=', target_code)], limit=1)
if target:
node.with_context(fp_skip_kind_autoclassify=True).write(
{'kind_id': target.id},
)
@api.model_create_multi
def create(self, vals_list):
nodes = super().create(vals_list)
nodes._fp_autoclassify_kind()
return nodes
def write(self, vals):
res = super().write(vals)
# Only re-run autoclassify when name OR kind_id changed
if 'name' in vals or 'kind_id' in vals:
self._fp_autoclassify_kind()
return res
```
Two side-effects this guarantees:
- Recipe duplication via `copy()` → after super().copy() runs, the hook fires on the new node and upgrades the kind if applicable. So future per-part clones get correct kinds even if the source was sloppy.
- Authors typing a step name in the Simple/Tree editor → kind auto-upgrades as soon as the name is saved (provided they hadn't already picked a specific kind).
### Change 6 — `no_parts` cards always land in Receiving column
In [`fusion_plating_shopfloor/controllers/plant_kanban.py:165`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py):
```python
def _resolve_card_area(job):
"""..."""
# NEW — Defect: no_parts cards belong in Receiving regardless of
# active step. The receiver is who acts; the receiver works the
# Receiving column.
if job.card_state == 'no_parts':
return 'receiving'
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
return 'receiving'
```
Belt-and-suspenders so even if a job slips through with a bad area_kind or before kinds are recomputed, "no parts" cards still show where they belong.
### Change 7 — Unified migration
New file: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`. Runs AFTER fusion_plating's data files load (so the resolver extensions are available).
Phases, in order:
1. **Resequence recipe 3620** ops + delete duplicate empty `ENP-Alum Line` sub_process (id 4056).
2. **Backfill `kind=other` nodes** using the extended resolver + active-kind translation. Affects ~10 nodes across recipes 3645/3689/3945/3782.
3. **Delete the 24 clone recipes** — single DELETE on `fusion_plating_process_node` where `name ILIKE '% — %' AND node_type='recipe'`. CASCADE cleans up children; SET NULL handles job refs.
4. **Recompute `fp.job.step.area_kind`** on all rows. After the kind-backfill + clone delete, some steps lose their `recipe_node_id` (NULL); those fall to the catch-all `'plating'`. Acceptable — those are all done/cancelled jobs.
5. **Recompute `fp.job.active_step_id` + `card_state`** on in-flight jobs (currently 0 on entech, but defensive).
All phases idempotent — re-running `-u` is safe.
### Change 8 — Version bumps
| Module | From | To |
|---|---|---|
| `fusion_plating` | `19.0.21.2.0` | `19.0.21.3.0` (resolver + autoclassify hook + new aliases) |
| `fusion_plating_jobs` | `19.0.10.25.0` | `19.0.10.26.0` (migration only) |
| `fusion_plating_shopfloor` | `19.0.33.1.3` | `19.0.33.1.4` (no_parts override) |
---
## Out of scope (explicit)
- **Reordering the other 6 base recipes.** Only recipe 3620 has the documented duplicate-sequence problem. The others have sane sequences and acceptable ordering.
- **Backfilling historical jobs' `area_kind`.** All 31 historical jobs are terminal (cancelled/done). They drop off the live board so their stored area_kind is decorative.
- **Manual kind picks for the ~5 nodes left as `other`** (e.g. `ENP-Alum Line - HP` sub_process). The resolver can't classify them reliably; admin can pick manually if needed.
- **Removing the per-part clone path itself.** The configurator still clones recipes per-part — that's the intended flow. We're just removing existing clones; future SOs will create fresh clones from the fixed base recipes.
- **Battle test for this fix.** The flow (SO confirm → job create → recipe clone → step gen → auto-complete → card-area resolve) is covered by manual smoke. A scripted battle test for this would duplicate significant configurator + auto-complete logic — disproportionate to the fix size.
---
## Test plan
### Manual smoke (after deploy)
1. **Confirm clones gone:**
```sql
SELECT COUNT(*) FROM fusion_plating_process_node
WHERE node_type='recipe' AND name ILIKE '% — %';
-- expected: 0
```
2. **Confirm 3620 reordered:**
```sql
SELECT sequence, name FROM fusion_plating_process_node
WHERE parent_id=3620 ORDER BY sequence;
-- expected: 10=Contract Review, 20=Incoming Inspection, 30=Masking,
-- 40=Racking, 50=Ready for processing, 60=ENP-Alum Line,
-- 70=De-Masking, 80=Oven baking, 90=De-racking,
-- 100=Oven bake (Post de-rack), 110=Post-plate Inspection,
-- 120=Final Inspection
-- NO duplicate sequences. ENP-Alum Line appears ONCE (not twice).
```
3. **Confirm kinds backfilled:**
```sql
SELECT n.name, k.code FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE k.code = 'other'
AND n.node_type IN ('operation','step')
ORDER BY n.name;
-- expected: only ENP-Alum Line - HP (or similar genuinely-other
-- nodes that resolver can't classify) — NOT Adhesion Test
-- Coupon, Corrosion Testing, Lab Testing, Plug The Threaded
-- Holes, etc.
```
4. **End-to-end flow:**
a. Create a new SO with a part whose default recipe is `ENP-ALUM-BASIC`.
b. Confirm the SO.
c. Check: the cloned recipe has Contract Review at sequence 10, Incoming Inspection at sequence 20, Masking at 30, Racking at 40.
d. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' from the no_parts override OR because Incoming Inspection is the active step after Contract Review auto-completes).
e. Mark Incoming Inspection done → card moves to Masking column.
5. **Auto-classify hook:**
a. Open the Simple Editor on any recipe.
b. Drop a new step, type name "Masking" (don't pick a kind).
c. Save the recipe.
d. Refresh the page.
e. Confirm the kind dropdown shows "Masking" (not "Other").
---
## Roll-out
1. Implement Changes 1-8 in one branch.
2. Local dev test — no local container available, so skip; verify directly on entech.
3. Deploy to entech via the standard `pct exec 111` flow.
4. SQL spot-checks per the test plan.
5. Manual smoke (steps 4 + 5).
6. Commit + push.
---
## Files touched
| File | Change |
|---|---|
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME`, add parenthetical-strip in `fp_resolve_step_kind()` |
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + hooks in `create()` and `write()` |
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase migration (Changes 2, 4, 1, recompute, recompute) |
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving override in `_resolve_card_area` |
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
Estimated diff: ~250 lines added, ~20 modified.

View File

@@ -0,0 +1,635 @@
# Shop Floor — Live Step + Kind/Library Cleanup
**Date:** 2026-05-24
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
**Status:** Revised after step-library audit. Awaiting implementation plan.
---
## Problem
All 7 jobs on entech are stuck in the **Receiving** column of the Shop Floor
plant kanban, each tagged with a purple "📋 QA-005 review" chip, even though
every step on every one of them is `done`. The board doesn't reflect shop
state.
Investigation surfaced **four code defects**, a **structural vocabulary
mismatch** between the user-extensible step kind taxonomy and the hardcoded
`area_kind` mapping, **gaps in the kind taxonomy** (no `blast` kind, three
relevant kinds inactive), and **30 step-library templates missing codes,
descriptions, and meaningful icons**.
### Defect 1 — `_compute_card_state` edge case mislabels done jobs
[`fusion_plating_jobs/models/fp_job.py:261-267`](../../../fusion_plating_jobs/models/fp_job.py)
A job whose `active_step_id` is False (all steps done OR no steps at all)
defaults to `'contract_review'` regardless of `job.state`. Done jobs get a
QA-005 chip they don't deserve.
### Defect 2 — `_compute_active_step_id` is too narrow
[`fusion_plating_jobs/models/fp_job.py:386-391`](../../../fusion_plating_jobs/models/fp_job.py)
Only matches `state == 'in_progress'`. Between-step / paused / ready jobs
have `active_step_id = False`. Combined with Defect 3, these teleport to
Receiving.
### Defect 3 — column-resolve fallback is `'receiving'`
[`fusion_plating_shopfloor/controllers/plant_kanban.py:161-170`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
When `active_step_id` is False this fallback fires for every non-running
job. Receiving becomes a parking lot.
### Defect 4 — done jobs aren't filtered off the board
Done + cancelled jobs stay visible forever. The 7 stuck cards on entech are
all `state='done'` jobs that shipped weeks ago.
### Defect 5 (structural) — kind→area_kind vocabulary mismatch
`fp.step.kind` is a user-extensible taxonomy (28 records, 12 active in the
dropdown post 2026-05-24 dedup). `kind_id` is `required=True` on both
`fp.step.template` and `fusion.plating.process.node`, defaulting to
`code='other'`.
`fp.job.step._compute_area_kind` reads `recipe_node.default_kind` (the kind
code) through the hardcoded `_STEP_KIND_TO_AREA` dict in
[`fp_job_step.py:25-73`](../../../fusion_plating_jobs/models/fp_job_step.py).
The two vocabularies overlap on **7 of 28 codes**. Adoption on entech:
| `kind.code` | Nodes | Mapping exists? | Falls to |
|---|---|---|---|
| `other` | 240 | ❌ | `'plating'` |
| `racking` | 122 | ✅ | `'racking'` ✓ |
| `wet_process` | 105 | ❌ | `'plating'` (lucky — wet line IS plating) |
| `bake` | 103 | ✅ | `'baking'` ✓ |
| `mask` | 92 | ❌ (dict has `'masking'`) | `'plating'` (wrong) |
| `inspect` | 52 | ❌ (dict has `'inspection'`) | `'plating'` (wrong) |
| `plate` | 35 | ❌ (dict has `'e_nickel_plate'`) | `'plating'` (lucky) |
| `final_inspect` | 31 | ❌ (dict has `'final_inspection'`) | `'plating'` (wrong) |
| `contract_review` | 17 | ✅ | `'receiving'` ✓ |
| `receiving` | 16 | ✅ | `'receiving'` ✓ |
| `ship` | 3 | ❌ (dict has `'shipping'`) | `'plating'` (wrong) |
The structural fix: make `area_kind` a required field on `fp.step.kind`
itself so each kind self-declares its column.
### Defect 6 (taxonomy) — kinds that should exist but don't / are inactive
| Kind | Currently | Needed because |
|---|---|---|
| `blast` | Does not exist | 11 recipe nodes named "Blasting" can't be classified correctly. There's no kind that maps to the Blasting column. |
| `derack` | Exists but `active=False` | 23+ recipe nodes named "De-racking" / "DeRacking" need their own kind for tablet routing clarity (`area_kind='de_racking'`). |
| `demask` | Exists but `active=False` | 33 recipe nodes named "De-Masking" are misclassified as `mask` → land in Masking column. Per spec §D4 De-Masking folds into De-Racking. |
| `gating` | Exists but `active=False` | 50+ "Ready For X" recipe nodes are unclassified gates. Without `gating` they fall back to `other` → catch-all. |
### Defect 7 (library) — 30 step-library templates missing metadata
Step Library audit (38 active templates):
| Field | Has it | Missing |
|---|---|---|
| `code` | 8 | 30 |
| `description` | 8 | 30 |
| Meaningful icon (not `fa-cog`) | 13 | 25 |
| `material_callout` | 0 | 38 |
| `process_type_id` | 0 | 38 |
The 8 well-formed templates (`RECV_STD`, `ELEC_CLEAN_STD`, `STRIKE_STD`, etc.)
came from the XML data file. The remaining 30 came from
`_seed_step_library_if_empty()` (programmatic seed from ENP-ALUM-BASIC recipe)
without their library-management metadata.
Several library templates are also classified to the wrong kind. Examples:
| Template | Currently `kind` | Should be `kind` |
|---|---|---|
| Blasting | `other` | `blast` (kind we're creating) |
| De-Masking | `mask` | `demask` (per spec §D4) |
| Ready for Plating / Ready for processing | `plate` / `other` | `gating` |
| Pre-Measurements / Check Sulfamate Nickel Area | `other` | `inspect` |
| Nickel Strip / Nickel Strip - Steel Line | `plate` | `wet_process` (it's a strip, not plating) |
### Defect 8 (recipe nodes) — in-the-wild misclassifications
Once kinds are fixed and library is corrected, the EXISTING ~880 recipe
nodes still point at the wrong kind in well-defined patterns:
| Pattern | Affected nodes | Re-point to |
|---|---|---|
| `name = 'Blasting'` AND `kind = other` | 11 | `kind = blast` |
| `name ILIKE 'Ready %'` AND `kind != gating` | ~50+ | `kind = gating` |
| `name ILIKE '%De-Masking%' OR '%DeMasking%'` AND `kind = mask` | 33 | `kind = demask` |
| `name = 'Scheduling'` AND `kind = other` | 5 | `kind = gating` |
| `name ILIKE '%Nickel Strip%'` AND `kind = plate` | ~10 | `kind = wet_process` |
| `name ILIKE '%Pre-Measurement%' OR '%Check Sulfamate%'` AND `kind = other` | ~10 | `kind = inspect` |
These are auto-migratable because the patterns are unambiguous. The harder
calls (e.g. "Post Plate Inspection" — `inspect` or `final_inspect`?) stay
manual.
---
## Approved fix
### Change 1 — `_compute_active_step_id` priority chain
Replace the single-state filter with a priority lookup over `step_ids`
sorted by sequence. First match wins:
```
in_progress > paused > ready > first pending
```
If every step is `done` (or no steps exist), returns False — handled by
Change 2.
**Why this order:**
- `in_progress` is the most informative.
- `paused` means someone was working and stopped; the card belongs at that station so the next operator can pick it up.
- `ready` is the next-up step waiting on an operator.
- The first `pending` after a `done` is the "next gate" — where the card visually waits.
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
### Change 2 — `_compute_card_state` edge case
Replace the buggy "no active step → contract_review" fallback with:
```python
if not job.active_step_id:
if job.state == 'done':
job.card_state = 'done'
elif job._fp_inbound_not_received():
job.card_state = 'no_parts'
else:
job.card_state = 'ready' # no steps yet — recipe not assigned
continue
```
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
### Change 3 — Board state filter
Add `('state', 'in', ('confirmed', 'in_progress'))` to the `fp.job` search
domain in `/fp/landing/plant_kanban`. Done + cancelled jobs disappear from
the board; they remain reachable elsewhere.
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
### Change 4 — Column-resolve fallback (comment only)
`_resolve_card_area`'s `'receiving'` fallback stays but updates inline
comment to explain the new semantics (truly orphaned cards only).
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
### Change 5 — `fp.step.kind.area_kind` field (structural)
Add a required Selection field to `fp.step.kind`. Each kind self-declares
which plant-view column its steps belong in.
```python
area_kind = fields.Selection(
[
('receiving', 'Receiving'),
('masking', 'Masking'),
('blasting', 'Blasting'),
('racking', 'Racking'),
('plating', 'Plating'),
('baking', 'Baking'),
('de_racking', 'De-Racking'),
('inspection', 'Final Inspection'),
('shipping', 'Shipping'),
],
string='Shop Floor Column',
required=True,
index=True,
tracking=True,
help='Determines which column on the Shop Floor plant kanban shows '
'cards whose active step uses this kind.',
)
```
**File:** [`fusion_plating/models/fp_step_kind.py`](../../../fusion_plating/models/fp_step_kind.py)
### Change 6 — `_compute_area_kind` priority chain
Simplify `fp.job.step._compute_area_kind`:
```python
@api.depends(
'work_centre_id.area_kind',
'recipe_node_id.kind_id.area_kind',
)
def _compute_area_kind(self):
for step in self:
# 1. work_centre.area_kind (explicit operator setup)
if step.work_centre_id and step.work_centre_id.area_kind:
step.area_kind = step.work_centre_id.area_kind
continue
# 2. recipe_node.kind_id.area_kind (kind taxonomy is authoritative)
node = step.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
step.area_kind = node.kind_id.area_kind
continue
# 3. Catch-all — data integrity issue if we land here
step.area_kind = 'plating'
```
The legacy `_STEP_KIND_TO_AREA` dict is deleted.
**File:** [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py)
### Change 7 — Step Kind UI surfaces `area_kind`
- **Form view** ([`fp_step_kind_views.xml`](../../../fusion_plating/views/fp_step_kind_views.xml)) — add `area_kind` as a prominent picker next to `code` + `name`, with a help-text inline ("Cards whose active step uses this kind appear in this column on the Shop Floor board").
- **List view** — add `area_kind` as a chip column.
- **Simple Editor kind picker** ([`simple_recipe_editor.xml:506-522`](../../../fusion_plating/static/src/xml/simple_recipe_editor.xml)) — option label becomes "Masking — Masking column" so authors see the routing at pick time. Requires updating `kindOptions` payload in [`simple_recipe_controller.py`](../../../fusion_plating/controllers/simple_recipe_controller.py) to include `area_kind` + a human-readable column label per kind.
### Change 8 — Step Kind taxonomy expansion (Cat A)
XML data file additions / updates in
[`fusion_plating/data/fp_step_kind_data.xml`](../../../fusion_plating/data/fp_step_kind_data.xml):
```xml
<!-- NEW: Blasting kind -->
<record id="step_kind_blast" model="fp.step.kind">
<field name="code">blast</field>
<field name="name">Blasting / Media Blast</field>
<field name="sequence">35</field>
<field name="icon">fa-bullseye</field>
<field name="area_kind">blasting</field>
</record>
<!-- Activate existing kinds + set area_kind. The records already exist
from 19.0.20.6.0 with active=False; here we flip + classify.
noupdate=1 protects user edits, so use a one-shot migration to
do the flip on existing installs (Change 10). -->
```
Migration (Change 10) handles the flip on existing installs since the data
file has `noupdate="1"`:
```python
# Activate kinds that were dropped in 19.0.20.6.0 but are needed
# for the area_kind taxonomy to be complete.
for code, area in (
('derack', 'de_racking'),
('demask', 'de_racking'),
('gating', 'receiving'),
):
cr.execute("""
UPDATE fp_step_kind
SET active = TRUE, area_kind = %s
WHERE code = %s AND active = FALSE
""", (area, code))
```
### Change 9 — Step Template metadata backfill + additions (Cat B)
Migration backfills metadata on the 30 templates seeded without it.
Idempotent — only fills NULL/empty fields, doesn't overwrite human edits.
```python
TEMPLATE_BACKFILL = {
# name : (code, icon, kind_code, description_snippet)
'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'),
'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'),
'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'),
'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'),
'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'),
'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'),
'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'),
'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'),
'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'),
'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'),
'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'),
'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'),
'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'),
'Final Inspection': ('FINAL_INSP_STD','fa-check-circle','final_inspect','Final visual + dimensional QA before packing.'),
'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'),
'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'),
'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'),
'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'),
'Nickel Strip - Steel Line': ('NI_STRIP_SL','fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'),
'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle','inspect', 'Post-plate inspection — thickness sample + visual.'),
'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'),
'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'),
'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step — parts staged ready for the plating line.'),
'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step — parts staged ready for the next operation.'),
'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'),
'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'),
'Soak Clean': ('SOAK_CLEAN_STD','fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'),
'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'),
'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'),
'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'),
}
```
New templates (XML data file additions, `noupdate="1"`):
| Name | Code | Kind | Why add |
|---|---|---|---|
| `Hot Water Porosity Test (A-15)` | `HWP_A15` | `inspect` | 7 recipe nodes use it — should be in the library |
| `Final Inspection / Packaging` | `FINAL_PKG_STD` | `final_inspect` | 3 recipe nodes use it; library has separate inspection + packaging but not the combined one |
**Files:**
- [`fusion_plating/data/fp_step_template_data.xml`](../../../fusion_plating/data/fp_step_template_data.xml) — 2 new template records
- Migration (Change 10) — TEMPLATE_BACKFILL loop, idempotent
### Change 10 — Unified migration
New file: [`fusion_plating/migrations/19.0.21.2.0/pre-migrate.py`](../../../fusion_plating/migrations/19.0.21.2.0/pre-migrate.py)
Pre-migrate runs BEFORE the `area_kind NOT NULL` constraint hits the
schema, so it fills values first.
```python
import logging
_logger = logging.getLogger(__name__)
KIND_TO_AREA = {
'other': 'plating', # catch-all default
'wet_process': 'plating',
'receiving': 'receiving',
'contract_review':'receiving',
'gating': 'receiving',
'racking': 'racking',
'derack': 'de_racking',
'mask': 'masking',
'demask': 'de_racking', # spec §D4
'cleaning': 'plating',
'electroclean': 'plating',
'etch': 'plating',
'rinse': 'plating',
'strike': 'plating',
'plate': 'plating',
'replenishment': 'plating',
'wbf_test': 'plating',
'dry': 'plating',
'bake': 'baking',
'inspect': 'inspection',
'final_inspect': 'inspection',
'hardness_test': 'inspection',
'adhesion_test': 'inspection',
'salt_spray': 'inspection',
'packaging': 'shipping',
'ship': 'shipping',
'blast': 'blasting',
'bead_blast': 'blasting',
'media_blast': 'blasting',
}
def migrate(cr, version):
# Phase 1 — seed area_kind on existing kinds BEFORE NOT NULL hits.
for code, area in KIND_TO_AREA.items():
cr.execute("""
UPDATE fp_step_kind SET area_kind = %s
WHERE code = %s AND (area_kind IS NULL OR area_kind = '')
""", (area, code))
# Anything still NULL: default to 'plating' to clear the constraint.
cr.execute("""
UPDATE fp_step_kind SET area_kind = 'plating'
WHERE area_kind IS NULL OR area_kind = ''
""")
_logger.info('[live-step-fix] kind.area_kind seeded')
# Phase 2 — activate the three inactive kinds we need (Cat A).
for code in ('derack', 'demask', 'gating'):
cr.execute("""
UPDATE fp_step_kind SET active = TRUE
WHERE code = %s AND active = FALSE
""", (code,))
_logger.info('[live-step-fix] derack/demask/gating activated')
```
New file: [`fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py`](../../../fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py)
Post-migrate runs AFTER schema sync, so all fields exist with values.
```python
import logging
_logger = logging.getLogger(__name__)
# Library template metadata backfill — copied from spec Change 9.
TEMPLATE_BACKFILL = { ... } # full dict per Change 9
# Recipe node patterns to repoint (Cat C).
NODE_REPOINTING = [
# (name_filter_sql, current_kind_code, new_kind_code, description)
("name = 'Blasting'", 'other', 'blast', 'Blasting → blast'),
("name ILIKE 'Ready %%'", None, 'gating', 'Ready For X → gating'),
("name ILIKE '%%De-Masking%%' OR name ILIKE '%%DeMasking%%'", 'mask', 'demask', 'De-Masking → demask'),
("name = 'Scheduling'", 'other', 'gating', 'Scheduling → gating'),
("name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip → wet_process'),
("name ILIKE '%%Pre-Measurement%%' OR name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate → inspect'),
]
def migrate(cr, version):
from odoo.api import Environment, SUPERUSER_ID
env = Environment(cr, SUPERUSER_ID, {})
# Phase 1 — template metadata backfill (Cat B). Idempotent.
Tpl = env['fp.step.template']
Kind = env['fp.step.kind']
fixed = 0
for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items():
tpl = Tpl.search([('name', '=', name)], limit=1)
if not tpl:
continue
vals = {}
if not tpl.code:
vals['code'] = code
if not tpl.description or tpl.description in ('', '<p><br></p>'):
vals['description'] = f'<p>{desc}</p>'
if tpl.icon == 'fa-cog':
vals['icon'] = icon
kind = Kind.search([('code', '=', kind_code)], limit=1)
if kind and tpl.kind_id.code != kind_code:
vals['kind_id'] = kind.id
if vals:
tpl.write(vals)
fixed += 1
_logger.info('[live-step-fix] template backfill: %s templates updated', fixed)
# Phase 2 — recipe node repointing (Cat C). Pattern-driven SQL.
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
params = []
sql = f"""
UPDATE fusion_plating_process_node n
SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1)
FROM fp_step_kind k
WHERE n.kind_id = k.id
AND ({filter_sql})
"""
params.append(new_code)
if cur_code is not None:
sql += " AND k.code = %s"
params.append(cur_code)
sql += " AND k.code != %s"
params.append(new_code)
cr.execute(sql, params)
_logger.info('[live-step-fix] repointed %s nodes: %s',
cr.rowcount, desc)
# Phase 3 — recompute area_kind on all fp.job.step rows.
steps = env['fp.job.step'].search([])
steps._compute_area_kind()
steps.flush_recordset(['area_kind'])
_logger.info('[live-step-fix] recomputed area_kind on %s steps', len(steps))
# Phase 4 — recompute active_step_id + card_state on in-flight jobs.
jobs = env['fp.job'].search([('state', 'in', ('confirmed', 'in_progress'))])
jobs._compute_active_step_id()
jobs._compute_card_state()
jobs.flush_recordset(['active_step_id', 'card_state'])
_logger.info('[live-step-fix] recomputed jobs: %s', len(jobs))
```
Idempotent across the board: phase 1 only fills NULLs / fa-cog defaults;
phase 2 includes `AND k.code != %s` so re-running won't re-do already
correct rows; phases 3-4 are pure recomputes.
### Change 11 — Version bumps
| Module | From | To |
|---|---|---|
| `fusion_plating` | `19.0.21.1.3` | `19.0.21.2.0` (schema change on fp.step.kind + data file additions) |
| `fusion_plating_jobs` | `19.0.10.23.0` | `19.0.10.24.0` (compute change + migration) |
| `fusion_plating_shopfloor` | `19.0.33.1.2` | `19.0.33.1.3` (controller filter + comment) |
---
## What this approach replaces
| Dropped from the original (pre-restructure) spec | Why |
|---|---|
| `_RESOLVER_KIND_TO_AREA` translation dict | Kind self-declares its column — no translation needed |
| `_resolve_area_kind_from_name` helper | Kind taxonomy is authoritative; name resolution is unnecessary |
| `_STARTER_KIND_BY_NAME` extensions for column routing | The starter resolver is for `default_kind` seeding (Sub 12a library), not column routing — stays as-is for that purpose |
| Parenthetical stripping regex | Not needed when we read the kind directly |
| Backfill of `default_kind` on existing recipe nodes via name resolver | Recipe nodes already have `kind_id` populated by 19.0.20.6.0 pre-migrate |
---
## Test plan
### Manual smoke (on entech after deploy)
1. Open Shop Floor tablet/desktop — confirm the 7 done jobs are GONE from the board.
2. Plating → Configuration → Recipes & Steps → **Step Kind catalog** — confirm:
- `blast` exists, active, area_kind=`blasting`
- `derack`, `demask`, `gating` are now `active=True`, area_kinds correct
- Every kind has area_kind set
3. Plating → Configuration → Recipes & Steps → **Step Library** — confirm:
- All 38 templates now have a code, description, meaningful icon
- `Hot Water Porosity Test (A-15)` and `Final Inspection / Packaging` are listed
- "Blasting" is `kind=blast`, "De-Masking" is `kind=demask`, "Ready for ..." are `kind=gating`
4. Open the Simple Recipe Editor; click "+ Add new kind" — confirm area_kind picker is visible/required in the inline-create flow.
5. Create a fresh test job from any recipe (e.g. ENP-ALUM-BASIC):
a. Confirm it lands in Receiving column with `card_state='ready'`.
b. Walk through all steps — confirm column transitions follow area_kind sequence.
c. Mark job done → confirm card drops off the board.
6. Verify a step with `state='paused'` keeps the card at its column.
### Spot-check existing data
```sql
-- Every node should have a kind with area_kind set.
SELECT n.id, n.name, k.code, k.area_kind
FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE k.area_kind IS NULL OR k.area_kind = '';
-- expected: 0 rows
-- Blasting nodes should now use blast kind.
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE n.name = 'Blasting' GROUP BY k.code;
-- expected: all rows have k.code = 'blast'
-- Ready For X gating nodes.
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE n.name ILIKE 'Ready %' GROUP BY k.code;
-- expected: all rows have k.code = 'gating'
-- De-Masking nodes use demask.
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE n.name ILIKE '%De-Masking%' OR n.name ILIKE '%DeMasking%'
GROUP BY k.code;
-- expected: all rows have k.code = 'demask'
-- Template code coverage.
SELECT COUNT(*) FROM fp_step_template
WHERE active = TRUE AND (code IS NULL OR code = '');
-- expected: 0
```
### Automated battle test
New script: `fusion_plating_quality/scripts/bt_s24_between_steps.py` covering
the live-step priority chain end-to-end (see prior version of the spec for
full pseudocode — unchanged).
### Existing tests
Existing tests in `fusion_plating_shopfloor/tests/` and
`fusion_plating_jobs/tests/` may need updates for:
- The new `state` filter in `/fp/landing/plant_kanban`.
- The new `active_step_id` priority chain.
Re-run all `bt_s*.py` scripts to confirm no regressions in S1-S23.
---
## Roll-out
1. Implement Changes 1-11 in a single branch.
2. Local dev test (`docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init`).
3. Deploy to entech using the standard `pct exec 111` flow. Pre-migrate seeds run automatically.
4. Verify on entech with manual smoke + SQL spot-checks.
5. Commit + push to GitHub.
---
## Non-goals (explicit)
- **Re-assigning historical steps to `work_centre_id`.** The 85+ steps with NULL `work_centre_id` stay that way. The kind→area_kind lookup gives them correct `area_kind` without needing a work_centre.
- **Recipe authoring UX changes** beyond the kind picker hint. Required-field enforcement on `kind_id` already exists.
- **Removing the "Other" kind.** Stays as a catch-all default mapped to `'plating'`.
- **Card_state precedence rework.** Rules 1-13 stay; only the edge-case fallback changes.
- **Mini-timeline rendering.** Separate compute (`mini_timeline_json`), out of scope.
- **Hidden-but-recent done jobs.** No "recent shipments" filter.
- **Subjective node re-classification.** "Post Plate Inspection" stays whatever the recipe author picked (`inspect` vs `final_inspect`). Only the unambiguous patterns in Change 10 phase 2 are auto-migrated.
- **process_type_id / material_callout backfill on templates.** Out of scope for this spec — those need recipe-author input per template.
---
## Files touched (summary)
| File | Change |
|---|---|
| `fusion_plating/models/fp_step_kind.py` | New `area_kind` Selection field (Change 5) |
| `fusion_plating/views/fp_step_kind_views.xml` | Add area_kind to form + list (Change 7) |
| `fusion_plating/controllers/simple_recipe_controller.py` | Include area_kind + label in kindOptions (Change 7) |
| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | Kind picker shows "→ Column" suffix (Change 7) |
| `fusion_plating/data/fp_step_kind_data.xml` | New `step_kind_blast` record (Change 8) |
| `fusion_plating/data/fp_step_template_data.xml` | New `Hot Water Porosity Test` + `Final Inspection / Packaging` templates (Change 9) |
| `fusion_plating/migrations/19.0.21.2.0/pre-migrate.py` | NEW — seed area_kind, activate kinds (Change 10 phase 1-2) |
| `fusion_plating/__manifest__.py` | Version bump (Change 11) |
| `fusion_plating_jobs/models/fp_job.py` | Rewrite `_compute_active_step_id` (Change 1) + `_compute_card_state` edge case (Change 2) |
| `fusion_plating_jobs/models/fp_job_step.py` | Simplify `_compute_area_kind` (Change 6); drop `_STEP_KIND_TO_AREA` dict |
| `fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py` | NEW — template backfill + node repointing + recomputes (Change 10 phase 1-4) |
| `fusion_plating_jobs/__manifest__.py` | Version bump (Change 11) |
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | Add state filter (Change 3) + comment (Change 4) |
| `fusion_plating_shopfloor/__manifest__.py` | Version bump (Change 11) |
| `fusion_plating_quality/scripts/bt_s24_between_steps.py` | NEW — battle test |
Estimated diff: ~400 lines added (most in the migration data tables), ~30 modified, ~50 deleted (the `_STEP_KIND_TO_AREA` dict goes away).

View File

@@ -0,0 +1,527 @@
# Tablet Lock Screen Redesign
**Date:** 2026-05-24
**Status:** Design — approved through brainstorming, awaiting plan
**Affects:** `fusion_plating_shopfloor` (FpTabletLock OWL component + tablet_controller)
**Scope:** Visual + interaction redesign only. PIN gate, unlock RPC, lockout timer, idle warning all unchanged.
---
## 1. Problem
The current FpTabletLock tile screen looks like a placeholder. Two operators per row stretch their tiles across 900px max-width; the screen is mostly empty whitespace; there's no branding; the "Tap your name to unlock" prompt is the only header; no animations; no clock/date. Functionally correct but feels unfinished on a wall-mounted shop-floor tablet.
User feedback after live testing (2026-05-24):
> "i want company logo and other nice customization, add some animation, reduce the card width so its just enough, there may be many employees, i do not want a lot of scrolling but not cramped at the same time"
Target: tablet that looks like a deliberately-designed shop terminal, fits ~10-15 operators per screen without scrolling, brands the device with the company logo, and has subtle motion that signals "alive."
---
## 2. Goals & non-goals
### Goals
1. **Brand the screen** — pull the company logo from `res.company.logo`, surface the company name + tagline.
2. **Tighter tile grid** — 3 columns max-width 480px, ~140px tile width. Fits 6 tiles per visible row; small shops (10-15 ops) show everything without scroll.
3. **Real-time clock + date** — operators glance at the lock screen for the time; big tabular-nums clock front-and-center.
4. **Subtle motion** — staggered entrance, hover lift, clocked-in pulse. Doesn't distract; signals freshness.
5. **Dark + light mode parity** — single SCSS source, branches at compile time via `$o-webclient-color-scheme`. No JS-side theme code.
6. **Accessibility**`prefers-reduced-motion` respected, touch targets ≥ 44px, contrast WCAG AA in both modes.
### Non-goals
- **Replacing the PIN gate.** The 4-digit PIN flow (FpPinPad component, hash + lockout, /fp/tablet/unlock endpoint) stays identical.
- **Multi-tenant theming.** Each company sees its own logo via `res.company.logo`; we don't build a theme editor for accent colors. The amber accent is a hardcoded brand token in this design.
- **Search box on the lock screen.** For ~10-15 operators, scanning the grid is faster than typing. Search returns as a Phase 2 enhancement if a customer scales to 25+ ops.
- **Custom tile sort.** Existing rule stays: clocked-in operators first, then alphabetical.
- **Welcome animations / video / mascot.** Subtle motion only.
---
## 3. Decisions locked during brainstorming
| # | Decision |
|---|---|
| D1 | **Hybrid A+B vibe** — Industrial Bold structure (dark gradient bg, bold tabular clock, amber accent) wearing Premium Glassmorphism finish (frosted-glass tiles with backdrop-filter, smooth cubic-bezier hover). |
| D2 | **Company logo** sourced from `res.company.logo` (Odoo's standard company logo binary field) via `/web/image/res.company/<id>/logo`. Letter-mark fallback when no logo is uploaded — built from `res.company.name` initials. |
| D3 | **Company name + tagline** below the logo. Name = `res.company.name`. Tagline = `res.company.report_header` (existing field, also drives invoice letterheads — natural reuse) with fallback "Shop Floor Terminal" if empty. |
| D4 | **3-column tile grid**, max-width 480px on the grid container. Tile ~140px wide. Avatar 52px circular with status pulse-dot overlay. |
| D5 | **Dark + light mode parity.** Same OWL component + same XML; SCSS branches at compile time on `$o-webclient-color-scheme`. No runtime theme code. |
| D6 | **Animation catalogue** (full list in §6) — entrance stagger, hover lift, click scale, pulse on clocked-in dot, real-time clock update. `prefers-reduced-motion` disables all of these. |
| D7 | **Sort order unchanged** — clocked-in operators first, then alphabetical by name. |
| D8 | **No search box** for MVP — scoped for the ~10-15-operator small-shop case. |
---
## 4. Layout
The screen is a full-viewport flex column, centered, with this vertical sequence:
```
┌──────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ │ ← logo frame (84×84)
│ │ LOGO │ (rounded 20, glass) │ glassmorphic
│ └─────────┘ │
│ Company Name │ ← logo-text (19px, 700)
│ PLATING · ESTD 1985 │ ← logo-sub (11px upper)
│ │
│ 21:09 │ ← clock (40px, 800, tabular)
│ SATURDAY · MAY 23 │ ← clock-date (12px upper)
│ │
│ [ 🔒 TAP YOUR NAME ] │ ← prompt pill
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ GS ● │ │ JM │ │ CV ● │ │
│ │Garry │ │Johnny│ │Carlos│ │ ← 3-column tile grid,
│ │CIN │ │PIN │ │CIN │ │ max-width 480px
│ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ LB ● │ │ RB ● │ │ KP ● │ │
│ │ Lisa │ │ Ravi │ │ Kris │ │
│ │ CIN │ │ CIN │ │ CIN │ │
│ └──────┘ └──────┘ └──────┘ │
│ │
└──────────────────────────────────────────────────┘
```
Spacing between sections: 22px gap. Logo block top margin: 28px. Outer padding: 28px 20px.
### 4.1 Logo block
```html
<div class="o_fp_lock_logo_block">
<div class="o_fp_lock_logo_frame">
<img t-att-src="logoUrl" t-att-alt="companyName" t-if="logoUrl"/>
<div t-else="" class="o_fp_lock_logo_placeholder" t-esc="companyInitials"/>
</div>
<div class="o_fp_lock_logo_text" t-esc="companyName"/>
<div class="o_fp_lock_logo_sub" t-esc="companyTagline"/>
</div>
```
- `logoUrl`: `/web/image/res.company/<id>/logo` — Odoo serves the binary directly. Always 200 if the field is populated (even 1×1 transparent on empty record), so probe the field server-side before emitting the URL.
- `companyInitials`: first 1-2 letters of `res.company.name` (e.g. "EN" for "EN Technologies", "ABC" capped to 2 chars). Computed server-side, sent in the tiles-endpoint payload.
- `companyTagline`: from `res.company.report_header` field; defaults to "Shop Floor Terminal" when empty.
The logo frame is a 84×84 rounded-20 glassmorphic container — same frosted treatment as the tiles. Looks great whether the logo is a sharp PNG, transparent SVG, or the letter-mark fallback.
### 4.2 Clock block
```html
<div class="o_fp_lock_clock_block">
<div class="o_fp_lock_clock" t-esc="state.clockText"/>
<div class="o_fp_lock_clock_date" t-esc="state.dateText"/>
</div>
```
- `state.clockText`: `HH:MM` (24h, configurable via `intl.DateTimeFormat`). Updates every minute via `setInterval` in `tablet_lock.js`.
- `state.dateText`: `WEEKDAY · MMM D` uppercase (e.g. "SATURDAY · MAY 23"). Recomputed on date change.
- Tabular numbers so digits don't jitter when changing.
- Initial render uses `new Date()` synchronously so there's no flash of empty content.
### 4.3 Prompt
A small pill, not a header:
```html
<div class="o_fp_lock_prompt">🔒 Tap your name</div>
```
Amber-tinted background (matches brand accent), uppercase with 0.18em letter-spacing. Sits between the clock and the tile grid as a visual anchor.
### 4.4 Tile grid
```html
<div class="o_fp_lock_tiles">
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
<button class="o_fp_lock_tile"
t-att-style="'animation-delay: ' + tile.animDelay + 'ms'"
t-on-click="() => this.onTileClick(tile.user_id)">
<div t-att-class="tile.is_clocked_in ? 'o_fp_lock_avatar is-clocked' : 'o_fp_lock_avatar'"
t-att-style="'background: ' + tile.avatar_gradient">
<img t-if="tile.has_photo" t-att-src="tile.avatar_url" t-att-alt="tile.name"/>
<span t-else="" t-esc="tile.initials"/>
</div>
<div class="o_fp_lock_name" t-esc="tile.name"/>
<div t-if="tile.is_clocked_in" class="o_fp_lock_status status-clocked">Clocked in</div>
<div t-elif="!tile.has_pin" class="o_fp_lock_status status-pin">PIN required</div>
</button>
</t>
</div>
```
- Grid: `grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 480px`.
- Animation delay computed JS-side per tile (50ms × index, capped at 300ms) so the stagger ripples without dragging.
- Avatar gradient (per-tile color): server-computed as `user.id % len(_AVATAR_GRADIENTS)` (8 colors). Deterministic — same operator gets the same color across sessions, so operators learn their own tile color. See §7.3 for the gradient list.
- `has_photo` is true when `res.users.image_128` is non-empty. Falls back to initials when empty.
---
## 5. Color system
All colors live in `_tablet_lock_tokens.scss` (new file, loaded before `tablet_lock.scss`). Same pattern as the plant-view tokens shipped earlier.
### Light-mode defaults
| Token | Hex | Purpose |
|---|---|---|
| `$_lock-bg-top` | `#fafafa` | Gradient top |
| `$_lock-bg-bottom` | `#f0f0f3` | Gradient bottom |
| `$_lock-accent` | `rgba(240,165,0,0.12)` | Top-radial ambient glow |
| `$_lock-accent-2` | `rgba(99,102,241,0.06)` | Bottom-radial ambient glow |
| `$_lock-text` | `#1d1f1e` | Primary text |
| `$_lock-muted` | `#71717a` | Secondary text |
| `$_lock-prompt` | `#b45309` | Prompt text |
| `$_lock-prompt-bg` | `rgba(240,165,0,0.10)` | Prompt pill bg |
| `$_lock-prompt-border` | `rgba(240,165,0,0.25)` | Prompt pill border |
| `$_lock-tile-bg` | `rgba(255,255,255,0.7)` | Tile bg (frosted) |
| `$_lock-tile-border` | `rgba(0,0,0,0.05)` | Tile border |
| `$_lock-tile-hover-bg` | `rgba(255,255,255,0.95)` | Tile hover bg |
| `$_lock-tile-hover-border` | `rgba(240,165,0,0.5)` | Tile hover border |
| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.18)` | Tile hover shadow |
| `$_lock-frame-bg` | `rgba(255,255,255,0.85)` | Logo frame bg |
| `$_lock-status-clocked` | `#16a34a` | Clocked-in green |
| `$_lock-status-pin` | `#d97706` | PIN required amber |
| `$_lock-pulse-dot-border` | `#fff` | Pulse-dot ring |
### Dark-mode overrides
| Token | Hex |
|---|---|
| `$_lock-bg-top` | `#1a1d21` (gradient base) |
| `$_lock-bg-bottom` | `#2d3138` |
| `$_lock-accent` | `rgba(240,165,0,0.08)` |
| `$_lock-accent-2` | `rgba(99,102,241,0.06)` |
| `$_lock-text` | `#f5f5f7` |
| `$_lock-muted` | `#adb5bd` |
| `$_lock-prompt` | `#f0a500` |
| `$_lock-prompt-bg` | `rgba(240,165,0,0.08)` |
| `$_lock-prompt-border` | `rgba(240,165,0,0.20)` |
| `$_lock-tile-bg` | `rgba(255,255,255,0.06)` |
| `$_lock-tile-border` | `rgba(255,255,255,0.08)` |
| `$_lock-tile-hover-bg` | `rgba(240,165,0,0.10)` |
| `$_lock-tile-hover-border` | `rgba(240,165,0,0.4)` |
| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.15), 0 0 0 1px rgba(240,165,0,0.2)` |
| `$_lock-frame-bg` | `rgba(255,255,255,0.08)` |
| `$_lock-status-clocked` | `#34c759` (brighter — needs to pop on dark) |
| `$_lock-status-pin` | `#ff9f0a` |
| `$_lock-pulse-dot-border` | `#2d3138` (so the dot reads as overlapping the dark tile, not floating) |
The full-screen background is a stack of two radial gradients (the ambient accent glows) over a linear gradient (the base), per `lock-final.html` from brainstorm:
```scss
background:
radial-gradient(ellipse at top, $_lock-accent, transparent 50%),
radial-gradient(ellipse at bottom, $_lock-accent-2, transparent 50%),
linear-gradient(135deg, $_lock-bg-top 0%, $_lock-bg-bottom 100%);
```
---
## 6. Animation catalogue
All animations use `cubic-bezier(0.4, 0, 0.2, 1)` for consistency (the "standard easing" curve). Every animation is gated by `@media (prefers-reduced-motion: no-preference)` — operators who set reduced motion in OS preferences see the same screen with no movement.
| # | Name | What it does | Duration | Trigger |
|---|---|---|---|---|
| 1 | `lockLogoEnter` | Logo block fades down + slides in | 500ms | onMount |
| 2 | `lockClockEnter` | Clock + prompt fade up | 500ms (100ms delay) | onMount |
| 3 | `lockTileEnter` | Each tile fades + slides up + scales from 0.96 | 400ms (50ms staggered per index, max 6) | onMount |
| 4 | `lockTileHover` | Lift translateY(-3px) + colored shadow + border glow | 250ms | hover/focus |
| 5 | `lockTilePress` | Quick scale(0.97) | 50ms | active/click |
| 6 | `lockPulseDot` | Green clocked-in dot pulses (ring expands + fades) | 2s loop | clocked-in state present |
| 7 | `lockClockTick` | (no animation — just text content update each minute) | — | `setInterval(60000)` |
### Reduced-motion override
```scss
@media (prefers-reduced-motion: reduce) {
.o_fp_lock_logo_block,
.o_fp_lock_clock_block,
.o_fp_lock_prompt,
.o_fp_lock_tile,
.o_fp_lock_avatar.is-clocked::after {
animation: none !important;
transition: none !important;
}
}
```
### Stagger cap
For very large operator counts the per-tile delay caps at 300ms (6 tiles × 50ms) so the screen doesn't take 3 seconds to settle. Compute `animDelay = Math.min(index * 50, 300)` JS-side.
---
## 7. Backend changes
### 7.1 Extend `/fp/tablet/tiles` payload
Currently returns:
```json
{"ok": true, "tiles": [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}
```
After redesign:
```json
{
"ok": true,
"company": {
"id": 1,
"name": "EN Technologies",
"tagline": "Plating & Finishing",
"logo_url": "/web/image/res.company/1/logo",
"has_logo": true,
"initials": "EN"
},
"tiles": [
{
"user_id": 5,
"name": "Garry Singh",
"initials": "GS",
"avatar_url": "/web/image/res.users/5/avatar_128",
"has_photo": true,
"is_clocked_in": true,
"has_pin": true,
"avatar_gradient": "linear-gradient(135deg, #ef4444, #dc2626)"
},
...
]
}
```
New fields per tile:
- `initials`: server-computed from `res.users.name` (first letter of first + last word, capped 2 chars).
- `has_photo`: true when `res.users.image_128` is non-empty (avoids the 1×1 default-image flash).
- `avatar_gradient`: deterministic from hash of user.id. Same gradient across sessions so operators recognize "their" tile color.
The company block is one query: `env.company.id`. Read `name`, `report_header`, check `logo` non-empty.
### 7.2 `_lock_company_payload` helper
A small module-level helper in `tablet_controller.py`:
```python
def _lock_company_payload(env):
"""Returns the company info block for the lock screen."""
co = env.company
return {
'id': co.id,
'name': co.name or '',
'tagline': co.report_header or _('Shop Floor Terminal'),
'logo_url': f'/web/image/res.company/{co.id}/logo',
'has_logo': bool(co.logo),
'initials': _initials_from(co.name),
}
def _initials_from(name):
"""First letter of first + last word, capped at 2 chars uppercase."""
if not name:
return '?'
words = name.strip().split()
if len(words) == 1:
return words[0][:2].upper()
return (words[0][0] + words[-1][0]).upper()
```
### 7.3 `_avatar_gradient_for` helper
```python
_AVATAR_GRADIENTS = [
'linear-gradient(135deg, #ef4444, #dc2626)', # red
'linear-gradient(135deg, #f59e0b, #d97706)', # amber
'linear-gradient(135deg, #10b981, #059669)', # emerald
'linear-gradient(135deg, #3b82f6, #2563eb)', # blue
'linear-gradient(135deg, #8b5cf6, #7c3aed)', # violet
'linear-gradient(135deg, #ec4899, #db2777)', # pink
'linear-gradient(135deg, #14b8a6, #0d9488)', # teal
'linear-gradient(135deg, #f97316, #ea580c)', # orange
]
def _avatar_gradient_for(user_id):
return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)]
```
8 colors, modulo user_id — same operator gets the same color forever. Sufficient variety for a small shop (10-15 ops have <2 collisions on average).
---
## 8. Frontend changes
### 8.1 Files modified
| File | Change |
|---|---|
| `static/src/scss/_tablet_lock_tokens.scss` | **new** — design tokens (loads first) |
| `static/src/scss/tablet_lock.scss` | full rewrite — gradient bg, logo block, clock block, prompt, tile grid, animations, dark/light branches |
| `static/src/xml/tablet_lock.xml` | wrap existing tile loop with new logo + clock + prompt blocks; add fallback structures |
| `static/src/js/tablet_lock.js` | add `state.clockText` + `state.dateText` + `_tickClock` setInterval; add `state.company`; consume new payload fields |
### 8.2 OWL component reactivity for the clock
The clock updates every 60 seconds:
```javascript
setup() {
// ... existing setup ...
this.state = useState({
// ... existing state ...
clockText: this._formatTime(new Date()),
dateText: this._formatDate(new Date()),
company: null,
});
onMounted(() => {
// ... existing onMounted ...
this._clockInterval = setInterval(() => {
const now = new Date();
this.state.clockText = this._formatTime(now);
this.state.dateText = this._formatDate(now);
}, 60000);
});
onWillUnmount(() => {
// ... existing cleanup ...
if (this._clockInterval) clearInterval(this._clockInterval);
});
}
_formatTime(d) {
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
_formatDate(d) {
return d.toLocaleDateString(undefined, {
weekday: 'long', month: 'short', day: 'numeric'
}).toUpperCase().replace(',', ' ·');
}
```
**Per project rule 20:** all the date/number formatting happens in JS (`_formatTime`, `_formatDate`). The template only renders `state.clockText` / `state.dateText` via `t-esc`. No `String()` / `Number()` / `padStart` calls inside the XML.
### 8.3 Stagger delay computed JS-side
In `_loadTiles`, after fetching, decorate each tile with its `animDelay`:
```javascript
async _loadTiles() {
this.state.loadingTiles = true;
try {
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
if (res && res.ok) {
this.state.company = res.company || null;
this.state.tiles = res.tiles.map((tile, idx) => ({
...tile,
animDelay: Math.min(idx * 50, 300), // cap at 300ms
}));
}
} catch (err) {
// Existing quiet fail
} finally {
this.state.loadingTiles = false;
}
}
```
### 8.4 Manifest registration
Adding two SCSS files. Per project rule 8 (SCSS @import forbidden), tokens must register BEFORE the consumer:
```python
# In fusion_plating_shopfloor/__manifest__.py, the lock screen block:
'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss', # NEW — load first
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss', # existing — rewritten
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml', # existing — extended
'fusion_plating_shopfloor/static/src/js/tablet_lock.js', # existing — extended
```
The tokens file lives in `scss/` (not `scss/components/`) because it's session-level — one tokens file for the whole lock-screen experience.
---
## 9. Accessibility
- **Touch targets**: avatar 52px + 14px padding = 80px tile content; tile itself extends to grid cell width ~140px × 110px tall. Both axes well above the 44×44 WCAG minimum.
- **Focus rings**: visible 2px solid amber outline on `:focus-visible`. Distinguishes keyboard navigation from mouse hover.
- **Contrast**:
- Dark mode: white text on `#1a1d21` background = 16.7:1 (AAA).
- Light mode: `#1d1f1e` text on `#fafafa` background = 17.8:1 (AAA).
- Amber prompt text on its tinted bg: 5.2:1 (AA passes).
- **Reduced motion**: full media-query gate documented in §6.
- **Alt text**: logo `<img alt="Company Name">` so screen readers announce the brand on focus.
- **Keyboard navigation**: tab order = logo (skip) → tiles in DOM order → first tile receives initial focus on mount.
---
## 10. Testing strategy
### 10.1 Unit / integration
- `test_tablet_tiles_endpoint_includes_company` — call `/fp/tablet/tiles`, assert response has `company` block with required keys.
- `test_initials_from_helper` — edge cases: empty name, single-word name, multi-word name with hyphens.
- `test_avatar_gradient_deterministic` — same user.id returns same gradient across calls.
### 10.2 Visual snapshot tests
Per state, a Playwright snapshot of the lock screen at `1366×768` (typical tablet) in both light and dark mode. Snapshots checked in; PR diff catches accidental CSS regressions.
### 10.3 Persona walks
- **Cold start** — operator approaches tablet with no recent session. Clock displays current time; tiles fade in; clicking own tile opens PIN pad immediately (no visible loading state).
- **Mid-shift unlock** — operator returns after auto-lock. Same flow; their tile shows the pulsing clocked-in dot.
- **No logo configured** — companies that haven't set `res.company.logo`. Letter-mark renders cleanly; layout unchanged.
- **Reduced motion** — toggle the OS preference; verify all animations disabled, layout still works.
---
## 11. Migration & rollout
No database migration needed — this is a presentation-layer change reusing existing fields (`res.company.logo`, `res.company.report_header`, `res.users.image_128`).
### Rollout sequence
1. Add tokens SCSS + extend tablet_controller payload — backend deploy.
2. Rewrite tablet_lock.scss + extend XML + extend JS — frontend deploy + asset cache bust.
3. Verify on entech: open the tablet lock URL on a real iPad and a desktop browser.
4. Iterate on visual details (logo padding, gradient intensity, accent color) based on shop-floor feedback.
No feature flag — the redesign is a strict visual improvement, no behavioral changes. Reverting is `git revert <commit>` if needed.
---
## 12. Open questions (deferred)
| # | Question | Resolution |
|---|---|---|
| Q1 | Search box for 25+ operator shops? | **Phase 2.** MVP scoped to ~10-15 ops. Re-evaluate when a customer scales. |
| Q2 | Custom accent color per company? | **Phase 2.** Amber is hardcoded in tokens for MVP. Could be a `res.company.x_fc_shopfloor_accent` field later. |
| Q3 | Weather / news widget on lock screen? | **No.** Out of scope; clutters the screen. Operators don't need it. |
| Q4 | Multi-language toggle visible on lock screen? | **No for MVP.** Existing user.lang flow handles this server-side; lock screen renders in the user's language once they're identified post-PIN. |
| Q5 | Operator photo upload UX? | **Existing flow stays** — managers upload via Preferences → My Profile. Lock screen consumes whatever's there. |
| Q6 | Animation when transitioning tile → PIN pad? | **Phase 2 polish.** Currently the existing FpPinPad just appears; could add a crossfade. Subjective; ship clean first. |
---
## 13. Summary
| Question | Answer |
|---|---|
| Layout | Vertical centered flex column: logo (84px) → clock (40px) → prompt pill → 3-column tile grid (max 480px) |
| Card model | One tile per `res.users` with tablet PIN configured (existing rule); deterministic per-user color gradient |
| Card density | 3 columns, ~140px tiles — fits ~9-12 visible without scroll on a 1366×768 tablet |
| Animation | 7 named animations (entrance stagger, hover lift, click press, status pulse) all bezier-eased, all gated by `prefers-reduced-motion` |
| Dark / Light mode | Single SCSS source with compile-time `$o-webclient-color-scheme` branch — same component, two bundles, no JS theme code |
| Backend touch | Extend `/fp/tablet/tiles` payload with `company` block + per-tile `initials`/`avatar_gradient`/`has_photo`. Two small helper functions. |
| Frontend touch | New `_tablet_lock_tokens.scss`. Full rewrite of `tablet_lock.scss`. Extend XML + JS for clock + company block. |
| Rollout | No DB migration. Plain code deploy + asset cache bust. No feature flag. |
The redesign solves the "looks like a placeholder" feel by branding the screen with the company logo, adding a real-time clock, tightening the tile grid for the small-shop case, and layering glassmorphic finishes + cubic-bezier animations on a hybrid Industrial Bold + Premium structure. Dark and light modes share one source.
Implementation plan to follow.

View File

@@ -0,0 +1,484 @@
# Tablet PIN Session Redesign — Design Document
**Date:** 2026-05-24
**Status:** Approved for implementation
**Owner module:** `fusion_plating_shopfloor` (with minor changes in `fusion_plating` for ACL data)
**Brainstorm:** session with @gsinghpal, 2026-05-24
**Linked plan:** TBD (writing-plans skill, next step)
**Related:** `docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md` (Phase 1 permissions overhaul which surfaced this gap)
---
## Problem Statement
The current tablet PIN system on entech is **theatre**. It looks like a security/audit gate but doesn't actually enforce attribution:
- The shop-floor tablet PC logs in ONCE as a persistent "shopfloor service" Odoo user. That session never changes.
- When a tech taps their tile and enters the PIN, the OWL frontend stores a `current_tech_id` in an in-memory service (`fp_shopfloor_tech_store`). The underlying Odoo session cookie does NOT change.
- ~15 of the ~30 shop-floor write endpoints accept a `tablet_tech_id` kwarg and use `env_for_tablet_tech` to attribute the write to the tech via `env(user=tech_id)`. The OTHER ~15 endpoints write under the session user (the shopfloor service user). The audit trail is incomplete.
- There is no idle timeout, no re-lock on walk-away. Once unlocked, the `tech_id` sits in OWL state until someone manually swaps it. A tech walks away, the next person clicks a job card → Odoo records the work under the prior tech.
- A tech (or anyone with browser access) can type any URL — `/odoo/settings`, `/web/...` — and act under the shopfloor service user's full backend privileges. The "lock" is only an OWL overlay over the kanban; it doesn't gate URL navigation.
**The whole point of adding a PIN was to enforce "log who did what."** Today it doesn't. AS9100/Nadcap audit trails are unreliable because attribution can be wrong.
---
## Locked Design Decisions
| # | Question | Decision |
|---|---|---|
| Q1 | Session model | **Real per-tech sessions (impersonation).** After PIN unlock the backend creates a REAL Odoo session AS the tech (cookie swap, server-side session row). They literally ARE that user for the duration of the unlock. |
| Q2 | Lock-back trigger | **Idle timeout + manual lock + hard ceiling.** Default 10 min idle, 8 hr hard ceiling regardless of activity. Manual "Lock" / "Hand-Off" button always available. |
| Q3 | Kiosk identity | **Dedicated kiosk user.** New user `fp_tablet_kiosk@enplating.local` in a new `group_fp_tablet_kiosk` group with near-zero ACL (read `res.users` for tile grid + read `ir.config_parameter` for idle settings). |
| Q4 | Manager override / impersonation | **No special path.** Manager wanting to chip in must PIN in as themselves. Simpler, strongest audit. |
| Q5 | OLD endpoint lifecycle | **Remove after successful rollout.** Two-step deploy with 1-week overlap, then Step 3 cleanup commits remove the legacy `tablet_tech_id` plumbing. |
---
## Section 1 — Architecture Overview
### Three identities, two state transitions
```
┌──────────────────────────────────────────────────────────────────────┐
│ STATE: KIOSK │
│ ──────────── │
│ Browser cookie = kiosk session_id │
│ Session uid = fp_tablet_kiosk (new user, near-zero ACL) │
│ Visible = lock screen + tile grid only │
│ Idle timer = OFF │
└──────────────────────────────────────────────────────────────────────┘
│ Tech taps tile + enters correct PIN
│ POST /fp/tablet/unlock_session
│ → server: verify hash, mint new session
┌──────────────────────────────────────────────────────────────────────┐
│ STATE: TECH │
│ ─────────── │
│ Browser cookie = tech session_id (fresh Set-Cookie from unlock) │
│ Session uid = tech.id (real Odoo session as the tech) │
│ Visible = full Plating UI per tech's normal ACLs │
│ Idle timer = ON (10 min default) │
│ Hard ceiling = 8 hr from session_started_at │
│ │
│ EVERY Odoo write naturally attributed via session.uid: │
│ create_uid = tech.id │
│ write_uid = tech.id │
│ chatter authorship = tech.partner_id │
│ NO MORE tablet_tech_id plumbing needed │
└──────────────────────────────────────────────────────────────────────┘
│ Any of: manual Lock button / 10 min idle /
│ 8 hr ceiling
│ POST /fp/tablet/lock_session
│ → server: destroy session, re-auth as kiosk
back to KIOSK
```
### Why this is fundamentally different from today
Today the tablet has ONE persistent session. The PIN is an OWL overlay; the underlying Odoo session never changes. New design: every PIN unlock creates a NEW Odoo session AS the tech. Lock-back DESTROYS that session and creates a fresh kiosk session.
Result: Odoo's standard `create_uid` / `write_uid` / chatter authorship already attributes everything correctly. We drop the `tablet_tech_id` kwarg + `env_for_tablet_tech` helper entirely — they become dead code.
**Security gain:** If the tech navigates to ANY Odoo URL (Settings, Users, anything), they're acting under their own permissions, not the kiosk's. The kiosk user has near-zero ACLs so even if someone hits `/fp/...` URLs before PIN-ing in, they see nothing.
---
## Section 2 — Components & Files
### New files
| Path | Responsibility |
|---|---|
| `fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml` | Idempotent create of `fp_tablet_kiosk@enplating.local` + assignment to `group_fp_tablet_kiosk`. Default password = random secret stored in `ir.config_parameter['fp.tablet.kiosk_password']` (sysadmin can reset). |
| `fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml` | New `res.groups` `group_fp_tablet_kiosk`. NOT under the Fusion Plating privilege block — orthogonal to the 8-role hierarchy. `privilege_id` stays empty so it doesn't pollute the role dropdown. |
| `fusion_plating_shopfloor/security/ir.model.access.csv` (rows added) | 2 ACL rows for kiosk: READ `res.users` (tile grid) + READ `ir.config_parameter` (idle settings). Nothing else. No fp.job, no sale.order, no anything. |
| `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | New model `fp.tablet.session.event` — append-only audit log. Owner-only read; only base.group_system can ever write/unlink (we don't grant that). |
| `fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml` | List + form views. Owner-only menu under Plating → Configuration → Tablet Audit Log. Smart button on `res.users` form. |
| `fusion_plating_shopfloor/models/res_users.py` (existing — add auth check) | Override `_check_credentials` to handle `type='fp_tablet_pin'` (custom auth manager). Verifies PIN hash + recordset state; raises `AccessDenied` on any failure. |
| `fusion_plating_shopfloor/data/fp_tablet_cron.xml` | New cron `_cron_force_lock_stale_sessions` (every 5 min). Finds any active fp.tablet.session.event past 8hr ceiling with no `session_ended_at`, force-marks it ended. |
| `fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js` | OWL service. Tracks idle time via DOM event listeners. Fires lock-back at 10min idle or 8hr ceiling. Replaces `fp_shopfloor_tech_store`. |
### Modified files
| Path | Change |
|---|---|
| `controllers/tablet_controller.py` | Replace `/fp/tablet/unlock` with two new endpoints: `/fp/tablet/unlock_session` (verifies PIN → mints new Odoo session via `request.session.authenticate(db, {type:'fp_tablet_pin', ...})`), `/fp/tablet/lock_session` (destroys tech session → re-auths as kiosk). OLD `/fp/tablet/unlock` kept alive during the 1-week overlap. |
| `controllers/_tablet_audit.py` | `env_for_tablet_tech` becomes a one-line no-op pass-through. Marked deprecated; deleted in Step 3 cleanup. |
| All ~15 endpoints with `tablet_tech_id` kwarg | Step 3 (post-overlap) — remove the kwarg. Endpoints run under `request.env.user` which IS the tech (because session swap). |
| OWL `FpTabletLock` component | Use new `tablet_session_manager` service. On successful unlock, `window.location.reload()` so the entire app re-bootstraps with the tech's session cookie. (Cleanest — no half-state.) |
| `services/fp_rpc.js` | Stop auto-injecting `tablet_tech_id`. Becomes a thin wrapper, removable once endpoints drop the kwarg in Step 3. |
### Auth path — custom auth manager
**Method:** Register a new auth type `fp_tablet_pin` via `res.users._check_credentials`.
1. PIN unlock endpoint calls `request.session.authenticate(request.db, {'type': 'fp_tablet_pin', 'login': tech.login, 'pin': pin})`.
2. Odoo's standard auth flow takes over: `_check_credentials` is invoked, sees `type='fp_tablet_pin'`, calls our handler.
3. Our handler hashes the PIN and compares against `tech.x_fc_tablet_pin_hash`. Validates `tech.active` and that the tech holds any shop-branch group.
4. On success, Odoo issues the session, sets cookie, returns response — **same code path Odoo uses for password login**. We get session lifecycle hooks, validation chain, and security for free.
**Alternative considered:** direct `request.session.uid = tech.id` + manual cookie. Faster to implement but bypasses Odoo's `_check_credentials` validation chain (2FA, IP gating, future security modules). Picked the slower-to-implement but correct path.
### Idle-timer mechanics (OWL service)
```javascript
// tablet_session_manager.js (sketch)
class TabletSessionManager {
setup(env) {
this.idleMs = 10 * 60 * 1000; // 10 min, configurable via ir.config_parameter
this.ceilingMs = 8 * 60 * 60 * 1000; // 8 hr hard
this.lastActivity = Date.now();
this.sessionStartedAt = ...; // from server on bootstrap
['click', 'touchstart', 'keydown', 'mousemove'].forEach(ev =>
document.addEventListener(ev, () => this.touch(), { passive: true })
);
setInterval(() => this.tick(), 5000);
}
touch() { this.lastActivity = Date.now(); }
tick() {
const now = Date.now();
if (now - this.lastActivity > this.idleMs ||
now - this.sessionStartedAt > this.ceilingMs) {
this.lockBack(now - this.lastActivity > this.idleMs ? 'idle' : 'ceiling');
}
}
async lockBack(reason) {
await rpc("/fp/tablet/lock_session", { reason });
window.location.reload(); // fresh page → fresh kiosk session
}
}
```
Server-side belt-and-suspenders: cron `_cron_force_lock_stale_sessions` runs every 5 min and force-destroys any tablet session past the 8-hr ceiling — handles browser crashes, tablet reboots with stale cookie, etc.
---
## Section 3 — Session Lifecycle in Detail
### Unlock flow
```
Tablet OWL Server
────── ─── ──────
Tap tile ──────────────▶ PIN pad opens
Enter 4 digits ────────────▶ collect PIN
POST /fp/tablet/unlock_session
{ tech_id, pin }
cookie: kiosk_session_id
────────────────────▶
1. verify kiosk session active
2. browse(tech_id), exists+active
3. lockout check (failed_count,
locked_until)
4. verify_tablet_pin via hash
├─ FAIL → fp.tablet.session.event
│ (failed_unlock,
│ attempted_user_id=tech_id,
│ failure_reason='wrong_pin',
│ ip, ua)
│ + increment failed_count
│ + maybe set locked_until
│ + return {ok:false, error}
└─ PASS:
5. session.authenticate(db, {
type:'fp_tablet_pin',
login:tech.login,
pin:pin })
→ Odoo issues new sid,
uid=tech.id
→ response Set-Cookie
6. fp.tablet.session.event
(unlock, user_id=tech_id,
session_id_hash=sha256(sid),
session_started_at=now,
ip, ua)
7. reset failed_count=0
8. return {ok:true, tech_name}
◀────────────────────
window.location.reload()
```
### Lock-back flow
```
Trigger (any of):
- User taps Lock button
- 10 min no activity (idleMs)
- 8 hr since session_started_at (ceilingMs)
POST /fp/tablet/lock_session { reason: 'manual' | 'idle' | 'ceiling' }
cookie: tech_session_id
──────▶ server:
1. read session.uid (current tech)
2. fp.tablet.session.event
(event_type matches reason,
user_id=tech_id, session_id_hash,
session_ended_at=now,
duration_seconds=now - session_started_at)
3. request.session.logout()
4. response Set-Cookie: clear tech session
5. session.authenticate(db, {type:'password',
login:'fp_tablet_kiosk',
password:KIOSK_SECRET_from_ir_config})
→ response carries new kiosk Set-Cookie
6. return {ok:true, locked_at:now}
◀────── browser receives Set-Cookie (kiosk session)
window.location.reload()
App re-bootstraps as kiosk → lock screen renders
```
### Edge cases (must work)
| Scenario | Behavior |
|---|---|
| Tech walks away, browser crashes | Cron `_cron_force_lock_stale_sessions` (every 5 min) finds the stale session, writes `event_type='force_lock'`. Next tablet boot starts as kiosk regardless. |
| Two techs race-tap two tiles simultaneously | First unlock wins. Second tech's POST sent with the FIRST tech's NEW cookie (Set-Cookie applied) → backend sees them as tech 1, returns access-denied to mint another session. UI debounces with spinner. |
| Wrong PIN 5 times | `failed_count` → 5, `locked_until` set to now+5min. Subsequent PIN attempts return `{ok:false, locked_until}` → UI shows countdown. Audit event each attempt. |
| Network drops mid-unlock | OWL gets timeout → retry button. Session NOT created server-side if request never completed. Single DB transaction guarantees atomicity. |
| Browser pre-fills cached cookie from old session | Server validates session row; if invalid, returns 401, OWL forces page reload → re-authenticates as kiosk. |
| Manager force-resets a tech's PIN while tech is unlocked | Tech's current session keeps working (sessions independent of PIN hash). Next PIN entry requires the new PIN. Manager action logged via `admin_reset` event. |
| Tech navigates to `/odoo/settings` or other URLs | They're a real Odoo user with their own ACLs. Standard ACLs apply. Technician sees what a Technician would see (mostly nothing — Manager+ only menus). |
| Tablet PC reboots mid-shift | Boots to login page (kiosk session cookie may have expired). Stored kiosk credential auto-fills. Reaches lock screen, ready for PIN. |
### Concurrency / race protection
- `unlock_session` takes a DB row lock on `res.users(id=tech_id)` for the duration of `verify_tablet_pin` + `failed_count` write. Prevents double-counting failed attempts.
- `fp.tablet.session.event` writes are sudo'd and append-only. Race conditions produce two adjacent audit rows (sortable by `create_date`) — never lose data.
---
## Section 4 — Audit Log Model + UI
### Model: `fp.tablet.session.event`
```python
class FpTabletSessionEvent(models.Model):
_name = 'fp.tablet.session.event'
_description = 'Tablet Session Event (audit log)'
_order = 'create_date desc'
_rec_name = 'event_type'
event_type = fields.Selection([
('unlock', 'Unlock (PIN success)'),
('failed_unlock', 'Failed PIN attempt'),
('manual_lock', 'Manual lock (Hand-Off button)'),
('idle_lock', 'Idle timeout lock'),
('ceiling_lock', '8-hour ceiling lock'),
('force_lock', 'Force lock (cron, stale session)'),
('admin_reset', 'Admin force-reset PIN'),
], required=True, readonly=True, index=True)
user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict',
help='The tech whose session was unlocked/locked. NULL for failed '
'attempts where the tile was tapped but unlock never succeeded.')
attempted_user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict',
help='For failed_unlock: which tile was tapped. user_id stays empty.')
session_id_hash = fields.Char(readonly=True,
help='sha256 hash of the Odoo session sid. Lets us correlate '
'events for the same session without storing the raw token.')
session_started_at = fields.Datetime(readonly=True)
session_ended_at = fields.Datetime(readonly=True)
duration_seconds = fields.Integer(readonly=True)
ip_address = fields.Char(readonly=True)
user_agent = fields.Char(readonly=True, help='Trimmed to 256 chars.')
failure_reason = fields.Selection([
('wrong_pin', 'Wrong PIN'),
('locked_out', 'Locked out (too many failures)'),
('no_pin_set', 'No PIN configured'),
('user_inactive', 'User archived or disabled'),
('no_role', 'User has no shop-branch role'),
], readonly=True)
acting_uid = fields.Many2one('res.users', readonly=True,
help='The user the SERVER saw at request time. Usually fp_tablet_kiosk '
'for unlocks; the manager for admin_reset; base.user_root for cron.')
notes = fields.Text(readonly=True)
```
**Design rules:**
- Append-only. No `_inherit = 'mail.thread'`. No `write()` or `unlink()` ACL granted to any group except `base.group_system` (which we DON'T grant — root SQL access required to tamper).
- Hashed session sid, not raw — if DB leaks, attackers can't replay sessions.
- `attempted_user_id` cleanly distinguishes "Carlos's tile was tapped" from "Carlos was authenticated."
### Lifecycle: who writes what
| Trigger | Endpoint | event_type | user_id | attempted_user_id | acting_uid |
|---|---|---|---|---|---|
| Successful PIN unlock | `/fp/tablet/unlock_session` | unlock | tech | — | kiosk |
| Wrong PIN | `/fp/tablet/unlock_session` (fail) | failed_unlock | — | tech | kiosk |
| 5th wrong PIN → lockout | same | failed_unlock (`locked_out`) | — | tech | kiosk |
| Lock button | `/fp/tablet/lock_session` | manual_lock | tech | — | tech |
| 10 min idle | `/fp/tablet/lock_session` | idle_lock | tech | — | tech |
| 8 hr ceiling | `/fp/tablet/lock_session` or cron | ceiling_lock | tech | — | tech or cron |
| Cron force-lock | `_cron_force_lock_stale_sessions` | force_lock | tech | — | base.user_root |
| Manager resets PIN | `/fp/tablet/reset_pin_for` | admin_reset | tech | — | manager |
### UI: 3 surfaces
1. **Owner-only menu** — Plating → Configuration → Tablet Audit Log. List view with badges, filters (today/week/month, event_type, user), group-by, default 90-day window.
2. **Smart button on `res.users` form (Owner-only)** — "Tablet Events" with last-7-days count, opens audit list filtered to that user (`user_id` OR `attempted_user_id`).
3. **Chatter linkback (deferred to follow-up)** — tooltip on chatter messages linking to the unlock event. Phase 2, not blocking.
### Retention
- Default: indefinite. `_cron_purge_old_session_events` exists but is DISABLED. Configurable via `ir.config_parameter['fp.tablet.audit.retention_days']` (unset = forever).
- AS9100 retention typically 3-7 years. Safe default for small shops.
### What this audit log does NOT replace
- Standard `create_uid` / `write_uid` on every model — that's the primary audit.
- Chatter authorship — still primary on individual records.
- This log catches what `create_uid` can't: failed attempts, session lengths, idle vs manual locks, gaps between sessions.
---
## Section 5 — Migration & Rollout
### Two-step deploy with overlap window
To avoid downtime, OLD and NEW endpoints coexist for ~1 week. Tablets switch over individually as they reboot.
**Step 1 — Day 0 deploy (NEW code, OLD still alive)**
`-u` brings up:
- kiosk user + group + ACL
- new `/fp/tablet/unlock_session` and `/fp/tablet/lock_session`
- `fp.tablet.session.event` model + audit views
- new OWL `tablet_session_manager` service
- custom `fp_tablet_pin` auth manager
OLD endpoints stay:
- `/fp/tablet/unlock` returns `current_tech_id` (no session swap)
- `env_for_tablet_tech` still routes endpoints
- OWL `fp_shopfloor_tech_store` still bypassed when new manager is active
Frontend feature flag `ir.config_parameter['fp.shopfloor.tablet_session_mode']`:
- `'legacy'` (Day 0 default) → OWL uses old flow
- `'session_swap'` → OWL uses new flow
**Step 2 — Days 1-7 cutover, one tablet at a time**
- Flip flag to `'session_swap'` on entech
- Per tablet:
1. Reboot or hard-refresh browser
2. Sysadmin enters kiosk credential ONCE (stored in 1Password)
3. Bookmark `/odoo/action-fusion_plating_shopfloor.action_fp_plant_kanban`
4. Lock screen renders under kiosk user
5. Test: tech taps tile, enters PIN, full flow works as them
6. Track in spreadsheet (2-3 tablets total)
**Step 3 — Day 14 cleanup commits**
- Sweep `tablet_tech_id` kwargs out of all ~15 endpoints
- Delete `_tablet_audit.env_for_tablet_tech` (becomes import error → forces final cleanup)
- Remove OLD `/fp/tablet/unlock` endpoint
- Remove OLD `fp_shopfloor_tech_store` OWL service
- Strip auto-injection from `services/fp_rpc.js`
- Archive the legacy "shopfloor service" user
### Auto-login pattern for kiosk
Three options, cheapest first:
1. **Browser-stored cookie + long session_lifetime (recommended for entech)** — set `session_db.session_lifetime` to 90 days for kiosk. Sysadmin logs in once, cookie lasts 3 months. Cheap, manual.
2. **Kiosk browser extension** — KioWare or Chromium kiosk-mode auto-fills credential. Auto-recovers from reboots.
3. **Odoo SSO with stored token** — overkill for 2-3 tablets.
### Rollback plan
- Set `tablet_session_mode = 'legacy'` → all OWL switches back. No redeploy.
- If kiosk user is broken, legacy "shopfloor service" still has its permissions and the OLD endpoints — system keeps working.
### What WON'T be touched
- `res.users.x_fc_tablet_pin_hash` — same field, same hash format, same `verify_tablet_pin()`. PINs don't need to be reset.
- Lockout state (`failed_count`, `locked_until`) — preserved.
- All other shop-floor functionality (kanban, workspace, recipes) — unaffected.
### Timing on entech
| Day | Action |
|---|---|
| 0 (deploy day) | `-u fusion_plating_shopfloor` + 4 related modules. Verify new endpoints. `tablet_session_mode='legacy'`. |
| 0 (evening) | Flip flag on one test tablet. Manual test 5 unlock/lock cycles. |
| 1-2 | Roll to second + third tablets. Owner watches audit log. |
| 3-7 | Operators use new flow. Owner reviews audit log daily. |
| 7 | Decision: keep `'session_swap'` permanently OR roll back. |
| 14 | If kept: Step 3 cleanup commits. |
---
## Section 6 — Acceptance, Risks, Deferred
### Acceptance criteria
1. **Identity** — pick any `create_uid` on `fp.job.step` change in last 30 days. Cross-reference `fp.tablet.session.event` for active session at that timestamp under that user. Match rate target: 100% of tablet-originated writes.
2. **Failed attempts** — query "every wrong-PIN attempt for user X in last week" returns rows with `attempted_user_id=X`, `failure_reason='wrong_pin'`, IP, UA, timestamp.
3. **Session length** — longest session ≤ 8hr (within 5-min cron grace).
4. **Gap detection** — adjacent lock → next unlock delta computable; "was anyone on the floor at 2pm?" answerable.
5. **No silent attribution** — post-Step 3, action endpoint without `tablet_tech_id` runs under `request.env.user` which IS the tech. `create_uid = tech.id`.
6. **Kiosk privilege check** — log in as `fp_tablet_kiosk` in private browser. Try to navigate any plating URL. Result: access denied / blank menu.
7. **Browser navigation under tech** — as the unlocked tech, hit `/odoo` main menu. They see ONLY menus their role allows.
8. **Idle lockout fires** — PIN-unlock, wait 11 min touching nothing. Auto-lock. `event_type='idle_lock'` row appears.
9. **Hard ceiling fires** — backdate `session_started_at` past 8 hrs. Run cron. `session_ended_at` populates, `event_type='force_lock'`.
10. **Audit log append-only** — try `event.write({...})` as Owner. AccessError. Only root SQL access can tamper.
### Open risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Custom auth manager conflicts with Odoo's session_lifetime / 2FA / IP modules | Medium | Distinctive type name (`fp_tablet_pin`, not `tablet_pin`). Tests for pwd + 2FA paths unchanged. Document in CLAUDE.md. |
| Browser cookie misbehavior on rapid lock/unlock | Low | `window.location.reload()` after every transition kills half-state. |
| Tablet PC reboot mid-shift with stale cookie | Medium | Long-lived kiosk cookie (90-day session_lifetime). Sysadmin re-login if expired (~30s downtime). |
| Backend cron clock drift | Low | All timestamps `fields.Datetime.now()` (UTC, server). Server cron is source of truth for ceiling. |
| Network drop mid-unlock | Low | One DB transaction — atomic. Either session+audit row both commit, or neither. |
| Operator hits backend URLs | Expected behavior | They're real Odoo users with their own ACLs. Standard ACLs apply. Working as designed. |
| Brute-force PIN attempts as DoS | Low (insider only) | 5-attempt → 5-min lockout. Cron clears `failed_count` after 1hr of no failures. 10000 possible PINs + lockout → ~3.5 years to brute-force on average. |
### Deferred to follow-up
- **Chatter linkback** (Section 4, surface 3) — useful but not blocking. Phase 2.
- **2FA on lock screen** (badge tap, biometric) — out of scope.
- **Per-tablet identity** — currently every tablet uses same kiosk credential. If you ever want to track which physical tablet did what, add `tablet_device_id`. Deferred — small shop doesn't need it.
- **SAML/SSO integration** — out of scope.
- **Manager override mode** — explicitly killed in Q4. Manager wanting to chip in must PIN in as themselves.
- **Time-clock integration** — separate concern. The `session_started_at` could feed time-tracking but that integration is its own design.
- **Mobile (non-tablet) access** — Technician on phone uses standard Odoo login. PIN flow is tablet-only.
### Estimated effort
- Phase 1: server-side (kiosk user, auth manager, endpoints, audit model, cron) — **~1.5 days**
- Phase 2: OWL (session manager, lock-back UI, reload-on-transition) — **~0.5 days**
- Phase 3: audit views + Owner menu + smart button — **~0.5 days**
- Phase 4: entech rollout (deploy, feature-flag test, per-tablet cutover, validation week) — **~1 day spread over 7 days**
- Phase 5: Step 3 cleanup (rip out tablet_tech_id) — **~0.5 days**
**Total: ~4 development days + 1 calendar week observation.**
---
## Status
- ✅ Brainstorm complete (5 locked decisions)
- ✅ Design doc written
- ⏳ Self-review (next)
- ⏳ User reviews this spec
- ⏳ Invoke `writing-plans` to create the implementation plan
- ⏳ Execute the plan per `subagent-driven-development`
- ⏳ Deploy + validate on entech
---
*End of design document.*

Some files were not shown because too many files have changed in this diff Show More