80 Commits

Author SHA1 Message Date
gsinghpal
1414ef2c1c fix(fusion_clock): NFC kiosk header — center logo at top, stack clock+date below 2026-05-14 08:42:26 -04:00
gsinghpal
42e8fe3d21 fix(fusion_clock): NFC kiosk — subtle logo glass, centered clock, AM/PM 2026-05-14 08:38:25 -04:00
gsinghpal
bad73fcea8 fix(fusion_clock): NFC kiosk visual polish — bigger chip, uncut waves, logo glass pill, no clock collision 2026-05-14 08:29:33 -04:00
gsinghpal
94249ba67d feat(fusion_clock): premium glass NFC kiosk + scope CSS to kiosk page
Visual rewrite of the NFC kiosk page:
- Animated mesh gradient background (drifts on a 28s loop)
- Glass-panel state cards with backdrop-filter blur
- Animated SVG NFC icon (concentric waves emanate from a chip)
- Company logo pulled from res.company.logo, displayed in header
- Dominant-hue extraction from logo sets --nfc-h CSS var; entire
  palette interpolates from that one HSL hue
- Success burst (green glow + scale), error shake, smooth state fades
- Reduced-motion fallback respects prefers-reduced-motion
- Glass numpad + employee picker in Enroll Mode

CRITICAL FIX: scoped all kiosk styles under :has(#nfc_kiosk_root) so
they no longer leak into other frontend pages. Previous version applied
html/body overflow:hidden + display:none on header/footer globally,
breaking website scrolling and chrome on every frontend page.
2026-05-14 08:22:47 -04:00
gsinghpal
2abd859a29 feat(fusion_clock): NFC kiosk Wake Lock to keep screen on while active 2026-05-14 08:09:27 -04:00
gsinghpal
98cb42d2e5 feat(fusion_clock): NFC kiosk on-screen debug overlay + clearer settings label 2026-05-14 08:03:47 -04:00
gsinghpal
878d05685c fix(fusion_clock): split min(80vw,700px) into width+max-width to avoid Sass unit error 2026-05-14 07:23:49 -04:00
gsinghpal
bd2c037a97 Update 2026-05-13-nfc-clock-kiosk-plan.md 2026-05-14 07:07:45 -04:00
gsinghpal
44636e47fb chore(fusion_clock): bump version to 19.0.3.0.0 for NFC kiosk feature 2026-05-14 01:32:07 -04:00
gsinghpal
06c49ecec6 feat(fusion_clock): NFC kiosk mock-tap debug shortcut
Add Ctrl+Shift+T keyboard shortcut (guarded by debugEnabled / nfc_kiosk_debug
setting) that prompts for a UID and fires _onEnrollTap or handleTap depending
on currentState (ENROLL vs IDLE). Persists last-used UID in localStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:30:35 -04:00
gsinghpal
37deaedf0d feat(fusion_clock): NFC kiosk Enroll Mode UI
Replaces the Task 18 stub renderEnroll with the full four-phase
implementation (password numpad → employee picker → tap-to-enroll →
result), adds _onEnrollTap wired to the NFC reading event, and exposes
it via window.__nfcKiosk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:28:36 -04:00
gsinghpal
30f7f18472 feat(fusion_clock): camera capture on NFC kiosk
Replace camera stub with real getUserMedia + canvas capture. Setup button
now starts NFC reader and camera together; camera failure is non-fatal when
photo is not required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:25:12 -04:00
gsinghpal
66e9749853 feat(fusion_clock): Web NFC integration on kiosk page
Adds NDEFReader scan loop, onNfcReading tap dispatcher, handleTap
state machine, postJson helper, capturePhoto stub (Task 17), and
setup wizard activation with error display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:22:55 -04:00
gsinghpal
c9be68a575 feat(fusion_clock): NFC kiosk JS scaffold + state machine + clock display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:20:31 -04:00
gsinghpal
19d692afe7 feat(fusion_clock): NFC kiosk QWeb template with static chrome + setup wizard
Replace placeholder template with full version: static chrome (company,
clock, date, location, settings button), one-time setup wizard state,
hidden video/canvas for camera, and data-* attrs for JS feature flags.
Update test assertion from h1 text to nfc_kiosk_root id to match new markup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:18:04 -04:00
gsinghpal
0351dcd497 feat(fusion_clock): NFC kiosk SCSS (always-dark, high-contrast)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:14:14 -04:00
gsinghpal
03fd3d7c1c feat(fusion_clock): NFC kiosk employee search endpoint
Add /fusion_clock/kiosk/nfc/employee_search that delegates to the
existing kiosk_search method, avoiding logic duplication. Adds
TestEmployeeSearch HttpCase (33 tests total, all passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:11:28 -04:00
gsinghpal
f4c9ed3d24 feat(fusion_clock): NFC tap photo capture + photo-required gate
- Add _strip_data_url_prefix() helper to clean data-URL prefix from base64 photo payloads
- Gate nfc_tap on fusion_clock.nfc_photo_required ICP param (default True): rejects with error='photo_required' when photo absent
- Write x_fclk_check_in_photo / x_fclk_check_out_photo on clock-in/out attendance records
- Add TestTapPhotoHandling (3 tests): photo saved, required-rejects-missing, optional-succeeds-without

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:09:27 -04:00
gsinghpal
ef885c66dc feat(fusion_clock): NFC tap endpoint debounce + 6 error-case tests
Adds module-level 5s debounce (_is_debounced) with thread-safe dict +
GC. Inserts debounce guard in nfc_tap immediately after uid validation.
Adds TestTapEndpointErrors (6 tests): unknown_card, clock_disabled,
no_location_configured, kiosk_disabled, invalid_uid, debounce.
Adds setUp() to both tap test classes to clear _recent_taps between
tests, preventing cross-test debounce bleed. 29/29 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:06:30 -04:00
gsinghpal
148aa5cba8 feat(fusion_clock): NFC tap endpoint (happy path)
Add /fusion_clock/kiosk/nfc/tap JSON-RPC endpoint that toggles attendance
via _attendance_action_change, writing x_fclk_clock_source='nfc_kiosk' and
location on clock-in, applying break deduction/penalty checks on clock-out.
Add 2 HttpCase tests (clock-in + clock-out with 6s debounce sleep).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:02:49 -04:00
gsinghpal
661c8ae227 feat(fusion_clock): NFC card enrollment endpoint
Adds /fusion_clock/kiosk/nfc/enroll (jsonrpc, auth=user) that validates
the enroll password, normalises the card UID, checks for duplicate
assignments, writes x_fclk_nfc_card_uid, and creates a card_enrollment
activity log entry. 4 new tests; 21 total passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:58:25 -04:00
gsinghpal
a24a1ddf1a feat(fusion_clock): NFC card UID normalization helper
Add _normalize_uid static method to FusionClockNfcKiosk that strips
whitespace, uppercases, removes separators, validates hex-only content,
and reformats to canonical colon-separated pairs; returns None for
empty/invalid input. Covered by 7 new TransactionCase unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:56:02 -04:00
gsinghpal
f05cacec22 feat(fusion_clock): NFC kiosk page render route
Controller scaffold with GET /fusion_clock/kiosk/nfc, placeholder QWeb
template, and HttpCase tests (10 pass, 0 failures). Fixed Odoo 19
res.users create API: groups_id -> group_ids.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:53:03 -04:00
gsinghpal
9239ee2822 feat(fusion_clock): add NFC Clock Kiosk settings block
Extends res.config.settings with 5 NFC kiosk fields (enable toggle,
photo required, enroll password, debug mode, kiosk location via
related company field) and adds the corresponding settings view block
with conditional sub-fields hidden until the kiosk is enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:45:41 -04:00
gsinghpal
4733885211 feat(fusion_clock): add NFC kiosk ir.config_parameter defaults
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:43:07 -04:00
gsinghpal
8e708bf2c4 fix(fusion_clock): NFC kiosk location domain + test isolation
Add domain filter on x_fclk_nfc_kiosk_location_id so the dropdown
only shows locations belonging to the current company in multi-company
setups. Replace shared-company mutation in test with a fresh company
to prevent cross-test state leakage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:40:28 -04:00
gsinghpal
caf240daec feat(fusion_clock): add NFC kiosk location to res.company
Adds x_fclk_nfc_kiosk_location_id (Many2one → fusion.clock.location) to
res.company so each company can designate which NFC kiosk location it uses.
Two tests cover field assignment and default-false behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:36:56 -04:00
gsinghpal
4bed8ab2c5 fix(fusion_clock): reorder NFC kiosk source before System per plan
Move ('nfc_kiosk', 'NFC Kiosk') to sit between kiosk and system in the
source Selection field, matching the spec's semantic grouping of
interactive sources before the automated system source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:34:36 -04:00
gsinghpal
50c209b8d3 feat(fusion_clock): NFC kiosk attendance fields + activity-log selections
- Add 'nfc_kiosk' to x_fclk_clock_source selection on hr.attendance
- Add x_fclk_check_in_photo and x_fclk_check_out_photo Binary fields (attachment=True)
- Add 'card_enrollment' and 'unknown_card_tap' to activity log log_type selection
- Add 'nfc_kiosk' to activity log source selection
- Add TestNfcAttendanceFields test class (3 tests); all 6 fusion_clock tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:24:35 -04:00
gsinghpal
65a1c4b17e fix(fusion_clock): remove unused ValidationError import in NFC tests 2026-05-14 00:19:20 -04:00
gsinghpal
91d3a3f9d1 docs(fusion_clock): use actual docker env names (odoo-modsdev-app/modsdev) in NFC plan 2026-05-14 00:14:51 -04:00
gsinghpal
70f855d91b feat(fusion_clock): add x_fclk_nfc_card_uid to hr.employee
Adds the NFC card UID field (Char, unique, manager-only) that the kiosk
will use to identify employees by card tap. Includes the tests package
with three post-install tests covering write, uniqueness, and nullable
multi-row behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:13:26 -04:00
gsinghpal
85eddba546 docs(fusion_clock): NFC clock kiosk implementation plan
20-task TDD plan for the NFC clock kiosk feature spec'd in
2026-05-13-nfc-clock-kiosk-design.md. Bite-sized steps with full code
in each, ordered: data model -> config -> backend endpoints ->
SCSS+template -> JS state machine -> NFC + camera -> Enroll Mode ->
debug shortcut -> version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:05:23 -04:00
gsinghpal
48d3e48e61 docs(fusion_clock): NFC clock kiosk design
Design for tap-to-clock NFC kiosk in fusion_clock. Pilot scope: 1
station per company, Samsung Galaxy Tab Active 5 Pro running Web NFC
in Chrome kiosk mode. Reuses Ubiquiti-issued cards. Silent photo
verification via front camera. Backend reuses FusionClockAPI helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:50:00 -04:00
gsinghpal
f07e1bcce1 fix(chatter): wrap HTML message_post bodies in Markup() — 4 sites
Four message_post calls were passing strings with HTML tags as
plain `body=_(...)` instead of `body=Markup(_(...))`. Odoo escapes
non-Markup strings, so the chatter rendered "<b>QA Review failed</b>"
as literal text instead of bolding it.

Original bug surfaced via the Contract Review (QA-005) flow:
  body: "&lt;b&gt;QA Review failed&lt;/b&gt; by Garry Singh. Awaiting
  client information.&lt;br/&gt;&lt;b&gt;Reason:&lt;/b&gt;&lt;br/&gt;
  &lt;div data-oe-version=\"2.0\"&gt;Need to get updated
  drawing...&lt;/div&gt;"

Audit scan turned up three more identical patterns:

  fusion_plating/models/fp_parent_numbered_mixin.py:118
     "Issued <strong>%s</strong> to ..."
  fusion_plating_jobs/models/sale_order.py:282
     "Confirmed quote <strong>%s</strong> as <strong>%s</strong>."
  fusion_plating_quality/models/fp_contract_review.py:430
     "<b>QA Review failed</b> by ... <b>Reason:</b><br/>%(reason)s"
  fusion_plating_quality/models/fp_contract_review.py:524
     "<b>QA Review completed</b> by ... <b>Special Instructions
      captured:</b><br/>%(notes)s"

Fixes:
- Wrapped each body=_(...) with Markup(_(...)) using the
  Markup(template) % values pattern (auto-escapes the substituted
  values; user-supplied free text stays safe).
- For Html-field substitutions (qa_failure_reason,
  special_instructions), explicitly wrapped the value in Markup()
  so already-formatted HTML editor content (with data-oe-version="2.0"
  wrapper divs) flows through without being re-escaped.
- Added `from markupsafe import Markup` to the two files that
  didn't already import it (mixin + contract_review).

Drift cleanup: pulled the 180-line newer fp_contract_review.py
from entech to the local repo (added action_qa_review_failed,
action_open_client_email_wizard, action_view_client_emails,
action_complete_after_info, awaiting_info state, qa_failure_reason
+ special_instructions Html fields, etc. that had been edited on
entech without being committed).

Tested by re-posting via odoo shell on review 10: body now stores
"<b>QA Review failed</b>..." with literal HTML tags instead of
the double-escaped "&lt;b&gt;..." entities. Old chatter records
with the bad escape stay as-is in the audit trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:41:39 -04:00
gsinghpal
e7c6960de9 feat(sticker): restore customer-name secrecy cover (ABC-MANU)
Body Customer row now prints a 3-and-4 short code instead of the
full company name. Operators see "ABC-MANU" on the floor; visiting
customers / unauthorised passers-by can't immediately tell whose
parts are on which rack.

Rule (per user's reference design):
  - First 3 chars of first word + "-" + first 4 chars of second word
  - Single-word names → just first 3 chars
  - All uppercase
  - Strips non-alphanumeric per word so "St. John's Mfg." doesn't
    leak punctuation into the slice

Logic lives in the shared inner template, so all 4 variants pick
it up automatically:
  sale.order     External + Internal Sticker
  fp.job         External + Internal Job Sticker

Verified on fp.job 2635: Customer row now reads "ABC-MANU" (was
"ABC Manufactoring").

Doesn't use the orphaned x_fc_short_code field on res.partner
(that field has no column or compute — broken Studio remnant).
A future spec can replace this inline computation with a proper
stored+inverse field if customers want per-partner overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:24:07 -04:00
gsinghpal
ad64b0b4c9 changes 2026-05-13 08:17:35 -04:00
gsinghpal
cd763fa1d7 chore(sticker): rename External action labels for the variant split
Print menu now shows External + Internal as paired entries:

  sale.order:  External Sticker      / Internal Sticker
  fp.job:      External Job Sticker  / Internal Job Sticker

XML IDs unchanged (action_report_fp_so_sticker /
action_report_fp_job_sticker) so existing bookmarks and
binding_model_id records keep working. print_report_name strings
also updated so the downloaded filename matches the new label.

DB verification:
  fp.job      | External Job Sticker
  fp.job      | Internal Job Sticker
  sale.order  | External Sticker
  sale.order  | Internal Sticker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:07:50 -04:00
gsinghpal
f40f44aafd feat(sticker): add Internal Job Sticker variant on fp.job Print menu
Mirror of the SO Internal variant for fp.job. Same body fields,
same per-box loop; Notes column reads x_fc_internal_description
from the first linked SO line (job.sale_order_line_ids[:1]).
Operator on the shop floor sees ops-internal notes without those
ever appearing on the customer-facing External sticker.

Verified on fp.job 2635 with seeded internal_description: Notes
column reads "INTERNAL JOB: handle with care, no rework on this
batch" — confirms the Job Internal variant's override path mirrors
the SO Internal variant's.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:06:12 -04:00
gsinghpal
63bf271725 feat(sticker): add Internal Sticker variant on sale.order Print menu
Same 3-cell + body layout as External; Notes column reads
x_fc_internal_description (Sub 2 internal-description field on the
SO line) instead of line.name. Shop floor gets ops-facing notes
without leaking them to the customer-facing variant.

New action record action_report_fp_so_sticker_internal — binds to
sale.order, appears in the Print menu next to the existing External
sticker. New template report_fp_so_sticker_internal that pre-sets
_notes_content before t-calling the shared inner.

Verified on SO-30019 with a seeded internal_description: Notes
column reads "INTERNAL: rework if any dings on flange. Buff per
WI-104." — confirms the override path is wired through the
defaults-block initialiser, the inner's fallback chain, and the
new outer template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:04:29 -04:00
gsinghpal
974b8a5152 feat(sticker): wire _qty_total in SO + Job External outers
Activates the per-box loop landed in the prior commit. SO External
reads line.product_uom_qty; Job External reads job.qty. Inner
template now renders one sticker per physical box, marking each
with "X / N" in the Qty row.

Verified on fp.job 2635 (qty temporarily set to 3): 3-page PDF
with Qty rows "1 / 3", "2 / 3", "3 / 3" — each page identical
otherwise (same WO#, same QR, same body fields).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:02:32 -04:00
gsinghpal
0a32ed2da7 feat(sticker): per-box render loop + Notes override hook
Inner sticker template gains two parameters that outer templates
pre-set:

  _qty_total — total qty for the line/job. Inner wraps the body
    in t-foreach="range(int(_qty_total or 1))" so a qty=5 line
    produces 5 consecutive single-box stickers. Qty row in the
    body switches from "5" to "1 / 5", "2 / 5", ... "5 / 5".
    When _qty_total is missing/0/1, the Qty row keeps showing
    the plain integer (regression-free).

  _notes_content — Notes column source. Existing inner code
    hard-read _line.name; new code accepts an outer override
    and falls back to _line.name. External outers don't set it
    (unchanged behaviour); the new Internal outers (Task 4+5)
    pre-set it to x_fc_internal_description.

Defaults template initialises both new vars to False so the
inner's "outer-supplied OR fallback" pattern doesn't NameError
when called from existing outers that haven't been updated yet.

Verified regression-free: fp.job 2635 (qty=1) renders identically
to its pre-Task baseline — Qty row shows plain "1", Notes from
line.name as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:00:22 -04:00
gsinghpal
e4681a58c6 fix(jobs): split fp.jobs by thickness + serial on SO confirm
The _fp_auto_create_job grouping key was (recipe, part, coating).
Lines that shared all three but differed in thickness (or serial)
silently collapsed into one fp.job — the second line's thickness/SN
was lost, and any downstream cert printed the first line's values
across both batches. Silent mis-attestation = compliance hole.

Extended the key tuple to (recipe, part, coating, thickness, serial).
Single-line SOs and same-(thickness, SN) multi-line SOs collapse
identically to before. Only lines that previously merged when they
shouldn't have now split into their own fp.jobs.

TDD via test_so_confirm_splits_by_thickness:
  - seeds the part with default_process_id so both lines hit the
    `if recipe:` branch (where the bug lived — the no_recipe branch
    already split correctly per line)
  - confirms 2 jobs after action_confirm with each carrying its
    own thickness via the linked SO line

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:57:56 -04:00
gsinghpal
135cbd3a5c docs: implementation plan — sticker multi-part / per-box / Internal+External
7 tasks, bite-sized steps with exact code + commands. TDD on the
backend grouping change (new test_so_confirm_splits_by_thickness);
deploy-and-render-PDF on the QWeb template changes. Each task
self-contained, pushes to entech LXC 111 via the standard pct
exec + cat-pipe path, bumps the module version, and commits.

Task 7 is verification-only — creates a multi-line test SO with
two different thicknesses, renders External + Internal stickers
on both the SO and each spawned fp.job, confirms the box loop
and the Notes variant pattern both work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:47:40 -04:00
gsinghpal
3182ca3c39 docs: design spec — sticker multi-part / per-box / Internal+External
Three problems on the box-sticker stack rolled into one spec:

1. Backend: _create_fp_jobs grouping key collapses lines with
   different thicknesses or SNs into one job. Silent compliance
   hole. Fix: add thickness_id + serial_id to the key tuple.
2. No per-box stickers: a line with qty=5 prints 1 page showing
   "Qty: 5". Want 5 pages with "1 / 5", "2 / 5", ... "5 / 5".
3. No Internal variant: sticker always reads line.name (customer
   facing). Want a parallel variant that reads
   x_fc_internal_description (Sub 2 internal description field).

Renaming: existing actions keep their XML IDs (bookmarks /
binding_model_id records survive). Labels become:
  sale.order:  External Sticker      + Internal Sticker      (new)
  fp.job:      External Job Sticker  + Internal Job Sticker  (new)

All three changes share the same inner template, same files —
ship together. No data migration required; existing fp.jobs are
protected by the idempotency guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:41:53 -04:00
gsinghpal
677e460438 fix(sticker): wire SN # + Thickness to the correct Sub 5 fields
The values were structurally blank because the variable
resolution was reading the wrong field names:

  Was:  _line.x_fc_serial_number    (doesn't exist)
        _line.x_fc_thickness        (doesn't exist)
  Now:  _line.x_fc_serial_id.name           (M2O fp.serial)
        _line.x_fc_thickness_id.display_name (M2O fp.coating.thickness)

Sub 5 shipped these as Many2one registries (fp.serial,
fp.coating.thickness) — the sticker was guessing at flat
Char-field equivalents that were never created.

Verified on SO-30019: SN # now prints "65767", Thickness now
prints "0.3-0.5 mils" (the en-dash in display_name mojibakes
to "â€"" through wkhtmltopdf's font path on entech, so we
replace en-dash + em-dash with ASCII hyphen-minus before
render — ASCII-only is what label printers want anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:25:42 -04:00
gsinghpal
c7b794f604 fix(sticker): drop SO-line sequence suffix + bump Notes type
SO sticker (report_fp_so_sticker):
  Was: "SO-30019 / 10"  (the "/ 10" was line.sequence — Odoo's
       default increment-by-10 — meaningless to the operator)
  Now: "SO-30019"
Multi-line SOs are disambiguated by the body fields (Part #,
Customer, etc.) which already differ per sticker, so the
suffix wasn't earning its keep.

Notes column size bumps:
- Label 44pt -> 48pt
- Content 30pt -> 36pt (+20%) — easier to read from across
  the line. Line-height tightened 1.15 -> 1.1 to keep the
  multi-paragraph wrap inside the body band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:21:09 -04:00
gsinghpal
64c61dcca8 feat(sticker): much bigger text + QR +30%
wkhtmltopdf renders CSS font-size at a smaller physical scale
than the em-square math predicts (a "30pt" cell text was only
~4mm tall visually). Pushing all type up significantly so it
actually reads at scan/print distance:

Text bumps:
- Body field text 30pt -> 50pt (+67%, label + value)
- WO# 56pt -> 72pt (+29%)
- Notes label 30pt -> 44pt
- Notes content 22pt -> 30pt (+36%)
- Muted rev tag 22pt -> 30pt
- Body cell padding 0 10px -> 0 8px (a touch more horizontal
  room for long values now that the font is bigger)

QR + 30% as asked:
- Wrapper 280 -> 365px (+30.4%). Image 368 -> 480px, offset
  -44 -> -58px (recomputed for the new quiet-zone crop).

Header re-balanced for the bigger content:
- Height 25% -> 32% (fits the +30% QR + bigger WO# + bigger
  logo at 135px)
- Body band: 75% -> 68% (rows now ~9.6mm tall; line-height
  1.0 keeps the 50pt body text snug inside)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:48:40 -04:00
gsinghpal
649b75d4a1 feat(sticker): bigger field labels + values + notes text
Trimmed the header from 30% to 25% of page height to free up
vertical room for the body band's 7 rows. Each row is now
~10.45mm tall (was 9.88mm), so the field font fits comfortably
at the bigger size.

Size bumps:
- Body field text 26pt -> 30pt (label + value, +15%)
- Muted rev tag 18pt -> 22pt
- Notes label 26pt -> 30pt
- Notes content 19pt -> 22pt (+16%, wraps cleanly to 2 lines
  when the customer description runs long)

Header re-fit (smaller cells, same content):
- Header height 30% -> 25%
- WO# font 62pt -> 56pt
- Logo max-height 135 -> 105px
- QR wrapper 340 -> 280px (image 447 -> 368px, offset -53 ->
  -44px to keep the quiet-zone crop math right)
- High-def 600x600 QR source unchanged — still prints crisp
  at the smaller wrapper size

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:41:17 -04:00
gsinghpal
8aa817b1a0 feat(sticker): bigger text, bigger high-def QR, drop "WO #" prefix
WO# cell now just renders the number (e.g. "WO-30019") since the
"WO" is already baked into the doc index format — the redundant
prefix was eating cell width without adding information.

Size bumps:
- WO# 44pt -> 62pt (text is shorter so the cell can carry the
  extra weight)
- Body field text 22pt -> 26pt, line-height 1.1 -> 1.0 so the
  bigger font still fits 7 rows in the body band
- Notes label 22pt -> 26pt, content 16pt -> 19pt
- Logo max-height 120 -> 135px
- Muted rev tag 16pt -> 18pt

QR upgrades (both "bigger" and "high def" as asked):
- Source resolution 300x300 -> 600x600. At 300dpi print across
  a 28.8mm wrapper, effective output is ~515ppi vs the prior
  ~256ppi. Scanners on the floor will read it cleanly even at
  steeper angles / scuffed labels.
- Wrapper 290 -> 340px (+17%). Image 390 -> 447px, offset -50
  -> -53px (recomputed quiet-zone crop: 600 * 0.12 = 72px
  margin -> 456px effective QR data -> 340 * 600/456 = 447
  scaled image -> (447-340)/2 = 53px offset).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:38:05 -04:00
gsinghpal
80d1cc5639 feat(sticker): 3-cell header + right-side Notes column + new field list
Restores the original ENTECH sticker layout from the operator's
screenshot reference:

Header (3 horizontal cells, divided by vertical rules):
  [Logo]  |  WO #WO-30019  |  [QR]

Body (left side = field table, right side = Notes column):
  PO #:        587854         | Notes:
  SN #:        -              | <customer-facing description>
  Customer:    ABC Manufact.  |
  Part #:      9876... Rev A  |
  Due Date:    May 17, 2026   |
  Thickness:   -              |
  Qty:         1              |

Changes from previous (stacked-left) layout:
- Header: 1-row 3-cell (Logo 28% | WO# 44% | QR 28%) replaces
  the 2-cell w/ logo+WO# stacked on left.
- Body: 2-region (66% / 34%) replaces single 7-row table.
  Notes column now spans full body height on the right.
- Fields: SN # and Thickness added; Process row removed.
- Labels: "PO (RO)" -> "PO #", "Part Number" -> "Part #".
- Notes content: switched from SO.x_fc_internal_note to the SO
  line's `name` (= customer-facing description per Sub 2 Q6).
- SN # reads _line.x_fc_serial_number (Sub 5 field).
- Thickness reads _line.x_fc_thickness with coating.thickness
  fallback (Sub 5 field, defensive 'in _fields' check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:33:18 -04:00
gsinghpal
2db789d7dd feat(sticker): bigger QR + double-height Notes row
Both changes the operator asked for, applied to the original
ENTECH stacked-left layout (no other structural changes):

- QR wrapper 380px → 460px (image 510px → 620px, offset -65 → -80
  to keep the white quiet-zone cropped). Roughly +21% surface area.
- Notes row height 14.28% → 24% (~2x). Other 6 rows shrink
  proportionally from 14.28% to 12.67% each so the band still
  totals 100%. Notes value also gets white-space: normal +
  vertical-align: top so the operator's handwriting room sits at
  the top of the cell and a long internal note can wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:25:37 -04:00
gsinghpal
7a02382623 fix(reports): WO Margin model name must match report_name + '_template' suffix
The previous fix swapped t-field -> t-esc so the QWeb error stopped,
but the report still printed blank. Root cause: Odoo looks up the
report data model via env['report.<report_name>'], but our model was
named 'report.fusion_plating_jobs.report_fp_job_margin' while the
action's report_name is 'fusion_plating_jobs.report_fp_job_margin_template'.
The model lookup missed, _get_report_values never fired, and the
template rendered with no 'rows' in scope — empty foreach -> empty
page.

Renamed the model to report.fusion_plating_jobs.report_fp_job_margin_template.

Verified: PDF size jumped from 1229 bytes (blank) to 125880 bytes
(fully populated). HTML now contains 'Job Margin', 'Step Breakdown',
and the actual WO name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:08:38 -04:00
gsinghpal
169e97af02 feat(nexa_coa_setup): analytic plans + seed accounts
- 'Customer Project' plan (renamed from 'Project' to avoid duplicate with
  project module's auto-created plan) — mandatory
- 'Department' plan (mandatory) — seeded with DEPT-DEV, DEPT-SALES,
  DEPT-ADMIN, DEPT-HOSTING
- 'SR&ED Tag' plan (optional) — seeded with 7 tag values:
  SRED-T4-DEV-SALARY, SRED-SPECIFIED-EMPLOYEE,
  SRED-CONTRACTOR-CA-ARM-LENGTH, SRED-CONTRACTOR-CA-NON-ARM-LENGTH,
  SRED-MATERIALS-CONSUMED, SRED-OVERHEAD-PROXY-BASIS, NOT-ELIGIBLE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:53:21 -04:00
gsinghpal
3c959771ae feat(nexa_coa_setup): pre_init_hook to clear l10n_ca code collisions
Bakes the staging-side one-off collision clearing into the module install
itself so production install will execute the same sweep automatically.

For each of the 29 l10n_ca codes that conflict with Nexa's planned chart:
- If the account has zero postings: suffix code with '.OLD', mark inactive,
  rename to '(l10n_ca LEGACY) <original>'
- If the account has postings (currently 115100 AR control with 240 lines
  and 511100 Inside Purchases with 1 line): leave alone (Nexa renumbered
  to 119100 / 511105 in the XML)

Idempotent — pre_init_hook re-running has no effect (already-suffixed
codes are skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:51:25 -04:00
gsinghpal
449f29fc7f fix(reports): WO Margin PDF — t-field requires dot-notation on Odoo 19
The template used 't-field="step['rate']"' for monetary values pulled
from dict rows. Odoo 19's QWeb asserts t-field has at least one dot
(it's strictly for record.field_name lookups). Replaced six bare-dict
t-field usages with t-esc; the existing t-options widget=monetary +
display_currency still applies for currency formatting.

Verified by rendering report for WO-30019 — 1229-byte valid PDF, no
QWeb error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:51:17 -04:00
gsinghpal
3c2fb22346 feat(nexa_coa_setup): chart of accounts — 128 accounts across 1-6xxxxx
Renumbered to avoid collisions with pre-loaded l10n_ca codes:
- Due From Shareholder/Associated: 115xxx → 119xxx range (115100/115110 already
  held l10n_ca AR control accounts with 240 postings)
- Cloud Infrastructure: 511100 → 511105 (511100 was l10n_ca 'Inside Purchases'
  with 1 historical posting)

All other 28 colliding l10n_ca codes (118xxx, 213xxx, 214xxx, 221xxx, 311xxx,
411xxx, 413xxx, 511110-511210, 512100-512200, 611100-300, 612xxx) had zero
postings and were cleared in-place by suffixing existing codes with '.OLD'
via a one-off odoo-shell script on staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:50:00 -04:00
gsinghpal
3a41370189 fix(nexa_coa_setup): tolerant fiscal-year lock hook
The post_init_hook attempt to set fiscalyear_lock_date=2025-12-31 fails
with RedirectWarning when unreconciled bank statement lines exist in
the period. Catch RedirectWarning/UserError/ValidationError, log a
clear instruction to set the lock manually after reconciliation, and
let install continue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:41:15 -04:00
gsinghpal
d6513ff7ab feat(nexa_coa_setup): module skeleton with hooks stub
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:39:24 -04:00
gsinghpal
457d9b7dbf fix(numbering): post-review fixes — credit notes, SO unlink, multi-part grouping, SQL whitelist
- B1: Add Credit Note wizard path was blocked because invoice_origin
  has copy=False and the wizard doesn't set fp_from_so_invoice. Now
  the validator allows reversals when reversed_entry_id points at a
  customer-facing move that itself went through the validator at
  original creation time. account.move._fp_parent_sale_order also
  walks self.reversed_entry_id._fp_parent_sale_order so the credit
  note inherits the parent number (CN-<parent>).

- Bug 1: sale.order.unlink() now blocks deletion when x_fc_parent_number
  is set (matches spec §6.2). Draft quotes remain freely deletable
  per Odoo standard. Applies to all users including admins.

- Bug 2: out_receipt added to CUSTOMER_TYPES so POS-style receipts
  hit the same SO-flow gate as out_invoice / out_refund.

- C1: WO grouping key changed from recipe.id to (recipe.id, part.id,
  coating.id). Bundling lines with different parts under one WO put
  first_line's part_number on the CoC header — silent compliance
  mis-attestation. Now distinct parts always get distinct WOs even
  when they share a recipe.

- C3: SQL whitelist (_FP_COUNTER_FIELD_RE) on _fp_assign_parent_name's
  interpolated counter field name. No user input today; defence in
  depth for future subclasses that might read the name from context.

Verified on entech: parent=30017, credit note = CN-30017,
multi-part SO produces 2 WOs (one per part), confirmed-SO unlink
blocked, out_receipt blocked, whitelist regex enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:19:08 -04:00
gsinghpal
c85a9bbf82 docs: nexa_coa_setup implementation plan
Bite-sized task plan to implement the CoA design against odoo-nexa
nexamain database. 12 phases:
0. Safety backup + staging clone
1. Module skeleton (nexa_coa_setup)
2. Chart of accounts (~110 new accounts across 1-6xxxxx)
3. Analytic plans (Project, Department, SR&ED Tag)
4. Hooks for archive-unused / rename-legacy
5. Tax cleanup
6. 8 fiscal positions with auto-detect
7. Service product categories
8. Westin/Divine partner records (RP-Associated tag)
9. 8 bank reconciliation rules
10. End-to-end test invoices (ON, US, intercompany)
11. Apply to production (with explicit GO/NO-GO gate)
12. Operating runbook

Each task has a verify-before / change / verify-after / commit cycle.
Staging clone (nexamain_staging) used for every phase before prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:05:33 -04:00
gsinghpal
5b399fbdda fix(configurator): copy operator-input prompts when cloning recipe to part
_clone_subtree() in fp_part_composer_controller built node vals
manually and never copied source.input_ids — so 'Load Template'
copied the recipe tree structure but dropped every custom prompt,
leaving operators with empty data-capture screens. The fix iterates
input_ids and calls .copy({'node_id': new_node.id}) so kind,
target_min/max/unit, compliance_tag, hint, selection_options,
sequence — every field on the input model — flows through.

Verified on entech: ENP-ALUM-BASIC clone now shows all 105 prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:58:11 -04:00
gsinghpal
b5416d242c test(numbering): E2E walkthrough — quote -> SO -> WO -> IN -> CoC -> DLV -> RCV -> Hold -> RMA
Verified pass on entech (parent=30015): all linked docs share the
parent number, immutability + unlink-block + direct-invoice-block
all enforced. NCR/CAPA fall back to legacy sequences as designed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:35:29 -04:00
gsinghpal
fdbbd2852a fix(numbering): WO Detail report strips WO- prefix for compact display
short_wo now handles both naming schemes: new WO-NNNNN[-NN] (strips
WO-) and legacy WH/JOB/NNNNN (last slash segment). Customer-facing
Work Order column shows '30000-02' instead of 'WO-30000-02'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:34:09 -04:00
gsinghpal
be109c9c79 feat(numbering): surface quote ref under SO name on the form
A small grey 'Originally quoted as Q202605-200' line appears below
the SO heading once the order is confirmed. Uses invisible= on the
wrapper div (Odoo 19 forbids t-if in standard form views).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:22 -04:00
gsinghpal
78d633f63f feat(numbering): immutable name/doc_index + unlink block on issued docs
write() override raises UserError if name or x_fc_doc_index is in
vals and differs from the stored value (bypass: context flag
fp_allow_name_rename=True for the SO-confirm rename + bulk WO
creation paths). unlink() override raises UserError for records
that have been issued a name; applies to all users including
admins — cancellation must go through the state machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:39:03 -04:00
gsinghpal
95cb73d91a feat(numbering): wire NCR, CAPA, Hold, RMA into parent-numbered mixin
Hold derives parent via job_id.sale_order_id; RMA via sale_order_id
directly — both get HOLD-<parent> / RMA-<parent> names. NCR and CAPA
have no SO link in core, so they fall back to their legacy sequences
(NCR/YYYY/NNN, CAPA/YYYY/NNN); future modules can override the
_fp_parent_sale_order hook to enable parent naming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:36:29 -04:00
gsinghpal
0d85063b5e feat(numbering): wire CoC/RCV/DLV/PU into parent-numbered mixin + rename counters
Per-model counter fields on sale.order renamed to x_fc_pn_*_count
to avoid collision with pre-existing compute fields of the same
short name in bridge_mrp / receiving / configurator (silent
compute-override was suppressing the storage). 4 child models
(fp.certificate, fp.receiving, fusion.plating.delivery,
fusion.plating.pickup.request) now derive names as PFX-<parent>
with -NN suffix from the 2nd onward.

fusion.plating.pickup.request gains a sale_order_id field
(optional) so pickups created against an SO get parent-derived
names, while standalone pickups (pre-SO) fall back to PU/YYYY/NNNN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:30:37 -04:00
gsinghpal
765a0a4c82 feat(numbering): block direct invoice creation + wire account.move into mixin
Customer invoices (out_invoice / out_refund) can only be created via
sale.order._create_invoices() or with an invoice_origin matching an
existing SO. Applies to ALL users including admins. Once created,
the move's name is derived from the SO's parent number: IN-30000,
IN-30000-02, CN-30000, ... Pre-existing portal-job link on
action_post() preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:21:09 -04:00
gsinghpal
daf1235e20 docs(nexa-coa): annual HST + T2 filing cadence; HST# normalization
Captures user-confirmed CRA registration & filing setup:
- Annual GST/HST filer (return Mar 31, instalments if prior net tax ≥ \$3k)
- Annual T2 filer (return Jun 30, balance due Mar 31 for CCPC)
- HST# 741224877 currently stored as 9-digit BN root only; normalize to
  full 15-char '741224877 RT0001' for tax-report validation
- Quick Method opportunity downgraded — \$400k threshold applies to
  associated-group totals; Nexa+Westin+Divine combined likely exceeds it
- Add HST cadence escalation flag (quarterly auto-trigger at \$1.5M)
- Acceptance criteria expanded with HST# format, filer config, and
  intercompany invoice test case

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:20:16 -04:00
gsinghpal
3d4f003aba docs(nexa-coa): treat Westin & Divine as associated corps
Restructure Section 9 to handle Westin Healthcare Inc and Divine Mobility
Inc as Gurpreet's associated corporations (ITA s.256):
- Future intercompany flows go through normal AR/AP with partner records
  tagged 'RP-Associated', not slush 'Due to/from' GL buckets
- 'Due to/from Associated Corporations' now reserved only for true
  intercompany loans (no invoice)
- Surface SBD $500k sharing and SR&ED $3M sharing rules; Schedule 23
  allocation drives major annual tax decisions
- Manpreet account archived (employee of another corp, not Nexa-related)
- Add transfer-pricing risk flag (ITA s.247, 10% penalty)
- Add multi-company Odoo as future sub-project

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:18:52 -04:00
gsinghpal
6c6fb8d2a4 feat(numbering): WO grouping by recipe + parent-derived bulk naming
Replaces x_fc_wo_group_tag grouping with resolved-recipe grouping.
Bare WO-<parent> when 1 recipe, WO-<parent>-NN zero-padded for N>1
ordered by min line sequence. fp.job inherits parent-numbered mixin
for the manual-add path; bulk SO-confirm sets names explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:18:10 -04:00
gsinghpal
1b1bebdcd8 feat(numbering): assign parent_number + rename to SO-<n> on confirm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:14:47 -04:00
gsinghpal
e0d1998811 feat(numbering): draw quote name from fp.quote.number on SO create
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:12:45 -04:00
gsinghpal
bc3f584851 feat(numbering): add parent_number + counters to sale.order
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:10:55 -04:00
gsinghpal
105909470f feat(numbering): add fp.parent.numbered.mixin abstract model
Atomic counter via SELECT FOR UPDATE on the parent SO row. Composes
child names as PREFIX-PARENT (bare for first) or PREFIX-PARENT-NN
(zero-padded 2-digit, then unpadded past 99). Subclasses implement
three hooks: _fp_parent_sale_order, _fp_name_prefix, _fp_parent_counter_field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:09:17 -04:00
gsinghpal
6e67fc5ce3 docs: nexa systems CoA + accounting setup design spec
Comprehensive chart-of-accounts redesign for odoo-nexa nexamain DB:
hybrid approach over l10n_ca, three analytic plans (Project/Department/SR&ED
Tag), fiscal positions for auto tax handling, cleanup plan for the
~370 unused accounts and 49 messy taxes, automation hooks via product
categories and bank reconciliation rules.

Goals: CRA compliance, SR&ED claim infrastructure, zero-rated export
handling, one-click invoicing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:08:40 -04:00
gsinghpal
fd9d4e775b feat(numbering): add fp.parent.number + fp.quote.number sequences
Parent sequence starts at 30000. Quote sequence is Q + YYYYMM + non-resetting
counter starting at 200. Phase 1 Task 1 of the parent-number hierarchy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:07:16 -04:00
gsinghpal
2de5491693 plan(numbering): step-by-step implementation plan
15 tasks across 8 phases — foundation (sequences + mixin + SO fields),
quote/SO rename, WO grouping rewrite, invoice block + naming, child
model wiring (CoC/RCV/DLV/PU/NCR/CAPA/Hold/RMA), immutability + unlink
block, view + report fixes, end-to-end walkthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:38:08 -04:00
gsinghpal
671820427a spec(numbering): parent-number hierarchy design
Quote→SO→WO→IN→CoC→DLV→RCV→… all share a single parent number drawn
from the sale order. New abstract mixin centralises naming with atomic
counter increment, compliance-grade immutability, and a hard block on
direct invoice creation outside the SO workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:28:52 -04:00
81 changed files with 15091 additions and 313 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
# Nexa Systems Inc — Chart of Accounts & Accounting Setup Design
**Date**: 2026-05-12
**Target**: odoo-nexa production instance, database `nexamain`
**Status**: Design — pending implementation plan
## 1. Context
Nexa Systems Inc is a Canadian CCPC providing IT services: custom software development, custom ERP, business apps, hosting, custom websites, and custom web apps. Operations are Canada-wide with planned global expansion. Workforce: solo founder today (Gurpreet, Canadian), hiring plan favours Canadian T4/T4A with occasional India contractors for burst capacity. Nexa will pursue SR&ED tax credits.
**Current state (as of 2026-05-12)**:
- Odoo 19 Enterprise, l10n_ca localization loaded
- 426 GL accounts (most unused — generic Canadian template bloat)
- 49 active taxes with duplicates
- 14 journals incl. 7 bank accounts (overprovisioned)
- 776 journal entries, 125 invoices, data 2020-01-01 to 2026-05-04
- **Historical Odoo data is NOT authoritative** — accountant has filed externally on Excel-based records. Past will be reconciled later.
- All prior years filed with CRA. Fiscal year-end Dec 31.
**CRA registration & filing cadence**:
- **Business Number / HST account**: `741224877` (currently stored as 9-digit BN root only on company record; needs to be updated to full 15-char format `741224877 RT0001` for Odoo's Canadian tax reports to validate cleanly).
- **GST/HST filing**: annual. Return due **3 months after fiscal year-end** (March 31).
- **T2 corporate income tax filing**: annual. Return due **6 months after fiscal year-end** (June 30). Balance owing due 3 months after year-end (March 31) for CCPCs eligible for SBD; 2 months otherwise.
- **HST instalments**: annual filers must remit quarterly instalments if their net tax for the prior year was ≥ $3,000. Track via account 118200 GST/HST Instalments Paid.
- **T2 instalments**: monthly or quarterly instalments required if Part I tax owing in prior year ≥ $3,000.
**Goals**:
1. **CRA compliance** — clean tax handling, T2 Schedule 125 alignment, audit-ready
2. **Tax savings** — SR&ED claim infrastructure from day 1, zero-rated export handling, CCA structure
3. **Automation** — fiscal positions, default accounts, bank feeds, subscription billing
4. **Ease of use** — invoicing is one-click after customer/product selection
**Scope**: Chart of accounts structure + tax/fiscal-position setup + analytic plans + automation hooks. **Out of scope**: bank feed onboarding (separate sub-project), CCA custom module (defer until volume warrants), historical data reconciliation (separate sub-project when accountant records arrive).
## 2. Approach
**Approach #2 — Hybrid**: keep l10n_ca's 6-digit code scheme (Canadian accountants recognize it), aggressively curate (~370 unused accounts archived, ~20 renamed, ~70 added), supplement with three analytic plans for finer reporting without GL proliferation.
**Rejected alternatives**:
- *Surgical* — keep all 426 accounts unchanged. Rejected: bookkeeping burden, no IT-services shape.
- *Clean slate (custom 4-digit)* — toss l10n_ca. Rejected: accountants would have to learn it; loses pre-mapped CRA tax structure.
## 3. Code Skeleton
```
1xxxxx ASSETS
111xxx Cash & cash equivalents
112xxx Accounts receivable
113xxx Prepaid expenses
114xxx Other current assets
115xxx Due from shareholder / related parties
118xxx Tax assets (HST ITC, instalments)
151xxx Capital assets — cost
154xxx Accumulated depreciation (contra)
2xxxxx LIABILITIES
211xxx Accounts payable
213xxx HST/GST/QST collected
214xxx Net tax payable
215xxx Source deductions payable
216xxx Corporate income tax payable
221xxx Due to shareholder
222xxx Due to related parties
251xxx Long-term debt
3xxxxx EQUITY
311xxx Share capital + contributed surplus
321xxx Retained earnings + dividends
4xxxxx REVENUE (by service line — jurisdiction handled by tax codes, not by account)
411xxx Recurring revenue (SaaS, hosting, support)
412xxx Project revenue (custom dev, web app, website, ERP)
413xxx Services (consulting, training, support hourly)
414xxx Reseller revenue (third-party software/hardware)
419xxx Sales adjustments (discounts, returns, bad debt recovery)
5xxxxx DIRECT COSTS (COGS)
511xxx Infrastructure & hosting costs
512xxx Project direct costs (subcontractors, project software, project travel)
513xxx Cost of resold goods
519xxx COGS adjustments
6xxxxx OPERATING EXPENSES
611xxx Personnel — internal staff (T4)
612xxx Personnel — contract (T4A non-project)
621xxx Office & facilities
631xxx Technology — operating (internal SaaS subs)
641xxx Marketing & sales
651xxx Professional fees
661xxx Insurance
671xxx Travel & entertainment
681xxx Training & development
691xxx Banking & finance charges
699xxx Other (bad debt, donations, fines, FX losses, depreciation)
7xxxxx Other income (interest, FX gains)
8xxxxx Other expenses (rare; mostly absorbed in 691/699)
```
**Three analytic plans** (orthogonal tagging, applied per journal line):
| Plan | Required On | Purpose |
|---|---|---|
| **Project** | revenue, COGS, project costs | Project P&L, customer profitability, WIP, billable-hour realization |
| **Department** | payroll, OpEx | Departmental P&L, overhead allocation |
| **SR&ED Tag** | labour, contractors, materials (R&D) | T661 SR&ED claim — eligibility classification |
## 4. Revenue Accounts (4xxxxx)
```
Recurring Revenue
411100 SaaS Subscription Revenue
411200 Hosting & Infrastructure Revenue
411300 Support & Maintenance Contracts
411400 Domain/SSL/Renewal Pass-through Revenue
411500 Setup / Onboarding Fees
Project Revenue (one-time, milestone-billed)
412100 Custom Software Development
412200 Custom Web Application Development
412300 Custom Website Development
412400 ERP Implementation & Customization
412500 Mobile App Development ← reserved for future
412600 Business App / Integration Work
Services (hourly, retainer)
413100 Consulting & Advisory
413200 Training & Workshops
413300 Technical Support — Per-incident / Hourly
Reseller / Pass-through
414100 Third-party Software Resale (M365, Adobe)
414200 Hardware Resale
Adjustments (contra-revenue)
419100 Sales Discounts
419200 Sales Returns & Refunds
419300 Bad Debt Recovery
```
**Design rule**: one revenue account per service line. Jurisdiction (ON/Atlantic/QC/export/etc.) tracked entirely through tax codes and fiscal positions, NOT duplicate accounts.
## 5. Direct Costs / COGS (5xxxxx)
```
Infrastructure & Hosting
511100 Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)
511110 CDN & Edge Services (Cloudflare, Fastly)
511120 Backup & Storage Services
511130 Database & Backend Services (Supabase, hosted Postgres, Redis)
511140 Monitoring & Observability (customer-facing only)
511150 SSL Certificates & Domains (wholesale for resale)
511160 DNS & Email Hosting (wholesale)
Third-party APIs & Per-transaction Costs
511200 Third-party API Costs (Twilio, SendGrid, OpenAI)
511210 Per-customer Licensing & Royalties
Note: 511100511160 are shared between SaaS revenue (411100) and Hosting revenue (411200).
Allocation to specific revenue line happens via the Project analytic plan, not separate accounts.
Project Direct Costs
512100 Subcontracted Labour — Canadian (T4A) ← SR&ED-eligible
512110 Subcontracted Labour — Foreign ← NOT SR&ED-eligible
512200 Project-specific Software & Licenses
512300 Project Travel & Onsite (rebilled)
512400 Project Hardware (passed through)
Resold Goods & Services
513100 Cost of Software Resold
513200 Cost of Hardware Resold
Adjustments
519100 COGS Adjustments / Write-offs
```
**Design choices**:
- **Salaries in OpEx, not COGS** — keeps SR&ED tracking clean; allocation to projects via Project analytic plan.
- **Stripe/merchant fees in OpEx (691200)** — re-class to COGS later if SaaS revenue dominates.
- **Canadian vs Foreign subcontractor split** — critical for SR&ED (80% × 35% = 28% credit on CA arm's length; 0% on foreign).
## 6. Operating Expenses (6xxxxx)
```
Personnel — Internal Staff (T4)
611100 Salaries & Wages — Development ← SR&ED-eligible base
611200 Salaries & Wages — Sales & Marketing
611300 Salaries & Wages — Admin & Operations
611400 Salary — Shareholder/Officer (Gurpreet) ← 75% SR&ED cap (specified employee)
611500 Employer CPP / QPP Contributions
611600 Employer EI Premiums
611700 Employer Health Tax (EHT/QHST)
611800 WCB / WSIB Premiums
611900 Employee Benefits (health, dental, group)
611950 Bonuses & Incentives
611960 Vacation Pay Accrual
Personnel — Contract (non-project)
612100 Contract Labour — Canadian (admin/marketing/freelance)
612200 Contract Labour — Foreign
Office & Facilities
621100 Rent — Commercial Office
621200 Home Office — Business Portion ← own account; allocated %
621300 Utilities — Commercial
621400 Internet & Phone — Business
621500 Office Supplies & Consumables
621600 Cleaning & Maintenance
621700 Office Snacks & Refreshments
Technology — Operating
631100 Software — Productivity (M365, Slack, Notion, Linear, GitHub)
631200 Software — Development Tools (Cursor, Figma, IDEs)
631300 Software — Internal Infrastructure
631400 Software — Security & IT
631500 Software — Sales & Marketing
Marketing & Sales
641100 Advertising — Digital Ads
641200 Advertising — Content / SEO
641300 Trade Shows & Conferences
641400 Promotional Items / Branded Swag
641500 Website — Own (nexasystems.ca)
Professional Fees
651100 Legal Fees — General
651200 Accounting & Bookkeeping
651300 Tax Preparation (T2, T1, GST/HST)
651400 Business Consulting
Insurance
661100 Commercial General Liability
661200 Professional Liability / E&O
661300 Cyber Liability
661400 Property Insurance
661500 Directors & Officers Insurance
Travel & Entertainment
671100 Travel — Flights, Hotels, Ground Transport
671200 Meals & Entertainment — 50% Deductible ← own account; 50% adjustment at year-end
671300 Vehicle — Operating
671400 Mileage Reimbursement — Personal Vehicle
Training & Development
681100 Conferences & Seminars
681200 Courses & Certifications
681300 Books & Publications
681400 Professional Memberships & Dues
Banking & Finance
691100 Bank Service Charges
691200 Merchant Processing Fees (Stripe, PayPal, Square)
691300 Wire Transfer & FX Fees
691400 Interest Expense — Bank Loans / LOC
691500 Interest Expense — Credit Cards
691600 Late Payment Penalties — Non-deductible
Other
699100 Bad Debt Expense
699200 Donations & Sponsorships
699300 Penalties & Fines — Non-deductible
699400 Realized FX Losses
699500 Depreciation / CCA Expense
```
**Notable design decisions**:
- Salaries split by function (dev/sales/admin) — so SR&ED proxy applies cleanly to dev only.
- Owner/Shareholder salary isolated (611400) — for T2 Schedule 11 (Compensation of Officers) and CRA reasonableness defence.
- Non-deductible items isolated (691600, 699300) — prevents accidental deduction.
- Meals & Entertainment own account (671200) — accountant applies the 50% adjustment cleanly.
- Home office own account (621200) — business-use % applied to the whole account.
## 7. Capital Assets & CCA (1xxxxx + asset module)
```
Capital Assets — Cost
151100 Computer Hardware & Equipment (CCA Class 50, 55% DB)
151200 Office Furniture & Equipment (CCA Class 8, 20% DB)
151300 Vehicles (CCA Class 10 / 10.1)
151400 Leasehold Improvements (CCA Class 13, SL)
151500 Acquired Software/Intangibles (CCA Class 14.1, 5% DB)
151600 Tools & Small Equipment <$500 (CCA Class 12, 100% Y1)
Accumulated Depreciation (contra)
154100 Acc. Dep — Computer Hardware
154200 Acc. Dep — Office Furniture
154300 Acc. Dep — Vehicles
154400 Acc. Dep — Leasehold Improvements
154500 Acc. Dep — Acquired Software
```
**Asset model approach**: book straight-line depreciation in Odoo for financial reporting (clean monthly journal); maintain CCA schedule separately for T2 filing. CCA rates: Class 50 effective 82.5% Y1 (with AccII through 2027); Class 14.1 software 100% Y1; Class 12 small tools 100% Y1.
## 8. Tax Accounts (1xxxxx + 2xxxxx)
```
Tax Assets
118100 HST/GST Input Tax Credit (ITC) Receivable
118200 HST/GST Instalments Paid
118300 QST Input Tax Refund Receivable
Tax Liabilities
213100 HST/GST Collected on Sales ← single bucket; tax report breaks down by code
213500 QST Collected
214100 Net HST/GST Payable
215100 Source Deductions Payable — Federal Tax
215200 Source Deductions Payable — CPP
215300 Source Deductions Payable — EI
216100 Corporate Income Tax — Federal Payable
216200 Corporate Income Tax — Provincial Payable
216300 Corporate Tax Instalments Paid (contra)
```
## 9. Shareholder, Associated Corporations & Equity
**Associated corporations** (Gurpreet >25% owner of each → ITA s.256 associated group):
- Nexa Systems Inc (this company)
- Westin Healthcare Inc
- Divine Mobility Inc
**Treatment**: Westin and Divine are **regular Customers and Vendors of Nexa**, NOT slush accounts. Their transactions flow through normal AR/AP. They get partner records tagged `Related Party — Associated Corporation` for disclosure tracking. The "Due To/From Related Party" GL buckets exist only for true intercompany loans (cash moved between the corps' bank accounts without an invoice).
```
Due From — Assets
115100 Due From Shareholder — Gurpreet
115900 Due From Associated Corporations (intercompany loans only — NOT customer AR)
Due To — Liabilities
221100 Due To Shareholder — Gurpreet (short-term, <1 year)
221200 Shareholder Loan — Gurpreet (long-term, with commercial terms)
222900 Due To Associated Corporations (intercompany loans only — NOT vendor AP)
Equity
311100 Share Capital — Common Shares
311200 Share Capital — Preferred Shares (placeholder)
311300 Contributed Surplus
321100 Retained Earnings — Current Year
321200 Retained Earnings — Prior Years
321900 Dividends Declared (contra)
```
**Partner setup** (under Contacts, not GL accounts):
- `Westin Healthcare Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
- `Divine Mobility Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
- Nexa invoices Westin/Divine like any client → AR in 112xxx, revenue in 4xxxxx, HST 13% (Ontario)
- Westin/Divine bill Nexa → AP in 211xxx, expense in 6xxxxx / COGS in 5xxxxx
**Intercompany compliance flags (CRITICAL — drives major tax decisions)**:
1. **Small Business Deduction (SBD) sharing — ITA s.125(5.1)**: The $500k federal SBD limit is **shared across all associated corporations**. If Nexa, Westin, and Divine are each profitable, they collectively get **one** $500k pool, not three. The corps must file Schedule 23 (T2) allocating the limit. Strategy: allocate the limit to whichever corp has the highest taxable income each year.
2. **SR&ED expenditure limit shared — ITA s.127(10.2)**: The $3M expenditure limit for the 35% refundable ITC is also shared across the associated group. Same Schedule 23 mechanism. Nexa being the dev shop probably consumes most/all of it.
3. **Transfer pricing — ITA s.247**: Services between related corps must be priced at fair market value. Nexa invoicing Westin at $50/hr while billing arm's-length clients $150/hr will be scrutinized. Document the rate methodology. Penalty for non-compliance is 10% of the adjustment.
4. **Subsection 15(2) shareholder loans**: outstanding >1 year past FY end → taxable to Gurpreet personally.
5. **T2 Schedule 9** (Related and Associated Corporations) must be filed by Nexa listing Westin and Divine.
6. **GAAR risk**: aggressive intercompany pricing or loan arrangements designed primarily for tax benefit can be challenged under general anti-avoidance rules.
## 10. Analytic Plans
### 10.1 Project Plan
- One analytic account per customer engagement
- Naming: `PRJ-{YYYY}-{CUST}-{SHORTNAME}` (e.g., `PRJ-2026-WESTIN-ERP`)
- Required on revenue, COGS, project costs
- Linked to Odoo Project module for time tracking → automatic GL posting
### 10.2 Department Plan
- `DEPT-DEV` — Development
- `DEPT-SALES` — Sales & Marketing
- `DEPT-ADMIN` — Admin & Operations
- `DEPT-HOSTING` — Hosting Operations (optional future split)
- Required on payroll, OpEx
### 10.3 SR&ED Tag Plan
- `SRED-T4-DEV-SALARY` — T4 dev employees on R&D (full proxy 55%)
- `SRED-SPECIFIED-EMPLOYEE` — Gurpreet/officers (75% basic salary cap)
- `SRED-CONTRACTOR-CA-ARM-LENGTH` — Canadian arm's length (80% eligible)
- `SRED-CONTRACTOR-CA-NON-ARM-LENGTH` — affiliated CA contractors
- `SRED-MATERIALS-CONSUMED` — R&D materials
- `SRED-OVERHEAD-PROXY-BASIS` — direct labour basis
- `NOT-ELIGIBLE` — default
**T661 generation at year-end**: filter analytic report on SR&ED tag → eligible salaries + 55% proxy + 80% contractor + materials = total qualified expenditures × 35% refundable ITC.
## 11. Tax Setup & Fiscal Positions
**Consolidated active taxes** (~14, down from 49):
| Tax | Rate | Sale / Purchase | Applies |
|---|---|---|---|
| HST 13% Ontario | 13% | Both | ON |
| HST 15% Atlantic | 15% | Both | NB, NS, PE, NL |
| GST 5% | 5% | Both | AB, MB, SK, BC, YT, NT, NU |
| GST 5% + PST 7% BC | 12% group | Both | BC (goods, rare for services) |
| GST 5% + PST 7% MB | 12% group | Both | MB |
| GST 5% + PST 6% SK | 11% group | Both | SK |
| GST 5% + QST 9.975% QC | 14.975% group | Both | QC |
| Zero-rated Export | 0% | Sale | US, EU, ROW |
| Tax Exempt | 0% | Sale | Cert-holders |
**Fiscal Positions** (auto-applied based on customer billing address):
| Position | Customer Location | Auto-Substitute Default Tax |
|---|---|---|
| CA — Ontario (default) | ON | HST 13% |
| CA — Atlantic | NB/NS/PE/NL | HST 15% |
| CA — Quebec | QC | GST 5% + QST 9.975% |
| CA — BC | BC | GST 5% (PST per-product) |
| CA — Prairies / Territories | AB/MB/SK/YT/NT/NU | GST 5% |
| Export — US | United States | 0% Zero-rated |
| Export — International | Outside CA/US | 0% Zero-rated |
| Tax Exempt | Tagged customers | 0% |
**Invoice flow**: customer → fiscal position auto-applies → product picks default tax → fiscal position substitutes → no manual tax decisions.
**Export advantage**: zero-rated sales charge no HST but retain ITC claims on all related inputs. For a small shop with 30% US revenue, this is ~$515k/year in recovered HST.
## 12. Cleanup Plan
### Phase 1 — Archive (~370 accounts)
- Every l10n_ca account NOT in the keep-list (built from Sections 49).
- Constraint: Odoo blocks archiving accounts with postings. Archive zero-history only.
- Accounts with history we no longer want: stop posting; they go to $0 going forward.
### Phase 2 — Rename (~20 accounts)
| Old | New |
|---|---|
| 1400 Transferred to Gurpreet | 221100 Due To Shareholder — Gurpreet |
| 1505 Sent to India | 612200 Contract Labour — Foreign |
| 1580 Transferred to Westin | ARCHIVE — Westin is an associated corp, future transactions go through normal AR/AP via partner record `Westin Healthcare Inc` |
| 1590 Transferred to Divine | ARCHIVE — Divine is an associated corp, future transactions go through normal AR/AP via partner record `Divine Mobility Inc` |
| 1600 Transferred to Manpreet | ARCHIVE — Manpreet is an employee of another company, not a related party of Nexa; historical transactions to be re-classified by accountant during reconciliation |
| 1500 Food & Entertainment | 671200 Meals & Entertainment — 50% Deductible |
| 1501 Office Expenses | 621500 Office Supplies & Consumables |
| 411000 Inside Sales | ARCHIVE (replaced by 412xxx) |
| 412000 Harmonized Provinces Sales | ARCHIVE (jurisdiction = tax codes) |
| 413000 Non-Harmonized Provinces Sales | ARCHIVE |
| 414000 International Sales | ARCHIVE |
| 12000 Abdul & Future Mobility | ARCHIVE (use partner subledger) |
| 12001 MSI Account | ARCHIVE |
| 110010 Bank Fee | 691100 Bank Service Charges |
| 511100 Inside Purchases | ARCHIVE |
### Phase 3 — Add (~70 new accounts)
All per Sections 49.
### Phase 4 — Bank Consolidation
Current 8 bank journals (BMO, RBC, RBC VISA, Scotia ×3, Bank, Cash). Audit; archive inactive. Target: ≤5 active (primary operating, USD for future global, LOC, 12 credit cards).
### Phase 5 — Lock Prior Periods
Set `fiscalyear_lock_date = 2025-12-31`. Blocks postings to closed periods. Forces all 2026 work into new structure.
## 13. Automation Hooks
### Product Categories with Default Accounts
| Product Category | Default Income | Default COGS | Default Tax |
|---|---|---|---|
| Services / SaaS Subscription | 411100 | — | per fiscal position |
| Services / Hosting | 411200 | — | per fiscal position |
| Services / Support Contract | 411300 | — | per fiscal position |
| Services / Custom Software Dev | 412100 | — | per fiscal position |
| Services / Web App Dev | 412200 | — | per fiscal position |
| Services / Website Dev | 412300 | — | per fiscal position |
| Services / ERP Implementation | 412400 | — | per fiscal position |
| Services / Consulting | 413100 | — | per fiscal position |
| Services / Training | 413200 | — | per fiscal position |
| Services / Setup Fee | 411500 | — | per fiscal position |
| Resale / Software | 414100 | 513100 | per fiscal position |
| Resale / Hardware | 414200 | 513200 | per fiscal position |
### Bank Reconciliation Rules
| Pattern (description contains) | Auto-categorize To | Tax |
|---|---|---|
| `AMAZON WEB SERVICES`, `AWS` | 511100 Cloud Infrastructure | HST 13% ITC |
| `HETZNER`, `OVH`, `DIGITALOCEAN`, `LINODE` | 511100 | 0% foreign |
| `CLOUDFLARE`, `FASTLY` | 511110 CDN | mixed |
| `GITHUB`, `JETBRAINS`, `CURSOR` | 631200 Software — Dev Tools | HST 13% ITC |
| `MICROSOFT`, `SLACK`, `NOTION`, `LINEAR` | 631100 Software — Productivity | HST 13% ITC |
| `STRIPE PAYOUT` | AR receipts journal | — |
| `STRIPE FEE` | 691200 Merchant Processing | exempt |
| `GOOGLE ADS`, `LINKEDIN ADS` | 641100 Advertising | HST 13% ITC |
### Bank Feeds (Plaid via Odoo Enterprise)
Daily auto-import → bank reconciliation rules → ~70% of transactions auto-categorized.
### Subscription Module
Already installed. Use for SaaS/Hosting/Support contracts: recurring invoices, Stripe auto-charge, MRR/ARR/churn dashboards.
### Default Journals
- Customer Invoices → `INV`
- Vendor Bills → `BILL`
- Bank feeds → respective bank journals
- HR Expenses → `EXP` (add if missing)
- Misc → `MISC`
- Exchange Difference → `EXCH`
## 14. Out-of-Scope (Future Sub-Projects)
- **Historical reconciliation** — load accountant's Excel records into new structure (requires accountant docs).
- **Custom CCA module** — only if asset count grows; until then, accountant maintains CCA schedule separately.
- **Multi-currency setup** — add USD bank + currency-rate-live config when first US client signs.
- **Payroll system** — when first T4 employee is hired; integrate with Wagepoint/Payworks/ADP or Odoo Payroll.
- **Approval workflows** — purchase approval, expense approval limits.
- **Inventory** — N/A unless reselling hardware regularly.
## 15. Tax-Saving Opportunities Enabled
| Opportunity | Mechanism | Estimated Annual Value | Notes |
|---|---|---|---|
| SR&ED ITC | Analytic SR&ED tag + T661 filing | $30k$100k (refundable) | **$3M expenditure limit SHARED across Nexa/Westin/Divine — allocate to Nexa via S23** |
| Zero-rated exports | Fiscal position for US/international | $515k recovered HST on inputs | Per-company |
| Small Business Deduction (SBD) | Federal 9% on first $500k taxable income | ~$30k/yr if hitting threshold | **$500k limit SHARED across associated group — allocate to highest-income corp via S23** |
| CCA Class 50 + AccII | 82.5% Y1 deduction on computers/servers | Time-value, front-loads deductions | Per-company |
| Quick Method GST/HST | If <$400k sales, simpler method | $5002k/yr cash if eligible | **LIKELY UNAVAILABLE — Quick Method $400k threshold applies to associated-group totals; Nexa + Westin + Divine combined revenue probably exceeds limit. Re-verify with accountant.** |
| OIDMTC (Ontario Interactive Digital Media) | If building interactive media products | 3540% of eligible labour | Strict eligibility test; need to verify product fits |
| Apprenticeship Job Creation TC | 10% of eligible apprentice wages, max $2k/yr per apprentice | Per apprentice hired | Activates when first apprentice T4 employee hired |
| Intercompany cost recovery | Bill associated corps for shared services (back-office, hosting, IT) | Allocates expenses to highest-tax-rate corp | Requires arm's-length pricing documentation |
## 16. Risks & Open Questions
1. **Associated corporation tax planning** — Westin Healthcare Inc, Divine Mobility Inc, and Nexa Systems Inc share the $500k SBD limit and the $3M SR&ED expenditure limit. Yearly Schedule 23 allocation decision needs accountant input. Recommendation: allocate SR&ED limit primarily to Nexa (dev shop); allocate SBD to whichever corp has highest taxable income each year.
2. **Transfer pricing on intercompany services** — Nexa billing Westin/Divine must be at fair market value. Document hourly rate methodology and apply consistently across all clients. Penalty: 10% of any adjustment.
3. **Past data backposting** — once accountant records arrive, mapping old transactions into new structure requires care to avoid breaking the post-2025-12-31 lock.
4. **BC PST on software services** — BC PST exempts custom software developed for a specific customer; off-the-shelf software and certain SaaS subscriptions ARE taxable. For Nexa's mix (most work is custom dev = exempt; SaaS sold off-the-shelf to BC customers = taxable at 7%), each BC customer/product combo needs review. Default to "GST only" for custom dev; flag SaaS-to-BC for review at first sale.
5. **Quebec QST registration** — required if Nexa has QC customers and revenue >$30k. Confirm registration status. If not yet registered and you start taking QC clients, registration with Revenu Québec is separate from CRA.
8. **HST filing cadence review** — currently annual. Once revenue clears $1.5M (combined Nexa-only, not associated group), CRA may auto-move you to **quarterly** filing. Monitor and update filing cadence in tax report config when it happens.
6. **Specified employee SR&ED math** — Gurpreet's salary cap is 75%, no bonus inclusion. Accountant must apply at T661 time.
7. **Multi-company Odoo (future sub-project)** — Westin and Divine currently run on separate Odoo databases (odoo-westin, odoo-mobility). Future option: migrate all three into one multi-company nexamain database to enable auto-mirrored intercompany invoices (Nexa invoices Westin → auto-creates Bill in Westin's books). Major data-migration effort; only worth it once intercompany volume justifies the effort.
## 17. Acceptance Criteria
- [ ] All 11 sections of CoA approved and present in odoo-nexa nexamain DB
- [ ] ≥370 unused accounts archived
- [ ] 14 active taxes (down from 49)
- [ ] 8 fiscal positions configured with auto-detection
- [ ] 3 analytic plans created (Project, Department, SR&ED Tag) with seed analytic accounts
- [ ] Product categories created with default accounts
- [ ] Bank reconciliation rules created
- [ ] Fiscal year locked at 2025-12-31
- [ ] Company HST/BN number stored in full 15-char form (`741224877 RT0001`)
- [ ] HST report config set to **annual filer**, fiscal-year-end Dec 31, deadline March 31
- [ ] Westin Healthcare Inc and Divine Mobility Inc partner records created with Customer + Vendor flags, tagged `RP-Associated`
- [ ] Test invoice flows through correctly for: ON customer (HST 13%), US customer (Zero-rated), QC customer (GST+QST)
- [ ] Test vendor bill creates correct ITC for: Canadian vendor (HST ITC), foreign vendor (no ITC)
- [ ] Test intercompany invoice: Nexa → Westin generates proper AR + 13% HST collected (Westin is Ontario-based)
- [ ] Bank consolidation complete; ≤5 active bank journals

View File

@@ -0,0 +1,300 @@
# NFC Clock Kiosk — Design
**Date:** 2026-05-13
**Module:** `fusion_clock`
**Status:** Approved design — pending implementation plan
**Pilot scope:** 1 station per company
## Problem
`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that:
- Takes ~2 seconds (vs ~10 seconds for name search + PIN)
- Reuses the same physical Ubiquiti-issued card the employee uses for doors
- Works with gloves, dirty hands, or wet hands (touchscreens fail here)
- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts
## Goals
1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap
2. **Single-credential**: same card the employee uses for door access also clocks them in
3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check
4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible
5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged
6. **One-time setup**: enroll once, then employees never touch a setup flow again
## Non-goals
- Multi-station / multi-zone clocking (future — pilot is 1 station per company)
- Per-station geolocation (one location per company; tablet is implicitly at the company location)
- Offline mode (v1 fails loudly on network loss; offline replay is future work)
- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed)
- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back)
- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient)
## Architecture decision
**Option B: Separate kiosk page, shared backend.**
A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched.
**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes.
**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in).
## Hardware decision
**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning:
- Built-in NFC antenna on the back, dead-center
- IP68, MIL-STD-810H, drop-resistant (shop-floor durable)
- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices)
- Knox enables true kiosk lockdown
- Pogo dock = magnetic constant power, no cable to yank
- 10.1" screen visible from a few feet away (vs 8" on regular Active 5)
Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data.
## Data model
### `hr.employee` — new field
- `x_fclk_nfc_card_uid``Char`, indexed, unique constraint when not null
- Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80`
- Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field
### `res.company` — new field
- `x_fclk_nfc_kiosk_location_id``Many2one` to `fusion.clock.location`
- Designates which fusion.clock.location is bound to the NFC kiosk for this company
- Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty
- Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware)
### `hr.attendance` — new fields
- `x_fclk_check_in_photo``Binary`, `attachment=True`. Frame captured at clock-in.
- `x_fclk_check_out_photo``Binary`, `attachment=True`. Frame captured at clock-out.
- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`.
### `ir.config_parameter` — new entries
- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch.
- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one.
- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user.
- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development.
### `res.config.settings` — new view section
"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above.
**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`.
## Backend — controller and endpoints
**New file:** `controllers/clock_nfc_kiosk.py`
All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`.
**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings).
### `GET /fusion_clock/kiosk/nfc` — page render
- Renders the NFC kiosk QWeb template
- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1")
- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group
### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle
- `type='jsonrpc'`, `auth='user'`
- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }`
- Logic:
1. Normalize UID (uppercase, colon-separated, reject malformed input)
2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID.
3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}`
4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}`
5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently
6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: <remote_addr>, latitude: 0, longitude: 0 }` to toggle attendance state
7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = <resolved>`, distance fields = 0
8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out)
9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}`
10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk)
11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }`
### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment
- `type='jsonrpc'`, `auth='user'`
- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }`
- Logic:
1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group)
2. Normalize UID
3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: "<name>"}`
4. Write `x_fclk_nfc_card_uid` on the target employee
5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z")
6. Return `{ success: true, employee_name, card_uid }`
### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll
- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic.
## Frontend — kiosk page UX
**Files:**
- `views/kiosk_nfc_templates.xml` — QWeb template for the page
- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine
- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark)
**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds.
### State machine
```
┌─── (3s timeout) ─────────────────────────┐
▼ │
┌─────────────────────────┐ tap detected ┌────────────────────┐
│ IDLE │ ────────────────► │ PROCESSING │
│ "Tap card to clock │ │ spinner, "Reading"│
│ in or out" │ └────────────────────┘
│ big clock, date, │ │
│ company name │ success / error
└─────────────────────────┘ ▼
▲ ┌─────────────────────────┐
│ │ RESULT │
│ │ green: "Welcome John, │
└─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │
│ red: "Card not │
│ enrolled" │
└─────────────────────────┘
```
### IDLE state
- Top: company name + current time (HH:MM, updates every second) + date
- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation
- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode)
### PROCESSING state
- Brief spinner + "Reading card…"
- Mostly imperceptible at typical network latency
### RESULT state — success
- Green panel
- Large employee avatar on the left
- "John Smith" — name in big text
- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today"
- Auto-return to IDLE after 3s
### RESULT state — error
- Red panel
- `card_unknown` → "Card not recognized. See your manager."
- `network_error` → "No connection. Please try again."
- `debounce` → silent (no UI change to avoid double-tap confusion)
- `photo_required` → "Camera unavailable. Ask IT to check the kiosk."
- Auto-return to IDLE after 4s
### Web NFC implementation
- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted)
- After activation, `NDEFReader.scan()` runs continuously
- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data)
- UID format: hex bytes joined by colons, uppercased
- If `scan()` throws, restart with a 1-second backoff
### Camera implementation
- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC
- Hidden `<video>` element streams continuously
- On tap, grab one frame to a `<canvas>`, encode as JPEG quality 0.7 (~3060 KB), POST as base64 in the same JSON payload as the UID
- If `nfc_photo_required = True` and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading
### Enroll Mode
- Tap the bottom-right "⚙" → on-screen numpad password entry → match against `fusion_clock.nfc_enroll_password` → enter Enroll Mode
- Enroll Mode UI:
1. Search input → employee list (uses `/fusion_clock/kiosk/nfc/employee_search`)
2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
3. Tap detected → POST to `/enroll` → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
4. "Done" button → exit Enroll Mode → back to IDLE
- 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)
### One-time setup flow (first load on a new tablet)
1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
4. "Setup complete." → enters IDLE
- After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)
### Mock-tap debug mode
- Gated by `fusion_clock.nfc_kiosk_debug = True`
- When enabled, hidden keyboard shortcut `Ctrl+Shift+T` fires a mock tap with a configurable UID stored in localStorage
- Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting
## Edge cases & failure modes
| Scenario | Behavior |
|---|---|
| Card not enrolled | Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change. |
| Employee disabled (`x_fclk_enable_clock=False`) | "Clock disabled for this account." Activity logged. |
| Card lost/damaged | Manager opens employee form, clears `x_fclk_nfc_card_uid`, issues new card, re-enrolls via kiosk Enroll Mode. |
| Card already assigned during enroll | "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite. |
| Tablet offline / WiFi drops | Fail loudly: "No connection. Use the portal on your phone." No local cache in v1. |
| Same card tapped twice within 5s | Server-side debounce. Second tap silently ignored. |
| MIFARE clone attack | UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure. |
| Tablet stolen | Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session). |
| Power outage | Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE. |
| Tablet clock drift | Irrelevant. All timestamps come from `fields.Datetime.now()` server-side. Tablet clock is for display only. |
| UID format mismatch (Ubiquiti vs Web NFC byte order) | Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint. |
| Camera unavailable while `nfc_photo_required=True` | Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation. |
## Hardware checklist (per company)
- Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
- Samsung official Pogo charging dock — ~$100
- Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
- USB-C 30W PSU + cable — ~$25
- Fully Kiosk Browser commercial license (~€10 one-time) OR Samsung Knox Configure (~$30/year/device)
- "TAP HERE" decal for the back of the tablet — DIY/printed sticker
**Total**: ~$915 per company, one-time.
## Provisioning script (one-time per tablet)
**Prerequisite — Odoo side (one-time per company):**
- Create a `res.users` named e.g. `kiosk-westin@<domain>`, member of `fusion_clock.group_fusion_clock_manager`
- Generate a long random password; store it in a password manager
- Set `res.company.x_fclk_nfc_kiosk_location_id` for that company to the desired `fusion.clock.location`
- Toggle `fusion_clock.enable_nfc_kiosk = True` and `fusion_clock.nfc_photo_required` per policy
- Set `fusion_clock.nfc_enroll_password` to a 4-digit Enroll Mode password
**Tablet side:**
1. Factory reset
2. Sign in with company Google account
3. Install Fully Kiosk Browser from Play Store
4. In Fully Kiosk: set kiosk URL → `https://<odoo-domain>/fusion_clock/kiosk/nfc`, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
7. Mount on dock
## Testing plan
### Python unit tests (`tests/test_clock_nfc_kiosk.py`)
- Tap with valid UID → attendance toggled, photo saved, activity logged
- Tap with unknown UID → `card_unknown` error, no attendance row
- Tap when `x_fclk_enable_clock=False``clock_disabled` error
- Double-tap same UID within 5s → second is debounced
- Enroll with conflicting UID → `card_already_assigned`, no overwrite
- Enroll with wrong password → 403
- Tap with no `fusion.clock.location` configured for company → `no_location_configured`
- UID normalization: lowercase input → stored uppercase
### Manual smoke tests (real tablet or Android phone for dev)
- Cold boot → IDLE within 5s
- Tap → RESULT within 1s
- Photo attached to attendance record (verify in backend)
- Enroll Mode password gate works; 60s timeout exits cleanly
- WiFi disconnect → tap shows "No connection"; reconnect → tap works again
- Tap own card 5x in fast succession → only one state change (debounce holds)
### Dev shortcut
- Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
- For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
- Mock-tap debug mode (`Ctrl+Shift+T`) lets the UI state machine be tested without any hardware
### Soak test (before declaring pilot ready)
- 24h continuous on the dock
- Periodic taps every few hours
- Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts
## Future considerations
- **Offline mode** — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
- **Multi-station** — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
- **QR-code-on-portal alternate credential** — for iPhone-only employees who don't want to carry a card. Adds `BarcodeDetector` to the kiosk page alongside `NDEFReader`, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
- **Ubiquiti webhook integration** — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
- **Native Android kiosk app** — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.2.0.0',
'version': '19.0.3.0.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -76,12 +76,15 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/portal_timesheet_templates.xml',
'views/portal_report_templates.xml',
'views/kiosk_templates.xml',
'views/kiosk_nfc_templates.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_clock/static/src/css/portal_clock.css',
'fusion_clock/static/src/scss/nfc_kiosk.scss',
'fusion_clock/static/src/js/fusion_clock_portal.js',
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
],
'web.assets_backend': [
'fusion_clock/static/src/scss/fusion_clock.scss',

View File

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

View File

@@ -0,0 +1,249 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import re
import time
import threading
from odoo import fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
_DEBOUNCE_WINDOW_SECONDS = 5.0
_recent_taps = {} # {card_uid: monotonic_ts}
_recent_taps_lock = threading.Lock()
def _is_debounced(uid):
"""Return True if this UID was tapped within the debounce window."""
now = time.monotonic()
with _recent_taps_lock:
last = _recent_taps.get(uid, 0)
if now - last < _DEBOUNCE_WINDOW_SECONDS:
return True
_recent_taps[uid] = now
# Opportunistic GC: drop entries older than 60s
stale_keys = [k for k, t in _recent_taps.items() if now - t > 60]
for k in stale_keys:
_recent_taps.pop(k, None)
return False
def _strip_data_url_prefix(b64):
"""Strip 'data:image/...;base64,' prefix from a data URL, returning raw base64."""
if not b64:
return b''
if isinstance(b64, str) and b64.startswith('data:'):
comma = b64.find(',')
if comma >= 0:
return b64[comma + 1:].encode('ascii', errors='ignore')
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
class FusionClockNfcKiosk(http.Controller):
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
@staticmethod
def _normalize_uid(uid):
"""Normalize an NFC card UID to canonical hex (uppercase, colon-separated).
Returns None if the input is empty or not valid hex.
"""
if not uid:
return None
cleaned = uid.strip().upper().replace('-', '').replace(':', '').replace(' ', '')
if not cleaned or not _UID_HEX_PATTERN.match(cleaned):
return None
if len(cleaned) % 2 != 0:
return None
return ':'.join(cleaned[i:i+2] for i in range(0, len(cleaned), 2))
@http.route('/fusion_clock/kiosk/nfc', type='http', auth='user', website=True)
def nfc_kiosk_page(self, **kw):
"""Render the NFC kiosk page for a wall-mounted tablet."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return request.redirect('/my')
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
return request.redirect('/my')
company = request.env.company
location = company.x_fclk_nfc_kiosk_location_id
company_logo_url = (
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
)
values = {
'page_name': 'nfc_kiosk',
'company_name': company.name,
'company_logo_url': company_logo_url,
'location_name': location.name if location else 'No location configured',
'location_configured': bool(location),
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
}
return request.render('fusion_clock.nfc_kiosk_page', values)
@staticmethod
def _check_enroll_password(env, supplied):
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
configured = env['ir.config_parameter'].sudo().get_param('fusion_clock.nfc_enroll_password', '')
if not configured:
return True
return (supplied or '') == configured
@http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'access_denied'}
if not self._check_enroll_password(request.env, enroll_password):
return {'error': 'invalid_password'}
normalized = self._normalize_uid(card_uid)
if not normalized:
return {'error': 'invalid_uid'}
Employee = request.env['hr.employee'].sudo()
target = Employee.browse(int(employee_id or 0))
if not target.exists():
return {'error': 'employee_not_found'}
existing = Employee.search([
('x_fclk_nfc_card_uid', '=', normalized),
('id', '!=', target.id),
], limit=1)
if existing:
return {
'error': 'card_already_assigned',
'existing_employee': existing.name,
}
target.x_fclk_nfc_card_uid = normalized
# Activity log (uses 'card_enrollment' + 'nfc_kiosk' selections added in Task 2)
request.env['fusion.clock.activity.log'].sudo().create({
'employee_id': target.id,
'log_type': 'card_enrollment',
'description': f"NFC card {normalized} enrolled by {user.name}",
'source': 'nfc_kiosk',
})
return {
'success': True,
'employee_name': target.name,
'card_uid': normalized,
}
@http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
def nfc_tap(self, card_uid='', photo_b64='', **kw):
"""Toggle attendance state for the employee owning this card UID."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'access_denied'}
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
return {'error': 'kiosk_disabled'}
normalized = self._normalize_uid(card_uid)
if not normalized:
return {'error': 'invalid_uid'}
if _is_debounced(normalized):
return {'error': 'debounce'}
photo_required = ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True'
if photo_required and not photo_b64:
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
company = request.env.company
location = company.x_fclk_nfc_kiosk_location_id
if not location:
return {'error': 'no_location_configured'}
Employee = request.env['hr.employee'].sudo()
employee = Employee.search([('x_fclk_nfc_card_uid', '=', normalized)], limit=1)
if not employee:
_logger.warning("[nfc-kiosk] Unknown NFC card tapped: %s", normalized)
return {'error': 'card_unknown', 'message': 'Card not enrolled. See your manager.'}
if not employee.x_fclk_enable_clock:
return {'error': 'clock_disabled', 'message': 'Clock disabled for this account.'}
from .clock_api import FusionClockAPI
api = FusionClockAPI()
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = now.date()
geo_info = {
'latitude': 0,
'longitude': 0,
'browser': 'nfc_kiosk',
'ip_address': request.httprequest.remote_addr or '',
}
attendance = employee.sudo()._attendance_action_change(geo_info)
if not is_checked_in:
attendance.sudo().write({
'x_fclk_location_id': location.id,
'x_fclk_in_distance': 0.0,
'x_fclk_clock_source': 'nfc_kiosk',
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
})
api._log_activity(
employee, 'clock_in',
f"NFC kiosk clock-in at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
'action': 'clock_in',
'employee_name': employee.name,
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
'message': f'{employee.name} clocked in at {location.name}',
'net_hours_today': 0.0,
}
else:
attendance.sudo().write({
'x_fclk_out_distance': 0.0,
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
return {
'success': True,
'action': 'clock_out',
'employee_name': employee.name,
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
'message': f'{employee.name} clocked out',
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
}
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
def nfc_employee_search(self, query='', **kw):
"""Delegate to the existing kiosk search to avoid duplication."""
from .clock_kiosk import FusionClockKiosk
return FusionClockKiosk().kiosk_search(query=query)

View File

@@ -145,4 +145,22 @@
<field name="value">True</field>
</record>
<!-- NFC Clock Kiosk -->
<record id="config_enable_nfc_kiosk" model="ir.config_parameter">
<field name="key">fusion_clock.enable_nfc_kiosk</field>
<field name="value">False</field>
</record>
<record id="config_nfc_photo_required" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_photo_required</field>
<field name="value">True</field>
</record>
<record id="config_nfc_enroll_password" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_enroll_password</field>
<field name="value"></field>
</record>
<record id="config_nfc_kiosk_debug" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_kiosk_debug</field>
<field name="value">False</field>
</record>
</odoo>

View File

@@ -10,3 +10,4 @@ from . import clock_activity_log
from . import clock_leave_request
from . import clock_shift
from . import clock_correction
from . import res_company

View File

@@ -34,6 +34,8 @@ class FusionClockActivityLog(models.Model):
('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'),
('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'),
],
string='Log Type',
required=True,
@@ -71,6 +73,7 @@ class FusionClockActivityLog(models.Model):
('systray', 'Systray'),
('backend_fab', 'Backend FAB'),
('kiosk', 'Kiosk'),
('nfc_kiosk', 'NFC Kiosk'),
('system', 'System (Cron)'),
],
string='Source',

View File

@@ -130,6 +130,7 @@ class HrAttendance(models.Model):
('systray', 'Systray'),
('backend_fab', 'Backend FAB'),
('kiosk', 'Kiosk'),
('nfc_kiosk', 'NFC Kiosk'),
('manual', 'Manual'),
('auto', 'Auto Clock-Out'),
],
@@ -147,6 +148,16 @@ class HrAttendance(models.Model):
digits=(10, 2),
help="Distance from location center at clock-out, in meters.",
)
x_fclk_check_in_photo = fields.Binary(
string='Check-In Photo',
attachment=True,
help="Front-camera photo captured at NFC kiosk clock-in.",
)
x_fclk_check_out_photo = fields.Binary(
string='Check-Out Photo',
attachment=True,
help="Front-camera photo captured at NFC kiosk clock-out.",
)
x_fclk_break_minutes = fields.Float(
string='Break (min)',
default=0.0,

View File

@@ -47,6 +47,25 @@ class HrEmployee(models.Model):
groups="fusion_clock.group_fusion_clock_manager",
)
# NFC card (kiosk identification)
x_fclk_nfc_card_uid = fields.Char(
string='NFC Card UID',
index=True,
copy=False,
groups="fusion_clock.group_fusion_clock_manager",
help="Hex UID of the NFC card assigned to this employee. "
"Format: uppercase, colon-separated, e.g. 04:A2:B5:62:C1:80. "
"Same card the employee uses for door access.",
)
_sql_constraints = [
(
'fclk_nfc_card_uid_unique',
'UNIQUE(x_fclk_nfc_card_uid)',
'This NFC card is already assigned to another employee.',
),
]
# On-time streak
x_fclk_ontime_streak = fields.Integer(
string='On-Time Streak',

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResCompany(models.Model):
_inherit = 'res.company'
x_fclk_nfc_kiosk_location_id = fields.Many2one(
'fusion.clock.location',
string='NFC Kiosk Location',
domain="[('company_id', '=', id)]",
help="Designates which fusion.clock.location is bound to the NFC kiosk "
"for this company. Required when NFC kiosk is enabled.",
)

View File

@@ -232,6 +232,43 @@ class ResConfigSettings(models.TransientModel):
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
)
# ── NFC Clock Kiosk ────────────────────────────────────────────────
fclk_enable_nfc_kiosk = fields.Boolean(
string='Enable NFC Clock Kiosk',
config_parameter='fusion_clock.enable_nfc_kiosk',
default=False,
help="Enable the tap-to-clock NFC kiosk page at /fusion_clock/kiosk/nfc.",
)
fclk_nfc_photo_required = fields.Boolean(
string='Require Photo on Tap',
config_parameter='fusion_clock.nfc_photo_required',
default=True,
help="If enabled, the kiosk rejects taps when the front camera is unavailable. "
"Recommended for buddy-punch deterrence.",
)
fclk_nfc_enroll_password = fields.Char(
string='Enroll Mode Password',
config_parameter='fusion_clock.nfc_enroll_password',
help="Short password the manager types on the kiosk to enter Enroll Mode. "
"Leave empty to fall back to manager-group membership only.",
)
fclk_nfc_kiosk_debug = fields.Boolean(
string='Debug Mode (overlay + mock-tap)',
config_parameter='fusion_clock.nfc_kiosk_debug',
default=False,
help="Enables two dev/troubleshooting features on the NFC kiosk page: "
"(1) a green-text debug overlay at the top of the screen logging every NFC and tap event in real time, "
"and (2) a Ctrl+Shift+T keyboard shortcut that simulates a tap with a configurable UID. "
"Turn OFF in production — the overlay is intrusive for end users.",
)
fclk_nfc_kiosk_location_id = fields.Many2one(
related='company_id.x_fclk_nfc_kiosk_location_id',
readonly=False,
string='NFC Kiosk Location',
help="Which clock location is bound to the NFC kiosk for this company. "
"Required when the kiosk is enabled.",
)
def set_values(self):
super().set_values()
ICP = self.env['ir.config_parameter'].sudo()

