44 Commits

Author SHA1 Message Date
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
34 changed files with 7071 additions and 206 deletions

File diff suppressed because it is too large Load Diff

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,245 @@
# -*- 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
values = {
'page_name': 'nfc_kiosk',
'company_name': company.name,
'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,41 @@ 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='Enable Mock-Tap Debug',
config_parameter='fusion_clock.nfc_kiosk_debug',
default=False,
help="Enables a Ctrl+Shift+T keyboard shortcut on the kiosk page for "
"simulating a tap with a configurable UID. Off in production.",
)
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,422 @@
/* @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";
// ──────────────────────────────────────────────────────────────
// 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">
<div class="nfc-kiosk__icon">⌐■</div>
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
</div>
`;
}
function renderProcessing() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__processing">Reading card…</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 (top-right time + date)
// ──────────────────────────────────────────────────────────────
function updateClock() {
const now = new Date();
const hh = String(now.getHours()).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) timeEl.textContent = `${hh}:${mm}`;
if (dateEl) dateEl.textContent = dateStr;
}
updateClock();
setInterval(updateClock, 1000);
// ──────────────────────────────────────────────────────────────
// Setup wizard
// ──────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────
// Web NFC reader
// ──────────────────────────────────────────────────────────────
let ndefReader = null;
let nfcReady = false;
async function startNfcReader() {
if (!("NDEFReader" in window)) {
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
}
ndefReader = new NDEFReader();
await ndefReader.scan();
ndefReader.addEventListener("reading", onNfcReading);
ndefReader.addEventListener("readingerror", () => {
console.warn("[nfc-kiosk] reading error; reader still active");
});
nfcReady = true;
}
function onNfcReading(event) {
// event.serialNumber is the card UID — works for raw MIFARE access cards
const uid = (event.serialNumber || "").toUpperCase();
if (!uid) return;
if (currentState === STATE.ENROLL) {
// Enroll Mode handles taps differently (wired up in Task 18)
window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
return;
}
if (currentState !== STATE.IDLE) return; // ignore taps mid-result
handleTap(uid);
}
async function handleTap(uid) {
setState(STATE.PROCESSING);
let photoB64 = "";
try {
photoB64 = await capturePhoto();
} catch (e) {
console.warn("[nfc-kiosk] camera capture failed", e);
// Server enforces photo_required if needed
}
try {
const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
if (result.error === "debounce") {
// silent — back to IDLE
setState(STATE.IDLE);
return;
}
setState(STATE.RESULT, result);
} catch (e) {
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);
}
// ──────────────────────────────────────────────────────────────
// Setup wizard activation
// ──────────────────────────────────────────────────────────────
const setupBtn = document.getElementById("nfc_setup_start");
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
try {
await startNfcReader();
try {
await startCamera();
} catch (camErr) {
if (photoRequired) throw camErr;
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
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,242 @@
// NFC Clock Kiosk — always-dark, high-contrast.
// Per CLAUDE.md: shop-floor kiosks need explicit hex (no var(--bs-*) which drift)
// and we deliberately do NOT branch on $o-webclient-color-scheme — this is a
// frontend bundle (not backend), and the kiosk is intentionally always dark
// regardless of the user's color scheme preference.
$nfc-bg: #0b0d10;
$nfc-panel: #15191f;
$nfc-text: #ffffff;
$nfc-text-muted: #9ba3ad;
$nfc-success: #18a957;
$nfc-error: #d9374e;
$nfc-accent: #3b82f6;
$nfc-border: #2a3038;
html, body {
background: $nfc-bg !important;
color: $nfc-text;
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.o_main_navbar, header, footer { display: none !important; }
.nfc-kiosk {
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;
}
.nfc-kiosk__company {
position: absolute;
top: 1.5rem;
left: 50%;
transform: translateX(-50%);
font-size: 1.25rem;
color: $nfc-text-muted;
}
.nfc-kiosk__time {
position: absolute;
top: 1.5rem;
right: 2rem;
font-size: 2rem;
font-weight: 600;
color: $nfc-text;
font-variant-numeric: tabular-nums;
}
.nfc-kiosk__date {
position: absolute;
top: 4.5rem;
right: 2rem;
font-size: 1rem;
color: $nfc-text-muted;
}
.nfc-kiosk__location {
position: absolute;
bottom: 1.5rem;
left: 2rem;
font-size: 0.95rem;
color: $nfc-text-muted;
}
.nfc-kiosk__settings {
position: absolute;
bottom: 1rem;
right: 1rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: transparent;
color: $nfc-text-muted;
border: 1px solid $nfc-border;
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
&:hover { color: $nfc-text; }
}
.nfc-kiosk__idle {
text-align: center;
}
.nfc-kiosk__icon {
font-size: 8rem;
color: $nfc-accent;
animation: nfc-pulse 2s ease-in-out infinite;
}
@keyframes nfc-pulse {
0%, 100% { opacity: 0.85; transform: scale(1); }
50% { opacity: 1.0; transform: scale(1.06); }
}
.nfc-kiosk__prompt {
font-size: 2rem;
font-weight: 500;
margin-top: 2rem;
}
.nfc-kiosk__processing {
text-align: center;
font-size: 1.5rem;
color: $nfc-text-muted;
}
.nfc-kiosk__result {
width: min(80vw, 700px);
padding: 2.5rem 3rem;
border-radius: 1rem;
display: flex;
align-items: center;
gap: 2rem;
&--success { background: $nfc-success; }
&--error { background: $nfc-error; }
}
.nfc-kiosk__avatar {
width: 7rem;
height: 7rem;
border-radius: 50%;
background-size: cover;
background-position: center;
background-color: rgba(255,255,255,0.2);
flex-shrink: 0;
}
.nfc-kiosk__result-text {
flex: 1;
.name { font-size: 2rem; font-weight: 700; }
.action { font-size: 1.5rem; margin-top: 0.5rem; }
.hours { font-size: 1.1rem; opacity: 0.9; margin-top: 0.25rem; }
}
.nfc-kiosk__setup {
text-align: center;
max-width: 600px;
h2 { font-size: 2rem; margin-bottom: 1rem; }
p { color: $nfc-text-muted; margin-bottom: 2rem; }
button {
font-size: 1.5rem;
padding: 1rem 3rem;
background: $nfc-accent;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
}
}
.nfc-kiosk__enroll-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.85);
z-index: 1000;
display: flex; align-items: center; justify-content: center;
padding: 2rem;
}
.nfc-kiosk__enroll-panel {
background: $nfc-panel;
border: 1px solid $nfc-border;
border-radius: 1rem;
padding: 2.5rem;
width: min(80vw, 700px);
h2 { font-size: 1.5rem; margin: 0 0 1.5rem; }
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin: 1rem 0;
button {
font-size: 2rem; padding: 1.5rem 0;
background: $nfc-bg; color: $nfc-text;
border: 1px solid $nfc-border; border-radius: 0.5rem;
cursor: pointer;
}
}
.pin-display {
font-size: 2.5rem; letter-spacing: 0.5rem;
text-align: center; margin: 1rem 0;
font-variant-numeric: tabular-nums;
}
.employee-search {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1.25rem;
background: $nfc-bg; color: $nfc-text;
border: 1px solid $nfc-border;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.employee-list {
max-height: 40vh;
overflow-y: auto;
.employee-row {
padding: 0.75rem 1rem;
border-bottom: 1px solid $nfc-border;
cursor: pointer;
font-size: 1.1rem;
&:hover { background: $nfc-bg; }
}
}
.actions {
display: flex; gap: 1rem; justify-content: flex-end;
margin-top: 1.5rem;
button {
font-size: 1rem; padding: 0.75rem 1.5rem;
border-radius: 0.5rem; cursor: pointer; border: none;
}
.cancel { background: $nfc-border; color: $nfc-text; }
.confirm { background: $nfc-accent; color: white; }
}
}

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,45 @@
<?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'">
<!-- 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) -->
<div id="nfc_state_container">
<!-- Initially: One-time setup wizard step 1 -->
<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 element for camera feed -->
<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="Mock-Tap Debug" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_debug"/>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>

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.15',
'version': '19.0.18.15.16',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -13,6 +13,7 @@ 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 _
@@ -115,9 +116,9 @@ class FpParentNumberedMixin(models.AbstractModel):
(new_name, new_index, self.id),
)
self.invalidate_recordset(['name', 'x_fc_doc_index'])
so.message_post(body=_(
so.message_post(body=Markup(_(
'Issued <strong>%s</strong> to %s #%s.'
) % (new_name, self._name, self.id))
)) % (new_name, self._name, self.id))
return True
# ------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.22.10',
'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

@@ -279,9 +279,9 @@ class SaleOrder(models.Model):
'name': f'SO-{parent_int}',
'x_fc_parent_number': parent_int,
})
so.message_post(body=_(
so.message_post(body=Markup(_(
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
) % (old_name, so.name))
)) % (old_name, so.name))
result = super().action_confirm()
for so in self:
so._fp_auto_create_job()
@@ -412,15 +412,13 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by (recipe, part, coating). Lines that share ALL THREE
# collapse into one WO. Sharing only the recipe is not enough —
# the WO header captures part_id and coating_config_id from
# first_line, and downstream the CoC prints the WO header's
# part_number on the customer-facing cert. Bundling Part A +
# Part B under one WO because they happen to share a recipe
# would put Part A's number on a cert covering both, which is
# a compliance bug (silent mis-attestation).
# No-recipe lines get their own group each.
# 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:
@@ -433,8 +431,16 @@ class SaleOrder(models.Model):
'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)
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)

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

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

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.4.13.0',
'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

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

@@ -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)