Compare commits

...

41 Commits

Author SHA1 Message Date
gsinghpal
76c898aadf docs(fusion_accounting): Phase 0 foundation implementation plan
Detailed task-by-task plan for executing Phase 0 of the Enterprise
Takeover Roadmap. 22 tasks covering:

- Sub-module skeletons (_core, _ai, _migration) and meta-module conversion
- Move all current AI module code into fusion_accounting_ai with git mv
- ir_model_data ownership reassignment via post-migration script
- Data adapter pattern (base + bank_rec + reports + followup + assets adapters)
- Refactor of every AI tool to route through adapters (pilot in bank_rec, then survey + per-file)
- Strip all hard Enterprise dependencies from manifests
- Enterprise-detection helper and shared-field-ownership models in _core
- Multi-company record rule on fusion.accounting.session (was a Known Issue)
- Migration safety guard that blocks Enterprise uninstall until wizard runs
- Migration wizard skeleton (per-feature migrations added by future phases)
- tools/check_odoo_diff.sh for the annual upgrade ritual
- Per-sub-module CLAUDE.md, UPGRADE_NOTES.md, README.md
- CI pipeline (or deferral note if not yet viable)
- Empirical Enterprise-uninstall verification test on a throwaway instance
- End-to-end smoke test + completion tag

Each task uses TDD where applicable (test fails, implement, test passes,
commit) and concrete validation commands where TDD doesn't fit (file moves,
config changes, manual smoke tests).

Made-with: Cursor
2026-04-18 21:13:07 -04:00
gsinghpal
6c4ff7751f feat(plating): comprehensive timezone fix across dashboards/PDFs/emails
Database stores datetimes naive-UTC, but the dashboards and emails were
showing UTC strings to users in EST/EDT — making 9pm Toronto look like 1am
the next day. Adds a single helper module + auto-detection on install.

Core changes (fusion_plating):
- New fp_tz.py helper: fp_user_tz, fp_format, fp_isoformat_utc, fp_time_ago
  Resolves user.tz → company.x_fc_default_tz → UTC.
- res.company.x_fc_default_tz Selection (full pytz IANA list)
- res.config.settings exposes the company tz under a new "Regional
  Settings" block in Settings > Fusion Plating
- post_init_hook auto-populates the tz on first install: tries admin
  user → server /etc/timezone → America/Toronto fallback
- fp_process_node._to_dict now sends create_date/write_date as ISO with
  explicit +00:00 marker so JS new Date() parses it as UTC and the
  recipe tree editor's "time ago" math works correctly

Shop-floor controllers:
- shopfloor_controller.py: every fields.Datetime.to_string() and naive
  .strftime() swapped for fp_format(env, ...) — due_at, bake times,
  last_log_date, gates, server_time all now in user's tz
- _time_ago() removed; replaced with fp_time_ago helper which compares
  tz-aware datetimes (the local one was naive-vs-naive and could be
  off by hours)
- manager_controller.py date_planned: str(...)[:10] slice replaced
  with fp_format MM/DD in user's tz

Notifications + reports:
- mail_template_data.xml: 5 .strftime() calls in body_html → babel
  format_datetime / format_date with tz=(user.tz or company tz)
- report_fp_job_traveller.xml: rec.received_date (Datetime) gets
  t-options="{'widget':'datetime'}" so Odoo's QWeb renders in user tz

Settings view layout:
- fusion_plating now owns the Settings page "Fusion Plating" app shell
- fusion_plating_certificates xpaths into it instead of redefining
  (prevents app-name collision)

Verified on odoo-entech (LXC 111): post_init_hook detects
America/Toronto from /etc/timezone, MO date_start 2026-04-17 05:28 UTC
correctly displays as 2026-04-17 01:28 EDT.

Module versions bumped: fusion_plating 19.0.3.0.0,
fusion_plating_shopfloor 19.0.9.0.0, plus certificates / notifications /
reports → 19.0.3.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:03:02 -04:00
gsinghpal
956678dd27 docs(fusion_accounting): roadmap design for Enterprise takeover
Adds the brainstormed roadmap design that turns fusion_accounting from an
AI-only extension into a full replacement for Odoo 19 Enterprise accounting
(account_accountant, account_reports, accountant, account_followup, plus
selected satellites) for Nexa client deployments.

Covers:
- Sub-module topology (9 modules + meta-module): _core, _bank_rec, _reports,
  _dashboard, _followup, _assets, _budget, _ai, _migration
- Data preservation strategy: bank reconciliations verified preserved
  automatically (live in Community account.partial.reconcile);
  shared-field-ownership pattern for Enterprise extension fields on
  account.move; pre-uninstall migration wizard for Enterprise-only tables
- Phased roadmap: Phase 0 foundation through Phase 7+ optional satellites,
  with Bank Rec as Phase 1 priority and Reports as the largest phase
- Architecture rules: hybrid mirror/abstract zones, fusion.* naming,
  runtime coexistence detection, zero hard Enterprise deps
- Cross-version upgrade workflow: pinned Odoo source snapshots per version,
  annual diff ritual, UPGRADE_NOTES.md per sub-module
- AI integration via adapter pattern (current AI tools route through
  adapters that prefer fusion native, fall back to Enterprise, then to
  pure Community)
- Testing strategy, security, performance, multi-company/currency,
  localization, hosting

Implementation of each phase happens in subsequent sessions, each with
its own writing-plans pass starting with Phase 0 Foundation.

Made-with: Cursor
2026-04-18 20:55:22 -04:00
gsinghpal
e52477e2ba fix(plant-overview): priority stripe clips to card's rounded corners
The coloured priority stripe (4px vertical bar at the card's left
edge, set via ::before pseudo) extended past the top and bottom
rounded corners of the card — visible as sharp corners on cards with
Urgent or HOT priority (yellow/red stripe).

Cause:
  .o_fp_po_card::before was positioned at left/top/bottom: -1px and
  given its own border-radius, but the stripe's own radii didn't
  match the card's 14px radius precisely, and the -1px offsets
  pushed the stripe outside the card's curves.

Fix:
  1. .o_fp_po_card gets overflow: hidden. Shadows are painted outside
     the content box in CSS so box-shadow still renders fine, but any
     child element (including ::before) now clips to the parent's
     border-radius automatically.
  2. Stripe ::before simplified to left/top/bottom: 0 — no more
     negative offsets, no more independent border-radius rules.
     The parent's overflow does the corner-matching.

Verified in /web/assets/5e85f15/web.assets_backend.min.css:
  .o_fp_po_card { ...; overflow: hidden; ... }
  .o_fp_po_card::before { content: ""; position: absolute;
      left: 0; top: 0; bottom: 0; width: 4px; ... }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:00:14 -04:00
gsinghpal
83271ee69e fix(shopfloor): pages own the scroll + sharp corner fix
Two problems after the previous round:

1) Mobile scroll still not working, even on a real phone.

Dug into /usr/lib/python3/dist-packages/odoo/addons/web/static/src/
webclient/webclient_layout.scss and found Odoo's mobile layout
switches scroll ownership at @media-breakpoint-down(md) (<768px):

  Desktop: .o_content has overflow:auto — your content scrolls there
  Mobile:  .o_action gets overflow:auto, .o_content is overflow:initial

Our client action roots had `min-height: 100%` and relied on an
ancestor for scroll. That ancestor changes between breakpoints, and
somewhere in the transition scroll gets lost — the page fills but
can't scroll.

Fix: make each page OWN its scroll, like .o_content on desktop
kanban/list views. Three roots now have:

  .o_fp_tablet / .o_fp_manager / .o_fp_plant_overview {
      height: 100%;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
  }

Scroll works regardless of which ancestor Odoo decides owns it at
any given breakpoint.

2) Sharp corner on column header at mobile widths.

The previous commit set `overflow: visible` on .o_fp_po_column at
<=900px trying to help scroll. But the column has border-radius: 20px
and contains .o_fp_po_col_header (which has its own background). When
overflow is visible, the header bg extends to the column's corners
without being clipped — you see squared corners on the mobile card.