View File

@@ -0,0 +1,599 @@
/* @odoo-module */
// NFC Clock Kiosk — Web NFC + camera + state machine.
// Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the
// element #nfc_kiosk_root only exists on that page, so the module is
// inert elsewhere).
(function() {
"use strict";
const root = document.getElementById("nfc_kiosk_root");
if (!root) return; // not on the kiosk page
const stateContainer = document.getElementById("nfc_state_container");
const photoRequired = root.dataset.photoRequired === "1";
const debugEnabled = root.dataset.debugEnabled === "1";
const locationConfigured = root.dataset.locationConfigured === "1";
// ──────────────────────────────────────────────────────────────
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
// ──────────────────────────────────────────────────────────────
let _debugOverlayEl = null;
function debugLog(msg) {
try { console.log("[nfc-kiosk-debug]", msg); } catch (e) {}
if (!debugEnabled) return;
if (!_debugOverlayEl) {
_debugOverlayEl = document.createElement("div");
_debugOverlayEl.style.cssText = "position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:11px;padding:0.5rem;max-height:35vh;overflow-y:auto;z-index:9999;line-height:1.3;border-bottom:1px solid #0f0;";
document.body.appendChild(_debugOverlayEl);
}
const line = document.createElement("div");
const ts = new Date().toLocaleTimeString();
line.textContent = "[" + ts + "] " + msg;
_debugOverlayEl.appendChild(line);
while (_debugOverlayEl.childNodes.length > 40) {
_debugOverlayEl.removeChild(_debugOverlayEl.firstChild);
}
_debugOverlayEl.scrollTop = _debugOverlayEl.scrollHeight;
}
debugLog("page loaded; debugEnabled=" + debugEnabled + " photoRequired=" + photoRequired + " NDEFReader=" + ("NDEFReader" in window));
// ──────────────────────────────────────────────────────────────
// Dominant-hue extraction from company logo
// Sets the CSS variable --nfc-h on <html> so SCSS can interpolate
// the entire palette from the brand color. Falls back to default
// (220 = aurora-blue) if no logo or extraction fails.
// ──────────────────────────────────────────────────────────────
function rgbToHue(r, g, b) {
const rN = r / 255, gN = g / 255, bN = b / 255;
const max = Math.max(rN, gN, bN), min = Math.min(rN, gN, bN);
const d = max - min;
if (d === 0) return null; // grayscale, no hue info
let h;
if (max === rN) h = ((gN - bN) / d) % 6;
else if (max === gN) h = (bN - rN) / d + 2;
else h = (rN - gN) / d + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
return h;
}
function extractDominantHue(img) {
try {
const c = document.createElement("canvas");
const w = c.width = Math.min(img.naturalWidth, 200);
const h = c.height = Math.min(img.naturalHeight, 200);
const ctx = c.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, w, h);
const data = ctx.getImageData(0, 0, w, h).data;
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 128) continue; // skip transparent
const red = data[i], green = data[i + 1], blue = data[i + 2];
const lum = (red + green + blue) / 3;
if (lum > 235 || lum < 25) continue; // skip near-white/near-black
const range = Math.max(red, green, blue) - Math.min(red, green, blue);
if (range < 25) continue; // skip near-grays
r += red; g += green; b += blue; count++;
}
if (count < 50) {
debugLog("hue extraction: too few colored pixels (" + count + "), using default");
return null;
}
const avgR = Math.round(r / count), avgG = Math.round(g / count), avgB = Math.round(b / count);
const hue = rgbToHue(avgR, avgG, avgB);
debugLog("hue extracted: rgb(" + avgR + "," + avgG + "," + avgB + ") → h=" + hue);
return hue;
} catch (e) {
debugLog("hue extraction failed: " + e.message);
return null;
}
}
function applyBrandHue(hue) {
if (hue == null) return;
document.documentElement.style.setProperty("--nfc-h", String(hue));
}
const logoImg = document.getElementById("nfc_company_logo");
if (logoImg) {
const tryExtract = () => {
const hue = extractDominantHue(logoImg);
applyBrandHue(hue);
};
if (logoImg.complete && logoImg.naturalWidth) {
tryExtract();
} else {
logoImg.addEventListener("load", tryExtract);
logoImg.addEventListener("error", () => debugLog("logo failed to load"));
}
} else {
debugLog("no company logo on page; using default hue");
}
// ──────────────────────────────────────────────────────────────
// State machine
// ──────────────────────────────────────────────────────────────
const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" };
let currentState = STATE.SETUP;
function setState(next, payload) {
currentState = next;
if (next === STATE.IDLE) renderIdle();
else if (next === STATE.PROCESSING) renderProcessing();
else if (next === STATE.RESULT) renderResult(payload);
else if (next === STATE.ENROLL) renderEnroll(payload);
}
// ──────────────────────────────────────────────────────────────
// Rendering helpers
// ──────────────────────────────────────────────────────────────
function renderIdle() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__idle">
<svg class="nfc-kiosk__icon-svg" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<circle class="nfc-wave nfc-wave-3" cx="100" cy="100" r="98"
stroke="currentColor" stroke-width="4" fill="none"/>
<circle class="nfc-wave nfc-wave-2" cx="100" cy="100" r="78"
stroke="currentColor" stroke-width="4" fill="none"/>
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
stroke="currentColor" stroke-width="4" fill="none"/>
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
rx="11" fill="currentColor"/>
</svg>
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
</div>
`;
}
function renderProcessing() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__processing">
<span>Reading card</span>
<span class="dots"><span></span><span></span><span></span></span>
</div>
`;
}
function renderResult(payload) {
const isError = payload && payload.error;
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
if (isError) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.message || "Error")}</div>
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 4000);
} else {
const avatar = payload.employee_avatar_url || "";
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
const hours = payload.action === "clock_out" && payload.net_hours_today
? `${payload.net_hours_today.toFixed(1)}h today`
: "";
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.employee_name)}</div>
<div class="action">${action} at ${time}</div>
${hours ? `<div class="hours">${hours}</div>` : ""}
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 3000);
}
}
// ──────────────────────────────────────────────────────────────
// Enroll Mode
// ──────────────────────────────────────────────────────────────
let enrollPassword = "";
let enrollSelectedEmployee = null;
let enrollIdleTimer = null;
function resetEnrollIdleTimer() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = setTimeout(() => {
// 60s of inactivity in Enroll Mode → exit
exitEnrollMode();
}, 60000);
}
function exitEnrollMode() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = null;
enrollPassword = "";
enrollSelectedEmployee = null;
setState(STATE.IDLE);
}
function renderEnroll(payload) {
const phase = (payload && payload.phase) || "password";
resetEnrollIdleTimer();
if (phase === "password") {
const masked = "•".repeat(enrollPassword.length);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Enter Enroll Mode Password</h2>
<div class="pin-display">${masked}</div>
<div class="numpad">
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
<button data-n="back">⌫</button>
<button data-n="0">0</button>
<button data-n="ok">OK</button>
</div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
btn.addEventListener("click", async () => {
resetEnrollIdleTimer();
const n = btn.dataset.n;
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
else if (n === "ok") {
if (enrollPassword.length === 0) return;
renderEnroll({ phase: "search" });
return;
}
else enrollPassword += n;
renderEnroll({ phase: "password" });
});
});
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "search") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Pick the employee to enroll</h2>
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="enroll_list"></div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
const searchEl = document.getElementById("enroll_search");
const listEl = document.getElementById("enroll_list");
let debounceTimer = null;
searchEl.addEventListener("input", () => {
resetEnrollIdleTimer();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value });
listEl.innerHTML = (result.employees || []).map(e =>
`<div class="employee-row" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${escapeHtml(e.name)}<small style="opacity:.6"> · ${escapeHtml(e.department || "")}</small></div>`
).join("");
listEl.querySelectorAll(".employee-row").forEach(row => {
row.addEventListener("click", () => {
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
renderEnroll({ phase: "tap" });
});
});
}, 200);
});
searchEl.focus();
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "tap") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2>Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card</h2>
<div class="nfc-kiosk__icon" style="font-size:5rem">⌐■</div>
<p style="color:#9ba3ad">Hold the card to the back of the tablet</p>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "result") {
const ok = !payload.error;
const msg = ok
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
: (payload.error === "invalid_password"
? "Wrong password. Try again."
: payload.error === "card_already_assigned"
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
: `Enroll failed: ${escapeHtml(payload.error)}`);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
<div class="actions" style="justify-content:center">
<button class="confirm" id="enroll_another">Enroll another</button>
<button class="cancel" id="enroll_done">Done</button>
</div>
</div>
</div>
`;
document.getElementById("enroll_another").addEventListener("click", () => {
enrollSelectedEmployee = null;
renderEnroll({ phase: ok ? "search" : "password" });
});
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
}
}
async function _onEnrollTap(uid) {
if (!enrollSelectedEmployee) return;
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
employee_id: enrollSelectedEmployee.id,
card_uid: uid,
enroll_password: enrollPassword,
});
renderEnroll({ phase: "result", ...result });
}
// ⚙ button → enter Enroll Mode
const settingsBtn = document.getElementById("nfc_settings_btn");
if (settingsBtn) {
settingsBtn.addEventListener("click", () => {
if (currentState !== STATE.IDLE) return;
enrollPassword = "";
enrollSelectedEmployee = null;
setState(STATE.ENROLL, { phase: "password" });
});
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[c]));
}
// ──────────────────────────────────────────────────────────────
// Clock display (centered top: time with AM/PM + date)
// ──────────────────────────────────────────────────────────────
function updateClock() {
const now = new Date();
let hours = now.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12;
if (hours === 0) hours = 12; // 0 → 12 in 12-hour clock
const hh = String(hours).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
const timeEl = document.getElementById("nfc_clock_time");
const dateEl = document.getElementById("nfc_clock_date");
if (timeEl) {
// Render hh:mm + AM/PM as separate spans so SCSS can style them differently
timeEl.innerHTML = `${hh}:${mm}<span class="ampm">${ampm}</span>`;
}
if (dateEl) dateEl.textContent = dateStr;
}
updateClock();
setInterval(updateClock, 1000);
// ──────────────────────────────────────────────────────────────
// Setup wizard
// ──────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────
// Web NFC reader
// ──────────────────────────────────────────────────────────────
let ndefReader = null;
let nfcReady = false;
async function startNfcReader() {
debugLog("startNfcReader: NDEFReader in window = " + ("NDEFReader" in window));
if (!("NDEFReader" in window)) {
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
}
ndefReader = new NDEFReader();
debugLog("startNfcReader: ndefReader created, calling scan()...");
await ndefReader.scan();
debugLog("startNfcReader: scan() resolved ✓");
ndefReader.addEventListener("reading", onNfcReading);
ndefReader.addEventListener("readingerror", (ev) => {
debugLog("readingerror event fired");
console.warn("[nfc-kiosk] reading error; reader still active");
});
nfcReady = true;
debugLog("startNfcReader: listeners attached, nfcReady=true");
}
function onNfcReading(event) {
// event.serialNumber is the card UID — works for raw MIFARE access cards
const rawSerial = event.serialNumber || "";
const uid = rawSerial.toUpperCase();
const recCount = (event.message && event.message.records) ? event.message.records.length : 0;
debugLog("reading event: serialNumber=" + JSON.stringify(rawSerial) + " (len=" + rawSerial.length + ") records=" + recCount + " state=" + currentState);
if (!uid) {
debugLog(" → IGNORED: empty serialNumber");
return;
}
if (currentState === STATE.ENROLL) {
debugLog(" → routing to _onEnrollTap");
window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
return;
}
if (currentState !== STATE.IDLE) {
debugLog(" → IGNORED: not in IDLE (state=" + currentState + ")");
return;
}
debugLog(" → calling handleTap(" + uid + ")");
handleTap(uid);
}
async function handleTap(uid) {
debugLog("handleTap: uid=" + uid);
setState(STATE.PROCESSING);
let photoB64 = "";
try {
photoB64 = await capturePhoto();
debugLog("handleTap: photo captured, size=" + photoB64.length);
} catch (e) {
debugLog("handleTap: photo capture failed: " + e.message);
console.warn("[nfc-kiosk] camera capture failed", e);
}
try {
debugLog("handleTap: POST /fusion_clock/kiosk/nfc/tap...");
const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
debugLog("handleTap: response = " + JSON.stringify(result).slice(0, 200));
if (result.error === "debounce") {
setState(STATE.IDLE);
return;
}
setState(STATE.RESULT, result);
} catch (e) {
debugLog("handleTap: POST failed: " + e.message);
setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." });
}
}
async function postJson(url, params) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }),
});
const json = await res.json();
return json.result || {};
}
// ──────────────────────────────────────────────────────────────
// Camera
// ──────────────────────────────────────────────────────────────
let cameraStream = null;
const videoEl = document.getElementById("nfc_camera_feed");
const canvasEl = document.getElementById("nfc_camera_canvas");
async function startCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera not supported on this browser/device.");
}
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
videoEl.srcObject = cameraStream;
await videoEl.play();
}
async function capturePhoto() {
if (!videoEl || !canvasEl || !videoEl.videoWidth) return "";
const w = videoEl.videoWidth;
const h = videoEl.videoHeight;
canvasEl.width = w;
canvasEl.height = h;
const ctx = canvasEl.getContext("2d");
ctx.drawImage(videoEl, 0, 0, w, h);
return canvasEl.toDataURL("image/jpeg", 0.7);
}
// ──────────────────────────────────────────────────────────────
// Wake Lock — keeps the screen on while the kiosk page is active.
// Released automatically on tab close/navigation; re-acquired on
// visibilitychange when the page comes back to the foreground.
// ──────────────────────────────────────────────────────────────
let wakeLock = null;
async function acquireWakeLock() {
if (!("wakeLock" in navigator)) {
debugLog("wakeLock: API not supported on this browser");
return;
}
if (wakeLock) {
debugLog("wakeLock: already held, skipping");
return;
}
try {
wakeLock = await navigator.wakeLock.request("screen");
debugLog("wakeLock: acquired ✓ (screen will stay on)");
wakeLock.addEventListener("release", () => {
debugLog("wakeLock: released by browser/OS");
wakeLock = null;
});
} catch (e) {
debugLog("wakeLock: request failed: " + (e && e.message));
}
}
document.addEventListener("visibilitychange", async () => {
if (document.visibilityState === "visible") {
debugLog("visibility: visible — re-acquiring wakeLock");
await acquireWakeLock();
} else {
debugLog("visibility: " + document.visibilityState);
}
});
// ──────────────────────────────────────────────────────────────
// Setup wizard activation
// ──────────────────────────────────────────────────────────────
const setupBtn = document.getElementById("nfc_setup_start");
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
debugLog("setup button clicked");
try {
await startNfcReader();
debugLog("setup: NFC ready, starting camera...");
try {
await startCamera();
debugLog("setup: camera ready ✓");
} catch (camErr) {
debugLog("setup: camera failed: " + camErr.message);
if (photoRequired) throw camErr;
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
await acquireWakeLock();
setState(STATE.IDLE);
} catch (e) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(e.message)}</p>
</div>
`;
}
});
}
// ──────────────────────────────────────────────────────────────
// Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True)
// ──────────────────────────────────────────────────────────────
if (debugEnabled) {
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) {
e.preventDefault();
const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01";
const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored);
if (!uid) return;
localStorage.setItem("nfc_mock_uid", uid);
if (currentState === STATE.ENROLL) {
_onEnrollTap(uid.toUpperCase());
} else if (currentState === STATE.IDLE) {
handleTap(uid.toUpperCase());
}
}
});
console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap");
}
window.__nfcKiosk = {
setState, STATE, photoRequired, debugEnabled, locationConfigured,
handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19)
};
})();

