Compare commits

...

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

View File

@@ -3,3 +3,4 @@
from . import portal_clock from . import portal_clock
from . import clock_api from . import clock_api
from . import clock_kiosk 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> <field name="value">True</field>
</record> </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> </odoo>

View File

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

View File

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

View File

@@ -130,6 +130,7 @@ class HrAttendance(models.Model):
('systray', 'Systray'), ('systray', 'Systray'),
('backend_fab', 'Backend FAB'), ('backend_fab', 'Backend FAB'),
('kiosk', 'Kiosk'), ('kiosk', 'Kiosk'),
('nfc_kiosk', 'NFC Kiosk'),
('manual', 'Manual'), ('manual', 'Manual'),
('auto', 'Auto Clock-Out'), ('auto', 'Auto Clock-Out'),
], ],
@@ -147,6 +148,16 @@ class HrAttendance(models.Model):
digits=(10, 2), digits=(10, 2),
help="Distance from location center at clock-out, in meters.", 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( x_fclk_break_minutes = fields.Float(
string='Break (min)', string='Break (min)',
default=0.0, default=0.0,

View File

@@ -47,6 +47,25 @@ class HrEmployee(models.Model):
groups="fusion_clock.group_fusion_clock_manager", 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 # On-time streak
x_fclk_ontime_streak = fields.Integer( x_fclk_ontime_streak = fields.Integer(
string='On-Time Streak', 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.", 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): def set_values(self):
super().set_values() super().set_values()
ICP = self.env['ir.config_parameter'].sudo() 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> </setting>
</block> </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> </app>
</xpath> </xpath>
</field> </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', 'name': 'Fusion Plating',
'version': '19.0.18.15.15', 'version': '19.0.18.15.16',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -13,6 +13,7 @@ for the design rationale.
""" """
import re import re
from markupsafe import Markup
from odoo import fields, models from odoo import fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.tools.translate import _ from odoo.tools.translate import _
@@ -115,9 +116,9 @@ class FpParentNumberedMixin(models.AbstractModel):
(new_name, new_index, self.id), (new_name, new_index, self.id),
) )
self.invalidate_recordset(['name', 'x_fc_doc_index']) self.invalidate_recordset(['name', 'x_fc_doc_index'])
so.message_post(body=_( so.message_post(body=Markup(_(
'Issued <strong>%s</strong> to %s #%s.' 'Issued <strong>%s</strong> to %s #%s.'
) % (new_name, self._name, self.id)) )) % (new_name, self._name, self.id))
return True return True
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

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

View File

@@ -279,9 +279,9 @@ class SaleOrder(models.Model):
'name': f'SO-{parent_int}', 'name': f'SO-{parent_int}',
'x_fc_parent_number': 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>.' 'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
) % (old_name, so.name)) )) % (old_name, so.name))
result = super().action_confirm() result = super().action_confirm()
for so in self: for so in self:
so._fp_auto_create_job() 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) _logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return return
# Group by (recipe, part, coating). Lines that share ALL THREE # Group by (recipe, part, coating, thickness, serial). Lines that
# collapse into one WO. Sharing only the recipe is not enough — # share ALL FIVE collapse into one WO. Same compliance reasoning
# the WO header captures part_id and coating_config_id from # as part_id + coating_id: bundling lines with different thicknesses
# first_line, and downstream the CoC prints the WO header's # or different serials under one WO would carry the first line's
# part_number on the customer-facing cert. Bundling Part A + # values onto the cert + sticker — silent mis-attestation. Sub 5
# Part B under one WO because they happen to share a recipe # added thickness_id + serial_id; this extends the grouping logic
# would put Part A's number on a cert covering both, which is # to honour them. No-recipe lines still get their own group each.
# a compliance bug (silent mis-attestation).
# No-recipe lines get their own group each.
groups = {} groups = {}
unrecipe_idx = 0 unrecipe_idx = 0
for line in plating_lines: for line in plating_lines:
@@ -433,8 +431,16 @@ class SaleOrder(models.Model):
'x_fc_coating_config_id' in line._fields 'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id and line.x_fc_coating_config_id.id
) or False ) 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: if recipe:
key = (recipe.id, part_id, coating_id) key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
else: else:
unrecipe_idx += 1 unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx) key = ('no_recipe', unrecipe_idx)

View File

@@ -29,12 +29,12 @@
</record> </record>
<record id="action_report_fp_job_sticker" model="ir.actions.report"> <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="model">fp.job</field>
<field name="report_type">qweb-pdf</field> <field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_template</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="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_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field> <field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/> <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="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline 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" 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="_partner_name" t-value="job.partner_id.name"/>
<!-- The fp.job's own name (WH/JOB/00033) is already <!-- The fp.job's own name (WH/JOB/00033) is already
printed in the header as "WO #...", so suppress printed in the header as "WO #...", so suppress
@@ -70,4 +71,48 @@
</t> </t>
</template> </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> </odoo>

View File

@@ -323,6 +323,82 @@ class TestSoConfirmHook(TransactionCase):
else: else:
self.skipTest('x_fc_part_catalog_id field not present') 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): class TestJobLifecycleHooks(TransactionCase):
def setUp(self): def setUp(self):

View File

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

View File

@@ -3,6 +3,9 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -22,9 +25,9 @@ class FpContractReview(models.Model):
"""Contract Review (QA-005). """Contract Review (QA-005).
Per-part, two-section QA review: Section 2.0 Planning / Production Per-part, two-section QA review: Section 2.0 Planning / Production
Review (signed by a QA Assistant) and Section 3.0 Quality Review Review (Planning Review stage, signed by a Planning Signer) and
(signed by a QA Manager). Both sections must be signed for the Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
review to be complete. Both sections must be signed for the review to be complete.
The review is always optional. It never blocks MO/SO/WO progression. 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 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') qty = fields.Integer(string='Qty')
due_date = fields.Date(string='Due') 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_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time')
s20_capacity_to_process = fields.Boolean(string='Capacity to Process') s20_capacity_to_process = fields.Boolean(string='Capacity to Process')
@@ -118,7 +121,7 @@ class FpContractReview(models.Model):
copy=False, 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_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)")
s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied') s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
@@ -180,8 +183,9 @@ class FpContractReview(models.Model):
state = fields.Selection( state = fields.Selection(
[('draft', 'Draft'), [('draft', 'Draft'),
('assistant_review', 'QA Assistant Review'), ('assistant_review', 'Planning Review'),
('manager_review', 'QA Manager Review'), ('manager_review', 'QA Review'),
('awaiting_info', 'Awaiting Client Info'),
('complete', 'Complete'), ('complete', 'Complete'),
('dismissed', 'Dismissed')], ('dismissed', 'Dismissed')],
default='draft', default='draft',
@@ -189,6 +193,56 @@ class FpContractReview(models.Model):
tracking=True, 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 -------------------------------------------------------- # ---- Constraints --------------------------------------------------------
_sql_constraints = [ _sql_constraints = [
@@ -351,6 +405,133 @@ class FpContractReview(models.Model):
'fusion_plating_quality.action_report_contract_review' 'fusion_plating_quality.action_report_contract_review'
).report_action(self) ).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 ------------------------------------------------------------ # ---- Helpers ------------------------------------------------------------
def _check_signer(self, section): def _check_signer(self, section):

View File

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

View File

@@ -324,12 +324,26 @@
about page size; the output PDF is multi-page if the SO has about page size; the output PDF is multi-page if the SO has
multiple plating lines. --> multiple plating lines. -->
<record id="action_report_fp_so_sticker" model="ir.actions.report"> <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="model">sale.order</field>
<field name="report_type">qweb-pdf</field> <field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_so_sticker</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="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_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field> <field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/> <field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>

View File

@@ -60,6 +60,22 @@
else (_mo and _mo.product_qty) or 0"/> 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="_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 '-'"/> <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. <!-- _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 Outer can pass '' to hide it (e.g. fp.job already shows its
own name in the header). Defaults to _mo.name. --> own name in the header). Defaults to _mo.name. -->
@@ -69,10 +85,31 @@
or (_so and _so.x_fc_internal_note or (_so and _so.x_fc_internal_note
and _so.x_fc_internal_note.striptags()[:100]) and _so.x_fc_internal_note.striptags()[:100])
or '-'"/> 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 <!-- 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( <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> <style>
@page { margin: 0; size: 152mm 102mm; } @page { margin: 0; size: 152mm 102mm; }
@@ -82,10 +119,9 @@
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
} }
/* Boxy professional layout: thick outer border, horizontal row /* 3-cell header (Logo | WO# | QR) + 2-region body (fields left,
borders, vertical label/value divider. Absolute positioning + Notes column right). Absolute positioning + % heights/widths
% row heights force the content to fill the full page in are mandatory — wkhtmltopdf ignores vh/vw/flex. ----------- */
wkhtmltopdf (which ignores vh/vw/flex). ------------------- */
.fp-sticker { .fp-sticker {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #000; color: #000;
@@ -97,14 +133,14 @@
page-break-after: always; page-break-after: always;
page-break-inside: avoid; 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 { .fp-sticker-head-wrap {
position: absolute; position: absolute;
left: 0; right: 0; top: 0; left: 0; right: 0; top: 0;
height: 40%; height: 32%;
border-bottom: 2px solid #000; border-bottom: 2px solid #000;
box-sizing: border-box; box-sizing: border-box;
padding: 0;
} }
table.fp-sticker-head { table.fp-sticker-head {
width: 100%; width: 100%;
@@ -112,82 +148,71 @@
table-layout: fixed; table-layout: fixed;
border-collapse: collapse; border-collapse: collapse;
} }
table.fp-sticker-head td { padding: 0; vertical-align: middle; } col.fp-col-head-logo { width: 28%; }
col.fp-col-head-left { width: 66%; } col.fp-col-head-wo { width: 44%; }
col.fp-col-head-right { width: 34%; } col.fp-col-head-qr { width: 28%; }
td.fp-sticker-head-left { table.fp-sticker-head td {
overflow: hidden; padding: 0;
border-right: 2px solid #000; vertical-align: middle;
}
td.fp-sticker-head-right {
text-align: center; text-align: center;
vertical-align: middle;
overflow: hidden; overflow: hidden;
} }
/* Left column nested 2-row table: logo on top, WO# below. td.fp-sticker-head-logo { border-right: 2px solid #000; padding: 0 6px; }
Horizontal divider between rows mirrors body row borders. */ td.fp-sticker-head-wo { border-right: 2px solid #000; }
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;
}
.fp-sticker-logo { .fp-sticker-logo {
/* Logo bumped 40% (116 → 162px height, 520 → 728px width). */ max-height: 135px;
max-height: 162px; max-width: 95%;
max-width: 728px; display: inline-block;
display: block; vertical-align: middle;
} }
.fp-sticker-wo { .fp-sticker-wo {
font-size: 72pt; font-size: 72pt;
font-weight: 900; font-weight: 900;
letter-spacing: 0.2mm; letter-spacing: 0.1mm;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
margin: 0; margin: 0;
} }
/* QR wrapper crops the white quiet-zone around the QR pattern /* QR wrapper crops the ~12% quiet-zone the barcode generator
so it doesn't visually float on a white square inside the adds around the QR pattern. We render the image larger than
cell. The PNG from Odoo's barcode generator carries a the wrapper and offset so the wrapper clips that border out.
~12% border (4 modules of quiet-zone) on each side; we Wrapper 365px = ~30.9mm at 300dpi (30% larger than the
render the image larger than the wrapper and offset it so previous 280px). 600x600 source = high-def at print scale. ---- */
the wrapper clips that border out. ---------------------- */
.fp-sticker-qr-wrap { .fp-sticker-qr-wrap {
width: 380px; width: 365px;
height: 380px; height: 365px;
display: inline-block; display: inline-block;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.fp-sticker-qr { .fp-sticker-qr {
width: 510px; width: 480px;
height: 510px; height: 480px;
position: absolute; position: absolute;
top: -65px; top: -58px;
left: -65px; left: -58px;
margin: 0; margin: 0;
display: block; display: block;
} }
/* ---- BODY band (7 rows, each 14.28% of the band) ---- */ /* ---- BODY band: left fields region + right Notes region. ---- */
.fp-sticker-body-wrap { .fp-sticker-body-wrap {
position: absolute; position: absolute;
left: 0; right: 0; 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 { table.fp-sticker-body {
width: 100%; width: 100%;
@@ -197,18 +222,18 @@
} }
table.fp-sticker-body tr { height: 14.28%; } table.fp-sticker-body tr { height: 14.28%; }
table.fp-sticker-body tr + tr td { border-top: 1px solid #000; } table.fp-sticker-body tr + tr td { border-top: 1px solid #000; }
col.fp-col-label { width: 32%; } col.fp-col-label { width: 38%; }
col.fp-col-value { width: 68%; } col.fp-col-value { width: 62%; }
table.fp-sticker-body td { table.fp-sticker-body td {
vertical-align: middle; vertical-align: middle;
padding: 0 14px; padding: 0 8px;
font-size: 38pt; font-size: 50pt;
line-height: 1.1; line-height: 1.0;
} }
td.fp-sticker-label { td.fp-sticker-label {
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
border-right: 2px solid #000; border-right: 1px solid #000;
background-color: #f1f2f4; background-color: #f1f2f4;
} }
td.fp-sticker-value { td.fp-sticker-value {
@@ -217,43 +242,55 @@
white-space: nowrap; white-space: nowrap;
} }
.fp-sticker-strong { font-weight: 700; } .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> </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"> <div class="fp-sticker">
<!-- 3-cell header: Logo | WO# | QR -->
<div class="fp-sticker-head-wrap"> <div class="fp-sticker-head-wrap">
<table class="fp-sticker-head"> <table class="fp-sticker-head">
<colgroup> <colgroup>
<col class="fp-col-head-left"/> <col class="fp-col-head-logo"/>
<col class="fp-col-head-right"/> <col class="fp-col-head-wo"/>
<col class="fp-col-head-qr"/>
</colgroup> </colgroup>
<tr> <tr>
<td class="fp-sticker-head-left"> <td class="fp-sticker-head-logo">
<!-- env.company.logo is often blank while logo_web <!-- env.company.logo is often blank while logo_web
is populated from the company partner's image. is populated from the partner's image. Fall
Fall back across both + partner.image_1920. --> back across both + partner.image_1920. -->
<t t-set="_logo" t-value="env.company.logo <t t-set="_logo" t-value="env.company.logo
or env.company.logo_web or env.company.logo_web
or env.company.partner_id.image_1920 or env.company.partner_id.image_1920
or False"/> or False"/>
<table class="fp-sticker-head-left-stack"> <img t-if="_logo"
<tr class="fp-row-logo"> class="fp-sticker-logo"
<td> t-att-src="image_data_uri(_logo)"/>
<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>
</td> </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"> <div class="fp-sticker-qr-wrap" t-if="_qr_src">
<img class="fp-sticker-qr" <img class="fp-sticker-qr"
t-att-src="_qr_src"/> t-att-src="_qr_src"/>
@@ -263,92 +300,95 @@
</table> </table>
</div> </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"> <div class="fp-sticker-body-wrap">
<table class="fp-sticker-body"> <div class="fp-body-left">
<colgroup> <table class="fp-sticker-body">
<col class="fp-col-label"/> <colgroup>
<col class="fp-col-value"/> <col class="fp-col-label"/>
</colgroup> <col class="fp-col-value"/>
<tr> </colgroup>
<td class="fp-sticker-label">PO (RO):</td> <tr>
<td class="fp-sticker-value"> <td class="fp-sticker-label">PO #:</td>
<span class="fp-sticker-strong" <td class="fp-sticker-value">
t-esc="_po_number"/> <span class="fp-sticker-strong"
<t t-if="_mo_ref"> t-esc="_po_number"/>
<span class="fp-sticker-muted"> </td>
(<span t-esc="_mo_ref"/>) </tr>
</span> <tr>
</t> <td class="fp-sticker-label">SN #:</td>
</td> <td class="fp-sticker-value">
</tr> <span t-esc="_serial_number"/>
<tr> </td>
<td class="fp-sticker-label">Customer:</td> </tr>
<td class="fp-sticker-value"> <tr>
<span t-esc="_partner_name"/> <td class="fp-sticker-label">Customer:</td>
</td> <td class="fp-sticker-value">
</tr> <span t-esc="_partner_display"/>
<tr> </td>
<td class="fp-sticker-label">Process:</td> </tr>
<td class="fp-sticker-value"> <tr>
<t t-if="_process"> <td class="fp-sticker-label">Part #:</td>
<span t-esc="_process.name"/> <td class="fp-sticker-value">
</t> <t t-if="_part">
<t t-elif="_coating"> <span class="fp-sticker-strong"
<span t-esc="_coating.name"/> t-esc="_part.part_number"/>
</t> <t t-if="_part.revision">
<t t-else="">-</t> <!-- Strip "Rev " prefix if the field
</td> value already includes it, so we
</tr> don't print "Rev Rev 1". -->
<tr> <t t-set="_rev_clean" t-value="_part.revision.strip()"/>
<td class="fp-sticker-label">Part Number:</td> <t t-if="_rev_clean.lower().startswith('rev ')">
<td class="fp-sticker-value"> <t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
<t t-if="_part"> </t>
<span class="fp-sticker-strong" <span class="fp-sticker-muted">
t-esc="_part.part_number"/> Rev <span t-esc="_rev_clean"/>
<t t-if="_part.revision"> </span>
<!-- Some parts store the revision with a </t>
"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()"/>
</t> </t>
<span class="fp-sticker-muted"> <t t-else="">-</t>
Rev <span t-esc="_rev_clean"/> </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> </span>
</t> </td>
</t> </tr>
<t t-else="">-</t> </table>
</td> </div>
</tr> <div class="fp-body-right">
<tr> <div class="fp-notes-label">Notes:</div>
<td class="fp-sticker-label">Due Date:</td> <div class="fp-notes-content">
<td class="fp-sticker-value"> <t t-esc="_notes_content"/>
<t t-if="_due"> </div>
<span t-esc="_due.strftime('%b %d, %Y')"/> </div>
</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>
</div> </div>
</div> </div>
</t>
</template> </template>
<!-- ===================================================== <!-- =====================================================
@@ -370,6 +410,8 @@
<t t-set="_mo_ref" t-value="False"/> <t t-set="_mo_ref" t-value="False"/>
<t t-set="_internal_note" t-value="False"/> <t t-set="_internal_note" t-value="False"/>
<t t-set="_scan_path" 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> </template>
<!-- ========== Outer template — mrp.workorder entry ========== --> <!-- ========== Outer template — mrp.workorder entry ========== -->
@@ -407,18 +449,19 @@
skipped — they don't go through plating so they don't need a skipped — they don't go through plating so they don't need a
box sticker. box sticker.
The "WO #" header shows "<SO>/<line seq>" so the sticker The "WO#" header shows the SO name (e.g. SO-30019). The body
remains identifiable before the fp.job is generated. The QR carries the part-specific fields (Part #, Customer, etc.) which
encodes /fp/so-line/<line.id> — the controller can decide disambiguate multi-line SOs without needing a sequence suffix.
whether to land on the parent SO, the line, or (later) the The QR encodes /fp/so-line/<line.id> — the controller can
spawned job. --> decide whether to land on the parent SO, the line, or (later)
the spawned job. -->
<template id="report_fp_so_sticker"> <template id="report_fp_so_sticker">
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-foreach="docs" t-as="so"> <t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)" <t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line"> t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/> <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_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/> <t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/> <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="_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="_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" 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="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/> <t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
@@ -436,4 +480,37 @@
</t> </t>
</template> </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> </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)