Fix: keep `overflow: hidden` on .o_fp_po_column at every breakpoint
(that's what clips the rounded corners). Only lift `max-height` on
mobile so columns size to content naturally. Since the PAGE now owns
the scroll (see fix #1), the column doesn't need internal scroll —
no `overflow: auto` on the body is needed either.

Verified in compiled CSS at /web/assets/7ff5b28/web.assets_backend.min.css:
  .o_fp_tablet          { height: 100%; overflow-y: auto; ... }
  .o_fp_manager         { height: 100%; overflow-y: auto; ... }
  .o_fp_plant_overview  { height: 100%; overflow-y: auto; ... }
  .o_fp_po_column       { border-radius: 20px; overflow: hidden }
  @media (max-width: 900px) .o_fp_po_column {
      flex: 1 1 auto; min-width: 100%; max-width: 100%;
      max-height: none;   // no overflow override — hidden stays
  }

Version bumped 19.0.6.0.0 -> 19.0.7.0.0 to force bundle hash change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:49:35 -04:00
gsinghpal
082c585e24 fix(shopfloor): mobile scroll works — remove nested scroll containers
User: "scrolling is not working" in Chrome DevTools mobile simulation.
Three actual problems:

1. Plant Overview columns had max-height: calc(100vh - 180px) +
   overflow: hidden, with a nested overflow-y: auto on the column
   body. Classic Trello kanban pattern — works on desktop, breaks
   on mobile. You get two scroll containers fighting each other and
   the PAGE itself can't scroll past the viewport height.

2. .o_fp_po_columns had overflow-x: auto on all widths. On the
   phone-stack breakpoint (<600px) this was also still on, creating
   another nested scroll container.

3. Draggable cards can swallow touch events on mobile because
   touch-action defaults to "auto" and Chrome's mobile simulator
   treats touch on draggable elements as potential drag-start.

Fixes — all at the <=900px breakpoint (tablets + phones):

  .o_fp_po_column          max-height: none; overflow: visible
  .o_fp_po_col_body        overflow-y: visible
  .o_fp_po_columns         flex-direction: column; overflow: visible

Plus .o_fp_po_card carries `touch-action: pan-y` unconditionally —
touch-scroll gestures never get hijacked by the draggable="true"
attribute. Desktop mousedown drag still works (HTML5 drag-drop
isn't touch-based by default).

Also added -webkit-overflow-scrolling: touch to all three page
roots (.o_fp_tablet, .o_fp_manager, .o_fp_plant_overview) and to
the internal scroll containers that remain on desktop — gives iOS
Safari proper momentum scroll (11 occurrences in the compiled
bundle).

Drag-drop JS preventDefault calls audited — they only fire on
dragover/drop (HTML5 drag events), which don't exist on touch by
default, so no touch interference there.

Verified via compiled CSS:
  .o_fp_po_card { touch-action: pan-y; ... }
  @media (max-width: 900px) .o_fp_po_column { overflow-x: visible;
         overflow-y: visible; min-height: auto }
  @media (max-width: 900px) .o_fp_po_col_body { overflow-y: visible }

Version bumped 19.0.5.0.0 -> 19.0.6.0.0 to force the bundle hash
to change. New URL: /web/assets/4a1b69e/web.assets_backend.min.css

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:40:52 -04:00
gsinghpal
afc01ec1d9 fix(shopfloor): proper dark-mode via \$o-webclient-color-scheme branch
Dug deeper after the user reported shop-floor pages staying white in
dark mode. Traced through Odoo 19 source:

  _dependencies/web_enterprise/static/src/
    webclient/color_scheme/color_scheme_service.js  <- reads cookie
    scss/primary_variables.scss       \$o-webclient-color-scheme: bright
    scss/primary_variables.dark.scss  \$o-webclient-color-scheme: dark

Odoo compiles TWO separate CSS bundles:
  web.assets_backend       -> compiled with \$...scheme: bright
  web.assets_web_dark      -> compiled with \$...scheme: dark
    (the .dark.scss files are layered in front of the light ones)

Our shop-floor SCSS is in web.assets_backend, which means it gets
compiled into BOTH bundles. But the previous CSS-variable fallback
chain (var(--fp-page-bg, var(--bs-tertiary-bg, #hex))) baked the
SAME hex fallback into both bundles, so cards stayed white in dark.

Odoo's own code doesn't redefine --bs-* CSS custom properties at
runtime either — it just bakes the dark palette straight into the
dark bundle via SCSS \$-variables during compile.

Fix: _fp_shopfloor_tokens.scss now branches at compile time:

    \$o-webclient-color-scheme: bright !default;

    \$_fp-page-hex: #f3f4f6;  // light defaults
    \$_fp-card-hex: #ffffff;
    ...
    @if \$o-webclient-color-scheme == dark {
        \$_fp-page-hex: #1a1d21 !global;
        \$_fp-card-hex: #22262d !global;
        ...
    }

    \$fp-page: var(--fp-page-bg, \$_fp-page-hex);
    \$fp-card: var(--fp-card-bg, \$_fp-card-hex);

The CSS-custom-property fallback stays so deployments can still skin
via --fp-* without touching SCSS; the underlying hex changes between
bundles.

Verified via odoo-shell:
  LIGHT bundle: .o_fp_plant_overview { background-color: var(...#f3f4f6) }
                .o_fp_po_card         { background-color: var(...#ffffff);
                                         border: ... #d8dadd }
  DARK bundle:  .o_fp_plant_overview { background-color: var(...#1a1d21) }
                .o_fp_po_card         { background-color: var(...#22262d);
                                         border: ... #343942 }

Two separate bundle URLs generated:
  /web/assets/a593157/web.assets_backend.min.css
  /web/assets/a9dba7d/web.assets_web_dark.min.css

=== CLAUDE.md ===
Replaced the previous (incorrect) .o_dark_mode override advice with
a proper "Branch on \$o-webclient-color-scheme at SCSS compile time"
section, including the bundle names and the verify-via-odoo-shell
snippet. Future redesigns now have a single, correct pattern to
follow.

Version bumped 19.0.4.0.0 -> 19.0.5.0.0 to force asset hash change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:30:14 -04:00
gsinghpal
11f7791c5e fix(shopfloor): dark mode auto-inverts + Quick View button visible
Two fixes + a memory entry in CLAUDE.md.

=== Dark mode ===

User: "when I change the theme the whole background does not turn
dark like the other pages does". Digging through Odoo 19 source:

  /_dependencies/web_enterprise/static/src/scss/
    bootstrap_overridden.dark.scss
    primary_variables.dark.scss
    secondary_variables.dark.scss

Odoo doesn't flip dark mode via a runtime .o_dark_mode class on the
DOM — it compiles a SEPARATE asset bundle where $o-webclient-color-
scheme: dark is set, which redefines every --bs-* token with dark
values. When the user toggles dark mode, Odoo swaps the whole CSS
bundle.

So my previous :root[data-bs-theme="dark"] { --fp-page-bg: #13161a; }
block was DEAD CODE — nothing ever sets data-bs-theme on the root.

Fixed: tokens now fall through to Bootstrap's --bs-* semantic tokens
before hitting a hex default, so they auto-invert when Odoo swaps
bundles. Three-level fallback chain:

  $fp-page : var(--fp-page-bg,
                 var(--bs-tertiary-bg, #f3f4f6));
  $fp-card : var(--fp-card-bg,
                 var(--bs-card-bg,
                     var(--o-view-background-color, #ffffff)));
  $fp-border : var(--fp-border-color,
                   var(--bs-border-color, #d8dadd));
  $fp-ink : var(--fp-ink, var(--bs-body-color, #1f2937));

Dead .o_dark_mode block removed. No runtime selector needed.

=== Quick View button ===

User: "Quick View button color is white with white button in light
mode." Cause: Bootstrap's .btn-primary loads AFTER our custom CSS
in the bundle and resets color: #fff, background: var(--bs-btn-bg)
— which clobbered our $fp-accent / $fp-ink assignment because a
later rule at the same specificity wins.

Fix: split the primary button into its own rule with higher
specificity (.o_fp_manager .o_fp_manager_head_actions .btn.btn-primary)
and !important on the three key properties — so Bootstrap can't
shout us down. Hover uses brightness(1.08) for a subtle darken
without needing another color assignment.

=== CLAUDE.md additions ===

Added two new rules documenting the lessons so this isn't relearned:

  Rule 8 — Odoo 19 forbids @import in custom SCSS (silent warning,
  falls back to cached bundle). Register partials in the assets list
  in load order; SCSS variables cascade through the bundle.

  "Card Styling — Copy Odoo's Kanban Pattern" section explaining:
  - Don't rely on --bs-border-color directly for card surfaces
  - Chain through $fp-* → --fp-* → --bs-* → hex
  - 3-layer contrast rule (page → container → card)
  - Reference _fp_shopfloor_tokens.scss as canonical

  "Asset Bundle Cache Busting" section with 4-step escalation path
  for when CSS changes don't show up in browser.

Verified: bundle regenerated to /web/assets/b48ab17/web.assets_backend.min.css
(id 1945). Card rule compiled with full fallback chain visible.
Primary button carries !important modifier for bg/border/color.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:22:17 -04:00
gsinghpal
81277edb25 fix(shopfloor): explicit hex colors like Odoo's own kanban
Why the borders weren't showing: the previous approach used
color-mix(var(--bs-body-color) 4%, var(--o-view-background-color)) for
card/column backgrounds. Under Odoo 19 the resolved values for those
variables were nearly identical to var(--bs-body-bg), so the card
surfaces visually merged into the page. Same problem for borders:
var(--bs-border-color) can render extremely faint depending on theme.

Checked what Odoo's native kanban does — dug through the compiled
CSS and found:
    .o_kanban_record { background-color: white;
                        border: 1px solid #d8dadd; }
    .o_kanban_group  { background: var(--KanbanGroup-background); }

Odoo uses EXPLICIT hex values and card-specific tokens, not the
generic body/border variables. Adopted the same approach.

New tokens in _fp_shopfloor_tokens.scss — all explicit, plus a
dark-mode override block keyed off [data-bs-theme="dark"] and
.o_dark_mode (Odoo 19 uses both):

    light                             dark
    ------------------------          ------------------
    --fp-page-bg: #f3f4f6             #13161a
    --fp-column-bg: #e9ebef           #1a1e24
    --fp-card-bg: #ffffff             #22262d
    --fp-card-soft-bg: #f8fafc        #1c2027
    --fp-border-color: #d8dadd        #343942
    --fp-ink: #1f2937                 #e5e7eb
    --fp-ink-mute: #6b7280             #8a909a

    shadow scale switched from color-mix to explicit rgba(0,0,0,...)
    so it renders identically across browsers.

All three SCSS files updated via sed to swap
var(--bs-border-color)  ->  #{$fp-border}
...then $fp-border resolves to var(--fp-border-color, #d8dadd) — a
proper card-level border that is VISIBLE (28 refs to --fp-card-bg
and 35 refs to --fp-border-color confirmed in the compiled bundle).

Plant Overview specifically now has:
  * Column: #f8fafc bg + #d8dadd border + shadow
    (column is brighter than the page it sits on)
  * Column HEADER: #ffffff inside the column, with bottom border
    (clear separator between stages)
  * Card: solid #ffffff bg + #d8dadd border + shadow
    (brightest surface, pops off the column)
  * Gap between columns: 16px so the column borders don't touch

Module version bumped to 19.0.3.0.0. Bundle regenerated at
/web/assets/0cd8bc1/web.assets_backend.min.css (1.45 MB, id 1939).
Verified by parsing compiled CSS:
  .o_fp_po_card: background-color: var(--fp-card-bg, #ffffff);
                 border: 1px solid var(--fp-border-color, #d8dadd);
  .o_fp_po_column: background-color: var(--fp-card-soft-bg, #f8fafc);
                   border: 1px solid var(--fp-border-color, #d8dadd);

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:13:38 -04:00
gsinghpal
2588a2b651 fix(plant-overview): real drop insertion indicator + small logo back
Three direct fixes responding to user feedback:

1. Drag-drop "simulation" — now works like Trello/Linear. As the
   cursor moves over a column, a live DOM placeholder node is
   INJECTED into the card list at the exact position the dragged
   card will drop. The placeholder is a 4px pulsing accent-coloured
   bar with a soft glow ring. Slides smoothly between cards as the
   cursor moves. Column body also gets a tinted background + inset
   accent outline for the "whole column is receptive" cue.

   Previous version only tinted the column — no indicator of WHERE
   the card would land. The new approach actually mimics the physical
   gesture: cards visually make room for the incoming card.

2. Customer logo restored at 32×32px.
   Removing it was the wrong call. It's back now as a small
   thumbnail avatar (rounded 10px corners, soft border, object-fit
   contain so wide logos don't squish). Sits to the left of the
   customer name in the card top row. Fallback icon for customers
   without a logo. Takes the same space as the step badge on the
   right — compact and organised.

3. Module version bumped 19.0.1.0.0 → 19.0.2.0.0 so the asset
   bundle content hash changes. The new compiled CSS is served at
   /web/assets/022171c/web.assets_backend.min.css (previously
   /web/assets/278b43c/...). Fresh URL forces browser to refetch —
   this is what was causing the "still no border" complaint.

Verified in compiled CSS: o_fp_po_card_avatar, o_fp_po_drop_placeholder,
o_fp_placeholder_pulse keyframes, o_fp_drop_target — all present.
Zero SCSS warnings. Module upgrade clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:06:40 -04:00
gsinghpal
83a999afad style(shopfloor): borders back, real drop indicator, logo demoted
Three concrete fixes based on user feedback:

1. Card borders restored — every card / panel / KPI tile / queue row /
   bake row / team card now has a thin 1px border (var(--bs-border-color))
   ON TOP of the soft shadow. That's the classic SaaS card treatment
   and solves the "jobs have no borders, they blend together" problem.
   Hover lifts the border to the accent colour (~45% mix) so cards
   feel responsive.

2. Plant Overview drop-zone indicator restored.
   - Column body gets inset outline + tinted background on dragover
     (.o_fp_drop_target class already added by onColDragOver in JS)
   - A 56px dashed placeholder bar appears at the bottom of the column
     via ::after on the drop target. That's the "here's where the card
     will land" visual the user remembered.
   - Dragged card gets scale(0.97) + slight rotation + opacity 0.4 for
     a clearer "I'm picking this up" feedback.

3. Customer logo removed from Plant Overview cards.
   The big company logo at the top of each kanban card was wasting
   space. Customer NAME still shows (in bold, full-width, with text-
   ellipsis), step badge pill stays on the right. No more wasted
   real estate on visuals nobody looks at twice.

Extra polish while in there:
   - Section headers (Tablet + Manager) now have a coloured icon badge
     — a rounded square 36×36 with tinted background + accent-coloured
     icon next to the H3 title. Adds visual weight without noise.
   - Panel head gets a 1px bottom divider.
   - Manager panels tint the icon badge per panel tone (amber for
     Unassigned, green for In Progress, blue for Team).
   - Header action buttons (Tablet scan/picker, Manager refresh/mode)
     get proper borders + hover state.
   - State dividers on bake/gate/hold rows preserved as inset shadows.

Verified: bundle rebuilt at /web/assets/278b43c/web.assets_backend.min.css
(1.45MB, id 1930). All key classes present: o_fp_drop_target,
o_fp_dragging, o_fp_po_parts_bar, o_fp_po_parts_fill, section-header
icon badges. Zero SCSS warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:01:04 -04:00
gsinghpal
067d1f01c8 redesign(shopfloor): clean slate — depth by shadow, no card borders
User feedback: the previous gradient-heavy look felt cluttered, job
cards had confusing heavy borders, the hierarchy was noisy. Wiped all
three SCSS files and both OWL templates and rebuilt from scratch with
a clean minimalist design language.

Design philosophy — the single source of truth:

  * NO borders on cards — depth comes from elevation (shadow) + a
    tiny surface-tint difference between page and card
  * ONE accent colour (var(--o-action)); semantic red/amber/green only
    for status pills and state bars
  * Shadow-only cards: $fp-elev-1, $fp-elev-2, $fp-elev-3 built on
    color-mix of foreground so they adapt to dark mode automatically
  * Generous whitespace, 8pt spacing scale ($fp-space-1 through
    $fp-space-10)
  * Type-first hierarchy: 32px page titles, 44px KPI numbers, tabular
    numerics so refreshing counts don't jitter
  * Priority/state cues via narrow 4-6px coloured bars and small dots
    — never via loud backgrounds or gradient washes
  * All interactive elements at 48px touch minimum (shop-floor gloves)

New token file (_fp_shopfloor_tokens.scss) exports:
  - $fp-space-1..10, $fp-radius-sm..xl, $fp-radius-pill
  - $fp-page / $fp-card / $fp-card-soft surface tints
  - $fp-ink / $fp-ink-soft / $fp-ink-mute / $fp-ink-faint text tiers
  - $fp-elev-1..3 layered shadows
  - $fp-text-xs..3xl type scale
  - @mixin fp-pill, fp-focus-ring, fp-card, fp-hover-only
  - fp-wash() function for state-coloured soft backgrounds

Tablet Station (fusion_plating_shopfloor.scss + shopfloor_tablet.xml):
  - Clean hero: just the title, station chip, picker + scan button
  - KPI cards: no gradient overlay, just a 10px coloured dot and big
    44px number. Hover lifts with shadow
  - Active WO: soft green wash background, no border, pulsing dot
  - Panels contain queue/baths/bakes/gates/holds — all on the same
    card surface with big rounded corners, no internal borders
  - Queue rows: flat on a soft page-tinted background, hover slides
    right 2px (no lift, cleaner)
  - Bake/Gate/Hold rows: state-coloured inset shadow as a 4px stripe,
    no border
  - Empty states: centred with a 44px muted icon and friendly copy

Manager Desk (manager_dashboard.scss + manager_dashboard.xml):
  - Matching hero with live dot that calmly pulses green during a fetch
  - 4 KPI cards in the same language as the tablet
  - Three panels (Unassigned / In Progress / Team) with coloured dots
    next to their titles instead of top accent bars
  - MO cards NO borders, subtle page-tint background, 4px left stripe
    only for priority (red HOT, amber Urgent)
  - Team cards: avatar + name + live load pill, hover slides right
  - WO expanded rows use card-soft buttons/dropdowns for low contrast

Plant Overview (plant_overview.scss):
  - Columns are now shadow-lifted cards on the tinted page background
  - Kanban cards: no border, small shadow, lift on hover
  - Priority stripe is an inset box-shadow (not a border) so hover
    transform doesn't wobble

Backend contract preserved — OWL class names, prop signatures, RPC
endpoints, and stateBadge mapping all unchanged. Only visuals.

Verified:
  * Bundle compiled to /web/assets/.../web.assets_backend.min.css
    (1.45MB, id 1926)
  * All 6 new classes present in compiled CSS
  * Zero SCSS "forbidden import" warnings
  * Zero Odoo module upgrade errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:45:16 -04:00
gsinghpal
6d1efc6c43 fix(shopfloor): register tokens SCSS in bundle, drop forbidden @import
Odoo 19 forbids local SCSS @import statements for security reasons and
silently falls back to the OLD cached CSS bundle when it sees them. My
redesign commit used:

    @import "./fp_shopfloor_tokens";

in three SCSS files. Odoo logged

    WARNING Local import './fp_shopfloor_tokens' is forbidden for
    security reasons. Please remove all @import {your_file} imports
    in your custom files.

...and the compiled bundle kept rendering the old look. That's what
the user saw.

Fix:
  1. Add _fp_shopfloor_tokens.scss as the FIRST entry in
     web.assets_backend in the manifest. Odoo concatenates the bundle
     in order, so variables/mixins in the first file are visible to
     every later file — native @import is not needed.
  2. Strip the @import "./fp_shopfloor_tokens"; line from all three
     consumer files (tablet, manager, plant overview).

Verified: asset bundle regenerated to /web/assets/.../web.assets_backend.min.css
(1.45 MB). Grepped the compiled CSS and all five new classes are present:
o_fp_tablet_header, o_fp_kpi_strip, o_fp_mgr_card, o_fp_live_dot,
o_fp_panel_unassigned. 8 radial-gradients baked in. Zero warnings in
the Odoo server log post-rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:35:00 -04:00
gsinghpal
298f5942eb refactor(shopfloor): modern redesign w/ gradients, theme-safe tokens
Shop-floor operators and managers live on these screens all day — the
old look worked but felt like a spec sheet. Pass through all 5 pages
with one design language: gradient KPI cards, hero banners, soft
shadows, rounded corners, large touch targets, friendly empty states.
Dark mode and light mode both look deliberate, not inverted.

New shared file: _fp_shopfloor_tokens.scss

  A single source of truth for radii, elevation (shadows that respect
  dark mode via color-mix on foreground), typography scale (tabular
  numerics for KPIs, 18px base for shop-floor readability), animation
  easings, semantic gradients (@mixin fp-grad), tone helpers
  (@mixin fp-tone), focus ring, and the 44px touch-min token.
  Every other SCSS file imports this — no duplicated colour math.

  Gradients are built on color-mix(in srgb, var(--bs-foo) X%, transparent)
  so they layer naturally on either the light or dark page background.
  No @media-prefers-color-scheme forks needed.

Tablet Station (fusion_plating_shopfloor.scss):

  * Hero banner with dual radial-gradient wash (brand + success), live
    station chip, gradient focus ring on the picker.
  * KPI cards (6-up on desktop, 2x3 on phone) get a subtle top accent
    line, coloured gradient overlay, 40px headline number, faded icon
    at corner. Tone variants (info/success/warning/danger/muted) drive
    colour without extra CSS.
  * Active WO banner is a green gradient pill with a breathing-dot
    pulse — unmissable when something is running.
  * Panels get top accents, queue rows get priority pills (HI/M/·),
    bake/gate/hold rows get colour-coded left accent bars.
  * Tiles have a 4px left stripe keyed to state + hover lift.
  * Status chips are uppercase, pill-shaped, tone-tinted with
    color-mix so they respect theme.
  * Empty states now have a large 36px icon + friendly copy instead
    of a one-liner.
  * Focus rings use the shared @mixin fp-focus-ring.

Manager Desk (manager_dashboard.scss):

  * Same hero treatment with radial gradient + live-dot pulse.
  * 3 panels carry a coloured top accent bar — amber (Unassigned),
    green (In Progress), blue (Team). Instant visual routing.
  * KPI strip matches tablet.
  * MO cards get a left priority stripe (red for HOT, amber for
    Urgent), lift on hover, expand cleanly.
  * Team avatars get a border + subtle tint background for depth.
  * Worker/tank pickers have custom focus rings.

Plant Overview (plant_overview.scss):

  * Header is now a gradient wash tied to the brand colour.
  * Work-centre columns get a thin gradient top-stripe and pill-style
    count badges.
  * Cards have real depth (layered shadow), lift harder on hover,
    change border colour on hover.

All three files share the same design tokens, so colours/shadows/
radii are identical across pages. Edit one place, everything updates.

Verified: backend asset bundle compiles clean (no SCSS errors), zero
warnings on module upgrade, asset cache cleared for fresh delivery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:30:47 -04:00
gsinghpal
ae03e32b5d style(shopfloor): phone + iPad responsive across all 5 pages
Shop-floor workers and managers use phones and iPads on the line.
The existing layouts only stacked at 1100px / 1280px, which left
everything cramped on a 375px iPhone or 390px Android. Pass through
all 5 shop-floor screens with disciplined breakpoints and touch-first
sizing.

Breakpoint ladder (consistent across files):
  1400px : manager WO row: worker/tank pickers drop to their own rows
  1280px : manager grid 3 → 2 columns, Team spans both
   1100px : tablet dashboard 2 → 1 column
    900px : manager grid → 1 column; tablet + manager padding shrinks
    768px : plant overview columns stack; first-piece & bake kanbans
            already handled natively by Odoo
    600px : PHONE — all columns stack, everything full-width, every
            button min-height 44px (Apple HIG touch target), font
            shrinks for denser phone screens

Manager Desk (manager_dashboard.scss):
  - Header stacks into two full-width rows on phone, action buttons
    flex-grow to share the row
  - 3 column grid stacks earlier (900px instead of 800px) so iPad
    portrait gets a clean single-column view
  - WO rows: assign/tank pickers go full-width on their own rows at
    1400px, then the whole row stacks to 1 column at 600px
  - Cards min 56px tap zone
  - Team avatars keep their layout but cap gap on phone

Tablet Station (fusion_plating_shopfloor.scss):
  - Header: picker/scan button stack full-width on phone
  - KPI strip auto-fit by default, forced 2×3 grid on phone so 6
    tiles stay visible without scrolling past a wall of tall cards
  - Queue rows: Start/Finish buttons drop to their own row on phone,
    each flexing to 50% width → easy one-thumb tap
  - Bake/Gate/Hold rows: full stack on phone, action buttons flex-grow
  - Bath tile grid: 2-up on phone (not auto-fit)
  - Active WO banner stacks, Open-WO button full-width
  - Station picker and scan input go full-width

Plant Overview (plant_overview.scss):
  - Columns stack at 768px (already there) + 600px padding shrink,
    search input full-width, header wraps sensibly
  - Cards get min-height 64px for touch

Touch-device hover suppression:
  @media (hover: none) — hover highlights were sticking after tap on
  phones/iPads. Block them for .o_fp_queue_row, .o_fp_tile,
  .o_fp_tablet_card, .o_fp_mgr_card, .o_fp_team_card, .o_fp_po_card.

Asset cache cleared so phones pick up the new SCSS on next load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:16:35 -04:00
gsinghpal
d29857078a fix(manager-desk): unstick the spinner + live updates that don't flash
Root cause of the stuck "Loading manager data..." spinner: the overview
endpoint included a search_count on sale.order.x_fc_workflow_stage,
which is a non-stored computed field. Odoo 19 raised:

  ValueError: Cannot convert sale.order.x_fc_workflow_stage to SQL
              because it is not stored

The controller silently logged the error; the JS caught and swallowed
the RPC failure, leaving state.overview=null forever. So the UI just
kept spinning while production changed around the manager.

Fixes:

1. Controller (manager_controller.py)
   - "Awaiting assignment SOs" is now computed from STORED fields only:
       state='sale' AND x_fc_receiving_status='inspected'
             AND x_fc_assigned_manager_id=False
     Same stage, legal SQL.
   - Whole endpoint wrapped in try/except; failures return
     {'ok': False, 'error': '...'} so the UI can surface them instead
     of dying silently.
   - Response carries a payload_hash (md5 of the JSON body minus
     user_name). If the client sends back known_hash and nothing has
     moved, the server returns {'unchanged': True, 'payload_hash': ...}
     and the client skips the repaint entirely. Keeps the UI quiet
     between polls.

2. OWL component (manager_dashboard.js)
   - Poll cadence tightened from 30s → 8s (production-pace).
   - Unchanged payloads don't mutate state.overview → no re-render,
     no flash. Live dot just updates its tooltip.
   - Changed payloads do an in-place MERGE of the overview (copying
     scalars/arrays onto the existing reactive object) instead of
     replacing it wholesale. OWL's diff only re-renders rows that
     actually moved.
   - isFetching guard so overlapping polls can't stack up.
   - state.loadError surfaces backend errors in a red banner with a
     Retry button — no more silent spinner.

3. UX
   - Live dot next to the title: soft green at rest, bright green
     pulsing during a fetch.
   - "Updated Xs ago" subtitle uses a getter so the label freshens
     between polls.
   - Manual Refresh button next to Quick/Detailed toggle.
   - Spinner only appears on the genuine first load; gone forever
     once the first payload lands.

Verified: the old crashing query now runs clean on demo data; odoo
logs show zero errors for the last 5 minutes of polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:06:04 -04:00
gsinghpal
a660f1f05d fix(configurator): part-level saved descriptions (not generic)
The earlier description templates were global — same 8 generic texts
applied to any part. That's useless when a customer has 3,500 parts
and each part has 3–5 canned variants (standard, masked threads,
masked bore, rework, rush packaging). That's ~17,500 rows total, and
the variants ONLY make sense in the context of a specific part number.

Restructured so descriptions live on each part:

Model changes:
  fp.sale.description.template.part_catalog_id (new M2O, indexed,
    ondelete cascade) — the primary scoping field
  fp.sale.description.template.partner_id — now a related store=True
    field pulled from the part, so customer-level search still works
  fp.part.catalog.description_template_ids (new O2M inverse) — the
    5–10 canned descriptions attached to this specific part
  fp.part.catalog.description_template_count (computed)

UI changes:
  Part Catalog form: new "Descriptions" notebook page with inline
    editable list (sequence + name + tag + description + usage_count).
    5 variants take 30 seconds to enter.
  Part Catalog form: new smart button "Descriptions" showing the count,
    jumps to the full list filtered by this part.
  Template list view: part_catalog_id column added, list ordered by
    part first. Search view adds Part filter + Part-Specific /
    Generic (No Part) filters + Group By Part.

Wizard changes:
  description_template_id domain now prioritises part-specific, falls
    through to partner, coating, or generic on a single dynamic domain.
  _onchange_suggest_template priority: part → customer → coating →
    none. No longer auto-picks a random global template when a part
    has its own.

Smoke-tested on VS-HSA201-B (Amphenol):
  5 canned variants seeded on the part form
  Wizard with this part auto-suggested the lowest-sequence one
  The part's Descriptions smart button shows "5"

Bulk data entry path for the client's 3,500 parts: either use the
inline list on each part form, or import via CSV with the new
part_catalog_id column (external_id or DB id).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:46:53 -04:00
gsinghpal
f340c87b6a feat(bridge_mrp): shop-role auto-routing + tablet worker mode (CHUNK 4/4)
Completes the worker-access story. Handoffs now route themselves.

New model fp.work.role with 8 seeded defaults (noupdate so shops can
rename/prune):
  masking · racking · plating_op · demask · oven · derack ·
  inspection · rework

Each one has a code, icon, description, sequence, active flag.
Config menu: Configuration → Shop Roles (manager-only).

Field additions:
  hr.employee.x_fc_work_role_ids (Many2many) — tag workers with the
    roles they perform. One-person shop: one employee, every role.
    Specialised shop: one role per employee. Cross-trained: multiple.
  fusion.plating.process.node.x_fc_work_role_id (Many2one) — tag
    each recipe operation with the role that performs it.
  mrp.workorder.x_fc_work_role_id (Many2one) — copied from the recipe
    operation on WO generation.

Auto-assignment on WO generation:
  _generate_workorders_from_recipe() now copies the operation's role
  onto the WO, then calls _fp_pick_worker_for_role() which picks the
  least-loaded employee (active WO count) with that role. WO lands in
  their Tablet "My Queue" the moment the MO is confirmed. No manual
  routing needed for the common case.

Tablet Station — worker mode:
  /fp/shopfloor/tablet_overview now filters to WOs where
  x_fc_assigned_user_id == env.user when the field is populated.
  KPIs (WOs Ready / In Progress) reflect the logged-in worker's load,
  not shop-wide totals. "My Queue" rows carry wo_state + can_start +
  can_finish so inline Start/Finish buttons appear.
  New JS handlers onStartWo / onFinishWo call /fp/shopfloor/start_wo
  and /fp/shopfloor/stop_wo (finish=true). One-tap progression.

Views:
  hr.employee form gets a "Shop Roles" notebook page with many2many_tags.
  Process node form gets x_fc_work_role_id inline after work_center_id.
  Work Order form shows role + assigned worker.

Smoke-tested end-to-end on WH/MO/00010:
  Masking      → Administrator (masking role)
  Racking      → Administrator (racking role)
  E-Nickel     → Andrew (plating_op, least-loaded tiebreaker)
  Demask       → Administrator (masking)
  Oven bake    → Andrew (oven)
  Derack       → Administrator (racking fallback)
  Post-plate QA → Administrator (inspection)

80 existing WOs backfilled with role + worker via name-match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:08:23 -04:00
gsinghpal
1c6a460ca1 feat(shopfloor): Manager Desk — assign workers, swap tanks, take over (CHUNK 2/4)
New client action "Manager Desk" under Shop Floor menu (manager-only).
Three-column dashboard designed for the shop manager's daily job:

  Column 1 — Needs a Worker
    MOs with active WOs missing user assignment. Each card expands to
    show per-WO rows with:
      - Assign Worker dropdown (pulls from group_fusion_plating_operator)
      - Tank swap dropdown (all tanks with current bath)
      - Take Over (claims for the manager in one click)
      - Open (jump to WO form)

  Column 2 — In Progress
    MOs with workers actively running WOs. Shows who's on each step,
    lets manager reassign or take over if someone steps away.

  Column 3 — Team
    Avatar grid of operators with live queue + in-progress counts.
    Click to drill into that operator's full WO list.

KPI strip on top: Unassigned WOs, In Progress, Ready to Ship, Awaiting
Assignment SOs.

Quick / Detailed view toggle — Detailed auto-expands every card body.

New field mrp.workorder.x_fc_assigned_user_id (indexed, tracked) —
the worker currently owning this step. Will be the pivot the Tablet
Station filters on in Chunk 4.

Three new endpoints:
  /fp/manager/overview       — dashboard snapshot (30s auto-refresh)
  /fp/manager/assign_worker  — set user on a WO
  /fp/manager/assign_tank    — swap tank on a WO
  /fp/manager/take_over      — manager claims the WO (no-show coverage)

Controller is graceful when mrp module isn't installed (empty overview,
no crash) and when the bridge_mrp assignment field isn't present (falls
back to showing all active WOs as "unassigned").

Verified: 4 WOs assigned across 2 users, overview queries return the
expected counts, res.groups.user_ids (Odoo 19 API, not deprecated .users).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:03:01 -04:00
gsinghpal
095d9f487c feat(bridge_mrp): SO workflow stage + contextual buttons (CHUNK 3/4)
Sale Order form now guides the user through the next step without
making them navigate between screens.

New computed field sale.order.x_fc_workflow_stage with 12 stages:
  draft → awaiting_parts → inspecting → accept_parts → assign_work
       → in_production → ready_to_ship → shipped → invoicing
       → paid → complete (+ cancelled)

Driven by SO.state + x_fc_receiving_status + MO state +
delivery.state + invoice payment state.

Five contextual header buttons (only 1-2 visible at any time,
fusion_claims pattern — invisible="x_fc_workflow_stage != 'foo'"):

  Mark Inspecting       → flips receiving to 'inspecting'
  Accept Parts          → flips receiving to 'accepted' + SO status
                          to 'inspected', unlocks manager assignment
  Assign To Me & Release → manager claims the job, confirms all draft
                          MOs (which auto-generates WOs already)
  Open Shop Floor       → jumps to Plant Overview during production
  Mark Shipped          → closes open delivery records → triggers
                          auto-invoice per strategy

Info banner shows current stage + assigned manager on the sheet so
users always know where they are.

New fields:
  sale.order.x_fc_assigned_manager_id (Many2one res.users, tracked)
  mrp.production.x_fc_assigned_manager_id (Many2one, propagated on
                                           MO confirm)

MO.action_confirm() now pulls the assigned manager from the SO (or
falls back to SO.user_id) and sets it on the MO — sets up the
Manager Dashboard (chunk 2) and role-based assignment (chunk 4) to
filter "my jobs" cleanly.

Smoke-tested across 10 demo SOs — stages compute correctly:
  S00028 → ready_to_ship, S00027-25 → awaiting_parts,
  S00023-20 → complete/shipped, S00029 → draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:57:41 -04:00
gsinghpal
28dd7fdd76 feat(certificates): per-customer document preferences (CHUNK 1/4)
Customers can now pick which shipping-time documents they actually want
instead of the shop remembering it per account. Four booleans on
res.partner (only shown on companies, not contacts):

  x_fc_send_coc              (default True)  Certificate of Conformance
  x_fc_send_thickness_report (default True)  Thickness readings
  x_fc_send_packing_slip     (default True)  Packing slip PDF
  x_fc_send_bol              (default False) Bill of Lading

Surfaced in a "Plating Documents" page on the customer form.

Two downstream gates:

1. fp.notification.template._collect_attachments() now reads the flags
   when attaching CoC / thickness / packing / BoL PDFs to the shipping
   confirmation email. Flags missing on the partner (e.g. legacy
   customers) fall back to the original defaults so nothing regresses.

2. mrp.production.button_mark_done() only auto-creates the quality
   documents the customer wants. A customer that unchecks both CoC and
   thickness gets zero certs auto-generated — shop can still create
   them manually if needed.

Note: today a standalone thickness-only report template doesn't exist,
so when a customer asks for thickness only (CoC off, thickness on) the
dispatcher still attaches the CoC PDF (which carries thickness data)
but with CoC creation gated off. A dedicated thickness-only template
is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:54:54 -04:00
gsinghpal
f94be9dfa9 fix(part-catalog): upload slot + swapped Number/Name + smart buttons
Three fixes on fp.part.catalog form:

1. 3D Model upload actually works now. The old field exposed only a
   Many2one search dropdown — no way to add a new file. Added a
   Binary upload slot (model_upload + model_upload_filename) that
   fires an onchange which wraps the bytes in an ir.attachment and
   links it to model_attachment_id. The upload slot is hidden once a
   model is already attached, so the current file stays visible.
   Accepts STEP/STP/STL/IGES/IGS/BREP. Auto-runs the surface-area
   calculation after attach, same as before.

2. Part Number is now the big <h1> title, Part Name is the smaller
   field underneath. Matches how plating shops actually identify
   parts (by customer part number, not a free-text name). Swapped
   column order in the list view too — Part Number first, then Name.

3. Four smart buttons now on the part form:
     - Customer  → opens res.partner record
     - Sale Orders  (already existed)
     - Work Orders  → filtered mrp.workorder list across SOs for this part
     - Quotes  (already existed)
     - Revisions  → shown only when 2+ revs exist, opens the revision
       tree filtered by root part
   New compute fields workorder_count + revision_count feed the
   statinfo widgets, with matching action_view_customer,
   action_view_workorders, action_view_revisions handlers.

Verified on demo data:
  VS-ESMC6H00801P01 → SO=2, WO=18, REV=2
  VS-PQR8440        → SO=1, WO=9,  REV=3
  All counts light up, buttons drill in cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:00:52 -04:00
gsinghpal
70fe10c214 fix(configurator): money fields now show $ everywhere
Root cause: pricing.rule records had currency_id=NULL because the
default=lambda only applies on new records. Monetary fields without a
currency silently render as plain numbers — no $ symbol.

Fixes:

1. currency_id now required=True on fp.pricing.rule, fp.treatment,
   fp.customer.price.list, fp.quote.configurator, fusion.plating.quote.request
   — so it can never be missing going forward.

2. post_init_hook + matching backfill helper in
   fusion_plating_configurator/__init__.py pins the company currency
   on any existing records that were created before the required flag.
   Ran on upgrade → all 4 pricing.rule rows now have CAD/$.

3. Flipped two remaining Float money fields to Monetary:
   - fp.job.consumption.unit_cost and total_cost (were Float digits=4/2)
   - (mrp.workorder.x_fc_workcenter_cost_hour stays Float — it is a
     related field from core mrp.workcenter.costs_hour which is Float)

4. Every Monetary field reference in views now has explicit:
     widget="monetary" options="{'currency_field': 'currency_id'}"
   Previously Odoo's default rendering dropped the $ in some contexts.
   Touched: fp_pricing_rule_views (list + form), fp_treatment_views,
   fp_customer_price_list_views (already done), fp_quote_configurator_views
   (list + form shipping/delivery/calculated/override), fp_quote_request_views
   (list + form), fp_job_consumption_views, mrp_production_views job-costing
   group, direct-order wizard (already done earlier).

5. Unit / % suffix polish as we went: rush_surcharge_percent shows "%",
   default_duration_minutes shows "min" on treatment form, treatment list
   labels duration column.

Verified: all 4 pricing rules now render "$0.45", "$0.85" etc; 62 records
across 6 models all have currency_id populated; zero remaining Float $
fields in the codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:54:14 -04:00
gsinghpal
b85642816f feat(configurator): menu reorder, currency/unit display polish, line description templates
Three UX improvements:

1. Sales menu reordered — New Quote (seq 1) is now the first entry,
   followed by New Direct Order (5), Quotations (10), Sale Orders (20).
   "New Quote" moved out of Configurator submenu into Sales so both
   quote-creation paths live side-by-side.

2. Currency + unit display audit:
   - fp.customer.price.list.unit_price flipped from Float to Monetary
     with currency_field='currency_id' — list view now shows $ symbol
     and a Total sum row
   - fp.direct.order.wizard.unit_price flipped to Monetary, added
     currency_id field and computed line_subtotal ($)
   - % suffix appended to deposit_percent and progress_initial_percent
     in the wizard
   - Unit suffixes added where missing: bake_window.quantity (pcs),
     window_hours (h), bake_temp (°F), bake_duration_hours (h);
     bath.volume (L), bath.mto_count (turnovers); tank.volume shows
     volume_uom inline

3. Saved line descriptions (new feature):
   - New model fp.sale.description.template with name, description,
     tag (standard/masking/rework/aerospace/nuclear/packaging/other),
     optional coating_config_id and partner_id, usage_count bumped
     on each use
   - List + form + search views; new "Line Descriptions" menu under
     Configurator
   - 8 starter templates seeded (noupdate=1): ENP Standard/Aerospace/
     Nuclear, masking variants, rework, packaging, delicate handling
   - Direct Order Wizard gets a template picker (searchable Many2one)
     + editable paragraph; picking a template copies text to the
     editable field, user tweaks freely, tweaked text lands on the
     SO line as "<header>\n\n<description>"
   - Auto-suggests template on coating+partner match if nothing
     picked yet

Smoke-tested end-to-end: picked aerospace template, tweaked text,
confirmed wizard → SO S00030 has full description on line, usage
counter bumped from 0 to 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:43:58 -04:00
gsinghpal
b09538b4e2 changes 2026-04-17 17:31:12 -04:00
gsinghpal
e07002d550 feat(shopfloor): rich Tablet Station dashboard + full shop-floor demo data
Tablet Station rebuilt as a live dashboard (not just a QR scanner):

  * KPI strip — WOs Ready/Progress, Awaiting/Missed bakes,
    First-Piece pending, Quality Holds (each tinted by state)
  * Active WO banner with pulsing indicator when a WO is running
  * My Queue panel (left) — priority-badged operator next-up list,
    clickable rows that jump to the WO/bake/gate form
  * Baths tile grid (right) — last-log status chips, MTO count,
    hover jump to chemistry log
  * Bake Windows list — inline Start/End/Open actions, colour-coded
    by state (awaiting / in-progress / missed)
  * First-Piece Gates — Pass/Fail buttons for pending inspections
  * Quality Holds — Review jump when any open holds exist
  * Station picker + scan drawer (collapsed by default)
  * 30s auto-refresh, persists picked station in localStorage

New controller endpoints: /fp/shopfloor/tablet_overview,
/fp/shopfloor/pair_station, /fp/shopfloor/mark_gate.

Demo seeder (Phase 12.5) now populates:
  * 5 shop-floor stations (Plating, Bake, Inspection, Shipping, Receiving)
  * +3 bake windows (awaiting / in-progress / near-due)
  * 4 first-piece gates (1 pending, 1 passed+released, 1 passed-holding, 1 failed)
  * 2 quality holds on active MOs (one on_hold, one under_review)

All four Shop Floor menu pages (Plant Overview, Tablet Station, Bake
Windows, First-Piece Gates) now have meaningful content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 07:43:10 -04:00
gsinghpal
3b5b5cbf7c feat(reports): centralised Job Traveller / Shop Router
One PDF that follows a job through the shop — prints from either the
Sale Order or the Manufacturing Order. Matches existing design language
(fp_landscape_styles, .fp-header-primary banners, bordered tables,
.sig-line for sign-off, .highlight-box for callouts).

Sections per traveller:
  1. Title bar with REWORK / RUSH ORDER badges
  2. Job header — customer, PO #, part #, coating, recipe, facility,
     qty, dates, current parts location
  3. Receiving summary — received qty, state, damage flag
  4. Process Routing table — one row per WO with step #, operation,
     work centre, bath, tank, target thickness, dwell, expected
     duration, + sign-off columns (operator, date/time, initials,
     qty pass/reject)
  5. Bath chemistry targets snapshot per bath used
  6. Quality holds — red callout only when present
  7. Certificates issued + Delivery info (side-by-side)
  8. Rework reason block (only on rework MOs)
  9. Ruled notes / exceptions area
  10. Final supervisor + QA sign-off

Four ir.actions.report entries registered:
  - Job Traveller (Landscape) on mrp.production  [default print]
  - Job Traveller (Portrait)  on mrp.production
  - Job Traveller (Landscape) on sale.order      [iterates MOs]
  - Job Traveller (Portrait)  on sale.order

Regression-tested all 15 existing reports (SO, WO, MO margin, invoice,
BoL, CoC EN, receipt) — every one still renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:48:03 -04:00
gsinghpal
adc27c637a feat(bridge_mrp): SO smart buttons for full production lifecycle
Sale Order form now hubs the full flow — Manufacturing, Work Orders,
Portal Jobs, Quality Holds, Certificates, Deliveries — hidden when
count == 0. Clicking each jumps to the filtered list/form so users
can drill in without leaving the SO.

Counts are computed on the fly from: mrp.production.origin == SO.name,
production.workorder_ids, production.x_fc_portal_job_id, quality.hold
production_id, fp.certificate.sale_order_id, fp.delivery.job_ref.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:33:21 -04:00
gsinghpal
838b41cb89 fix(bridge_mrp): WO recipe generator + demo work-order backfill
bridge_mrp._generate_workorders_from_recipe was writing 'description'
on mrp.workorder, which doesn't exist in Odoo 19 — instead the step
instructions now post to each WO's chatter after bulk create, which
is where the operator sees them anyway.

Demo seeder now creates the full WO chain:
- 9 MRP work centres paired with 9 FP work centres (FP-QUEUE, -RACK,
  -MASK, -EN, -BAKE, -INSP, -DERACK, -DEMASK, -POSTBAKE) with
  costs_hour set so actuals-vs-quoted margin can compute.
- Wires the existing ENP-ALUM-BASIC recipe's 9 operation nodes to
  those FP work centres by matching names.
- Links every coating config to the recipe so the auto-assign hook
  (mrp.production.action_confirm → _auto_assign_recipe_from_so) has
  something to pull.
- Backfills work orders on all existing demo MOs: calls the generator
  once recipe is set. For historical (done) MOs, marks all their WOs
  done with backdated durations (25-90 min). For the Cyclone active
  MO, sets a realistic progression: first WO done, second in progress
  (priority: Hot), rest in 'ready'.

Verified: 90 WOs live, 10 per work centre. One MO shows the full
progression state mix. WO Traveller PDF renders (132KB) — both
portrait + landscape variants still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:18:08 -04:00
gsinghpal
cb79186325 fix(coc): customer logo in 3rd column of customer block (not separate row)
Moved doc.partner_id.image_1920 from a standalone right-aligned div
below the accreditation table to a third column (20% width, centre-
aligned) of the customer-info table — sits inline with Customer
Address (40%) and Contact Name/Email/Phone (40%). The customer
block is now a single bordered 3-column row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:07:03 -04:00
gsinghpal
edd52f16a7 fix(coc): bump top padding to 50mm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:04:52 -04:00
gsinghpal
22b06f47d9 fix(coc): bump top padding to 36mm to fully clear external_layout header
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:03:40 -04:00
gsinghpal
71bd0da5e1 fix(coc): add 18mm top padding so title clears external_layout header
Body was overlapping the company letterhead band — added padding-top
to .fp-coc so the title starts below it cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:02:09 -04:00
gsinghpal
44a980c468 refactor(coc): use web.external_layout for header/footer + 3-column bordered accreditations
Per feedback, dropped the custom company-contact header and paperformat
in favour of Odoo's standard web.external_layout. This gives the CoC:
  - Company-branded header (logo, name, address, phone, email, tax id)
    matching whichever layout variant the company picked in
    Settings → General → Document Layout (Standard / Boxed / Clean /
    Striped). Automatically themed with company.primary_color.
  - Consistent page X/Y footer + "Printed on" timestamp.
  - Correct header_spacing so the letterhead band lines up with the
    default paperformat.

Our body now owns:
  - Centred "Certificate of Conformance" / "Certificat de Conformité"
  - 3-column bordered accreditation table — one logo per cell (Nadcap,
    AS9100D/ISO 9001, CGP) with equal 33.33% widths and #000 borders,
    2.8cm cell height so logos centre vertically
  - Optional customer logo (res.partner.image_1920) right-aligned
    below the accreditation row
  - Customer info block (name, address, contact, email, phone)
  - Certification info table (date, generated-by, WO#)
  - Quantities table (part, process, PO, shipped, NC qty, job no)
  - Signature image + bordered cert statement
  - "Fusion Plating by Nexa Systems" brand note

Template plumbing:
- Explicit `<t t-set="company" t-value="doc.sale_order_id.company_id
  or doc.production_id.company_id or env.company"/>` in the EN/FR
  wrappers because QWeb's t-call scoping doesn't expose variables set
  inside external_layout to the body we pass through. Without this,
  coc_body's `company.x_fc_owner_user_id` raises KeyError.
- Removed paperformat_fp_coc from the report actions (now uses the
  default paperformat, which is designed for external_layout's
  reserved header_spacing).

Verified: 332KB PDF, 1 page, all 5 images embedded, Amphenol logo on
right side of accreditation row, signature renders, company header
band at top, page footer at bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:59:54 -04:00
gsinghpal
66f7f6c644 fix(coc): single-page layout — custom paperformat + strip Odoo wrappers
The CoC was rendering on 2 pages with ~35mm of dead whitespace at the
top. Three compounding causes:

1. Default Odoo paperformat reserves header_spacing=35mm (where the
   standard letterhead would sit when using web.external_layout). Our
   CoC has its own full-bleed header so that reservation was pure
   empty space.
   → New paperformat_fp_coc with header_spacing=0, 8mm all-around
     margins, attached to both report_coc_en and report_coc_fr actions.

2. The `<div class="article o_report_layout_boxed">` and nested
   `<div class="page">` wrappers inherited Odoo's CSS which applies
   `page-break-after: always` on `.page` and additional padding on
   `.article`.
   → Dropped both wrappers — template now renders body directly
     inside html_container.

3. Inline style block didn't override Odoo's body/main padding.
   → Aggressive !important reset at the top of the style block on
     html, body, main, .article, .page, and the hidden header/footer
     classes. Also shrunk all paddings by ~30% and bumped base font
     to 9pt to guarantee single-page fit.

Verified: PDF is now 1 page, content starts at the top (title flush
with top margin), accreditation logos + customer logo + signature all
render correctly within the single page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:50:35 -04:00
gsinghpal
96ecf7a9e1 feat(coc): professional CoC with accreditation badges + signature + company branding
Problem: the rebuilt CoC rendered mostly empty because accreditation logos
had to be uploaded manually via Settings first, and no signature existed —
looked unprofessional next to the Steelhead reference.

Fix:
- Seeder now auto-generates clean text-based accreditation badges with PIL
  (Nadcap blue, AS9100D/ISO 9001 blue, CGP red) sized to match the
  reference layout. Client can swap in real trademarked logos via Settings
  → Fusion Plating → Accreditation Logos at any time.
- Seeder creates a demo "Kris Pathinather" user, sets them as the
  certificate owner on res.company, and renders a scripted-looking
  signature image that matches the printed name on the cert.
- Seeder uploads a generated "Amphenol Canada Corp." badge to Amphenol's
  res.partner.image_1920 so that customer's CoCs include their logo
  on the top-right corner (mirrors how the reference shows it).
- coc_body template: guard hr.employee.signature access with a field-
  exists check (the field is provided by an optional module not
  installed on every Odoo).
- CoC uses web.html_container directly instead of wrapping in
  web.basic_layout — the outer wrapper was injecting top padding that
  pushed the title ~25% down the page. Now starts cleanly at the top.
- Tightened CoC CSS: removed unused label classes, added @page margin
  directive, fixed vertical-align on header cells so logos and company
  contact stay middle-aligned regardless of row height.
- Invoice PDF PAID stamp now also triggers on payment_state =
  'in_payment', so historical demo invoices look paid without needing
  full bank reconciliation.

Verified: renders a 152KB PDF with 5 embedded images, signer name
matches signature, all accreditation badges visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:42:35 -04:00
gsinghpal
fbaf318832 chore(fusion_plating): add story-driven demo seeder + polish invoice PAID stamp
Demo seeder (scripts/fp_demo_seed.py):
- Idempotent Python script run via odoo shell; populates ~60 records
  across 6 customer stories covering every workflow state for live demo
- Customers: Amphenol (net-terms, deep history), Magellan (progress
  billing, active), Cyclone (deposit, in-production), Honeywell
  (net-terms, just confirmed), Westin (COD, direct-order path),
  Delinquent Industries (account hold — Confirm raises UserError)
- Coating configs with realistic AMS specs (2404, 2700 Rev G, 2406)
  and bake-relief flags set on applicable processes
- Part catalog with revision chains (Rev 1 / Rev 2 / Rev 3 for hot parts)
- Customer price lists with volume tiers
- Per-customer invoice strategy defaults
- Bath chemistry logs (15 readings, last 2 OOS → pending replenishment
  suggestion visible in menu)
- Racks: 4 active + 1 needing strip (MTO 3.2 / 3.0) for kanban demo
- Bake windows: 1 awaiting (ticking down), 1 baked, 1 missed (alert)
- Quote configurator sessions: 3 draft, 3 confirmed/won, 3 lost (with
  reasons), 1 expired — populates the win/loss analysis
- Historical closed orders: 8 jobs backdated across 4 months with
  SO → MO → Delivery → Invoice → Payment run through each hook so
  portal-job progression, certificates with thickness readings, and
  invoice AR aging all look real
- Active orders at every workflow stage for the live demo cycle

Polish:
- report_fp_invoice PAID stamp now also triggers on payment_state ==
  'in_payment' (in addition to 'paid'). Odoo leaves payments in
  'in_payment' until the bank reconciliation job matches them against
  a statement line, so historical demo invoices would otherwise never
  show as stamped even though the payment is posted and the customer
  owes nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:30:53 -04:00
gsinghpal
a623c6684d fix(fusion_plating): bug review fixes + progress/net-terms invoicing + formal CoC rebuild
Bug review fixes (found by code review + live QWeb error):
- report_fp_sale.xml: product_uom → product_uom_id (Odoo 19 renamed;
  was raising KeyError during PDF render, blocking all sale-order prints)
- mrp_production.button_mark_done: add idempotency guard on delivery
  auto-create (was duplicating on every re-close)
- fp.certificate._compute_batch_ids: use empty recordset instead of
  False for Many2many computed fields
- fp_notification_template._collect_attachments: collapse attach_quotation
  + attach_sale_order into a single render so email doesn't double-attach
  the same PDF
- fp.operator.certification: SQL unique on computed state was unreliable;
  added explicit `revoked` boolean, made state pure-compute, replaced
  SQL constraint with @api.constrains that checks active-only uniqueness;
  has_active_cert now reads revoked + expires_date directly (no stale
  stored state between nightly recomputes)

Two missing invoice strategies implemented + 1 pre-existing deposit bug fix:
- Progress Billing: new x_fc_progress_initial_percent field on sale.order;
  _create_progress_initial_invoice bills the configured % on SO confirm
  via down-payment wizard, _create_final_balance_invoice bills the
  remainder on delivery
- Net Terms: no invoice on confirm; full invoice auto-created when
  fusion.plating.delivery.action_mark_delivered fires
- Fix for deposit (pre-existing, silent): sale.advance.payment.inv
  reads active_ids at wizard-create time, not on create_invoices();
  context was being set on the wrong call, so every deposit attempt
  raised "Expected singleton" and message-posted to chatter instead
  of actually invoicing
- New fusion_plating_invoicing/models/fp_delivery.py hooks
  action_mark_delivered to dispatch final invoice for progress/net_terms
- fp.direct.order.wizard + SO form surface the progress_initial_percent
  field (conditional on strategy)

Report styling cleanup:
- Hide DISCOUNT column from sale + invoice landscape reports unless at
  least one line has a non-zero discount; colspan auto-adjusts
- Replace hardcoded #0066a1 in all reports with company.primary_color
  driven by doc.company_id → company → user.company_id fallback chain,
  with #1d1f1e as ultimate fallback; new .fp-header-primary class
  exposes the colour for inline section headers (CARGO DESCRIPTION,
  PAYMENT DETAILS, OPERATOR SIGN-OFF, etc.) so they retint with the
  company theme without template edits

Certificate of Conformance — formal ENTECH-style rebuild:
- New res.company fields: x_fc_owner_user_id (default signer, sig from
  hr.employee.signature), x_fc_coc_signature_override (manual upload),
  x_fc_{nadcap,as9100,cgp}_logo + _active toggles for accreditation
  badges
- New res.config.settings section "Fusion Plating" exposing the above
  as configurable blocks; manager-only menu under Configuration →
  Fusion Plating Settings
- New fp.certificate fields: nc_quantity, customer_job_no,
  contact_partner_id (child contact for Name / Email / Phone block)
- New report_coc_en + report_coc_fr templates (primary): custom header
  (company contact | accreditations | company logo), bilingual labels
  per variant, customer info block with customer logo, 3-column cert
  info table, 6-column line-item table (Part # | Process | Customer
  PO | Shipped | NC Qty | Customer Job No.), signature image + bordered
  certification statement, footer "Fusion Plating by Nexa Systems"
- Legacy report_coc + report_coc_portrait kept for existing portal-job
  bindings (no behaviour change)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:18:22 -04:00
gsinghpal
6658544f85 feat(fusion_plating): Tier 2 (quality + audit) and Tier 3 (business) features
Tier 2 — Quality & audit readiness:

- T2.1 SPC on thickness readings (fp.certificate)
  - spec_min_mils / spec_max_mils auto-pulled from coating config on create
  - Computed: std_dev_mils, min/max, cpk, cpk_status (incapable/marginal/
    capable/excellent/insufficient)
  - Western Electric trend rules (rule 1: any point beyond 3σ; rule 4:
    8 consecutive on one side of mean) → trend_alert + explanation
  - New SPC group on certificate form with badge-coloured indicators

- T2.2 Operator certification enforcement (fp.operator.certification)
  - Per (employee, process_type) records with issued/expires dates,
    training record attachment, revocation workflow
  - State auto-computed: active → expired when date passes
  - MrpWorkorder.button_start() blocks with UserError if current user's
    linked hr.employee lacks an active cert for the bath's process_type
  - Managers bypass the check; expiring-soon filter in search view
  - HR Employee form: "Plating Certifications" tab

- T2.3 Material traceability chain
  - fusion.plating.batch.workorder_id (new Many2one) + production_id
    (related through WO) for full chain
  - fp.certificate gets computed batch_ids / bath_ids / batch_count
  - "Batches" stat button → list of batches used for this cert's MO,
    with their chemistry logs intact

- T2.4 Pre-treatment as first-class baths
  - process_family selection on fusion.plating.process.type
    (pre_treatment / plating / post_treatment / bake / strip / passivation /
    masking / inspection)
  - Bath search view: Pre-Treatments / Plating / Post-Treatments / Strip
    quick filters
  - Existing bath infra (logs, replenishment, SPC) now applies to pre-
    treatment baths equally

Tier 3 — Business / revenue:

- T3.1 Customer-specific price lists (fp.customer.price.list)
  - Per (customer, coating_config) with unit_price + basis (per_part /
    sqin / sqft / lb)
  - effective_from / effective_to for annual contract pricing
  - min_quantity for volume breaks (cheapest price at requested qty wins)
  - _find_price() helper resolves active entry by date + qty
  - Direct Order wizard auto-fills unit_price on (partner, coating, qty)
    change unless operator has typed an override
  - Configurator menu → Customer Price Lists

- T3.2 Quote win/loss tracking (fp.quote.configurator)
  - State values: draft → confirmed (won) / lost / expired / cancelled
  - lost_reason selection (price / lead_time / tech / spec_mismatch /
    no_bid / no_response / competitor / other) + lost_competitor_name
    + lost_details text
  - Action buttons: Mark as Lost (requires reason), Mark as Expired
  - won_date auto-set on SO creation; lost_date auto-set on mark_lost
  - New "Win / Loss" tab on configurator form

- T3.3 Actuals vs. quoted margin (mrp.production)
  - Computed monetary fields: x_fc_consumables_cost, x_fc_labour_cost,
    x_fc_actual_cost, x_fc_quoted_revenue, x_fc_margin_actual,
    x_fc_margin_pct
  - Labour = sum(WO duration × workcentre cost_hour)
  - Revenue = SO amount_untaxed via mo.origin lookup
  - New "Job Costing" group on MO form with badge-coloured margin

- T3.4 Job consumables tracking (fp.job.consumption)
  - One row per consumable event (bath replenisher, masking tape, PPE,
    chemistry): product, qty, uom, unit_cost (snapshot), total_cost,
    source, optional workorder link
  - One2many x_fc_consumption_ids on mrp.production
  - "Consumables" stat button on MO → filtered list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:55:22 -04:00
gsinghpal
d3dd6376a6 feat(fusion_plating): quote-to-cash infra, notifications, wizards, Tier 1 plating features
Quote-to-cash PDF reports (portrait + landscape variants, 16 new actions):
- Quotation / Sales Order, Work Order Traveller, Packing Slip, Bill of Lading,
  Certificate of Conformance (portrait added), Invoice, Payment Receipt
- Shared fp_portrait_styles + fp_landscape_styles base templates

Workflow gap fixes (fusion_plating_bridge_mrp):
- Auto-assign recipe from SO coating config in MrpProduction.action_confirm
- Auto-create draft CoC (fp.certificate) on MrpProduction.button_mark_done

Notifications overhaul (fusion_plating_notifications v2.0):
- Expanded TRIGGER_EVENTS to 7 (added quote_sent, mo_complete, shipped, payment_received)
- Shared _dispatch method replaces three duplicated send helpers
- Auto-attach PDF reports per template config (quote, SO, CoC, invoice, receipt, BoL)
- Rebuilt 7 email templates with fusion_claims accent-bar design
  (info/success color-coded, theme-safe, 600px max-width)
- New hooks: MrpProduction done, FpDelivery mark_delivered, AccountPayment post,
  SaleOrder action_quotation_send

Wizards (fusion_plating_configurator):
- fp.direct.order.wizard — skip quotation for repeat customers with PO in hand;
  optional new-revision drawing upload bumps fp.part.catalog revision and links
  new rev to the SO; creates + confirms the SO in one step
- fp.part.catalog.import.wizard — 3-step CSV import with dry-run preview,
  tolerant parsing (customer by name/email/xmlid, human-readable selections),
  duplicate detection, create-missing-customers option, single transaction commit
- Partner form stat buttons: Direct Order, Import Parts
- CSV template download button

Tier 1 practical plating features:
- T1.1 Hydrogen bake window enforcement (fp.coating.config.requires_bake_relief,
  auto-create fusion.plating.bake.window on plating WO finish, FpDelivery lockout
  when window is open)
- T1.2 Bath replenishment rules + pending suggestion queue
  (fusion.plating.bath.replenishment.rule + .suggestion, hook on bath log line
  create, operator Apply / Dismiss actions)
- T1.3 Rack/fixture library (fusion.plating.rack with MTO counter, strip
  schedule, lifecycle: active → needs_strip → stripping → retired)
- T1.4 Rework / strip-and-replate MOs (x_fc_is_rework, x_fc_original_production_id,
  Create Rework stat button on completed MOs)
- T1.5 Parts location (x_fc_current_location computed on mrp.production —
  "In progress: Alkaline Clean" / "Queued: Bake Oven" / "Ready to Ship")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:41:12 -04:00
gsinghpal
7c7ef06057 folder rename 2026-04-16 20:53:53 -04:00
732 changed files with 22141 additions and 2560 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -14,6 +14,60 @@
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
6. **res.groups**: NO `users` field, NO `category_id` field.
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.
## 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
background-color: white;
border: 1px solid #d8dadd;
```
For custom OWL dashboards / client actions use the same approach:
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
```scss
$fp-card: var(--fp-card-bg, #ffffff);
$fp-border: var(--fp-border-color, #d8dadd);
```
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
```scss
$o-webclient-color-scheme: bright !default;
$_my-page-hex: #f3f4f6;
$_my-card-hex: #ffffff;
@if $o-webclient-color-scheme == dark {
$_my-page-hex: #1a1d21 !global;
$_my-card-hex: #22262d !global;
}
$my-page: var(--my-page-bg, $_my-page-hex);
$my-card: var(--my-card-bg, $_my-card-hex);
```
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
```python
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
```
## Asset Bundle Cache Busting
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
1. Bump the module `version` in `__manifest__.py`
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
## Naming
- New fields: `x_fc_*` prefix

View File

@@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
# ----- Facility footprint for this legal entity ----------------------
x_fc_facility_ids = fields.One2many(
'fusion.plating.facility',
'company_id',
string='Plating Facilities',
)
x_fc_facility_count = fields.Integer(
string='# Facilities',
compute='_compute_x_fc_facility_count',
)
x_fc_default_facility_id = fields.Many2one(
'fusion.plating.facility',
string='Default Facility',
help='Facility used when the context does not specify one (single-site shops).',
)
def _compute_x_fc_facility_count(self):
for rec in self:
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)

View File

@@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpDelivery(models.Model):
"""Extend delivery to auto-update portal job when delivered.
GAP 5: Delivery marked "delivered" → portal job → "shipped"
+ set actual_ship_date on the job.
"""
_inherit = 'fusion.plating.delivery'
def action_mark_delivered(self):
"""Override to cascade delivery completion to the portal job."""
res = super().action_mark_delivered()
PortalJob = self.env['fusion.plating.portal.job']
for delivery in self:
if not delivery.job_ref:
continue
# Find the portal job by name/reference
job = PortalJob.search(
[('name', '=', delivery.job_ref)], limit=1,
)
if not job:
continue
job.write({
'state': 'shipped',
'actual_ship_date': fields.Date.today(),
'tracking_ref': delivery.name,
})
job.message_post(body='Parts shipped — delivery %s marked delivered.' % delivery.name)
return res

View File

@@ -1,246 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class MrpProduction(models.Model):
"""Extend manufacturing order with Fusion Plating references and
workflow automations that bridge MO lifecycle → portal job → delivery.
"""
_inherit = 'mrp.production'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
help='The customer specification governing this manufacturing order.',
)
x_fc_facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
help='The Fusion Plating facility where this order is produced.',
)
x_fc_portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
string='Portal Job',
help='The portal job linked to this manufacturing order.',
)
x_fc_recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
domain=[('node_type', '=', 'recipe')],
help='Process recipe template for this manufacturing order.',
tracking=True,
)
x_fc_override_ids = fields.One2many(
'fusion.plating.job.node.override',
'production_id',
string='Recipe Overrides',
)
x_fc_override_count = fields.Integer(
string='Overrides',
compute='_compute_override_count',
)
@api.depends('x_fc_override_ids')
def _compute_override_count(self):
for rec in self:
rec.x_fc_override_count = len(rec.x_fc_override_ids)
def action_configure_recipe_steps(self):
"""Open the wizard to configure opt-in/out steps for this job."""
self.ensure_one()
if not self.x_fc_recipe_id:
raise UserError(_('Please select a recipe first.'))
return {
'type': 'ir.actions.act_window',
'name': f'Configure Steps — {self.x_fc_recipe_id.name}',
'res_model': 'fp.recipe.config.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_production_id': self.id,
'default_recipe_id': self.x_fc_recipe_id.id,
},
}
# ------------------------------------------------------------------
# Recipe → Work Order generation
# ------------------------------------------------------------------
def _generate_workorders_from_recipe(self):
"""Generate mrp.workorder records from the assigned recipe.
Walks the recipe tree, creates one WO per 'operation' node,
and formats child 'step' nodes as WO instructions.
Respects opt-in/out overrides from x_fc_override_ids.
"""
WorkOrder = self.env['mrp.workorder']
for production in self:
if not production.x_fc_recipe_id:
continue # No recipe assigned
if production.workorder_ids:
continue # WOs already exist — don't duplicate
# Build lookup of overrides keyed by node ID
override_map = {} # {node_id: included_bool}
for override in production.x_fc_override_ids:
override_map[override.node_id.id] = override.included
# Walk tree and collect operation WO values
wo_vals_list = []
seq_counter = [10] # mutable for closure, increments by 10
def _is_node_included(node):
"""Determine if a node should be included based on opt-in/out
logic and per-job overrides.
- disabled: always included (not configurable)
- opt_in: excluded by default, included only with override
- opt_out: included by default, excluded only with override
"""
nid = node.id
opt = node.opt_in_out or 'disabled'
if opt == 'disabled':
return True
if nid in override_map:
return override_map[nid]
# No override → use default
if opt == 'opt_in':
return False # Default excluded
# opt_out → default included
return True
def walk_node(node):
if not _is_node_included(node):
return
if node.node_type == 'operation':
# Map FP work centre → MRP work centre
mrp_wc = False
if node.work_center_id and node.work_center_id.x_fc_mrp_workcenter_id:
mrp_wc = node.work_center_id.x_fc_mrp_workcenter_id.id
if not mrp_wc:
_logger.warning(
'MO %s: operation "%s" has no mapped MRP work centre — '
'skipping WO creation.',
production.name, node.name,
)
# Still recurse into children for nested sub-operations
for child in node.child_ids.sorted('sequence'):
walk_node(child)
return
# Collect step instructions from child 'step' nodes
steps = []
step_num = 1
for child in node.child_ids.sorted('sequence'):
if child.node_type == 'step' and _is_node_included(child):
line = '%d. %s' % (step_num, child.name)
if child.estimated_duration:
line += ' (%.0f min)' % child.estimated_duration
steps.append(line)
step_num += 1
wo_vals_list.append({
'production_id': production.id,
'name': node.name,
'workcenter_id': mrp_wc,
'duration_expected': node.estimated_duration or 0,
'sequence': seq_counter[0],
'description': '\n'.join(steps) if steps else '',
})
seq_counter[0] += 10
elif node.node_type in ('recipe', 'sub_process'):
# Container nodes — recurse into children
for child in node.child_ids.sorted('sequence'):
walk_node(child)
# 'step' nodes at top level are handled by their parent operation
# Start walking from recipe root
walk_node(production.x_fc_recipe_id)
# Bulk create work orders
if wo_vals_list:
WorkOrder.create(wo_vals_list)
production.message_post(
body=_('%d work orders generated from recipe "%s".') % (
len(wo_vals_list), production.x_fc_recipe_id.name),
)
# ------------------------------------------------------------------
# GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs
# ------------------------------------------------------------------
def action_confirm(self):
"""Override to auto-create a portal job and generate work orders
from the assigned recipe when the MO is confirmed.
"""
res = super().action_confirm()
PortalJob = self.env['fusion.plating.portal.job']
for mo in self:
if mo.x_fc_portal_job_id:
# Already linked — just update state
mo.x_fc_portal_job_id.write({'state': 'in_progress'})
continue
# Resolve customer from sale order via origin
partner = False
if mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if so:
partner = so.partner_id
if not partner:
continue # No customer — skip portal job creation
job = PortalJob.create({
'name': mo.name,
'partner_id': partner.id,
'state': 'in_progress',
'received_date': fields.Date.today(),
'target_ship_date': (
mo.date_start.date() + __import__('datetime').timedelta(days=10)
if mo.date_start else False
),
'quantity': int(mo.product_qty),
'company_id': mo.company_id.id,
})
mo.x_fc_portal_job_id = job
# Generate work orders from recipe (after portal job creation)
self._generate_workorders_from_recipe()
return res
# ------------------------------------------------------------------
# GAP 3+4: MO done → update portal job + auto-create delivery
# ------------------------------------------------------------------
def button_mark_done(self):
"""Override to cascade MO completion to portal job and delivery."""
res = super().button_mark_done()
Delivery = self.env.get('fusion.plating.delivery')
for mo in self:
job = mo.x_fc_portal_job_id
if not job:
continue
# GAP 3: MO done → portal job ready_to_ship
job.write({'state': 'ready_to_ship'})
job.message_post(body='Manufacturing complete — ready to ship.')
# GAP 4: Auto-create delivery record
if Delivery is None:
continue
partner = job.partner_id
Delivery.create({
'partner_id': partner.id,
'job_ref': job.name,
'source_facility_id': mo.x_fc_facility_id.id if mo.x_fc_facility_id else False,
'state': 'draft',
})
return res

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- Extend mrp.production form: add Fusion Plating fields -->
<record id="view_mrp_production_form_fp_bridge" model="ir.ui.view">
<field name="name">mrp.production.form.fp.bridge</field>
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Fusion Plating" name="fusion_plating">
<group>
<field name="x_fc_customer_spec_id"/>
<field name="x_fc_facility_id"/>
</group>
<group>
<field name="x_fc_portal_job_id"/>
<field name="x_fc_recipe_id"/>
</group>
</group>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_configure_recipe_steps" type="object"
class="oe_stat_button" icon="fa-sliders"
invisible="not x_fc_recipe_id">
<field name="x_fc_override_count" widget="statinfo"
string="Overrides"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,128 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FpCertificate(models.Model):
"""Unified certificate registry.
Logs every quality document issued to customers: CoC, thickness
reports, mill test reports, Nadcap certs, and customer-specific
formats. Auto-created when reports are generated.
"""
_name = 'fp.certificate'
_description = 'Fusion Plating — Certificate'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'issue_date desc, id desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
certificate_type = fields.Selection(
[
('coc', 'Certificate of Conformance'),
('thickness_report', 'Thickness Report'),
('mill_test', 'Mill Test Report'),
('nadcap_cert', 'Nadcap Certificate'),
('customer_specific', 'Customer-Specific'),
],
string='Type', required=True, default='coc', tracking=True,
)
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True, tracking=True,
domain="[('customer_rank', '>', 0)]",
)
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
production_id = fields.Many2one('mrp.production', string='Manufacturing Order')
portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job')
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
process_description = fields.Char(
string='Process', help='e.g. "ELECTROLESS NICKEL PLATING PER AMS 2404"',
)
spec_reference = fields.Char(string='Spec Reference')
po_number = fields.Char(string='Customer PO #')
entech_wo_number = fields.Char(string='Entech WO #')
quantity_shipped = fields.Integer(string='Qty Shipped')
issued_by_id = fields.Many2one(
'res.users', string='Issued By', default=lambda self: self.env.user,
)
certified_by_id = fields.Many2one(
'res.users', string='Certified By', help='Signing authority (e.g. Quality Manager).',
)
issue_date = fields.Date(string='Issue Date', default=fields.Date.today, tracking=True)
attachment_id = fields.Many2one('ir.attachment', string='Certificate PDF')
thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
)
state = fields.Selection(
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
string='Status', default='draft', tracking=True, required=True,
)
void_reason = fields.Text(string='Void Reason')
notes = fields.Html(string='Notes')
# ----- Computed stats from readings -------------------------------------
reading_count = fields.Integer(
string='Readings', compute='_compute_reading_stats',
)
mean_nip_mils = fields.Float(
string='Mean NiP (mils)', compute='_compute_reading_stats', digits=(10, 4),
)
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_mils')
def _compute_reading_stats(self):
for rec in self:
readings = rec.thickness_reading_ids
rec.reading_count = len(readings)
if readings:
nip_values = readings.mapped('nip_mils')
rec.mean_nip_mils = sum(nip_values) / len(nip_values) if nip_values else 0
else:
rec.mean_nip_mils = 0
# ----- Sequence ---------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
return super().create(vals_list)
# ----- State actions ----------------------------------------------------
def action_issue(self):
for rec in self:
if rec.state != 'draft':
raise UserError(_('Only draft certificates can be issued.'))
rec.state = 'issued'
rec.message_post(body=_('Certificate issued.'))
def action_void(self):
for rec in self:
if rec.state != 'issued':
raise UserError(_('Only issued certificates can be voided.'))
if not rec.void_reason:
raise UserError(_('Please enter a void reason before voiding.'))
rec.state = 'voided'
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
def action_send_to_customer(self):
"""Open email composer with the certificate PDF attached."""
self.ensure_one()
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
ctx = {
'default_model': 'fp.certificate',
'default_res_ids': self.ids,
'default_composition_mode': 'comment',
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
}
if self.attachment_id:
ctx['default_attachment_ids'] = [self.attachment_id.id]
return {
'type': 'ir.actions.act_window',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}

View File

@@ -1,106 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
@api.onchange('partner_id')
def _onchange_partner_id_invoice_strategy(self):
"""Auto-fill invoice strategy from customer defaults."""
if self.partner_id:
default = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
)
if default:
self.x_fc_invoice_strategy = default.default_strategy
self.x_fc_deposit_percent = default.default_deposit_percent
if default.payment_term_id:
self.payment_term_id = default.payment_term_id
def action_confirm(self):
"""Override to check account hold and trigger invoice strategy."""
for order in self:
# --- Account hold check ---
if order.partner_id.x_fc_account_hold:
is_manager = self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
)
if not is_manager:
raise UserError(_(
'Cannot confirm — customer "%s" is on account hold.\n'
'Reason: %s\n\n'
'Contact a manager to override.'
) % (order.partner_id.name,
order.partner_id.x_fc_account_hold_reason or 'No reason specified'))
else:
# Manager gets a warning in chatter but can proceed
order.message_post(
body=_(
'Warning: Customer "%s" is on account hold (reason: %s). '
'Order confirmed by manager override.'
) % (order.partner_id.name,
order.partner_id.x_fc_account_hold_reason or 'N/A'),
)
res = super().action_confirm()
# --- Invoice strategy automation ---
for order in self:
strategy = order.x_fc_invoice_strategy
if not strategy:
continue
if strategy == 'deposit' and order.x_fc_deposit_percent:
order._create_deposit_invoice()
elif strategy == 'cod_prepay':
order._create_full_invoice()
return res
def _create_deposit_invoice(self):
"""Create a deposit (down payment) invoice for the deposit percentage."""
self.ensure_one()
percent = self.x_fc_deposit_percent
if not percent or percent <= 0:
return
try:
# Use Odoo's standard down payment mechanism
wizard = self.env['sale.advance.payment.inv'].create({
'advance_payment_method': 'percentage',
'amount': percent,
})
wizard.with_context(active_ids=self.ids, active_model='sale.order').create_invoices()
self.message_post(
body=_('Deposit invoice (%.0f%%) created automatically — strategy: Deposit.') % percent,
)
except Exception as e:
_logger.warning('Failed to create deposit invoice for SO %s: %s', self.name, e)
self.message_post(
body=_('Failed to auto-create deposit invoice: %s. Create manually.') % str(e),
)
def _create_full_invoice(self):
"""Create a full invoice immediately (COD/Prepay strategy)."""
self.ensure_one()
try:
invoices = self._create_invoices()
if invoices:
self.message_post(
body=_('Full invoice created automatically — strategy: COD / Prepay.'),
)
except Exception as e:
_logger.warning('Failed to create COD invoice for SO %s: %s', self.name, e)
self.message_post(
body=_('Failed to auto-create invoice: %s. Create manually.') % str(e),
)

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="fp_notif_so_confirmed" model="fp.notification.template">
<field name="name">Order Confirmation</field>
<field name="trigger_event">so_confirmed</field>
<field name="mail_template_id" ref="fp_mail_template_so_confirmed"/>
<field name="active" eval="True"/>
</record>
<record id="fp_notif_parts_received" model="fp.notification.template">
<field name="name">Parts Received</field>
<field name="trigger_event">parts_received</field>
<field name="mail_template_id" ref="fp_mail_template_parts_received"/>
<field name="active" eval="True"/>
</record>
<record id="fp_notif_invoice_posted" model="fp.notification.template">
<field name="name">Invoice Posted</field>
<field name="trigger_event">invoice_posted</field>
<field name="mail_template_id" ref="fp_mail_template_invoice_posted"/>
<field name="active" eval="True"/>
<field name="attach_invoice" eval="True"/>
</record>
</odoo>

View File

@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="fp_mail_template_so_confirmed" model="mail.template">
<field name="name">FP: Order Confirmation</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Order Confirmation — {{ object.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="body_html" type="html">
<p>Dear {{ object.partner_id.name }},</p>
<p>Your order <strong>{{ object.name }}</strong> has been confirmed.</p>
<p>We will notify you when your parts have been received at our facility.</p>
<p>Thank you for your business.</p>
<p>— EN Technologies Inc.</p>
</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="fp_mail_template_parts_received" model="mail.template">
<field name="name">FP: Parts Received</field>
<field name="model_id" eval="env['ir.model']._get_id('fp.receiving')"/>
<field name="subject">Parts Received — {{ object.name }}</field>
<field name="email_from">{{ (object.sale_order_id.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="body_html" type="html">
<p>Dear {{ object.partner_id.name }},</p>
<p>We have received your parts for order <strong>{{ object.sale_order_id.name }}</strong>.</p>
<p>Quantity received: {{ object.received_qty }}</p>
<p>Your parts are now in our production queue. We will keep you updated on progress.</p>
<p>— EN Technologies Inc.</p>
</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="fp_mail_template_invoice_posted" model="mail.template">
<field name="name">FP: Invoice Notification</field>
<field name="model_id" ref="account.model_account_move"/>
<field name="subject">Invoice {{ object.name }} — EN Technologies</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="body_html" type="html">
<p>Dear {{ object.partner_id.name }},</p>
<p>Please find your invoice <strong>{{ object.name }}</strong> for amount <strong>{{ object.amount_total }}</strong>.</p>
<p>Thank you for your business.</p>
<p>— EN Technologies Inc.</p>
</field>
<field name="auto_delete" eval="True"/>
</record>
</odoo>

View File

@@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
def action_post(self):
res = super().action_post()
for move in self:
if move.move_type == 'out_invoice' and move.partner_id:
# Find linked SO
so = False
if move.invoice_origin:
so = self.env['sale.order'].search(
[('name', '=', move.invoice_origin)], limit=1,
)
self._send_fp_notification(
'invoice_posted', move, move.partner_id, sale_order=so,
)
return res
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
"""Send a notification email and log it."""
template = self.env['fp.notification.template'].search(
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
)
if not template or not template.mail_template_id:
return
try:
template.mail_template_id.send_mail(record.id, force_send=False)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'sent',
})
except Exception as e:
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'failed',
'error_message': str(e),
})

View File

@@ -1,51 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
TRIGGER_EVENTS = [
('so_confirmed', 'Order Confirmed'),
('parts_received', 'Parts Received'),
('mo_complete', 'Manufacturing Complete'),
('shipment', 'Shipment (Carrier)'),
('delivery', 'Delivery (Local)'),
('invoice_posted', 'Invoice Posted'),
('deposit_created', 'Deposit Required'),
]
class FpNotificationTemplate(models.Model):
"""Configurable notification wrapper.
Each record maps a trigger event to a mail.template and controls
whether the notification fires and what attachments are included.
"""
_name = 'fp.notification.template'
_description = 'Fusion Plating — Notification Template'
_order = 'trigger_event'
name = fields.Char(string='Template Name', required=True)
trigger_event = fields.Selection(
TRIGGER_EVENTS, string='Trigger Event', required=True,
)
mail_template_id = fields.Many2one(
'mail.template', string='Email Template',
help='The Odoo mail template used to render and send the email.',
)
active = fields.Boolean(string='Active', default=True)
attach_coc = fields.Boolean(string='Attach CoC')
attach_thickness_report = fields.Boolean(string='Attach Thickness Report')
attach_invoice = fields.Boolean(string='Attach Invoice')
attach_packing_list = fields.Boolean(string='Attach Packing List')
attach_pod = fields.Boolean(string='Attach Proof of Delivery')
cc_internal_ids = fields.Many2many(
'res.users', 'fp_notification_template_cc_rel',
'template_id', 'user_id', string='CC (Internal)',
)
_sql_constraints = [
('fp_notification_trigger_uniq', 'unique(trigger_event)',
'Only one notification template per trigger event.'),
]

View File

@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class FpReceiving(models.Model):
_inherit = 'fp.receiving'
def action_accept(self):
res = super().action_accept()
for rec in self:
self._send_fp_notification(
'parts_received', rec, rec.partner_id,
sale_order=rec.sale_order_id,
)
return res
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
"""Send a notification email and log it."""
template = self.env['fp.notification.template'].search(
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
)
if not template or not template.mail_template_id:
return
try:
template.mail_template_id.send_mail(record.id, force_send=False)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'sent',
})
except Exception as e:
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'failed',
'error_message': str(e),
})

View File

@@ -1,51 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
res = super().action_confirm()
for order in self:
self._send_fp_notification(
'so_confirmed', order, order.partner_id, sale_order=order,
)
return res
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
"""Send a notification email and log it."""
template = self.env['fp.notification.template'].search(
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
)
if not template or not template.mail_template_id:
return
try:
template.mail_template_id.send_mail(record.id, force_send=False)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'sent',
})
except Exception as e:
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'failed',
'error_message': str(e),
})

View File

@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import controllers
from . import models

View File

@@ -1,205 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Paper format + report actions for all Fusion Plating reports.
-->
<odoo>
<!-- ============================================================= -->
<!-- Landscape Paper Format -->
<!-- ============================================================= -->
<record id="paperformat_fp_a4_landscape" model="report.paperformat">
<field name="name">A4 Landscape (Fusion Plating)</field>
<field name="default" eval="False"/>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">20</field>
<field name="margin_bottom">20</field>
<field name="margin_left">7</field>
<field name="margin_right">7</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">20</field>
<field name="dpi">90</field>
</record>
<!-- ============================================================= -->
<!-- 1. Certificate of Conformance (Portal Job) -->
<!-- ============================================================= -->
<record id="action_report_coc" model="ir.actions.report">
<field name="name">Certificate of Conformance</field>
<field name="model">fusion.plating.portal.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_coc</field>
<field name="report_file">fusion_plating_reports.report_coc</field>
<field name="print_report_name">'CoC - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_portal.model_fusion_plating_portal_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 2. Non-Conformance Report -->
<!-- ============================================================= -->
<record id="action_report_ncr" model="ir.actions.report">
<field name="name">Non-Conformance Report</field>
<field name="model">fusion.plating.ncr</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_ncr</field>
<field name="report_file">fusion_plating_reports.report_ncr</field>
<field name="print_report_name">'NCR - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_ncr"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 3. Corrective / Preventive Action -->
<!-- ============================================================= -->
<record id="action_report_capa" model="ir.actions.report">
<field name="name">CAPA Report</field>
<field name="model">fusion.plating.capa</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_capa</field>
<field name="report_file">fusion_plating_reports.report_capa</field>
<field name="print_report_name">'CAPA - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_capa"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 4. Bath Chemistry Log -->
<!-- ============================================================= -->
<record id="action_report_bath_log" model="ir.actions.report">
<field name="name">Bath Chemistry Log</field>
<field name="model">fusion.plating.bath.log</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_bath_chemistry_log</field>
<field name="report_file">fusion_plating_reports.report_bath_chemistry_log</field>
<field name="print_report_name">'Bath Log - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating.model_fusion_plating_bath_log"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 5. Calibration Certificate -->
<!-- ============================================================= -->
<record id="action_report_calibration" model="ir.actions.report">
<field name="name">Calibration Certificate</field>
<field name="model">fusion.plating.calibration.equipment</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_calibration_cert</field>
<field name="report_file">fusion_plating_reports.report_calibration_cert</field>
<field name="print_report_name">'Calibration - %s' % object.code</field>
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_calibration_equipment"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 6. First Article Inspection Report -->
<!-- ============================================================= -->
<record id="action_report_fair" model="ir.actions.report">
<field name="name">FAIR Report</field>
<field name="model">fusion.plating.fair</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fair</field>
<field name="report_file">fusion_plating_reports.report_fair</field>
<field name="print_report_name">'FAIR - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_fair"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 7. Audit Report -->
<!-- ============================================================= -->
<record id="action_report_audit" model="ir.actions.report">
<field name="name">Audit Report</field>
<field name="model">fusion.plating.audit</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_audit</field>
<field name="report_file">fusion_plating_reports.report_audit</field>
<field name="print_report_name">'Audit - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_audit"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 8. Incident Report -->
<!-- ============================================================= -->
<record id="action_report_incident" model="ir.actions.report">
<field name="name">Incident Report</field>
<field name="model">fusion.plating.incident</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_incident</field>
<field name="report_file">fusion_plating_reports.report_incident</field>
<field name="print_report_name">'Incident - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_safety.model_fusion_plating_incident"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 9. Spill Register -->
<!-- ============================================================= -->
<record id="action_report_spill" model="ir.actions.report">
<field name="name">Spill Report</field>
<field name="model">fusion.plating.spill.register</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_spill</field>
<field name="report_file">fusion_plating_reports.report_spill</field>
<field name="print_report_name">'Spill - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_spill_register"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 10. Waste Manifest -->
<!-- ============================================================= -->
<record id="action_report_waste_manifest" model="ir.actions.report">
<field name="name">Waste Manifest</field>
<field name="model">fusion.plating.waste.manifest</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_waste_manifest</field>
<field name="report_file">fusion_plating_reports.report_waste_manifest</field>
<field name="print_report_name">'Waste Manifest - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_waste_manifest"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 11. Discharge Sample -->
<!-- ============================================================= -->
<record id="action_report_discharge_sample" model="ir.actions.report">
<field name="name">Discharge Sample Report</field>
<field name="model">fusion.plating.discharge.sample</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_discharge_sample</field>
<field name="report_file">fusion_plating_reports.report_discharge_sample</field>
<field name="print_report_name">'Discharge - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_discharge_sample"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 12. Work Order Margin Report -->
<!-- ============================================================= -->
<record id="action_report_wo_margin" model="ir.actions.report">
<field name="name">Work Order Margin Report</field>
<field name="model">mrp.production</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_wo_margin</field>
<field name="report_file">fusion_plating_reports.report_wo_margin</field>
<field name="print_report_name">'Margin Report - %s' % object.name</field>
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
</odoo>

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Shared landscape CSS for all Fusion Plating reports.
-->
<odoo>
<template id="fp_landscape_styles">
<style>
.fp-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
.fp-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.fp-landscape table.bordered, .fp-landscape table.bordered th, .fp-landscape table.bordered td { border: 1px solid #000; }
.fp-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fp-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fp-landscape .text-center { text-align: center; }
.fp-landscape .text-end { text-align: right; }
.fp-landscape .text-start { text-align: left; }
.fp-landscape .adp-bg { background-color: #e3f2fd; }
.fp-landscape .client-bg { background-color: #fff3e0; }
.fp-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.fp-landscape .note-row { font-style: italic; }
.fp-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
.fp-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
.fp-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fp-landscape .totals-table { border: 1px solid #000; }
.fp-landscape .totals-table td { border: 1px solid #000; padding: 8px 12px; font-size: 11pt; }
.fp-landscape .status-ok { color: #2e7d32; font-weight: bold; }
.fp-landscape .status-warning { color: #f57f17; font-weight: bold; }
.fp-landscape .status-fail { color: #c62828; font-weight: bold; }
</style>
</template>
</odoo>

View File

@@ -1,114 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Certificate of Conformance — Portal Job
-->
<odoo>
<template id="report_coc">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<h2 style="text-align: left;">
Certificate of Conformance
<span t-field="doc.name"/>
</h2>
<!-- Job Info -->
<table class="bordered info-table">
<thead><tr>
<th>JOB REF</th>
<th>CUSTOMER</th>
<th>QUANTITY</th>
<th>RECEIVED</th>
<th>SHIP DATE</th>
<th>TRACKING REF</th>
<th>STATUS</th>
</tr></thead>
<tbody><tr>
<td class="text-center"><span t-field="doc.name"/></td>
<td><span t-field="doc.partner_id"/></td>
<td class="text-center"><span t-field="doc.quantity"/></td>
<td class="text-center"><span t-field="doc.received_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-field="doc.actual_ship_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-field="doc.tracking_ref"/></td>
<td class="text-center"><span t-field="doc.state"/></td>
</tr></tbody>
</table>
<!-- Customer Address -->
<table class="bordered">
<thead><tr>
<th colspan="2">CUSTOMER DETAILS</th>
</tr></thead>
<tbody>
<tr>
<td style="width:30%; font-weight:bold;">Name</td>
<td><span t-field="doc.partner_id.name"/></td>
</tr>
<tr>
<td style="font-weight:bold;">Address</td>
<td>
<span t-field="doc.partner_id" t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Processes -->
<table class="bordered" t-if="doc.process_type_ids">
<thead><tr>
<th>PROCESSES APPLIED</th>
</tr></thead>
<tbody><tr>
<td>
<t t-foreach="doc.process_type_ids" t-as="pt">
<span t-out="pt.name"/>
<t t-if="not pt_last">, </t>
</t>
</td>
</tr></tbody>
</table>
<!-- Certification Statement -->
<table class="bordered">
<tr class="section-row"><td>CERTIFICATION</td></tr>
<tr><td style="padding: 16px 12px; font-size: 11pt;">
This certifies that the above items were processed in accordance
with applicable specifications and meet all requirements as stated
in the purchase order. All work was performed in compliance with
the quality management system.
</td></tr>
</table>
<!-- Notes -->
<t t-if="doc.notes">
<table class="bordered">
<tr class="section-row"><td>NOTES</td></tr>
<tr><td><t t-out="doc.notes"/></td></tr>
</table>
</t>
<!-- Signature Block -->
<table class="bordered" style="margin-top: 30px;">
<tbody>
<tr>
<td style="width:50%; height: 60px; vertical-align: bottom; font-weight: bold;">
Quality Manager Signature: ___________________________
</td>
<td style="width:50%; height: 60px; vertical-align: bottom; font-weight: bold;">
Date: ___________________________
</td>
</tr>
</tbody>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -1,178 +0,0 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Shop Floor Tablet (OWL backend client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Odoo 19 conventions:
// * Backend OWL component using `static template` + `static props = []`
// (note: empty array, NOT empty object).
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
// * Registered under registry.category("actions") so the menu / record
// action can launch it as a client action ("fp_shopfloor_tablet").
// =============================================================================
import { Component, useState, onMounted, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class ShopfloorTablet extends Component {
static template = "fusion_plating_shopfloor.ShopfloorTablet";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.scanInput = useRef("scanInput");
this.state = useState({
scannedCode: "",
station: null,
currentTank: null,
currentBath: null,
currentJob: null,
queueRows: [],
message: "",
messageType: "info", // info | success | warning | danger
loading: false,
});
onMounted(async () => {
await this.refreshQueue();
if (this.scanInput.el) {
this.scanInput.el.focus();
}
});
}
// ----- Helpers --------------------------------------------------------
setMessage(text, type = "info") {
this.state.message = text;
this.state.messageType = type;
}
clearTargets() {
this.state.currentTank = null;
this.state.currentBath = null;
this.state.currentJob = null;
}
// ----- QR scan --------------------------------------------------------
async onScan() {
const code = (this.state.scannedCode || "").trim();
if (!code) {
return;
}
this.state.loading = true;
try {
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
if (!result || !result.ok) {
this.setMessage(
(result && result.error) || "Unrecognised QR code",
"danger",
);
this.state.loading = false;
return;
}
this.clearTargets();
switch (result.model) {
case "fusion.plating.tank":
this.state.currentTank = result;
this.setMessage(
`Tank ${result.name}${result.queue_size} in queue`,
"info",
);
break;
case "fusion.plating.bath":
this.state.currentBath = result;
this.setMessage(`Bath ${result.name}`, "info");
break;
case "fusion.plating.bake.window":
this.state.currentJob = result;
this.setMessage(
`Job ${result.name}${result.time_remaining || ""} remaining`,
result.state === "missed_window" ? "danger" : "warning",
);
break;
case "fusion.plating.shopfloor.station":
this.state.station = result;
this.setMessage(
`Station paired: ${result.name}`,
"success",
);
break;
default:
this.setMessage(`Scanned ${result.model}`, "info");
}
} catch (err) {
this.setMessage(`Scan error: ${err.message || err}`, "danger");
} finally {
this.state.scannedCode = "";
this.state.loading = false;
if (this.scanInput.el) {
this.scanInput.el.focus();
}
await this.refreshQueue();
}
}
onScanKey(ev) {
if (ev.key === "Enter") {
this.onScan();
}
}
// ----- Bake controls --------------------------------------------------
async onStartBake() {
if (!this.state.currentJob) {
return;
}
try {
const res = await rpc("/fp/shopfloor/start_bake", {
bake_window_id: this.state.currentJob.id,
});
if (res && res.ok) {
this.setMessage("Bake started", "success");
this.state.currentJob.state = res.state;
}
} catch (err) {
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
}
await this.refreshQueue();
}
async onEndBake() {
if (!this.state.currentJob) {
return;
}
try {
const res = await rpc("/fp/shopfloor/end_bake", {
bake_window_id: this.state.currentJob.id,
});
if (res && res.ok) {
this.setMessage(
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
"success",
);
this.state.currentJob.state = res.state;
}
} catch (err) {
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
}
await this.refreshQueue();
}
// ----- Queue ----------------------------------------------------------
async refreshQueue() {
try {
const res = await rpc("/fp/shopfloor/queue", {});
if (res && res.ok) {
this.state.queueRows = res.rows || [];
}
} catch (err) {
// Non-fatal: queue refresh shouldn't block scanning
}
}
}
registry.category("actions").add("fp_shopfloor_tablet", ShopfloorTablet);

View File

@@ -1,280 +0,0 @@
// =============================================================================
// Fusion Plating — Shop Floor backend / tablet styles
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// THEME AWARENESS
// ---------------
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
// the tablet view renders correctly in BOTH light and dark mode without any
// duplication or media queries. Status tints use color-mix() against the
// theme token so green/yellow/red adapt to the surface.
//
// background: var(--bs-body-bg)
// surface: var(--o-view-background-color)
// foreground: var(--bs-body-color)
// muted text: var(--bs-secondary-color)
// border: var(--bs-border-color)
// primary: var(--o-action)
// =============================================================================
// -----------------------------------------------------------------------------
// Local mixin — semantic tint that respects light/dark mode
// -----------------------------------------------------------------------------
@mixin fp-shop-tint($color-var, $amount: 14%) {
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
color: var(#{$color-var});
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
}
// -----------------------------------------------------------------------------
// Tablet root container — large touch targets, generous whitespace
// -----------------------------------------------------------------------------
.o_fp_tablet {
background-color: var(--o-view-background-color, var(--bs-body-bg));
color: var(--bs-body-color);
min-height: 100%;
padding: 24px;
font-size: 1.1rem;
display: flex;
flex-direction: column;
gap: 18px;
.o_fp_tablet_header {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--bs-border-color);
}
.o_fp_tablet_title {
font-size: 1.6rem;
font-weight: 600;
color: var(--bs-body-color);
}
.o_fp_tablet_station {
color: var(--bs-secondary-color);
font-size: 1rem;
}
.o_fp_tablet_scan_row {
display: flex;
gap: 12px;
align-items: stretch;
}
.o_fp_tablet_message {
padding: 14px 18px;
border-radius: 10px;
font-size: 1.1rem;
line-height: 1.4;
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
&.o_fp_msg_success { @include fp-shop-tint(--bs-success); }
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
}
.o_fp_tablet_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.o_fp_tablet_queue {
background-color: var(--o-view-background-color, var(--bs-body-bg));
border: 1px solid var(--bs-border-color);
border-radius: 12px;
padding: 16px 18px;
.o_fp_tablet_queue_title {
font-size: 1.2rem;
font-weight: 600;
color: var(--bs-body-color);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--bs-border-color);
}
.o_fp_tablet_queue_list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.o_fp_tablet_queue_item {
background-color: color-mix(in srgb, var(--bs-body-color) 4%, transparent);
border: 1px solid var(--bs-border-color);
border-radius: 8px;
padding: 10px 14px;
.o_fp_tablet_queue_label {
color: var(--bs-body-color);
}
.o_fp_tablet_queue_desc {
color: var(--bs-secondary-color);
font-size: 0.95rem;
}
}
}
}
// -----------------------------------------------------------------------------
// Large card surface used for tank / bath info on the tablet
// -----------------------------------------------------------------------------
.o_fp_tablet_card {
background-color: var(--o-view-background-color, var(--bs-body-bg));
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
border-radius: 12px;
padding: 18px 20px;
min-height: 140px;
transition: border-color 120ms ease, box-shadow 120ms ease;
&:hover {
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
box-shadow: 0 2px 10px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
}
.o_fp_tablet_card_label {
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-secondary-color);
margin-bottom: 6px;
}
.o_fp_tablet_card_value {
font-size: 1.6rem;
font-weight: 600;
color: var(--bs-body-color);
margin-bottom: 6px;
line-height: 1.1;
}
.o_fp_tablet_card_meta {
font-size: 0.95rem;
color: var(--bs-secondary-color);
}
}
// -----------------------------------------------------------------------------
// Bake window card — colour shifts with state
// -----------------------------------------------------------------------------
.o_fp_bake_window_card {
background-color: var(--o-view-background-color, var(--bs-body-bg));
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
border-left-width: 6px;
border-radius: 12px;
padding: 18px 20px;
min-height: 160px;
.o_fp_tablet_card_label {
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-secondary-color);
margin-bottom: 6px;
}
.o_fp_tablet_card_value {
font-size: 1.6rem;
font-weight: 600;
line-height: 1.1;
}
.o_fp_tablet_card_meta {
font-size: 0.95rem;
color: var(--bs-secondary-color);
}
.o_fp_tablet_card_actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
&[data-status="awaiting_bake"] {
border-left-color: var(--bs-warning);
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
}
&[data-status="bake_in_progress"] {
border-left-color: var(--bs-info, var(--o-action));
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
}
&[data-status="baked"] {
border-left-color: var(--bs-success);
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
}
&[data-status="missed_window"],
&[data-status="scrapped"] {
border-left-color: var(--bs-danger);
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
}
}
// -----------------------------------------------------------------------------
// Large QR scan input — friendly to tablet keyboards / wedge scanners
// -----------------------------------------------------------------------------
.o_fp_scan_input {
flex: 1 1 auto;
min-height: 56px;
padding: 12px 18px;
font-size: 1.3rem;
border: 2px solid var(--bs-border-color);
border-radius: 10px;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
&:focus {
outline: none;
border-color: var(--o-action);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
}
&::placeholder {
color: var(--bs-secondary-color);
}
}
// -----------------------------------------------------------------------------
// Big touch-friendly action button
// -----------------------------------------------------------------------------
.o_fp_big_button {
min-height: 56px;
min-width: 120px;
padding: 12px 24px;
font-size: 1.1rem;
font-weight: 500;
border-radius: 10px;
border: 1px solid var(--o-action);
background-color: var(--o-action);
color: var(--o-we-text-on-action, #fff);
cursor: pointer;
transition: filter 120ms ease, transform 80ms ease;
&:hover:not(:disabled) {
filter: brightness(1.05);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}

View File

@@ -1,441 +0,0 @@
// =============================================================================
// Fusion Plating — Plant Overview Dashboard
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// THEME AWARENESS
// ---------------
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
// the dashboard renders correctly in BOTH light and dark mode.
//
// background: var(--bs-body-bg)
// surface: var(--o-view-background-color)
// foreground: var(--bs-body-color)
// muted text: var(--bs-secondary-color)
// border: var(--bs-border-color)
// primary: var(--o-action)
// =============================================================================
.o_fp_plant_overview {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--o-view-background-color, var(--bs-body-bg));
padding: 0;
}
// ---- Header -----------------------------------------------------------------
.o_fp_po_header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 20px;
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
box-shadow: 0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
.o_fp_po_header_left {
display: flex;
align-items: center;
}
.o_fp_po_title {
margin: 0;
font-size: 1.3rem;
font-weight: 700;
color: var(--bs-body-color);
}
.o_fp_po_refresh_ts {
font-size: 0.8rem;
}
.o_fp_po_header_right {
display: flex;
align-items: center;
gap: 10px;
}
}
// ---- Search -----------------------------------------------------------------
.o_fp_po_search_box {
position: relative;
display: flex;
align-items: center;
.o_fp_po_search_icon {
position: absolute;
left: 10px;
color: var(--bs-secondary-color);
pointer-events: none;
}
.o_fp_po_search_input {
padding: 6px 32px 6px 32px;
border: 1px solid var(--bs-border-color);
border-radius: 6px;
font-size: 0.875rem;
width: 260px;
outline: none;
transition: border-color 0.15s;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
&:focus {
border-color: var(--o-action);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--o-action) 15%, transparent);
}
}
.o_fp_po_search_clear {
position: absolute;
right: 6px;
background: none;
border: none;
color: var(--bs-secondary-color);
cursor: pointer;
padding: 2px 6px;
&:hover {
color: var(--bs-body-color);
}
}
}
.o_fp_po_refresh_btn {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
// ---- Columns container ------------------------------------------------------
.o_fp_po_columns {
display: flex;
gap: 12px;
padding: 16px 20px;
overflow-x: auto;
flex: 1;
min-height: 0;
align-items: flex-start;
}
// ---- Single column (work centre) --------------------------------------------
.o_fp_po_column {
flex: 0 0 280px;
min-width: 260px;
max-width: 320px;
display: flex;
flex-direction: column;
background: var(--bs-body-bg);
border-radius: 10px;
box-shadow: 0 1px 4px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
max-height: calc(100vh - 140px);
}
.o_fp_po_col_header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 2px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
border-radius: 10px 10px 0 0;
.o_fp_po_col_name {
font-weight: 700;
font-size: 0.9rem;
color: var(--bs-body-color);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.o_fp_po_col_count {
background: var(--bs-secondary-color);
color: #fff;
font-size: 0.75rem;
min-width: 24px;
text-align: center;
}
}
.o_fp_po_col_body {
overflow-y: auto;
padding: 8px;
flex: 1;
transition: background-color 0.15s, border-color 0.15s;
border: 2px solid transparent;
border-radius: 0 0 10px 10px;
// Drop target highlight when dragging a card over this column
&.o_fp_drop_target {
background-color: color-mix(in srgb, var(--o-action) 8%, transparent);
border-color: color-mix(in srgb, var(--o-action) 40%, transparent);
}
}
// ---- Card -------------------------------------------------------------------
.o_fp_po_card {
background: var(--bs-body-bg);
border-width: 1px;
border-style: solid;
border-color: $border-color;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
cursor: grab;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.15s, transform 0.1s, opacity 0.15s;
&:hover {
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
border-color: darken($border-color, 10%);
}
&:active {
cursor: grabbing;
}
&:last-child {
margin-bottom: 0;
}
// Dragging ghost state
&.o_fp_dragging {
opacity: 0.4;
border-style: dashed;
box-shadow: none;
transform: none;
}
// State variants
&.o_fp_card_progress {
border-left: 4px solid var(--bs-warning);
}
&.o_fp_card_ready {
border-left: 4px solid var(--bs-primary);
}
&.o_fp_card_done {
border-left: 4px solid var(--bs-success);
opacity: 0.75;
}
&.o_fp_card_pending {
border-left: 4px solid var(--bs-warning);
}
}
// ---- Card top row (image + title + step badge) --------------------------------
.o_fp_po_card_top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.o_fp_po_card_img {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.o_fp_po_card_img_placeholder {
width: 32px;
height: 32px;
border-radius: 4px;
background: var(--bs-tertiary-bg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--bs-secondary-color);
font-size: 14px;
}
.o_fp_po_card_title {
flex: 1;
min-width: 0;
font-size: 0.9rem;
color: var(--bs-body-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.o_fp_po_card_step_badge {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bs-info);
color: #fff;
font-size: 0.7rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
// ---- Priority card borders ---------------------------------------------------
.o_fp_po_card_hot {
border-left: 4px solid var(--bs-danger) !important;
background: color-mix(in srgb, var(--bs-danger) 8%, var(--bs-body-bg));
}
.o_fp_po_card_urgent {
border-left: 4px solid var(--bs-warning) !important;
background: color-mix(in srgb, var(--bs-warning) 8%, var(--bs-body-bg));
}
// ---- Product name and step display -------------------------------------------
.o_fp_po_card_product {
margin-bottom: 4px;
}
.o_fp_po_card_step {
margin-bottom: 4px;
}
.o_fp_po_card_customer {
font-size: 0.9rem;
margin-bottom: 2px;
color: var(--bs-body-color);
}
.o_fp_po_card_refs {
font-size: 0.8rem;
color: var(--bs-secondary-color);
margin-bottom: 6px;
}
// ---- Parts progress bar -----------------------------------------------------
.o_fp_po_card_parts {
margin-bottom: 6px;
}
.o_fp_po_parts_bar {
height: 6px;
background: var(--bs-tertiary-bg);
border-radius: 3px;
overflow: hidden;
margin-bottom: 2px;
}
.o_fp_po_parts_fill {
height: 100%;
background: var(--bs-warning);
border-radius: 3px;
transition: width 0.3s ease;
}
.o_fp_po_parts_label {
font-size: 0.75rem;
color: var(--bs-secondary-color);
}
.o_fp_po_card_last {
font-size: 0.75rem;
margin-bottom: 6px;
}
// ---- Tags + date footer -----------------------------------------------------
.o_fp_po_card_footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
}
.o_fp_po_card_tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.o_fp_po_tag {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 2px 6px;
border-radius: 4px;
line-height: 1.4;
&.o_fp_tag_hot {
background: var(--bs-danger);
color: #fff;
}
&.o_fp_tag_priority {
background: var(--bs-success);
color: #fff;
}
&.o_fp_tag_attention {
background: var(--bs-warning);
color: var(--bs-body-color);
}
&.o_fp_tag_default {
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
}
}
.o_fp_po_card_date {
font-size: 0.75rem;
font-weight: 600;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
// ---- Empty / no-cards -------------------------------------------------------
.o_fp_po_no_cards {
font-size: 0.85rem;
}
// ---- Responsive -------------------------------------------------------------
@media (max-width: 768px) {
.o_fp_po_columns {
flex-direction: column;
align-items: stretch;
padding: 12px;
}
.o_fp_po_column {
flex: 1 1 auto;
min-width: 100%;
max-width: 100%;
max-height: none;
}
.o_fp_po_search_input {
width: 180px !important;
}
.o_fp_po_header {
padding: 12px;
}
}

View File

@@ -1,115 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
<div class="o_fp_tablet">
<div class="o_fp_tablet_header">
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
<div class="o_fp_tablet_station" t-if="state.station">
Station: <strong t-esc="state.station.name"/>
</div>
</div>
<div class="o_fp_tablet_scan_row">
<input
type="text"
class="o_fp_scan_input"
placeholder="Scan QR code"
t-ref="scanInput"
t-model="state.scannedCode"
t-on-keydown="onScanKey"
/>
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
Scan
</button>
</div>
<div t-if="state.message" t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
<span t-esc="state.message"/>
</div>
<div class="o_fp_tablet_grid">
<div class="o_fp_tablet_card" t-if="state.currentTank">
<div class="o_fp_tablet_card_label">Tank</div>
<div class="o_fp_tablet_card_value">
<t t-esc="state.currentTank.name"/>
</div>
<div class="o_fp_tablet_card_meta">
State: <t t-esc="state.currentTank.state"/>
</div>
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
Bath: <t t-esc="state.currentTank.current_bath_name"/>
</div>
<div class="o_fp_tablet_card_meta">
Queue: <t t-esc="state.currentTank.queue_size"/>
</div>
</div>
<div class="o_fp_tablet_card" t-if="state.currentBath">
<div class="o_fp_tablet_card_label">Bath</div>
<div class="o_fp_tablet_card_value">
<t t-esc="state.currentBath.name"/>
</div>
<div class="o_fp_tablet_card_meta">
State: <t t-esc="state.currentBath.state"/>
</div>
<div class="o_fp_tablet_card_meta" t-if="state.currentBath.tank_name">
Tank: <t t-esc="state.currentBath.tank_name"/>
</div>
</div>
<div class="o_fp_bake_window_card"
t-if="state.currentJob"
t-att-data-status="state.currentJob.state">
<div class="o_fp_tablet_card_label">Bake Job</div>
<div class="o_fp_tablet_card_value">
<t t-esc="state.currentJob.name"/>
</div>
<div class="o_fp_tablet_card_meta">
State: <t t-esc="state.currentJob.state"/>
</div>
<div class="o_fp_tablet_card_meta">
Remaining: <t t-esc="state.currentJob.time_remaining"/>
</div>
<div class="o_fp_tablet_card_actions">
<button class="o_fp_big_button"
t-if="state.currentJob.state === 'awaiting_bake'"
t-on-click="onStartBake">
Start Bake
</button>
<button class="o_fp_big_button"
t-if="state.currentJob.state === 'bake_in_progress'"
t-on-click="onEndBake">
End Bake
</button>
</div>
</div>
</div>
<div class="o_fp_tablet_queue">
<div class="o_fp_tablet_queue_title">Next Up</div>
<div t-if="!state.queueRows.length" class="text-muted">
Queue is empty.
</div>
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
<li class="o_fp_tablet_queue_item">
<div class="o_fp_tablet_queue_label">
<strong t-esc="row.label"/>
</div>
<div class="o_fp_tablet_queue_desc text-muted">
<t t-esc="row.description"/>
</div>
</li>
</t>
</ul>
</div>
</div>
</t>
</templates>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,949 @@
# Fusion Accounting — Enterprise Takeover Roadmap
**Status:** Design (approved 2026-04-18)
**Owner:** Nexa Systems Inc.
**Target:** Odoo 19 Community + fusion_accounting becomes a feature-complete drop-in replacement for Odoo 19 Enterprise accounting (`account_accountant`, `account_reports`, `accountant`, `account_followup`, plus selected satellite modules) for clients deployed by Nexa Systems.
---
## 1. Context and Goals
### 1.1 Current State
`fusion_accounting` today is a thin AI co-pilot that depends on three Enterprise modules:
```python
'depends': ['account', 'account_accountant', 'account_reports', 'account_followup', 'mail']
```
It adds Claude/GPT-driven tool calling, a chat panel, a dashboard, an approval workflow, and rule-based automation on top of Odoo's accounting features. It does not own any core accounting capability — it orchestrates Enterprise's APIs.
### 1.2 Business Driver
Nexa Systems deploys Odoo to clients. The Enterprise subscription cost is a friction point. The goal is to deliver Enterprise-equivalent accounting capability on Odoo 19 Community via fusion_accounting, so clients can run on Community without losing core accounting features. fusion_accounting is **not** distributed publicly (no Odoo App Store listing); it ships only as part of a Nexa client engagement.
### 1.3 Scope of "Takeover"
The Enterprise modules being targeted, with verified file counts:
| Enterprise Module | Files | Role | Targeted Phase |
|---|---|---|---|
| `account_accountant` | 232 | bank-rec widget, journal dashboard, fiscal year, auto-reconcile, deferred revenue/expense, signing | Phases 1, 3 |
| `account_reports` | 618 | financial reports engine + 18 standard reports | Phase 2 |
| `accountant` | 26 | menu root + glue | Phase 0 |
| `account_followup` | 58 | customer payment reminders | Phase 5 |
| `account_asset` | n/a | asset register, depreciation | Phase 6 |
| `account_budget` | n/a | budgets vs actuals | Phase 6 |
| `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, `account_edi_*` | n/a | various | Phase 7+ (per client need) |
### 1.4 Existing Reference Material
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/` — current AI module (will be reorganized in Phase 0)
- `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/` — abandoned earlier attempt; contains 461 files of code that a Feb 2026 audit (in that folder's `AUDIT_REPORT.md`) determined to be near-verbatim copies of Odoo Enterprise. **The WIP code is not continued.** Its `__manifest__.py` is harvested as a feature checklist; its file structure as a target-architecture sanity check
- `/Users/gurpreet/Github/RePackaged-Odoo/accounting/` — pinned snapshot of Odoo 19 Enterprise accounting source; used as reference-only for clean-room rewrites and as the diff baseline for V19→V20 upgrades
### 1.5 Non-Goals
- Not building a public commercial product (no App Store distribution, no commercial licensing pricing model)
- Not replicating every Enterprise feature (Phase 7+ items are deferred until a real client needs them)
- Not maintaining backward compatibility with Odoo versions before 19
- Not rewriting Community `account` — fusion_accounting builds on top of, never replaces, Community accounting
---
## 2. Sub-Module Topology
fusion_accounting is split into independently installable sub-modules. Each has a single, well-bounded responsibility and a clear Enterprise counterpart it replaces.
### 2.1 The Sub-Modules
```mermaid
graph TD
community["account<br/>Odoo Community base"]
core["fusion_accounting_core<br/>shared fields, lock dates, fiscal year base,<br/>company config, security groups, analytic_mixin"]
bankrec["fusion_accounting_bank_rec<br/>reconcile widget + auto-reconcile engine"]
reports["fusion_accounting_reports<br/>financial reports engine + standard reports"]
dashboard["fusion_accounting_dashboard<br/>journal kanban, digest"]
followup["fusion_accounting_followup<br/>payment reminders"]
assets["fusion_accounting_assets<br/>asset register, depreciation"]
budget["fusion_accounting_budget<br/>budgets vs actuals"]
ai["fusion_accounting_ai<br/>Claude/GPT copilot + chat + dashboard tiles<br/>(current fusion_accounting code lives here)"]
migration["fusion_accounting_migration<br/>transitional Enterprise to fusion data wizard"]
meta["fusion_accounting<br/>meta-module: depends on all sub-modules"]
core --> community
bankrec --> core
reports --> core
dashboard --> core
followup --> reports
assets --> core
budget --> core
ai --> core
migration --> core
ai -.optional adapter calls.-> bankrec
ai -.optional adapter calls.-> reports
ai -.optional adapter calls.-> followup
ai -.optional adapter calls.-> assets
meta --> core
meta --> bankrec
meta --> reports
meta --> dashboard
meta --> followup
meta --> assets
meta --> budget
meta --> ai
meta -.transitional only.-> migration
```
### 2.2 Sub-Module Responsibilities
| Sub-module | Replaces | Owns | Phase |
|---|---|---|---|
| `fusion_accounting_core` | `accountant` (menu glue), shared bits of `account_accountant` | Shared field declarations on `account.move`/`account.bank.statement.line` (deferred fields, signing user), `fusion.fiscal.year`, lock-date wizard, security groups, settings page, `analytic_mixin` shared ownership | Phase 0 |
| `fusion_accounting_bank_rec` | `account_accountant` bank rec widget + `account_accountant/wizard/account_auto_reconcile_wizard.py` | OWL bank-rec widget, `fusion.reconcile.engine`, auto-reconcile wizard, reconcile model extensions | Phase 1 |
| `fusion_accounting_reports` | `account_reports` (entire 618-file engine + reports) | `fusion.account.report`, `fusion.account.report.line`, PDF templates, OWL report viewer, P&L/BS/TB/GL/Aged/Partner/CashFlow/Executive Summary | Phase 2 |
| `fusion_accounting_dashboard` | `account_accountant` journal dashboard, `accountant/data/account_accountant_data.xml`, digest | Journal kanban, digest tiles, "Needs Attention" data shape | Phase 3 |
| `fusion_accounting_followup` | `account_followup` | `fusion.followup.line`, follow-up workflow, multi-level reminders | Phase 5 |
| `fusion_accounting_assets` | `account_asset` | `fusion.asset`, `fusion.asset.group`, depreciation engine, asset-register report | Phase 6 |
| `fusion_accounting_budget` | `account_budget` | `fusion.budget`, budget-vs-actual report | Phase 6 |
| `fusion_accounting_ai` | (none — original) | Existing AI orchestrator, tools, chat panel, approval workflow, scoring, rules — moved verbatim from current `fusion_accounting` | Phase 0 |
| `fusion_accounting_migration` | (none — transitional) | Wizard that copies Enterprise-only data into fusion tables before Enterprise uninstall; safety guard that blocks Enterprise uninstall until wizard runs | Phase 0 |
| `fusion_accounting` (meta) | (none — packaging) | Empty shell; `depends` on every sub-module so a single install gets everything | Phase 0 |
### 2.3 Why Split (vs. monolith)
- Sub-modules can be enabled per client need (a small client without payroll-style assets installs core + bank_rec + reports + ai only)
- Each sub-module has independent test runs and CI (faster feedback loop)
- Each sub-module's cross-version upgrade is independent — `fusion_accounting_reports` can absorb V20 changes without touching `fusion_accounting_bank_rec`
- The AI sub-module stays cleanly separate, which makes it easy to keep using fusion's AI on top of Odoo Enterprise (when a client retains Enterprise) by installing `_ai` only
### 2.4 Open Sub-Module Naming Decisions
The meta-module retains the name `fusion_accounting` so existing client installs don't see a name change. Sub-modules use the `fusion_accounting_*` prefix consistently.
---
## 3. Data Preservation and Client Switchover Strategy
The single most important guarantee in this entire design: **client switchover from Odoo Enterprise to Odoo Community + fusion_accounting must lose zero accounting data**, especially bank reconciliations.
This section is the contract that backs that guarantee.
### 3.1 What Survives an Enterprise Uninstall Automatically
Verified by direct read of `RePackaged-Odoo/accounting/account/` source. These models and fields live in the Community `account` module and are unaffected by any Enterprise uninstall:
| Data | Storage | Verified Location |
|---|---|---|
| Bank reconciliation links | `account.partial.reconcile` | `account/models/account_partial_reconcile.py` |
| Full reconciliation markers | `account.full.reconcile` | `account/models/account_partial_reconcile.py` |
| Bank statement lines + `is_reconciled` flag | `account.bank.statement.line` | `account/models/account_bank_statement_line.py` |
| Invoices, bills, payments | `account.move`, `account.payment` | `account/models/account_move.py`, `account_payment.py` |
| Journal entries + lines | `account.move`, `account.move.line` | `account/models/account_move_line.py` |
| Chart of accounts | `account.account` | `account/models/account_account.py` |
| Taxes | `account.tax` | `account/models/account_tax.py` |
| Journals | `account.journal` | `account/models/account_journal.py` |
| Partners | `res.partner` | `base` |
| Reconciliation rule base | `account.reconcile.model` | `account/models/account_reconcile_model.py` |
| `checked` (Reviewed) flag on moves | `account.move.checked` | `account/models/account_move.py` line 315 |
**Critical observation about bank reconciliation in Odoo 19:** The Enterprise `account_accountant` module does **not** define a `bank.rec.widget` Python model in V19. The bank-rec widget is implemented entirely as frontend OWL components in `account_accountant/static/src/components/bank_reconciliation/`, with a thin `BankReconciliationService` (`bank_reconciliation_service.js`) that calls Community ORM methods directly. There is no Enterprise-side persistent storage for the widget. When the widget is removed (Enterprise uninstall), the underlying `account.partial.reconcile` rows are untouched; fusion's replacement widget reads the same rows and shows every historical reconciliation as already-matched.
(The Work-in-Progress code at `Work in Progress/fusion_accounting/models/bank_rec_widget.py` uses the V17/V18 architecture where `bank.rec.widget` was a `_auto = False` Python model. That architecture was removed in V19. Our Phase 1 implementation must match V19 architecture.)
**Verified Enterprise uninstall hook safety**: `account_accountant/__init__.py` line 32-42 only revokes security group assignments. There are zero destructive DB operations in the uninstall hook.
**Verified absence of cascade hazards**: grep for `ondelete='cascade'` in `account_accountant/models/` returns zero matches. No Enterprise model deletion can cascade-delete a reconciliation.
### 3.2 What Is Lost on Enterprise Uninstall (Without Mitigation)
| Enterprise-owned data | Importance | Mitigation Strategy |
|---|---|---|
| `account.fiscal.year` records (fiscal year closing definitions) | Medium | Migration wizard → `fusion.fiscal.year` |
| `account.asset` records + asset-line links on moves | High if assets used | Migration wizard → `fusion.asset` |
| `account.loan` records | Low (rare) | Migration wizard → `fusion.loan` (Phase 7+) |
| Budget records | Medium if used | Migration wizard → `fusion.budget` |
| Follow-up rule definitions + history | Medium | Migration wizard → `fusion.followup.*` |
| `account.move.deferred_move_ids`, `deferred_original_move_ids`, `deferred_entry_type` | **High** if deferred revenue/expense used — breaks the link between original and deferred postings | **Shared-field ownership** in `fusion_accounting_core` |
| `account.move.signing_user` (audit signer) | Medium | **Shared-field ownership** |
| `account.move.payment_state_before_switch` | Throwaway (technical) | Ignore |
| `account.reconcile.model.created_automatically` | Throwaway (single boolean) | Shared-field ownership in `_bank_rec` |
| `account.bank.statement.line.cron_last_check` | Throwaway (technical) | Ignore |
| Report XML records (P&L, BS structure) | None — reference data, not client data | fusion ships its own equivalents in `_reports` |
| Enterprise-only menus, actions | None — UI only | fusion installs its own |
### 3.3 Mitigation Pattern A: Shared-Field Ownership
For Enterprise-added fields on Community models (the `deferred_*`, `signing_user`, `created_automatically` fields), `fusion_accounting_core` declares **identical** field definitions with the **same** relation table names:
```python
class AccountMove(models.Model):
_inherit = "account.move"
deferred_move_ids = fields.Many2many(
comodel_name='account.move',
relation='account_move_deferred_rel', # identical relation table to Enterprise
column1='original_move_id',
column2='deferred_move_id',
copy=False,
)
deferred_original_move_ids = fields.Many2many(
comodel_name='account.move',
relation='account_move_deferred_rel',
column1='deferred_move_id',
column2='original_move_id',
copy=False,
)
deferred_entry_type = fields.Selection(
selection=[('expense', 'Deferred Expense'), ('revenue', 'Deferred Revenue')],
copy=False,
)
signing_user = fields.Many2one(comodel_name='res.users', copy=False)
payment_state_before_switch = fields.Char(copy=False)
```
**Mechanism**: Odoo's module registry tracks every module that declares a given field on a given model. When `account_accountant` uninstalls, Odoo only drops the column (or relation table) if no other installed module also declares it. Because `fusion_accounting_core` declares these identically, Odoo retains the column/table. Existing data values are preserved row-by-row.
**Caveat**: this pattern creates a schema dependency on Enterprise's choices. If Odoo ever renames `account_move_deferred_rel` in V20, both the Enterprise and fusion versions of that field break together — the migration is just `ALTER TABLE ... RENAME` in our migration script. We accept this risk because the alternative (renaming to fusion-namespaced fields) requires a much heavier migration of every existing row.
### 3.4 Mitigation Pattern B: Pre-Uninstall Migration Wizard
For Enterprise-only models (`account.asset`, `account.fiscal.year`, `account.loan`, budgets, followups), `fusion_accounting_migration` provides a wizard accessible from Settings → Fusion Accounting → Migrate from Enterprise.
The wizard:
1. Detects which Enterprise modules are installed
2. For each detected module, checks the corresponding fusion module is also installed (and prompts to install if missing)
3. Shows a preview: row counts per Enterprise table that will be migrated, listing target fusion table for each
4. On confirm, runs `INSERT INTO fusion_<table> SELECT ... FROM <enterprise_table>` for each migration step, preserving primary keys and `ir.model.data` xml_ids
5. Generates a migration report (record counts, any rows that failed validation, warnings)
6. Marks each Enterprise table as "migrated" via an `ir.config_parameter` flag (`fusion_accounting.migration.<module>.completed`)
7. Re-running the wizard is idempotent: already-migrated tables are skipped unless explicitly re-migrated
A separate **safety guard** in `fusion_accounting_migration` overrides `ir.module.module.button_immediate_uninstall` for Enterprise accounting modules; if the migration flag for that module is False and it has data, the uninstall is blocked with a UserError linking to the wizard.
### 3.5 Switchover Protocol (the operator workflow)
```mermaid
graph TD
start[Client on Odoo 19 Enterprise] --> step1["Install fusion_accounting meta-module<br/>while Enterprise still running"]
step1 --> step2["fusion_accounting_core declares shared fields<br/>Odoo registers dual ownership for deferred_*, signing_user, etc."]
step2 --> step3["Open Settings → Fusion Accounting → Migrate from Enterprise"]
step3 --> step4["Wizard shows preview: row counts per table"]
step4 --> step5["Operator confirms"]
step5 --> step6["Wizard copies asset, fiscal year, loan, budget, followup rows<br/>into fusion tables"]
step6 --> step7["Wizard generates migration report"]
step7 --> step8["Operator reviews report"]
step8 --> step9["Operator triggers Enterprise uninstall in dep-safe order:<br/>account_reports → account_followup → account_asset →<br/>account_budget → account_loans → account_accountant → accountant"]
step9 --> step10["Safety guard verifies migration flags before each uninstall"]
step10 --> done["Done: Client on Community + fusion_accounting<br/>Bank recs intact, deferred links preserved,<br/>migrated data accessible via fusion menus"]
```
### 3.6 Empirical Verification Test (Phase 0 deliverable)
The shared-field-ownership analysis and the inventory of "what survives" is based on reading source. Strong, but not conclusive. **Phase 0 includes a one-time empirical test**:
1. Provision a throwaway Odoo 19 Enterprise instance
2. Install full Enterprise accounting stack
3. Create representative test data:
- 50 invoices, 30 vendor bills, mix of paid/unpaid
- 15 bank reconciliations (full and partial)
- 5 deferred revenue entries with `deferred_move_ids` populated
- 3 fiscal year closings
- 10 asset records with depreciation history
- 2 budgets with actuals
- Multi-currency journal entries
- 1 cash-basis tax move
3. Take `pg_dump` snapshot
4. Uninstall Enterprise modules in dep-safe order **without** running the migration wizard (this is the worst-case test)
5. Diff schema and row counts before and after
6. Document findings in `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md`
7. If gaps are found vs. Section 3.2, expand the wizard scope or shared-field declarations accordingly
This test is a Phase 0 acceptance gate. The roadmap does not advance to Phase 1 until empirical verification confirms or expands the analysis.
### 3.7 Reverse-Migration Note
The reverse direction (client on Community + fusion adds an Enterprise subscription later) is not a hard requirement. fusion's runtime feature-gating (Section 4.4) handles the coexistence case: when Enterprise is detected, fusion's conflicting menus hide and the AI module continues running on top of Enterprise. A reverse-migration wizard can be added in Phase 7+ if a real client needs it.
### 3.8 Backup and Rollback
Every client deployment must include, before any switchover step:
- `pg_dump` of the live database
- Snapshot of all installed module versions (`SELECT name, latest_version FROM ir_module_module WHERE state='installed'`)
- Snapshot of `/mnt/extra-addons/` contents
Rollback procedure: restore DB from `pg_dump`, restore extra-addons from snapshot, restart Odoo. The migration wizard's "Generate Backup First" checkbox is checked by default and must be explicitly unchecked to skip.
---
## 4. Phased Roadmap
Each phase produces shippable value. Phase order is locked. Time estimates are rough single-engineer figures and are not binding deadlines — the user has explicitly stated "no rush, product-first".
### 4.1 Phase Overview
| Phase | Focus | Estimate | Depends On |
|---|---|---|---|
| 0 | Foundation, sub-module split, migration scaffold, empirical test | 1-2 wks | (none) |
| 1 | Bank reconciliation (priority) | 3-5 wks | 0 |
| 2 | Financial reports engine | 6-10 wks | 0 |
| 3 | Dashboard + fiscal year + lock dates | 2-3 wks | 1, 2 |
| 4 | Tax reports + returns | 3-5 wks | 2 |
| 5 | Payment follow-ups | 2-3 wks | 3, 4 |
| 6 | Assets + budgets | 3-5 wks | 5 |
| 7+ | Optional satellites (loans, check printing, batch payment, 3-way match, EDI, SEPA, SAFT, intrastat, online sync) | per item | 6 |
Phases 1 and 2 can run in parallel after Phase 0 (no shared scope).
### 4.2 Phase 0 — Foundation
No user-facing features. Pure plumbing so every later phase is cheaper.
**Scope:**
- Create sub-module scaffolding for `fusion_accounting_core`, `fusion_accounting_migration`, `fusion_accounting_ai`
- Move existing AI copilot code from current `fusion_accounting/` into `fusion_accounting_ai/`. Files moved: `models/`, `services/`, `controllers/`, `wizards/`, `data/`, `static/src/`, `views/`, `security/`, `report/`, `tests/`. Update internal imports
- Convert current `fusion_accounting/` into the meta-module: empty `__init__.py`, manifest with `depends = ['fusion_accounting_core', 'fusion_accounting_ai', ...]` (sub-modules added as later phases ship), no Python/JS/XML code of its own
- Strip hard Enterprise deps from `fusion_accounting_ai/__manifest__.py`. Replace `account_accountant`, `account_reports`, `account_followup` with `account` (Community). Add runtime detection (Section 4.4)
- Refactor every AI tool in `fusion_accounting_ai/services/tools/` that calls Enterprise APIs to go through an adapter layer (`services/adapters/bank_rec_adapter.py`, `reports_adapter.py`, `followup_adapter.py`). Adapters pick between Enterprise APIs (when present) and fusion native (when present) and a "feature-unavailable" stub (when neither)
- Create `fusion_accounting_core/models/account_move.py` with shared-field declarations (Section 3.3)
- Create `fusion_accounting_migration/` shell: empty wizard, safety guard scaffold (no migrations yet)
- Create `tools/check_odoo_diff.sh` script that diffs two pinned Odoo source snapshots and outputs a categorized change list
- Move security groups: `group_fusion_accounting_user/manager/admin` move from current to `fusion_accounting_core/security/`. Multi-company record rule on `fusion.accounting.session` added (currently missing per existing CLAUDE.md "Known Issues")
- Create per-sub-module `CLAUDE.md` (factor common rules from existing `fusion_accounting/CLAUDE.md`) and `UPGRADE_NOTES.md` template
- Run the empirical verification test (Section 3.6) on a throwaway V19 Enterprise instance
- CI: GitHub Actions or gitea workflow that runs `pytest` per sub-module on every push
**Exit criteria:**
- Current AI copilot installs and runs on pure Community (no Enterprise modules present)
- Current AI copilot still installs and runs alongside Enterprise (coexistence mode)
- Empirical test report committed
- All adapter calls wired (no direct Enterprise API access from AI tools)
- CI green
**Risks and mitigations:**
- **Risk**: moving code between modules breaks existing client deployments. **Mitigation**: meta-module install upgrade hook handles model-record reassignment via `ir_model_data` updates; pre-migration script runs on first install of Phase 0
- **Risk**: empirical test reveals gaps. **Mitigation**: scope-expand the migration wizard before declaring Phase 0 complete
### 4.3 Phase 1 — Bank Reconciliation
The user's stated priority. Replaces `account_accountant`'s bank-rec widget end-to-end.
**Scope:**
- Create `fusion_accounting_bank_rec/` sub-module
- **Frontend (mirror zone)**: build `static/src/components/bank_reconciliation/` mirroring the file layout of `account_accountant/static/src/components/bank_reconciliation/` (`kanban_controller`, `kanban_renderer`, `bank_reconciliation_service`, `apply_amount`, `bankrec_form_dialog`, `button`, `button_list`, `chatter`, `file_uploader`, `line_info_pop_over`, `line_to_reconcile`, `list_view`, `quick_create`, `reconciled_line_name`, `search_dialog`, `statement_line`, `statement_summary`). Mirror is structural — class names, file names, OWL component boundaries — not copy-paste. Implementation written fresh against documented Odoo behavior
- **Backend (abstract zone)**: `models/fusion_reconcile_engine.py` containing the matching algorithm (FIFO, partial reconcile, write-off lines, exchange-rate diff posting, tax splits). Original implementation against documented requirements. Operates on Community `account.partial.reconcile`
- `models/fusion_reconcile_model.py` extending Community `account.reconcile.model` with auto-rules, partner mapping, journal mapping. Shared-field ownership for `created_automatically`
- `wizards/auto_reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_auto_reconcile_wizard.py`
- `wizards/reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_reconcile_wizard.py`
- `views/bank_rec_widget_views.xml` defines the action that opens the OWL widget; `views/account_reconcile_model_views.xml` for rule editing
- Menu: "Bank Reconciliation" under fusion accounting menu, with feature-gate (hidden if `account_accountant` installed)
- AI integration: existing AI tools `get_unreconciled_bank_lines`, `find_similar_bank_lines`, `get_bank_line_details`, `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices` get refactored to call fusion's bank rec engine via `fusion_accounting_ai/services/adapters/bank_rec_adapter.py`. The Tier 3 tools `create_vendor_bill`, `register_bill_payment`, `create_expense_entry` keep their existing logic (they write to Community `account.move`)
- Migration: wizard validates `account.partial.reconcile` row count is preserved across switchover (read-only check, no migration needed)
- Tests:
- Unit (engine): matching correctness with fixtures (single partner, multi-partner, multi-currency, partial, exchange diff, write-off, tax split)
- Integration: install + create statement + reconcile via UI + assert `account.partial.reconcile` rows
- Tour (JS): smoke through the full bank rec workflow
- Migration: install Enterprise, create 10 reconciliations, install fusion, uninstall Enterprise, assert reconciliations visible in fusion widget
**Exit criteria:**
- Community + fusion_accounting user can reconcile bank statements with feature parity to Enterprise
- All Phase 1 tests passing
- Migration round-trip (Enterprise → fusion) preserves every reconciliation
- AI tools work against fusion bank rec engine
### 4.4 Phase 2 — Financial Reports Engine
The largest phase. Replaces `account_reports` (618 files).
**Scope:**
- Create `fusion_accounting_reports/` sub-module
- **Backend (abstract zone)**: `models/fusion_account_report.py` defining `fusion.account.report` and `fusion.account.report.line`. Generic engine that takes a report definition (sections, filters, computation rules) and produces report rows from `account.move.line` data. Original computation kernel — does not copy `account_reports`'s `account_report.py`
- **Backend (mirror zone)**: report definition records mirror Odoo's data files. Files: `data/balance_sheet.xml`, `data/profit_and_loss.xml`, `data/cash_flow_report.xml`, `data/general_ledger.xml`, `data/trial_balance.xml`, `data/aged_partner_balance.xml`, `data/partner_ledger.xml`, `data/executive_summary.xml`, `data/sales_report.xml`, `data/multicurrency_revaluation_report.xml`, `data/bank_reconciliation_report.xml`, `data/deferred_reports.xml`, `data/journal_report.xml`, `data/customer_statement.xml`. XML structure follows Odoo's so V20 ports are diff-and-apply
- **Frontend (mirror zone)**: `static/src/components/` mirrors `account_reports/static/src/components/` — filters bar, comparison toggle, drill-down, foldable sections, footnotes
- **PDF export**: QWeb templates in `report/` mirror Odoo's `data/pdf_export_templates.xml` and `data/customer_reports_pdf_export_templates.xml`. Asset bundle `fusion_accounting_reports.assets_pdf_export` defined in manifest
- Performance: denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post). Drill-down lazy-loads line detail. Per-(company, period, filter_hash) cache invalidated on `account.move.line` write
- Multi-company, multi-currency, cash-basis toggle — all handled by the engine
- AI integration: tools `get_profit_loss`, `get_balance_sheet`, `get_trial_balance`, `get_aged_receivables`, `get_aged_payables`, `get_partner_ledger`, `answer_financial_question` refactored via `reports_adapter.py`
- Migration: report XML records are reference data, not client data. fusion ships its own equivalent records; no migration of report definitions needed. Existing journal entry data (which the reports compute from) is in Community `account` and untouched
- Tests:
- Unit (engine): SQL-fixture comparisons (compute report → compare against hand-rolled SQL) for every standard report, every filter combination
- Integration: install + post entries + open report + assert numbers
- Multi-currency: single + multi + revaluation period
- Performance: 1k / 10k / 100k journal lines, assert P95 latency under 5s
- PDF: render every report to PDF, assert no QWeb errors
- Tour: smoke through report viewer with filters
**Exit criteria:**
- All 14 standard reports rendering correctly (numerical match against SQL fixtures)
- PDF export working for every report
- Performance targets met
- AI tools backed by fusion reports
### 4.5 Phase 3 — Dashboard + Fiscal Year + Lock Dates
**Scope:**
- Create `fusion_accounting_dashboard/` sub-module
- **Journal kanban dashboard**: mirror layout of `account_accountant/views/account_journal_dashboard_views.xml`. Computed metrics in `models/account_journal.py` extending Community `account.journal` with kanban-state fields (counts, totals, action buttons). Original computation; mirror UI
- `models/fusion_fiscal_year.py` defining `fusion.fiscal.year` (replaces `account.fiscal.year`)
- Fiscal year wizard: closing workflow, period locks, initial-balance carry-forward
- Lock date wizard: clean-room rewrite of `account_accountant/wizard/account_change_lock_date.py`. Operates on Community `account.lock_exception` model (verified at `account/models/account_lock_exception.py`)
- Digest tile contributions: extend `mail.digest` with fusion accounting metrics (revenue, expense, AR, AP)
- "Needs Attention" panel — connect data already returned by current AI dashboard endpoint to a frontend rendering. Dashboard endpoint (currently in `fusion_accounting_ai/controllers/`) moves to `fusion_accounting_dashboard/controllers/`; AI module's dashboard tiles call dashboard's endpoint via adapter
- Tests:
- Journal dashboard kanban metrics match expected values for fixtures
- Fiscal year close locks subsequent edits
- Lock date wizard prevents posting before lock date
- Digest renders without errors
**Exit criteria:**
- Journal dashboard at parity with Enterprise
- Fiscal year management functional
- Lock dates enforced
- Digest emails delivering
### 4.6 Phase 4 — Tax Reports + Returns
**Scope:**
- Build on Phase 2 reports engine; tax reports are specialized `fusion.account.report` records
- Generic tax report (`data/generic_tax_report.xml`) with country-specific overrides
- Canadian HST: unify the existing HST workflow in `fusion_accounting_ai` (currently in `services/prompts/domain_prompts.py` and tool functions) with the new tax report engine. The existing `find_missing_itc_bills`, `get_overdue_invoices`, etc. tools call into the tax report
- `fusion.account.return` model (replaces `account.return` from `account_reports`) tracking tax return drafts, submitted state, payment status
- Return creation wizard, return submission wizard, return generic payment wizard — clean-room rewrites of the corresponding `account_reports` wizards
- Tax closing entries (move generation on tax period close)
- Tests:
- Tax report numbers match SQL fixtures
- Return workflow: draft → review → submitted → paid
- HST 4-phase workflow (per existing CLAUDE.md) end-to-end
**Exit criteria:**
- Generic tax report functional
- Canadian HST workflow runs through fusion (no Enterprise dependency)
- Return tracking working
### 4.7 Phase 5 — Payment Follow-ups
**Scope:**
- Create `fusion_accounting_followup/` sub-module
- `models/fusion_followup_line.py` (replaces `account_followup.followup.line`)
- `models/res_partner.py` extends `res.partner` with follow-up level, last reminder date, dunning history
- `models/account_move.py` extends `account.move` with follow-up state (overdue days, last reminder)
- Multi-level reminder workflow: each level has email template, days delay, optional SMS, optional `mail.activity`
- `wizards/followup_send_wizard.py` for manual sends; cron for automatic
- Follow-up report (PDF): clean-room template
- AI integration: `fusion_accounting_ai` adds tools `draft_followup_message_for_partner`, `send_followup_to_overdue_partners` calling the followup engine via adapter
- Migration: wizard copies `account_followup.followup.line` and partner-level follow-up state into `fusion.followup.line` and shared-field-owned partner fields
- Tests:
- Multi-level escalation
- Email template rendering
- SMS delivery (mock)
- AI-drafted message quality (snapshot tests)
**Exit criteria:**
- Multi-level dunning working
- Migration from `account_followup` preserves history
### 4.8 Phase 6 — Assets + Budgets
**Scope:**
- Create `fusion_accounting_assets/` sub-module
- `models/fusion_asset.py` (replaces `account.asset`)
- `models/fusion_asset_group.py` (replaces `account.asset.group`)
- Depreciation engine: linear, declining, custom schedules. Original implementation
- `wizards/asset_modify.py` for revaluation, sale, disposal — clean-room rewrite
- Asset register report integrates with Phase 2 reports engine
- Migration wizard copies `account.asset` rows + line links on moves
- Create `fusion_accounting_budget/` sub-module
- `models/fusion_budget.py` (replaces `budget.analytic`)
- Budget vs actual report integrates with Phase 2 reports engine
- Migration wizard copies budget records
- Tests for both
**Exit criteria:**
- Asset depreciation schedules computed correctly
- Disposal generates correct GL entries
- Budget variance report functional
### 4.9 Phase 7+ — Optional Satellites
Not scheduled. Each is its own brainstorming → spec → plan → implementation cycle when a real client needs it. Candidate satellite modules:
- `fusion_accounting_loans` — loan amortization
- `fusion_accounting_check_printing` — check printing
- `fusion_accounting_batch_payment` — batch payments
- `fusion_accounting_3way_match` — purchase 3-way match
- `fusion_accounting_edi` — UBL/CII e-invoicing
- `fusion_accounting_sepa` — SEPA direct debit + credit transfer
- `fusion_accounting_saft` — SAFT export
- `fusion_accounting_intrastat` — intrastat report
- `fusion_accounting_iso20022` — ISO 20022 payment files
- `fusion_accounting_online_sync` — online bank sync (Yodlee/Plaid integration)
### 4.10 Per-Phase Deliverables (uniform)
Each phase produces:
1. A separate **design document** in `docs/superpowers/specs/YYYY-MM-DD-fusion-accounting-phase-N-*-design.md` (brainstormed in its own session)
2. A separate **implementation plan** via the `writing-plans` skill
3. Working code with passing tests
4. Entry in the sub-module's `UPGRADE_NOTES.md` listing Odoo source files referenced and intentional deltas
5. Coverage in `fusion_accounting_migration` if the phase replaces an Enterprise data-bearing model
6. Manual QA checklist (install, migrate, smoke, uninstall) committed to the sub-module
7. Update to the meta-module `__manifest__.py` adding the new sub-module to its `depends`
---
## 5. Architecture Rules
These rules apply to every sub-module and every phase. They are the discipline that keeps V19→V20 upgrades mechanical and prevents the WIP-style descent into copied code with stale architecture.
### 5.1 The Hybrid Split
Every sub-module has two zones with different rules:
**Mirror zone** (follows Odoo structure 1:1):
- XML view definitions and xpath targets
- Frontend OWL component file layout, service registration, widget props
- PDF/QWeb templates: structure, CSS class names
- Wizard flows: step order, field names where they appear in views
- Asset bundle declarations in manifests
**Locations**: `views/`, `static/src/components/`, `report/` QWeb templates, `wizards/*_views.xml`, `__manifest__.py` asset bundles
**Abstract zone** (our own design, insulated from Odoo internals):
- Core algorithms: matching, aggregation, computation, depreciation
- Data access helpers
- Business validation, approval flows
- AI integration adapters
- Engine classes (e.g. `fusion_reconcile_engine.py`)
**Locations**: `models/fusion_*_engine.py`, `services/`, `controllers/` (business logic only — request routing is mirror-zone)
**Rule of thumb**: if Odoo refactors it every release, mirror it. If it's been stable for a decade (FIFO matching, accrual rules, depreciation math), abstract it.
### 5.2 Naming Conventions
| Thing | Convention | Example |
|---|---|---|
| Model `_name` | `fusion.*` prefix always | `fusion.bank.rec.widget`, `fusion.account.report`, `fusion.fiscal.year` |
| Model `_inherit` on Community | Keep `account.*` (no rename) | `class AccountMove(models.Model): _inherit = 'account.move'` |
| Model `_inherit` on Enterprise | **Forbidden** — duplicate fields via shared-field-ownership instead | n/a |
| Python class names | `Fusion` prefix for new models | `FusionBankRecWidget`, `FusionAccountReport` |
| Table names (auto-derived) | Follows model prefix | `fusion_bank_rec_widget`, `fusion_account_report` |
| XML record IDs | `fusion_*` prefix | `<record id="fusion_view_bank_rec_form">` |
| Menu IDs | `fusion_menu_*` prefix | Avoids collision with `account_menu_*` |
| Action IDs | `fusion_action_*` | Same |
| Controller routes | `/fusion_accounting/*` | Already in use; carries forward |
| Security groups | `group_fusion_*` | Already in use |
| Field names on inherited Community models | Identical to Enterprise if shared-field-owned; otherwise `x_fusion_*` prefix | `deferred_move_ids` (shared), `x_fusion_ai_confidence` (our own) |
| CSS/SCSS classes | `.fusion_*` or `.o_fusion_*` | Avoids Bootstrap/Odoo collision |
| `ir.config_parameter` keys | `fusion_accounting.*` | Already in use |
### 5.3 Coexistence Detection
Every sub-module that replaces an Enterprise feature must detect Enterprise at install time and at runtime, and feature-gate accordingly.
**Helper function** (lives in `fusion_accounting_core/models/ir_module_module.py`):
```python
class IrModuleModule(models.Model):
_inherit = "ir.module.module"
@api.model
def _fusion_is_enterprise_accounting_installed(self):
return bool(self.sudo().search_count([
('name', 'in', ['account_accountant', 'account_reports', 'accountant']),
('state', '=', 'installed'),
]))
```
**Three coexistence modes per sub-module**, configurable in Settings → Fusion Accounting → Integration Mode:
1. **Replace** (default when Enterprise absent): fusion menus visible, fusion views primary, fusion workflows active
2. **Augment** (default when Enterprise present): fusion menus hidden, fusion widgets disabled, fusion AI module continues to call Enterprise APIs via adapters
3. **Force-replace** (manual): fusion menus visible alongside Enterprise (operator's choice — risk of confusion, used during migration)
Menu visibility achieved via `groups` attribute referencing a dynamically-computed group (`group_fusion_show_menus_when_enterprise_absent`), implemented as a `@api.depends` computed field on `res.users` that recomputes membership when modules change state.
### 5.4 Zero Hard Enterprise Dependencies
After Phase 0:
- `fusion_accounting_core/__manifest__.py`: `depends = ['account', 'mail', 'web_tour']`
- `fusion_accounting_ai/__manifest__.py`: `depends = ['fusion_accounting_core']` plus `external_dependencies` for `anthropic`, `openai`
- Every other `fusion_accounting_*/__manifest__.py`: `depends = ['fusion_accounting_core']` plus fusion siblings as needed (e.g., `_followup` depends on `_reports`)
**No `fusion_accounting_*` module may have `account_accountant`, `account_reports`, `accountant`, `account_followup`, `account_asset`, `account_budget`, `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, or any `account_edi_*` in its `depends`.**
Runtime detection (Section 5.3) replaces compile-time dependency.
### 5.5 Canonical Sub-Module Directory Layout
```
fusion_accounting_<feature>/
├── __manifest__.py
├── __init__.py
├── CLAUDE.md # module-specific context for Cursor agent
├── UPGRADE_NOTES.md # Odoo version deltas absorbed
├── README.md # operator-facing install/configure/troubleshoot
├── docs/
│ └── odoo_diff/ # snapshots of relevant Odoo source for diffing
│ └── v19/
│ └── account_accountant__bank_reconciliation_service.js
├── controllers/
│ └── __init__.py
├── data/
├── demo/
├── i18n/
├── models/
│ ├── __init__.py
│ ├── fusion_<feature>_engine.py # abstract zone: core algorithm
│ ├── account_<x>.py # mirror zone: inherits Community model
│ └── fusion_<y>.py # mirror zone: our own models
├── report/
├── security/
│ ├── ir.model.access.csv
│ └── <feature>_security.xml
├── services/ # AI / heavy business logic
├── static/
│ ├── description/
│ │ ├── icon.png
│ │ └── index.html
│ └── src/
│ ├── components/ # mirror zone: OWL components
│ ├── scss/
│ ├── services/ # frontend services
│ └── views/
├── tests/
│ ├── __init__.py
│ ├── test_<feature>_engine.py # abstract zone unit tests
│ ├── test_<feature>_integration.py # full-stack integration tests
│ ├── test_migration.py # Enterprise → fusion round-trip
│ └── tours/
├── views/
├── wizards/
└── migrations/ # Odoo version migration scripts (XX.0.x.y.z)
└── 19.0.1.0.0/
├── pre-migration.py
└── post-migration.py
```
### 5.6 Odoo 19 Gotchas (carried forward, factored across CLAUDE.md files)
The current `fusion_accounting/CLAUDE.md` documents Odoo 19-specific traps that have already cost time. All carry forward:
- Search views: no `string` attribute on `<search>` or `<group>`; group-by filters need `domain="[]"`; `<separator/>` before `<group>`
- OWL client actions: `static props = ["*"]` (accept any), not `static props = []` (accept none)
- OWL rich HTML: `markup()` and `t-out` unreliable in Odoo 19; use `onMounted` + `onPatched` + direct `innerHTML`
- Cron `safe_eval`: no `import` statements; use `datetime.datetime.now()` not `from datetime import datetime`
- `read_group()` deprecated → use `_read_group()`
- `ir_config_parameter` Selection field migrations: stored DB value must match new options or Settings page crashes
- `implied_ids` on groups only applies to newly-added users — existing users need SQL backfill
- `TransientModel` in controllers: use `.new({...})` not `.create({...})`
- HTTP routes: `type="jsonrpc"`, not `type="json"` (deprecated)
- `res.config.settings`: only boolean/integer/float/char/selection/many2one/datetime; no Date fields
- `res.groups`: no `users` field, no `category_id` field
- Search views: no `group expand="0"` syntax
- SCSS imports: `@import "./partial"` is forbidden in Odoo 19 custom SCSS; register every SCSS file as a separate entry in `web.assets_backend`
- Card styling: don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)`; use Odoo's kanban explicit-hex pattern with custom-property tokens
- Dark mode: branch on `$o-webclient-color-scheme` at SCSS compile time, not runtime DOM class
- Asset bundle cache busting: bump manifest version + `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` if needed
These rules belong in each sub-module's `CLAUDE.md` (the relevant subset) plus the workspace-root `CLAUDE.md` (common rules).
### 5.7 Manifest Versioning and Branch Strategy
- Per-sub-module manifest: `'version': 'XX.0.x.y.z'` where XX is the Odoo version (e.g., `19.0.1.0.0` for V19, first release)
- Bump `XX` on Odoo version change (V19 → V20 → V21)
- Bump `x` on major feature additions within an Odoo version
- Bump `y` on minor features and bug fixes
- Bump `z` on hotfixes
- Git branches: `main-v19`, `main-v20`, etc. Each client deployment is pinned to one branch
- Release tags: `<sub-module>/v19.0.1.0.0` per sub-module per release
---
## 6. Cross-Version Upgrade Workflow
This section is the user's stated top concern: how to keep porting Enterprise changes forward each year without it becoming a rewrite project.
### 6.1 Snapshot Discipline
Maintain one pinned snapshot of the relevant Odoo source per Odoo version:
```
/Users/gurpreet/Github/RePackaged-Odoo/
├── accounting-v19/ # current snapshot (already in place at accounting/)
├── accounting-v20/ # added when V20 ships
├── accounting-v21/ # added when V21 ships
```
Older snapshots are never deleted — they are the diff source for upgrade work.
### 6.2 Annual Upgrade Ritual
When Odoo V<N+1> ships:
1. Add the snapshot folder
2. For each fusion sub-module:
- Run `tools/check_odoo_diff.sh <enterprise_module> v<N> v<N+1> > reports/v<N+1>_<module>_diff.md`
- Manually classify each change in the diff:
- `[MIRROR]` — apply the same hunk to fusion's mirror-zone files (mechanical)
- `[ABSTRACT]` — verify the Odoo public API surface our adapter uses still works; update the adapter if signatures changed
- `[NEW FEATURE]` — decide port or defer
- `[BUG FIX]` — port (usually cheap)
- `[REMOVED]` — clean up our equivalent
- Apply mirror-zone hunks (these are usually direct `patch -p1` operations)
- Write Odoo version migration scripts in `migrations/<N+1>.0.0.0.0/` for any data-shape changes
- Update `UPGRADE_NOTES.md`
- Run all tests
3. Tag releases on `main-v<N+1>` branch
4. Pilot upgrade on one client first; ratchet outward
### 6.3 `UPGRADE_NOTES.md` Template
```markdown
# UPGRADE_NOTES — fusion_accounting_bank_rec
## V19.0.1.0.0 (initial)
- Ported from: account_accountant V19 (snapshot date 2026-04-18)
- Mirror sources:
- account_accountant/static/src/components/bank_reconciliation/* → fusion_accounting_bank_rec/static/src/components/bank_reconciliation/*
- account_accountant/wizard/account_auto_reconcile_wizard.py → fusion_accounting_bank_rec/wizards/auto_reconcile_wizard.py (clean-room)
- Abstract zone:
- models/fusion_reconcile_engine.py — original implementation
- Intentional deltas from Odoo:
- AI hook in reconcile step (calls fusion_accounting_ai.suggest_match adapter)
- Different default colour palette (SCSS var overrides)
## V20.0.x.y.z (planned, not yet shipped)
- Odoo changes account_accountant V19 → V20 absorbed:
- [MIRROR] kanban_renderer.js: column layout changed, applied identical hunk
- [ABSTRACT] account.reconcile.model._apply_lines_for_bank_widget signature changed — updated adapter
- [NEW FEATURE] batch-reconcile-across-journals — deferred to V20.1
- Migration scripts:
- migrations/20.0.0.0.0/pre-migration.py: rename column foo → bar
```
### 6.4 `tools/check_odoo_diff.sh` Specification
The script lives at `fusion_accounting/tools/check_odoo_diff.sh` (workspace root, shared across sub-modules). Usage:
```bash
tools/check_odoo_diff.sh <enterprise_module> <from_version> <to_version> [<output_file>]
```
Behavior:
- Runs `diff -ruN /Users/gurpreet/Github/RePackaged-Odoo/accounting-<from>/<module> /Users/gurpreet/Github/RePackaged-Odoo/accounting-<to>/<module>`
- Splits output into per-file sections
- For each file, classifies based on file path: `views/` and `static/src/components/` and `report/``[MIRROR]` candidate; `models/*_engine.py`-like → `[ABSTRACT]` review; new files → `[NEW FEATURE]` review
- Outputs a markdown report with per-file sections and classification suggestions
- Exit code: 0 if no changes, non-zero if changes (CI can use to flag annual upgrades)
### 6.5 Pinning and Rollback
- Git: `main-v19`, `main-v20`, etc. branches in fusion repo. Each client stays on their pinned Odoo version
- Manifest version pinned per sub-module per Odoo version
- Client deployment: never auto-upgrade. Upgrade is a deliberate, tested, per-client migration
- Rollback: restore DB from `pg_dump` taken before upgrade, restore `fusion_accounting_*` checkout from git tag, restart Odoo
### 6.6 Cross-Version Migration Scripts
Odoo's standard migration mechanism applies. Each sub-module has a `migrations/` folder with subfolders named after manifest versions. Scripts run automatically when the manifest version bumps in the database vs. on disk.
```python
# fusion_accounting_assets/migrations/20.0.0.0.0/pre-migration.py
def migrate(cr, version):
# V20 renamed fusion_asset.original_value to fusion_asset.acquisition_cost
cr.execute("ALTER TABLE fusion_asset RENAME COLUMN original_value TO acquisition_cost")
```
---
## 7. AI Integration, Testing, Documentation
### 7.1 AI Integration
The AI copilot (existing `fusion_accounting/services/`, `fusion_accounting/static/src/`, `fusion_accounting/controllers/` etc.) moves to `fusion_accounting_ai/` in Phase 0 and stays original code. What changes:
**Adapter pattern**: every AI tool that today calls Enterprise APIs gets routed through an adapter:
```
fusion_accounting_ai/services/adapters/
├── bank_rec_adapter.py
├── reports_adapter.py
├── followup_adapter.py
├── assets_adapter.py
└── _registry.py
```
Adapter behavior (uniform pattern across all adapters):
```python
class BankRecAdapter:
def __init__(self, env):
self.env = env
def list_unreconciled_lines(self, journal_id, limit=100):
# Prefer fusion native if installed
if 'fusion.bank.rec.widget' in self.env.registry:
return self.env['fusion.bank.rec.widget'].sudo().get_unreconciled(journal_id, limit)
# Fall back to Enterprise if installed
elif self.env['ir.module.module']._fusion_is_module_installed('account_accountant'):
return self._enterprise_unreconciled_lines(journal_id, limit)
# Last resort: pure Community search
else:
return self.env['account.bank.statement.line'].sudo().search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
], limit=limit)
```
This pattern means `fusion_accounting_ai` always works, regardless of which other modules are installed. The AI tool functions in `fusion_accounting_ai/services/tools/` get refactored once in Phase 0 to call adapters; subsequent phases just enrich the adapters.
**New AI capabilities unlocked by native implementations**: each native phase exposes engine internals to AI tools that Enterprise didn't expose cleanly. Examples:
- Phase 1: AI gets access to fusion's match-confidence scores
- Phase 2: AI can request a report computation with custom comparison periods on the fly
- Phase 4: AI has direct access to tax-grid-by-account decomposition
- Phase 5: AI drafts follow-up messages with full payment history context
**Existing AI patterns carry forward unchanged**:
- Tool tiering (Tier 1 / 2 / 3 with auto-promotion)
- Provider pinning per session (Claude vs OpenAI consistency within a session)
- Tier 3 approval flow with `pending_approval` placeholder swap on approve/reject
- Rich-text chat output via `mdToHtml()` and `innerHTML` injection
- Interactive `fusion-table` blocks for actionable results
- Session ownership / multi-company record rules (the `fusion.accounting.session` rule that's currently missing gets added in Phase 0)
### 7.2 Testing Strategy
Every phase must pass these test categories before exit:
| Category | Scope | Where it lives |
|---|---|---|
| **Unit (engine)** | Pure-Python; no Odoo DB. Algorithm correctness with fixtures | `tests/test_<feature>_engine.py` |
| **Integration (Odoo TestCase)** | Full Odoo DB; install + create data + exercise workflow + assert state | `tests/test_<feature>_integration.py` |
| **Migration round-trip** | Install Enterprise, create Enterprise-only data, install fusion, run wizard, uninstall Enterprise, assert data integrity | `tests/test_migration.py` |
| **Tour (JS)** | End-to-end widget UI smoke | `tests/tours/<feature>_tour.js` |
| **Performance** | Phase 2 reports especially; assert P95 latency at 1k/10k/100k rows | `tests/test_<feature>_performance.py` |
| **Multi-matrix** | Single-company, multi-company, multi-currency, cash-basis on/off | parameterized within other tests |
CI runs all tests on every push. A nightly job runs migration tests against a fixture Enterprise DB.
### 7.3 Documentation Deliverables
Per sub-module:
- `CLAUDE.md` — module-specific context for Cursor/AI assistance
- `UPGRADE_NOTES.md` — Odoo version porting log
- `README.md` — operator-facing: install, configure, troubleshoot, common gotchas
- One screencast or animated GIF per major user workflow, in `static/description/`
- Per-feature feature flag documentation in `CLAUDE.md` if applicable
Workspace-root documentation:
- `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` — common Odoo 19 conventions (already substantial; carries forward)
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md` — meta-module overview pointing at sub-modules
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/docs/superpowers/specs/` — design and plan docs (this doc and future ones)
### 7.4 Security
- Three groups carry forward from existing module: `group_fusion_accounting_user/manager/admin`. Move from current location to `fusion_accounting_core/security/security.xml` in Phase 0
- Auto-assignments from Community accounting groups: `account.group_account_user` → fusion User; `account.group_account_manager` → fusion Admin (already in place)
- Multi-company record rules on every fusion model with `company_id`. Add the missing rule on `fusion.accounting.session` in Phase 0
- ACLs in `security/ir.model.access.csv` per sub-module, scoped to that sub-module's models only
- Approve/reject endpoints continue to use `auth='user'` with imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`)
### 7.5 Performance Considerations (Phase 2 in particular)
Odoo Enterprise reports have known performance issues on large databases. The Phase 2 design doc must lock in:
- Denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post)
- Lazy-load line detail (drill-down fetches separately, not all at once)
- Cache report runs per `(company_id, period, filter_hash)` with invalidation on `account.move.line` write/post/cancel
- Parallel computation across companies in multi-company reports
- SQL query review (no Python aggregation of large result sets)
### 7.6 Multi-Company, Multi-Currency, Analytic
Not a separate phase. Woven into every phase's exit criteria:
- Every fusion model with company-scoped data has `company_id` field and a multi-company record rule
- Every monetary field pairs with `currency_id`
- `analytic_mixin` (currently in `account_accountant/models/analytic_mixin.py`): declared in `fusion_accounting_core` via shared-field-ownership pattern so analytic tags survive Enterprise uninstall
### 7.7 Localization
Canadian HST is built into the existing AI module (`fusion_accounting_ai/services/prompts/domain_prompts.py`) and carries forward. Other localizations are deferred:
- Each country-specific tax report becomes a `fusion.account.report` record in `fusion_accounting_reports/data/<country>_<report>.xml`
- Country-specific chart of accounts: continue to use Odoo's `account.chart.template` mechanism (Community)
- New countries are added on demand, per client engagement
### 7.8 Hosting and Deployment
Out of scope for this design doc; covered in workspace-root operational docs. fusion_accounting deploys to the existing Nexa Odoo infrastructure (per existing `fusion_accounting/CLAUDE.md`: `odoo-westin` for Westin Healthcare, equivalents for other clients). Deploy commands in CLAUDE.md carry forward.
---
## 8. Acceptance Criteria for This Roadmap
This roadmap is considered "done" (and ready for the first writing-plans session for Phase 0) when:
- The user has reviewed this document and signed off
- No unresolved ambiguity remains in any of the locked decisions (sub-module topology, data preservation, phase order, architecture rules, upgrade workflow)
- The empirical verification test (Section 3.6) is scheduled as part of Phase 0 and not deferred
The next session's deliverable will be the Phase 0 implementation plan (via the `writing-plans` skill), which will turn Section 4.2 into actionable, testable tasks.
---
## 9. Open Questions Deferred to Future Sessions
Items consciously left open here, to be resolved in their respective phase brainstorming sessions:
- Phase 1: exact UI deltas from Odoo's bank rec widget (colour palette, AI confidence badge placement, keyboard shortcuts)
- Phase 2: report definition data format (XML mirroring Odoo vs. our own simpler format)
- Phase 2: caching layer implementation (in-memory vs. Redis vs. PostgreSQL materialized views)
- Phase 4: which non-Canadian tax jurisdictions to seed
- Phase 5: SMS provider integration (Twilio? `mail.sms` Odoo built-in?)
- Phase 6: depreciation methods to support beyond linear/declining (sum-of-years-digits, units-of-production)
- Phase 7+: which satellites have actual client demand right now
---
## 10. References
- Workspace root: `/Users/gurpreet/Github/Odoo-Modules/`
- Current AI module: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/`
- Current AI module conventions: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md`
- Workspace conventions: `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md`
- WIP code (not continued): `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/`
- WIP audit report: `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/AUDIT_REPORT.md`
- Pinned Odoo source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/`
- Plan file (this session): `/Users/gurpreet/.cursor/plans/fusion_accounting_takeover_roadmap_c851fdb4.plan.md`

View File

@@ -168,5 +168,26 @@
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
<!-- Cron Job: ADP Hold Expiry Reminders (2026-04).
For each on-hold ADP case:
- Sends monthly reminder to the CLIENT (authorizer excluded per
2026-04 authorizer email policy). Cadence:
fusion_claims.adp_hold_reminder_interval_days (default 30).
- Sends ONE final warning to client + authorizer when funding
expires within fusion_claims.adp_hold_final_warning_days_before_expiry
(default 30 days before expiry).
- Silently skips cases where the client has no email on file.
Flags reset automatically when the case resumes from hold. -->
<record id="ir_cron_adp_hold_expiry_reminders" model="ir.cron">
<field name="name">Fusion Claims: ADP Hold Expiry Reminders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_adp_hold_expiry_reminders()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=9, minute=30, second=0)"/>
</record>
</data>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@@ -12,12 +12,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fc-landscape .text-center { text-align: center; }
.fc-landscape .text-end { text-align: right; }
@@ -26,7 +30,7 @@
.fc-landscape .client-bg { background-color: #fff3e0; }
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-landscape .note-row { font-style: italic; }
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fc-landscape .totals-table { border: 1px solid #000; }

View File

@@ -11,7 +11,11 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#005a83'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
@@ -25,7 +29,7 @@
.fc-report .client-bg { background-color: #fff3e0; }
.fc-report .section-row { background-color: #f8f8f8; font-weight: bold; }
.fc-report .note-row { font-style: italic; }
.fc-report h4 { color: #005a83; margin: 0 0 15px 0; }
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; }
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
</style>

View File

@@ -12,19 +12,22 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
<style>
.fc-contract { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
.fc-contract h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-contract h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-contract h4 { color: #0066a1; margin: 0 0 10px 0; font-size: 13pt; }
.fc-contract h1 { color: <t t-out="primary"/>; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-contract h2 { color: <t t-out="primary"/>; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-contract h4 { color: <t t-out="primary"/>; margin: 0 0 10px 0; font-size: 13pt; }
.fc-contract p { margin: 2px 0; text-align: justify; }
.fc-contract .intro { margin-bottom: 8px; font-size: 8pt; }
.fc-contract ul { margin: 2px 0 2px 15px; padding: 0; }
.fc-contract li { margin-bottom: 1px; }
.fc-contract table { width: 100%; border-collapse: collapse; }
.fc-contract table.bordered, .fc-contract table.bordered th, .fc-contract table.bordered td { border: 1px solid #000; }
.fc-contract th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
.fc-contract th { background-color: <t t-out="primary"/>; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
.fc-contract td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
.fc-contract .text-center { text-align: center; }
.fc-contract .text-end { text-align: right; }
@@ -48,7 +51,7 @@
.fc-contract .sig-row { display: table; width: 100%; margin-bottom: 20px; }
.fc-contract .sig-col { display: table-cell; width: 48%; vertical-align: top; }
.fc-contract .sig-spacer { display: table-cell; width: 4%; }
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: #0066a1; margin-bottom: 8px; border-bottom: 2px solid #0066a1; padding-bottom: 3px; }
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: <t t-out="primary"/>; margin-bottom: 8px; border-bottom: 2px solid <t t-out="primary"/>; padding-bottom: 3px; }
.fc-contract .sig-field { margin-bottom: 12px; }
.fc-contract .sig-line { border-bottom: 1px solid #000; min-height: 25px; }
.fc-contract .sig-label { font-size: 7pt; color: #666; margin-top: 2px; }

View File

@@ -15,13 +15,16 @@
<t t-set="is_deduction" t-value="doc.x_fc_adp_application_status == 'approved_deduction'"/>
<t t-set="lines" t-value="doc.order_line.filtered(lambda l: l.product_id and l.display_type not in ('line_section', 'line_note'))"/>
<t t-set="has_deduction" t-value="any(l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' for l in lines)"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
<style>
.fc-ai { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-ai h2 { color: #0066a1; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
.fc-ai h2 { color: <t t-out="primary"/>; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
.fc-ai table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-ai table.bordered, .fc-ai table.bordered th, .fc-ai table.bordered td { border: 1px solid #000; }
.fc-ai th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-ai th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-ai td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-ai .text-center { text-align: center; }
.fc-ai .text-end { text-align: right; }

View File

@@ -12,10 +12,13 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
<style>
.fc-waiver { font-family: Arial, sans-serif; font-size: 10pt; line-height: 1.5; }
.fc-waiver h1 { color: #0066a1; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
.fc-waiver h1 { color: <t t-out="primary"/>; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
.fc-waiver h2 { color: #333; font-size: 11pt; margin: 15px 0 8px 0; }
.fc-waiver p { margin: 8px 0; text-align: justify; }
.fc-waiver .intro { margin-bottom: 15px; font-style: italic; }

View File

@@ -17,20 +17,24 @@
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<!-- Get sale order for MOD fields -->
<t t-set="so" t-value="doc.x_fc_source_sale_order_id"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-mod-inv { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-mod-inv table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-mod-inv table.bordered, .fc-mod-inv table.bordered th, .fc-mod-inv table.bordered td { border: 1px solid #000; }
.fc-mod-inv th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod-inv th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod-inv td { padding: 6px 8px; vertical-align: top; }
.fc-mod-inv .text-center { text-align: center; }
.fc-mod-inv .text-end { text-align: right; }
.fc-mod-inv .text-start { text-align: left; }
.fc-mod-inv .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-mod-inv .note-row { font-style: italic; color: #555; font-size: 9pt; }
.fc-mod-inv h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod-inv .req-box { border: 2px solid #1a5276; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
.fc-mod-inv h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod-inv .req-box { border: 2px solid <t t-out="primary"/>; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
</style>
<div class="fc-mod-inv">

View File

@@ -9,21 +9,25 @@
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-mod { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-mod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-mod table.bordered, .fc-mod table.bordered th, .fc-mod table.bordered td { border: 1px solid #000; }
.fc-mod th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod td { padding: 6px 8px; vertical-align: top; }
.fc-mod .text-center { text-align: center; }
.fc-mod .text-end { text-align: right; }
.fc-mod .text-start { text-align: left; }
.fc-mod .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-mod h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod .info-header { background-color: #eaf2f8; color: #333; }
.fc-mod .mod-accent { color: #1a5276; font-weight: bold; }
.fc-mod .highlight-box { border: 2px solid #1a5276; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
.fc-mod .mod-accent { color: <t t-out="primary"/>; font-weight: bold; }
.fc-mod .highlight-box { border: 2px solid <t t-out="primary"/>; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
</style>
<div class="fc-mod">

View File

@@ -12,12 +12,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-pod .text-center { text-align: center; }
.fc-pod .text-end { text-align: right; }
@@ -25,7 +29,7 @@
.fc-pod .adp-bg { background-color: #e3f2fd; }
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-pod .note-row { font-style: italic; }
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }

View File

@@ -11,19 +11,23 @@
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-pod .text-center { text-align: center; }
.fc-pod .text-end { text-align: right; }
.fc-pod .text-start { text-align: left; }
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-pod .note-row { font-style: italic; }
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }

View File

@@ -11,19 +11,25 @@
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<!-- Pickup uses the company's secondary brand colour (green by convention,
distinct from the delivery report's primary). Falls back to legacy
green if the company has not set a secondary colour. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#2e7d32'"/>
<style>
.fc-pop { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-pop table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-pop table.bordered, .fc-pop table.bordered th, .fc-pop table.bordered td { border: 1px solid #000; }
.fc-pop th { background-color: #2e7d32; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pop th { background-color: <t t-out="secondary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pop td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-pop .text-center { text-align: center; }
.fc-pop .text-end { text-align: right; }
.fc-pop .text-start { text-align: left; }
.fc-pop .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-pop .note-row { font-style: italic; }
.fc-pop h2 { color: #2e7d32; margin: 8px 0; font-size: 16pt; }
.fc-pop h2 { color: <t t-out="secondary"/>; margin: 8px 0; font-size: 16pt; }
.fc-pop .info-table td { padding: 6px 10px; font-size: 10pt; }
.fc-pop .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
.fc-pop .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }

View File

@@ -5,6 +5,21 @@
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!--
Colour convention used across fusion_claims reports (2026-04):
Each report should set `primary` and `secondary` near the top of
its template body, drawing from the company's brand colours
configured via the document-layout wizard:
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
Then reference `<t t-out="primary"/>` inside the <style> block and
inline `style="..."` attributes. Fallbacks preserve legacy rendering
on databases that have never set a colour.
-->
<!-- Shared Report Header Template -->
<template id="report_header_fusion_claims">
<div class="fc-header">
@@ -117,20 +132,21 @@
</div>
</template>
<!-- Report Styles -->
<!-- Report Styles (callers should set `primary` before t-call). -->
<template id="report_styles_fusion_claims">
<t t-set="primary" t-value="primary or ((company.primary_color if company else False) or '#0077b6')"/>
<style>
.fc-header { margin-bottom: 20px; }
.fc-footer { margin-top: 20px; }
.fc-table { width: 100%; border-collapse: collapse; }
.fc-table th { background-color: #0077b6; color: white; padding: 8px; text-align: left; }
.fc-table th { background-color: <t t-out="primary"/>; color: white; padding: 8px; text-align: left; }
.fc-table td { padding: 6px; border-bottom: 1px solid #ddd; }
.fc-table .section-header { background-color: #f0f0f0; font-weight: bold; }
.fc-table .text-end { text-align: right; }
.fc-totals { margin-top: 20px; }
.fc-totals table { width: 300px; float: right; }
.fc-totals td { padding: 4px 8px; }
.fc-totals .total-row { font-weight: bold; background-color: #0077b6; color: white; }
.fc-totals .total-row { font-weight: bold; background-color: <t t-out="primary"/>; color: white; }
</style>
</template>

View File

@@ -12,12 +12,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fc-landscape .text-center { text-align: center; }
.fc-landscape .text-end { text-align: right; }
@@ -26,7 +30,7 @@
.fc-landscape .client-bg { background-color: #fff3e0; }
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-landscape .note-row { font-style: italic; }
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fc-landscape .totals-table { border: 1px solid #000; }

View File

@@ -11,12 +11,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-report table.bordered, .fc-report table.bordered th, .fc-report table.bordered td { border: 1px solid #000; }
.fc-report th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-report th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-report td { padding: 6px 8px; vertical-align: top; }
.fc-report .text-center { text-align: center; }
.fc-report .text-end { text-align: right; }
@@ -25,7 +29,7 @@
.fc-report .client-bg { background-color: #fff3e0; }
.fc-report .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-report .note-row { font-style: italic; }
.fc-report h4 { color: #0066a1; margin: 0 0 15px 0; font-size: 16pt; }
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
.fc-report .info-header { background-color: #f5f5f5; color: #333; }

View File

@@ -1385,12 +1385,12 @@
confirm="Reopen this cancelled application at the Quotation stage?"
help="Return a cancelled application to Quotation"/>
<button name="action_adp_reopen_expired" type="object"
string="Reopen" class="btn-info"
icon="fa-refresh"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'expired'"
confirm="Reopen this expired application at the Quotation stage?"
help="Return an expired application to Quotation"/>
<button name="action_adp_duplicate_for_reassessment" type="object"
string="Create Reassessment Order" class="btn-primary"
icon="fa-copy"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('expired', 'cancelled')"
confirm="Create a new sale order for reassessment? The old order stays as a historical record. The authorizer will need to complete a new assessment before resubmission."
help="Create a new order linked to this one so the authorizer can reassess the client's needs"/>
<button name="action_adp_resubmit_from_denied" type="object"
string="Resubmit" class="btn-primary"
@@ -1604,11 +1604,11 @@
icon="fa-refresh"
invisible="x_fc_adp_application_status != 'cancelled'"
confirm="Reopen this cancelled application at the Quotation stage?"/>
<button name="action_adp_reopen_expired" type="object"
string="Reopen" class="btn-info btn-sm me-1"
icon="fa-refresh"
invisible="x_fc_adp_application_status != 'expired'"
confirm="Reopen this expired application at the Quotation stage?"/>
<button name="action_adp_duplicate_for_reassessment" type="object"
string="Create Reassessment Order" class="btn-primary btn-sm me-1"
icon="fa-copy"
invisible="x_fc_adp_application_status not in ('expired', 'cancelled')"
confirm="Create a new sale order for reassessment? The authorizer will need to complete a new assessment before resubmission."/>
<button name="action_adp_resubmit_from_denied" type="object"
string="Resubmit" class="btn-primary btn-sm me-1"
icon="fa-repeat"
@@ -2635,10 +2635,134 @@
context="{'group_by': 'x_fc_adp_application_status'}"/>
<filter string="Client Type" name="group_client_type"
context="{'group_by': 'x_fc_client_type'}"/>
<filter string="Authorizer" name="group_authorizer"
<filter string="Authorizer" name="group_authorizer"
context="{'group_by': 'x_fc_authorizer_id'}"/>
</xpath>
</field>
</record>
<!-- ===================================================================== -->
<!-- SALE ORDER FORM: WSIB / Insurance / MDC / Hardship Case Details -->
<!-- ===================================================================== -->
<record id="view_order_form_fusion_claims_funder_workflows" model="ir.ui.view">
<field name="name">sale.order.form.fusion.claims.funder.workflows</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="priority">48</field>
<field name="arch" type="xml">
<xpath expr="//group[@name='sale_header']" position="after">
<field name="x_fc_show_wsib_fields" invisible="1"/>
<field name="x_fc_show_insurance_fields" invisible="1"/>
<field name="x_fc_show_mdc_fields" invisible="1"/>
<field name="x_fc_show_hardship_fields" invisible="1"/>
<field name="x_fc_is_wsib_sale" invisible="1"/>
<field name="x_fc_is_insurance_sale" invisible="1"/>
<field name="x_fc_is_mdc_sale" invisible="1"/>
<field name="x_fc_is_hardship_sale" invisible="1"/>
<!-- ================== WSIB ================== -->
<group name="wsib_case_details" string="WSIB Case"
invisible="not x_fc_show_wsib_fields">
<group>
<field name="x_fc_wsib_status" string="Status"
required="x_fc_sale_type == 'wsib'"/>
<field name="x_fc_wsib_claim_number"/>
<field name="x_fc_wsib_adjudicator_name"/>
</group>
<group>
<field name="x_fc_wsib_form_7_date"/>
<field name="x_fc_wsib_approval_date"
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
<field name="x_fc_wsib_approval_letter"
filename="x_fc_wsib_approval_letter_filename"
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
<field name="x_fc_wsib_approval_letter_filename" invisible="1"/>
</group>
</group>
<!-- ================== INSURANCE ================== -->
<group name="insurance_case_details" string="Insurance Case"
invisible="not x_fc_show_insurance_fields">
<group>
<field name="x_fc_insurance_status" string="Status"
required="x_fc_sale_type == 'insurance'"/>
<field name="x_fc_insurance_submission_mode"/>
<field name="x_fc_insurance_company_id"/>
<field name="x_fc_insurance_letter_source"/>
<field name="x_fc_insurance_home_assessment_required"/>
</group>
<group>
<field name="x_fc_insurance_policy_number"/>
<field name="x_fc_insurance_claim_number"/>
<field name="x_fc_insurance_pre_auth_amount"
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
<field name="x_fc_insurance_pre_auth_expiry"
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
<field name="x_fc_insurance_approval_letter"
filename="x_fc_insurance_approval_letter_filename"
invisible="x_fc_insurance_status in ('quotation', 'home_assessment_scheduled', 'home_assessment_completed', 'documents_ready', 'submitted_by_client', 'pre_auth_submitted')"/>
<field name="x_fc_insurance_approval_letter_filename" invisible="1"/>
</group>
</group>
<!-- ================== MDC ================== -->
<group name="mdc_case_details" string="Muscular Dystrophy Case"
invisible="not x_fc_show_mdc_fields">
<group>
<field name="x_fc_mdc_status" string="Status"
required="x_fc_sale_type == 'muscular_dystrophy'"/>
<field name="x_fc_mdc_client_id_number"/>
<field name="x_fc_mdc_enrollment_verified"/>
<field name="x_fc_mdc_enrollment_verified_date"
invisible="not x_fc_mdc_enrollment_verified"/>
<field name="x_fc_mdc_letter_source"/>
<field name="x_fc_mdc_submitted_by"/>
</group>
<group>
<field name="x_fc_mdc_po_number"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_po_date"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_po_amount"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_payment_due_date" readonly="1"
invisible="not x_fc_mdc_po_date"/>
<field name="x_fc_mdc_po_document"
filename="x_fc_mdc_po_document_filename"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_po_document_filename" invisible="1"/>
</group>
</group>
<!-- ================== HARDSHIP ================== -->
<group name="hardship_case_details" string="Hardship Funding Case"
invisible="not x_fc_show_hardship_fields">
<group>
<field name="x_fc_hardship_status" string="Status"
required="x_fc_sale_type == 'hardship'"/>
<field name="x_fc_hardship_funder_id"/>
<field name="x_fc_hardship_submitted_by"/>
<field name="x_fc_hardship_pre_assessment_source"/>
</group>
<group>
<field name="x_fc_hardship_interview_date"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf')"/>
<field name="x_fc_hardship_approval_date"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_received_via"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_amount"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_client_portion"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_letter"
filename="x_fc_hardship_approval_letter_filename"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_letter_filename" invisible="1"/>
</group>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from . import controllers
from . import models
_logger = logging.getLogger(__name__)
def post_init_hook(env):
"""Auto-detect a sensible default timezone on first install.
Sets ``res.company.x_fc_default_tz`` to the admin user's timezone
(Odoo populates that from the browser on first login), falling back
to the host server's timezone, then to ``America/Toronto`` as a
last resort. Only writes when the field is still empty so re-installs
never clobber a user's choice.
"""
from .models.fp_tz import detect_default_tz
detected = detect_default_tz(env)
for company in env['res.company'].sudo().search([]):
if not company.x_fc_default_tz:
company.x_fc_default_tz = detected
_logger.info(
'Fusion Plating: set default timezone for company %s -> %s',
company.name, detected,
)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.1.0.0',
'version': '19.0.3.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -90,9 +90,14 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_facility_views.xml',
'views/fp_bath_views.xml',
'views/fp_process_node_views.xml',
'views/fp_rack_views.xml',
'views/fp_bath_replenishment_views.xml',
'views/fp_operator_certification_views.xml',
'views/res_config_settings_views.xml',
'views/fp_menu.xml',
'data/fp_recipe_enp_alum_basic.xml',
],
'post_init_hook': 'post_init_hook',
'assets': {
'web.assets_backend': [
'fusion_plating/static/src/scss/fusion_plating.scss',

View File

@@ -12,5 +12,10 @@ from . import fp_bath
from . import fp_bath_log
from . import fp_bath_log_line
from . import fp_bath_parameter
from . import fp_bath_replenishment_rule
from . import fp_process_node
from . import fp_rack
from . import fp_operator_certification
from . import fp_tz
from . import res_company
from . import res_config_settings

View File

@@ -112,3 +112,47 @@ class FpBathLogLine(models.Model):
rec.status = 'warning'
else:
rec.status = 'ok'
# ------------------------------------------------------------------
# T1.2 — Auto-suggest replenishment on every log line
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
lines._spawn_replenishment_suggestions()
return lines
def _spawn_replenishment_suggestions(self):
"""For every out-of-spec reading, run the matching replenishment
rule and create a pending suggestion the operator can apply."""
Rule = self.env['fusion.plating.bath.replenishment.rule']
Suggestion = self.env['fusion.plating.bath.replenishment.suggestion']
for line in self:
if not line.parameter_id or not line.log_id.bath_id:
continue
bath = line.log_id.bath_id
rules = Rule._find_rules(bath, line.parameter_id.id)
for rule in rules:
dose = rule._compute_dose(
line.value, line.target_min, line.target_max, bath.volume,
)
if dose <= 0:
continue
Suggestion.create({
'bath_id': bath.id,
'log_line_id': line.id,
'rule_id': rule.id,
'parameter_id': line.parameter_id.id,
'current_value': line.value,
'target_min': line.target_min,
'target_max': line.target_max,
'product_name': rule.product_name,
'dose_amount': dose,
'dose_uom': rule.dose_uom,
'state': 'pending',
})
bath.message_post(
body=f'Replenishment suggested: add {dose} {rule.dose_uom} '
f'of {rule.product_name} ({line.parameter_id.name} '
f'reading: {line.value})',
)

View File

@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpBathReplenishmentRule(models.Model):
"""Linear replenishment rule: when a chemistry reading drifts outside
target, calculate how much replenisher to add.
The formula is deliberately simple:
dose = deficit × bath.volume × dose_rate
where deficit = (target_min value) for below_min rules
or = (value target_max) for above_max rules.
Shops wanting non-linear or piecewise rules can extend this model.
"""
_name = 'fusion.plating.bath.replenishment.rule'
_description = 'Fusion Plating — Replenishment Rule'
_order = 'process_type_id, parameter_id'
name = fields.Char(string='Rule Name', required=True)
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type',
help='If set, this rule applies to every bath running this process. '
'Leave blank and set bath_id for a bath-specific rule.',
)
bath_id = fields.Many2one(
'fusion.plating.bath', string='Specific Bath',
help='Narrow the rule to a single bath (overrides process-level rule).',
)
parameter_id = fields.Many2one(
'fusion.plating.bath.parameter', string='Parameter', required=True,
)
trigger = fields.Selection(
[('below_min', 'Reading Below Target Min'),
('above_max', 'Reading Above Target Max')],
string='Trigger', required=True, default='below_min',
)
product_name = fields.Char(
string='Replenisher Name', required=True,
help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% — Grade A"',
)
product_id = fields.Many2one(
'product.product', string='Product (Inventory)',
help='Optional link to an inventory product for consumption tracking.',
)
dose_rate = fields.Float(
string='Dose Rate', required=True, digits=(12, 4),
help='Amount of replenisher per unit of parameter deficit per gallon '
'of bath volume. E.g. 0.5 means "add 0.5 mL per (g/L deficit) per gallon".',
)
dose_uom = fields.Selection(
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
string='Dose UoM', required=True, default='ml',
)
min_dose = fields.Float(
string='Minimum Dose', default=0.0,
help='Do not suggest doses below this (useful to avoid noise).',
)
max_dose = fields.Float(
string='Safety Cap', default=0.0,
help='Cap the suggested dose. 0 = no cap.',
)
notes = fields.Text(string='Operator Notes')
active = fields.Boolean(default=True)
@api.model
def _find_rules(self, bath, parameter_id):
"""Return rules applicable to this (bath, parameter). Bath-specific
rules take precedence over process-level ones.
"""
bath_rule = self.search([
('bath_id', '=', bath.id),
('parameter_id', '=', parameter_id),
('active', '=', True),
])
if bath_rule:
return bath_rule
return self.search([
('bath_id', '=', False),
('process_type_id', '=', bath.process_type_id.id),
('parameter_id', '=', parameter_id),
('active', '=', True),
])
def _compute_dose(self, value, target_min, target_max, bath_volume):
"""Return a dose amount for this rule given the reading context.
Returns 0.0 if the trigger doesn't apply.
"""
self.ensure_one()
deficit = 0.0
if self.trigger == 'below_min' and target_min and value < target_min:
deficit = target_min - value
elif self.trigger == 'above_max' and target_max and value > target_max:
deficit = value - target_max
if deficit <= 0:
return 0.0
dose = deficit * (bath_volume or 1.0) * self.dose_rate
if self.min_dose and dose < self.min_dose:
return 0.0
if self.max_dose and dose > self.max_dose:
dose = self.max_dose
return round(dose, 3)
class FpBathReplenishmentSuggestion(models.Model):
"""One suggestion generated from a bath-log reading. Operators mark
them applied or dismissed once the dose has been added."""
_name = 'fusion.plating.bath.replenishment.suggestion'
_description = 'Fusion Plating — Replenishment Suggestion'
_inherit = ['mail.thread']
_order = 'create_date desc, id desc'
bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', required=True, ondelete='cascade',
)
log_line_id = fields.Many2one(
'fusion.plating.bath.log.line', string='Triggering Reading',
ondelete='cascade',
)
rule_id = fields.Many2one(
'fusion.plating.bath.replenishment.rule', string='Rule',
ondelete='set null',
)
parameter_id = fields.Many2one(
'fusion.plating.bath.parameter', string='Parameter', required=True,
)
current_value = fields.Float(string='Current Reading', digits=(12, 4))
target_min = fields.Float(string='Target Min', digits=(12, 4))
target_max = fields.Float(string='Target Max', digits=(12, 4))
product_name = fields.Char(string='Replenisher', required=True)
dose_amount = fields.Float(string='Suggested Dose', digits=(12, 3))
dose_uom = fields.Selection(
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
string='UoM', required=True, default='ml',
)
state = fields.Selection(
[('pending', 'Pending'), ('applied', 'Applied'), ('dismissed', 'Dismissed')],
default='pending', tracking=True,
)
applied_at = fields.Datetime(readonly=True)
applied_by_id = fields.Many2one('res.users', readonly=True)
charged_to_mo_ref = fields.Char(
string='Charged to MO',
help='Manufacturing order this replenishment was charged against '
'(for job costing). Blank = unassigned.',
)
def action_apply(self):
"""Mark applied + log to bath chatter. A follow-up JobConsumption
record can be created by `action_apply_and_charge()` to attribute
cost to a specific MO.
"""
for rec in self:
rec.write({
'state': 'applied',
'applied_at': fields.Datetime.now(),
'applied_by_id': self.env.user.id,
})
rec.bath_id.message_post(
body=f'Replenishment applied: {rec.dose_amount} {rec.dose_uom} '
f'of {rec.product_name} (parameter: {rec.parameter_id.name})'
)
def action_dismiss(self):
self.write({'state': 'dismissed'})

View File

@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
class FpOperatorCertification(models.Model):
"""A signed-off training record that certifies an operator on a
specific process type.
Used to gate shop-floor work orders: an operator cannot start a
plating WO unless they hold a current (non-expired) certification
for that process.
"""
_name = 'fp.operator.certification'
_description = 'Fusion Plating — Operator Certification'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'employee_id, process_type_id'
name = fields.Char(
string='Certification Ref',
compute='_compute_name', store=True,
)
employee_id = fields.Many2one(
'hr.employee', string='Operator', required=True,
ondelete='cascade', tracking=True,
)
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type',
required=True, ondelete='restrict', tracking=True,
)
issued_date = fields.Date(
string='Issued', default=fields.Date.today, required=True,
)
expires_date = fields.Date(
string='Expires',
help='Blank = no expiry. Set a date for re-certification tracking.',
)
issued_by_id = fields.Many2one(
'res.users', string='Certified By', default=lambda self: self.env.user,
)
training_record_attachment_id = fields.Many2one(
'ir.attachment', string='Training Record',
)
notes = fields.Text(string='Notes')
revoked = fields.Boolean(string='Revoked', tracking=True)
revoked_reason = fields.Text(string='Revoked Reason')
state = fields.Selection(
[('active', 'Active'),
('expired', 'Expired'),
('revoked', 'Revoked')],
string='Status',
compute='_compute_state', store=True, tracking=True,
# NOT readonly=False — this is purely derived from revoked + expires_date
# so the nightly recompute never fights with manual edits.
)
@api.depends('employee_id', 'process_type_id')
def _compute_name(self):
for rec in self:
if rec.employee_id and rec.process_type_id:
rec.name = f'{rec.employee_id.name} / {rec.process_type_id.name}'
else:
rec.name = ''
@api.depends('expires_date', 'revoked')
def _compute_state(self):
today = fields.Date.today()
for rec in self:
if rec.revoked:
rec.state = 'revoked'
elif rec.expires_date and rec.expires_date < today:
rec.state = 'expired'
else:
rec.state = 'active'
@api.constrains('employee_id', 'process_type_id', 'revoked', 'expires_date')
def _check_single_active(self):
"""At most one active certification per (employee, process_type)."""
today = fields.Date.today()
for rec in self:
if rec.revoked:
continue
if rec.expires_date and rec.expires_date < today:
continue
# This record is active — look for another active sibling
dupes = self.search_count([
('id', '!=', rec.id),
('employee_id', '=', rec.employee_id.id),
('process_type_id', '=', rec.process_type_id.id),
('revoked', '=', False),
'|', ('expires_date', '=', False),
('expires_date', '>=', today),
])
if dupes:
from odoo.exceptions import ValidationError
raise ValidationError(_(
'Operator %s already has an active certification for "%s". '
'Revoke or expire the existing one before adding another.'
) % (rec.employee_id.name, rec.process_type_id.name))
def action_revoke(self):
for rec in self:
rec.revoked = True
rec.message_post(body=_('Certification revoked.'))
@api.model
def has_active_cert(self, employee_id, process_type_id):
"""Utility — True if this employee holds a current certification.
Checks revoked + expires_date directly instead of the computed
`state` column, so even a certification that expired yesterday
is caught immediately (no wait for nightly recompute).
"""
if not employee_id or not process_type_id:
return False
today = fields.Date.today()
return bool(self.search_count([
('employee_id', '=', employee_id),
('process_type_id', '=', process_type_id),
('revoked', '=', False),
'|', ('expires_date', '=', False),
('expires_date', '>=', today),
]))
class HrEmployee(models.Model):
_inherit = 'hr.employee'
x_fc_certification_ids = fields.One2many(
'fp.operator.certification', 'employee_id',
string='Plating Certifications',
)
x_fc_certified_process_ids = fields.Many2many(
'fusion.plating.process.type', compute='_compute_certified_processes',
string='Certified Processes',
)
@api.depends('x_fc_certification_ids.state', 'x_fc_certification_ids.process_type_id')
def _compute_certified_processes(self):
for emp in self:
active = emp.x_fc_certification_ids.filtered(lambda c: c.state == 'active')
emp.x_fc_certified_process_ids = active.mapped('process_type_id')

View File

@@ -6,6 +6,8 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from .fp_tz import fp_isoformat_utc
class FpProcessNode(models.Model):
"""A node in the process recipe tree.
@@ -312,9 +314,11 @@ class FpProcessNode(models.Model):
'child_count': len(children),
'opt_in_out': self.opt_in_out or 'disabled',
'input_count': len(self.input_ids),
'create_date': self.create_date.isoformat() if self.create_date else '',
# ISO with explicit UTC marker so JS new Date() parses it
# correctly and re-localises to the browser's timezone.
'create_date': fp_isoformat_utc(self.create_date),
'create_uid_name': self.create_uid.name if self.create_uid else '',
'write_date': self.write_date.isoformat() if self.write_date else '',
'write_date': fp_isoformat_utc(self.write_date),
'write_uid_name': self.write_uid.name if self.write_uid else '',
'children': children,
}

View File

@@ -39,6 +39,20 @@ class FpProcessType(models.Model):
required=True,
ondelete='restrict',
)
process_family = fields.Selection(
[('pre_treatment', 'Pre-Treatment'),
('plating', 'Plating'),
('post_treatment', 'Post-Treatment'),
('bake', 'Hydrogen Bake / Heat Treat'),
('strip', 'Strip'),
('passivation', 'Passivation'),
('masking', 'Masking / De-masking'),
('inspection', 'Inspection / QC')],
string='Family', default='plating', required=True, tracking=True,
help='High-level grouping used to filter baths and plan routings. '
'Pre-treatments (alkaline clean, acid etch, zincate) should be '
'tracked as full baths with their own chemistry logs.',
)
sequence = fields.Integer(
string='Sequence',
default=10,

View File

@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
class FpRack(models.Model):
"""Plating rack / barrel / fixture.
Racks carry parts through baths and accumulate nickel themselves over
time. Once the rack's metal turnover (MTO) count exceeds the strip
interval, the rack must be stripped before re-use to avoid bald spots
on parts.
"""
_name = 'fusion.plating.rack'
_description = 'Fusion Plating — Rack / Fixture'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, rack_type, name'
name = fields.Char(string='Rack ID', required=True, tracking=True)
rack_type = fields.Selection(
[('rack', 'Rack'), ('barrel', 'Barrel'),
('fixture', 'Fixture'), ('basket', 'Basket')],
string='Type', required=True, default='rack',
)
facility_id = fields.Many2one(
'fusion.plating.facility', string='Facility', required=True, tracking=True,
)
company_id = fields.Many2one(
'res.company', related='facility_id.company_id', store=True, readonly=True,
)
capacity = fields.Integer(
string='Capacity (parts)',
help='Max parts per load. Used for batch planning.',
)
contact_points = fields.Integer(
string='Contact Points',
help='Number of clips/tips that touch parts. Wear points for re-stripping.',
)
# --- Wear tracking ---
mto_count = fields.Float(
string='MTO (current)', default=0.0, tracking=True,
help='Metal turnover accumulated since last strip.',
)
strip_interval_mto = fields.Float(
string='Strip After (MTO)', default=3.0,
help='When MTO crosses this value, rack needs stripping.',
)
last_stripped_date = fields.Datetime(string='Last Stripped', tracking=True)
last_stripped_by_id = fields.Many2one(
'res.users', string='Stripped By', tracking=True,
)
strips_count = fields.Integer(string='Total Strips', default=0, readonly=True)
state = fields.Selection(
[('active', 'Active'),
('needs_strip', 'Needs Strip'),
('stripping', 'Stripping'),
('retired', 'Retired')],
string='Status', default='active', required=True, tracking=True,
compute='_compute_state', store=True, readonly=False,
)
status_color = fields.Integer(compute='_compute_status_color')
notes = fields.Html(string='Notes')
active = fields.Boolean(default=True)
_sql_constraints = [
('fp_rack_facility_name_uniq', 'unique(facility_id, name)',
'Rack ID must be unique per facility.'),
]
# ------------------------------------------------------------------
# Computes
# ------------------------------------------------------------------
@api.depends('mto_count', 'strip_interval_mto')
def _compute_state(self):
for rec in self:
if rec.state in ('stripping', 'retired'):
continue # Manually set — don't override
if rec.strip_interval_mto and rec.mto_count >= rec.strip_interval_mto:
rec.state = 'needs_strip'
elif rec.state != 'active':
rec.state = 'active'
@api.depends('state')
def _compute_status_color(self):
mapping = {'active': 4, 'needs_strip': 3, 'stripping': 2, 'retired': 10}
for rec in self:
rec.status_color = mapping.get(rec.state, 0)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_start_strip(self):
self.write({'state': 'stripping'})
def action_mark_stripped(self):
for rec in self:
rec.write({
'state': 'active',
'mto_count': 0.0,
'last_stripped_date': fields.Datetime.now(),
'last_stripped_by_id': self.env.user.id,
'strips_count': rec.strips_count + 1,
})
rec.message_post(body=_('Rack stripped and returned to service.'))
def action_retire(self):
self.write({'state': 'retired', 'active': False})
def _increment_mto(self, delta=1.0):
"""Add `delta` to the rack's MTO count. Called by the WO finish hook."""
for rec in self:
rec.mto_count = (rec.mto_count or 0.0) + delta

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Timezone helpers for Fusion Plating.
The Postgres database stores all datetimes naive-UTC. Anything that is
shown to a user — dashboards, PDFs, emails, OWL frontends — must be
converted to a human's local timezone first.
Resolution order for "what timezone does this user see":
1. The current user's `res.users.tz`
2. The current company's `x_fc_default_tz` (Fusion Plating setting)
3. UTC
Use ``fp_user_tz(env)`` to get the resolved pytz tzinfo, then either
convert datetimes yourself or use the convenience helpers
``fp_format`` / ``fp_isoformat_utc``.
"""
import pytz
def fp_user_tz(env):
"""Return a pytz tzinfo for the current user (or company fallback)."""
name = (
(env.user.tz if env and env.user else None)
or (env.company.x_fc_default_tz if env and env.company else None)
or 'UTC'
)
try:
return pytz.timezone(name)
except Exception:
return pytz.UTC
def fp_to_user_tz(env, dt):
"""Convert a naive UTC datetime to a tz-aware datetime in the user's tz.
Returns ``None`` if ``dt`` is falsy. Datetimes that already carry a
tzinfo are converted in place; naive ones are assumed to be UTC
(matching Odoo's storage convention).
"""
if not dt:
return None
tz = fp_user_tz(env)
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.astimezone(tz)
def fp_format(env, dt, fmt='%Y-%m-%d %H:%M'):
"""Format a naive UTC datetime as a string in the user's tz.
Returns an empty string when ``dt`` is falsy so callers can use the
result directly in dicts / templates without ``if`` guards.
"""
if not dt:
return ''
return fp_to_user_tz(env, dt).strftime(fmt)
def fp_isoformat_utc(dt):
"""Return an ISO-8601 string with an explicit UTC marker.
Naive datetimes from Odoo are assumed UTC. Adding the ``+00:00``
suffix tells JavaScript ``new Date(...)`` to parse the string as
UTC (without it, the browser interprets the string as *local*
wall time and silently shifts it). Pair this with frontend code
that calls ``.toLocaleString()`` on the resulting Date object so
the user sees their own local time.
"""
if not dt:
return ''
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.isoformat()
def fp_time_ago(env, dt):
"""Return a 'just now / 5m ago / 2h ago / 3d ago' string.
Both sides of the comparison are converted to UTC tz-aware first so
that the delta is meaningful regardless of where Odoo or the user
happens to be running.
"""
if not dt:
return ''
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
from datetime import datetime
now = datetime.now(pytz.UTC)
delta = now - dt
total_seconds = int(delta.total_seconds())
if total_seconds < 0:
total_seconds = 0
if total_seconds < 60:
return 'just now'
minutes = total_seconds // 60
if minutes < 60:
return f'{minutes}m ago'
hours = minutes // 60
if hours < 24:
return f'{hours}h ago'
days = hours // 24
if days < 7:
return f'{days}d ago'
weeks = days // 7
remaining_days = days % 7
if remaining_days:
return f'{weeks}w {remaining_days}d ago'
return f'{weeks}w ago'
# ---------------------------------------------------------------------------
# Auto-detection used by the post_init_hook
# ---------------------------------------------------------------------------
_FALLBACK_TZ = 'America/Toronto'
def detect_default_tz(env=None):
"""Best guess at a sensible default tz when the module is installed.
Tries, in order:
1. The admin user's ``tz`` (Odoo sets it from the browser on login).
2. The current company's ``partner_id.tz``.
3. The host server's IANA timezone (Linux typical).
4. ``America/Toronto`` as the final fallback (this project is
Canada-focused and that's the most likely correct guess).
"""
if env is not None:
admin = env.ref('base.user_admin', raise_if_not_found=False)
if admin and admin.tz:
return admin.tz
try:
partner_tz = env.company.partner_id.tz
except Exception:
partner_tz = None
if partner_tz:
return partner_tz
# Server-side detection — works on most Linux hosts.
try:
from datetime import datetime
local = datetime.now().astimezone()
name = str(local.tzinfo)
if name and name in pytz.all_timezones:
return name
except Exception:
pass
try:
with open('/etc/timezone', 'r') as fh:
tz = fh.read().strip()
if tz in pytz.all_timezones:
return tz
except Exception:
pass
return _FALLBACK_TZ

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
def _fp_tz_get(self):
"""Same selection list Odoo uses on res.partner.tz."""
import pytz
return [(tz, tz) for tz in pytz.all_timezones]
class ResCompany(models.Model):
_inherit = 'res.company'
# ----- Fusion Plating default timezone --------------------------------
# Used as the fallback whenever a user's res.users.tz is empty (cron
# jobs, batch emails, headless contexts). Auto-populated by the
# post_init_hook in fusion_plating/__init__.py.
x_fc_default_tz = fields.Selection(
_fp_tz_get,
string='Fusion Plating Timezone',
default=lambda self: self.env.user.tz or 'America/Toronto',
help='Timezone used for plating dashboards, reports, and emails when '
'a user has no personal timezone set. Detected automatically on '
'install; the admin can change it any time from '
'Settings > Fusion Plating.',
)
# ----- Facility footprint for this legal entity ----------------------
x_fc_facility_ids = fields.One2many(
'fusion.plating.facility',
'company_id',
string='Plating Facilities',
)
x_fc_facility_count = fields.Integer(
string='# Facilities',
compute='_compute_x_fc_facility_count',
)
x_fc_default_facility_id = fields.Many2one(
'fusion.plating.facility',
string='Default Facility',
help='Facility used when the context does not specify one (single-site shops).',
)
def _compute_x_fc_facility_count(self):
for rec in self:
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)
# =====================================================================
# CoC / Certificate report settings
# =====================================================================
x_fc_owner_user_id = fields.Many2one(
'res.users',
string='Certificate Owner (Default Signer)',
help='Quality manager / owner whose signature appears on Certificates '
'of Conformance by default. Signature is pulled from their linked '
'HR Employee record.',
)
x_fc_coc_signature_override = fields.Binary(
string='Signature Override Image',
help='Optional. Upload a pre-scanned signature image to use on '
'Certificates of Conformance. Overrides the Owner user\'s '
'employee signature when set. Useful if the owner doesn\'t have '
'an HR record or wants a different signature for plating certs.',
)
# --- Accreditation logos shown in CoC header ---
x_fc_nadcap_logo = fields.Binary(string='Nadcap Logo')
x_fc_nadcap_active = fields.Boolean(
string='Nadcap Accredited',
help='Show the Nadcap logo on certificates.',
)
x_fc_as9100_logo = fields.Binary(string='AS9100 / ISO 9001 Logo')
x_fc_as9100_active = fields.Boolean(
string='AS9100 / ISO 9001 Certified',
help='Show the AS9100 / ISO 9001 logo on certificates.',
)
x_fc_cgp_logo = fields.Binary(string='Controlled Goods Program Logo')
x_fc_cgp_active = fields.Boolean(
string='CGP Registered',
help='Show the Controlled Goods Program logo on certificates.',
)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
"""Expose Fusion Plating company-level settings on the Settings page.
Today this only carries the default timezone, but it's the single
place to add new shop-wide preferences (default facility, currency
overrides, etc.).
"""
_inherit = 'res.config.settings'
x_fc_default_tz = fields.Selection(
related='company_id.x_fc_default_tz',
readonly=False,
string='Fusion Plating Timezone',
)

View File

@@ -32,3 +32,15 @@ access_fp_process_node_manager,fp.process.node.manager,model_fusion_plating_proc
access_fp_process_node_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,group_fusion_plating_operator,1,0,0,0
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,group_fusion_plating_supervisor,1,1,1,0
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,group_fusion_plating_manager,1,1,1,1
access_fp_rack_operator,fp.rack.operator,model_fusion_plating_rack,group_fusion_plating_operator,1,1,0,0
access_fp_rack_supervisor,fp.rack.supervisor,model_fusion_plating_rack,group_fusion_plating_supervisor,1,1,1,0
access_fp_rack_manager,fp.rack.manager,model_fusion_plating_rack,group_fusion_plating_manager,1,1,1,1
access_fp_replenishment_rule_operator,fp.replenishment.rule.operator,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_operator,1,0,0,0
access_fp_replenishment_rule_supervisor,fp.replenishment.rule.supervisor,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_supervisor,1,1,1,0
access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_manager,1,1,1,1
access_fp_replenishment_suggestion_operator,fp.replenishment.suggestion.operator,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_operator,1,1,1,0
access_fp_replenishment_suggestion_supervisor,fp.replenishment.suggestion.supervisor,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_supervisor,1,1,1,0
access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_manager,1,1,1,1
access_fp_operator_cert_operator,fp.operator.cert.operator,model_fp_operator_certification,group_fusion_plating_operator,1,0,0,0
access_fp_operator_cert_supervisor,fp.operator.cert.supervisor,model_fp_operator_certification,group_fusion_plating_supervisor,1,1,1,0
access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certification,group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
32 access_fp_process_node_input_operator fp.process.node.input.operator model_fusion_plating_process_node_input group_fusion_plating_operator 1 0 0 0
33 access_fp_process_node_input_supervisor fp.process.node.input.supervisor model_fusion_plating_process_node_input group_fusion_plating_supervisor 1 1 1 0
34 access_fp_process_node_input_manager fp.process.node.input.manager model_fusion_plating_process_node_input group_fusion_plating_manager 1 1 1 1
35 access_fp_rack_operator fp.rack.operator model_fusion_plating_rack group_fusion_plating_operator 1 1 0 0
36 access_fp_rack_supervisor fp.rack.supervisor model_fusion_plating_rack group_fusion_plating_supervisor 1 1 1 0
37 access_fp_rack_manager fp.rack.manager model_fusion_plating_rack group_fusion_plating_manager 1 1 1 1
38 access_fp_replenishment_rule_operator fp.replenishment.rule.operator model_fusion_plating_bath_replenishment_rule group_fusion_plating_operator 1 0 0 0
39 access_fp_replenishment_rule_supervisor fp.replenishment.rule.supervisor model_fusion_plating_bath_replenishment_rule group_fusion_plating_supervisor 1 1 1 0
40 access_fp_replenishment_rule_manager fp.replenishment.rule.manager model_fusion_plating_bath_replenishment_rule group_fusion_plating_manager 1 1 1 1
41 access_fp_replenishment_suggestion_operator fp.replenishment.suggestion.operator model_fusion_plating_bath_replenishment_suggestion group_fusion_plating_operator 1 1 1 0
42 access_fp_replenishment_suggestion_supervisor fp.replenishment.suggestion.supervisor model_fusion_plating_bath_replenishment_suggestion group_fusion_plating_supervisor 1 1 1 0
43 access_fp_replenishment_suggestion_manager fp.replenishment.suggestion.manager model_fusion_plating_bath_replenishment_suggestion group_fusion_plating_manager 1 1 1 1
44 access_fp_operator_cert_operator fp.operator.cert.operator model_fp_operator_certification group_fusion_plating_operator 1 0 0 0
45 access_fp_operator_cert_supervisor fp.operator.cert.supervisor model_fp_operator_certification group_fusion_plating_supervisor 1 1 1 0
46 access_fp_operator_cert_manager fp.operator.cert.manager model_fp_operator_certification group_fusion_plating_manager 1 1 1 1

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,151 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===== Rule List ===== -->
<record id="view_fp_replenishment_rule_list" model="ir.ui.view">
<field name="name">fp.replenishment.rule.list</field>
<field name="model">fusion.plating.bath.replenishment.rule</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="process_type_id"/>
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="trigger"/>
<field name="product_name"/>
<field name="dose_rate"/>
<field name="dose_uom"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Rule Form ===== -->
<record id="view_fp_replenishment_rule_form" model="ir.ui.view">
<field name="name">fp.replenishment.rule.form</field>
<field name="model">fusion.plating.bath.replenishment.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<group>
<group string="Scope">
<field name="process_type_id"/>
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="trigger"/>
</group>
<group string="Dose">
<field name="product_name"/>
<field name="product_id"/>
<field name="dose_rate"/>
<field name="dose_uom"/>
<field name="min_dose"/>
<field name="max_dose"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_replenishment_rule" model="ir.actions.act_window">
<field name="name">Replenishment Rules</field>
<field name="res_model">fusion.plating.bath.replenishment.rule</field>
<field name="view_mode">list,form</field>
</record>
<!-- ===== Suggestion List ===== -->
<record id="view_fp_replenishment_suggestion_list" model="ir.ui.view">
<field name="name">fp.replenishment.suggestion.list</field>
<field name="model">fusion.plating.bath.replenishment.suggestion</field>
<field name="arch" type="xml">
<list decoration-info="state == 'pending'"
decoration-muted="state in ('applied','dismissed')"
default_order="create_date desc">
<field name="create_date" optional="show"/>
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="current_value"/>
<field name="target_min"/>
<field name="target_max"/>
<field name="product_name"/>
<field name="dose_amount"/>
<field name="dose_uom"/>
<field name="state" widget="badge"
decoration-info="state == 'pending'"
decoration-success="state == 'applied'"
decoration-muted="state == 'dismissed'"/>
<button name="action_apply" type="object"
string="Apply" class="btn-primary"
invisible="state != 'pending'" icon="fa-check"/>
<button name="action_dismiss" type="object"
string="Dismiss"
invisible="state != 'pending'" icon="fa-times"/>
</list>
</field>
</record>
<!-- ===== Suggestion Form ===== -->
<record id="view_fp_replenishment_suggestion_form" model="ir.ui.view">
<field name="name">fp.replenishment.suggestion.form</field>
<field name="model">fusion.plating.bath.replenishment.suggestion</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_apply" string="Apply"
type="object" class="btn-primary"
invisible="state != 'pending'"/>
<button name="action_dismiss" string="Dismiss"
type="object" class="btn-secondary"
invisible="state != 'pending'"/>
<field name="state" widget="statusbar"
statusbar_visible="pending,applied"/>
</header>
<sheet>
<group>
<group string="Context">
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="log_line_id"/>
<field name="rule_id"/>
</group>
<group string="Reading vs Target">
<field name="current_value"/>
<field name="target_min"/>
<field name="target_max"/>
</group>
</group>
<group string="Suggested Dose">
<group>
<field name="product_name"/>
<field name="dose_amount"/>
<field name="dose_uom"/>
</group>
<group>
<field name="applied_at"/>
<field name="applied_by_id"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_replenishment_suggestion" model="ir.actions.act_window">
<field name="name">Replenishment Suggestions</field>
<field name="res_model">fusion.plating.bath.replenishment.suggestion</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_pending': 1}</field>
</record>
</odoo>

View File

@@ -67,12 +67,20 @@
<field name="tank_id"/>
<field name="process_type_id"/>
<field name="facility_id" readonly="1"/>
<field name="volume"/>
<label for="volume"/>
<div class="o_row">
<field name="volume" nolabel="1" class="oe_inline"/>
<span class="ms-1">L</span>
</div>
</group>
<group>
<field name="makeup_date"/>
<field name="makeup_by_id"/>
<field name="mto_count" readonly="1"/>
<label for="mto_count"/>
<div class="o_row">
<field name="mto_count" nolabel="1" readonly="1" class="oe_inline"/>
<span class="ms-1">turnovers</span>
</div>
<field name="last_log_date" readonly="1"/>
<field name="last_log_status" readonly="1" widget="badge"
decoration-success="last_log_status == 'ok'"
@@ -172,6 +180,15 @@
<field name="process_type_id"/>
<field name="facility_id"/>
<separator/>
<filter string="Pre-Treatments" name="family_pre"
domain="[('process_type_id.process_family', '=', 'pre_treatment')]"/>
<filter string="Plating Baths" name="family_plating"
domain="[('process_type_id.process_family', '=', 'plating')]"/>
<filter string="Post-Treatments" name="family_post"
domain="[('process_type_id.process_family', '=', 'post_treatment')]"/>
<filter string="Strip Baths" name="family_strip"
domain="[('process_type_id.process_family', '=', 'strip')]"/>
<separator/>
<filter string="Operational" name="operational" domain="[('state','=','operational')]"/>
<filter string="Under Review" name="review" domain="[('state','=','under_review')]"/>
<filter string="Out of Spec" name="oos" domain="[('last_log_status','=','out_of_spec')]"/>

View File

@@ -43,6 +43,30 @@
action="action_fp_tank"
sequence="30"/>
<menuitem id="menu_fp_racks"
name="Racks &amp; Fixtures"
parent="menu_fp_operations"
action="action_fp_rack"
sequence="35"/>
<menuitem id="menu_fp_replenishment_suggestions"
name="Replenishment Suggestions"
parent="menu_fp_operations"
action="action_fp_replenishment_suggestion"
sequence="40"/>
<menuitem id="menu_fp_replenishment_rules"
name="Replenishment Rules"
parent="menu_fp_config"
action="action_fp_replenishment_rule"
sequence="55"/>
<menuitem id="menu_fp_operator_certifications"
name="Operator Certifications"
parent="menu_fp_config"
action="action_fp_operator_cert"
sequence="60"/>
<!-- ===== CONFIGURATION ===== -->
<menuitem id="menu_fp_config"
name="Configuration"

View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===== Certification List ===== -->
<record id="view_fp_operator_cert_list" model="ir.ui.view">
<field name="name">fp.operator.cert.list</field>
<field name="model">fp.operator.certification</field>
<field name="arch" type="xml">
<list decoration-success="state == 'active'"
decoration-danger="state == 'expired'"
decoration-muted="state == 'revoked'">
<field name="employee_id"/>
<field name="process_type_id"/>
<field name="issued_date"/>
<field name="expires_date"/>
<field name="issued_by_id" optional="show"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ===== Certification Form ===== -->
<record id="view_fp_operator_cert_form" model="ir.ui.view">
<field name="name">fp.operator.cert.form</field>
<field name="model">fp.operator.certification</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_revoke" string="Revoke"
type="object" class="btn-danger"
invisible="state != 'active'"
confirm="Revoke this certification?"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h2><field name="name" readonly="1"/></h2>
</div>
<group>
<group>
<field name="employee_id"/>
<field name="process_type_id"/>
<field name="issued_by_id"/>
</group>
<group>
<field name="issued_date"/>
<field name="expires_date"/>
<field name="training_record_attachment_id"/>
</group>
</group>
<group string="Revocation" invisible="state != 'revoked'">
<field name="revoked_reason" nolabel="1" colspan="2"/>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Search ===== -->
<record id="view_fp_operator_cert_search" model="ir.ui.view">
<field name="name">fp.operator.cert.search</field>
<field name="model">fp.operator.certification</field>
<field name="arch" type="xml">
<search>
<field name="employee_id"/>
<field name="process_type_id"/>
<filter name="active" string="Active"
domain="[('state', '=', 'active')]"/>
<filter name="expired" string="Expired"
domain="[('state', '=', 'expired')]"/>
<filter name="expiring_soon" string="Expiring within 30 days"
domain="[('expires_date', '&lt;=', (context_today() + datetime.timedelta(days=30)).strftime('%Y-%m-%d')),
('expires_date', '&gt;=', context_today().strftime('%Y-%m-%d')),
('state', '=', 'active')]"/>
<separator/>
<group>
<filter name="group_employee" string="Operator"
context="{'group_by': 'employee_id'}"/>
<filter name="group_process" string="Process"
context="{'group_by': 'process_type_id'}"/>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_operator_cert" model="ir.actions.act_window">
<field name="name">Operator Certifications</field>
<field name="res_model">fp.operator.certification</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_operator_cert_search"/>
<field name="context">{'search_default_active': 1}</field>
</record>
<!-- Extend employee form with a smart list of certs -->
<record id="view_hr_employee_fp_certs" model="ir.ui.view">
<field name="name">hr.employee.form.fp.certs</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Plating Certifications" name="fp_certs">
<field name="x_fc_certification_ids"
context="{'default_employee_id': id}">
<list editable="bottom">
<field name="process_type_id"/>
<field name="issued_date"/>
<field name="expires_date"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===== List ===== -->
<record id="view_fp_rack_list" model="ir.ui.view">
<field name="name">fusion.plating.rack.list</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<list decoration-danger="state == 'needs_strip'"
decoration-warning="state == 'stripping'"
decoration-muted="state == 'retired'">
<field name="name"/>
<field name="rack_type"/>
<field name="facility_id"/>
<field name="capacity"/>
<field name="mto_count"/>
<field name="strip_interval_mto"/>
<field name="last_stripped_date"/>
<field name="strips_count"/>
<field name="state" widget="badge"
decoration-success="state == 'active'"
decoration-danger="state == 'needs_strip'"
decoration-warning="state == 'stripping'"
decoration-muted="state == 'retired'"/>
</list>
</field>
</record>
<!-- ===== Form ===== -->
<record id="view_fp_rack_form" model="ir.ui.view">
<field name="name">fusion.plating.rack.form</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_start_strip"
string="Start Strip"
type="object" class="btn-primary"
invisible="state != 'needs_strip'"/>
<button name="action_mark_stripped"
string="Mark Stripped"
type="object" class="btn-primary"
invisible="state != 'stripping'"/>
<button name="action_retire"
string="Retire"
type="object" class="btn-secondary"
invisible="state == 'retired'"
confirm="Retire this rack permanently?"/>
<field name="state" widget="statusbar"
statusbar_visible="active,needs_strip,stripping"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. RACK-014"/></h1>
</div>
<group>
<group string="Identity">
<field name="rack_type"/>
<field name="facility_id"/>
<field name="capacity"/>
<field name="contact_points"/>
</group>
<group string="Wear &amp; Strip">
<field name="mto_count"/>
<field name="strip_interval_mto"/>
<field name="last_stripped_date"/>
<field name="last_stripped_by_id"/>
<field name="strips_count"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Kanban ===== -->
<record id="view_fp_rack_kanban" model="ir.ui.view">
<field name="name">fusion.plating.rack.kanban</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<kanban default_group_by="state">
<field name="status_color"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click oe_kanban_color_#{record.status_color.raw_value}">
<strong><field name="name"/></strong>
<div><field name="rack_type"/><field name="facility_id"/></div>
<div>MTO: <field name="mto_count"/> / <field name="strip_interval_mto"/></div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===== Search ===== -->
<record id="view_fp_rack_search" model="ir.ui.view">
<field name="name">fusion.plating.rack.search</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="facility_id"/>
<filter name="needs_strip" string="Needs Strip"
domain="[('state', '=', 'needs_strip')]"/>
<filter name="active" string="Active"
domain="[('state', '=', 'active')]"/>
<separator/>
<group>
<filter name="group_facility" string="Facility"
context="{'group_by': 'facility_id'}"/>
<filter name="group_type" string="Type"
context="{'group_by': 'rack_type'}"/>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_rack" model="ir.actions.act_window">
<field name="name">Racks &amp; Fixtures</field>
<field name="res_model">fusion.plating.rack</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_fp_rack_search"/>
</record>
</odoo>

View File

@@ -64,8 +64,11 @@
<page string="Physical">
<group>
<group>
<field name="volume"/>
<field name="volume_uom"/>
<label for="volume"/>
<div class="o_row">
<field name="volume" nolabel="1" class="oe_inline"/>
<field name="volume_uom" nolabel="1" class="oe_inline"/>
</div>
<field name="material"/>
</group>
<group>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Fusion Plating — Settings page block.
Inherits the standard Settings form. The `app` element creates a
Fusion Plating section in the left rail; downstream modules
(certificates, invoicing, etc.) extend the same `app` with extra
blocks via xpath into //app[@name='fusion_plating'].
-->
<record id="res_config_settings_view_form_fp_core" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.plating.core</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Plating" string="Fusion Plating"
name="fusion_plating"
groups="fusion_plating.group_fusion_plating_manager">
<block title="Regional Settings"
name="fp_regional_settings"
help="Defaults applied to dashboards, reports, and emails when a user has no personal preference set.">
<setting id="fp_default_timezone"
string="Default Timezone"
help="Timezone used to display times in dashboards, PDFs, and notification emails. Detected automatically when Fusion Plating is installed; change it any time. Individual users can still override this from their own profile (Preferences > Localization).">
<field name="x_fc_default_tz"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

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