View File

@@ -0,0 +1,593 @@
// NFC Clock Kiosk — premium glass + animated mesh, always-dark.
//
// CRITICAL: All styles in this file are scoped under `:has(#nfc_kiosk_root)`
// to prevent leaking into other frontend pages. The previous version applied
// `html,body { overflow:hidden; height:100vh }` and `header,footer{display:none}`
// globally, which broke website scrolling and chrome on every frontend page.
//
// The single CSS custom property `--nfc-h` (hue, 0360) is set by JS after
// extracting the dominant color from the company logo. All colors interpolate
// from that hue via HSL, so the entire palette adapts to the customer brand.
// ─────────────────────────────────────────────────────────────────────
// Defaults (overridden by JS once logo dominant-hue is extracted)
// ─────────────────────────────────────────────────────────────────────
:root {
--nfc-h: 220; // fallback aurora-blue hue
--nfc-bg: #0b0d10;
--nfc-text: #ffffff;
--nfc-text-muted: #9ba3ad;
--nfc-success: #18a957;
--nfc-error: #d9374e;
}
// ─────────────────────────────────────────────────────────────────────
// Page-level styling — ONLY when the kiosk is on the page
// ─────────────────────────────────────────────────────────────────────
html:has(#nfc_kiosk_root) {
overflow: hidden;
height: 100%;
body {
overflow: hidden;
height: 100%;
margin: 0;
padding: 0;
background: var(--nfc-bg) !important;
color: var(--nfc-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
// Hide site chrome on the kiosk page only
.o_main_navbar, header, footer, .o_header_standard, .o_footer { display: none !important; }
}
// ─────────────────────────────────────────────────────────────────────
// Kiosk root container with animated mesh gradient background
// ─────────────────────────────────────────────────────────────────────
.nfc-kiosk {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
box-sizing: border-box;
user-select: none;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
background: var(--nfc-bg);
// Animated mesh gradient (drifts behind everything)
&::before {
content: "";
position: absolute;
inset: -15%;
background:
radial-gradient(circle at 20% 30%, hsla(var(--nfc-h), 75%, 40%, 0.55) 0%, transparent 45%),
radial-gradient(circle at 80% 20%, hsla(calc(var(--nfc-h) + 40), 65%, 35%, 0.50) 0%, transparent 50%),
radial-gradient(circle at 70% 75%, hsla(calc(var(--nfc-h) - 25), 70%, 35%, 0.45) 0%, transparent 55%),
radial-gradient(circle at 15% 85%, hsla(calc(var(--nfc-h) + 80), 60%, 30%, 0.40) 0%, transparent 50%);
filter: blur(60px) saturate(140%);
animation: nfc-mesh-drift 28s ease-in-out infinite alternate;
z-index: 0;
will-change: transform;
}
// Subtle vignette on top so edges don't feel washed out
&::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.45) 100%);
z-index: 1;
pointer-events: none;
}
> * { position: relative; z-index: 2; }
}
@keyframes nfc-mesh-drift {
0% { transform: translate(0%, 0%) rotate(0deg) scale(1); }
50% { transform: translate(3%, -2%) rotate(2deg) scale(1.05); }
100% { transform: translate(-3%, 3%) rotate(-1deg) scale(0.98); }
}
// ─────────────────────────────────────────────────────────────────────
// Header chrome — logo, time, date, location, settings
// ─────────────────────────────────────────────────────────────────────
// Logo centered at the top, on a subtle frosted-glass pill — just enough lift
// to keep dark logos readable on the dark gradient.
.nfc-kiosk__logo {
position: absolute;
top: 1.25rem;
left: 50%;
transform: translateX(-50%);
max-height: 52px;
max-width: 220px;
object-fit: contain;
background: rgba(255, 255, 255, 0.20);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
padding: 0.5rem 0.85rem;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow:
0 6px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
box-sizing: content-box;
animation: nfc-logo-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes nfc-logo-in {
from { opacity: 0; transform: translateX(-50%) translateY(-12px) scale(0.94); }
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
.nfc-kiosk__company {
position: absolute;
top: 5.75rem;
left: 1.5rem;
font-size: 0.85rem;
color: var(--nfc-text-muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
// When a logo is present, the company-name text is redundant — hide it.
.nfc-kiosk__logo ~ .nfc-kiosk__company { display: none; }
// Clock + date stack vertically below the logo, all centered.
.nfc-kiosk__time {
position: absolute;
top: 6.25rem; // sits below the logo (logo bottom ≈ 90px)
left: 50%;
transform: translateX(-50%);
font-size: 2.5rem;
font-weight: 300;
color: var(--nfc-text);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
text-shadow: 0 2px 12px rgba(0,0,0,0.4);
display: flex;
align-items: baseline;
gap: 0.45rem;
white-space: nowrap;
.ampm {
font-size: 1rem;
font-weight: 500;
color: var(--nfc-text-muted);
letter-spacing: 0.08em;
}
}
.nfc-kiosk__date {
position: absolute;
top: 9.75rem; // sits below the time
left: 50%;
transform: translateX(-50%);
font-size: 0.85rem;
color: var(--nfc-text-muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.nfc-kiosk__location {
position: absolute;
bottom: 1.5rem;
left: 1.5rem;
font-size: 0.85rem;
color: var(--nfc-text-muted);
background: rgba(255,255,255,0.04);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
padding: 0.5rem 1rem;
border-radius: 999px;
}
.nfc-kiosk__settings {
position: absolute;
bottom: 1.5rem;
right: 1.5rem;
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: rgba(255,255,255,0.04);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
color: var(--nfc-text-muted);
border: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 200ms ease, border-color 200ms ease, transform 200ms ease;
&:hover, &:active {
color: var(--nfc-text);
border-color: rgba(255,255,255,0.2);
transform: scale(1.05);
}
}
// ─────────────────────────────────────────────────────────────────────
// Reusable glass panel
// ─────────────────────────────────────────────────────────────────────
%nfc-glass {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid rgba(255,255,255,0.1);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
inset 0 1px 0 rgba(255,255,255,0.08);
border-radius: 1.5rem;
}
// ─────────────────────────────────────────────────────────────────────
// State container — base fade-in for whatever child renders
// ─────────────────────────────────────────────────────────────────────
#nfc_state_container > * {
animation: nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes nfc-state-in {
from { opacity: 0; transform: scale(0.96) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
// ─────────────────────────────────────────────────────────────────────
// IDLE — large NFC icon + prompt
// ─────────────────────────────────────────────────────────────────────
.nfc-kiosk__idle {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.nfc-kiosk__icon-svg {
width: 14rem;
height: 14rem;
overflow: visible; // let waves expand past viewBox without clipping
color: hsl(var(--nfc-h), 80%, 65%);
filter: drop-shadow(0 0 30px hsla(var(--nfc-h), 80%, 55%, 0.6));
.nfc-chip {
animation: nfc-chip-pulse 2.5s ease-in-out infinite;
transform-origin: center;
}
.nfc-wave {
transform-origin: center;
transform-box: fill-box; // scale around the wave's own center, not viewBox origin
opacity: 0;
animation: nfc-wave-emit 2.5s ease-out infinite;
}
.nfc-wave-1 { animation-delay: 0s; }
.nfc-wave-2 { animation-delay: 0.6s; }
.nfc-wave-3 { animation-delay: 1.2s; }
}
@keyframes nfc-chip-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes nfc-wave-emit {
0% { transform: scale(0.6); opacity: 0; }
25% { opacity: 0.85; }
80% { opacity: 0.25; }
100% { transform: scale(1.35); opacity: 0; }
}
.nfc-kiosk__prompt {
font-size: 2.25rem;
font-weight: 300;
letter-spacing: -0.01em;
color: var(--nfc-text);
text-shadow: 0 2px 20px rgba(0,0,0,0.4);
}
// ─────────────────────────────────────────────────────────────────────
// PROCESSING — pulsing dots
// ─────────────────────────────────────────────────────────────────────
.nfc-kiosk__processing {
@extend %nfc-glass;
padding: 2.5rem 3.5rem;
text-align: center;
font-size: 1.5rem;
color: var(--nfc-text);
display: inline-flex;
align-items: center;
gap: 1rem;
.dots {
display: inline-flex;
gap: 0.4rem;
span {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: hsl(var(--nfc-h), 80%, 65%);
animation: nfc-dot-bounce 1.2s ease-in-out infinite;
&:nth-child(2) { animation-delay: 0.15s; }
&:nth-child(3) { animation-delay: 0.3s; }
}
}
}
@keyframes nfc-dot-bounce {
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
40% { transform: scale(1.0); opacity: 1.0; }
}
// ─────────────────────────────────────────────────────────────────────
// RESULT — glass card with avatar + name + action
// ─────────────────────────────────────────────────────────────────────
.nfc-kiosk__result {
@extend %nfc-glass;
width: 80vw;
max-width: 720px;
padding: 2.5rem 3rem;
display: flex;
align-items: center;
gap: 2rem;
position: relative;
&--success {
border-color: rgba(24,169,87,0.55);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 80px rgba(24,169,87,0.35),
inset 0 1px 0 rgba(255,255,255,0.1);
animation: nfc-success-burst 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
&--error {
border-color: rgba(217,55,78,0.55);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 60px rgba(217,55,78,0.3),
inset 0 1px 0 rgba(255,255,255,0.1);
animation: nfc-shake 350ms ease-in-out, nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
}
}
@keyframes nfc-success-burst {
0% {
opacity: 0;
transform: scale(0.88);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 0 rgba(24,169,87,0),
inset 0 1px 0 rgba(255,255,255,0.1);
}
50% {
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 140px rgba(24,169,87,0.7),
inset 0 1px 0 rgba(255,255,255,0.1);
}
100% {
opacity: 1;
transform: scale(1);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 80px rgba(24,169,87,0.35),
inset 0 1px 0 rgba(255,255,255,0.1);
}
}
@keyframes nfc-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-10px); }
40% { transform: translateX(10px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
.nfc-kiosk__avatar {
width: 7rem;
height: 7rem;
border-radius: 50%;
background-size: cover;
background-position: center;
background-color: rgba(255,255,255,0.15);
flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.2);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
animation: nfc-avatar-in 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes nfc-avatar-in {
from { opacity: 0; transform: scale(0.4); }
to { opacity: 1; transform: scale(1); }
}
.nfc-kiosk__result-text {
flex: 1;
.name { font-size: 2.25rem; font-weight: 600; letter-spacing: -0.02em; }
.action { font-size: 1.5rem; margin-top: 0.5rem; opacity: 0.95; font-weight: 400; }
.hours { font-size: 1.05rem; opacity: 0.75; margin-top: 0.5rem; }
}
// ─────────────────────────────────────────────────────────────────────
// SETUP wizard
// ─────────────────────────────────────────────────────────────────────
.nfc-kiosk__setup {
@extend %nfc-glass;
text-align: center;
max-width: 600px;
padding: 3.5rem 3rem;
h2 { font-size: 2rem; margin-bottom: 1rem; font-weight: 300; letter-spacing: -0.01em; }
p { color: var(--nfc-text-muted); margin-bottom: 2rem; line-height: 1.5; }
button {
font-size: 1.25rem;
padding: 1rem 2.5rem;
background: hsl(var(--nfc-h), 80%, 55%);
color: white;
border: none;
border-radius: 999px;
cursor: pointer;
font-weight: 500;
box-shadow: 0 8px 32px hsla(var(--nfc-h), 80%, 50%, 0.4);
transition: transform 200ms ease, box-shadow 200ms ease;
&:hover, &:active {
transform: translateY(-2px);
box-shadow: 0 12px 36px hsla(var(--nfc-h), 80%, 50%, 0.5);
}
}
}
// ─────────────────────────────────────────────────────────────────────
// ENROLL Mode overlay — glass panel with numpad / search / tap-prompt
// ─────────────────────────────────────────────────────────────────────
.nfc-kiosk__enroll-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
animation: nfc-overlay-in 250ms ease-out;
}
@keyframes nfc-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.nfc-kiosk__enroll-panel {
@extend %nfc-glass;
padding: 2.5rem;
width: 80vw;
max-width: 720px;
h2 {
font-size: 1.5rem;
margin: 0 0 1.5rem;
font-weight: 400;
color: var(--nfc-text);
}
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin: 1rem 0;
button {
font-size: 2rem;
padding: 1.5rem 0;
background: rgba(255,255,255,0.05);
color: var(--nfc-text);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.75rem;
cursor: pointer;
transition: background 150ms ease, transform 100ms ease;
font-weight: 300;
&:hover { background: rgba(255,255,255,0.1); }
&:active { background: rgba(255,255,255,0.15); transform: scale(0.96); }
}
}
.pin-display {
font-size: 2.5rem;
letter-spacing: 0.5rem;
text-align: center;
margin: 1rem 0 1.5rem;
font-variant-numeric: tabular-nums;
min-height: 3rem;
color: hsl(var(--nfc-h), 80%, 70%);
}
.employee-search {
width: 100%;
padding: 1rem 1.25rem;
font-size: 1.15rem;
background: rgba(255,255,255,0.05);
color: var(--nfc-text);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.75rem;
margin-bottom: 1rem;
box-sizing: border-box;
outline: none;
&:focus { border-color: hsl(var(--nfc-h), 80%, 55%); }
&::placeholder { color: var(--nfc-text-muted); }
}
.employee-list {
max-height: 40vh;
overflow-y: auto;
.employee-row {
padding: 0.85rem 1rem;
border-bottom: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
font-size: 1.05rem;
border-radius: 0.5rem;
transition: background 150ms ease;
&:hover, &:active { background: rgba(255,255,255,0.06); }
}
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
button {
font-size: 1rem;
padding: 0.85rem 1.75rem;
border-radius: 999px;
cursor: pointer;
border: none;
font-weight: 500;
transition: transform 150ms ease, opacity 150ms ease;
&:hover, &:active { transform: scale(0.97); }
}
.cancel { background: rgba(255,255,255,0.08); color: var(--nfc-text); }
.confirm {
background: hsl(var(--nfc-h), 80%, 55%);
color: white;
box-shadow: 0 4px 20px hsla(var(--nfc-h), 80%, 50%, 0.4);
}
}
}
// ─────────────────────────────────────────────────────────────────────
// Reduced-motion fallback — respect users who prefer no animation
// ─────────────────────────────────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.nfc-kiosk::before { animation: none; }
.nfc-kiosk__icon-svg .nfc-wave,
.nfc-kiosk__icon-svg .nfc-chip,
#nfc_state_container > *,
.nfc-kiosk__logo,
.nfc-kiosk__result--success,
.nfc-kiosk__result--error,
.nfc-kiosk__avatar { animation: none; }
}

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_nfc_models
from . import test_clock_nfc_kiosk

View File

@@ -0,0 +1,413 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcKioskController(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Test Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.kiosk_user = cls.env['res.users'].create({
'name': 'NFC Kiosk User',
'login': 'nfc-kiosk-test',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
def test_kiosk_page_redirects_when_disabled(self):
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
self.authenticate('nfc-kiosk-test', 'kioskpass123')
response = self.url_open('/fusion_clock/kiosk/nfc', allow_redirects=False)
self.assertIn(response.status_code, (301, 302, 303))
def test_kiosk_page_renders_when_enabled(self):
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
self.authenticate('nfc-kiosk-test', 'kioskpass123')
response = self.url_open('/fusion_clock/kiosk/nfc')
self.assertEqual(response.status_code, 200)
self.assertIn('nfc_kiosk_root', response.text)
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_clock.controllers.clock_nfc_kiosk import FusionClockNfcKiosk
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestUidNormalization(TransactionCase):
def test_lowercase_input_uppercased(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid('04:a2:b5:62:c1:80'),
'04:A2:B5:62:C1:80',
)
def test_no_separator_input_gets_colons(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid('04A2B562C180'),
'04:A2:B5:62:C1:80',
)
def test_dash_separator_replaced(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid('04-A2-B5-62-C1-80'),
'04:A2:B5:62:C1:80',
)
def test_whitespace_stripped(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid(' 04:A2:B5:62:C1:80 '),
'04:A2:B5:62:C1:80',
)
def test_empty_input_returns_none(self):
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(''))
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(None))
def test_invalid_chars_returns_none(self):
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('not-a-uid'))
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04:A2:ZZ:62:C1:80'))
def test_odd_length_returns_none(self):
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04A2B562C18'))
import json
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestEnrollEndpoint(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234')
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Enroll Kiosk User',
'login': 'nfc-kiosk-enroll',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.alice = cls.env['hr.employee'].create({'name': 'Alice E', 'x_fclk_enable_clock': True})
cls.bob = cls.env['hr.employee'].create({'name': 'Bob E', 'x_fclk_enable_clock': True})
def _call(self, payload):
self.authenticate('nfc-kiosk-enroll', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/enroll',
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_enroll_success(self):
result = self._call({
'employee_id': self.alice.id,
'card_uid': '04:a2:b5:62:c1:80',
'enroll_password': '1234',
})
self.assertTrue(result.get('success'))
self.assertEqual(result.get('card_uid'), '04:A2:B5:62:C1:80')
self.alice.invalidate_recordset()
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
def test_enroll_wrong_password(self):
result = self._call({
'employee_id': self.alice.id,
'card_uid': '04:A2:B5:62:C1:81',
'enroll_password': 'wrong',
})
self.assertEqual(result.get('error'), 'invalid_password')
self.alice.invalidate_recordset()
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
def test_enroll_card_already_assigned(self):
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:82'
result = self._call({
'employee_id': self.bob.id,
'card_uid': '04:A2:B5:62:C1:82',
'enroll_password': '1234',
})
self.assertEqual(result.get('error'), 'card_already_assigned')
self.assertEqual(result.get('existing_employee'), 'Alice E')
self.bob.invalidate_recordset()
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
def test_enroll_invalid_uid(self):
result = self._call({
'employee_id': self.alice.id,
'card_uid': 'not-a-uid',
'enroll_password': '1234',
})
self.assertEqual(result.get('error'), 'invalid_uid')
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapEndpointHappyPath(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Tap Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Tap Kiosk User',
'login': 'nfc-kiosk-tap',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.alice = cls.env['hr.employee'].create({
'name': 'Alice T',
'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90',
})
def setUp(self):
super().setUp()
# Clear module-level debounce cache so tests don't inherit state from other classes
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
nfc_kiosk_module._recent_taps.clear()
def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''):
self.authenticate('nfc-kiosk-tap', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/tap',
data=json.dumps({
'jsonrpc': '2.0',
'method': 'call',
'params': {'card_uid': card_uid, 'photo_b64': photo_b64},
}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_first_tap_clocks_in(self):
result = self._tap()
self.assertTrue(result.get('success'))
self.assertEqual(result.get('action'), 'clock_in')
self.assertEqual(result.get('employee_name'), 'Alice T')
attendance = self.env['hr.attendance'].search([
('employee_id', '=', self.alice.id),
], order='check_in desc', limit=1)
self.assertTrue(attendance)
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
self.assertEqual(attendance.x_fclk_location_id, self.location)
self.assertFalse(attendance.check_out)
def test_second_tap_clocks_out(self):
self._tap()
# Wait for debounce window (5s) to elapse
import time
time.sleep(6)
result = self._tap()
self.assertTrue(result.get('success'))
self.assertEqual(result.get('action'), 'clock_out')
attendance = self.env['hr.attendance'].search([
('employee_id', '=', self.alice.id),
], order='check_in desc', limit=1)
self.assertTrue(attendance.check_out)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapEndpointErrors(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Err Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Err Kiosk User',
'login': 'nfc-kiosk-err',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.disabled_emp = cls.env['hr.employee'].create({
'name': 'Disabled E',
'x_fclk_enable_clock': False,
'x_fclk_nfc_card_uid': '04:A2:B5:62:DE:AD',
})
cls.active_emp = cls.env['hr.employee'].create({
'name': 'Active E',
'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:AC:01',
})
def setUp(self):
super().setUp()
# Clear module-level debounce cache so tests don't bleed into each other
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
nfc_kiosk_module._recent_taps.clear()
# Reset ICP to known-good defaults before each test
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
self.env.company.x_fclk_nfc_kiosk_location_id = self.location.id
def _tap(self, card_uid):
self.authenticate('nfc-kiosk-err', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/tap',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'card_uid': card_uid, 'photo_b64': ''},
}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_unknown_card(self):
result = self._tap('04:00:00:00:00:00')
self.assertEqual(result.get('error'), 'card_unknown')
def test_disabled_employee(self):
result = self._tap('04:A2:B5:62:DE:AD')
self.assertEqual(result.get('error'), 'clock_disabled')
def test_no_location_configured(self):
self.env.company.x_fclk_nfc_kiosk_location_id = False
result = self._tap('04:A2:B5:62:AC:01')
self.assertEqual(result.get('error'), 'no_location_configured')
def test_kiosk_disabled(self):
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
result = self._tap('04:A2:B5:62:AC:01')
self.assertEqual(result.get('error'), 'kiosk_disabled')
def test_invalid_uid(self):
result = self._tap('not-a-uid')
self.assertEqual(result.get('error'), 'invalid_uid')
def test_debounce_silent_second_tap(self):
first = self._tap('04:A2:B5:62:AC:01')
self.assertTrue(first.get('success'))
second = self._tap('04:A2:B5:62:AC:01')
self.assertEqual(second.get('error'), 'debounce')
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapPhotoHandling(HttpCase):
SAMPLE_PNG_DATAURL = (
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Photo Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Photo Kiosk User',
'login': 'nfc-kiosk-photo',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.emp = cls.env['hr.employee'].create({
'name': 'Photo Emp',
'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:F0:01',
})
def setUp(self):
super().setUp()
# Avoid debounce contamination from other test classes
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
nfc_kiosk_module._recent_taps.clear()
def _tap(self, photo_b64=''):
self.authenticate('nfc-kiosk-photo', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/tap',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'card_uid': '04:A2:B5:62:F0:01', 'photo_b64': photo_b64},
}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_photo_saved_on_clock_in(self):
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
result = self._tap(self.SAMPLE_PNG_DATAURL)
self.assertTrue(result.get('success'))
attendance = self.env['hr.attendance'].search([
('employee_id', '=', self.emp.id),
], order='check_in desc', limit=1)
self.assertTrue(attendance.x_fclk_check_in_photo)
def test_photo_required_rejects_when_missing(self):
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
result = self._tap(photo_b64='')
self.assertEqual(result.get('error'), 'photo_required')
def test_photo_optional_succeeds_without_photo(self):
self.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
result = self._tap(photo_b64='')
self.assertTrue(result.get('success'))
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestEmployeeSearch(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Search Kiosk User',
'login': 'nfc-kiosk-search',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.env['hr.employee'].create({'name': 'Searchable Steve', 'x_fclk_enable_clock': True})
def test_search_returns_matching_employees(self):
self.authenticate('nfc-kiosk-search', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/employee_search',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'query': 'Steve'},
}),
headers={'Content-Type': 'application/json'},
)
result = response.json().get('result', {})
self.assertIn('employees', result)
names = [e['name'] for e in result['employees']]
self.assertIn('Searchable Steve', names)

View File

@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
from psycopg2 import IntegrityError
from odoo.tools.misc import mute_logger
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcModels(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.alice = cls.Employee.create({'name': 'Alice NFC', 'x_fclk_enable_clock': True})
cls.bob = cls.Employee.create({'name': 'Bob NFC', 'x_fclk_enable_clock': True})
def test_card_uid_is_writable(self):
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
def test_card_uid_is_unique_when_set(self):
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self.bob.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
self.bob.flush_recordset(['x_fclk_nfc_card_uid'])
def test_card_uid_can_be_null_for_multiple_employees(self):
self.alice.x_fclk_nfc_card_uid = False
self.bob.x_fclk_nfc_card_uid = False
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcAttendanceFields(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.employee = cls.env['hr.employee'].create({
'name': 'NFC Test Employee',
'x_fclk_enable_clock': True,
})
def test_clock_source_includes_nfc_kiosk(self):
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': '2026-05-13 08:00:00',
'x_fclk_clock_source': 'nfc_kiosk',
})
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
def test_photo_fields_accept_binary(self):
# 1x1 transparent PNG as base64
png_b64 = (
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
b'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
)
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': '2026-05-13 08:00:00',
'x_fclk_check_in_photo': png_b64,
})
self.assertTrue(attendance.x_fclk_check_in_photo)
def test_activity_log_accepts_new_selections(self):
log = self.env['fusion.clock.activity.log'].create({
'employee_id': self.employee.id,
'log_type': 'card_enrollment',
'source': 'nfc_kiosk',
'description': 'Test enrollment log',
})
self.assertEqual(log.log_type, 'card_enrollment')
self.assertEqual(log.source, 'nfc_kiosk')
log2 = self.env['fusion.clock.activity.log'].create({
'employee_id': self.employee.id,
'log_type': 'unknown_card_tap',
'source': 'nfc_kiosk',
'description': 'Test unknown card log',
})
self.assertEqual(log2.log_type, 'unknown_card_tap')
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcKioskCompanyField(TransactionCase):
def test_company_has_nfc_kiosk_location(self):
company = self.env['res.company'].create({'name': 'NFC Test Co Plant'})
location = self.env['fusion.clock.location'].create({
'name': 'Plant 1',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
company.x_fclk_nfc_kiosk_location_id = location.id
self.assertEqual(company.x_fclk_nfc_kiosk_location_id, location)
def test_company_field_defaults_to_false(self):
new_company = self.env['res.company'].create({'name': 'Test Co NFC'})
self.assertFalse(new_company.x_fclk_nfc_kiosk_location_id)

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="nfc_kiosk_page" name="NFC Clock Kiosk">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
</t>
<div id="nfc_kiosk_root" class="nfc-kiosk"
t-att-data-photo-required="'1' if photo_required else '0'"
t-att-data-debug-enabled="'1' if debug_enabled else '0'"
t-att-data-location-configured="'1' if location_configured else '0'"
t-att-data-company-logo-url="company_logo_url or ''">
<!-- Company logo (also drives the dominant-hue palette via JS) -->
<img t-if="company_logo_url"
class="nfc-kiosk__logo"
id="nfc_company_logo"
t-att-src="company_logo_url"
crossorigin="anonymous"
alt="Company logo"/>
<!-- Static chrome (always visible) -->
<div class="nfc-kiosk__company" t-esc="company_name"/>
<div class="nfc-kiosk__time" id="nfc_clock_time">--:--</div>
<div class="nfc-kiosk__date" id="nfc_clock_date"></div>
<div class="nfc-kiosk__location">
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
</div>
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode"></button>
<!-- Dynamic state container (JS swaps inner HTML based on state) -->
<div id="nfc_state_container">
<!-- Initial: One-time setup wizard -->
<div class="nfc-kiosk__setup">
<h2>Welcome to Fusion Clock NFC Kiosk</h2>
<p>Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device.</p>
<button id="nfc_setup_start">Tap to enable NFC reader</button>
</div>
</div>
<!-- Hidden video + canvas for camera capture -->
<video id="nfc_camera_feed" autoplay="autoplay" playsinline="playsinline" muted="muted"
style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;"/>
<canvas id="nfc_camera_canvas" style="display:none;"/>
</div>
</t>
</template>
</odoo>

View File

@@ -242,6 +242,34 @@
</setting>
</block>
<!-- ============================================================ -->
<!-- NFC Clock Kiosk -->
<!-- ============================================================ -->
<block title="NFC Clock Kiosk" name="fclk_nfc_kiosk">
<setting id="fclk_nfc_enable" string="Enable NFC Kiosk"
help="Tap-to-clock kiosk for shop-floor tablets at /fusion_clock/kiosk/nfc">
<field name="fclk_enable_nfc_kiosk"/>
<div class="content-group" invisible="not fclk_enable_nfc_kiosk">
<div class="row mt16">
<label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_location_id"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_photo_required"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_enroll_password" password="True"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_kiosk_debug" string="Debug Overlay" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_debug"/>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,371 @@
# Parent Number Hierarchy — Design
**Date:** 2026-05-12
**Status:** Draft — pending user review
**Author:** Brainstormed with Gurpreet
**Scope:** Replace divergent sequences (`S00xxx` / `WH/JOB/01xxx` / `INV/2026/xxxx` / `CERT-` / `DLV/` / `RCV-` / etc.) with a single shared parent-number scheme tied to the sale order. Every document that 1:1 links to an SO derives its name from the SO's parent number.
---
## 1. Goals
1. **One source of truth.** When anyone sees a number, they immediately know which SO it belongs to. No mental lookup needed.
2. **Compliance-grade traceability.** Numbers are immutable post-issuance. Cancellation leaves gaps; gaps are part of the audit trail. Hard-deletion is blocked on every customer-shared and compliance-relevant model.
3. **Forward-only.** Existing records keep their current names. New records start fresh from `30000`.
4. **Block off-flow invoice creation.** Invoices may only be created via the sale-order workflow — no direct creation, no group-based bypass.
## 2. Non-Goals
- Renumbering or migrating existing records (`S00063`, `WH/JOB/01373`, `INV/2026/0042`, etc.). They keep their existing names until they close out naturally.
- Touching docs that physically span multiple SOs: **batches** (rack/barrel — a single rack can hold parts from three different customers), **bake windows** (per-batch, same issue), **move log** (per-event audit row, too granular), **equipment / maintenance / calibration / NADCAP audits** (equipment-bound). These keep their existing sequences.
- Multi-company numbering segregation. (One company in scope.)
## 3. Naming Rules
### 3.1 Quote name
While the `sale.order` is in `state == 'draft'` (a quotation), the name uses a non-resetting per-month counter:
```
Q + YYYY + MM + - + N
e.g. Q202605-200, Q202605-201, Q202606-202
```
The `N` counter never resets — only the year/month prefix rolls. New `ir.sequence` `fp.quote.number` handles this with prefix `Q%(year)s%(month)s-` and `padding=0`.
### 3.2 Parent number
When the SO is confirmed (`action_confirm`), a new integer is drawn from `ir.sequence` `fp.parent.number` (starts at `30000`, increments by 1, never resets). Stored on the SO as `x_fc_parent_number`. The pre-confirm quote name is preserved in `x_fc_quote_ref`.
### 3.3 Child names
Every child document linked to an SO is named as:
```
<PREFIX>-<parent> ← first / only child (bare)
<PREFIX>-<parent>-NN ← 2nd through 99th (zero-padded 2-digit)
<PREFIX>-<parent>-NNN ← 100th and beyond (unpadded — practically unreachable)
```
| Model | Prefix | Example |
|------------------------------------|---------|--------------------------|
| `sale.order` (confirmed) | `SO` | `SO-30000` |
| `fp.job` | `WO` | `WO-30000`, `WO-30000-02`|
| `account.move` (customer invoice) | `IN` | `IN-30000`, `IN-30000-02`|
| `account.move` (customer refund) | `CN` | `CN-30000-02` |
| `fp.certificate` | `CoC` | `CoC-30000` |
| `fusion.plating.delivery` | `DLV` | `DLV-30000` |
| `fp.receiving` | `RCV` | `RCV-30000` |
| `fusion.plating.pickup.request` | `PU` | `PU-30000` |
| `fusion.plating.ncr` | `NCR` | `NCR-30000-02` |
| `fusion.plating.capa` | `CAPA` | `CAPA-30000` |
| `fusion.plating.quality.hold` | `HOLD` | `HOLD-30000` |
| `fusion.plating.rma` | `RMA` | `RMA-30000` |
### 3.4 WO suffix at SO confirm — special case
WOs are unique in that **the full set is materialized at SO-confirm time** (one WO per recipe group). All other docs are created on demand later. So WO suffixing at confirm:
- 1 recipe group → 1 WO named bare (`WO-30000`).
- N recipe groups → N WOs named `WO-30000-01`, `WO-30000-02`, ..., `WO-30000-N` (zero-padded). Suffix matches creation order (group sorted by `min(line.sequence)`).
If a user **later** manually adds an extra WO to the SO:
- If the original was bare (1 group originally) → new WO is `WO-30000-02`. Bare one stays bare. (Bare implicitly carries index `1`.)
- If the originals were suffixed → new WO continues the count (`WO-30000-N+1`).
This is the only model where the bare-vs-suffix decision happens at create-time-of-the-set rather than create-time-of-the-individual. All other models follow the simple rule: first = bare, subsequent = suffixed.
### 3.5 Existing records
**Untouched.** Records with old-format names (`S00063`, `WH/JOB/01373`, `INV/2026/0042`, `CERT-00001`, `DLV/2026/0001`, `RCV-00001`, …) keep their existing names forever. They age out as jobs close. Sequences are reset to start producing the new format from `30000`.
## 4. Data Model
### 4.1 New fields on `sale.order`
| Field | Type | Notes |
|------------------------|---------|-----------------------------------------------------------------------|
| `x_fc_quote_ref` | Char | The original quote-stage name (`Q202605-200`). Preserved after confirm. |
| `x_fc_parent_number` | Integer | Assigned on `action_confirm`. Drives child naming. Indexed. |
| `x_fc_wo_count` | Integer | Cached number of WOs issued. Monotonic. |
| `x_fc_invoice_count` | Integer | Cached. Monotonic. |
| `x_fc_cn_count` | Integer | Customer credit notes. Monotonic. |
| `x_fc_cert_count` | Integer | CoCs issued. Monotonic. |
| `x_fc_delivery_count` | Integer | Deliveries. Monotonic. |
| `x_fc_receiving_count` | Integer | Receivings. Monotonic. |
| `x_fc_pickup_count` | Integer | Pickup requests. Monotonic. |
| `x_fc_ncr_count` | Integer | NCRs raised against this SO. Monotonic. |
| `x_fc_capa_count` | Integer | CAPAs. Monotonic. |
| `x_fc_hold_count` | Integer | Quality holds. Monotonic. |
| `x_fc_rma_count` | Integer | RMAs. Monotonic. |
Counters are **monotonic and never decrement**, even on cancellation/unlink (which itself is blocked — see §6).
### 4.2 New field on every child model
| Field | Type | Notes |
|------------------|---------|--------------------------------------------------------------------|
| `x_fc_doc_index` | Integer | The index this child was assigned (1, 2, 3, …). `readonly=True` after create. Indexed jointly with the link to SO. |
### 4.3 New abstract model: `fp.parent.numbered.mixin`
```python
class FpParentNumberedMixin(models.AbstractModel):
_name = 'fp.parent.numbered.mixin'
_description = 'Fusion Plating — Parent-Number-Derived Naming'
x_fc_doc_index = fields.Integer(
string='Parent Doc Index',
readonly=True, copy=False, index=True,
help='Sequential index within this parent SO (1 = first child).',
)
# ---- Hooks subclasses override --------------------------------
def _fp_parent_sale_order(self):
"""Return the linked sale.order record or self.env['sale.order']."""
raise NotImplementedError
def _fp_name_prefix(self):
"""Return the 2-4 letter prefix for this model (e.g. 'WO', 'IN')."""
raise NotImplementedError
def _fp_parent_counter_field(self):
"""Return the field name on sale.order that counts THIS model's children."""
raise NotImplementedError
# ---- Core (sealed) --------------------------------------------
def _fp_assign_parent_name(self):
"""Atomically: lock the parent SO, read+bump the counter, assign
x_fc_doc_index and name. Used by subclass create() hooks."""
# implementation in §5.3
```
Subclasses register by:
```python
class FpJob(models.Model):
_name = 'fp.job'
_inherit = ['fp.job', 'fp.parent.numbered.mixin'] # multi-inherit pattern
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'WO'
def _fp_parent_counter_field(self):
return 'x_fc_wo_count'
```
## 5. Behaviour
### 5.1 Quote creation (`sale.order.create`)
Override existing `create()` so that when a new sale.order is created and no `name` is provided (or `name == 'New'`), it pulls from the `fp.quote.number` sequence rather than Odoo's default. The resulting name (`Q202605-200`) is also stored in `x_fc_quote_ref` so it's preserved verbatim after confirm.
### 5.2 SO confirm (`sale.order.action_confirm`)
In the existing confirm flow, AFTER any existing checks but BEFORE `_fp_native_jobs_for_so()` (the WO creation):
1. If `x_fc_parent_number` is unset:
1. Draw next from `fp.parent.number` (starts at 30000).
2. Write `x_fc_parent_number` and rename `name` from `Q...` to `SO-<parent>`.
3. Post chatter: *"Confirmed quote Q202605-200 as SO-30000."*
2. Proceed with WO creation (§5.4 below).
### 5.3 Atomic counter increment (mixin core)
`_fp_assign_parent_name()` does, in order, in a single transaction:
1. `cr.execute("SELECT <counter_field> FROM sale_order WHERE id = %s FOR UPDATE", [so.id])` — acquires a row-level lock on the parent SO until commit.
2. Reads the current count.
3. Computes the new index `= current + 1`.
4. `UPDATE sale_order SET <counter_field> = <new index> WHERE id = %s`.
5. Sets `self.x_fc_doc_index = new_index` and `self.name = self._fp_compose_name(new_index)`.
6. Posts chatter on the parent SO: *"Issued WO-30000-02 to fp.job #1234."*
Composition rule:
```python
def _fp_compose_name(self, index):
so = self._fp_parent_sale_order()
parent = so.x_fc_parent_number
prefix = self._fp_name_prefix()
if index <= 1:
return f'{prefix}-{parent}'
if index <= 99:
return f'{prefix}-{parent}-{index:02d}'
return f'{prefix}-{parent}-{index}'
```
### 5.4 WO creation at SO confirm — special bulk path
`_fp_native_jobs_for_so()` (in `fusion_plating_jobs/models/sale_order.py`) is rewritten to:
1. Group lines by **resolved recipe id** (the 4-tier priority resolution we just shipped — `line.x_fc_process_variant_id``part.default_process_id``coating.recipe_id``part.recipe_id`). `x_fc_wo_group_tag` is dropped as an override mechanism (recipe-driven grouping replaces it).
2. Count the resulting groups.
3. If 1 group → create 1 job with `vals['name'] = f"WO-{parent}"` and `x_fc_doc_index = 1`. Bump SO's `x_fc_wo_count` to 1.
4. If N > 1 groups → create N jobs ordered by `min(line.sequence)`. For each, `vals['name'] = f"WO-{parent}-{i:02d}"` and `x_fc_doc_index = i`. Bump `x_fc_wo_count` to N.
5. All assignments happen inside a single `for_update` lock on the SO.
If a user later manually creates an extra `fp.job` for this SO (via the form, not the SO-confirm flow), the mixin's standard path runs: lock SO → bump `x_fc_wo_count` → assign next index → compose name.
### 5.5 Invoice creation flow
When `sale.order._create_invoices()` runs (deposit, progress, partial, or final invoicing):
1. The standard Odoo flow proceeds as-is for line aggregation / tax / journal selection.
2. Before `account.move.create()` is called, the SO's `_create_invoices` override sets `self = self.with_context(fp_from_so_invoice=True)`.
3. Our `account.move.create()` override:
- For `move_type in ('out_invoice', 'out_refund')`:
- If `not self.env.context.get('fp_from_so_invoice')` AND `not vals.get('invoice_origin')` matching an SO name → **raise `UserError`** ("Customer invoices must be created from a Sale Order. Open the SO and use Create Invoice."). No group bypass; applies to admins.
- Else proceed.
- Post-create, immediately invoke `_fp_assign_parent_name()` (mixin pulls SO via `invoice_origin` lookup) — which assigns `x_fc_doc_index` and overrides `name` from the journal-default `INV/2026/xxxx` to `IN-<parent>` or `IN-<parent>-NN`.
Credit notes (`out_refund`) use prefix `CN` and counter `x_fc_cn_count`.
### 5.6 Other child models — uniform path
For `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, `fusion.plating.ncr`, `fusion.plating.capa`, `fusion.plating.quality.hold`, `fusion.plating.rma`:
- Each model's existing `create()` override (which currently pulls from its own `ir.sequence`) is rewritten to resolve the parent SO and call `_fp_assign_parent_name()`.
- If the record has **no** linked SO (e.g. a standalone NCR raised from a calibration finding, an RMA from a generic customer complaint with no SO), the mixin falls back to the model's old sequence (e.g. `NCR-2026-NNN`).
- The old sequences stay in place as the standalone fallback. They're not removed.
### 5.7 Direct-creation block on invoices
Implementation in §5.5. Concrete error message:
> *"Customer invoices and credit notes must be created from a Sale Order. Open the originating SO and use the Create Invoice / Add Credit Note action. This rule applies to all users including administrators — it is enforced to keep the parent-number audit trail intact."*
The check is in `account.move._create_invoice_check_so()` (new helper), called from the create override. The helper:
1. Reads `move_type`.
2. If not customer-facing (`out_invoice`/`out_refund`) → pass.
3. Else, look for `fp_from_so_invoice=True` in context OR `invoice_origin` resolving to an existing `sale.order`.
4. If neither → raise.
## 6. Immutability and Deletion
### 6.1 `name` and `x_fc_doc_index` are immutable
The mixin sets `name` and `x_fc_doc_index` with `readonly=True`. Additionally, a `write()` override raises `UserError` if either field is in the values dict and the record already has a non-empty name. No code path can rename a record post-creation.
### 6.2 `unlink()` blocked on compliance models
For: `sale.order`, `account.move` (customer invoices and credit notes), `fp.certificate`, `fp.job`, `fusion.plating.delivery`, `fp.receiving`, `fusion.plating.ncr`, `fusion.plating.capa`, `fusion.plating.quality.hold`, `fusion.plating.rma`.
`unlink()` raises `UserError` for **every** user (no group bypass) when the record has a name AND its state is not `draft`. The error message:
> *"Document `<name>` cannot be deleted — it is part of the compliance audit trail. Cancel it instead (state machine handles cancellation). This rule applies to all users including administrators."*
Draft records (no name assigned yet, never issued) can be deleted normally — they're not yet part of the audit trail.
### 6.3 Cancellation leaves gaps
When `IN-30000-02` is cancelled, the counter `x_fc_invoice_count` is NOT decremented. The next invoice for SO-30000 is `IN-30000-03`. The audit chatter and the `x_fc_doc_index` field both record `IN-30000-02` as issued + cancelled.
## 7. Spanning Documents — Exception List
The following keep their existing per-model sequences (NOT touched by this design):
| Model | Reason |
|--------------------------------|-----------------------------------------------------------------------------|
| `fusion.plating.batch` | A rack/barrel can hold parts from multiple SOs simultaneously. |
| `fusion.plating.bake.window` | Per-batch; same reasoning. |
| `fp.job.step.move` | Per-event audit row; too granular and per-step, not per-SO. |
| `maintenance.equipment` / plans| Equipment-bound, not order-bound. |
| Compliance docs (Nadcap, ITP, CFT, RISK, SPILL, INC, etc.) | Audit / event-driven, not SO-driven. |
This list is exhaustive — every other linked document gets the parent-number treatment.
## 8. Reports + Views
### 8.1 Reports to verify show parent-derived names
All these read `record.name` directly, so they "just work" once the data flow is right. Verification checklist:
- [ ] Quote PDF (standard Odoo sale report — uses `name`)
- [ ] Sale Order confirmation PDF
- [ ] Invoice PDF (standard Odoo)
- [ ] WO Detail PDF — **bug to fix**: current `short_wo` derivation uses `(job.name or '').split('/')[-1]` which assumed `WH/JOB/` prefix. Update to either strip the new `WO-` prefix or simply show full `job.name`.
- [ ] CoC EN / FR PDFs
- [ ] Chronological CoC PDF
- [ ] Traveller PDF
- [ ] Delivery / Packing Slip / BoL PDFs
- [ ] Job Sticker
- [ ] Rack Travel Ticket
- [ ] WO Margin report
### 8.2 Form views
- `sale.order` form: after confirm, show both `x_fc_quote_ref` (small grey "Originally quoted as Q202605-200") and `name` (big SO-30000 heading).
- All child forms: show `name` (no change to layout).
- All search views referencing `WH/JOB/` or `INV/` prefixes in `decoration-info` style hooks should be neutral — they don't typically depend on the prefix.
### 8.3 List/kanban views
No structural changes. The sort order by `name` works fine since all new names follow `<PREFIX>-30000`, `<PREFIX>-30000-02`, ..., which sort alphabetically as expected.
## 9. Sequence Definitions
New sequences (XML data):
```xml
<record id="seq_fp_quote_number" model="ir.sequence">
<field name="name">Fusion Plating: Quote Number</field>
<field name="code">fp.quote.number</field>
<field name="prefix">Q%(year)s%(month)s-</field>
<field name="padding">0</field> <!-- non-padding sequential -->
<field name="use_date_range" eval="False"/> <!-- counter never resets -->
<field name="number_next_actual">200</field> <!-- start from current quote count -->
</record>
<record id="seq_fp_parent_number" model="ir.sequence">
<field name="name">Fusion Plating: Parent Number</field>
<field name="code">fp.parent.number</field>
<field name="prefix"/> <!-- no prefix, just the integer -->
<field name="padding">0</field>
<field name="number_next_actual">30000</field>
</record>
```
Existing sequences (`fp.job`, `account.move` journal, `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, `fp.rma`) stay defined and are used as fallbacks for standalone-no-SO cases (§5.6).
## 10. Migration
- New sequences added with `number_next_actual` at the target starting values (30000 for parent, 200 for quote).
- No data backfill on existing records.
- Module upgrade rolls out:
- Mixin abstract model
- Field additions on `sale.order` and on each child
- Create/write/unlink overrides
- View tweaks
- Rollback path: re-installing the prior version restores the old `create()` flows. The new fields on existing records would become unused but harmless.
## 11. Open Items / Edge Cases
1. **Pickup request before SO exists.** Pickup requests can be raised before an SO is confirmed (or even created). The mixin's standalone fallback covers this. If a pickup is later linked to a confirmed SO, the name is NOT retroactively changed (immutability rule). A separate `x_fc_so_id` link records the relationship; the original name stays.
2. **Quote sequence migration.** The `number_next_actual=200` is illustrative. Confirmed value from the user before the spec is implemented (he stated "Q202605-200" as the format with `200` as the example counter, so we start there or at any agreed value).
3. **Reports not updating display label.** The WO Detail's `short_wo` derivation is the one known concrete report-side break. The rest read `name` raw and don't need template changes.
4. **Manual job creation outside the SO flow.** Users may manually create an `fp.job` from the form (rare). The mixin's standard path handles this: requires a linked SO, locks it, bumps counter, assigns name. If no SO is linked, raise UserError.
## 12. Implementation Order (high-level)
Detailed step-by-step plan to be produced by `writing-plans` skill. High-level sequence:
1. Add `fp.parent.numbered.mixin` abstract model + sequence data.
2. Add SO fields (`x_fc_quote_ref`, `x_fc_parent_number`, counters) and `x_fc_doc_index` on each child.
3. Quote/SO rename in `sale.order.create` and `action_confirm`.
4. Block direct invoice creation (override on `account.move.create`).
5. Wire each child model into the mixin: `fp.job` first (most critical), then `account.move` (invoice/credit note), then `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, then quality models (NCR, CAPA, Hold, RMA).
6. Add `unlink()` and `write()` overrides for immutability.
7. WO recipe-group rewrite of `_fp_native_jobs_for_so` (replaces existing `x_fc_wo_group_tag` grouping).
8. View tweaks: SO form quote ref display.
9. Fix WO Detail report's `short_wo` derivation.
10. Full audit walkthrough on a fresh DB: create quote → confirm → ship → invoice → CoC → verify every doc shows parent-derived name.
---
**End of design.**

View File

@@ -0,0 +1,439 @@
# Sticker — Multi-part, Per-box, Internal/External Variants
**Date:** 2026-05-13
**Module(s):** `fusion_plating_jobs`, `fusion_plating_reports`
**Author:** Gurpreet (Nexa Systems Inc.)
**Status:** Approved — ready for implementation plan
## Summary
The box sticker (printed at SO level and at fp.job level) currently
mishandles three real-world scenarios on multi-line orders:
1. **Silent thickness/SN merge bug.** When two SO lines share
`(recipe, part, coating)` but differ in thickness or serial,
the current `_create_fp_jobs` grouping collapses them into one
`fp.job`. The job inherits the FIRST line's thickness/SN — the
other line's values are silently dropped from the sticker (and
eventually from the CoC).
2. **No per-box stickers.** A line with `qty = 5` prints one
sticker showing `Qty: 5`. Operators want one physical label per
box, with a `1 / 5`, `2 / 5`, ... indicator.
3. **No Internal variant.** The sticker always prints the
customer-facing description (`_line.name`) in the Notes column.
The shop floor wants a parallel variant that shows the
internal ops description (`_line.x_fc_internal_description`,
from Sub 2) instead.
This spec covers all three as a single piece of work — they touch
the same files and ship together.
## Goals / non-goals
**Goals**
- Multi-thickness / multi-SN lines split into separate `fp.job`
records with correct WO-XXXXX-NN naming.
- SO sticker and Job sticker render one page per physical box,
with a `Box X / N` indicator replacing the current `Qty: N`.
- New "Internal" variant for each sticker that prints the internal
description in the Notes column. Existing variant becomes
"External".
- Both variants share the same inner template — only the Notes
source differs.
- Existing action XML IDs unchanged so bookmarks and binding
records keep working.
**Non-goals**
- Per-physical-box serial number tracking (today's `x_fc_serial_id`
is one per line, shared across all boxes in that line — that's
fine).
- Box-count override (today: 1 sticker per qty unit; if the shop
packs 5 parts into 1 box, that's an operational choice the
sticker doesn't try to encode).
- Migration of pre-existing single-line, single-thickness jobs —
they remain as-is.
## Current state (post Sub 11)
### Backend — `fusion_plating_jobs/models/sale_order.py`
```python
# Inside _create_fp_jobs(), the grouping key:
key = (recipe.id, part_id, coating_id)
groups[key] = groups.get(key, ...) | line
```
Lines that share ALL THREE collapse into one `fp.job`. Sub 11's
comment explicitly calls out the part_id+coating_id check ("sharing
only the recipe is not enough — would put Part A's number on a cert
covering both") but doesn't extend the same reasoning to thickness
or SN. The thickness Many2one (`x_fc_thickness_id`) and serial
Many2one (`x_fc_serial_id`) were added in Sub 5, after the grouping
logic was last touched.
### Sticker — `fusion_plating_reports/report/report_fp_wo_sticker.xml`
Two outer templates wrap a shared inner:
- `report_fp_so_sticker` (bound to `sale.order` via
`action_report_fp_so_sticker`) — iterates
`so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)`,
renders one inner per line.
- `report_fp_job_sticker_template` (in
`fusion_plating_jobs/report/report_fp_job_sticker.xml`, bound to
`fp.job` via `action_report_fp_job_sticker`) — iterates `docs`,
renders one inner per job.
Neither outer accounts for `qty > 1` — each line/job produces
exactly one inner render.
The inner template `report_fp_wo_sticker_inner` sets variables and
renders one page. The Notes content is fixed:
```xml
<t t-set="_notes_content" t-value="(_line and _line.name)
or (_part and _part.name)
or '-'"/>
```
There is no way for an outer to override this — it's a hard read of
`_line.name`.
## Architecture — the three changes
### Change 1 — Backend split: extend grouping key
In `fusion_plating_jobs/models/sale_order.py`, in the method that
builds the `groups` dict (currently `_create_fp_jobs` around line
424441), extend the key tuple:
```python
# Before
key = (recipe.id, part_id, coating_id)
# After
thickness_id = (
'x_fc_thickness_id' in line._fields
and line.x_fc_thickness_id.id
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
```
**Effect:** Lines that previously merged silently across different
thicknesses or SNs now split into separate fp.jobs. WO-XXXXX-NN
suffixes apply normally (driven by the existing
`ordered_keys = sorted(...)` block — no change needed there).
**Backwards compat:** Single-line SOs and same-(thickness, SN)
multi-line SOs collapse identically to before. No data migration
required.
### Change 2 — Per-box render in the inner template
`fusion_plating_reports/report/report_fp_wo_sticker.xml`, in the
`report_fp_wo_sticker_inner` template:
1. Move the variable-resolution + style block OUT of the per-page
render (these don't change per box, so they don't need to repeat).
2. Wrap the `<div class="fp-sticker">` body in a box loop:
```xml
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
<div class="fp-sticker">
... existing structure ...
</div>
</t>
```
3. Change the Qty row's value column to show `X / N` when
`_qty_total > 1`:
```xml
<tr>
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<t t-if="_qty_total and _qty_total &gt; 1">
<span t-esc="_box_idx"/> / <span t-esc="int(_qty_total)"/>
</t>
<t t-else="">
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</t>
</span>
</td>
</tr>
```
**Outer templates supply `_qty_total`:**
- SO outer: `_qty_total = line.product_uom_qty`
- Job outer: `_qty_total = job.qty`
If `_qty_total` is missing/zero, fall back to `1` so single-box
behavior is unchanged.
### Change 3 — Internal/External variants
#### 3a. Inner template: override-or-fallback on `_notes_content`
In `report_fp_wo_sticker_inner`, change the `_notes_content` set
from a hard read to override-or-fallback (matches the existing
pattern for `_so`, `_part`, etc.):
```xml
<!-- Was: -->
<t t-set="_notes_content" t-value="(_line and _line.name)
or (_part and _part.name)
or '-'"/>
<!-- After: -->
<t t-set="_notes_content" t-value="_notes_content
or (_line and _line.name)
or (_part and _part.name)
or '-'"/>
```
External outer templates don't set `_notes_content` → falls through
to `_line.name` (unchanged External behavior).
Internal outer templates pre-set `_notes_content` before
t-calling the inner:
```xml
<t t-set="_notes_content" t-value="(_line and 'x_fc_internal_description' in _line._fields
and _line.x_fc_internal_description) or '-'"/>
```
#### 3b. New outer templates + action records
**SO Internal** — in `fusion_plating_reports/report/report_fp_wo_sticker.xml`:
```xml
<template id="report_fp_so_sticker_internal">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Override: read internal description instead of line.name -->
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
and line.x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</t>
</template>
```
**SO External** — existing `report_fp_so_sticker` template gets one
addition: `<t t-set="_qty_total" t-value="line.product_uom_qty"/>`.
No other logic change (no `_notes_content` set = External default).
**Job Internal** — in `fusion_plating_jobs/report/report_fp_job_sticker.xml`:
```xml
<template id="report_fp_job_sticker_internal_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Override: read internal description from first linked SO line -->
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1]
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</template>
```
**Job External** — existing `report_fp_job_sticker_template`
template gets one addition: `<t t-set="_qty_total" t-value="job.qty"/>`.
**Action records — labels + new XML IDs**
In `fusion_plating_reports/report/report_actions.xml`:
```xml
<!-- Existing record — rename label only -->
<record id="action_report_fp_so_sticker" model="ir.actions.report">
<field name="name">External Sticker</field> <!-- was: "WO Box Sticker" -->
...
</record>
<!-- New record -->
<record id="action_report_fp_so_sticker_internal" model="ir.actions.report">
<field name="name">Internal Sticker</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_so_sticker_internal</field>
<field name="report_file">fusion_plating_reports.report_fp_so_sticker_internal</field>
<field name="print_report_name">'Internal Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
</record>
```
In `fusion_plating_jobs/report/report_fp_job_sticker.xml`:
```xml
<!-- Existing record — rename label only -->
<record id="action_report_fp_job_sticker" model="ir.actions.report">
<field name="name">External Job Sticker</field> <!-- was: "Job Sticker" -->
...
</record>
<!-- New record -->
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
<field name="name">Internal Job Sticker</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
```
## Files touched
| # | File | Change |
|---|------|--------|
| 1 | `fusion_plating_jobs/models/sale_order.py` | Extend grouping key in `_create_fp_jobs` (+5 lines) |
| 2 | `fusion_plating_reports/report/report_fp_wo_sticker.xml` | Inner template: box loop, Qty row logic, `_notes_content` fallback chain. SO outer: add `_qty_total`. NEW: SO Internal outer template. |
| 3 | `fusion_plating_reports/report/report_actions.xml` | Rename existing SO action label. NEW: SO Internal action record. |
| 4 | `fusion_plating_jobs/report/report_fp_job_sticker.xml` | Job outer: add `_qty_total`. Rename existing job action label. NEW: Job Internal outer template + action record. |
| 5 | `fusion_plating_jobs/__manifest__.py` | Version bump |
| 6 | `fusion_plating_reports/__manifest__.py` | Version bump |
## Migration
None required.
- **New grouping key (`_create_fp_jobs`)** is purely additive —
existing jobs are protected by the existing
`if existing: return` idempotency guard. Single-line and
same-(thickness, SN) multi-line SOs collapse identically to
before.
- **Existing XML IDs unchanged** — bookmarks / `binding_model_id`
records keep working. Only the visible label flips.
- **New variants** appear in the Print menu on next module
upgrade with no data work.
## Testing
### Scenario 1 — Multi-thickness split (new fp.jobs)
Create a new SO with two lines:
- Line 10: Part A, Coating X, Thickness 0.3-0.5 mils, qty 2
- Line 20: Part A, Coating X, Thickness 0.5-1.0 mils, qty 1
Confirm SO → 2 fp.jobs are created:
- `WO-XXXXX-01`: qty 2, thickness 0.3-0.5
- `WO-XXXXX-02`: qty 1, thickness 0.5-1.0
Print each job's External sticker → confirm correct thickness on each.
### Scenario 2 — Per-box rendering
Take Scenario 1's SO, click "Print → External Sticker" on the SO.
Confirm: 3-page PDF.
- Page 1: Line 10 box 1 → Qty row shows `1 / 2`
- Page 2: Line 10 box 2 → Qty row shows `2 / 2`
- Page 3: Line 20 box 1 → Qty row shows `1`
### Scenario 3 — Internal variant
On the same SO, click "Print → Internal Sticker".
Confirm: same 3 pages, same WO#/PO#/Customer/Part#/SN/Thickness/Qty,
but the Notes column shows `x_fc_internal_description` from each
line instead of `name`.
If `x_fc_internal_description` is blank on a line, Notes shows `-`.
### Scenario 4 — Regression check (existing single-line)
Re-print SO-30019 (1 line, qty 1) → External sticker prints
single-page, no `X / N` indicator, Notes shows `_line.name` as
before. Internal variant: single-page, Notes shows `x_fc_internal_description`
or `-`.
### Scenario 5 — Job-level multi-box
Take any existing fp.job with `qty = 3`. Print External Job Sticker.
Confirm: 3 pages, `1/3`, `2/3`, `3/3`. Internal Job Sticker also 3
pages with the line's internal description in Notes.
### Scenario 6 — Action menu visibility
On a sale order Print menu: both "External Sticker" and
"Internal Sticker" appear. On an fp.job Print menu: both
"External Job Sticker" and "Internal Job Sticker" appear.
## Out-of-scope items (deferred)
- **Per-box SN registry.** Today `x_fc_serial_id` is one per line.
If the customer needs unique SNs per physical box (5 parts =
5 SNs), build out an `fp.box.serial` registry that links to the
line. Out of scope for this spec — would need workflow design
(UI for assigning, where SNs print, etc.).
- **Box count ≠ qty.** Some shops pack multiple parts per box.
Today this spec assumes 1 sticker per qty unit. If needed,
add an `x_fc_box_count` field on the line that defaults to qty
but can be overridden, and the sticker loops over box_count
instead. Defer until requested.
- **Sticker preview UI in the form view.** No live preview today;
operators print + visually verify. Defer.
## Open questions
None — all decisions locked at spec time:
| Q | Decision |
|---|---|
| Add SN to grouping key? | **Yes.** Same reasoning as thickness — silent merge of different SNs is a compliance hole. |
| Per-box indicator location? | **Replace Qty row value.** Operator's confirmation: "we can use the quantity field portion for the box, there is room we can use rather than creating another line below and making everything smaller." |
| Box indicator format? | **`1 / 5`** (slash, spaces around for legibility at 50pt). When qty=1, show plain `1` (no slash) — matches current behavior. |
| Label naming convention? | **Prefix.** `External Sticker` / `Internal Sticker` (SO Print menu), `External Job Sticker` / `Internal Job Sticker` (fp.job Print menu). |
| Migration for existing jobs? | **None.** Idempotency guard in `_create_fp_jobs` protects them. |
| Existing action XML IDs? | **Unchanged.** Only labels rename — bookmarks/binding records survive. |
| Fractional qty? | Cast to `int(qty)` — current behavior preserved. |
| Qty=0 line? | Already filtered out by `lambda l: l.x_fc_part_catalog_id` (no part → no sticker). |

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.18.15.10',
'version': '19.0.18.15.16',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -84,6 +84,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'data/fp_landing_data.xml',
'data/fp_sequence_data.xml',
'data/fp_job_sequences.xml',
'data/fp_numbering_sequences.xml',
'data/fp_process_category_data.xml',
# fp_menu.xml MUST load early — defines menu_fp_root, menu_fp_config,
# menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!--
Parent-number sequence: drives the integer at the heart of every
linked document's name (SO-30000, WO-30000, IN-30000, CoC-30000, ...).
Starts at 30000 per the 2026-05-12 parent-number design.
noupdate=1 so a module upgrade never resets the counter.
-->
<record id="seq_fp_parent_number" model="ir.sequence">
<field name="name">Fusion Plating: Parent Number</field>
<field name="code">fp.parent.number</field>
<field name="prefix"/>
<field name="padding">0</field>
<field name="number_next_actual">30000</field>
<field name="company_id" eval="False"/>
</record>
<!--
Quote sequence: Q + YYYY + MM + '-' + non-resetting counter.
The counter is global (never resets when year/month rolls).
Padding 0 because the counter naturally grows past 4 digits
over time.
-->
<record id="seq_fp_quote_number" model="ir.sequence">
<field name="name">Fusion Plating: Quote Number</field>
<field name="code">fp.quote.number</field>
<field name="prefix">Q%(year)s%(month)s-</field>
<field name="padding">0</field>
<field name="number_next_actual">200</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -3,6 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_parent_numbered_mixin
from . import fp_process_category
from . import fp_process_type
from . import fp_facility

View File

@@ -51,7 +51,7 @@ class FpJob(models.Model):
dt = pytz.UTC.localize(dt)
return dt.astimezone(tz).strftime(fmt)
_description = 'Work Order'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
# Sub 12d — state-aware sort. Active work bubbles to the top
# (in_progress → confirmed/draft → on_hold → done → cancelled),
# then high-priority first within each state, then nearest deadline.
@@ -389,12 +389,50 @@ class FpJob(models.Model):
continue
job.current_step_id = False
# ------------------------------------------------------------------
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'WO'
def _fp_parent_counter_field(self):
return 'x_fc_pn_wo_count'
@api.model_create_multi
def create(self, vals_list):
"""fp.job naming priority:
1. Caller-provided name (bulk SO-confirm path sets these explicitly).
2. Mixin parent-derived name (manual WO add to an existing SO).
3. Legacy fp.job sequence (standalone job, no SO link).
"""
# Pass A: fall back to legacy 'New' sentinel for records that
# don't get a parent-derived name. The mixin's post-create
# _fp_assign_parent_name() will override 'New' once the record
# exists if a parent SO is reachable.
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
return super().create(vals_list)
if not vals.get('name'):
vals['name'] = _('New')
records = super().create(vals_list)
# Pass B: any record that came through with 'New' (no explicit
# name from the bulk SO path) tries the parent-derived path,
# falling back to the legacy sequence if there's no parent SO.
for rec in records:
if rec.name and rec.name != _('New') and rec.name != 'New':
continue # caller set an explicit name (e.g. bulk SO confirm)
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
# Raw SQL — fp.job has no immutability guard yet in this
# task, but Task 11 will add one. Using SQL here keeps the
# fallback path consistent across all child models.
self.env.cr.execute(
"UPDATE fp_job SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# ------------------------------------------------------------------
# State machine — actions

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Abstract mixin: derive a record's name from its parent sale order.
Every model that 1:1 links to an SO inherits this mixin. The mixin
owns the atomic counter logic so race conditions and counter drift
are impossible. Subclasses implement three small hooks and call
``self._fp_assign_parent_name()`` from their ``create()`` override.
See docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md
for the design rationale.
"""
import re
from markupsafe import Markup
from odoo import fields, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
# Whitelist regex for counter-field names. The mixin interpolates the
# returned name into raw SQL, so a future subclass that read this from
# a context value or Selection field would otherwise open a SQL-injection
# surface. Enforce: must look like one of our x_fc_pn_*_count counters
# (lowercase letters / underscores only).
_FP_COUNTER_FIELD_RE = re.compile(r'^x_fc_pn_[a-z_]+_count$')
class FpParentNumberedMixin(models.AbstractModel):
_name = 'fp.parent.numbered.mixin'
_description = 'Fusion Plating - Parent-Number-Derived Naming'
x_fc_doc_index = fields.Integer(
string='Parent Doc Index',
readonly=True,
copy=False,
index=True,
help='1-based position within this parent SO. 1 = the first '
'child of this type for the SO; subsequent siblings get 2, '
'3, etc. The first sibling renders its name bare; later '
'siblings get a zero-padded "-NN" suffix.',
)
# ------------------------------------------------------------------
# Hooks subclasses must override
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
"""Return the linked sale.order recordset (or empty)."""
return self.env['sale.order']
def _fp_name_prefix(self):
"""Return the model's prefix (e.g. 'WO', 'IN', 'CoC')."""
raise NotImplementedError(
'Subclass must define _fp_name_prefix()'
)
def _fp_parent_counter_field(self):
"""Return the counter field on sale.order for THIS model."""
raise NotImplementedError(
'Subclass must define _fp_parent_counter_field()'
)
# ------------------------------------------------------------------
# Core: atomic counter + name composition
# ------------------------------------------------------------------
def _fp_compose_name(self, parent_number, index):
"""Pure helper: compose the name string per the design's rules."""
prefix = self._fp_name_prefix()
if index <= 1:
return f'{prefix}-{parent_number}'
if index <= 99:
return f'{prefix}-{parent_number}-{index:02d}'
return f'{prefix}-{parent_number}-{index}'
def _fp_assign_parent_name(self):
"""Lock the parent SO, bump the counter, set name + doc index.
Returns True if assignment succeeded; False if no parent SO is
linked (caller falls back to the model's own legacy sequence).
"""
self.ensure_one()
so = self._fp_parent_sale_order()
if not so or not so.x_fc_parent_number:
return False
counter_field = self._fp_parent_counter_field()
# Whitelist check — the field name is interpolated directly into
# SQL below, so we never trust an arbitrary string. All current
# subclasses return a literal; this guard exists so a future
# subclass that reads the field name from context / Selection /
# user input can't smuggle a SQL fragment in.
if not _FP_COUNTER_FIELD_RE.match(counter_field or ''):
raise UserError(_(
'Invalid parent-counter field name %r — must match '
'pattern x_fc_pn_*_count.'
) % counter_field)
# SELECT FOR UPDATE - locks the SO row until commit, so a
# concurrent create on the same SO blocks here and reads the
# updated counter after we release. No race, no drift.
self.env.cr.execute(
f'SELECT {counter_field} FROM sale_order WHERE id = %s FOR UPDATE',
(so.id,),
)
row = self.env.cr.fetchone()
current = (row and row[0]) or 0
new_index = current + 1
self.env.cr.execute(
f'UPDATE sale_order SET {counter_field} = %s WHERE id = %s',
(new_index, so.id),
)
so.invalidate_recordset([counter_field])
new_name = self._fp_compose_name(so.x_fc_parent_number, new_index)
# Raw SQL update bypasses the immutability write() guard added
# in Task 11 (since this IS the legitimate assignment path).
self.env.cr.execute(
f'UPDATE {self._table} SET name = %s, x_fc_doc_index = %s WHERE id = %s',
(new_name, new_index, self.id),
)
self.invalidate_recordset(['name', 'x_fc_doc_index'])
so.message_post(body=Markup(_(
'Issued <strong>%s</strong> to %s #%s.'
)) % (new_name, self._name, self.id))
return True
# ------------------------------------------------------------------
# Immutability: name + x_fc_doc_index can't change post-issuance.
# Bypass: context flag fp_allow_name_rename=True. Used ONLY by:
# 1. sale.order.action_confirm (Q -> SO rename, one-time)
# 2. Bulk WO creation mid-create (sets names explicitly)
# 3. Legacy-sequence fallback path in child create() overrides
# Compliance: once issued, an audit-trail number can never change.
# ------------------------------------------------------------------
FP_IMMUTABLE_FIELDS = ('name', 'x_fc_doc_index')
def write(self, vals):
if not self.env.context.get('fp_allow_name_rename'):
for f in self.FP_IMMUTABLE_FIELDS:
if f in vals:
for rec in self:
current = rec[f]
if current and current != vals[f]:
raise UserError(_(
'Field "%(field)s" on %(model)s "%(name)s" '
'is immutable. Once issued, it cannot be '
'changed - this preserves the compliance '
'audit trail. (Attempted: %(old)r -> %(new)r)'
) % {
'field': f, 'model': self._description,
'name': rec.display_name,
'old': current, 'new': vals[f],
})
return super().write(vals)
# ------------------------------------------------------------------
# Unlink block: issued documents can't be hard-deleted.
# Cancellation must go through the state machine so the audit trail
# keeps the issued number tied to its cancellation reason. Hard
# delete would leave a phantom gap in the counter. Applies to ALL
# users including admins — no group bypass.
# ------------------------------------------------------------------
def unlink(self):
for rec in self:
# Records still in their initial 'New' state (no number
# ever issued) are fine to delete — they're not yet in
# the audit trail. Once x_fc_doc_index is non-zero OR
# name is something other than 'New' / '/', the record
# has been issued and is permanent.
issued = rec.x_fc_doc_index or (
rec.name and rec.name not in (False, '', 'New', '/')
)
if issued:
raise UserError(_(
'Document "%(name)s" cannot be deleted - it is '
'part of the compliance audit trail. Cancel it '
'instead (use the state machine\'s Cancel action). '
'This rule applies to all users including '
'administrators.'
) % {'name': rec.display_name})
return super().unlink()

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.5.5.0',
'version': '19.0.5.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -20,7 +20,7 @@ class FpCertificate(models.Model):
"""
_name = 'fp.certificate'
_description = 'Fusion Plating — Certificate'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'issue_date desc, id desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
@@ -271,14 +271,22 @@ class FpCertificate(models.Model):
rec.trend_alert = alert
rec.trend_explanation = explanation
# ----- Sequence + spec-limit auto-fill ---------------------------------
# ----- Parent-numbered mixin hooks -------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'CoC'
def _fp_parent_counter_field(self):
return 'x_fc_pn_cert_count'
# ----- Create: parent-derived name (fallback to legacy sequence) -------
@api.model_create_multi
def create(self, vals_list):
SaleOrder = self.env['sale.order']
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
# Pull thickness spec limits from coating config if not set
# Spec-limit auto-fill (existing behaviour, preserved).
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
if not already_set and vals.get('sale_order_id'):
so = SaleOrder.browse(vals['sale_order_id'])
@@ -286,7 +294,23 @@ class FpCertificate(models.Model):
if cfg and cfg.thickness_uom == 'mils':
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0)
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0)
return super().create(vals_list)
# Defer naming: let the record exist so the mixin can write
# name via raw SQL, then fall back to the legacy sequence if
# no parent SO is reachable.
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
self.env.cr.execute(
"UPDATE fp_certificate SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# ----- State actions ----------------------------------------------------
def action_issue(self):

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.18.10.3',
'version': '19.0.18.10.4',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -124,6 +124,15 @@ def _clone_subtree(env, source, part, parent):
new_node = Node.create(vals)
# Copy operator-input prompts (temperature reading, visual inspection,
# etc.) onto the cloned node. Without this, "Load Template" copies the
# step structure but loses every custom prompt the recipe author set up
# — operators end up with empty data-capture screens. .copy() handles
# every field on the input model (kind, target_min/max/unit,
# compliance_tag, sequence, hint, …) and rebinds node_id via override.
for src_input in source.input_ids:
src_input.copy({'node_id': new_node.id})
# Recurse into children in deterministic sequence order.
for child in source.child_ids.sorted('sequence'):
_clone_subtree(env, child, part, new_node)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.21.5',
'version': '19.0.8.27.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -88,16 +88,30 @@ class FpRecordInputsController(http.Controller):
if node and node.description:
instructions_html = node.description
# Recipe root id — surfaced so the dialog's "Edit Recipe" shortcut
# opens the Simple Editor on the EXACT recipe variant this job is
# reading from. Avoids the trap where the operator edits a sibling
# variant (e.g. the template, while the job runs the part-specific
# clone) and wonders why their min/max never appears.
recipe_root_id = False
if node:
root = node
while root.parent_id:
root = root.parent_id
recipe_root_id = root.id
return {
'ok': True,
'step': {
'id': step.id,
'name': step.name,
'recipe_node_id': node.id if node else False,
},
'job': {
'id': step.job_id.id,
'name': step.job_id.name,
},
'recipe_root_id': recipe_root_id,
'prompts': prompts,
'user_initials': user_initials or '',
'instructions_html': instructions_html or '',

View File

@@ -1,24 +1,125 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# When an invoice is posted, find the linked fp.job (via origin) and
# update the portal job state to 'complete' + stamp invoice_ref.
"""account.move overrides for Fusion Plating:
1. Block direct creation of out_invoice / out_refund for ALL users
including administrators. The only legal entry points are:
* sale.order._create_invoices() — sets context fp_from_so_invoice=True
* manual create() with invoice_origin matching an existing sale.order.name
2. Once a customer move is created via a legitimate path, derive its
name from the SO's parent number (IN-30000 / IN-30000-02 for
invoices, CN-30000 / CN-30000-02 for credit notes). Per the
2026-05-12 parent-number hierarchy design.
3. On post, link the invoice back to its fp.job's portal job (mark
complete, stamp invoice_ref). Pre-existing behaviour, preserved.
"""
import logging
from odoo import models
from odoo import api, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
CUSTOMER_TYPES = ('out_invoice', 'out_refund', 'out_receipt')
class AccountMove(models.Model):
_inherit = 'account.move'
_inherit = ['account.move', 'fp.parent.numbered.mixin']
# =================================================================
# Parent-numbered mixin hooks
# =================================================================
def _fp_parent_sale_order(self):
"""Find linked SO via SO context flag (set by _create_invoices),
or fall back to invoice_origin name match, then to the reversed
entry's SO (for the Add Credit Note path where invoice_origin
has copy=False and doesn't survive the move.copy())."""
so_id = self.env.context.get('fp_invoice_source_so_id')
if so_id:
so = self.env['sale.order'].browse(so_id).exists()
if so:
return so
if self.invoice_origin:
so = self.env['sale.order'].search(
[('name', '=', self.invoice_origin)], limit=1,
)
if so:
return so
# Reversal path: read the parent move's SO link so the credit
# note's name flows from the same parent number as the invoice
# it's reversing.
if self.reversed_entry_id:
parent_so = self.reversed_entry_id._fp_parent_sale_order()
if parent_so:
return parent_so
return self.env['sale.order']
def _fp_name_prefix(self):
return 'CN' if self.move_type == 'out_refund' else 'IN'
def _fp_parent_counter_field(self):
return 'x_fc_pn_cn_count' if self.move_type == 'out_refund' else 'x_fc_pn_invoice_count'
# =================================================================
# Create override: block off-flow + assign parent-derived name
# =================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._fp_validate_customer_invoice(vals)
moves = super().create(vals_list)
for mv in moves:
if mv.move_type in CUSTOMER_TYPES:
mv._fp_assign_parent_name()
return moves
@api.model
def _fp_validate_customer_invoice(self, vals):
"""Refuse out_invoice / out_refund / out_receipt creation that
didn't come through the SO workflow. Applies to ALL users
including admins."""
mtype = vals.get('move_type', 'entry')
if mtype not in CUSTOMER_TYPES:
return
if self.env.context.get('fp_from_so_invoice'):
return
origin = (vals.get('invoice_origin') or '').strip()
if origin and self.env['sale.order'].sudo().search_count(
[('name', '=', origin)]
):
return
# Credit-note / reversal path: Odoo's "Add Credit Note" wizard
# calls move.copy() with reversed_entry_id set in the defaults,
# but invoice_origin has copy=False on the standard field so
# it doesn't survive the copy. Allow reversals through as long
# as the reversed entry is itself a customer-facing move (which
# means it already went through this validator at original
# creation time — the audit trail is intact).
reversed_id = vals.get('reversed_entry_id')
if reversed_id:
parent = self.env['account.move'].sudo().browse(reversed_id)
if parent.exists() and parent.move_type in CUSTOMER_TYPES:
return
raise UserError(_(
'Customer invoices, credit notes, and receipts must be '
'created from a Sale Order. Open the originating SO and '
'use the Create Invoice / Add Credit Note action.\n\n'
'This rule applies to all users including administrators. '
'It is enforced to keep the parent-number audit trail '
'intact (see fusion_plating numbering policy).'
))
# =================================================================
# Post hook: link the invoice to its fp.job's portal job
# =================================================================
def action_post(self):
result = super().action_post()
for invoice in self.filtered(
lambda m: m.move_type in ('out_invoice', 'out_refund')
lambda m: m.move_type in CUSTOMER_TYPES
):
invoice._fp_link_to_job()
return result
@@ -28,7 +129,6 @@ class AccountMove(models.Model):
if not self.invoice_origin:
return
Job = self.env['fp.job'].sudo()
# Walk SO -> fp.job
SO = self.env['sale.order'].sudo()
so = SO.search([('name', '=', self.invoice_origin)], limit=1)
if not so:

View File

@@ -10,7 +10,15 @@ from odoo import api, models
class ReportFpJobMargin(models.AbstractModel):
_name = 'report.fusion_plating_jobs.report_fp_job_margin'
# Odoo looks up the report's data model via report.<report_name>.
# The action's report_name is `fusion_plating_jobs.report_fp_job_margin_template`,
# so this MUST be `report.fusion_plating_jobs.report_fp_job_margin_template`.
# Pre-2026-05-12 the model name was missing the `_template` suffix,
# which silently caused _get_report_values to never fire and the
# template rendered with no `rows` -> blank PDF. The t-field error
# was masking this because it crashed earlier; once t-field was
# swapped to t-esc the blank-render surfaced.
_name = 'report.fusion_plating_jobs.report_fp_job_margin_template'
_description = 'Work Order Margin Report'
@api.model

View File

@@ -17,6 +17,15 @@ class ResUsers(models.Model):
'a different value and saves, it persists here for every '
'future job and step.',
)
x_fc_signature_image = fields.Binary(
string='Plating Signature',
attachment=True,
help='Drawn or uploaded signature image. Used in WO detail and '
'certificate reports for any signature-type prompt this user '
'signed off on; falls back to typed initials when blank. '
'Capture it once in user preferences; it stamps every '
'future sign-off automatically.',
)
@api.model
def _fp_default_initials(self):

View File

@@ -12,6 +12,7 @@ import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@@ -32,6 +33,47 @@ class SaleOrder(models.Model):
'to drill through the linked Plating Job first.',
)
# ------------------------------------------------------------------
# Parent-number hierarchy (2026-05-12 design)
# See docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md
# ------------------------------------------------------------------
x_fc_parent_number = fields.Integer(
string='Parent Number',
readonly=True,
copy=False,
index=True,
help='Set on confirm. Drives every linked document\'s name '
'(WO-NNN, IN-NNN, CoC-NNN, ...). Immutable post-assignment.',
)
x_fc_quote_ref = fields.Char(
string='Originally Quoted As',
readonly=True,
copy=False,
help='The quote-stage name (e.g. Q202605-200). Preserved when '
'the SO is renamed on confirm.',
)
# Per-model counters — monotonic, never decrement. Source of truth
# for the next sibling's x_fc_doc_index. Updated via row-locked SQL
# in fp.parent.numbered.mixin so concurrent creates can't drift.
#
# Naming: `x_fc_pn_*_count` — the `pn_` infix distinguishes our
# storage counters from pre-existing compute fields (e.g. the
# `x_fc_delivery_count` compute in bridge_mrp, `x_fc_ncr_count`
# in configurator, `x_fc_receiving_count` in fp_receiving) which
# are surface counters for smart buttons. Distinct names avoid
# the silent compute-override that made Tasks 3+9 fail until 9.5.
x_fc_pn_wo_count = fields.Integer(string='Parent: WO Count', readonly=True, copy=False, default=0)
x_fc_pn_invoice_count = fields.Integer(string='Parent: Invoice Count', readonly=True, copy=False, default=0)
x_fc_pn_cn_count = fields.Integer(string='Parent: Credit Note Count', readonly=True, copy=False, default=0)
x_fc_pn_cert_count = fields.Integer(string='Parent: Certificate Count', readonly=True, copy=False, default=0)
x_fc_pn_delivery_count = fields.Integer(string='Parent: Delivery Count', readonly=True, copy=False, default=0)
x_fc_pn_receiving_count = fields.Integer(string='Parent: Receiving Count', readonly=True, copy=False, default=0)
x_fc_pn_pickup_count = fields.Integer(string='Parent: Pickup Count', readonly=True, copy=False, default=0)
x_fc_pn_ncr_count = fields.Integer(string='Parent: NCR Count', readonly=True, copy=False, default=0)
x_fc_pn_capa_count = fields.Integer(string='Parent: CAPA Count', readonly=True, copy=False, default=0)
x_fc_pn_hold_count = fields.Integer(string='Parent: Hold Count', readonly=True, copy=False, default=0)
x_fc_pn_rma_count = fields.Integer(string='Parent: RMA Count', readonly=True, copy=False, default=0)
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
# relocated from fusion_plating_bridge_mrp. Field re-declared with
@@ -186,7 +228,60 @@ class SaleOrder(models.Model):
action.update({'view_mode': 'form', 'res_id': certs.id})
return action
# ------------------------------------------------------------------
# Parent-number hierarchy — quote naming on create
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
"""Draw Q-YYYYMM-N from fp.quote.number when no explicit name.
The drawn name is also stashed in x_fc_quote_ref so it survives
the confirm-time rename to SO-<parent_number>. If the caller
passed an explicit name we preserve that AND mirror it into
x_fc_quote_ref (covers data migration, restore, etc.).
"""
Seq = self.env['ir.sequence']
for vals in vals_list:
existing = vals.get('name')
if not existing or existing == _('New') or existing == 'New':
quote_name = Seq.next_by_code('fp.quote.number')
if quote_name:
vals['name'] = quote_name
vals.setdefault('x_fc_quote_ref', quote_name)
elif not vals.get('x_fc_quote_ref'):
vals['x_fc_quote_ref'] = existing
return super().create(vals_list)
def action_confirm(self):
"""Assign parent number + rename Q-…-N to SO-<parent>, then run
the standard confirm (which kicks off WO creation).
Parent number is drawn from fp.parent.number; the quote name
was already saved to x_fc_quote_ref on create() so it survives
the rename. Idempotent — if x_fc_parent_number is already set,
the rename is skipped (re-confirm scenarios)."""
Seq = self.env['ir.sequence']
for so in self:
if so.x_fc_parent_number:
continue
parent = Seq.next_by_code('fp.parent.number')
if not parent:
raise UserError(_(
'Sequence fp.parent.number is missing. Reinstall '
'fusion_plating to restore it.'
))
parent_int = int(parent)
old_name = so.name
# fp_allow_name_rename whitelists this single legitimate
# rename path through the immutability write() guard
# (added in Task 11).
so.with_context(fp_allow_name_rename=True).write({
'name': f'SO-{parent_int}',
'x_fc_parent_number': parent_int,
})
so.message_post(body=Markup(_(
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
)) % (old_name, so.name))
result = super().action_confirm()
for so in self:
so._fp_auto_create_job()
@@ -208,12 +303,83 @@ class SaleOrder(models.Model):
) % {'job': job.name, 'err': exc})
return result
def _create_invoices(self, grouped=False, final=False, date=None):
"""Set fp_from_so_invoice=True so account.move.create() allows
the customer-invoice creation (the direct-creation block is
bypassed via this context flag). Also lets the parent-numbered
mixin find the originating SO without depending on invoice_origin.
"""
return super(SaleOrder, self.with_context(
fp_from_so_invoice=True,
fp_invoice_source_so_id=self.id if len(self) == 1 else False,
))._create_invoices(grouped=grouped, final=final, date=date)
def unlink(self):
"""Spec §6.2 — confirmed SOs are part of the compliance audit
trail and cannot be deleted. Cancellation must go through the
state machine instead. Draft SOs (no parent_number assigned
yet) remain freely deletable per Odoo standard. Applies to
all users including administrators."""
for so in self:
if so.x_fc_parent_number:
raise UserError(_(
'Sale Order "%(name)s" cannot be deleted — it has '
'been confirmed (parent number %(parent)s issued) '
'and is part of the compliance audit trail. Cancel '
'it instead. This rule applies to all users '
'including administrators.'
) % {'name': so.display_name, 'parent': so.x_fc_parent_number})
return super().unlink()
def _fp_resolve_recipe_for_line(self, line):
"""4-tier recipe resolution. Used BOTH for grouping (Task 6
recipe-driven WO splits) AND for the per-job vals construction.
Priority (most-specific first):
1. line.x_fc_process_variant_id — Sarah explicitly picked a
part-scoped variant on this order line. Always wins.
2. part.default_process_id — part's flagged default
variant. Customer-and-part-tuned recipe; must beat any
generic coating template.
3. coating.recipe_id — coating-config recipe
(generic template fallback).
4. part.recipe_id — legacy fallback.
Returns the recipe record or an empty recordset.
"""
Node = self.env['fusion.plating.process.node']
part = (
'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id
) or False
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
coating = (
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
) or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
picked = (
'x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id
) or False
if picked:
return picked
if part and 'default_process_id' in part._fields and part.default_process_id:
return part.default_process_id
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
return coating.recipe_id
if part and 'recipe_id' in part._fields and part.recipe_id:
return part.recipe_id
return Node
def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines.
Lines that share a `x_fc_wo_group_tag` collapse into one job;
untagged lines get one job per line. Mirrors bridge_mrp's
_fp_auto_create_mo grouping logic.
2026-05-12 parent-number rewrite: lines are grouped by resolved
recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
WO-<parent> (bare). If N>1 groups → N WOs named WO-<parent>-01,
WO-<parent>-02, ..., ordered by min line sequence so suffixes
mirror SO display order. WO names are then immutable; later
manual additions to the SO get the next index via the mixin.
"""
self.ensure_one()
Job = self.env['fp.job'].sudo()
@@ -246,20 +412,52 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by x_fc_wo_group_tag (untagged → distinct group per line)
groups = {} # tag → recordset of lines
untagged_idx = 0
# Group by (recipe, part, coating, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Same compliance reasoning
# as part_id + coating_id: bundling lines with different thicknesses
# or different serials under one WO would carry the first line's
# values onto the cert + sticker — silent mis-attestation. Sub 5
# added thickness_id + serial_id; this extends the grouping logic
# to honour them. No-recipe lines still get their own group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
tag = (
'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag
recipe = self._fp_resolve_recipe_for_line(line)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
if not tag:
untagged_idx += 1
tag = '__untagged_%d' % untagged_idx
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line
coating_id = (
'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id
) or False
thickness_id = (
'x_fc_thickness_id' in line._fields
and line.x_fc_thickness_id.id
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
# Order groups by min line sequence so dash-suffixes mirror SO
# display order. Deterministic regardless of dict iteration order.
ordered_keys = sorted(
groups.keys(),
key=lambda k: min(groups[k].mapped('sequence') or [0]),
)
n_groups = len(ordered_keys)
parent = self.x_fc_parent_number # set by action_confirm earlier
# Create a job per group
for tag, lines in groups.items():
for idx, key in enumerate(ordered_keys, start=1):
lines = groups[key]
first_line = lines[0]
qty = sum(lines.mapped('product_uom_qty'))
part = (
@@ -272,39 +470,11 @@ class SaleOrder(models.Model):
and first_line.x_fc_coating_config_id
or False
)
# Header fallback for legacy/configurator SOs that put part +
# coating on the SO header instead of the line.
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
# Recipe lookup priority:
# 1. line.x_fc_process_variant_id — Sarah explicitly picked
# a part-scoped variant on this order line. Always wins.
# 2. coating.recipe_id — coating-config recipe.
# 3. part.default_process_id — part's flagged default.
# 4. part.recipe_id — legacy fallback.
#
# If multiple lines in the same WO group have different
# variants we use the FIRST line's variant (consistent with
# everything else in this loop using `first_line`).
recipe = False
picked_variant = (
'x_fc_process_variant_id' in first_line._fields
and first_line.x_fc_process_variant_id
or False
)
if picked_variant:
recipe = picked_variant
if not recipe and coating and 'recipe_id' in coating._fields \
and coating.recipe_id:
recipe = coating.recipe_id
if not recipe and part and 'default_process_id' in part._fields \
and part.default_process_id:
recipe = part.default_process_id
if not recipe and part and 'recipe_id' in part._fields \
and part.recipe_id:
recipe = part.recipe_id
recipe = self._fp_resolve_recipe_for_line(first_line)
vals = {
'partner_id': self.partner_id.id,
@@ -359,11 +529,32 @@ class SaleOrder(models.Model):
# Quoted revenue: sum line totals
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
# Parent-number naming (2026-05-12). Bare for the single-group
# case; zero-padded -NN suffix when multiple recipes split the
# SO into multiple WOs. Set explicitly so fp.job.create() skips
# its own naming fallback.
if parent:
if n_groups == 1:
vals['name'] = f'WO-{parent}'
vals['x_fc_doc_index'] = 1
else:
vals['name'] = f'WO-{parent}-{idx:02d}' if idx <= 99 else f'WO-{parent}-{idx}'
vals['x_fc_doc_index'] = idx
job = Job.create(vals)
_logger.info(
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
self.name, job.name, qty, (recipe.name if recipe else '-'),
)
# Bump SO counter to reflect the bulk creation. Future manual
# WO additions pick up from here via the mixin standard path.
if parent and n_groups:
self.env.cr.execute(
"UPDATE sale_order SET x_fc_pn_wo_count = %s WHERE id = %s",
(n_groups, self.id),
)
self.invalidate_recordset(['x_fc_pn_wo_count'])
return True
# ------------------------------------------------------------------

View File

@@ -45,8 +45,8 @@
<td><span t-esc="step['work_centre']"/></td>
<td class="text-end"><span t-esc="step['duration_expected']"/></td>
<td class="text-end"><span t-esc="step['duration_actual']"/></td>
<td class="text-end"><span t-field="step['rate']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
<td class="text-end"><span t-field="step['cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
<td class="text-end"><span t-esc="step['rate']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
<td class="text-end"><span t-esc="step['cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
</tr>
</t>
<tr style="font-weight: bold; background: #f3f3f3;">
@@ -54,16 +54,16 @@
<td></td>
<td class="text-end"><span t-esc="row['total_minutes']"/></td>
<td></td>
<td class="text-end"><span t-field="row['total_labour']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
<td class="text-end"><span t-esc="row['total_labour']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
</tr>
</tbody>
</table>
<h3 style="margin-top: 1.5em;">Margin Summary</h3>
<table class="table table-sm" style="max-width: 400px;">
<tr><th>Quoted Revenue</th><td class="text-end"><span t-field="row['quoted_revenue']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr><th>Actual Cost</th><td class="text-end"><span t-field="row['actual_cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr style="font-weight: bold;"><th>Margin</th><td class="text-end"><span t-field="row['margin']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr><th>Quoted Revenue</th><td class="text-end"><span t-esc="row['quoted_revenue']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr><th>Actual Cost</th><td class="text-end"><span t-esc="row['actual_cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr style="font-weight: bold;"><th>Margin</th><td class="text-end"><span t-esc="row['margin']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr><th>Margin %</th><td class="text-end"><span t-esc="round(row['margin_pct'], 1)"/>%</td></tr>
</table>
</div>

View File

@@ -29,12 +29,12 @@
</record>
<record id="action_report_fp_job_sticker" model="ir.actions.report">
<field name="name">Job Sticker</field>
<field name="name">External Job Sticker</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_template</field>
<field name="print_report_name">'Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="print_report_name">'External Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
@@ -60,6 +60,7 @@
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<!-- The fp.job's own name (WH/JOB/00033) is already
printed in the header as "WO #...", so suppress
@@ -70,4 +71,48 @@
</t>
</template>
<!-- Internal Job sticker — same fields as External, but the Notes
column reads x_fc_internal_description from the first linked
SO line (Sub 5 thickness+serial grouping means same-x_fc lines
share a job, so first-line is representative). -->
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
<field name="name">Internal Job Sticker</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
<template id="report_fp_job_sticker_internal_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description from
the first linked SO line. -->
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1]
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</template>
</odoo>

View File

@@ -74,6 +74,35 @@
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
or ''"/>
<!-- Customer-facing WO id: strip the sequence prefix
("WH/JOB/01373" → "01373"). Keeps the column / cert
reference compact; full job.name is still used
internally and on the print_report_name. -->
<!-- WO display: strip the model prefix so the Work
Order column shows "30000" or "30000-02" instead
of "WO-30000". Handles both naming schemes:
- new "WO-NNNNN[-NN]" (post 2026-05-12 numbering)
- legacy "WH/JOB/NNNNN" (pre-2026-05-12 jobs) -->
<t t-set="short_wo" t-value="(
job.name and job.name.startswith('WO-') and job.name[3:]
or (job.name or '').split('/')[-1]
)"/>
<!-- Photo evidence — collect every captured-input value
that has an attachment, in step / time order. We
number them globally (Photo 1..N) and use those
numbers both in the per-step measurement tables
(so the customer can see at a glance "this prompt
has a photo, see #3 below") and as the gallery
titles at the end of the report. The dict carries
the cv record, its 1-based index, and pre-computed
caption fields so the gallery loop stays clean. -->
<t t-set="all_photo_values"
t-value="job.move_ids
.sorted('move_datetime')
.mapped('transition_input_value_ids')
.filtered(lambda v: v.value_attachment_id)"/>
<t t-set="photo_index_by_id" t-value="{cv.id: idx + 1 for idx, cv in enumerate(all_photo_values)}"/>
<!-- Walk EVERY step in sequence, not just moves. The
old report only rendered moves so steps without
recorded measurements (just Finish & Next) never
@@ -99,6 +128,52 @@
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 10px 0 6px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 14px; }
.fp-wo-detail .fp-prepared { margin-bottom: 14px; }
/* Photo gallery — bordered tile per attachment.
flex-wrap so wkhtmltopdf lays out two per row
on A4 portrait; page-break-inside on the tile
keeps captions glued to their image. */
.fp-wo-detail .fp-photo-section { margin-top: 18px; }
.fp-wo-detail .fp-photo-section h2 {
font-size: 13pt; font-weight: bold; color: #1a4d80;
margin: 0 0 8px 0; border-bottom: 2px solid #1a4d80;
padding-bottom: 3px;
}
.fp-wo-detail .fp-photo-grid {
display: flex; flex-wrap: wrap; gap: 8px;
}
.fp-wo-detail .fp-photo-tile {
border: 1px solid #000; padding: 6px;
width: 86mm; box-sizing: border-box;
page-break-inside: avoid; background: #fff;
}
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap {
width: 100%; height: 70mm;
display: flex; align-items: center; justify-content: center;
background: #f5f5f5; border: 1px solid #d0d0d0;
overflow: hidden; margin-bottom: 4px;
}
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap img {
max-width: 100%; max-height: 100%; object-fit: contain;
}
.fp-wo-detail .fp-photo-title {
font-size: 9pt; font-weight: bold; margin: 2px 0;
}
.fp-wo-detail .fp-photo-desc {
font-size: 8pt; color: #444; line-height: 1.25;
}
.fp-wo-detail .fp-photo-ref {
font-size: 8pt; color: #1a4d80; font-style: italic;
white-space: nowrap;
}
/* Inline signature image inside the step
measurement Value cell — rendered when a
`signature` prompt has a recorder with a
Plating Signature on file. Sized to fit the
table row without blowing it up. */
.fp-wo-detail img.fp-sig-inline {
max-height: 14mm; max-width: 50mm;
vertical-align: middle;
}
</style>
<h1>Work Order Detail</h1>
@@ -152,7 +227,7 @@
<span t-esc="job.qty"/>
</td>
<td class="text-center">
<span t-esc="job.name"/>
<span t-esc="short_wo"/>
</td>
<td>
<span t-esc="po_number or '—'"/>
@@ -272,6 +347,17 @@
<t t-set="actual_str"
t-value="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
</t>
<!-- Signature-type prompts: show the
recorder's Plating Signature image in
the Value cell when available, with
typed initials as caption beneath.
Falls back to plain initials when the
user hasn't uploaded a signature yet. -->
<t t-set="is_sig_prompt"
t-value="inp and 'input_type' in inp._fields and inp.input_type == 'signature'"/>
<t t-set="sig_recorder" t-value="cv.move_id.moved_by_user_id"/>
<t t-set="sig_img"
t-value="(is_sig_prompt and sig_recorder and 'x_fc_signature_image' in sig_recorder._fields and sig_recorder.x_fc_signature_image) or False"/>
<tr>
<td><span t-esc="prompt_name"/></td>
<td>
@@ -280,7 +366,30 @@
</t>
</td>
<td>
<strong t-esc="actual_str"/>
<t t-if="sig_img">
<img class="fp-sig-inline"
t-att-src="'data:image/png;base64,%s' % sig_img.decode()"
t-att-alt="actual_str"/>
<t t-if="actual_str">
<br/>
<span style="font-size: 7.5pt; color: #555;" t-esc="actual_str"/>
</t>
</t>
<t t-else="">
<strong t-esc="actual_str"/>
</t>
<!-- Photo cross-reference. Operators
attached a photo for this prompt;
point the reader to the gallery
at the end of the doc. -->
<t t-if="cv.value_attachment_id">
<t t-set="_pidx" t-value="photo_index_by_id.get(cv.id)"/>
<br t-if="actual_str or sig_img"/>
<span class="fp-photo-ref">
<i class="fa fa-camera"/>
See Photo #<span t-esc="_pidx"/>
</span>
</t>
</td>
<td>
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
@@ -301,6 +410,68 @@
</p>
</t>
<!-- ===== PHOTO EVIDENCE GALLERY ===== -->
<!-- Renders every photo-type captured value as a
bordered tile with title (prompt + step) and
description (operator + timestamp + any
text caption they typed alongside the photo).
Numbered to match the "See Photo #N" inline
references above. Forced to its own page so
the tiles don't get split mid-step. -->
<t t-if="all_photo_values">
<div style="page-break-before: always;"/>
<div style="height: 6mm;"/>
<div class="fp-photo-section">
<h2>Photo Evidence (<span t-esc="len(all_photo_values)"/>)</h2>
<div class="fp-photo-grid">
<t t-foreach="all_photo_values" t-as="pv">
<t t-set="pidx" t-value="photo_index_by_id.get(pv.id)"/>
<t t-set="att" t-value="pv.value_attachment_id"/>
<t t-set="ptitle"
t-value="(pv.node_input_id and pv.node_input_id.name) or (pv.value_text and pv.value_text.split(':')[0]) or att.name or 'Photo'"/>
<t t-set="pstep"
t-value="(pv.move_id and ((pv.move_id.to_step_id and pv.move_id.to_step_id.name) or (pv.move_id.from_step_id and pv.move_id.from_step_id.name))) or ''"/>
<t t-set="puser"
t-value="(pv.move_id and pv.move_id.moved_by_user_id and pv.move_id.moved_by_user_id.name) or ''"/>
<t t-set="pdt"
t-value="pv.move_id and pv.move_id.move_datetime"/>
<!-- Caption: strip the leading "Prompt:"
prefix that ad-hoc rows store so we
don't print the prompt name twice. -->
<t t-set="pcaption" t-value="pv.value_text or ''"/>
<t t-if="pv.node_input_id and pv.node_input_id.name and pcaption.startswith(pv.node_input_id.name + ':')">
<t t-set="pcaption" t-value="pcaption[len(pv.node_input_id.name)+1:].strip()"/>
</t>
<div class="fp-photo-tile">
<div class="fp-photo-imgwrap">
<img t-att-src="'/web/image/%s' % att.id"
t-att-alt="att.name"/>
</div>
<div class="fp-photo-title">
Photo #<span t-esc="pidx"/><span t-esc="ptitle"/>
</div>
<div class="fp-photo-desc">
<t t-if="pstep">
<strong>Step:</strong> <span t-esc="pstep"/><br/>
</t>
<t t-if="puser or pdt">
<strong>Captured by:</strong>
<span t-esc="puser or '—'"/>
<t t-if="pdt">
on <span t-esc="job.fp_format_local(pdt, '%b %d, %Y %I:%M %p')"/>
</t>
<br/>
</t>
<t t-if="pcaption">
<strong>Note:</strong> <span t-esc="pcaption"/>
</t>
</div>
</div>
</t>
</div>
</div>
</t>
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
<!-- page-break-before is honoured by wkhtmltopdf
but the new page starts flush against the
@@ -310,21 +481,35 @@
<div style="page-break-before: always;"/>
<div style="height: 8mm;"/>
<t t-set="owner_sig" t-value="False"/>
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
<!-- Certifier = the job's plating manager. Pulls
their Plating Signature (`x_fc_signature_image`)
from Preferences → My Profile. Falls back to
the company owner's signature, then to the
settings override only if no user has one. -->
<t t-set="certifier_user" t-value="job.manager_id or (('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id) or False)"/>
<t t-set="signature_img" t-value="False"/>
<t t-if="certifier_user and 'x_fc_signature_image' in certifier_user._fields and certifier_user.x_fc_signature_image">
<t t-set="signature_img" t-value="certifier_user.x_fc_signature_image"/>
</t>
<t t-set="sig_override" t-value="('x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override) or False"/>
<t t-set="signature_img" t-value="sig_override or owner_sig"/>
<t t-set="signer_name" t-value="(job.manager_id and job.manager_id.name) or ('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
<!-- Final fallback: company-level override for sites
whose certifier hasn't uploaded their signature yet. -->
<t t-if="not signature_img and 'x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override">
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override"/>
</t>
<t t-set="signer_name" t-value="(certifier_user and certifier_user.name) or ''"/>
<t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/>
<t t-set="_co_stmt" t-value="('x_fc_default_cert_statement' in company._fields and company.x_fc_default_cert_statement) or False"/>
<t t-set="cert_statement" t-value="_cust_stmt or _co_stmt or 'We certify that the parts listed above have been processed in accordance with the specifications referenced and that all required tests have been performed. Records on file at our facility per AS9100 / ISO 9001 retention policy.'"/>
<!-- External note auto-fills the Other Comments box so
anything the manager typed on the job ("subbed
out for fluoride dip", "customer pickup at 4pm")
prints on the customer-facing cert. Manager can
still scribble on the printed copy if nothing
was typed. -->
<t t-set="other_comments" t-value="('x_fc_external_note' in job._fields and job.x_fc_external_note) or ''"/>
<table class="bordered">
<tr>
<td style="width: 50%; vertical-align: top; height: 40mm;">
@@ -338,7 +523,7 @@
<td style="width: 50%; vertical-align: top;">
<strong>Certification Statement:</strong>
<span style="font-size: 8.5pt;">
Ref. WO# <span t-esc="job.name"/>
Ref. WO# <span t-esc="short_wo"/>
</span>
<p style="font-size: 8pt; margin-top: 4px; white-space: pre-wrap;"
t-esc="cert_statement"/>
@@ -347,6 +532,9 @@
<tr>
<td colspan="2" style="height: 25mm;">
<strong>Other Comments:</strong>
<p t-if="other_comments"
style="font-size: 9pt; margin-top: 4px; white-space: pre-wrap;"
t-esc="other_comments"/>
</td>
</tr>
</table>

View File

@@ -0,0 +1,136 @@
"""End-to-end numbering walkthrough for the 2026-05-12 parent-number
hierarchy. Quote -> confirm -> 2 invoices (partial billing) -> CoC ->
delivery -> receiving -> NCR (legacy fallback) -> Hold (parent-derived)
-> immutability check -> unlink block check -> direct invoice block.
Asserts every SO-linked doc shares the same parent number. Re-runnable;
rolls back at the end so no DB state is left behind.
Run via odoo-shell:
exec(open('/path/to/numbering_e2e_walkthrough.py').read())
"""
from odoo.exceptions import UserError
SO = env['sale.order']
AM = env['account.move']
journal = env['account.journal'].search([('type', '=', 'sale')], limit=1)
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=2)
assert len(parts) >= 2, 'need at least 2 parts with default recipes'
partner = env['res.partner'].search([], limit=1)
facility = env['fusion.plating.facility'].search([], limit=1)
product = env['product.product'].search([('type', '!=', 'service')], limit=1) or env['product.product'].search([], limit=1)
print('=' * 60)
print('Numbering hierarchy E2E walkthrough')
print('=' * 60)
# === A: Quote -> confirm ===
so = SO.create({
'partner_id': partner.id,
'x_fc_po_override': True,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 5,
'x_fc_part_catalog_id': parts[0].id, 'sequence': 10}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': parts[1].id, 'sequence': 20}),
],
})
quote_name = so.name
print(f'A. Quote: {quote_name}')
assert quote_name.startswith('Q'), f'expected Q-prefix, got {quote_name}'
so.action_confirm()
parent = so.x_fc_parent_number
print(f'A. Confirmed: {so.name} (parent={parent}, quote_ref={so.x_fc_quote_ref})')
assert so.name == f'SO-{parent}'
assert so.x_fc_quote_ref == quote_name
# === B: WOs (2 recipes split SO into -01, -02) ===
jobs = env['fp.job'].search([('sale_order_id', '=', so.id)], order='x_fc_doc_index')
print(f'B. WOs: {jobs.mapped("name")}')
assert len(jobs) == 2
assert jobs[0].name == f'WO-{parent}-01'
assert jobs[1].name == f'WO-{parent}-02'
# === C: Two invoices (partial billing) ===
inv1 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({
'move_type': 'out_invoice', 'partner_id': partner.id,
'journal_id': journal.id, 'invoice_origin': so.name,
})
inv2 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({
'move_type': 'out_invoice', 'partner_id': partner.id,
'journal_id': journal.id, 'invoice_origin': so.name,
})
print(f'C. Invoices: {inv1.name}, {inv2.name}')
assert inv1.name == f'IN-{parent}'
assert inv2.name == f'IN-{parent}-02'
# === D: CoC ===
coc = env['fp.certificate'].create({'sale_order_id': so.id, 'partner_id': partner.id})
print(f'D. CoC: {coc.name}')
assert coc.name == f'CoC-{parent}'
# === E: Delivery (linked via job_ref) ===
dlv = env['fusion.plating.delivery'].create({'partner_id': partner.id, 'job_ref': jobs[0].name})
print(f'E. Delivery: {dlv.name}')
assert dlv.name == f'DLV-{parent}'
# === F: Receiving (already auto-created at confirm; manual is -02) ===
existing_rcv = env['fp.receiving'].search([('sale_order_id', '=', so.id)])
print(f'F. Receivings (incl. auto-created): {existing_rcv.mapped("name")}')
assert any(r.name == f'RCV-{parent}' for r in existing_rcv)
# === G: Hold (via job_id) ===
hold = env['fusion.plating.quality.hold'].create({
'job_id': jobs[0].id, 'hold_reason': 'qc_failure', 'qty_on_hold': 1,
'description': 'E2E test hold',
})
print(f'G. Hold: {hold.name}')
assert hold.name == f'HOLD-{parent}'
# === H: RMA (via sale_order_id directly) ===
rma = env['fusion.plating.rma'].create({'sale_order_id': so.id, 'partner_id': partner.id})
print(f'H. RMA: {rma.name}')
assert rma.name == f'RMA-{parent}'
# === I: NCR + CAPA (no SO link in core -> legacy seq) ===
ncr = env['fusion.plating.ncr'].create({
'description': 'E2E test', 'customer_partner_id': partner.id, 'facility_id': facility.id,
})
print(f'I. NCR (no SO link): {ncr.name}')
assert not ncr.name.startswith('NCR-3'), f'expected legacy seq, got {ncr.name}'
capa = env['fusion.plating.capa'].create({
'description': 'E2E test capa', 'ncr_id': ncr.id, 'facility_id': facility.id,
})
print(f'I. CAPA (no SO link): {capa.name}')
assert not capa.name.startswith('CAPA-3'), f'expected legacy seq, got {capa.name}'
# === J: Immutability ===
try:
jobs[0].name = 'HACKED'
print('FAIL J: name mutation succeeded')
except UserError:
print('J. OK: WO name immutable')
# === K: Unlink block ===
try:
coc.unlink()
print('FAIL K: unlink succeeded')
except UserError:
print('K. OK: CoC unlink blocked')
# === L: Direct invoice creation block ===
try:
AM.create({
'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id,
})
print('FAIL L: direct invoice succeeded')
except UserError:
print('L. OK: direct invoice blocked')
print('=' * 60)
print(f'PASS: every doc tied to parent {parent}')
print('=' * 60)
env.cr.rollback()
print('(rolled back — DB unchanged)')

View File

@@ -69,6 +69,7 @@ export class FpRecordInputsDialog extends Component {
saving: false,
stepName: "",
jobName: "",
recipeRootId: false,
rows: [],
// Operator's persisted initials — pre-filled into signature
// / "Reviewer Initials" prompts on load. When the operator
@@ -103,6 +104,7 @@ export class FpRecordInputsDialog extends Component {
}
this.state.stepName = data.step.name;
this.state.jobName = data.job.name;
this.state.recipeRootId = data.recipe_root_id || false;
this.state.userInitials = data.user_initials || "";
this.state.instructionsHtml = data.instructions_html || "";
this.state.instructionImages = data.instruction_images || [];
@@ -193,13 +195,14 @@ export class FpRecordInputsDialog extends Component {
isSelection(row) { return row.input_type === "selection"; }
isPassFail(row) { return row.input_type === "pass_fail"; }
isSignature(row) { return row.input_type === "signature"; }
// Fallback to text for anything else (text, time_hms, ...)
isTimeHms(row) { return row.input_type === "time_hms"; }
// Fallback to text for anything else
isText(row) {
return !this.isNumeric(row) && !this.isBoolean(row)
&& !this.isDate(row) && !this.isPhoto(row)
&& !this.isMulti(row) && !this.isPanel(row)
&& !this.isSelection(row) && !this.isPassFail(row)
&& !this.isSignature(row);
&& !this.isSignature(row) && !this.isTimeHms(row);
}
// Friendly label for the type pill — defaults to the raw key when no
@@ -208,6 +211,60 @@ export class FpRecordInputsDialog extends Component {
return TYPE_LABELS[row.input_type] || row.input_type || "Text";
}
// Step granularity for <input type="number"> — drives the up/down
// arrow increment AND the typed-decimal validity. Defaults of step=1
// make tablet entry painful when the spec is 0.03 0.05 mil because
// every arrow press jumps a full unit. Derive from the recipe-author's
// target_min / target_max precision so operator arrow-taps move in the
// same decimal magnitude the spec was written in. Falls back to
// input-type defaults when no targets are set.
stepFor(row) {
const decimals = Math.max(
this._fpCountDecimals(row.target_min),
this._fpCountDecimals(row.target_max),
);
if (decimals > 0) {
return Math.pow(10, -decimals).toFixed(decimals);
}
const t = row.input_type || "";
if (t === "thickness" || t === "multi_point_thickness") return "0.0001";
if (t === "ph") return "0.01";
if (t === "temperature" || t === "time_seconds") return "1";
return "any";
}
_fpCountDecimals(n) {
if (n === null || n === undefined || n === "" || n === 0) return 0;
const s = String(n);
const idx = s.indexOf(".");
if (idx < 0) return 0;
// Trim trailing zeros so "0.0500" doesn't look like 4-decimals
// when the author actually wrote 2-decimal precision.
return s.slice(idx + 1).replace(/0+$/, "").length;
}
// Jump from the runtime dialog into the Simple Recipe Editor on the
// EXACT recipe variant this job step is bound to. Closes the dialog
// (operator returns by re-opening Record Inputs after editing). The
// intent is to remove the "I edited the recipe but nothing changed"
// confusion — they were editing a sibling variant.
async openSimpleEditor() {
if (!this.state.recipeRootId) {
this.notification.add(
_t("No recipe linked to this step yet."),
{ type: "warning" },
);
return;
}
this.props.close();
await this.action.doAction({
type: "ir.actions.client",
tag: "fp_simple_recipe_editor",
name: _t("Edit Recipe"),
context: { recipe_id: this.state.recipeRootId },
});
}
// True when the recipe author defined BOTH target_min and target_max
// on the prompt — the signal that the operator is expected to capture
// a range (multiple readings → record their min and max observation).

View File

@@ -11,6 +11,12 @@
Job <t t-esc="state.jobName"/>
</span>
</div>
<button t-if="state.recipeRootId"
class="btn btn-link o_fp_ri_edit_recipe"
title="Edit this step's prompts (target ranges, type, options) in the Simple Recipe Editor."
t-on-click="openSimpleEditor">
<i class="fa fa-pencil me-1"/> Edit Recipe
</button>
</div>
</t>
@@ -116,7 +122,7 @@
class="o_fp_ri_numeric">
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_number"
t-att-placeholder="row.target_min or '0.00'"/>
<t t-set="hint" t-value="rangeHint(row)"/>
@@ -136,7 +142,7 @@
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
@@ -144,7 +150,7 @@
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
@@ -167,7 +173,7 @@
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
@@ -175,7 +181,7 @@
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
@@ -301,19 +307,19 @@
<div t-if="isMulti(row)" class="o_fp_ri_multi">
<div class="o_fp_ri_multi_grid">
<label>R1
<input type="number" step="any" t-model.number="row.point_1"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_1"/>
</label>
<label>R2
<input type="number" step="any" t-model.number="row.point_2"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_2"/>
</label>
<label>R3
<input type="number" step="any" t-model.number="row.point_3"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_3"/>
</label>
<label>R4
<input type="number" step="any" t-model.number="row.point_4"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_4"/>
</label>
<label>R5
<input type="number" step="any" t-model.number="row.point_5"/>
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_5"/>
</label>
<div class="o_fp_ri_multi_avg">
<span class="text-muted">Avg</span>
@@ -325,20 +331,28 @@
<!-- Bath chemistry panel — pH / conc / temp / bath -->
<div t-if="isPanel(row)" class="o_fp_ri_panel">
<label>pH
<input type="number" step="any" t-model.number="row.panel_ph"/>
<input type="number" step="0.01" t-model.number="row.panel_ph"/>
</label>
<label>Concentration
<input type="number" step="any" t-model.number="row.panel_concentration"/>
<input type="number" step="0.1" t-model.number="row.panel_concentration"/>
</label>
<label>Temperature
<input type="number" step="any" t-model.number="row.panel_temperature"/>
<input type="number" step="1" t-model.number="row.panel_temperature"/>
</label>
<label>Bath ID
<input type="text" t-model="row.panel_bath_id"/>
</label>
</div>
<!-- Text fallback (text, signature, time_hms, anything else) -->
<!-- Time (HH:MM:SS) — native time picker with seconds.
Mobile/tablet browsers surface the OS time wheel. -->
<input t-if="isTimeHms(row)"
type="time"
step="1"
class="o_fp_ri_input o_fp_ri_input_text"
t-model="row.value_text"/>
<!-- Text fallback (text, signature, anything else) -->
<input t-if="isText(row)"
type="text"
class="o_fp_ri_input o_fp_ri_input_text"

View File

@@ -323,6 +323,82 @@ class TestSoConfirmHook(TransactionCase):
else:
self.skipTest('x_fc_part_catalog_id field not present')
def test_so_confirm_splits_by_thickness(self):
"""Two lines with same recipe+part+coating but DIFFERENT thicknesses
must produce TWO fp.jobs — silent merge was a compliance bug (the
second thickness's CoC would carry the first thickness).
The bug only manifests when lines hit the `if recipe:` branch in
_fp_auto_create_job — without a resolved recipe, the no_recipe
branch already splits per line. We seed a recipe via
part.default_process_id so both lines resolve to the same recipe
and reach the buggy grouping path.
"""
SOL = self.env['sale.order.line']
Part = self.env['fp.part.catalog']
Node = self.env['fusion.plating.process.node']
Thick = self.env['fp.coating.thickness']
if 'x_fc_part_catalog_id' not in SOL._fields \
or 'x_fc_thickness_id' not in SOL._fields \
or 'default_process_id' not in Part._fields:
self.skipTest('Sub 5 + recipe-on-part fields not present')
# Two distinct existing thicknesses. Creating them from scratch
# requires a coating_config → process_type chain that's too noisy
# for a unit test; reuse what's seeded.
thicknesses = Thick.search([], limit=2)
if len(thicknesses) < 2:
self.skipTest('need >= 2 fp.coating.thickness records seeded')
thick_a, thick_b = thicknesses[0], thicknesses[1]
# Any existing top-level recipe works — the test only needs both
# lines to resolve to the SAME recipe so they collide on the key.
recipe = Node.search([('parent_id', '=', False)], limit=1)
if not recipe:
self.skipTest('no fusion.plating.process.node records to anchor a recipe')
partner_for_part = self.env['res.partner'].create({'name': 'SplitPartner'})
part = Part.create({
'name': 'SplitPart', 'part_number': 'SP-1',
'partner_id': partner_for_part.id,
'default_process_id': recipe.id,
})
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'client_order_ref': 'TEST-PO-SPLIT',
})
SOL.create({
'order_id': so.id, 'product_id': self.product.id,
'product_uom_qty': 2.0, 'price_unit': 10.0,
'x_fc_part_catalog_id': part.id,
'x_fc_thickness_id': thick_a.id,
})
SOL.create({
'order_id': so.id, 'product_id': self.product.id,
'product_uom_qty': 1.0, 'price_unit': 10.0,
'x_fc_part_catalog_id': part.id,
'x_fc_thickness_id': thick_b.id,
})
so.action_confirm()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(
len(jobs), 2,
'Lines with different thicknesses must spawn separate fp.jobs '
'(both lines share recipe+part+coating, only thickness differs)',
)
# Each job's linked SO line should carry its own thickness
thicknesses_on_jobs = set()
for job in jobs:
for line in job.sale_order_line_ids:
if line.x_fc_thickness_id:
thicknesses_on_jobs.add(line.x_fc_thickness_id.id)
self.assertEqual(
thicknesses_on_jobs, {thick_a.id, thick_b.id},
'The two distinct thicknesses must each appear on its own job',
)
class TestJobLifecycleHooks(TransactionCase):
def setUp(self):

View File

@@ -288,6 +288,25 @@
<field name="x_fc_ship_via"/>
<field name="x_fc_invoice_strategy"/>
</xpath>
<xpath expr="//group[@name='x_fc_customer_refs']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- Notes group sits awkwardly above the main fields in core; relocate
to a notebook tab so the form opens on the operationally relevant
fields (customer / part / steps) instead of empty note placeholders. -->
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//page[@name='costs']" position="before">
<page string="Notes" name="notes">
<group>
<field name="x_fc_internal_note" nolabel="1"
placeholder="Internal note (not shown to customer)…"/>
<field name="x_fc_external_note" nolabel="1"
placeholder="External note (printed on customer paperwork)…"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -32,6 +32,25 @@
string="Certificates"/>
</button>
</xpath>
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
line under the SO name (the big SO-30000 heading). Only
renders once the SO has been confirmed (quote_ref is set
on create, parent_number is set on confirm — both
needed for the line to make sense).
NB: Odoo 19 forbids t-if in standard form views — using
`invisible` attribute on the wrapper div instead. -->
<xpath expr="//div[hasclass('oe_title')]" position="inside">
<field name="x_fc_parent_number" invisible="1"/>
<div class="text-muted"
style="font-size: 0.9em; margin-top: 4px;"
invisible="not x_fc_quote_ref or not x_fc_parent_number">
Originally quoted as
<field name="x_fc_quote_ref"
readonly="1" nolabel="1"
class="d-inline"/>
</div>
</xpath>
</field>
</record>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.5.0',
'version': '19.0.3.6.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '

View File

@@ -24,14 +24,14 @@ class FpDelivery(models.Model):
"""
_name = 'fusion.plating.delivery'
_description = 'Fusion Plating — Delivery'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'scheduled_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
default=lambda self: self._default_name(),
default='New',
tracking=True,
)
partner_id = fields.Many2one(
@@ -159,8 +159,49 @@ class FpDelivery(models.Model):
compute='_compute_custody_count',
)
# ------------------------------------------------------------------
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
"""No direct sale_order_id on this model — resolve via
job_ref → fp.job.name → job.sale_order_id."""
if not self.job_ref or 'fp.job' not in self.env:
return self.env['sale.order']
job = self.env['fp.job'].sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
return job.sale_order_id if job else self.env['sale.order']
def _fp_name_prefix(self):
return 'DLV'
def _fp_parent_counter_field(self):
return 'x_fc_pn_delivery_count'
@api.model_create_multi
def create(self, vals_list):
"""Parent-derived name (DLV-<parent>[-NN]) with legacy-sequence
fallback for deliveries that don't link back to an SO."""
for vals in vals_list:
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_delivery SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
@api.model
def _default_name(self):
"""Retained for any legacy caller. New code should rely on
create() — the parent-numbered mixin sets the name there."""
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery')
return seq or '/'

View File

@@ -21,16 +21,26 @@ class FpPickupRequest(models.Model):
"""
_name = 'fusion.plating.pickup.request'
_description = 'Fusion Plating — Pickup Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'requested_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
default=lambda self: self._default_name(),
default='New',
tracking=True,
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='set null',
index=True,
help='Sale order this pickup is associated with. Pickup may be '
'created BEFORE the SO exists; in that case the '
'parent-number naming falls back to the standalone '
'PU/YYYY/NNNN sequence and the link can be set later.',
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
@@ -126,8 +136,39 @@ class FpPickupRequest(models.Model):
compute='_compute_custody_count',
)
# ------------------------------------------------------------------
# Parent-numbered mixin hooks
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'PU'
def _fp_parent_counter_field(self):
return 'x_fc_pn_pickup_count'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.pickup.request') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_pickup_request SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
@api.model
def _default_name(self):
"""Retained for legacy callers; new flow uses the create() override."""
seq = self.env['ir.sequence'].next_by_code('fusion.plating.pickup.request')
return seq or '/'

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.4.12.2',
'version': '19.0.4.14.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -16,7 +16,7 @@ class FpCapa(models.Model):
"""
_name = 'fusion.plating.capa'
_description = 'Fusion Plating — Corrective / Preventive Action'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'due_date asc, id desc'
name = fields.Char(
@@ -100,6 +100,23 @@ class FpCapa(models.Model):
)
active = fields.Boolean(default=True)
# ------------------------------------------------------------------
# Parent-numbered mixin hooks
# CAPAs reach SO via ncr_id → fp.job link if present (jobless NCRs
# fall back to legacy sequence).
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
# CAPA usually flows from an NCR. If the NCR has a job-back-link
# (added by future modules), we can reach SO through it. For now
# there's no link in core — falls back to legacy seq.
return self.env['sale.order']
def _fp_name_prefix(self):
return 'CAPA'
def _fp_parent_counter_field(self):
return 'x_fc_pn_capa_count'
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.capa')
@@ -109,8 +126,19 @@ class FpCapa(models.Model):
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.capa') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_capa SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
@api.depends('due_date', 'state')
def _compute_is_overdue(self):

View File

@@ -3,6 +3,9 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -22,9 +25,9 @@ class FpContractReview(models.Model):
"""Contract Review (QA-005).
Per-part, two-section QA review: Section 2.0 Planning / Production
Review (signed by a QA Assistant) and Section 3.0 Quality Review
(signed by a QA Manager). Both sections must be signed for the
review to be complete.
Review (Planning Review stage, signed by a Planning Signer) and
Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
Both sections must be signed for the review to be complete.
The review is always optional. It never blocks MO/SO/WO progression.
Its purpose is an audit artefact and a printable 1:1 of the paper
@@ -79,7 +82,7 @@ class FpContractReview(models.Model):
qty = fields.Integer(string='Qty')
due_date = fields.Date(string='Due')
# ---- Section 2.0 — Planning / Production Review (QA Assistant) ---------
# ---- Section 2.0 — Planning / Production Review (Planning Review) ------
s20_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time')
s20_capacity_to_process = fields.Boolean(string='Capacity to Process')
@@ -118,7 +121,7 @@ class FpContractReview(models.Model):
copy=False,
)
# ---- Section 3.0 — Quality Review (QA Manager) -------------------------
# ---- Section 3.0 — Quality Review (QA Review) --------------------------
s30_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)")
s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
@@ -180,8 +183,9 @@ class FpContractReview(models.Model):
state = fields.Selection(
[('draft', 'Draft'),
('assistant_review', 'QA Assistant Review'),
('manager_review', 'QA Manager Review'),
('assistant_review', 'Planning Review'),
('manager_review', 'QA Review'),
('awaiting_info', 'Awaiting Client Info'),
('complete', 'Complete'),
('dismissed', 'Dismissed')],
default='draft',
@@ -189,6 +193,56 @@ class FpContractReview(models.Model):
tracking=True,
)
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
# When a QA Signer (Brett or whoever the company has rostered) finds a
# client requirement that fails during the QA Review, they mark the
# review failed. The state moves to `awaiting_info`, an activity is
# scheduled for every QA Signer to follow up, and a smart button on
# the form gives them a one-click email composer to ping the client.
# When the client replies, the QA Signer captures notes in
# `special_instructions` and marks complete — the notes print on the
# final QA-005 PDF for the audit trail.
qa_failure_reason = fields.Html(
string='QA Failure Reason',
copy=False,
help='What client requirement failed and why we need more info. '
'Captured here before flipping the review to '
'"Awaiting Client Info" so every QA Signer sees the same '
'context. Pre-fills the client email composer.',
)
info_requested_date = fields.Datetime(
string='Info Requested Date',
readonly=True,
copy=False,
help='Stamped automatically the first time the client email '
'composer is sent.',
)
info_received_date = fields.Datetime(
string='Info Received Date',
copy=False,
help='Manually stamped when the QA Signer marks the review '
'complete after receiving the client info.',
)
special_instructions = fields.Html(
string='Special Instructions',
copy=False,
help='Free-form notes captured by the QA Signer when they close '
'out the review. Prints at the bottom of the QA-005 PDF '
'so the audit record carries the agreed resolution.',
)
client_email_count = fields.Integer(
compute='_compute_client_email_count',
help='Smart-button counter — number of emails posted to chatter '
'against this review. Always non-zero after the first send.',
)
@api.depends('message_ids', 'message_ids.message_type')
def _compute_client_email_count(self):
for rec in self:
rec.client_email_count = len(rec.message_ids.filtered(
lambda m: m.message_type == 'email'
))
# ---- Constraints --------------------------------------------------------
_sql_constraints = [
@@ -351,6 +405,133 @@ class FpContractReview(models.Model):
'fusion_plating_quality.action_report_contract_review'
).report_action(self)
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
def action_mark_qa_failed(self):
"""QA Signer marks the review failed because a client requirement
is missing or unclear. Captures the reason, flips state to
`awaiting_info`, and schedules a follow-up activity for every QA
Signer rostered on the company (so the work doesn't fall through
the cracks if Brett is on vacation)."""
self.ensure_one()
if self.state not in ('manager_review', 'assistant_review'):
raise UserError(_(
'Only a review at the QA Review (or Planning Review) stage '
'can be flagged as failed. Current state: %s.'
) % dict(self._fields['state'].selection).get(self.state, self.state))
# Reuse the section-30 signer roster — the same group of people
# who can sign QA can flag a QA failure.
self._check_signer(30)
if not self.qa_failure_reason or not self.qa_failure_reason.strip():
raise UserError(_(
'Capture the QA Failure Reason before flagging the '
'review failed — the reason pre-fills the client email '
'and is required for the audit trail.'
))
self.write({'state': 'awaiting_info'})
self.message_post(body=Markup(_(
'<b>QA Review failed</b> by %(user)s. Awaiting client '
'information.<br/><b>Reason:</b><br/>%(reason)s'
)) % {
'user': self.env.user.name,
'reason': Markup(self.qa_failure_reason or ''),
})
# Schedule activity for every QA Signer (any of them can pick it up).
signers = self.company_id._fp_get_qa_signers(30)
if not signers:
# Fall back to the user who flagged it, so the activity is
# not orphaned on shops that haven't configured a roster.
signers = self.env.user
try:
activity_type = self.env.ref('mail.mail_activity_data_todo')
except ValueError:
activity_type = self.env['mail.activity.type'].search(
[('category', '=', 'default')], limit=1)
for user in signers:
self.activity_schedule(
activity_type_id=activity_type.id if activity_type else False,
summary=_('Follow up on QA-005 — client info required'),
note=self.qa_failure_reason or '',
user_id=user.id,
date_deadline=fields.Date.context_today(self) +
timedelta(days=2),
)
return True
def action_open_client_email_wizard(self):
"""Smart-button target — opens the email composer wizard pre-filled
with the customer's contact email + a body templated from the
QA failure reason. The wizard handles the actual mail.mail send
and stamps `info_requested_date` on this review."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Email Client — Request Info'),
'res_model': 'fp.contract.review.client.email.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_review_id': self.id,
'default_recipient_email':
self.customer_id.email or '',
'default_recipient_name':
self.customer_id.name or '',
},
}
def action_view_client_emails(self):
"""Drill-down behind the smart button counter — shows the chatter
messages of type=email for this review."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Client Emails — %s') % self.name,
'res_model': 'mail.message',
'view_mode': 'list,form',
'domain': [
('model', '=', 'fp.contract.review'),
('res_id', '=', self.id),
('message_type', '=', 'email'),
],
}
def action_complete_after_info(self):
"""Close out a review that was in `awaiting_info` once the client
info has been received and `special_instructions` captured. Stamps
Section 3.0 sign-off with the current user + timestamp so the QA
review is fully closed and the QA-005 PDF carries a complete
audit trail."""
self.ensure_one()
if self.state != 'awaiting_info':
raise UserError(_(
'Only a review in "Awaiting Client Info" can be marked '
'complete via this action.'
))
self._check_signer(30)
now = fields.Datetime.now()
vals = {
'state': 'complete',
'info_received_date': self.info_received_date or now,
's30_signed_by': self.env.user.id,
's30_signed_date': now,
's30_locked': True,
}
self.write(vals)
# Mark the activity as done so the follow-up disappears from
# everyone's inbox once the case is closed.
self.activity_feedback(
['mail.mail_activity_data_todo'],
feedback=_('Client info received — review closed.'),
)
self.message_post(body=Markup(_(
'<b>QA Review completed</b> by %(user)s after receiving '
'client information.<br/>'
'<b>Special Instructions captured:</b><br/>%(notes)s'
)) % {
'user': self.env.user.name,
'notes': Markup(self.special_instructions or '') or _('(none)'),
})
return True
# ---- Helpers ------------------------------------------------------------
def _check_signer(self, section):

View File

@@ -17,7 +17,7 @@ class FpNcr(models.Model):
"""
_name = 'fusion.plating.ncr'
_description = 'Fusion Plating — Non-Conformance Report'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'reported_date desc, id desc'
name = fields.Char(
@@ -130,6 +130,22 @@ class FpNcr(models.Model):
)
active = fields.Boolean(default=True)
# ------------------------------------------------------------------
# Parent-numbered mixin hooks
# NCRs don't have a direct SO/job link in core yet — falls back to
# legacy fusion.plating.ncr sequence. When a future module adds a
# link, it can override _fp_parent_sale_order to enable parent
# naming retroactively without further changes here.
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.env['sale.order']
def _fp_name_prefix(self):
return 'NCR'
def _fp_parent_counter_field(self):
return 'x_fc_pn_ncr_count'
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.ncr')
@@ -139,8 +155,19 @@ class FpNcr(models.Model):
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.ncr') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_ncr SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
@api.depends('capa_ids')
def _compute_capa_count(self):

View File

@@ -17,7 +17,7 @@ class FpQualityHold(models.Model):
"""
_name = 'fusion.plating.quality.hold'
_description = 'Fusion Plating — Quality Hold'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'create_date desc'
name = fields.Char(
@@ -124,6 +124,17 @@ class FpQualityHold(models.Model):
# ------------------------------------------------------------------
# Defaults / create
# ------------------------------------------------------------------
# Parent-numbered mixin hooks. Holds reach the SO through their
# linked fp.job (the standard authoring path on the shop floor).
def _fp_parent_sale_order(self):
return self.job_id.sale_order_id if self.job_id else self.env['sale.order']
def _fp_name_prefix(self):
return 'HOLD'
def _fp_parent_counter_field(self):
return 'x_fc_pn_hold_count'
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code(
@@ -135,8 +146,19 @@ class FpQualityHold(models.Model):
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.quality.hold') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_quality_hold SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# ------------------------------------------------------------------
# Actions

View File

@@ -36,7 +36,7 @@ _logger = logging.getLogger(__name__)
class FpRma(models.Model):
_name = 'fusion.plating.rma'
_description = 'Fusion Plating — Return Material Authorisation'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'create_date desc, id desc'
_rec_name = 'name'
@@ -243,6 +243,17 @@ class FpRma(models.Model):
# ------------------------------------------------------------------
# Defaults / create / name
# ------------------------------------------------------------------
# Parent-numbered mixin hooks. RMAs reach the SO directly via
# sale_order_id (set at create-time from the original order).
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'RMA'
def _fp_parent_counter_field(self):
return 'x_fc_pn_rma_count'
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma')
@@ -252,8 +263,19 @@ class FpRma(models.Model):
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_rma SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# ------------------------------------------------------------------
# Computes

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.7.3',
'version': '19.0.3.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -16,7 +16,7 @@ class FpReceiving(models.Model):
"""
_name = 'fp.receiving'
_description = 'Fusion Plating — Receiving'
_inherit = ['mail.thread', 'mail.activity.mixin']
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'received_date desc, id desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
@@ -97,19 +97,38 @@ class FpReceiving(models.Model):
rec.unresolved_damage_count = len(rec.damage_ids.filtered(lambda d: not d.resolved))
# -------------------------------------------------------------------------
# Sequence
# Sequence + parent-derived naming
# -------------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'RCV'
def _fp_parent_counter_field(self):
return 'x_fc_pn_receiving_count'
@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.receiving') or 'New'
# Prefill received_qty from expected_qty so the operator only
# types when the count is wrong (the common case is "all
# arrived"). Saves a step on every routine receipt.
# types when the count is wrong.
if vals.get('expected_qty') and not vals.get('received_qty'):
vals['received_qty'] = vals['expected_qty']
return super().create(vals_list)
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New'
self.env.cr.execute(
"UPDATE fp_receiving SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# -------------------------------------------------------------------------
# Sub 8 — box-count-only actions (new primary flow)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.10.3.0',
'version': '19.0.10.16.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -324,12 +324,26 @@
about page size; the output PDF is multi-page if the SO has
multiple plating lines. -->
<record id="action_report_fp_so_sticker" model="ir.actions.report">
<field name="name">WO Box Sticker</field>
<field name="name">External Sticker</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_so_sticker</field>
<field name="report_file">fusion_plating_reports.report_fp_so_sticker</field>
<field name="print_report_name">'WO Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="print_report_name">'External Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
</record>
<!-- SO Internal sticker — same layout, prints internal description
instead of the customer-facing line.name. Shop-floor variant. -->
<record id="action_report_fp_so_sticker_internal" model="ir.actions.report">
<field name="name">Internal Sticker</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_so_sticker_internal</field>
<field name="report_file">fusion_plating_reports.report_fp_so_sticker_internal</field>
<field name="print_report_name">'Internal Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>

View File

@@ -60,6 +60,22 @@
else (_mo and _mo.product_qty) or 0"/>
<t t-set="_po_number" t-value="_po_number or (_so and _so.x_fc_po_number) or '-'"/>
<t t-set="_partner_name" t-value="_partner_name or (_so and _so.partner_id.name) or '-'"/>
<!-- Customer short-code for shop-floor "secrecy cover" — operators
see "ABC-MANU" instead of "ABC Manufacturing Inc", so visiting
customers / unauthorised passers-by can't immediately tell whose
parts are on which rack. Rule: first 3 chars of word[0] + "-"
+ first 4 chars of word[1], all uppercase. Single-word names:
just the first 3 chars. Strips non-alphanumeric per word so
punctuation in "St. John's Mfg." doesn't poison the slice. -->
<t t-set="_partner_words"
t-value="[''.join(c for c in w if c.isalnum())
for w in (_partner_name or '').split()
if ''.join(c for c in w if c.isalnum())]"/>
<t t-set="_partner_display" t-value="
(_partner_words[0][:3].upper() + '-' + _partner_words[1][:4].upper())
if len(_partner_words) &gt;= 2
else (_partner_words[0][:3].upper() if _partner_words else (_partner_name or '-'))
"/>
<!-- _mo_ref controls the muted "(WH/MO/00033)" suffix next to PO.
Outer can pass '' to hide it (e.g. fp.job already shows its
own name in the header). Defaults to _mo.name. -->
@@ -69,10 +85,31 @@
or (_so and _so.x_fc_internal_note
and _so.x_fc_internal_note.striptags()[:100])
or '-'"/>
<!-- Serial number — Sub 5 added x_fc_serial_id (M2O fp.serial) on
the SO line. The serial record's `name` is the printable label. -->
<t t-set="_serial_number" t-value="(_line and 'x_fc_serial_id' in _line._fields and _line.x_fc_serial_id and _line.x_fc_serial_id.name) or '-'"/>
<!-- Thickness — Sub 5 added x_fc_thickness_id (M2O fp.coating.thickness)
on the SO line. `display_name` is the human-readable range, e.g.
"0.30.5 mils". The en-dash (U+2013) in display_name mojibakes
to "â€"" through wkhtmltopdf's font path on entech, so we
swap en-dash + em-dash for a plain hyphen-minus before
rendering. ASCII-only printable for any QR-label printer. -->
<t t-set="_thickness_dn" t-value="_line and 'x_fc_thickness_id' in _line._fields and _line.x_fc_thickness_id and _line.x_fc_thickness_id.display_name"/>
<t t-set="_thickness" t-value="(_thickness_dn and _thickness_dn.replace(u'', '-').replace(u'—', '-')) or '-'"/>
<!-- Notes content — outer can pre-set this (e.g. the Internal
variant passes line.x_fc_internal_description). Otherwise
falls back to line.name (customer-facing description per
Sub 2 Q6), then to part.name. -->
<t t-set="_notes_content" t-value="_notes_content
or (_line and _line.name)
or (_part and _part.name)
or '-'"/>
<!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need
to fetch /report/barcode/ over the network during rendering. -->
to fetch /report/barcode/ over the network during rendering.
600x600 source at 300dpi print = ~515ppi effective — high-def
scan reliability for the 4x6" label. -->
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri(
'QR', _scan_url, width=300, height=300)"/>
'QR', _scan_url, width=600, height=600)"/>
<style>
@page { margin: 0; size: 152mm 102mm; }
@@ -82,10 +119,9 @@
width: 100% !important;
height: 100% !important;
}
/* Boxy professional layout: thick outer border, horizontal row
borders, vertical label/value divider. Absolute positioning +
% row heights force the content to fill the full page in
wkhtmltopdf (which ignores vh/vw/flex). ------------------- */
/* 3-cell header (Logo | WO# | QR) + 2-region body (fields left,
Notes column right). Absolute positioning + % heights/widths
are mandatory — wkhtmltopdf ignores vh/vw/flex. ----------- */
.fp-sticker {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #000;
@@ -97,14 +133,14 @@
page-break-after: always;
page-break-inside: avoid;
}
/* ---- HEADER band — grew to 40% to fit 2x WO# + logo + bigger QR. */
/* ---- HEADER band: 3 horizontal cells, divided by vertical
rules. Logo / WO# / QR. 32% to fit the +30% QR. ---- */
.fp-sticker-head-wrap {
position: absolute;
left: 0; right: 0; top: 0;
height: 40%;
height: 32%;
border-bottom: 2px solid #000;
box-sizing: border-box;
padding: 0;
}
table.fp-sticker-head {
width: 100%;
@@ -112,82 +148,71 @@
table-layout: fixed;
border-collapse: collapse;
}
table.fp-sticker-head td { padding: 0; vertical-align: middle; }
col.fp-col-head-left { width: 66%; }
col.fp-col-head-right { width: 34%; }
td.fp-sticker-head-left {
overflow: hidden;
border-right: 2px solid #000;
}
td.fp-sticker-head-right {
col.fp-col-head-logo { width: 28%; }
col.fp-col-head-wo { width: 44%; }
col.fp-col-head-qr { width: 28%; }
table.fp-sticker-head td {
padding: 0;
vertical-align: middle;
text-align: center;
vertical-align: middle;
overflow: hidden;
}
/* Left column nested 2-row table: logo on top, WO# below.
Horizontal divider between rows mirrors body row borders. */
table.fp-sticker-head-left-stack {
width: 100%;
height: 100%;
table-layout: fixed;
border-collapse: collapse;
}
table.fp-sticker-head-left-stack tr.fp-row-logo { height: 50%; }
table.fp-sticker-head-left-stack tr.fp-row-wo { height: 50%; }
table.fp-sticker-head-left-stack td {
padding: 0 14px;
vertical-align: middle;
}
/* Logo cell + WO# cell each get explicit vertical-align so the
content sits in the middle of its half of the header band. */
table.fp-sticker-head-left-stack tr.fp-row-logo td,
table.fp-sticker-head-left-stack tr.fp-row-wo td {
vertical-align: middle;
}
table.fp-sticker-head-left-stack tr + tr td {
border-top: 1px solid #000;
}
td.fp-sticker-head-logo { border-right: 2px solid #000; padding: 0 6px; }
td.fp-sticker-head-wo { border-right: 2px solid #000; }
.fp-sticker-logo {
/* Logo bumped 40% (116 → 162px height, 520 → 728px width). */
max-height: 162px;
max-width: 728px;
display: block;
max-height: 135px;
max-width: 95%;
display: inline-block;
vertical-align: middle;
}
.fp-sticker-wo {
font-size: 72pt;
font-weight: 900;
letter-spacing: 0.2mm;
letter-spacing: 0.1mm;
line-height: 1;
white-space: nowrap;
margin: 0;
}
/* QR wrapper crops the white quiet-zone around the QR pattern
so it doesn't visually float on a white square inside the
cell. The PNG from Odoo's barcode generator carries a
~12% border (4 modules of quiet-zone) on each side; we
render the image larger than the wrapper and offset it so
the wrapper clips that border out. ---------------------- */
/* QR wrapper crops the ~12% quiet-zone the barcode generator
adds around the QR pattern. We render the image larger than
the wrapper and offset so the wrapper clips that border out.
Wrapper 365px = ~30.9mm at 300dpi (30% larger than the
previous 280px). 600x600 source = high-def at print scale. ---- */
.fp-sticker-qr-wrap {
width: 380px;
height: 380px;
width: 365px;
height: 365px;
display: inline-block;
position: relative;
overflow: hidden;
}
.fp-sticker-qr {
width: 510px;
height: 510px;
width: 480px;
height: 480px;
position: absolute;
top: -65px;
left: -65px;
top: -58px;
left: -58px;
margin: 0;
display: block;
}
/* ---- BODY band (7 rows, each 14.28% of the band) ---- */
/* ---- BODY band: left fields region + right Notes region. ---- */
.fp-sticker-body-wrap {
position: absolute;
left: 0; right: 0;
top: 40%; bottom: 0;
top: 32%; bottom: 0;
}
.fp-body-left {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 64%;
border-right: 2px solid #000;
box-sizing: border-box;
}
.fp-body-right {
position: absolute;
left: 64%; right: 0; top: 0; bottom: 0;
box-sizing: border-box;
padding: 8px 10px;
overflow: hidden;
}
table.fp-sticker-body {
width: 100%;
@@ -197,18 +222,18 @@
}
table.fp-sticker-body tr { height: 14.28%; }
table.fp-sticker-body tr + tr td { border-top: 1px solid #000; }
col.fp-col-label { width: 32%; }
col.fp-col-value { width: 68%; }
col.fp-col-label { width: 38%; }
col.fp-col-value { width: 62%; }
table.fp-sticker-body td {
vertical-align: middle;
padding: 0 14px;
font-size: 38pt;
line-height: 1.1;
padding: 0 8px;
font-size: 50pt;
line-height: 1.0;
}
td.fp-sticker-label {
font-weight: 700;
white-space: nowrap;
border-right: 2px solid #000;
border-right: 1px solid #000;
background-color: #f1f2f4;
}
td.fp-sticker-value {
@@ -217,43 +242,55 @@
white-space: nowrap;
}
.fp-sticker-strong { font-weight: 700; }
.fp-sticker-muted { color: #555; font-size: 28pt; }
.fp-sticker-muted { color: #555; font-size: 30pt; }
/* Notes column on the right side of the body. */
.fp-notes-label {
font-weight: 700;
font-size: 48pt;
margin: 0 0 10px 0;
}
.fp-notes-content {
font-size: 36pt;
line-height: 1.1;
white-space: pre-line;
word-wrap: break-word;
overflow: hidden;
}
</style>
<!-- Per-box loop: renders one sticker page per physical box in
the line/job qty. When _qty_total is missing/0/1, falls
back to a single render (no "X / N" indicator). -->
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
<div class="fp-sticker">
<!-- 3-cell header: Logo | WO# | QR -->
<div class="fp-sticker-head-wrap">
<table class="fp-sticker-head">
<colgroup>
<col class="fp-col-head-left"/>
<col class="fp-col-head-right"/>
<col class="fp-col-head-logo"/>
<col class="fp-col-head-wo"/>
<col class="fp-col-head-qr"/>
</colgroup>
<tr>
<td class="fp-sticker-head-left">
<td class="fp-sticker-head-logo">
<!-- env.company.logo is often blank while logo_web
is populated from the company partner's image.
Fall back across both + partner.image_1920. -->
is populated from the partner's image. Fall
back across both + partner.image_1920. -->
<t t-set="_logo" t-value="env.company.logo
or env.company.logo_web
or env.company.partner_id.image_1920
or False"/>
<table class="fp-sticker-head-left-stack">
<tr class="fp-row-logo">
<td>
<img t-if="_logo"
class="fp-sticker-logo"
t-att-src="image_data_uri(_logo)"/>
</td>
</tr>
<tr class="fp-row-wo">
<td>
<div class="fp-sticker-wo">
WO #<span t-esc="_order_id"/>
</div>
</td>
</tr>
</table>
<img t-if="_logo"
class="fp-sticker-logo"
t-att-src="image_data_uri(_logo)"/>
</td>
<td class="fp-sticker-head-right">
<td class="fp-sticker-head-wo">
<div class="fp-sticker-wo">
<span t-esc="_order_id"/>
</div>
</td>
<td>
<div class="fp-sticker-qr-wrap" t-if="_qr_src">
<img class="fp-sticker-qr"
t-att-src="_qr_src"/>
@@ -263,92 +300,95 @@
</table>
</div>
<!-- Body: 7-row field table on the left, full-height Notes
column on the right showing the customer-facing description. -->
<div class="fp-sticker-body-wrap">
<table class="fp-sticker-body">
<colgroup>
<col class="fp-col-label"/>
<col class="fp-col-value"/>
</colgroup>
<tr>
<td class="fp-sticker-label">PO (RO):</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong"
t-esc="_po_number"/>
<t t-if="_mo_ref">
<span class="fp-sticker-muted">
(<span t-esc="_mo_ref"/>)
</span>
</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Customer:</td>
<td class="fp-sticker-value">
<span t-esc="_partner_name"/>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Process:</td>
<td class="fp-sticker-value">
<t t-if="_process">
<span t-esc="_process.name"/>
</t>
<t t-elif="_coating">
<span t-esc="_coating.name"/>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Part Number:</td>
<td class="fp-sticker-value">
<t t-if="_part">
<span class="fp-sticker-strong"
t-esc="_part.part_number"/>
<t t-if="_part.revision">
<!-- Some parts store the revision with a
"Rev " prefix already (e.g. "Rev 1"),
others store just the value ("1", "A").
Strip a leading "Rev " (case insensitive)
so we don't print "Rev Rev 1". -->
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
<t t-if="_rev_clean.lower().startswith('rev ')">
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
<div class="fp-body-left">
<table class="fp-sticker-body">
<colgroup>
<col class="fp-col-label"/>
<col class="fp-col-value"/>
</colgroup>
<tr>
<td class="fp-sticker-label">PO #:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong"
t-esc="_po_number"/>
</td>
</tr>
<tr>
<td class="fp-sticker-label">SN #:</td>
<td class="fp-sticker-value">
<span t-esc="_serial_number"/>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Customer:</td>
<td class="fp-sticker-value">
<span t-esc="_partner_display"/>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Part #:</td>
<td class="fp-sticker-value">
<t t-if="_part">
<span class="fp-sticker-strong"
t-esc="_part.part_number"/>
<t t-if="_part.revision">
<!-- Strip "Rev " prefix if the field
value already includes it, so we
don't print "Rev Rev 1". -->
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
<t t-if="_rev_clean.lower().startswith('rev ')">
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
</t>
<span class="fp-sticker-muted">
Rev <span t-esc="_rev_clean"/>
</span>
</t>
</t>
<span class="fp-sticker-muted">
Rev <span t-esc="_rev_clean"/>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Due Date:</td>
<td class="fp-sticker-value">
<t t-if="_due">
<span t-esc="_due.strftime('%b %d, %Y')"/>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Thickness:</td>
<td class="fp-sticker-value">
<span t-esc="_thickness"/>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<t t-if="_qty_total and int(_qty_total) &gt; 1">
<span t-esc="_box_idx"/> / <span t-esc="int(_qty_total)"/>
</t>
<t t-else="">
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</t>
</span>
</t>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Due Date:</td>
<td class="fp-sticker-value">
<t t-if="_due">
<span t-esc="_due.strftime('%b %d, %Y')"/>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</span>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Notes:</td>
<td class="fp-sticker-value">
<t t-esc="_internal_note"/>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<div class="fp-body-right">
<div class="fp-notes-label">Notes:</div>
<div class="fp-notes-content">
<t t-esc="_notes_content"/>
</div>
</div>
</div>
</div>
</t>
</template>
<!-- =====================================================
@@ -370,6 +410,8 @@
<t t-set="_mo_ref" t-value="False"/>
<t t-set="_internal_note" t-value="False"/>
<t t-set="_scan_path" t-value="False"/>
<t t-set="_notes_content" t-value="False"/>
<t t-set="_qty_total" t-value="False"/>
</template>
<!-- ========== Outer template — mrp.workorder entry ========== -->
@@ -407,18 +449,19 @@
skipped — they don't go through plating so they don't need a
box sticker.
The "WO #" header shows "<SO>/<line seq>" so the sticker
remains identifiable before the fp.job is generated. The QR
encodes /fp/so-line/<line.id> — the controller can decide
whether to land on the parent SO, the line, or (later) the
spawned job. -->
The "WO#" header shows the SO name (e.g. SO-30019). The body
carries the part-specific fields (Part #, Customer, etc.) which
disambiguate multi-line SOs without needing a sequence suffix.
The QR encodes /fp/so-line/<line.id> — the controller can
decide whether to land on the parent SO, the line, or (later)
the spawned job. -->
<template id="report_fp_so_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name + ' / ' + str(line.sequence or line.id)"/>
<t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/>
@@ -428,6 +471,7 @@
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
@@ -436,4 +480,37 @@
</t>
</template>
<!-- ========== Outer template — sale.order Internal variant ==========
Same layout + iteration as report_fp_so_sticker, but pre-sets
_notes_content from x_fc_internal_description (Sub 2 internal
description field) so the Notes column shows the ops-facing
description instead of line.name. -->
<template id="report_fp_so_sticker_internal">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description -->
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
and line.x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!--
Override auth_signup.set_password_email
Original: heavy Odoo branding in body and subject
(Welcome to Odoo, "connect to Odoo", "Odoo Tour", "Powered by Odoo", etc.)
Whitelabeled: company-branded invite, no Odoo references.
-->
<record id="auth_signup.set_password_email" model="mail.template">
<field name="subject">{{ object.create_uid.name }} from {{ object.company_id.name }} invites you to your account</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle">
<span style="font-size: 10px;">Welcome to <t t-out="object.company_id.name or ''">YourCompany</t></span><br/>
<span style="font-size: 20px; font-weight: bold;">
<t t-out="object.name or ''">Marc Demo</t>
</span>
</td><td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.company_id.name"/>
</td></tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="top" style="font-size: 13px;">
<div>
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
You have been invited by <t t-out="object.create_uid.name or ''">Admin</t> at <t t-out="object.company_id.name or ''">YourCompany</t> to access your account.
<div style="margin: 16px 0px 16px 0px;">
<a t-att-href="object.partner_id._get_signup_url()"
t-attf-style="background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; padding: 8px 16px 8px 16px; text-decoration: none; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px; font-size:13px;">
Accept invitation
</a>
</div>
<b>This link will remain valid for <t t-out="int(int(object.env['ir.config_parameter'].sudo().get_param('auth_signup.signup.validity.hours',144))/24)"></t> days.</b><br/>
<t t-set="website_url" t-value="object.get_base_url()"></t>
Sign-in URL: <b><a t-att-href='website_url' t-out="website_url or ''">https://yourcompany.com</a></b><br/>
Your sign-in email: <b><a t-attf-href="/web/login?login={{ object.email }}" target="_blank" t-out="object.email or ''">user@example.com</a></b><br/><br/>
Welcome aboard!<br/>
--<br/>The <t t-out="object.company_id.name or ''">YourCompany</t> Team
</div>
</td></tr>
<tr><td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle" align="left">
<t t-out="object.company_id.name or ''">YourCompany</t>
</td></tr>
<tr><td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
<t t-if="object.company_id.email">
| <a t-att-href="'mailto:%s' % object.company_id.email" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
</t>
<t t-if="object.company_id.website">
| <a t-att-href="'%s' % object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
</t>
</td></tr>
</table>
</td>
</tr>
</tbody>
</table>
</td></tr>
</table>
</field>
</record>
<!--
Override auth_signup.mail_template_user_signup_account_created
(sent to portal users who self-registered)
Just removes the "Powered by Odoo" footer block.
-->
<record id="auth_signup.mail_template_user_signup_account_created" model="mail.template">
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle">
<span style="font-size: 10px;">Your Account</span><br/>
<span style="font-size: 20px; font-weight: bold;">
<t t-out="object.name or ''">Marc Demo</t>
</span>
</td><td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.company_id.name"/>
</td></tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="top" style="font-size: 13px;">
<div>
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
Your account has been successfully created!<br/>
Your login is <strong><t t-out="object.email or ''">mark.brown23@example.com</t></strong><br/>
To gain access to your account, you can use the following link:
<div style="margin: 16px 0px 16px 0px;">
<a t-attf-href="/web/login?auth_login={{object.email}}"
t-attf-style="background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; padding: 8px 16px 8px 16px; text-decoration: none; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px; font-size:13px;">
Go to My Account
</a>
</div>
Thanks,<br/>
<t t-if="user.signature">
<br/>
<div>--<br/><t t-out="user.signature or ''">Mitchell Admin</t></div>
</t>
</div>
</td></tr>
<tr><td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle" align="left">
<t t-out="object.company_id.name or ''">YourCompany</t>
</td></tr>
<tr><td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
<t t-if="object.company_id.email">
| <a t-attf-href="mailto:{{object.company_id.email}}" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
</t>
<t t-if="object.company_id.website">
| <a t-att-href="object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
</t>
</td></tr>
</table>
</td>
</tr>
</tbody>
</table>
</td></tr>
</table>
</field>
</record>
<!--
Override auth_signup.portal_set_password_email
(sent to new portal users when admin invites them)
Just removes the "Powered by Odoo" footer block.
-->
<record id="auth_signup.portal_set_password_email" model="mail.template">
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0"
style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;">
<tr><td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size: 10px;">Your Account</span><br/>
<span style="font-size: 20px; font-weight: bold;" t-out="object.name or ''">Marc Demo</span>
</td>
<td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;"
t-att-alt="object.company_id.name"/>
</td>
</tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin:16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="top" style="font-size: 13px;">
<div>
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
Welcome to <t t-out="object.company_id.name">YourCompany</t>'s Portal!<br/><br/>
An account has been created for you with the following login: <t t-out="object.login">demo</t><br/><br/>
Click on the button below to pick a password and activate your account.
<div style="margin: 16px 0px 16px 0px; text-align: center;">
<a t-att-href="object.partner_id._get_signup_url()"
t-attf-style="display: inline-block; padding: 10px; text-decoration: none; font-size: 12px; background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px;">
<strong>Activate Account</strong>
</a>
</div>
<t t-out="ctx.get('welcome_message') or ''">Welcome to our company's portal.</t>
</div>
</td></tr>
<tr><td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle" align="left">
<t t-out="object.company_id.name or ''">YourCompany</t>
</td></tr>
<tr><td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
<t t-if="object.company_id.email">
| <a t-attf-href="mailto:{{ object.company_id.email }}" style="text-decoration: none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
</t>
<t t-if="object.company_id.website">
| <a t-att-href="object.company_id.website" style="text-decoration: none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
</t>
</td></tr>
</table>
</td>
</tr>
</tbody>
</table>
</td></tr>
</table>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from odoo import api, SUPERUSER_ID
from odoo.addons.fusion_whitelabels import _apply_mail_overrides
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
_apply_mail_overrides(env)

26
nexa_coa_setup/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Nexa Systems — Chart of Accounts Setup
Custom Odoo 19 module that configures the chart of accounts, taxes,
fiscal positions, analytic plans, and partner records for Nexa Systems Inc.
## Install
```
docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain \
-i nexa_coa_setup --no-http --stop-after-init
```
## Update
```
docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain \
-u nexa_coa_setup --no-http --stop-after-init
```
## Design reference
See `docs/superpowers/specs/2026-05-12-nexa-coa-design.md`.
## Safety
Always take a pg_dump BEFORE running `-i` or `-u`. See `docs/superpowers/plans/2026-05-12-nexa-coa-setup.md` Phase 0.

View File

@@ -0,0 +1,2 @@
from . import models
from .hooks import pre_init_hook, post_init_hook

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
{
"name": "Nexa Systems — Chart of Accounts Setup",
"version": "19.0.1.0.0",
"category": "Accounting/Localizations/Chart of Accounts",
"summary": "Custom CoA, taxes, fiscal positions, analytic plans, and intercompany partner setup for Nexa Systems Inc.",
"author": "Nexa Systems Inc.",
"website": "https://nexasystems.ca",
"license": "OPL-1",
"depends": [
"account",
"account_accountant",
"l10n_ca",
"analytic",
"sale_management",
"purchase",
"sale_subscription",
],
"data": [
"security/ir.model.access.csv",
"data/01_account_account.xml",
"data/02_account_journal.xml",
"data/03_account_tax.xml",
"data/04_account_fiscal_position.xml",
"data/05_account_analytic_plan.xml",
"data/06_account_analytic_account.xml",
"data/07_product_category.xml",
"data/08_res_partner_category.xml",
"data/09_res_partner.xml",
"data/10_account_reconcile_model.xml",
],
"pre_init_hook": "pre_init_hook",
"post_init_hook": "post_init_hook",
"installable": True,
"application": False,
"auto_install": False,
}

View File

@@ -0,0 +1,808 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- ============================================================ -->
<!-- 1xxxxx — ASSETS -->
<!-- ============================================================ -->
<!-- NOTE: 115100 is l10n_ca "Customers Account" (240 postings — AR control) — kept as l10n_ca.
115110 is l10n_ca "Customers Account (PoS)" — kept.
Nexa intercompany receivables live in the 119xxx range to avoid all collisions. -->
<record id="acct_119100" model="account.account">
<field name="code">119100</field>
<field name="name">Due From Shareholder — Gurpreet</field>
<field name="account_type">asset_current</field>
<field name="reconcile" eval="True"/>
</record>
<record id="acct_119900" model="account.account">
<field name="code">119900</field>
<field name="name">Due From Associated Corporations</field>
<field name="account_type">asset_current</field>
<field name="reconcile" eval="True"/>
</record>
<record id="acct_118100" model="account.account">
<field name="code">118100</field>
<field name="name">HST/GST Input Tax Credit (ITC) Receivable</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_118200" model="account.account">
<field name="code">118200</field>
<field name="name">HST/GST Instalments Paid</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_118300" model="account.account">
<field name="code">118300</field>
<field name="name">QST Input Tax Refund Receivable</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_151100" model="account.account">
<field name="code">151100</field>
<field name="name">Computer Hardware &amp; Equipment (CCA Class 50)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151200" model="account.account">
<field name="code">151200</field>
<field name="name">Office Furniture &amp; Equipment (CCA Class 8)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151300" model="account.account">
<field name="code">151300</field>
<field name="name">Vehicles (CCA Class 10/10.1)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151400" model="account.account">
<field name="code">151400</field>
<field name="name">Leasehold Improvements (CCA Class 13)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151500" model="account.account">
<field name="code">151500</field>
<field name="name">Acquired Software &amp; Intangibles (CCA Class 14.1)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151600" model="account.account">
<field name="code">151600</field>
<field name="name">Tools &amp; Small Equipment &lt;$500 (CCA Class 12)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154100" model="account.account">
<field name="code">154100</field>
<field name="name">Acc. Depreciation — Computer Hardware</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154200" model="account.account">
<field name="code">154200</field>
<field name="name">Acc. Depreciation — Office Furniture</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154300" model="account.account">
<field name="code">154300</field>
<field name="name">Acc. Depreciation — Vehicles</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154400" model="account.account">
<field name="code">154400</field>
<field name="name">Acc. Depreciation — Leasehold Improvements</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154500" model="account.account">
<field name="code">154500</field>
<field name="name">Acc. Depreciation — Acquired Software</field>
<field name="account_type">asset_fixed</field>
</record>
<!-- ============================================================ -->
<!-- 2xxxxx — LIABILITIES -->
<!-- ============================================================ -->
<record id="acct_213100" model="account.account">
<field name="code">213100</field>
<field name="name">HST/GST Collected on Sales</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_213500" model="account.account">
<field name="code">213500</field>
<field name="name">QST Collected on Sales</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_214100" model="account.account">
<field name="code">214100</field>
<field name="name">Net HST/GST Payable</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_215100" model="account.account">
<field name="code">215100</field>
<field name="name">Source Deductions Payable — Federal Tax</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_215200" model="account.account">
<field name="code">215200</field>
<field name="name">Source Deductions Payable — CPP</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_215300" model="account.account">
<field name="code">215300</field>
<field name="name">Source Deductions Payable — EI</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_216100" model="account.account">
<field name="code">216100</field>
<field name="name">Corporate Income Tax — Federal Payable</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_216200" model="account.account">
<field name="code">216200</field>
<field name="name">Corporate Income Tax — Provincial Payable</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_216300" model="account.account">
<field name="code">216300</field>
<field name="name">Corporate Tax Instalments Paid</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_221100" model="account.account">
<field name="code">221100</field>
<field name="name">Due To Shareholder — Gurpreet (short-term)</field>
<field name="account_type">liability_current</field>
<field name="reconcile" eval="True"/>
</record>
<record id="acct_221200" model="account.account">
<field name="code">221200</field>
<field name="name">Shareholder Loan — Gurpreet (long-term)</field>
<field name="account_type">liability_non_current</field>
<field name="reconcile" eval="True"/>
</record>
<record id="acct_222900" model="account.account">
<field name="code">222900</field>
<field name="name">Due To Associated Corporations</field>
<field name="account_type">liability_current</field>
<field name="reconcile" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- 3xxxxx — EQUITY -->
<!-- ============================================================ -->
<record id="acct_311100" model="account.account">
<field name="code">311100</field>
<field name="name">Share Capital — Common Shares</field>
<field name="account_type">equity</field>
</record>
<record id="acct_311200" model="account.account">
<field name="code">311200</field>
<field name="name">Share Capital — Preferred Shares</field>
<field name="account_type">equity</field>
</record>
<record id="acct_311300" model="account.account">
<field name="code">311300</field>
<field name="name">Contributed Surplus</field>
<field name="account_type">equity</field>
</record>
<record id="acct_321100" model="account.account">
<field name="code">321100</field>
<field name="name">Retained Earnings — Current Year</field>
<field name="account_type">equity</field>
</record>
<record id="acct_321200" model="account.account">
<field name="code">321200</field>
<field name="name">Retained Earnings — Prior Years</field>
<field name="account_type">equity</field>
</record>
<record id="acct_321900" model="account.account">
<field name="code">321900</field>
<field name="name">Dividends Declared</field>
<field name="account_type">equity</field>
</record>
<!-- ============================================================ -->
<!-- 4xxxxx — REVENUE -->
<!-- ============================================================ -->
<record id="acct_411100" model="account.account">
<field name="code">411100</field>
<field name="name">SaaS Subscription Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411200" model="account.account">
<field name="code">411200</field>
<field name="name">Hosting &amp; Infrastructure Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411300" model="account.account">
<field name="code">411300</field>
<field name="name">Support &amp; Maintenance Contracts Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411400" model="account.account">
<field name="code">411400</field>
<field name="name">Domain/SSL/Renewal Pass-through Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411500" model="account.account">
<field name="code">411500</field>
<field name="name">Setup / Onboarding Fees Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412100" model="account.account">
<field name="code">412100</field>
<field name="name">Custom Software Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412200" model="account.account">
<field name="code">412200</field>
<field name="name">Custom Web Application Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412300" model="account.account">
<field name="code">412300</field>
<field name="name">Custom Website Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412400" model="account.account">
<field name="code">412400</field>
<field name="name">ERP Implementation &amp; Customization Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412500" model="account.account">
<field name="code">412500</field>
<field name="name">Mobile App Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412600" model="account.account">
<field name="code">412600</field>
<field name="name">Business App / Integration Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_413100" model="account.account">
<field name="code">413100</field>
<field name="name">Consulting &amp; Advisory Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_413200" model="account.account">
<field name="code">413200</field>
<field name="name">Training &amp; Workshops Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_413300" model="account.account">
<field name="code">413300</field>
<field name="name">Technical Support — Per-incident / Hourly Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_414100" model="account.account">
<field name="code">414100</field>
<field name="name">Third-party Software Resale Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_414200" model="account.account">
<field name="code">414200</field>
<field name="name">Hardware Resale Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_419100" model="account.account">
<field name="code">419100</field>
<field name="name">Sales Discounts</field>
<field name="account_type">income</field>
</record>
<record id="acct_419200" model="account.account">
<field name="code">419200</field>
<field name="name">Sales Returns &amp; Refunds</field>
<field name="account_type">income</field>
</record>
<record id="acct_419300" model="account.account">
<field name="code">419300</field>
<field name="name">Bad Debt Recovery</field>
<field name="account_type">income_other</field>
</record>
<!-- ============================================================ -->
<!-- 5xxxxx — DIRECT COSTS (COGS) -->
<!-- ============================================================ -->
<!-- NOTE: 511100 was "Inside Purchases" in l10n_ca (1 posting from legacy bookkeeping) — kept as l10n_ca.
Cloud Infrastructure sits at 511105 to avoid collision. -->
<record id="acct_511105" model="account.account">
<field name="code">511105</field>
<field name="name">Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511110" model="account.account">
<field name="code">511110</field>
<field name="name">CDN &amp; Edge Services (Cloudflare, Fastly)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511120" model="account.account">
<field name="code">511120</field>
<field name="name">Backup &amp; Storage Services</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511130" model="account.account">
<field name="code">511130</field>
<field name="name">Database &amp; Backend Services (Supabase, hosted Postgres, Redis)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511140" model="account.account">
<field name="code">511140</field>
<field name="name">Monitoring &amp; Observability (customer-facing only)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511150" model="account.account">
<field name="code">511150</field>
<field name="name">SSL Certificates &amp; Domains (wholesale for resale)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511160" model="account.account">
<field name="code">511160</field>
<field name="name">DNS &amp; Email Hosting (wholesale for resale)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511200" model="account.account">
<field name="code">511200</field>
<field name="name">Third-party API Costs (Twilio, SendGrid, OpenAI)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511210" model="account.account">
<field name="code">511210</field>
<field name="name">Per-customer Licensing &amp; Royalties</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512100" model="account.account">
<field name="code">512100</field>
<field name="name">Subcontracted Labour — Canadian (T4A) — SR&amp;ED-eligible</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512110" model="account.account">
<field name="code">512110</field>
<field name="name">Subcontracted Labour — Foreign — NOT SR&amp;ED-eligible</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512200" model="account.account">
<field name="code">512200</field>
<field name="name">Project-specific Software &amp; Licenses</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512300" model="account.account">
<field name="code">512300</field>
<field name="name">Project Travel &amp; Onsite (rebilled)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512400" model="account.account">
<field name="code">512400</field>
<field name="name">Project Hardware (passed through)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_513100" model="account.account">
<field name="code">513100</field>
<field name="name">Cost of Software Resold</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_513200" model="account.account">
<field name="code">513200</field>
<field name="name">Cost of Hardware Resold</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_519100" model="account.account">
<field name="code">519100</field>
<field name="name">COGS Adjustments / Write-offs</field>
<field name="account_type">expense_direct_cost</field>
</record>
<!-- ============================================================ -->
<!-- 6xxxxx — OPERATING EXPENSES -->
<!-- ============================================================ -->
<record id="acct_611100" model="account.account">
<field name="code">611100</field>
<field name="name">Salaries &amp; Wages — Development (SR&amp;ED-eligible)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611200" model="account.account">
<field name="code">611200</field>
<field name="name">Salaries &amp; Wages — Sales &amp; Marketing</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611300" model="account.account">
<field name="code">611300</field>
<field name="name">Salaries &amp; Wages — Admin &amp; Operations</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611400" model="account.account">
<field name="code">611400</field>
<field name="name">Salary — Shareholder/Officer (Gurpreet)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611500" model="account.account">
<field name="code">611500</field>
<field name="name">Employer CPP / QPP Contributions</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611600" model="account.account">
<field name="code">611600</field>
<field name="name">Employer EI Premiums</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611700" model="account.account">
<field name="code">611700</field>
<field name="name">Employer Health Tax (EHT/QHST)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611800" model="account.account">
<field name="code">611800</field>
<field name="name">WCB / WSIB Premiums</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611900" model="account.account">
<field name="code">611900</field>
<field name="name">Employee Benefits (health, dental, group)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611950" model="account.account">
<field name="code">611950</field>
<field name="name">Bonuses &amp; Incentives</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611960" model="account.account">
<field name="code">611960</field>
<field name="name">Vacation Pay Accrual</field>
<field name="account_type">expense</field>
</record>
<record id="acct_612100" model="account.account">
<field name="code">612100</field>
<field name="name">Contract Labour — Canadian (admin/marketing/freelance)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_612200" model="account.account">
<field name="code">612200</field>
<field name="name">Contract Labour — Foreign</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621100" model="account.account">
<field name="code">621100</field>
<field name="name">Rent — Commercial Office</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621200" model="account.account">
<field name="code">621200</field>
<field name="name">Home Office — Business Portion</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621300" model="account.account">
<field name="code">621300</field>
<field name="name">Utilities — Commercial</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621400" model="account.account">
<field name="code">621400</field>
<field name="name">Internet &amp; Phone — Business</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621500" model="account.account">
<field name="code">621500</field>
<field name="name">Office Supplies &amp; Consumables</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621600" model="account.account">
<field name="code">621600</field>
<field name="name">Cleaning &amp; Maintenance</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621700" model="account.account">
<field name="code">621700</field>
<field name="name">Office Snacks &amp; Refreshments</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631100" model="account.account">
<field name="code">631100</field>
<field name="name">Software — Productivity (M365, Slack, Notion, Linear, GitHub)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631200" model="account.account">
<field name="code">631200</field>
<field name="name">Software — Development Tools (Cursor, Figma, IDEs)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631300" model="account.account">
<field name="code">631300</field>
<field name="name">Software — Internal Infrastructure</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631400" model="account.account">
<field name="code">631400</field>
<field name="name">Software — Security &amp; IT</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631500" model="account.account">
<field name="code">631500</field>
<field name="name">Software — Sales &amp; Marketing</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641100" model="account.account">
<field name="code">641100</field>
<field name="name">Advertising — Digital Ads</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641200" model="account.account">
<field name="code">641200</field>
<field name="name">Advertising — Content / SEO</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641300" model="account.account">
<field name="code">641300</field>
<field name="name">Trade Shows &amp; Conferences</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641400" model="account.account">
<field name="code">641400</field>
<field name="name">Promotional Items / Branded Swag</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641500" model="account.account">
<field name="code">641500</field>
<field name="name">Website — Own (nexasystems.ca)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651100" model="account.account">
<field name="code">651100</field>
<field name="name">Legal Fees — General</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651200" model="account.account">
<field name="code">651200</field>
<field name="name">Accounting &amp; Bookkeeping</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651300" model="account.account">
<field name="code">651300</field>
<field name="name">Tax Preparation (T2, T1, GST/HST)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651400" model="account.account">
<field name="code">651400</field>
<field name="name">Business Consulting</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661100" model="account.account">
<field name="code">661100</field>
<field name="name">Insurance — Commercial General Liability</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661200" model="account.account">
<field name="code">661200</field>
<field name="name">Insurance — Professional Liability / E&amp;O</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661300" model="account.account">
<field name="code">661300</field>
<field name="name">Insurance — Cyber Liability</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661400" model="account.account">
<field name="code">661400</field>
<field name="name">Insurance — Property</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661500" model="account.account">
<field name="code">661500</field>
<field name="name">Insurance — Directors &amp; Officers</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671100" model="account.account">
<field name="code">671100</field>
<field name="name">Travel — Flights, Hotels, Ground Transport</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671200" model="account.account">
<field name="code">671200</field>
<field name="name">Meals &amp; Entertainment — 50% Deductible</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671300" model="account.account">
<field name="code">671300</field>
<field name="name">Vehicle — Operating (gas, insurance, repairs, parking)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671400" model="account.account">
<field name="code">671400</field>
<field name="name">Mileage Reimbursement — Personal Vehicle</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681100" model="account.account">
<field name="code">681100</field>
<field name="name">Conferences &amp; Seminars (registration)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681200" model="account.account">
<field name="code">681200</field>
<field name="name">Courses &amp; Certifications</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681300" model="account.account">
<field name="code">681300</field>
<field name="name">Books &amp; Publications</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681400" model="account.account">
<field name="code">681400</field>
<field name="name">Professional Memberships &amp; Dues</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691100" model="account.account">
<field name="code">691100</field>
<field name="name">Bank Service Charges</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691200" model="account.account">
<field name="code">691200</field>
<field name="name">Merchant Processing Fees (Stripe, PayPal, Square)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691300" model="account.account">
<field name="code">691300</field>
<field name="name">Wire Transfer &amp; FX Fees</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691400" model="account.account">
<field name="code">691400</field>
<field name="name">Interest Expense — Bank Loans / LOC</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691500" model="account.account">
<field name="code">691500</field>
<field name="name">Interest Expense — Credit Cards</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691600" model="account.account">
<field name="code">691600</field>
<field name="name">Late Payment Penalties — Non-deductible</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699100" model="account.account">
<field name="code">699100</field>
<field name="name">Bad Debt Expense</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699200" model="account.account">
<field name="code">699200</field>
<field name="name">Donations &amp; Sponsorships (deductible)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699300" model="account.account">
<field name="code">699300</field>
<field name="name">Penalties &amp; Fines — Non-deductible</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699400" model="account.account">
<field name="code">699400</field>
<field name="name">Realized FX Losses</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699500" model="account.account">
<field name="code">699500</field>
<field name="name">Depreciation / CCA Expense</field>
<field name="account_type">expense</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
</data>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Named 'Customer Project' to avoid collision with the project module's
auto-created 'Project' plan. This is where customer-engagement
analytic accounts (PRJ-YYYY-CUST-NAME) live. -->
<record id="plan_project" model="account.analytic.plan">
<field name="name">Customer Project</field>
<field name="default_applicability">mandatory</field>
</record>
<record id="plan_department" model="account.analytic.plan">
<field name="name">Department</field>
<field name="default_applicability">mandatory</field>
</record>
<record id="plan_sred_tag" model="account.analytic.plan">
<field name="name">SR&amp;ED Tag</field>
<field name="default_applicability">optional</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Department analytic accounts -->
<record id="aa_dept_dev" model="account.analytic.account">
<field name="name">Development</field>
<field name="code">DEPT-DEV</field>
<field name="plan_id" ref="plan_department"/>
</record>
<record id="aa_dept_sales" model="account.analytic.account">
<field name="name">Sales &amp; Marketing</field>
<field name="code">DEPT-SALES</field>
<field name="plan_id" ref="plan_department"/>
</record>
<record id="aa_dept_admin" model="account.analytic.account">
<field name="name">Admin &amp; Operations</field>
<field name="code">DEPT-ADMIN</field>
<field name="plan_id" ref="plan_department"/>
</record>
<record id="aa_dept_hosting" model="account.analytic.account">
<field name="name">Hosting Operations</field>
<field name="code">DEPT-HOSTING</field>
<field name="plan_id" ref="plan_department"/>
</record>
<!-- SR&ED Tag analytic accounts -->
<record id="aa_sred_t4_dev" model="account.analytic.account">
<field name="name">T4 Dev Salary — full proxy</field>
<field name="code">SRED-T4-DEV-SALARY</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_specified" model="account.analytic.account">
<field name="name">Specified Employee Salary — 75% cap</field>
<field name="code">SRED-SPECIFIED-EMPLOYEE</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_contr_ca_arm" model="account.analytic.account">
<field name="name">Contractor CA Arm's Length — 80% eligible</field>
<field name="code">SRED-CONTRACTOR-CA-ARM-LENGTH</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_contr_ca_naf" model="account.analytic.account">
<field name="name">Contractor CA Non-Arm's Length</field>
<field name="code">SRED-CONTRACTOR-CA-NON-ARM-LENGTH</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_materials" model="account.analytic.account">
<field name="name">Materials Consumed in R&amp;D</field>
<field name="code">SRED-MATERIALS-CONSUMED</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_overhead_basis" model="account.analytic.account">
<field name="name">Overhead Proxy Basis (direct labour basis)</field>
<field name="code">SRED-OVERHEAD-PROXY-BASIS</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_not_eligible" model="account.analytic.account">
<field name="name">Not Eligible (default)</field>
<field name="code">NOT-ELIGIBLE</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
</data>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
</data>
</odoo>

134
nexa_coa_setup/hooks.py Normal file
View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
import logging
_logger = logging.getLogger(__name__)
# l10n_ca account codes that collide with the Nexa CoA design and that
# l10n_ca pre-loads with 'income_other'/'expense'/etc. types we don't want.
# Each of these is checked at pre_init: if it has zero postings we suffix
# its code with '.OLD' and archive it so our XML can claim the code.
# Codes with postings are LEFT ALONE — we renumbered the Nexa code instead
# (115100 stays as l10n_ca 'Customers Account' AR; Nexa shareholder receivable
# moved to 119100. 511100 stays as l10n_ca 'Inside Purchases'; Nexa Cloud
# Infrastructure moved to 511105).
_L10N_CA_COLLISION_CODES = [
"118100", "118200", "118300",
"213100", "214100",
"221200",
"311100", "311200", "311300",
"411100", "411200", "411300",
"413100", "413200", "413300",
"511110", "511120", "511130", "511140", "511200", "511210",
"512100", "512110", "512200",
"611100", "611200", "611300",
"612100", "612200",
]
def pre_init_hook(env):
"""Run BEFORE XML data is loaded. Clear l10n_ca account codes that would
collide with Nexa's chart of accounts."""
_logger.info("nexa_coa_setup: pre_init_hook starting")
_clear_l10n_ca_collisions(env)
_logger.info("nexa_coa_setup: pre_init_hook complete")
def _clear_l10n_ca_collisions(env):
"""For each colliding code: if it has zero postings, rename to NNNNNN.OLD
and set inactive. If it has postings, leave alone (Nexa code was renumbered
in the XML to avoid the conflict)."""
cleared = 0
kept_with_postings = 0
not_found = 0
for code in _L10N_CA_COLLISION_CODES:
acc = env["account.account"].search([("code", "=", code)], limit=1)
if not acc:
not_found += 1
continue
usage = env["account.move.line"].search_count([("account_id", "=", acc.id)])
if usage > 0:
_logger.info(
"nexa_coa_setup: keeping l10n_ca account %s (%s) — %d postings exist",
code, acc.name, usage,
)
kept_with_postings += 1
continue
new_code = f"{code}.OLD"
# Skip if already suffixed (idempotency)
if acc.code.endswith(".OLD"):
continue
acc.write({
"code": new_code,
"name": f"(l10n_ca LEGACY) {acc.name or acc.display_name}",
"active": False,
})
cleared += 1
_logger.info(
"nexa_coa_setup: collision sweep — cleared %d, kept-with-postings %d, not-found %d",
cleared, kept_with_postings, not_found,
)
def post_init_hook(env):
"""Imperative one-shot operations after module data is loaded.
Each helper is idempotent — safe to re-run on -u.
"""
_logger.info("nexa_coa_setup: post_init_hook starting")
_normalize_company_hst_number(env)
_archive_unused_l10n_ca_accounts(env)
_rename_legacy_accounts(env)
_lock_fiscal_year_2025(env)
_logger.info("nexa_coa_setup: post_init_hook complete")
def _normalize_company_hst_number(env):
"""Convert '741224877' to '741224877 RT0001' if not already in full form."""
company = env.ref("base.main_company", raise_if_not_found=False)
if not company:
return
vat = (company.partner_id.vat or "").strip()
if vat == "741224877":
company.partner_id.vat = "741224877 RT0001"
_logger.info("nexa_coa_setup: normalized HST# to '741224877 RT0001'")
def _archive_unused_l10n_ca_accounts(env):
"""Stub — filled in Phase 4. Archives ~370 unused accounts."""
pass
def _rename_legacy_accounts(env):
"""Stub — filled in Phase 4. Renames the 14xx/15xx legacy accounts."""
pass
def _lock_fiscal_year_2025(env):
"""Try to set fiscalyear_lock_date = 2025-12-31 on main company.
If Odoo blocks the lock because unreconciled bank statement lines or other
open items exist in the period, log a clear warning and continue. The user
can set the lock manually via Accounting > Configuration > Settings > Lock
Dates once those items are cleaned up.
"""
from datetime import date
from odoo.exceptions import RedirectWarning, UserError, ValidationError
company = env.ref("base.main_company", raise_if_not_found=False)
if not company:
return
target = date(2025, 12, 31)
if company.fiscalyear_lock_date and company.fiscalyear_lock_date >= target:
_logger.info("nexa_coa_setup: fiscalyear_lock_date already at or after 2025-12-31")
return
try:
company.fiscalyear_lock_date = target
_logger.info("nexa_coa_setup: fiscalyear_lock_date set to 2025-12-31")
except (RedirectWarning, UserError, ValidationError) as exc:
_logger.warning(
"nexa_coa_setup: could not auto-lock fiscal year 2025-12-31. "
"Reason: %s. Set the lock manually via Accounting > Configuration > "
"Settings > Lock Dates after the unreconciled items in the period "
"are cleaned up.",
exc,
)

View File

@@ -0,0 +1 @@
# no custom models — placeholder for future extensions

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink