Compare commits
136 Commits
phase6_3-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f372b462a | ||
|
|
67af54b46e | ||
|
|
5a699de1ca | ||
|
|
1b473a7873 | ||
|
|
9223f8da7c | ||
|
|
8c9b645196 | ||
|
|
2aa4bce089 | ||
|
|
46c62ebefa | ||
|
|
152e6d4328 | ||
|
|
33fff5acba | ||
|
|
2ae1c867b5 | ||
|
|
c990110646 | ||
|
|
5872583fbb | ||
|
|
c8db3915ea | ||
|
|
547e7d66a9 | ||
|
|
bfeca0ac32 | ||
|
|
40d563801a | ||
|
|
e271908109 | ||
|
|
72f75fe754 | ||
|
|
6cb352629a | ||
|
|
d53bb73055 | ||
|
|
ff51035494 | ||
|
|
0ed4f88da2 | ||
|
|
caeba27846 | ||
|
|
a2e254b934 | ||
|
|
8b14466da2 | ||
|
|
5a039ae369 | ||
|
|
aab6b9275b | ||
|
|
26a1086623 | ||
|
|
c00831a72a | ||
|
|
3a120dd400 | ||
|
|
4dc0a7cca5 | ||
|
|
4930a89970 | ||
|
|
72f0f182a6 | ||
|
|
5173554281 | ||
|
|
c2b693c97e | ||
|
|
051094813e | ||
|
|
edf3f95854 | ||
|
|
80887d6098 | ||
|
|
5d5964a327 | ||
|
|
80f80fb707 | ||
|
|
bfc138251a | ||
|
|
7dab5fb9c6 | ||
|
|
8d4c85cc52 | ||
|
|
fc17754996 | ||
|
|
0371624afb | ||
|
|
eed1c4619d | ||
|
|
170398ab6f | ||
|
|
d4e95dcd47 | ||
|
|
e1fedf7231 | ||
|
|
9a2975b154 | ||
|
|
271a995455 | ||
|
|
056178b433 | ||
|
|
2285c9def1 | ||
|
|
6afc9e3c0d | ||
|
|
b06d28e7f6 | ||
|
|
7b90f210b9 | ||
|
|
c75d2bde5a | ||
|
|
9e6b88f60e | ||
|
|
dc6afdd021 | ||
|
|
978cd5953e | ||
|
|
b869c31ba3 | ||
|
|
67fc22237b | ||
|
|
d9f2983ea7 | ||
|
|
3120612e35 | ||
|
|
2a93ece4ba | ||
|
|
b26fa13044 | ||
|
|
7ff46af192 | ||
|
|
6d4b6284ad | ||
|
|
d8456fb9a3 | ||
|
|
b41d9629e1 | ||
|
|
10477a7c8f | ||
|
|
8f6302b446 | ||
|
|
87e924d1d8 | ||
|
|
7fab01e5cb | ||
|
|
4911088dc1 | ||
|
|
086ff512b6 | ||
|
|
96e33834bd | ||
|
|
765b095035 | ||
|
|
358b90516b | ||
|
|
dd0dc26232 | ||
|
|
1dea752a29 | ||
|
|
9f3edd60ae | ||
|
|
0b92294586 | ||
|
|
a52ef29a84 | ||
|
|
97deb93ee7 | ||
|
|
b67186a25b | ||
|
|
258782e3c3 | ||
|
|
acc95d8ee0 | ||
|
|
e9b82fbe9d | ||
|
|
c3bcb4b99d | ||
|
|
cfaf4657ce | ||
|
|
7966f8d505 | ||
|
|
839a7f0abc | ||
|
|
0f751d82cc | ||
|
|
aa8161f764 | ||
|
|
31740b3949 | ||
|
|
e99cf20887 | ||
|
|
cc5542833f | ||
|
|
0568d8ae87 | ||
|
|
c2180d3691 | ||
|
|
42036c23ab | ||
|
|
7bcbcb4008 | ||
|
|
0047f49d2c | ||
|
|
5cc1117f75 | ||
|
|
de3ec7d97a | ||
|
|
89a937fb32 | ||
|
|
830b29ce49 | ||
|
|
269f9984ef | ||
|
|
9e5c23f37d | ||
|
|
36cd4341a7 | ||
|
|
c34dfce6c3 | ||
|
|
84ed406c8e | ||
|
|
f4e1f9d218 | ||
|
|
8eb2c2de95 | ||
|
|
bdf676e05a | ||
|
|
6c7e11db4d | ||
|
|
a53b03265d | ||
|
|
560ffa2cdf | ||
|
|
d89546bec7 | ||
|
|
818dfa3882 | ||
|
|
772107d25b | ||
|
|
c61371005a | ||
|
|
7a0bd67fc0 | ||
|
|
efc420b4ce | ||
|
|
fd2b2908f3 | ||
|
|
eb1fd50add | ||
|
|
a60506a645 | ||
|
|
8b9b4d60ad | ||
|
|
a90eace4d0 | ||
|
|
7c2ae84e32 | ||
|
|
63d692b322 | ||
|
|
1a3ca8704e | ||
|
|
d6ebcb6233 | ||
|
|
48805b5988 | ||
|
|
005daade55 |
7
fusion-plating/.claude/settings.local.json
Normal file
7
fusion-plating/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
358
fusion_clock/CLAUDE.md
Normal file
358
fusion_clock/CLAUDE.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# Fusion Clock - Claude Code Instructions
|
||||||
|
|
||||||
|
> Read together with the repo-root `../CLAUDE.md` for global Odoo 19 rules, asset-cache handling, Supabase KB notes, and shared Fusion conventions. This file is only for the `fusion_clock` module.
|
||||||
|
|
||||||
|
## 1. What This Module Is
|
||||||
|
|
||||||
|
- **Name**: Fusion Clock.
|
||||||
|
- **Version**: `19.0.3.3.0`.
|
||||||
|
- **Category**: Human Resources/Attendances.
|
||||||
|
- **License**: OPL-1, Nexa Systems Inc.
|
||||||
|
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
|
||||||
|
- **Top-level menu**: `Fusion Clock`.
|
||||||
|
- **Main surfaces**:
|
||||||
|
- Portal clock page at `/my/clock`.
|
||||||
|
- Portal timesheets at `/my/clock/timesheets`.
|
||||||
|
- Portal reports at `/my/clock/reports`.
|
||||||
|
- Shared PIN kiosk at `/fusion_clock/kiosk`.
|
||||||
|
- NFC tap kiosk at `/fusion_clock/kiosk/nfc`.
|
||||||
|
- Backend systray clock widget.
|
||||||
|
- Backend manager/team-lead dashboard client action.
|
||||||
|
|
||||||
|
Core behaviours: geofenced clock-in/out, IP whitelist fallback, shift scheduling, break deduction, penalties, overtime, auto clock-out, absence detection, leave requests, correction workflow, payroll CSV export, PDF reports, weekly summaries, shared kiosk, NFC kiosk with photo capture, and activity audit logs.
|
||||||
|
|
||||||
|
## 2. Dependencies
|
||||||
|
|
||||||
|
Declared in `__manifest__.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
hr_attendance, hr, portal, mail, resource
|
||||||
|
```
|
||||||
|
|
||||||
|
External Python used directly:
|
||||||
|
|
||||||
|
- `pytz` for timezone-safe local day boundaries.
|
||||||
|
- `requests` for Google Geocoding, OpenStreetMap/Nominatim fallback, and IP metadata.
|
||||||
|
- `dateutil.relativedelta` inside pay-period calculations.
|
||||||
|
|
||||||
|
External browser APIs:
|
||||||
|
|
||||||
|
- Browser geolocation.
|
||||||
|
- `ipapi.co` fallback geolocation in frontend/backend clock widgets.
|
||||||
|
- Google Maps/Places when `fusion_clock.google_maps_api_key` is configured.
|
||||||
|
- Web NFC and camera APIs for the NFC kiosk.
|
||||||
|
|
||||||
|
## 3. Naming And Field Prefixes
|
||||||
|
|
||||||
|
This module uses the module-specific prefix **`x_fclk_*`** on inherited Odoo models, not `x_fc_*`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `hr.employee.x_fclk_enable_clock`
|
||||||
|
- `hr.employee.x_fclk_nfc_card_uid`
|
||||||
|
- `hr.attendance.x_fclk_clock_source`
|
||||||
|
- `res.company.x_fclk_nfc_kiosk_location_id`
|
||||||
|
|
||||||
|
New inherited fields in this module should keep the `x_fclk_*` prefix unless there is a strong migration reason not to.
|
||||||
|
|
||||||
|
## 4. Model Map
|
||||||
|
|
||||||
|
Custom models:
|
||||||
|
|
||||||
|
| Model | File | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion.clock.location` | `models/clock_location.py` | Geofenced/IP-whitelisted clock locations. |
|
||||||
|
| `fusion.clock.shift` | `models/clock_shift.py` | Shift start/end/break schedule assigned to employees. |
|
||||||
|
| `fusion.clock.penalty` | `models/clock_penalty.py` | Late clock-in / early clock-out penalty records. |
|
||||||
|
| `fusion.clock.activity.log` | `models/clock_activity_log.py` | Append-style audit log for clock activity, geofence misses, absences, NFC enrolment, corrections. |
|
||||||
|
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
|
||||||
|
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
|
||||||
|
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
|
||||||
|
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
|
||||||
|
|
||||||
|
Inherited models:
|
||||||
|
|
||||||
|
- `hr.employee`: enable clock, default location, shift, kiosk PIN, NFC UID, pending reason flag, streaks, absence/overtime counters, and One2many links.
|
||||||
|
- `hr.attendance`: clock source, location, distances, photos, break minutes, net hours, penalties, auto clock-out flag, overtime fields.
|
||||||
|
- `res.config.settings`: all `fusion_clock.*` settings.
|
||||||
|
- `res.company`: NFC kiosk location binding.
|
||||||
|
|
||||||
|
Timezone helpers live in `models/tz_utils.py`. Use `get_local_today()` and `get_local_day_boundaries()` for attendance domains instead of comparing UTC dates directly.
|
||||||
|
|
||||||
|
## 5. Clocking Flow
|
||||||
|
|
||||||
|
Primary API endpoint: `/fusion_clock/clock_action` in `controllers/clock_api.py`.
|
||||||
|
|
||||||
|
Clock-in flow:
|
||||||
|
|
||||||
|
1. Resolve current user to `hr.employee`.
|
||||||
|
2. Block if `x_fclk_enable_clock` is false.
|
||||||
|
3. If `x_fclk_pending_reason` is true, return `requires_reason`.
|
||||||
|
4. Verify location against allowed active `fusion.clock.location` records.
|
||||||
|
5. Call Odoo's `_attendance_action_change()`.
|
||||||
|
6. Write location, distance, source, and optional photo to `hr.attendance`.
|
||||||
|
7. Log `clock_in`.
|
||||||
|
8. Create `late_in` penalty when outside grace.
|
||||||
|
9. Increment/reset on-time streak; log milestone at 5, 10, 20, 50, 100.
|
||||||
|
10. Notify office user for very-late clock-ins.
|
||||||
|
|
||||||
|
Clock-out flow:
|
||||||
|
|
||||||
|
1. Verify location again.
|
||||||
|
2. Call `_attendance_action_change()`.
|
||||||
|
3. Write out-distance.
|
||||||
|
4. Apply break deduction when configured.
|
||||||
|
5. Create `early_out` penalty when outside grace.
|
||||||
|
6. Log `clock_out`.
|
||||||
|
7. Log overtime if computed overtime is positive.
|
||||||
|
|
||||||
|
Location verification uses GPS when coordinates are available and geocoded locations exist. IP whitelist matching is attempted when a client IP is available. Error types include `no_locations`, `gps_unavailable`, `no_geocoded`, and `outside`.
|
||||||
|
|
||||||
|
## 6. Kiosk And NFC
|
||||||
|
|
||||||
|
Classic kiosk:
|
||||||
|
|
||||||
|
- Page: `/fusion_clock/kiosk`
|
||||||
|
- JSON routes:
|
||||||
|
- `/fusion_clock/kiosk/search`
|
||||||
|
- `/fusion_clock/kiosk/verify_pin`
|
||||||
|
- `/fusion_clock/kiosk/clock`
|
||||||
|
- Requires `fusion_clock.group_fusion_clock_manager`.
|
||||||
|
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
|
||||||
|
- Uses `hr.employee.x_fclk_kiosk_pin`.
|
||||||
|
|
||||||
|
NFC kiosk:
|
||||||
|
|
||||||
|
- Page: `/fusion_clock/kiosk/nfc`
|
||||||
|
- JSON routes:
|
||||||
|
- `/fusion_clock/kiosk/nfc/enroll`
|
||||||
|
- `/fusion_clock/kiosk/nfc/tap`
|
||||||
|
- `/fusion_clock/kiosk/nfc/employee_search`
|
||||||
|
- Requires `fusion_clock.group_fusion_clock_manager`.
|
||||||
|
- Controlled by:
|
||||||
|
- `fusion_clock.enable_nfc_kiosk`
|
||||||
|
- `fusion_clock.nfc_photo_required`
|
||||||
|
- `fusion_clock.nfc_enroll_password`
|
||||||
|
- `fusion_clock.nfc_kiosk_debug`
|
||||||
|
- `res.company.x_fclk_nfc_kiosk_location_id`
|
||||||
|
- Card UID canonical format is uppercase colon-separated hex, e.g. `04:A2:B5:62:C1:80`.
|
||||||
|
- Normalization lives in `FusionClockNfcKiosk._normalize_uid()` and is reused by the backend wizard.
|
||||||
|
- Tap debounce is module-level memory in `controllers/clock_nfc_kiosk.py`: same UID within 5 seconds returns `debounce`.
|
||||||
|
- Photo data URLs are stripped before writing binary fields.
|
||||||
|
- NFC clock-ins write `x_fclk_check_in_photo`; NFC clock-outs write `x_fclk_check_out_photo`.
|
||||||
|
|
||||||
|
Important: unknown-card taps currently return `card_unknown`; the `unknown_card_tap` log type exists but is not written by the endpoint.
|
||||||
|
|
||||||
|
## 7. Reports And Payroll Export
|
||||||
|
|
||||||
|
`fusion.clock.report` supports:
|
||||||
|
|
||||||
|
- Employee reports when `employee_id` is set.
|
||||||
|
- Batch reports when `employee_id` is empty.
|
||||||
|
- PDF generation through QWeb reports:
|
||||||
|
- `fusion_clock.action_report_clock_employee`
|
||||||
|
- `fusion_clock.action_report_clock_batch`
|
||||||
|
- CSV export via `action_export_csv()`.
|
||||||
|
- Custom CSV headings via JSON in `fusion_clock.csv_column_mapping`.
|
||||||
|
- Email send with generated PDF attached.
|
||||||
|
|
||||||
|
Pay period types:
|
||||||
|
|
||||||
|
```
|
||||||
|
weekly, biweekly, semi_monthly, monthly
|
||||||
|
```
|
||||||
|
|
||||||
|
The anchor date setting is `fusion_clock.pay_period_start` as a string in `YYYY-MM-DD` format.
|
||||||
|
|
||||||
|
Historical report generation is exposed through the `Generate Historical Reports` menu action and creates draft reports for completed attendance periods. The scheduled report cron only generates when yesterday is the period end.
|
||||||
|
|
||||||
|
## 8. Scheduled Automation
|
||||||
|
|
||||||
|
Configured in `data/ir_cron_data.xml`:
|
||||||
|
|
||||||
|
| Cron | Model method | Frequency |
|
||||||
|
|---|---|---|
|
||||||
|
| Fusion Clock: Auto Clock-Out | `hr.attendance._cron_fusion_auto_clock_out()` | Every 15 minutes |
|
||||||
|
| Fusion Clock: Generate Period Reports | `fusion.clock.report._cron_generate_period_reports()` | Daily |
|
||||||
|
| Fusion Clock: Daily Absence Check | `hr.attendance._cron_fusion_check_absences()` | Daily |
|
||||||
|
| Fusion Clock: Employee Reminders | `hr.attendance._cron_fusion_employee_reminders()` | Every 15 minutes |
|
||||||
|
| Fusion Clock: Weekly Summary | `hr.attendance._cron_fusion_weekly_summary()` | Daily, internally sends Mondays |
|
||||||
|
|
||||||
|
Auto clock-out closes open attendances after scheduled end plus grace, capped by max shift hours. It sets `x_fclk_pending_reason` so the employee must explain before clocking in again.
|
||||||
|
|
||||||
|
Absence detection checks enabled employees, skips weekends and global resource calendar leaves, and logs `absent` when no attendance or leave request exists.
|
||||||
|
|
||||||
|
## 9. Security
|
||||||
|
|
||||||
|
Groups:
|
||||||
|
|
||||||
|
- `group_fusion_clock_user`
|
||||||
|
- `group_fusion_clock_team_lead`
|
||||||
|
- `group_fusion_clock_manager`
|
||||||
|
|
||||||
|
Admin is auto-assigned to manager in `security/security.xml`.
|
||||||
|
|
||||||
|
Access pattern:
|
||||||
|
|
||||||
|
- Users and portal users can read their own clock data.
|
||||||
|
- Team leads can read direct reports for penalties, activity logs, corrections, and dashboard data.
|
||||||
|
- Managers have full model access and all configuration/kiosk/report menus.
|
||||||
|
- Portal rules are defined for `hr.attendance`, `fusion.clock.location`, `fusion.clock.report`, `fusion.clock.penalty`, `fusion.clock.activity.log`, `fusion.clock.leave.request`, `fusion.clock.correction`, and `fusion.clock.shift`.
|
||||||
|
|
||||||
|
Backend dashboard access is checked in `/fusion_clock/dashboard_data`: manager sees all enabled employees; team lead sees employees where `parent_id` is the current user's employee.
|
||||||
|
|
||||||
|
## 10. Frontend Assets
|
||||||
|
|
||||||
|
Frontend bundle:
|
||||||
|
|
||||||
|
- `static/src/css/portal_clock.css`
|
||||||
|
- `static/src/scss/nfc_kiosk.scss`
|
||||||
|
- `static/src/js/fusion_clock_portal.js`
|
||||||
|
- `static/src/js/fusion_clock_kiosk.js`
|
||||||
|
- `static/src/js/fusion_clock_nfc_kiosk.js`
|
||||||
|
|
||||||
|
Backend bundle:
|
||||||
|
|
||||||
|
- `static/src/scss/fusion_clock.scss`
|
||||||
|
- `static/src/js/fusion_clock_systray.js`
|
||||||
|
- `static/src/xml/systray_clock.xml`
|
||||||
|
- `static/src/js/fusion_clock_dashboard.js`
|
||||||
|
- `static/src/xml/fusion_clock_dashboard.xml`
|
||||||
|
- `static/src/js/fusion_clock_location_map.js`
|
||||||
|
- `static/src/js/fusion_clock_location_places.js`
|
||||||
|
- `static/src/xml/fusion_clock_location.xml`
|
||||||
|
|
||||||
|
Patterns:
|
||||||
|
|
||||||
|
- Public portal/kiosk JS should use `Interaction` from `@web/public/interaction` and register in `registry.category("public.interactions")`.
|
||||||
|
- Backend OWL client actions and field widgets use standalone `rpc()` from `@web/core/network/rpc`.
|
||||||
|
- `fusion_clock_systray.js` is a systray OWL component registered as `fusion_clock.ClockSystray`.
|
||||||
|
- `fusion_clock_dashboard.js` is a client action registered as `fusion_clock.Dashboard`.
|
||||||
|
- Location widgets are registered field widgets: `fclk_location_map` and `fclk_places_autocomplete`.
|
||||||
|
|
||||||
|
Known technical debt:
|
||||||
|
|
||||||
|
- `static/src/js/fusion_clock_nfc_kiosk.js` is currently an isolated IIFE. If touching it, prefer migrating to an Odoo 19 `Interaction` instead of expanding the IIFE pattern.
|
||||||
|
- `static/src/css/portal_clock.css` and `static/src/scss/fusion_clock.scss` contain runtime dark-mode selectors/media rules. For backend SCSS changes, follow the repo-root Odoo 19 compile-time dark bundle guidance.
|
||||||
|
- `fusion_clock.scss` uses some Bootstrap CSS vars for status accents. Avoid relying on Bootstrap vars for card/background/border surfaces in new dashboard work.
|
||||||
|
|
||||||
|
## 11. Settings Keys
|
||||||
|
|
||||||
|
Important `ir.config_parameter` keys:
|
||||||
|
|
||||||
|
```
|
||||||
|
fusion_clock.default_clock_in_time
|
||||||
|
fusion_clock.default_clock_out_time
|
||||||
|
fusion_clock.default_break_minutes
|
||||||
|
fusion_clock.auto_deduct_break
|
||||||
|
fusion_clock.break_threshold_hours
|
||||||
|
fusion_clock.enable_auto_clockout
|
||||||
|
fusion_clock.grace_period_minutes
|
||||||
|
fusion_clock.max_shift_hours
|
||||||
|
fusion_clock.enable_penalties
|
||||||
|
fusion_clock.penalty_grace_minutes
|
||||||
|
fusion_clock.penalty_deduction_minutes
|
||||||
|
fusion_clock.enable_overtime
|
||||||
|
fusion_clock.daily_overtime_threshold
|
||||||
|
fusion_clock.weekly_overtime_threshold
|
||||||
|
fusion_clock.office_user_id
|
||||||
|
fusion_clock.very_late_threshold_minutes
|
||||||
|
fusion_clock.max_monthly_absences
|
||||||
|
fusion_clock.enable_employee_notifications
|
||||||
|
fusion_clock.reminder_before_shift_minutes
|
||||||
|
fusion_clock.reminder_before_end_minutes
|
||||||
|
fusion_clock.send_weekly_summary
|
||||||
|
fusion_clock.enable_ip_fallback
|
||||||
|
fusion_clock.enable_photo_verification
|
||||||
|
fusion_clock.google_maps_api_key
|
||||||
|
fusion_clock.enable_kiosk
|
||||||
|
fusion_clock.kiosk_pin_required
|
||||||
|
fusion_clock.enable_correction_requests
|
||||||
|
fusion_clock.enable_sounds
|
||||||
|
fusion_clock.pay_period_type
|
||||||
|
fusion_clock.pay_period_start
|
||||||
|
fusion_clock.auto_generate_reports
|
||||||
|
fusion_clock.send_employee_reports
|
||||||
|
fusion_clock.report_recipient_user_ids
|
||||||
|
fusion_clock.report_recipient_emails
|
||||||
|
fusion_clock.csv_column_mapping
|
||||||
|
fusion_clock.enable_nfc_kiosk
|
||||||
|
fusion_clock.nfc_photo_required
|
||||||
|
fusion_clock.nfc_enroll_password
|
||||||
|
fusion_clock.nfc_kiosk_debug
|
||||||
|
```
|
||||||
|
|
||||||
|
`fclk_report_recipient_user_ids` is a Many2many on settings but is persisted manually as comma-separated user IDs in `fusion_clock.report_recipient_user_ids`.
|
||||||
|
|
||||||
|
## 12. Routes
|
||||||
|
|
||||||
|
HTTP pages:
|
||||||
|
|
||||||
|
```
|
||||||
|
/my/clock
|
||||||
|
/my/clock/timesheets
|
||||||
|
/my/clock/reports
|
||||||
|
/my/clock/reports/<report_id>/download
|
||||||
|
/fusion_clock/kiosk
|
||||||
|
/fusion_clock/kiosk/nfc
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON-RPC endpoints:
|
||||||
|
|
||||||
|
```
|
||||||
|
/fusion_clock/verify_location
|
||||||
|
/fusion_clock/clock_action
|
||||||
|
/fusion_clock/submit_reason
|
||||||
|
/fusion_clock/request_leave
|
||||||
|
/fusion_clock/request_correction
|
||||||
|
/fusion_clock/get_status
|
||||||
|
/fusion_clock/get_locations
|
||||||
|
/fusion_clock/get_settings
|
||||||
|
/fusion_clock/dashboard_data
|
||||||
|
/fusion_clock/kiosk/search
|
||||||
|
/fusion_clock/kiosk/verify_pin
|
||||||
|
/fusion_clock/kiosk/clock
|
||||||
|
/fusion_clock/kiosk/nfc/enroll
|
||||||
|
/fusion_clock/kiosk/nfc/tap
|
||||||
|
/fusion_clock/kiosk/nfc/employee_search
|
||||||
|
```
|
||||||
|
|
||||||
|
All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
|
||||||
|
|
||||||
|
## 13. Gotchas
|
||||||
|
|
||||||
|
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
|
||||||
|
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
|
||||||
|
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
|
||||||
|
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
|
||||||
|
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`.
|
||||||
|
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present.
|
||||||
|
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
|
||||||
|
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
|
||||||
|
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.
|
||||||
|
- If CSS/assets change, bump `__manifest__.py` version so Odoo rebuilds bundles.
|
||||||
|
|
||||||
|
## 14. Tests
|
||||||
|
|
||||||
|
Tests are post-install tagged:
|
||||||
|
|
||||||
|
```
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage currently focuses on NFC:
|
||||||
|
|
||||||
|
- `tests/test_nfc_models.py`: employee UID uniqueness, attendance NFC source/photo fields, company kiosk location field.
|
||||||
|
- `tests/test_clock_nfc_kiosk.py`: kiosk page gating, UID normalization, enroll endpoint, tap happy path, tap errors, photo-required handling, employee search.
|
||||||
|
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --test-tags fusion_clock --stop-after-init
|
||||||
|
```
|
||||||
|
|
||||||
|
For a normal module upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init
|
||||||
|
```
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.3.0',
|
'version': '19.0.3.5.6',
|
||||||
'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': """
|
||||||
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'views/clock_correction_views.xml',
|
'views/clock_correction_views.xml',
|
||||||
'views/clock_dashboard_views.xml',
|
'views/clock_dashboard_views.xml',
|
||||||
'views/hr_employee_views.xml',
|
'views/hr_employee_views.xml',
|
||||||
|
'views/clock_schedule_views.xml',
|
||||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||||
'wizard/clock_nfc_enrollment_views.xml',
|
'wizard/clock_nfc_enrollment_views.xml',
|
||||||
'views/clock_menus.xml',
|
'views/clock_menus.xml',
|
||||||
@@ -89,15 +90,22 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||||
],
|
],
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
'fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss',
|
||||||
|
'fusion_clock/static/src/scss/fusion_clock_shift_planner.scss',
|
||||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
'fusion_clock/static/src/scss/fusion_clock.scss',
|
||||||
'fusion_clock/static/src/js/fusion_clock_systray.js',
|
'fusion_clock/static/src/js/fusion_clock_systray.js',
|
||||||
'fusion_clock/static/src/xml/systray_clock.xml',
|
'fusion_clock/static/src/xml/systray_clock.xml',
|
||||||
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
|
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
|
||||||
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
|
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
|
||||||
|
'fusion_clock/static/src/js/fusion_clock_shift_planner.js',
|
||||||
|
'fusion_clock/static/src/xml/fusion_clock_shift_planner.xml',
|
||||||
'fusion_clock/static/src/js/fusion_clock_location_map.js',
|
'fusion_clock/static/src/js/fusion_clock_location_map.js',
|
||||||
'fusion_clock/static/src/js/fusion_clock_location_places.js',
|
'fusion_clock/static/src/js/fusion_clock_location_places.js',
|
||||||
'fusion_clock/static/src/xml/fusion_clock_location.xml',
|
'fusion_clock/static/src/xml/fusion_clock_location.xml',
|
||||||
],
|
],
|
||||||
|
'web.assets_web_dark': [
|
||||||
|
'fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@@ -4,3 +4,4 @@ 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
|
from . import clock_nfc_kiosk
|
||||||
|
from . import shift_planner
|
||||||
|
|||||||
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import math
|
import math
|
||||||
import logging
|
import logging
|
||||||
|
import pytz
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from odoo import http, fields, _
|
from odoo import http, fields, _
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||||
return
|
return
|
||||||
|
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
|
||||||
|
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||||
|
return
|
||||||
|
|
||||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||||
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
|
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
|
||||||
@@ -161,7 +165,16 @@ class FusionClockAPI(http.Controller):
|
|||||||
worked = attendance.worked_hours or 0.0
|
worked = attendance.worked_hours or 0.0
|
||||||
|
|
||||||
if worked >= threshold:
|
if worked >= threshold:
|
||||||
break_min = employee._get_fclk_break_minutes()
|
local_date = get_local_today(request.env, employee)
|
||||||
|
if attendance.check_in:
|
||||||
|
tz_name = (
|
||||||
|
employee.resource_id.tz
|
||||||
|
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||||
|
or employee.company_id.partner_id.tz
|
||||||
|
or 'UTC'
|
||||||
|
)
|
||||||
|
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||||
|
break_min = employee._get_fclk_break_minutes(local_date)
|
||||||
current = attendance.x_fclk_break_minutes or 0.0
|
current = attendance.x_fclk_break_minutes or 0.0
|
||||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||||
new_val = max(break_min, current)
|
new_val = max(break_min, current)
|
||||||
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
|
|||||||
|
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
today = get_local_today(request.env, employee)
|
today = get_local_today(request.env, employee)
|
||||||
|
day_plan = employee._get_fclk_day_plan(today)
|
||||||
|
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||||
|
|
||||||
geo_info = {
|
geo_info = {
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
|
|||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_scheduled_off:
|
||||||
|
self._log_activity(
|
||||||
|
employee, 'unscheduled_shift',
|
||||||
|
f"Clocked in on a scheduled OFF day at {location.name}.",
|
||||||
|
attendance=attendance, location=location,
|
||||||
|
latitude=latitude, longitude=longitude, distance=distance,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||||
|
if office_user_id:
|
||||||
|
request.env['hr.attendance'].sudo()._fclk_notify_office(
|
||||||
|
office_user_id,
|
||||||
|
f"Unscheduled Shift: {employee.name}",
|
||||||
|
f"{employee.name} clocked in on a scheduled OFF day.",
|
||||||
|
'hr.attendance',
|
||||||
|
attendance.id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'action': 'clock_in',
|
||||||
|
'attendance_id': attendance.id,
|
||||||
|
'check_in': fields.Datetime.to_string(attendance.check_in),
|
||||||
|
'location_name': location.name,
|
||||||
|
'location_address': location.address or '',
|
||||||
|
'message': f'Clocked in at {location.name} (unscheduled shift)',
|
||||||
|
'streak': employee.x_fclk_ontime_streak,
|
||||||
|
}
|
||||||
|
|
||||||
# Check for late clock-in penalty
|
# Check for late clock-in penalty
|
||||||
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
||||||
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||||
@@ -359,6 +402,7 @@ class FusionClockAPI(http.Controller):
|
|||||||
self._apply_break_deduction(attendance, employee)
|
self._apply_break_deduction(attendance, employee)
|
||||||
|
|
||||||
# Check for early clock-out penalty
|
# Check for early clock-out penalty
|
||||||
|
if not is_scheduled_off:
|
||||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
|
|
||||||
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
|
|||||||
'pending_reason': employee.x_fclk_pending_reason,
|
'pending_reason': employee.x_fclk_pending_reason,
|
||||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||||
}
|
}
|
||||||
|
local_today = get_local_today(request.env, employee)
|
||||||
|
day_plan = employee._get_fclk_day_plan(local_today)
|
||||||
|
result.update({
|
||||||
|
'scheduled_shift': day_plan.get('label') or '',
|
||||||
|
'scheduled_hours': round(day_plan.get('hours') or 0.0, 2),
|
||||||
|
'scheduled_off': bool(day_plan.get('is_off')),
|
||||||
|
})
|
||||||
|
|
||||||
if is_checked_in:
|
if is_checked_in:
|
||||||
att = request.env['hr.attendance'].sudo().search([
|
att = request.env['hr.attendance'].sudo().search([
|
||||||
@@ -533,7 +584,6 @@ class FusionClockAPI(http.Controller):
|
|||||||
'location_id': att.x_fclk_location_id.id or False,
|
'location_id': att.x_fclk_location_id.id or False,
|
||||||
})
|
})
|
||||||
|
|
||||||
local_today = get_local_today(request.env, employee)
|
|
||||||
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
|
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
|
||||||
today_atts = request.env['hr.attendance'].sudo().search([
|
today_atts = request.env['hr.attendance'].sudo().search([
|
||||||
('employee_id', '=', employee.id),
|
('employee_id', '=', employee.id),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from odoo import http, fields, _
|
from odoo import http, fields, _
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
|
|||||||
|
|
||||||
is_checked_in = employee.attendance_state == 'checked_in'
|
is_checked_in = employee.attendance_state == 'checked_in'
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
today = now.date()
|
today = get_local_today(request.env, employee)
|
||||||
|
day_plan = employee._get_fclk_day_plan(today)
|
||||||
|
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||||
|
|
||||||
geo_info = {
|
geo_info = {
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
@@ -120,6 +123,15 @@ class FusionClockKiosk(http.Controller):
|
|||||||
source='kiosk',
|
source='kiosk',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_scheduled_off:
|
||||||
|
api._log_activity(
|
||||||
|
employee, 'unscheduled_shift',
|
||||||
|
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||||
|
attendance=attendance, location=location,
|
||||||
|
latitude=latitude, longitude=longitude, distance=distance,
|
||||||
|
source='kiosk',
|
||||||
|
)
|
||||||
|
else:
|
||||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||||
|
|
||||||
@@ -135,6 +147,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
})
|
})
|
||||||
api._apply_break_deduction(attendance, employee)
|
api._apply_break_deduction(attendance, employee)
|
||||||
|
|
||||||
|
if not is_scheduled_off:
|
||||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import time
|
|||||||
import threading
|
import threading
|
||||||
from odoo import fields, http
|
from odoo import fields, http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
|
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
|
||||||
@@ -183,7 +184,9 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
|
|
||||||
is_checked_in = employee.attendance_state == 'checked_in'
|
is_checked_in = employee.attendance_state == 'checked_in'
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
today = now.date()
|
today = get_local_today(request.env, employee)
|
||||||
|
day_plan = employee._get_fclk_day_plan(today)
|
||||||
|
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||||
|
|
||||||
geo_info = {
|
geo_info = {
|
||||||
'latitude': 0,
|
'latitude': 0,
|
||||||
@@ -208,6 +211,15 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
latitude=0, longitude=0, distance=0,
|
latitude=0, longitude=0, distance=0,
|
||||||
source='nfc_kiosk',
|
source='nfc_kiosk',
|
||||||
)
|
)
|
||||||
|
if is_scheduled_off:
|
||||||
|
api._log_activity(
|
||||||
|
employee, 'unscheduled_shift',
|
||||||
|
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||||
|
attendance=attendance, location=location,
|
||||||
|
latitude=0, longitude=0, distance=0,
|
||||||
|
source='nfc_kiosk',
|
||||||
|
)
|
||||||
|
else:
|
||||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||||
return {
|
return {
|
||||||
@@ -224,6 +236,7 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||||
})
|
})
|
||||||
api._apply_break_deduction(attendance, employee)
|
api._apply_break_deduction(attendance, employee)
|
||||||
|
if not is_scheduled_off:
|
||||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
api._log_activity(
|
api._log_activity(
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
|
|||||||
], limit=1)
|
], limit=1)
|
||||||
|
|
||||||
# Today stats
|
# Today stats
|
||||||
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
|
today = get_local_today(request.env, employee)
|
||||||
|
today_schedule = employee._get_fclk_day_plan(today)
|
||||||
|
today_start, _ = get_local_day_boundaries(request.env, today, employee)
|
||||||
today_atts = request.env['hr.attendance'].sudo().search([
|
today_atts = request.env['hr.attendance'].sudo().search([
|
||||||
('employee_id', '=', employee.id),
|
('employee_id', '=', employee.id),
|
||||||
('check_in', '>=', today_start),
|
('check_in', '>=', today_start),
|
||||||
@@ -109,7 +111,6 @@ class FusionClockPortal(CustomerPortal):
|
|||||||
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
|
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
|
||||||
|
|
||||||
# Week stats
|
# Week stats
|
||||||
today = get_local_today(request.env, employee)
|
|
||||||
week_start = today - timedelta(days=today.weekday())
|
week_start = today - timedelta(days=today.weekday())
|
||||||
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
||||||
week_atts = request.env['hr.attendance'].sudo().search([
|
week_atts = request.env['hr.attendance'].sudo().search([
|
||||||
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
|
|||||||
'current_attendance': current_attendance,
|
'current_attendance': current_attendance,
|
||||||
'today_hours': round(today_hours, 1),
|
'today_hours': round(today_hours, 1),
|
||||||
'week_hours': round(week_hours, 1),
|
'week_hours': round(week_hours, 1),
|
||||||
|
'today_schedule': today_schedule,
|
||||||
'recent_attendances': recent,
|
'recent_attendances': recent,
|
||||||
'google_maps_key': google_maps_key,
|
'google_maps_key': google_maps_key,
|
||||||
'enable_sounds': enable_sounds,
|
'enable_sounds': enable_sounds,
|
||||||
|
|||||||
269
fusion_clock/controllers/shift_planner.py
Normal file
269
fusion_clock/controllers/shift_planner.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo import fields, http, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
|
||||||
|
class FusionClockShiftPlanner(http.Controller):
|
||||||
|
"""Backend JSON-RPC API for the Excel-style weekly shift planner."""
|
||||||
|
|
||||||
|
def _check_manager(self):
|
||||||
|
return request.env.user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||||
|
|
||||||
|
def _week_start(self, week_start=None):
|
||||||
|
date_obj = fields.Date.to_date(week_start) if week_start else fields.Date.today()
|
||||||
|
return date_obj - timedelta(days=date_obj.weekday())
|
||||||
|
|
||||||
|
def _manager_employees(self):
|
||||||
|
return request.env['hr.employee'].sudo().search([
|
||||||
|
('x_fclk_enable_clock', '=', True),
|
||||||
|
('company_id', 'in', request.env.user.company_ids.ids),
|
||||||
|
], order='department_id, name')
|
||||||
|
|
||||||
|
def _load_week_data(self, week_start=None):
|
||||||
|
start = self._week_start(week_start)
|
||||||
|
days = [start + timedelta(days=i) for i in range(7)]
|
||||||
|
employees = self._manager_employees()
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
|
||||||
|
schedules = Schedule.search([
|
||||||
|
('employee_id', 'in', employees.ids),
|
||||||
|
('schedule_date', '>=', start),
|
||||||
|
('schedule_date', '<=', days[-1]),
|
||||||
|
])
|
||||||
|
schedule_map = {
|
||||||
|
(schedule.employee_id.id, schedule.schedule_date): schedule
|
||||||
|
for schedule in schedules
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
for employee in employees:
|
||||||
|
grouped[employee.department_id.id or 0].append(employee)
|
||||||
|
|
||||||
|
departments = []
|
||||||
|
employee_rows = []
|
||||||
|
for department_id, department_employees in grouped.items():
|
||||||
|
department = department_employees[0].department_id
|
||||||
|
departments.append({
|
||||||
|
'id': department_id,
|
||||||
|
'name': department.name if department else _('No Department'),
|
||||||
|
'employee_ids': [emp.id for emp in department_employees],
|
||||||
|
})
|
||||||
|
for employee in department_employees:
|
||||||
|
cells = {}
|
||||||
|
for day in days:
|
||||||
|
cells[str(day)] = Schedule.fclk_cell_payload(
|
||||||
|
employee,
|
||||||
|
day,
|
||||||
|
schedule_map.get((employee.id, day)),
|
||||||
|
)
|
||||||
|
employee_rows.append({
|
||||||
|
'id': employee.id,
|
||||||
|
'name': employee.name,
|
||||||
|
'department_id': department_id,
|
||||||
|
'department_name': department.name if department else _('No Department'),
|
||||||
|
'job_title': employee.job_title or '',
|
||||||
|
'cells': cells,
|
||||||
|
})
|
||||||
|
|
||||||
|
shifts = request.env['fusion.clock.shift'].sudo().search([
|
||||||
|
('active', '=', True),
|
||||||
|
('company_id', 'in', request.env.user.company_ids.ids),
|
||||||
|
], order='sequence, name')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'week_start': str(start),
|
||||||
|
'week_end': str(days[-1]),
|
||||||
|
'days': [{
|
||||||
|
'date': str(day),
|
||||||
|
'weekday': day.strftime('%a').upper(),
|
||||||
|
'label': day.strftime('%d-%b'),
|
||||||
|
} for day in days],
|
||||||
|
'departments': departments,
|
||||||
|
'employees': employee_rows,
|
||||||
|
'shifts': [{
|
||||||
|
'id': shift.id,
|
||||||
|
'name': shift.name,
|
||||||
|
'start_time': shift.start_time,
|
||||||
|
'end_time': shift.end_time,
|
||||||
|
'break_minutes': shift.break_minutes,
|
||||||
|
'hours': shift.scheduled_hours,
|
||||||
|
'hours_display': Schedule.fclk_hours_display(shift.scheduled_hours),
|
||||||
|
'label': '%s - %s' % (
|
||||||
|
Schedule.fclk_float_to_display(shift.start_time),
|
||||||
|
Schedule.fclk_float_to_display(shift.end_time),
|
||||||
|
),
|
||||||
|
'option_label': '%s (%s - %s)' % (
|
||||||
|
shift.name,
|
||||||
|
Schedule.fclk_float_to_display(shift.start_time),
|
||||||
|
Schedule.fclk_float_to_display(shift.end_time),
|
||||||
|
),
|
||||||
|
} for shift in shifts],
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/load', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def load(self, week_start=None, **kw):
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
return self._load_week_data(week_start)
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/save', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def save(self, week_start=None, changes=None, **kw):
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
|
employees = self._manager_employees()
|
||||||
|
employee_map = {employee.id: employee for employee in employees}
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
errors = []
|
||||||
|
saved = 0
|
||||||
|
|
||||||
|
for change in changes or []:
|
||||||
|
employee_id = int(change.get('employee_id') or 0)
|
||||||
|
employee = employee_map.get(employee_id)
|
||||||
|
date_str = change.get('date')
|
||||||
|
if not employee:
|
||||||
|
errors.append({
|
||||||
|
'employee_id': employee_id,
|
||||||
|
'date': date_str,
|
||||||
|
'message': 'Employee not found or not allowed.',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
Schedule.fclk_apply_planner_cell(employee, date_str, change, request.env.user)
|
||||||
|
saved += 1
|
||||||
|
except ValidationError as exc:
|
||||||
|
errors.append({
|
||||||
|
'employee_id': employee_id,
|
||||||
|
'date': date_str,
|
||||||
|
'message': str(exc.args[0] if exc.args else exc),
|
||||||
|
})
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return {'success': False, 'saved': saved, 'errors': errors}
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'saved': saved,
|
||||||
|
'data': self._load_week_data(week_start),
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def copy_previous_week(self, week_start=None, **kw):
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
|
start = self._week_start(week_start)
|
||||||
|
prev_start = start - timedelta(days=7)
|
||||||
|
employees = self._manager_employees()
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
prev_schedules = Schedule.search([
|
||||||
|
('employee_id', 'in', employees.ids),
|
||||||
|
('schedule_date', '>=', prev_start),
|
||||||
|
('schedule_date', '<=', prev_start + timedelta(days=6)),
|
||||||
|
])
|
||||||
|
prev_map = {
|
||||||
|
(schedule.employee_id.id, schedule.schedule_date): schedule
|
||||||
|
for schedule in prev_schedules
|
||||||
|
}
|
||||||
|
|
||||||
|
before_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
|
||||||
|
for employee in employees:
|
||||||
|
for offset in range(7):
|
||||||
|
source_date = prev_start + timedelta(days=offset)
|
||||||
|
target_date = start + timedelta(days=offset)
|
||||||
|
source = prev_map.get((employee.id, source_date))
|
||||||
|
if not source:
|
||||||
|
payload = {'input': ''}
|
||||||
|
elif source.is_off:
|
||||||
|
payload = {'input': 'OFF'}
|
||||||
|
elif source.shift_id:
|
||||||
|
payload = {'shift_id': source.shift_id.id, 'input': source.fclk_display_value()}
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
'input': source.fclk_display_value(),
|
||||||
|
'start_time': source.start_time,
|
||||||
|
'end_time': source.end_time,
|
||||||
|
'break_minutes': source.break_minutes,
|
||||||
|
}
|
||||||
|
Schedule.fclk_apply_planner_cell(employee, target_date, payload, request.env.user)
|
||||||
|
|
||||||
|
after_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'changed': after_count - before_count,
|
||||||
|
'data': self._load_week_data(start),
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def export_xlsx(self, week_start=None, **kw):
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
|
data = self._load_week_data(week_start)
|
||||||
|
output = io.BytesIO()
|
||||||
|
import xlsxwriter
|
||||||
|
|
||||||
|
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
||||||
|
sheet = workbook.add_worksheet('Shift Planner')
|
||||||
|
|
||||||
|
fmt_day = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#b7dff5', 'border': 1})
|
||||||
|
fmt_sub = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#d8e9bd', 'border': 1})
|
||||||
|
fmt_employee = workbook.add_format({'bold': True, 'border': 1})
|
||||||
|
fmt_shift = workbook.add_format({'border': 1})
|
||||||
|
fmt_hours = workbook.add_format({'border': 1, 'align': 'center', 'bg_color': '#f5d39b'})
|
||||||
|
fmt_department = workbook.add_format({'bold': True, 'bg_color': '#eeeeee', 'border': 1})
|
||||||
|
|
||||||
|
sheet.set_column(0, 0, 22)
|
||||||
|
for col in range(1, 15, 2):
|
||||||
|
sheet.set_column(col, col, 24)
|
||||||
|
sheet.set_column(col + 1, col + 1, 9)
|
||||||
|
|
||||||
|
sheet.write(0, 0, 'EMPLOYEE', fmt_day)
|
||||||
|
col = 1
|
||||||
|
for day in data['days']:
|
||||||
|
sheet.merge_range(0, col, 0, col + 1, day['weekday'], fmt_day)
|
||||||
|
sheet.merge_range(1, col, 1, col + 1, day['label'], fmt_day)
|
||||||
|
sheet.write(2, col, 'Shift', fmt_sub)
|
||||||
|
sheet.write(2, col + 1, 'Hours', fmt_sub)
|
||||||
|
col += 2
|
||||||
|
sheet.write(2, 0, 'EMPLOYEE', fmt_sub)
|
||||||
|
|
||||||
|
row = 3
|
||||||
|
employee_by_id = {emp['id']: emp for emp in data['employees']}
|
||||||
|
for department in data['departments']:
|
||||||
|
sheet.merge_range(row, 0, row, 14, department['name'], fmt_department)
|
||||||
|
row += 1
|
||||||
|
for employee_id in department['employee_ids']:
|
||||||
|
employee = employee_by_id[employee_id]
|
||||||
|
sheet.write(row, 0, employee['name'], fmt_employee)
|
||||||
|
col = 1
|
||||||
|
for day in data['days']:
|
||||||
|
cell = employee['cells'][day['date']]
|
||||||
|
sheet.write(row, col, cell.get('label') or '', fmt_shift)
|
||||||
|
sheet.write(row, col + 1, cell.get('hours_display') or '0:00', fmt_hours)
|
||||||
|
col += 2
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
workbook.close()
|
||||||
|
output.seek(0)
|
||||||
|
filename = 'shift_planner_%s.xlsx' % data['week_start']
|
||||||
|
attachment = request.env['ir.attachment'].sudo().create({
|
||||||
|
'name': filename,
|
||||||
|
'type': 'binary',
|
||||||
|
'datas': base64.b64encode(output.read()),
|
||||||
|
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'attachment_id': attachment.id,
|
||||||
|
'filename': filename,
|
||||||
|
'url': '/web/content/%s?download=true' % attachment.id,
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@ from . import res_config_settings
|
|||||||
from . import clock_activity_log
|
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_schedule
|
||||||
from . import clock_correction
|
from . import clock_correction
|
||||||
from . import res_company
|
from . import res_company
|
||||||
|
|||||||
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,6 +34,7 @@ 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'),
|
||||||
|
('unscheduled_shift', 'Unscheduled Shift'),
|
||||||
('card_enrollment', 'Card Enrollment'),
|
('card_enrollment', 'Card Enrollment'),
|
||||||
('unknown_card_tap', 'Unknown Card Tap'),
|
('unknown_card_tap', 'Unknown Card Tap'),
|
||||||
],
|
],
|
||||||
@@ -108,6 +109,7 @@ 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',
|
||||||
|
'unscheduled_shift': 'Unscheduled Shift',
|
||||||
}
|
}
|
||||||
|
|
||||||
@api.depends('latitude', 'longitude')
|
@api.depends('latitude', 'longitude')
|
||||||
|
|||||||
414
fusion_clock/models/clock_schedule.py
Normal file
414
fusion_clock/models/clock_schedule.py
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class FusionClockSchedule(models.Model):
|
||||||
|
_name = 'fusion.clock.schedule'
|
||||||
|
_description = 'Clock Shift Schedule Entry'
|
||||||
|
_order = 'schedule_date, employee_id'
|
||||||
|
_rec_name = 'display_name'
|
||||||
|
|
||||||
|
employee_id = fields.Many2one(
|
||||||
|
'hr.employee',
|
||||||
|
string='Employee',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
)
|
||||||
|
schedule_date = fields.Date(
|
||||||
|
string='Date',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
shift_id = fields.Many2one(
|
||||||
|
'fusion.clock.shift',
|
||||||
|
string='Shift Template',
|
||||||
|
ondelete='set null',
|
||||||
|
)
|
||||||
|
is_off = fields.Boolean(
|
||||||
|
string='Off',
|
||||||
|
default=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
start_time = fields.Float(
|
||||||
|
string='Start Time',
|
||||||
|
default=9.0,
|
||||||
|
)
|
||||||
|
end_time = fields.Float(
|
||||||
|
string='End Time',
|
||||||
|
default=17.0,
|
||||||
|
)
|
||||||
|
break_minutes = fields.Float(
|
||||||
|
string='Break (min)',
|
||||||
|
default=30.0,
|
||||||
|
)
|
||||||
|
planned_hours = fields.Float(
|
||||||
|
string='Hours',
|
||||||
|
compute='_compute_planned_hours',
|
||||||
|
store=True,
|
||||||
|
)
|
||||||
|
note = fields.Char(string='Note')
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company',
|
||||||
|
string='Company',
|
||||||
|
related='employee_id.company_id',
|
||||||
|
store=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
department_id = fields.Many2one(
|
||||||
|
'hr.department',
|
||||||
|
string='Department',
|
||||||
|
related='employee_id.department_id',
|
||||||
|
store=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
display_name = fields.Char(
|
||||||
|
compute='_compute_display_name',
|
||||||
|
store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_employee_date_unique = models.Constraint(
|
||||||
|
'UNIQUE(employee_id, schedule_date)',
|
||||||
|
'Only one shift schedule is allowed per employee per day.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
|
||||||
|
def _compute_planned_hours(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.is_off:
|
||||||
|
rec.planned_hours = 0.0
|
||||||
|
continue
|
||||||
|
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
|
||||||
|
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
|
||||||
|
|
||||||
|
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
emp = rec.employee_id.name or ''
|
||||||
|
date_str = str(rec.schedule_date) if rec.schedule_date else ''
|
||||||
|
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
|
||||||
|
|
||||||
|
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
|
||||||
|
def _check_schedule_times(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.break_minutes < 0:
|
||||||
|
raise ValidationError(_("Break minutes cannot be negative."))
|
||||||
|
if rec.is_off:
|
||||||
|
continue
|
||||||
|
if rec.start_time < 0 or rec.start_time >= 24:
|
||||||
|
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
|
||||||
|
if rec.end_time <= 0 or rec.end_time > 24:
|
||||||
|
raise ValidationError(_("End time must be between 00:01 and 24:00."))
|
||||||
|
if rec.end_time <= rec.start_time:
|
||||||
|
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||||||
|
shift_minutes = (rec.end_time - rec.start_time) * 60.0
|
||||||
|
if rec.break_minutes >= shift_minutes:
|
||||||
|
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
|
||||||
|
|
||||||
|
@api.onchange('shift_id')
|
||||||
|
def _onchange_shift_id(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.shift_id:
|
||||||
|
rec.is_off = False
|
||||||
|
rec.start_time = rec.shift_id.start_time
|
||||||
|
rec.end_time = rec.shift_id.end_time
|
||||||
|
rec.break_minutes = rec.shift_id.break_minutes
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_float_to_display(self, value):
|
||||||
|
value = float(value or 0.0)
|
||||||
|
hour = int(value)
|
||||||
|
minute = int(round((value - hour) * 60))
|
||||||
|
if minute == 60:
|
||||||
|
hour += 1
|
||||||
|
minute = 0
|
||||||
|
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
|
||||||
|
display_hour = hour % 12
|
||||||
|
if display_hour == 0:
|
||||||
|
display_hour = 12
|
||||||
|
return f"{display_hour}:{minute:02d} {suffix}"
|
||||||
|
|
||||||
|
def fclk_display_value(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.is_off:
|
||||||
|
return 'OFF'
|
||||||
|
return (
|
||||||
|
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
|
||||||
|
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_hours_display(self, hours):
|
||||||
|
hours = float(hours or 0.0)
|
||||||
|
whole = int(hours)
|
||||||
|
minutes = int(round((hours - whole) * 60))
|
||||||
|
if minutes == 60:
|
||||||
|
whole += 1
|
||||||
|
minutes = 0
|
||||||
|
return f"{whole}:{minutes:02d}"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fclk_parse_time_part(self, raw):
|
||||||
|
text = (raw or '').strip().lower().replace('.', '')
|
||||||
|
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
|
||||||
|
if not match:
|
||||||
|
raise ValidationError(_("Could not understand time '%s'.") % raw)
|
||||||
|
hour = int(match.group(1))
|
||||||
|
minute = int(match.group(2) or 0)
|
||||||
|
meridiem = match.group(3)
|
||||||
|
if minute < 0 or minute > 59:
|
||||||
|
raise ValidationError(_("Minutes must be between 00 and 59."))
|
||||||
|
if meridiem:
|
||||||
|
if hour < 1 or hour > 12:
|
||||||
|
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
|
||||||
|
if meridiem == 'am':
|
||||||
|
hour = 0 if hour == 12 else hour
|
||||||
|
else:
|
||||||
|
hour = 12 if hour == 12 else hour + 12
|
||||||
|
elif hour > 24:
|
||||||
|
raise ValidationError(_("Hours must be between 0 and 24."))
|
||||||
|
return hour + (minute / 60.0)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
|
||||||
|
text = (input_value or '').strip()
|
||||||
|
if not text:
|
||||||
|
return {'clear': True}
|
||||||
|
if text.upper() == 'OFF':
|
||||||
|
return {
|
||||||
|
'clear': False,
|
||||||
|
'is_off': True,
|
||||||
|
'shift_id': False,
|
||||||
|
'start_time': 0.0,
|
||||||
|
'end_time': 0.0,
|
||||||
|
'break_minutes': 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = (
|
||||||
|
text.replace('–', '-')
|
||||||
|
.replace('—', '-')
|
||||||
|
.replace(' to ', '-')
|
||||||
|
.replace(' TO ', '-')
|
||||||
|
)
|
||||||
|
parts = [p.strip() for p in normalized.split('-', 1)]
|
||||||
|
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||||
|
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
|
||||||
|
start = self._fclk_parse_time_part(parts[0])
|
||||||
|
end = self._fclk_parse_time_part(parts[1])
|
||||||
|
if end <= start and end + 12 <= 24:
|
||||||
|
end += 12
|
||||||
|
if end <= start:
|
||||||
|
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||||||
|
return {
|
||||||
|
'clear': False,
|
||||||
|
'is_off': False,
|
||||||
|
'shift_id': False,
|
||||||
|
'start_time': start,
|
||||||
|
'end_time': end,
|
||||||
|
'break_minutes': float(default_break_minutes or 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_values_from_planner_payload(self, payload, employee):
|
||||||
|
payload = payload or {}
|
||||||
|
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
|
||||||
|
if payload.get('is_off'):
|
||||||
|
return {
|
||||||
|
'clear': False,
|
||||||
|
'is_off': True,
|
||||||
|
'shift_id': False,
|
||||||
|
'start_time': 0.0,
|
||||||
|
'end_time': 0.0,
|
||||||
|
'break_minutes': 0.0,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'clear': False,
|
||||||
|
'is_off': False,
|
||||||
|
'shift_id': False,
|
||||||
|
'start_time': float(payload.get('start_time') or 0.0),
|
||||||
|
'end_time': float(payload.get('end_time') or 0.0),
|
||||||
|
'break_minutes': float(payload.get('break_minutes') or 0.0),
|
||||||
|
}
|
||||||
|
shift_id = int(payload.get('shift_id') or 0)
|
||||||
|
if shift_id:
|
||||||
|
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
|
||||||
|
if not shift.exists():
|
||||||
|
raise ValidationError(_("Selected shift template no longer exists."))
|
||||||
|
return {
|
||||||
|
'clear': False,
|
||||||
|
'shift_id': shift.id,
|
||||||
|
'is_off': False,
|
||||||
|
'start_time': shift.start_time,
|
||||||
|
'end_time': shift.end_time,
|
||||||
|
'break_minutes': shift.break_minutes,
|
||||||
|
}
|
||||||
|
|
||||||
|
default_break = employee._get_fclk_break_minutes() if employee else 30.0
|
||||||
|
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_snapshot(self, schedule):
|
||||||
|
if not schedule:
|
||||||
|
return ''
|
||||||
|
return schedule.fclk_display_value()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
|
||||||
|
self = self.sudo()
|
||||||
|
employee = employee.sudo()
|
||||||
|
date_obj = fields.Date.to_date(schedule_date)
|
||||||
|
if not employee.exists() or not date_obj:
|
||||||
|
raise ValidationError(_("Invalid employee or schedule date."))
|
||||||
|
|
||||||
|
existing = self.search([
|
||||||
|
('employee_id', '=', employee.id),
|
||||||
|
('schedule_date', '=', date_obj),
|
||||||
|
], limit=1)
|
||||||
|
old_value = self.fclk_snapshot(existing)
|
||||||
|
parsed = self.fclk_values_from_planner_payload(payload, employee)
|
||||||
|
|
||||||
|
if parsed.get('clear'):
|
||||||
|
if existing:
|
||||||
|
existing.unlink()
|
||||||
|
new_schedule = self.browse()
|
||||||
|
new_value = ''
|
||||||
|
else:
|
||||||
|
vals = {
|
||||||
|
'employee_id': employee.id,
|
||||||
|
'schedule_date': date_obj,
|
||||||
|
'shift_id': parsed.get('shift_id') or False,
|
||||||
|
'is_off': bool(parsed.get('is_off')),
|
||||||
|
'start_time': parsed.get('start_time') or 0.0,
|
||||||
|
'end_time': parsed.get('end_time') or 0.0,
|
||||||
|
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||||||
|
'note': payload.get('note') or False,
|
||||||
|
}
|
||||||
|
if existing:
|
||||||
|
existing.write(vals)
|
||||||
|
new_schedule = existing
|
||||||
|
else:
|
||||||
|
new_schedule = self.create(vals)
|
||||||
|
new_value = new_schedule.fclk_display_value()
|
||||||
|
|
||||||
|
if old_value != new_value:
|
||||||
|
self.env['fusion.clock.schedule.audit'].sudo().create({
|
||||||
|
'schedule_id': new_schedule.id if new_schedule else False,
|
||||||
|
'employee_id': employee.id,
|
||||||
|
'schedule_date': date_obj,
|
||||||
|
'old_value': old_value,
|
||||||
|
'new_value': new_value,
|
||||||
|
'changed_by_id': (user or self.env.user).id,
|
||||||
|
'changed_at': fields.Datetime.now(),
|
||||||
|
'company_id': employee.company_id.id,
|
||||||
|
'department_id': employee.department_id.id,
|
||||||
|
})
|
||||||
|
return new_schedule
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_cell_payload(self, employee, date_obj, schedule=None):
|
||||||
|
schedule = schedule or self.search([
|
||||||
|
('employee_id', '=', employee.id),
|
||||||
|
('schedule_date', '=', date_obj),
|
||||||
|
], limit=1)
|
||||||
|
Schedule = self.env['fusion.clock.schedule']
|
||||||
|
if schedule:
|
||||||
|
return {
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'source': 'schedule',
|
||||||
|
'input': schedule.fclk_display_value(),
|
||||||
|
'label': schedule.fclk_display_value(),
|
||||||
|
'is_off': schedule.is_off,
|
||||||
|
'shift_id': schedule.shift_id.id or False,
|
||||||
|
'start_time': schedule.start_time,
|
||||||
|
'end_time': schedule.end_time,
|
||||||
|
'break_minutes': schedule.break_minutes,
|
||||||
|
'hours': schedule.planned_hours,
|
||||||
|
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
|
||||||
|
'note': schedule.note or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = employee._get_fclk_day_plan(date_obj)
|
||||||
|
return {
|
||||||
|
'schedule_id': False,
|
||||||
|
'source': plan.get('source') or 'fallback',
|
||||||
|
'input': plan.get('label') or '',
|
||||||
|
'label': plan.get('label') or '',
|
||||||
|
'is_off': plan.get('is_off', False),
|
||||||
|
'shift_id': False,
|
||||||
|
'start_time': plan.get('start_time') or 0.0,
|
||||||
|
'end_time': plan.get('end_time') or 0.0,
|
||||||
|
'break_minutes': plan.get('break_minutes') or 0.0,
|
||||||
|
'hours': plan.get('hours') or 0.0,
|
||||||
|
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
|
||||||
|
'note': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FusionClockScheduleAudit(models.Model):
|
||||||
|
_name = 'fusion.clock.schedule.audit'
|
||||||
|
_description = 'Clock Schedule Change Audit'
|
||||||
|
_order = 'changed_at desc, id desc'
|
||||||
|
_rec_name = 'display_name'
|
||||||
|
|
||||||
|
schedule_id = fields.Many2one(
|
||||||
|
'fusion.clock.schedule',
|
||||||
|
string='Schedule',
|
||||||
|
ondelete='set null',
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
employee_id = fields.Many2one(
|
||||||
|
'hr.employee',
|
||||||
|
string='Employee',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
)
|
||||||
|
schedule_date = fields.Date(
|
||||||
|
string='Schedule Date',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
old_value = fields.Char(string='Old Value')
|
||||||
|
new_value = fields.Char(string='New Value')
|
||||||
|
changed_by_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='Changed By',
|
||||||
|
required=True,
|
||||||
|
ondelete='restrict',
|
||||||
|
)
|
||||||
|
changed_at = fields.Datetime(
|
||||||
|
string='Changed At',
|
||||||
|
default=fields.Datetime.now,
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company',
|
||||||
|
string='Company',
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
department_id = fields.Many2one(
|
||||||
|
'hr.department',
|
||||||
|
string='Department',
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
display_name = fields.Char(
|
||||||
|
compute='_compute_display_name',
|
||||||
|
store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.display_name = "%s - %s: %s -> %s" % (
|
||||||
|
rec.employee_id.name or '',
|
||||||
|
rec.schedule_date or '',
|
||||||
|
rec.old_value or 'blank',
|
||||||
|
rec.new_value or 'blank',
|
||||||
|
)
|
||||||
@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
employee = att.employee_id
|
employee = att.employee_id
|
||||||
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
|
scheduled_hours = daily_threshold
|
||||||
|
if employee:
|
||||||
|
local_date = get_local_today(self.env, employee)
|
||||||
|
if att.check_in:
|
||||||
|
tz_name = (
|
||||||
|
employee.resource_id.tz
|
||||||
|
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||||
|
or employee.company_id.partner_id.tz
|
||||||
|
or 'UTC'
|
||||||
|
)
|
||||||
|
local_date = pytz.UTC.localize(att.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||||
|
scheduled_hours = employee._get_fclk_scheduled_hours(local_date)
|
||||||
net = att.x_fclk_net_hours or 0.0
|
net = att.x_fclk_net_hours or 0.0
|
||||||
|
|
||||||
if net > scheduled_hours:
|
if net > scheduled_hours:
|
||||||
@@ -264,10 +275,13 @@ class HrAttendance(models.Model):
|
|||||||
employee = att.employee_id
|
employee = att.employee_id
|
||||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
|
||||||
|
|
||||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
|
||||||
max_deadline = check_in + timedelta(hours=max_shift)
|
max_deadline = check_in + timedelta(hours=max_shift)
|
||||||
|
day_plan = employee._get_fclk_day_plan(check_in_date)
|
||||||
|
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||||
|
effective_deadline = max_deadline
|
||||||
|
else:
|
||||||
|
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
||||||
|
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||||
effective_deadline = min(deadline, max_deadline)
|
effective_deadline = min(deadline, max_deadline)
|
||||||
|
|
||||||
if now > effective_deadline:
|
if now > effective_deadline:
|
||||||
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
|
|||||||
# Apply break deduction
|
# Apply break deduction
|
||||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||||
if (att.worked_hours or 0) >= threshold:
|
if (att.worked_hours or 0) >= threshold:
|
||||||
break_min = employee._get_fclk_break_minutes()
|
break_min = employee._get_fclk_break_minutes(check_in_date)
|
||||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||||
|
|
||||||
att.sudo().message_post(
|
att.sudo().message_post(
|
||||||
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
|
|||||||
|
|
||||||
if yesterday.weekday() >= 5:
|
if yesterday.weekday() >= 5:
|
||||||
continue
|
continue
|
||||||
|
day_plan = emp._get_fclk_day_plan(yesterday)
|
||||||
|
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||||
|
continue
|
||||||
|
|
||||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||||
|
|
||||||
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
|
|||||||
|
|
||||||
if today.weekday() >= 5:
|
if today.weekday() >= 5:
|
||||||
continue
|
continue
|
||||||
|
day_plan = emp._get_fclk_day_plan(today)
|
||||||
|
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||||
|
continue
|
||||||
|
|
||||||
if emp.x_fclk_last_reminder_date == today:
|
if emp.x_fclk_last_reminder_date == today:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
|
|||||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_fclk_break_minutes(self):
|
def _get_fclk_schedule_for_date(self, date):
|
||||||
"""Return effective break minutes for this employee.
|
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
||||||
Priority: employee override > shift > global setting.
|
self.ensure_one()
|
||||||
|
date_obj = fields.Date.to_date(date)
|
||||||
|
if not date_obj:
|
||||||
|
return self.env['fusion.clock.schedule']
|
||||||
|
return self.env['fusion.clock.schedule'].sudo().search([
|
||||||
|
('employee_id', '=', self.id),
|
||||||
|
('schedule_date', '=', date_obj),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
def _get_fclk_day_plan(self, date):
|
||||||
|
"""Return the effective plan for a local date.
|
||||||
|
|
||||||
|
Dated schedules are the source of truth. If none exists, the legacy
|
||||||
|
employee shift/global settings remain the fallback.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
Schedule = self.env['fusion.clock.schedule'].sudo()
|
||||||
|
schedule = self._get_fclk_schedule_for_date(date)
|
||||||
|
if schedule:
|
||||||
|
return {
|
||||||
|
'source': 'schedule',
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'is_off': schedule.is_off,
|
||||||
|
'start_time': schedule.start_time,
|
||||||
|
'end_time': schedule.end_time,
|
||||||
|
'break_minutes': schedule.break_minutes,
|
||||||
|
'hours': schedule.planned_hours,
|
||||||
|
'label': schedule.fclk_display_value(),
|
||||||
|
}
|
||||||
|
if self.x_fclk_shift_id:
|
||||||
|
shift = self.x_fclk_shift_id
|
||||||
|
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
|
||||||
|
return {
|
||||||
|
'source': 'fallback',
|
||||||
|
'schedule_id': False,
|
||||||
|
'is_off': False,
|
||||||
|
'start_time': shift.start_time,
|
||||||
|
'end_time': shift.end_time,
|
||||||
|
'break_minutes': shift.break_minutes,
|
||||||
|
'hours': hours,
|
||||||
|
'label': '%s - %s' % (
|
||||||
|
Schedule.fclk_float_to_display(shift.start_time),
|
||||||
|
Schedule.fclk_float_to_display(shift.end_time),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||||
|
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||||
|
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
|
||||||
|
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
|
||||||
|
return {
|
||||||
|
'source': 'fallback',
|
||||||
|
'schedule_id': False,
|
||||||
|
'is_off': False,
|
||||||
|
'start_time': start_time,
|
||||||
|
'end_time': end_time,
|
||||||
|
'break_minutes': break_minutes,
|
||||||
|
'hours': hours,
|
||||||
|
'label': '%s - %s' % (
|
||||||
|
Schedule.fclk_float_to_display(start_time),
|
||||||
|
Schedule.fclk_float_to_display(end_time),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_fclk_break_minutes(self, date=None):
|
||||||
|
"""Return effective break minutes for this employee.
|
||||||
|
Priority: dated schedule > employee override > shift > global setting.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if date:
|
||||||
|
plan = self._get_fclk_day_plan(date)
|
||||||
|
if plan.get('source') == 'schedule' and not plan.get('is_off'):
|
||||||
|
return plan.get('break_minutes') or 0.0
|
||||||
if self.x_fclk_break_minutes > 0:
|
if self.x_fclk_break_minutes > 0:
|
||||||
return self.x_fclk_break_minutes
|
return self.x_fclk_break_minutes
|
||||||
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
|
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
|
||||||
@@ -138,7 +209,7 @@ class HrEmployee(models.Model):
|
|||||||
def _get_fclk_scheduled_times(self, date):
|
def _get_fclk_scheduled_times(self, date):
|
||||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
||||||
|
|
||||||
Uses employee shift if assigned, otherwise global settings.
|
Uses dated schedule first, employee shift second, then global settings.
|
||||||
The configured hours are interpreted in the employee's local
|
The configured hours are interpreted in the employee's local
|
||||||
timezone and converted to naive-UTC datetimes so they can be
|
timezone and converted to naive-UTC datetimes so they can be
|
||||||
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
||||||
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.x_fclk_shift_id:
|
plan = self._get_fclk_day_plan(date)
|
||||||
in_hour = self.x_fclk_shift_id.start_time
|
in_hour = plan.get('start_time') or 0.0
|
||||||
out_hour = self.x_fclk_shift_id.end_time
|
out_hour = plan.get('end_time') or 0.0
|
||||||
else:
|
|
||||||
ICP = self.env['ir.config_parameter'].sudo()
|
|
||||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
|
||||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
|
||||||
|
|
||||||
in_h = int(in_hour)
|
in_h = int(in_hour)
|
||||||
in_m = int((in_hour - in_h) * 60)
|
in_m = int((in_hour - in_h) * 60)
|
||||||
@@ -179,16 +246,13 @@ class HrEmployee(models.Model):
|
|||||||
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
||||||
return scheduled_in, scheduled_out
|
return scheduled_in, scheduled_out
|
||||||
|
|
||||||
def _get_fclk_scheduled_hours(self):
|
def _get_fclk_scheduled_hours(self, date=None):
|
||||||
"""Return the expected work hours for this employee's shift."""
|
"""Return the expected work hours for this employee's shift."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.x_fclk_shift_id:
|
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
|
||||||
return self.x_fclk_shift_id.scheduled_hours
|
if plan.get('is_off'):
|
||||||
ICP = self.env['ir.config_parameter'].sudo()
|
return 0.0
|
||||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
return plan.get('hours') or 0.0
|
||||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
|
||||||
break_hrs = self._get_fclk_break_minutes() / 60.0
|
|
||||||
return max((out_hour - in_hour) - break_hrs, 0.0)
|
|
||||||
|
|
||||||
def _compute_absence_counts(self):
|
def _compute_absence_counts(self):
|
||||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fus
|
|||||||
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
|
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
|
||||||
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
|
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
|
||||||
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
|
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
|
||||||
|
access_fusion_clock_schedule_user,fusion.clock.schedule.user,model_fusion_clock_schedule,group_fusion_clock_user,1,0,0,0
|
||||||
|
access_fusion_clock_schedule_manager,fusion.clock.schedule.manager,model_fusion_clock_schedule,group_fusion_clock_manager,1,1,1,1
|
||||||
|
access_fusion_clock_schedule_audit_manager,fusion.clock.schedule.audit.manager,model_fusion_clock_schedule_audit,group_fusion_clock_manager,1,0,0,0
|
||||||
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
|
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
|
||||||
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
|
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
|
||||||
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
|
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
|
||||||
@@ -22,4 +25,5 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
|
|||||||
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
|
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
|
||||||
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
|
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
|
||||||
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
||||||
|
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
|
||||||
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -174,6 +174,49 @@
|
|||||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
Record Rules - Dated Schedules
|
||||||
|
================================================================ -->
|
||||||
|
<record id="rule_schedule_user" model="ir.rule">
|
||||||
|
<field name="name">Schedule: User sees own</field>
|
||||||
|
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||||
|
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_schedule_team_lead" model="ir.rule">
|
||||||
|
<field name="name">Schedule: Team Lead sees direct reports</field>
|
||||||
|
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||||
|
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_schedule_manager" model="ir.rule">
|
||||||
|
<field name="name">Schedule: Manager full access</field>
|
||||||
|
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||||
|
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_schedule_audit_manager" model="ir.rule">
|
||||||
|
<field name="name">Schedule Audit: Manager reads all</field>
|
||||||
|
<field name="model_id" ref="model_fusion_clock_schedule_audit"/>
|
||||||
|
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
Record Rules - Correction Request
|
Record Rules - Correction Request
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
@@ -286,4 +329,15 @@
|
|||||||
<field name="perm_unlink" eval="False"/>
|
<field name="perm_unlink" eval="False"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_schedule_portal" model="ir.rule">
|
||||||
|
<field name="name">Schedule: Portal user sees own</field>
|
||||||
|
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||||
|
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Scheduled Shift Card ---- */
|
||||||
|
.fclk-schedule-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--fclk-card);
|
||||||
|
border: 1px solid var(--fclk-card-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin: -14px 0 28px;
|
||||||
|
box-shadow: var(--fclk-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-schedule-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: var(--fclk-blue);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-schedule-info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-schedule-label {
|
||||||
|
color: var(--fclk-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-schedule-value {
|
||||||
|
color: var(--fclk-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-schedule-hours {
|
||||||
|
color: var(--fclk-text);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Timer Section ---- */
|
/* ---- Timer Section ---- */
|
||||||
.fclk-timer-section {
|
.fclk-timer-section {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal file
741
fusion_clock/static/src/js/fusion_clock_shift_planner.js
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, onPatched, onWillStart, useExternalListener, useRef, useState } from "@odoo/owl";
|
||||||
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class FusionClockShiftPlanner extends Component {
|
||||||
|
static template = "fusion_clock.ShiftPlanner";
|
||||||
|
static props = [];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.dirtyCells = {};
|
||||||
|
this.root = useRef("root");
|
||||||
|
this.editorRef = useRef("shiftEditor");
|
||||||
|
this.activeCellAnchor = null;
|
||||||
|
this.activeEditorEmployee = null;
|
||||||
|
this.activeEditorDay = null;
|
||||||
|
this.timeOptions = this._buildTimeOptions();
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
weekStart: "",
|
||||||
|
weekEnd: "",
|
||||||
|
days: [],
|
||||||
|
departments: [],
|
||||||
|
employees: [],
|
||||||
|
shifts: [],
|
||||||
|
error: "",
|
||||||
|
dirtyCount: 0,
|
||||||
|
invalidCount: 0,
|
||||||
|
collapsed: {},
|
||||||
|
editor: {
|
||||||
|
open: false,
|
||||||
|
employeeId: false,
|
||||||
|
employeeName: "",
|
||||||
|
date: "",
|
||||||
|
dayLabel: "",
|
||||||
|
startValue: "9.00",
|
||||||
|
endValue: "17.00",
|
||||||
|
breakMinutes: 30,
|
||||||
|
hoursDisplay: "7:30",
|
||||||
|
error: "",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this.loadWeek();
|
||||||
|
});
|
||||||
|
useExternalListener(
|
||||||
|
window,
|
||||||
|
"click",
|
||||||
|
(ev) => this.onGlobalClick(ev),
|
||||||
|
{ capture: true }
|
||||||
|
);
|
||||||
|
useExternalListener(window, "resize", () => this._positionActiveEditor());
|
||||||
|
useExternalListener(window, "scroll", () => this._positionActiveEditor(), true);
|
||||||
|
onPatched(() => {
|
||||||
|
this._positionActiveEditor();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadWeek(weekStart = null) {
|
||||||
|
this.state.loading = true;
|
||||||
|
this.state.error = "";
|
||||||
|
try {
|
||||||
|
const data = await rpc("/fusion_clock/shift_planner/load", { week_start: weekStart });
|
||||||
|
if (data.error) {
|
||||||
|
this.state.error = data.error;
|
||||||
|
} else {
|
||||||
|
this._applyData(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.state.error = error.message || "Failed to load shift planner.";
|
||||||
|
}
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyData(data) {
|
||||||
|
this.dirtyCells = {};
|
||||||
|
this.state.weekStart = data.week_start;
|
||||||
|
this.state.weekEnd = data.week_end;
|
||||||
|
this.state.days = data.days || [];
|
||||||
|
this.state.departments = data.departments || [];
|
||||||
|
this.state.employees = data.employees || [];
|
||||||
|
this.state.shifts = data.shifts || [];
|
||||||
|
this.state.dirtyCount = 0;
|
||||||
|
this.state.invalidCount = 0;
|
||||||
|
this.state.error = "";
|
||||||
|
this.closeCellEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
get weekTitle() {
|
||||||
|
if (!this.state.weekStart || !this.state.weekEnd) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `${this.state.weekStart} to ${this.state.weekEnd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDepartmentEmployees(department) {
|
||||||
|
const ids = new Set(department.employee_ids || []);
|
||||||
|
return this.state.employees.filter((employee) => ids.has(employee.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
isCollapsed(department) {
|
||||||
|
return !!this.state.collapsed[department.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDepartment(department) {
|
||||||
|
this.state.collapsed[department.id] = !this.state.collapsed[department.id];
|
||||||
|
this.closeCellEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async previousWeek() {
|
||||||
|
await this.loadWeek(this._dateAdd(this.state.weekStart, -7));
|
||||||
|
}
|
||||||
|
|
||||||
|
async nextWeek() {
|
||||||
|
await this.loadWeek(this._dateAdd(this.state.weekStart, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
async currentWeek() {
|
||||||
|
await this.loadWeek();
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyPreviousWeek() {
|
||||||
|
if (!window.confirm("Copy the previous week into this week? Current saved cells for the week may be replaced.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/copy_previous_week", {
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
this.notification.add(result.error, { type: "danger" });
|
||||||
|
} else {
|
||||||
|
this._applyData(result.data);
|
||||||
|
this.notification.add(`Copied previous week (${result.changed || 0} changes).`, { type: "success" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not copy previous week.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this._recountInvalid();
|
||||||
|
if (this.state.invalidCount) {
|
||||||
|
this.notification.add("Fix invalid shift cells before saving.", { type: "danger" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const changes = Object.values(this.dirtyCells);
|
||||||
|
if (!changes.length) {
|
||||||
|
this.notification.add("No shift changes to save.", { type: "info" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/save", {
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
changes,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
this.notification.add(result.error, { type: "danger" });
|
||||||
|
} else if (!result.success) {
|
||||||
|
this._markServerErrors(result.errors || []);
|
||||||
|
this.notification.add("Some shift cells could not be saved.", { type: "danger" });
|
||||||
|
} else {
|
||||||
|
this._applyData(result.data);
|
||||||
|
this.notification.add(`Saved ${result.saved || 0} shift changes.`, { type: "success" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not save shift planner.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportXlsx() {
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/export_xlsx", {
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
this.notification.add(result.error, { type: "danger" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location = result.url;
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not export shift planner.", { type: "danger" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openCellEditor(employee, day, ev) {
|
||||||
|
if (this.state.loading || this.state.saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchor = ev.currentTarget.closest(".fclk-planner__shift-cell") || ev.currentTarget;
|
||||||
|
this.activeCellAnchor = anchor;
|
||||||
|
this.activeEditorEmployee = employee;
|
||||||
|
this.activeEditorDay = day;
|
||||||
|
|
||||||
|
const cell = employee.cells[day.date] || {};
|
||||||
|
const fallback = this._defaultTimes(employee, day);
|
||||||
|
const start = cell.is_off ? fallback.start : (cell.start_time || fallback.start);
|
||||||
|
const end = cell.is_off ? fallback.end : (cell.end_time || fallback.end);
|
||||||
|
const breakMinutes = cell.is_off ? 0 : (cell.break_minutes || fallback.breakMinutes || 30);
|
||||||
|
const hours = cell.is_off ? 0 : Math.max(end - start - breakMinutes / 60, 0);
|
||||||
|
|
||||||
|
this.state.editor.open = true;
|
||||||
|
this.state.editor.employeeId = employee.id;
|
||||||
|
this.state.editor.employeeName = employee.name;
|
||||||
|
this.state.editor.date = day.date;
|
||||||
|
this.state.editor.dayLabel = `${day.weekday} ${day.label}`;
|
||||||
|
this.state.editor.startValue = this._timeValue(start);
|
||||||
|
this.state.editor.endValue = this._timeValue(end);
|
||||||
|
this.state.editor.breakMinutes = breakMinutes;
|
||||||
|
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
|
||||||
|
this.state.editor.error = cell.error || "";
|
||||||
|
this._positionActiveEditor(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCellEditor() {
|
||||||
|
this.state.editor.open = false;
|
||||||
|
this.activeCellAnchor = null;
|
||||||
|
this.activeEditorEmployee = null;
|
||||||
|
this.activeEditorDay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onGlobalClick(ev) {
|
||||||
|
if (!this.state.editor.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = ev.target;
|
||||||
|
const clickedEditor = this.editorRef.el && this.editorRef.el.contains(target);
|
||||||
|
const clickedCell = this.activeCellAnchor && this.activeCellAnchor.contains(target);
|
||||||
|
if (!clickedEditor && !clickedCell) {
|
||||||
|
this.closeCellEditor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isActiveCell(employee, day) {
|
||||||
|
return this.state.editor.open
|
||||||
|
&& this.state.editor.employeeId === employee.id
|
||||||
|
&& this.state.editor.date === day.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCellInput(employee, day, ev) {
|
||||||
|
this._setCellFromInput(employee, day, ev.target.value, ev.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCellKeydown(employee, day, ev) {
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.closeCellEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === "Tab") {
|
||||||
|
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
|
||||||
|
this.closeCellEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
ev.preventDefault();
|
||||||
|
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
|
||||||
|
if (!employee.cells[day.date]?.error) {
|
||||||
|
this.closeCellEditor();
|
||||||
|
this._focusRelativeCell(ev.currentTarget, ev.shiftKey ? -this.state.days.length : this.state.days.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectQuickShift(option) {
|
||||||
|
const context = this._activeEditorContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
if (option.type === "template") {
|
||||||
|
parsed = {
|
||||||
|
is_off: false,
|
||||||
|
shift_id: option.shiftId,
|
||||||
|
start_time: option.start,
|
||||||
|
end_time: option.end,
|
||||||
|
break_minutes: option.breakMinutes,
|
||||||
|
hours: option.hours,
|
||||||
|
hours_display: option.hoursDisplay,
|
||||||
|
label: option.input,
|
||||||
|
normalized_input: option.input,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
parsed = this._parseInput(option.input, context.cell);
|
||||||
|
}
|
||||||
|
this._applyParsedToCell(context.employee, context.day, parsed, option.input);
|
||||||
|
this._syncEditorFromCell(context.employee, context.day);
|
||||||
|
this.closeCellEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearActiveCell() {
|
||||||
|
const context = this._activeEditorContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._setCellFromInput(context.employee, context.day, "");
|
||||||
|
this.closeCellEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditorStartChange(ev) {
|
||||||
|
this.state.editor.startValue = ev.target.value;
|
||||||
|
this.applyEditorRange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditorEndChange(ev) {
|
||||||
|
this.state.editor.endValue = ev.target.value;
|
||||||
|
this.applyEditorRange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEditorRange(close = true) {
|
||||||
|
const context = this._activeEditorContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = Number(this.state.editor.startValue);
|
||||||
|
let end = Number(this.state.editor.endValue);
|
||||||
|
if (end <= start) {
|
||||||
|
end = Math.min(start + 0.5, 24);
|
||||||
|
this.state.editor.endValue = this._timeValue(end);
|
||||||
|
}
|
||||||
|
const parsed = this._rangeToParsed(start, end, this.state.editor.breakMinutes || 0);
|
||||||
|
if (parsed.error) {
|
||||||
|
context.cell.error = parsed.error;
|
||||||
|
this.state.editor.error = parsed.error;
|
||||||
|
} else {
|
||||||
|
this._applyParsedToCell(context.employee, context.day, parsed, parsed.label);
|
||||||
|
this._syncEditorFromCell(context.employee, context.day);
|
||||||
|
}
|
||||||
|
this._recountInvalid();
|
||||||
|
if (close && !parsed.error) {
|
||||||
|
this.closeCellEditor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setCellFromInput(employee, day, input, target = null) {
|
||||||
|
const cell = employee.cells[day.date];
|
||||||
|
cell.input = input;
|
||||||
|
|
||||||
|
const parsed = this._parseInput(input, cell);
|
||||||
|
this._applyParsedToCell(employee, day, parsed, input);
|
||||||
|
if (!parsed.error && target && parsed.normalized_input !== undefined) {
|
||||||
|
target.value = parsed.normalized_input;
|
||||||
|
}
|
||||||
|
this._syncEditorFromCell(employee, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyParsedToCell(employee, day, parsed, input) {
|
||||||
|
const cell = employee.cells[day.date];
|
||||||
|
cell.error = parsed.error || "";
|
||||||
|
if (parsed.error) {
|
||||||
|
cell.input = input;
|
||||||
|
this.state.editor.error = parsed.error;
|
||||||
|
this._markDirty(employee, day);
|
||||||
|
this._recountInvalid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.is_off = parsed.is_off || false;
|
||||||
|
cell.shift_id = parsed.shift_id || false;
|
||||||
|
cell.start_time = parsed.start_time || 0;
|
||||||
|
cell.end_time = parsed.end_time || 0;
|
||||||
|
cell.break_minutes = parsed.break_minutes || 0;
|
||||||
|
cell.hours = parsed.hours || 0;
|
||||||
|
cell.hours_display = parsed.hours_display || "0:00";
|
||||||
|
cell.label = parsed.label || "";
|
||||||
|
cell.input = parsed.normalized_input !== undefined ? parsed.normalized_input : input;
|
||||||
|
this.state.editor.error = "";
|
||||||
|
this._markDirty(employee, day);
|
||||||
|
this._recountInvalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
_markDirty(employee, day) {
|
||||||
|
const cell = employee.cells[day.date];
|
||||||
|
const key = `${employee.id}:${day.date}`;
|
||||||
|
const payload = {
|
||||||
|
employee_id: employee.id,
|
||||||
|
date: day.date,
|
||||||
|
input: cell.input,
|
||||||
|
shift_id: cell.shift_id || false,
|
||||||
|
note: cell.note || "",
|
||||||
|
};
|
||||||
|
if ((cell.input || "").trim()) {
|
||||||
|
payload.is_off = !!cell.is_off;
|
||||||
|
payload.start_time = cell.start_time || 0;
|
||||||
|
payload.end_time = cell.end_time || 0;
|
||||||
|
payload.break_minutes = cell.break_minutes || 0;
|
||||||
|
}
|
||||||
|
this.dirtyCells[key] = payload;
|
||||||
|
this.state.dirtyCount = Object.keys(this.dirtyCells).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
_markServerErrors(errors) {
|
||||||
|
for (const error of errors) {
|
||||||
|
const employee = this.state.employees.find((emp) => emp.id === error.employee_id);
|
||||||
|
const cell = employee && employee.cells[error.date];
|
||||||
|
if (cell) {
|
||||||
|
cell.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._recountInvalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
_recountInvalid() {
|
||||||
|
let invalid = 0;
|
||||||
|
for (const employee of this.state.employees) {
|
||||||
|
for (const day of this.state.days) {
|
||||||
|
if (employee.cells[day.date]?.error) {
|
||||||
|
invalid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.state.invalidCount = invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseInput(value, currentCell = {}) {
|
||||||
|
const text = (value || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
is_off: false,
|
||||||
|
shift_id: false,
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0,
|
||||||
|
break_minutes: 0,
|
||||||
|
label: "",
|
||||||
|
hours: 0,
|
||||||
|
hours_display: "0:00",
|
||||||
|
normalized_input: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.toUpperCase() === "OFF") {
|
||||||
|
return {
|
||||||
|
is_off: true,
|
||||||
|
shift_id: false,
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0,
|
||||||
|
break_minutes: 0,
|
||||||
|
hours: 0,
|
||||||
|
hours_display: "0:00",
|
||||||
|
label: "OFF",
|
||||||
|
normalized_input: "OFF",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
const template = this.state.shifts.find((shift) =>
|
||||||
|
[shift.option_label, shift.label, shift.name].some((value) => (value || "").toLowerCase() === lowerText)
|
||||||
|
);
|
||||||
|
if (template) {
|
||||||
|
return {
|
||||||
|
is_off: false,
|
||||||
|
shift_id: template.id,
|
||||||
|
start_time: template.start_time,
|
||||||
|
end_time: template.end_time,
|
||||||
|
break_minutes: template.break_minutes,
|
||||||
|
hours: template.hours,
|
||||||
|
hours_display: template.hours_display,
|
||||||
|
label: template.label,
|
||||||
|
normalized_input: template.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = this._parseTypedShift(text, currentCell);
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseTypedShift(value, currentCell = {}) {
|
||||||
|
const normalized = value.replaceAll("–", "-").replaceAll("—", "-").replace(/\s+to\s+/i, "-");
|
||||||
|
const parts = normalized.split("-");
|
||||||
|
if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
|
||||||
|
throw new Error("Use 9-5, 9:00-5:30, 9:00 am - 5:30 pm, or OFF.");
|
||||||
|
}
|
||||||
|
const start = this._parseTimePart(parts[0]);
|
||||||
|
let end = this._parseTimePart(parts[1]);
|
||||||
|
if (end <= start && end + 12 <= 24) {
|
||||||
|
end += 12;
|
||||||
|
}
|
||||||
|
if (end <= start) {
|
||||||
|
throw new Error("End must be after start.");
|
||||||
|
}
|
||||||
|
const breakMinutes = currentCell.break_minutes || 30;
|
||||||
|
const hours = Math.max(end - start - breakMinutes / 60, 0);
|
||||||
|
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
|
||||||
|
return {
|
||||||
|
is_off: false,
|
||||||
|
shift_id: false,
|
||||||
|
start_time: start,
|
||||||
|
end_time: end,
|
||||||
|
break_minutes: breakMinutes,
|
||||||
|
hours,
|
||||||
|
hours_display: this._formatHours(hours),
|
||||||
|
label,
|
||||||
|
normalized_input: label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_rangeToParsed(start, end, breakMinutes) {
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||||
|
return { error: "Choose a start and end time." };
|
||||||
|
}
|
||||||
|
if (end <= start) {
|
||||||
|
return { error: "End must be after start." };
|
||||||
|
}
|
||||||
|
const hours = Math.max(end - start - breakMinutes / 60, 0);
|
||||||
|
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
|
||||||
|
return {
|
||||||
|
is_off: false,
|
||||||
|
shift_id: false,
|
||||||
|
start_time: start,
|
||||||
|
end_time: end,
|
||||||
|
break_minutes: breakMinutes,
|
||||||
|
hours,
|
||||||
|
hours_display: this._formatHours(hours),
|
||||||
|
label,
|
||||||
|
normalized_input: label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseTimePart(raw) {
|
||||||
|
const text = raw.trim().toLowerCase().replaceAll(".", "");
|
||||||
|
const match = text.match(/^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Could not read "${raw.trim()}".`);
|
||||||
|
}
|
||||||
|
let hour = Number(match[1]);
|
||||||
|
const minute = Number(match[2] || 0);
|
||||||
|
const meridiem = match[3];
|
||||||
|
if (minute < 0 || minute > 59) {
|
||||||
|
throw new Error("Minutes must be 00-59.");
|
||||||
|
}
|
||||||
|
if (meridiem) {
|
||||||
|
if (hour < 1 || hour > 12) {
|
||||||
|
throw new Error("Use 1-12 with am/pm.");
|
||||||
|
}
|
||||||
|
if (meridiem === "am") {
|
||||||
|
hour = hour === 12 ? 0 : hour;
|
||||||
|
} else {
|
||||||
|
hour = hour === 12 ? 12 : hour + 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hour < 0 || hour > 24) {
|
||||||
|
throw new Error("Hours must be 0-24.");
|
||||||
|
}
|
||||||
|
return hour + minute / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatFloatTime(value) {
|
||||||
|
let hour = Math.floor(value);
|
||||||
|
let minute = Math.round((value - hour) * 60);
|
||||||
|
if (minute === 60) {
|
||||||
|
hour += 1;
|
||||||
|
minute = 0;
|
||||||
|
}
|
||||||
|
const suffix = hour < 12 || hour === 24 ? "am" : "pm";
|
||||||
|
let displayHour = hour % 12;
|
||||||
|
if (displayHour === 0) {
|
||||||
|
displayHour = 12;
|
||||||
|
}
|
||||||
|
return `${displayHour}:${String(minute).padStart(2, "0")} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatHours(value) {
|
||||||
|
let hour = Math.floor(value);
|
||||||
|
let minute = Math.round((value - hour) * 60);
|
||||||
|
if (minute === 60) {
|
||||||
|
hour += 1;
|
||||||
|
minute = 0;
|
||||||
|
}
|
||||||
|
return `${hour}:${String(minute).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_timeValue(value) {
|
||||||
|
const rounded = Math.round(Number(value || 0) * 4) / 4;
|
||||||
|
return rounded.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTimeOptions() {
|
||||||
|
const options = [];
|
||||||
|
for (let minutes = 0; minutes <= 24 * 60; minutes += 15) {
|
||||||
|
const value = minutes / 60;
|
||||||
|
options.push({
|
||||||
|
value: this._timeValue(value),
|
||||||
|
label: this._formatFloatTime(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
_defaultTimes(employee, day) {
|
||||||
|
const dayIndex = this.state.days.findIndex((item) => item.date === day.date);
|
||||||
|
if (dayIndex > 0) {
|
||||||
|
const previousDay = this.state.days[dayIndex - 1];
|
||||||
|
const previousCell = employee.cells[previousDay.date];
|
||||||
|
if (previousCell && !previousCell.is_off && previousCell.start_time && previousCell.end_time) {
|
||||||
|
return {
|
||||||
|
start: previousCell.start_time,
|
||||||
|
end: previousCell.end_time,
|
||||||
|
breakMinutes: previousCell.break_minutes || 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstShift = this.state.shifts[0];
|
||||||
|
if (firstShift) {
|
||||||
|
return {
|
||||||
|
start: firstShift.start_time,
|
||||||
|
end: firstShift.end_time,
|
||||||
|
breakMinutes: firstShift.break_minutes || 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { start: 9, end: 17, breakMinutes: 30 };
|
||||||
|
}
|
||||||
|
|
||||||
|
get quickShiftOptions() {
|
||||||
|
const options = [{
|
||||||
|
key: "off",
|
||||||
|
type: "input",
|
||||||
|
input: "OFF",
|
||||||
|
label: "OFF",
|
||||||
|
detail: "0:00",
|
||||||
|
}];
|
||||||
|
const seen = new Set(["OFF"]);
|
||||||
|
for (const shift of this.state.shifts) {
|
||||||
|
if (seen.has(shift.label)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(shift.label);
|
||||||
|
options.push({
|
||||||
|
key: `shift-${shift.id}`,
|
||||||
|
type: "template",
|
||||||
|
shiftId: shift.id,
|
||||||
|
input: shift.label,
|
||||||
|
label: shift.name || shift.label,
|
||||||
|
detail: `${shift.label} - ${shift.hours_display}`,
|
||||||
|
start: shift.start_time,
|
||||||
|
end: shift.end_time,
|
||||||
|
breakMinutes: shift.break_minutes,
|
||||||
|
hours: shift.hours,
|
||||||
|
hoursDisplay: shift.hours_display,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const input of ["9:00 am - 5:00 pm", "7:00 am - 3:30 pm", "8:00 am - 4:30 pm", "11:00 am - 7:30 pm", "12:00 pm - 8:30 pm"]) {
|
||||||
|
if (seen.has(input)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = this._parseInput(input, { break_minutes: 30 });
|
||||||
|
seen.add(input);
|
||||||
|
options.push({
|
||||||
|
key: `common-${input}`,
|
||||||
|
type: "input",
|
||||||
|
input,
|
||||||
|
label: input,
|
||||||
|
detail: parsed.hours_display || "0:00",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeEditorContext() {
|
||||||
|
if (!this.state.editor.open || !this.activeEditorEmployee || !this.activeEditorDay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
employee: this.activeEditorEmployee,
|
||||||
|
day: this.activeEditorDay,
|
||||||
|
cell: this.activeEditorEmployee.cells[this.activeEditorDay.date],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncEditorFromCell(employee, day) {
|
||||||
|
if (!this.isActiveCell(employee, day)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cell = employee.cells[day.date] || {};
|
||||||
|
if (!cell.is_off && cell.start_time && cell.end_time) {
|
||||||
|
this.state.editor.startValue = this._timeValue(cell.start_time);
|
||||||
|
this.state.editor.endValue = this._timeValue(cell.end_time);
|
||||||
|
}
|
||||||
|
this.state.editor.breakMinutes = cell.break_minutes || 0;
|
||||||
|
this.state.editor.hoursDisplay = cell.hours_display || "0:00";
|
||||||
|
this.state.editor.error = cell.error || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_focusRelativeCell(input, offset) {
|
||||||
|
const inputs = Array.from(document.querySelectorAll(".fclk-planner__shift-input"));
|
||||||
|
const index = inputs.indexOf(input);
|
||||||
|
const next = inputs[index + offset];
|
||||||
|
if (next) {
|
||||||
|
next.focus();
|
||||||
|
next.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_positionActiveEditor(anchor = null) {
|
||||||
|
if (!this.state.editor.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = anchor || this.activeCellAnchor;
|
||||||
|
if (!target || !target.isConnected) {
|
||||||
|
this.closeCellEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const editorWidth = Math.min(380, window.innerWidth - 16);
|
||||||
|
const editorHeight = this.editorRef.el?.offsetHeight || 300;
|
||||||
|
let left = Math.max(8, Math.min(rect.left, window.innerWidth - editorWidth - 8));
|
||||||
|
let top = rect.bottom + 8;
|
||||||
|
if (top + editorHeight > window.innerHeight - 8) {
|
||||||
|
top = Math.max(8, rect.top - editorHeight - 8);
|
||||||
|
}
|
||||||
|
left = Math.round(left);
|
||||||
|
top = Math.round(top);
|
||||||
|
if (this.state.editor.left !== left) {
|
||||||
|
this.state.editor.left = left;
|
||||||
|
}
|
||||||
|
if (this.state.editor.top !== top) {
|
||||||
|
this.state.editor.top = top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_dateAdd(dateString, days) {
|
||||||
|
const date = new Date(`${dateString}T12:00:00`);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("fusion_clock.ShiftPlanner", FusionClockShiftPlanner);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
$o-webclient-color-scheme: bright !default;
|
||||||
|
|
||||||
|
$_fclk-planner-page: #f3f4f6;
|
||||||
|
$_fclk-planner-panel: #eef1f4;
|
||||||
|
$_fclk-planner-card: #ffffff;
|
||||||
|
$_fclk-planner-text: #1f2937;
|
||||||
|
$_fclk-planner-muted: #6b7280;
|
||||||
|
$_fclk-planner-border: #d8dadd;
|
||||||
|
$_fclk-planner-border-strong: #9ca3af;
|
||||||
|
$_fclk-planner-day: #b7dff5;
|
||||||
|
$_fclk-planner-subhead: #d8e9bd;
|
||||||
|
$_fclk-planner-hours: #f5d39b;
|
||||||
|
$_fclk-planner-fallback: #fff8e5;
|
||||||
|
$_fclk-planner-row-hover: #f9fafb;
|
||||||
|
$_fclk-planner-error: #dc2626;
|
||||||
|
$_fclk-planner-focus: #2563eb;
|
||||||
|
$_fclk-planner-shadow: rgba(15, 23, 42, 0.08);
|
||||||
|
$_fclk-planner-editor: #111827;
|
||||||
|
$_fclk-planner-editor-text: #f9fafb;
|
||||||
|
$_fclk-planner-editor-muted: #cbd5e1;
|
||||||
|
$_fclk-planner-editor-border: #374151;
|
||||||
|
$_fclk-planner-editor-control: #ffffff;
|
||||||
|
$_fclk-planner-editor-control-text: #111827;
|
||||||
|
$_fclk-planner-editor-chip: #1f2937;
|
||||||
|
$_fclk-planner-editor-chip-hover: #334155;
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
$_fclk-planner-page: #171a1f !global;
|
||||||
|
$_fclk-planner-panel: #20242b !global;
|
||||||
|
$_fclk-planner-card: #262b33 !global;
|
||||||
|
$_fclk-planner-text: #f3f4f6 !global;
|
||||||
|
$_fclk-planner-muted: #a3aab8 !global;
|
||||||
|
$_fclk-planner-border: #3b424c !global;
|
||||||
|
$_fclk-planner-border-strong: #647082 !global;
|
||||||
|
$_fclk-planner-day: #21465f !global;
|
||||||
|
$_fclk-planner-subhead: #394b2d !global;
|
||||||
|
$_fclk-planner-hours: #6f4f22 !global;
|
||||||
|
$_fclk-planner-fallback: #393326 !global;
|
||||||
|
$_fclk-planner-row-hover: #2b313a !global;
|
||||||
|
$_fclk-planner-error: #f87171 !global;
|
||||||
|
$_fclk-planner-focus: #60a5fa !global;
|
||||||
|
$_fclk-planner-shadow: rgba(0, 0, 0, 0.32) !global;
|
||||||
|
$_fclk-planner-editor: #0f172a !global;
|
||||||
|
$_fclk-planner-editor-text: #f9fafb !global;
|
||||||
|
$_fclk-planner-editor-muted: #cbd5e1 !global;
|
||||||
|
$_fclk-planner-editor-border: #475569 !global;
|
||||||
|
$_fclk-planner-editor-control: #1f2937 !global;
|
||||||
|
$_fclk-planner-editor-control-text: #f9fafb !global;
|
||||||
|
$_fclk-planner-editor-chip: #1e293b !global;
|
||||||
|
$_fclk-planner-editor-chip-hover: #334155 !global;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--fclk-planner-page: #{$_fclk-planner-page};
|
||||||
|
--fclk-planner-panel: #{$_fclk-planner-panel};
|
||||||
|
--fclk-planner-card: #{$_fclk-planner-card};
|
||||||
|
--fclk-planner-text: #{$_fclk-planner-text};
|
||||||
|
--fclk-planner-muted: #{$_fclk-planner-muted};
|
||||||
|
--fclk-planner-border: #{$_fclk-planner-border};
|
||||||
|
--fclk-planner-border-strong: #{$_fclk-planner-border-strong};
|
||||||
|
--fclk-planner-day: #{$_fclk-planner-day};
|
||||||
|
--fclk-planner-subhead: #{$_fclk-planner-subhead};
|
||||||
|
--fclk-planner-hours: #{$_fclk-planner-hours};
|
||||||
|
--fclk-planner-fallback: #{$_fclk-planner-fallback};
|
||||||
|
--fclk-planner-row-hover: #{$_fclk-planner-row-hover};
|
||||||
|
--fclk-planner-error: #{$_fclk-planner-error};
|
||||||
|
--fclk-planner-focus: #{$_fclk-planner-focus};
|
||||||
|
--fclk-planner-shadow: #{$_fclk-planner-shadow};
|
||||||
|
--fclk-planner-editor: #{$_fclk-planner-editor};
|
||||||
|
--fclk-planner-editor-text: #{$_fclk-planner-editor-text};
|
||||||
|
--fclk-planner-editor-muted: #{$_fclk-planner-editor-muted};
|
||||||
|
--fclk-planner-editor-border: #{$_fclk-planner-editor-border};
|
||||||
|
--fclk-planner-editor-control: #{$_fclk-planner-editor-control};
|
||||||
|
--fclk-planner-editor-control-text: #{$_fclk-planner-editor-control-text};
|
||||||
|
--fclk-planner-editor-chip: #{$_fclk-planner-editor-chip};
|
||||||
|
--fclk-planner-editor-chip-hover: #{$_fclk-planner-editor-chip-hover};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
:root {
|
||||||
|
--fclk-planner-page: #171a1f;
|
||||||
|
--fclk-planner-panel: #20242b;
|
||||||
|
--fclk-planner-card: #262b33;
|
||||||
|
--fclk-planner-text: #f3f4f6;
|
||||||
|
--fclk-planner-muted: #a3aab8;
|
||||||
|
--fclk-planner-border: #3b424c;
|
||||||
|
--fclk-planner-border-strong: #647082;
|
||||||
|
--fclk-planner-day: #21465f;
|
||||||
|
--fclk-planner-subhead: #394b2d;
|
||||||
|
--fclk-planner-hours: #6f4f22;
|
||||||
|
--fclk-planner-fallback: #393326;
|
||||||
|
--fclk-planner-row-hover: #2b313a;
|
||||||
|
--fclk-planner-error: #f87171;
|
||||||
|
--fclk-planner-focus: #60a5fa;
|
||||||
|
--fclk-planner-shadow: rgba(0, 0, 0, 0.32);
|
||||||
|
--fclk-planner-editor: #0f172a;
|
||||||
|
--fclk-planner-editor-text: #f9fafb;
|
||||||
|
--fclk-planner-editor-muted: #cbd5e1;
|
||||||
|
--fclk-planner-editor-border: #475569;
|
||||||
|
--fclk-planner-editor-control: #1f2937;
|
||||||
|
--fclk-planner-editor-control-text: #f9fafb;
|
||||||
|
--fclk-planner-editor-chip: #1e293b;
|
||||||
|
--fclk-planner-editor-chip-hover: #334155;
|
||||||
|
}
|
||||||
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal file
447
fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
.fclk-planner {
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--fclk-planner-page, #f3f4f6);
|
||||||
|
color: var(--fclk-planner-text, #1f2937);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--fclk-planner-card, #ffffff);
|
||||||
|
border-bottom: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||||
|
box-shadow: 0 1px 3px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__subtitle {
|
||||||
|
color: var(--fclk-planner-muted, #6b7280);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__warning {
|
||||||
|
margin: 12px 16px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #9a3412;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__loading {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 340px;
|
||||||
|
color: var(--fclk-planner-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
margin: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--fclk-planner-panel, #eef1f4);
|
||||||
|
border: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 6px 20px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__table {
|
||||||
|
--fclk-planner-shift-width: 135px;
|
||||||
|
--fclk-planner-hours-width: 55px;
|
||||||
|
--fclk-planner-days-width: 1330px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1600px;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
background: var(--fclk-planner-card, #ffffff);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-col {
|
||||||
|
width: calc(100% - var(--fclk-planner-days-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__shift-col {
|
||||||
|
width: var(--fclk-planner-shift-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__hours-col {
|
||||||
|
width: var(--fclk-planner-hours-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__table th,
|
||||||
|
.fclk-planner__table td {
|
||||||
|
border-right: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||||
|
border-bottom: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-head,
|
||||||
|
.fclk-planner__day-head,
|
||||||
|
.fclk-planner__sub-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 6;
|
||||||
|
color: var(--fclk-planner-text, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-head {
|
||||||
|
left: 0;
|
||||||
|
z-index: 8;
|
||||||
|
width: calc(100% - var(--fclk-planner-days-width));
|
||||||
|
background: var(--fclk-planner-day, #b7dff5);
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__day-head {
|
||||||
|
background: var(--fclk-planner-day, #b7dff5);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__sub-head {
|
||||||
|
top: 47px;
|
||||||
|
background: var(--fclk-planner-subhead, #d8e9bd);
|
||||||
|
text-align: left;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__hours-head {
|
||||||
|
width: var(--fclk-planner-hours-width);
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__weekday {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__date {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__department-row td {
|
||||||
|
background: var(--fclk-planner-panel, #eef1f4);
|
||||||
|
padding: 0;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__department-toggle {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fclk-planner-text, #1f2937);
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 7px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__department-count {
|
||||||
|
color: var(--fclk-planner-muted, #6b7280);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-row {
|
||||||
|
background: var(--fclk-planner-card, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-row:hover {
|
||||||
|
background: var(--fclk-planner-row-hover, #f9fafb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-cell {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
width: calc(100% - var(--fclk-planner-days-width));
|
||||||
|
background: inherit;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-name {
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__employee-role {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--fclk-planner-muted, #6b7280);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__shift-cell {
|
||||||
|
width: var(--fclk-planner-shift-width);
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 4px;
|
||||||
|
vertical-align: top;
|
||||||
|
background: var(--fclk-planner-card, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__shift-cell--fallback {
|
||||||
|
background: var(--fclk-planner-fallback, #fff8e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__shift-cell--error {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__shift-cell--active {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--fclk-planner-focus, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__shift-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fclk-planner-text, #1f2937);
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
outline: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__shift-input:focus {
|
||||||
|
background: var(--fclk-planner-card, #ffffff);
|
||||||
|
border-color: var(--fclk-planner-focus, #2563eb);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__cell-error {
|
||||||
|
color: var(--fclk-planner-error, #dc2626);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 3px 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__hours-cell {
|
||||||
|
width: var(--fclk-planner-hours-width);
|
||||||
|
background: var(--fclk-planner-hours, #f5d39b);
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 650;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 6px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__cell-editor {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1080;
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 14px;
|
||||||
|
color: var(--fclk-planner-editor-text, #f9fafb);
|
||||||
|
background: var(--fclk-planner-editor, #111827);
|
||||||
|
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__cell-editor::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
left: 28px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--fclk-planner-editor, #111827);
|
||||||
|
border-left: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||||
|
border-top: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__editor-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__editor-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__editor-day {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__editor-hours {
|
||||||
|
min-width: 56px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: #111827;
|
||||||
|
background: var(--fclk-planner-hours, #f5d39b);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__quick-grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__quick-chip {
|
||||||
|
min-height: 46px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: var(--fclk-planner-editor-text, #f9fafb);
|
||||||
|
background: var(--fclk-planner-editor-chip, #1f2937);
|
||||||
|
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__quick-chip:hover,
|
||||||
|
.fclk-planner__quick-chip:focus {
|
||||||
|
background: var(--fclk-planner-editor-chip-hover, #334155);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__quick-label {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__quick-detail {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__time-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__time-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fclk-planner-editor-muted, #cbd5e1);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__time-field select {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
color: var(--fclk-planner-editor-control-text, #111827);
|
||||||
|
background: var(--fclk-planner-editor-control, #ffffff);
|
||||||
|
border: 1px solid var(--fclk-planner-editor-border, #374151);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__editor-error {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__editor-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.fclk-planner__toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__table-wrap {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__cell-editor {
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal file
198
fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_clock.ShiftPlanner">
|
||||||
|
<div class="o_action fclk-planner" t-ref="root">
|
||||||
|
<div class="fclk-planner__toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 class="fclk-planner__title">Shift Planner</h2>
|
||||||
|
<div class="fclk-planner__subtitle"><t t-esc="weekTitle"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="fclk-planner__actions">
|
||||||
|
<button class="btn btn-light" t-on-click="() => this.previousWeek()" t-att-disabled="state.loading or state.saving">
|
||||||
|
<i class="fa fa-chevron-left"/>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-light" t-on-click="() => this.currentWeek()" t-att-disabled="state.loading or state.saving">This Week</button>
|
||||||
|
<button class="btn btn-light" t-on-click="() => this.nextWeek()" t-att-disabled="state.loading or state.saving">
|
||||||
|
<i class="fa fa-chevron-right"/>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" t-on-click="() => this.copyPreviousWeek()" t-att-disabled="state.loading or state.saving">
|
||||||
|
<i class="fa fa-copy me-1"/> Copy Previous Week
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" t-on-click="() => this.exportXlsx()" t-att-disabled="state.loading or state.saving">
|
||||||
|
<i class="fa fa-file-excel-o me-1"/> Export XLSX
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" t-on-click="() => this.save()" t-att-disabled="state.loading or state.saving or !state.dirtyCount">
|
||||||
|
<t t-if="state.saving"><i class="fa fa-spinner fa-spin me-1"/></t>
|
||||||
|
<t t-else=""><i class="fa fa-save me-1"/></t>
|
||||||
|
Save
|
||||||
|
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="state.error">
|
||||||
|
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="state.invalidCount">
|
||||||
|
<div class="fclk-planner__warning">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/>
|
||||||
|
<t t-esc="state.invalidCount"/> invalid cells need attention.
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="state.loading">
|
||||||
|
<div class="fclk-planner__loading">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||||
|
<span>Loading shift planner...</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="!state.loading and !state.error">
|
||||||
|
<div class="fclk-planner__table-wrap">
|
||||||
|
<table class="fclk-planner__table">
|
||||||
|
<colgroup>
|
||||||
|
<col class="fclk-planner__employee-col"/>
|
||||||
|
<t t-foreach="state.days" t-as="day" t-key="'col_' + day.date">
|
||||||
|
<col class="fclk-planner__shift-col"/>
|
||||||
|
<col class="fclk-planner__hours-col"/>
|
||||||
|
</t>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="fclk-planner__employee-head" rowspan="2">Employee</th>
|
||||||
|
<t t-foreach="state.days" t-as="day" t-key="day.date">
|
||||||
|
<th class="fclk-planner__day-head" colspan="2">
|
||||||
|
<div class="fclk-planner__weekday"><t t-esc="day.weekday"/></div>
|
||||||
|
<div class="fclk-planner__date"><t t-esc="day.label"/></div>
|
||||||
|
</th>
|
||||||
|
</t>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<t t-foreach="state.days" t-as="day" t-key="'sub_' + day.date">
|
||||||
|
<th class="fclk-planner__sub-head">Shift</th>
|
||||||
|
<th class="fclk-planner__sub-head fclk-planner__hours-head">Hours</th>
|
||||||
|
</t>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="state.departments" t-as="department" t-key="department.id">
|
||||||
|
<tr class="fclk-planner__department-row">
|
||||||
|
<td t-att-colspan="1 + state.days.length * 2">
|
||||||
|
<button class="fclk-planner__department-toggle" t-on-click="() => this.toggleDepartment(department)">
|
||||||
|
<i t-att-class="isCollapsed(department) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
|
||||||
|
<span><t t-esc="department.name"/></span>
|
||||||
|
<span class="fclk-planner__department-count">
|
||||||
|
<t t-esc="department.employee_ids.length"/> employees
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<t t-if="!isCollapsed(department)">
|
||||||
|
<t t-foreach="getDepartmentEmployees(department)" t-as="employee" t-key="employee.id">
|
||||||
|
<tr class="fclk-planner__employee-row">
|
||||||
|
<td class="fclk-planner__employee-cell">
|
||||||
|
<div class="fclk-planner__employee-name"><t t-esc="employee.name"/></div>
|
||||||
|
<div class="fclk-planner__employee-role" t-if="employee.job_title">
|
||||||
|
<t t-esc="employee.job_title"/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
|
||||||
|
<t t-set="cell" t-value="employee.cells[day.date]"/>
|
||||||
|
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
|
||||||
|
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
|
||||||
|
<input class="fclk-planner__shift-input"
|
||||||
|
t-att-value="cell.input"
|
||||||
|
t-att-title="cell.error || cell.label"
|
||||||
|
t-on-focus="(ev) => this.openCellEditor(employee, day, ev)"
|
||||||
|
t-on-change="(ev) => this.onCellInput(employee, day, ev)"
|
||||||
|
t-on-keydown="(ev) => this.onCellKeydown(employee, day, ev)"/>
|
||||||
|
<div class="fclk-planner__cell-error" t-if="cell.error">
|
||||||
|
<t t-esc="cell.error"/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="fclk-planner__hours-cell">
|
||||||
|
<t t-esc="cell.hours_display || '0:00'"/>
|
||||||
|
</td>
|
||||||
|
</t>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.editor.open"
|
||||||
|
t-ref="shiftEditor"
|
||||||
|
class="fclk-planner__cell-editor"
|
||||||
|
t-att-style="'top: ' + state.editor.top + 'px; left: ' + state.editor.left + 'px;'">
|
||||||
|
<div class="fclk-planner__editor-head">
|
||||||
|
<div class="fclk-planner__editor-person">
|
||||||
|
<div class="fclk-planner__editor-name"><t t-esc="state.editor.employeeName"/></div>
|
||||||
|
<div class="fclk-planner__editor-day"><t t-esc="state.editor.dayLabel"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="fclk-planner__editor-hours">
|
||||||
|
<span><t t-esc="state.editor.hoursDisplay"/></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fclk-planner__quick-grid">
|
||||||
|
<t t-foreach="quickShiftOptions" t-as="option" t-key="option.key">
|
||||||
|
<button type="button"
|
||||||
|
class="fclk-planner__quick-chip"
|
||||||
|
t-on-click="() => this.selectQuickShift(option)">
|
||||||
|
<span class="fclk-planner__quick-label"><t t-esc="option.label"/></span>
|
||||||
|
<span class="fclk-planner__quick-detail"><t t-esc="option.detail"/></span>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fclk-planner__time-row">
|
||||||
|
<label class="fclk-planner__time-field">
|
||||||
|
<span>Start</span>
|
||||||
|
<select t-on-change="(ev) => this.onEditorStartChange(ev)">
|
||||||
|
<t t-foreach="timeOptions" t-as="option" t-key="'start_' + option.value">
|
||||||
|
<option t-att-value="option.value"
|
||||||
|
t-att-selected="option.value === state.editor.startValue">
|
||||||
|
<t t-esc="option.label"/>
|
||||||
|
</option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="fclk-planner__time-field">
|
||||||
|
<span>End</span>
|
||||||
|
<select t-on-change="(ev) => this.onEditorEndChange(ev)">
|
||||||
|
<t t-foreach="timeOptions" t-as="option" t-key="'end_' + option.value">
|
||||||
|
<option t-att-value="option.value"
|
||||||
|
t-att-selected="option.value === state.editor.endValue">
|
||||||
|
<t t-esc="option.label"/>
|
||||||
|
</option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fclk-planner__editor-error" t-if="state.editor.error">
|
||||||
|
<t t-esc="state.editor.error"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fclk-planner__editor-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-light"
|
||||||
|
t-on-click="() => this.clearActiveCell()">
|
||||||
|
<i class="fa fa-eraser me-1"/> Clear
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
t-on-click="() => this.applyEditorRange(true)">
|
||||||
|
<i class="fa fa-check me-1"/> Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
from . import test_nfc_models
|
from . import test_nfc_models
|
||||||
from . import test_clock_nfc_kiosk
|
from . import test_clock_nfc_kiosk
|
||||||
|
from . import test_shift_planner
|
||||||
|
|||||||
254
fusion_clock/tests/test_shift_planner.py
Normal file
254
fusion_clock/tests/test_shift_planner.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from psycopg2 import IntegrityError
|
||||||
|
|
||||||
|
from odoo import fields
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.tests.common import HttpCase, TransactionCase, tagged
|
||||||
|
from odoo.tools.misc import mute_logger
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestShiftPlannerModels(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.Schedule = cls.env['fusion.clock.schedule'].sudo()
|
||||||
|
cls.Shift = cls.env['fusion.clock.shift'].sudo()
|
||||||
|
cls.employee = cls.env['hr.employee'].sudo().create({
|
||||||
|
'name': 'Planner Model Employee',
|
||||||
|
'company_id': cls.env.company.id,
|
||||||
|
'x_fclk_enable_clock': True,
|
||||||
|
})
|
||||||
|
cls.default_shift = cls.Shift.create({
|
||||||
|
'name': 'Default Planner Shift',
|
||||||
|
'start_time': 8.0,
|
||||||
|
'end_time': 16.5,
|
||||||
|
'break_minutes': 30,
|
||||||
|
'company_id': cls.env.company.id,
|
||||||
|
})
|
||||||
|
cls.employee.x_fclk_shift_id = cls.default_shift.id
|
||||||
|
cls.schedule_date = date(2026, 1, 5)
|
||||||
|
|
||||||
|
def test_unique_employee_date_schedule(self):
|
||||||
|
self.Schedule.create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': self.schedule_date,
|
||||||
|
'is_off': True,
|
||||||
|
})
|
||||||
|
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
self.Schedule.create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': self.schedule_date,
|
||||||
|
'is_off': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_off_schedule_has_zero_hours(self):
|
||||||
|
schedule = self.Schedule.create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': date(2026, 1, 6),
|
||||||
|
'is_off': True,
|
||||||
|
})
|
||||||
|
self.assertEqual(schedule.planned_hours, 0)
|
||||||
|
self.assertEqual(schedule.fclk_display_value(), 'OFF')
|
||||||
|
|
||||||
|
def test_working_schedule_computes_hours_minus_break(self):
|
||||||
|
schedule = self.Schedule.create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': date(2026, 1, 7),
|
||||||
|
'start_time': 9.0,
|
||||||
|
'end_time': 17.5,
|
||||||
|
'break_minutes': 30,
|
||||||
|
})
|
||||||
|
self.assertEqual(schedule.planned_hours, 8.0)
|
||||||
|
self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00')
|
||||||
|
|
||||||
|
def test_invalid_same_day_range_is_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.Schedule.create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': date(2026, 1, 8),
|
||||||
|
'start_time': 17.0,
|
||||||
|
'end_time': 9.0,
|
||||||
|
'break_minutes': 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_apply_planner_cell_creates_audit(self):
|
||||||
|
schedule_date = date(2026, 1, 9)
|
||||||
|
self.Schedule.fclk_apply_planner_cell(
|
||||||
|
self.employee,
|
||||||
|
schedule_date,
|
||||||
|
{'input': '9:00 am - 5:30 pm'},
|
||||||
|
self.env.user,
|
||||||
|
)
|
||||||
|
audit = self.env['fusion.clock.schedule.audit'].sudo().search([
|
||||||
|
('employee_id', '=', self.employee.id),
|
||||||
|
('schedule_date', '=', schedule_date),
|
||||||
|
], limit=1)
|
||||||
|
self.assertTrue(audit)
|
||||||
|
self.assertFalse(audit.old_value)
|
||||||
|
self.assertEqual(audit.new_value, '9:00 am - 5:30 pm')
|
||||||
|
|
||||||
|
def test_dated_schedule_overrides_employee_shift_and_fallback_remains(self):
|
||||||
|
planned_date = date(2026, 1, 12)
|
||||||
|
self.Schedule.create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': planned_date,
|
||||||
|
'start_time': 10.0,
|
||||||
|
'end_time': 18.0,
|
||||||
|
'break_minutes': 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
planned = self.employee._get_fclk_day_plan(planned_date)
|
||||||
|
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
|
||||||
|
|
||||||
|
self.assertEqual(planned['source'], 'schedule')
|
||||||
|
self.assertEqual(planned['start_time'], 10.0)
|
||||||
|
self.assertEqual(planned['hours'], 7.0)
|
||||||
|
self.assertEqual(fallback['source'], 'fallback')
|
||||||
|
self.assertEqual(fallback['start_time'], 8.0)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestShiftPlannerApi(HttpCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
manager_group = cls.env.ref('fusion_clock.group_fusion_clock_manager')
|
||||||
|
user_group = cls.env.ref('fusion_clock.group_fusion_clock_user')
|
||||||
|
cls.manager_user = cls.env['res.users'].sudo().create({
|
||||||
|
'name': 'Planner Manager',
|
||||||
|
'login': 'planner-manager',
|
||||||
|
'password': 'plannerpass',
|
||||||
|
'company_id': cls.env.company.id,
|
||||||
|
'company_ids': [(6, 0, [cls.env.company.id])],
|
||||||
|
'group_ids': [(6, 0, [manager_group.id])],
|
||||||
|
})
|
||||||
|
cls.employee_user = cls.env['res.users'].sudo().create({
|
||||||
|
'name': 'Planner Employee User',
|
||||||
|
'login': 'planner-employee-user',
|
||||||
|
'password': 'plannerpass',
|
||||||
|
'company_id': cls.env.company.id,
|
||||||
|
'company_ids': [(6, 0, [cls.env.company.id])],
|
||||||
|
'group_ids': [(6, 0, [user_group.id])],
|
||||||
|
'tz': 'UTC',
|
||||||
|
})
|
||||||
|
cls.employee = cls.env['hr.employee'].sudo().create({
|
||||||
|
'name': 'Planner API Employee',
|
||||||
|
'user_id': cls.employee_user.id,
|
||||||
|
'company_id': cls.env.company.id,
|
||||||
|
'x_fclk_enable_clock': True,
|
||||||
|
})
|
||||||
|
cls.shift = cls.env['fusion.clock.shift'].sudo().create({
|
||||||
|
'name': 'API Morning',
|
||||||
|
'start_time': 7.0,
|
||||||
|
'end_time': 15.5,
|
||||||
|
'break_minutes': 30,
|
||||||
|
'company_id': cls.env.company.id,
|
||||||
|
})
|
||||||
|
cls.week_start = '2026-01-19'
|
||||||
|
|
||||||
|
def _json_call(self, route, payload, login='planner-manager'):
|
||||||
|
self.authenticate(login, 'plannerpass')
|
||||||
|
response = self.url_open(
|
||||||
|
route,
|
||||||
|
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
)
|
||||||
|
return response.json().get('result', {})
|
||||||
|
|
||||||
|
def test_manager_can_load_save_and_export_planner(self):
|
||||||
|
load_result = self._json_call('/fusion_clock/shift_planner/load', {
|
||||||
|
'week_start': self.week_start,
|
||||||
|
})
|
||||||
|
self.assertIn(self.employee.id, [row['id'] for row in load_result['employees']])
|
||||||
|
|
||||||
|
save_result = self._json_call('/fusion_clock/shift_planner/save', {
|
||||||
|
'week_start': self.week_start,
|
||||||
|
'changes': [{
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'date': self.week_start,
|
||||||
|
'input': '9-5',
|
||||||
|
'shift_id': False,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertTrue(save_result.get('success'))
|
||||||
|
|
||||||
|
schedule = self.env['fusion.clock.schedule'].sudo().search([
|
||||||
|
('employee_id', '=', self.employee.id),
|
||||||
|
('schedule_date', '=', fields.Date.to_date(self.week_start)),
|
||||||
|
], limit=1)
|
||||||
|
self.assertTrue(schedule)
|
||||||
|
self.assertEqual(schedule.start_time, 9.0)
|
||||||
|
self.assertEqual(schedule.end_time, 17.0)
|
||||||
|
|
||||||
|
export_result = self._json_call('/fusion_clock/shift_planner/export_xlsx', {
|
||||||
|
'week_start': self.week_start,
|
||||||
|
})
|
||||||
|
self.assertTrue(export_result.get('success'))
|
||||||
|
self.assertTrue(export_result.get('url', '').startswith('/web/content/'))
|
||||||
|
self.assertTrue(self.env['ir.attachment'].sudo().browse(export_result['attachment_id']).exists())
|
||||||
|
|
||||||
|
def test_copy_previous_week(self):
|
||||||
|
previous_monday = fields.Date.to_date(self.week_start) - timedelta(days=7)
|
||||||
|
self.env['fusion.clock.schedule'].sudo().create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': previous_monday,
|
||||||
|
'shift_id': self.shift.id,
|
||||||
|
'start_time': self.shift.start_time,
|
||||||
|
'end_time': self.shift.end_time,
|
||||||
|
'break_minutes': self.shift.break_minutes,
|
||||||
|
})
|
||||||
|
result = self._json_call('/fusion_clock/shift_planner/copy_previous_week', {
|
||||||
|
'week_start': self.week_start,
|
||||||
|
})
|
||||||
|
self.assertTrue(result.get('success'))
|
||||||
|
copied = self.env['fusion.clock.schedule'].sudo().search([
|
||||||
|
('employee_id', '=', self.employee.id),
|
||||||
|
('schedule_date', '=', fields.Date.to_date(self.week_start)),
|
||||||
|
], limit=1)
|
||||||
|
self.assertEqual(copied.shift_id, self.shift)
|
||||||
|
|
||||||
|
def test_non_manager_cannot_mutate_planner(self):
|
||||||
|
result = self._json_call('/fusion_clock/shift_planner/save', {
|
||||||
|
'week_start': self.week_start,
|
||||||
|
'changes': [],
|
||||||
|
}, login='planner-employee-user')
|
||||||
|
self.assertEqual(result.get('error'), 'Access denied.')
|
||||||
|
|
||||||
|
def test_off_day_clock_in_succeeds_and_logs_unscheduled_shift(self):
|
||||||
|
today = fields.Date.today()
|
||||||
|
location = self.env['fusion.clock.location'].sudo().create({
|
||||||
|
'name': 'Planner Test Location',
|
||||||
|
'latitude': 43.65,
|
||||||
|
'longitude': -79.38,
|
||||||
|
'radius': 100,
|
||||||
|
'company_id': self.env.company.id,
|
||||||
|
'all_employees': True,
|
||||||
|
})
|
||||||
|
self.env['fusion.clock.schedule'].sudo().create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'schedule_date': today,
|
||||||
|
'is_off': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
result = self._json_call('/fusion_clock/clock_action', {
|
||||||
|
'latitude': location.latitude,
|
||||||
|
'longitude': location.longitude,
|
||||||
|
'source': 'portal',
|
||||||
|
}, login='planner-employee-user')
|
||||||
|
|
||||||
|
self.assertTrue(result.get('success'))
|
||||||
|
self.assertEqual(result.get('action'), 'clock_in')
|
||||||
|
self.assertIn('unscheduled', result.get('message', ''))
|
||||||
|
log = self.env['fusion.clock.activity.log'].sudo().search([
|
||||||
|
('employee_id', '=', self.employee.id),
|
||||||
|
('log_type', '=', 'unscheduled_shift'),
|
||||||
|
], limit=1)
|
||||||
|
self.assertTrue(log)
|
||||||
@@ -16,6 +16,34 @@
|
|||||||
sequence="5"
|
sequence="5"
|
||||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||||
|
|
||||||
|
<!-- Scheduling -->
|
||||||
|
<menuitem id="menu_fusion_clock_scheduling"
|
||||||
|
name="Scheduling"
|
||||||
|
parent="menu_fusion_clock_root"
|
||||||
|
sequence="8"
|
||||||
|
groups="group_fusion_clock_manager"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fusion_clock_shift_planner"
|
||||||
|
name="Shift Planner"
|
||||||
|
parent="menu_fusion_clock_scheduling"
|
||||||
|
action="action_fusion_clock_shift_planner"
|
||||||
|
sequence="5"
|
||||||
|
groups="group_fusion_clock_manager"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fusion_clock_scheduled_shifts"
|
||||||
|
name="Scheduled Shifts"
|
||||||
|
parent="menu_fusion_clock_scheduling"
|
||||||
|
action="action_fusion_clock_schedule"
|
||||||
|
sequence="10"
|
||||||
|
groups="group_fusion_clock_manager"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fusion_clock_schedule_audit"
|
||||||
|
name="Schedule Audit"
|
||||||
|
parent="menu_fusion_clock_scheduling"
|
||||||
|
action="action_fusion_clock_schedule_audit"
|
||||||
|
sequence="20"
|
||||||
|
groups="group_fusion_clock_manager"/>
|
||||||
|
|
||||||
<!-- Attendance Sub-Menu -->
|
<!-- Attendance Sub-Menu -->
|
||||||
<menuitem id="menu_fusion_clock_attendance"
|
<menuitem id="menu_fusion_clock_attendance"
|
||||||
name="Attendance"
|
name="Attendance"
|
||||||
|
|||||||
128
fusion_clock/views/clock_schedule_views.xml
Normal file
128
fusion_clock/views/clock_schedule_views.xml
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="action_fusion_clock_shift_planner" model="ir.actions.client">
|
||||||
|
<field name="name">Shift Planner</field>
|
||||||
|
<field name="tag">fusion_clock.ShiftPlanner</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_schedule_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.schedule.list</field>
|
||||||
|
<field name="model">fusion.clock.schedule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="schedule_date"/>
|
||||||
|
<field name="employee_id"/>
|
||||||
|
<field name="department_id"/>
|
||||||
|
<field name="is_off"/>
|
||||||
|
<field name="shift_id"/>
|
||||||
|
<field name="start_time" widget="float_time"/>
|
||||||
|
<field name="end_time" widget="float_time"/>
|
||||||
|
<field name="break_minutes"/>
|
||||||
|
<field name="planned_hours"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_schedule_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.schedule.form</field>
|
||||||
|
<field name="model">fusion.clock.schedule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="employee_id"/>
|
||||||
|
<field name="schedule_date"/>
|
||||||
|
<field name="is_off"/>
|
||||||
|
<field name="shift_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="start_time" widget="float_time"/>
|
||||||
|
<field name="end_time" widget="float_time"/>
|
||||||
|
<field name="break_minutes"/>
|
||||||
|
<field name="planned_hours" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="note"/>
|
||||||
|
<field name="department_id" readonly="1"/>
|
||||||
|
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_schedule_search" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.schedule.search</field>
|
||||||
|
<field name="model">fusion.clock.schedule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="employee_id"/>
|
||||||
|
<field name="department_id"/>
|
||||||
|
<field name="schedule_date"/>
|
||||||
|
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
|
||||||
|
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
|
||||||
|
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
|
||||||
|
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_clock_schedule" model="ir.actions.act_window">
|
||||||
|
<field name="name">Scheduled Shifts</field>
|
||||||
|
<field name="res_model">fusion.clock.schedule</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_schedule_audit_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.schedule.audit.list</field>
|
||||||
|
<field name="model">fusion.clock.schedule.audit</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list create="0" edit="0" delete="0">
|
||||||
|
<field name="changed_at"/>
|
||||||
|
<field name="employee_id"/>
|
||||||
|
<field name="schedule_date"/>
|
||||||
|
<field name="old_value"/>
|
||||||
|
<field name="new_value"/>
|
||||||
|
<field name="changed_by_id"/>
|
||||||
|
<field name="department_id"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_schedule_audit_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.schedule.audit.form</field>
|
||||||
|
<field name="model">fusion.clock.schedule.audit</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form create="0" edit="0" delete="0">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="changed_at"/>
|
||||||
|
<field name="changed_by_id"/>
|
||||||
|
<field name="employee_id"/>
|
||||||
|
<field name="schedule_date"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="old_value"/>
|
||||||
|
<field name="new_value"/>
|
||||||
|
<field name="department_id"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_clock_schedule_audit" model="ir.actions.act_window">
|
||||||
|
<field name="name">Schedule Audit</field>
|
||||||
|
<field name="res_model">fusion.clock.schedule.audit</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -142,6 +142,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled Shift -->
|
||||||
|
<div class="fclk-schedule-card">
|
||||||
|
<div class="fclk-schedule-icon">
|
||||||
|
<i class="fa fa-calendar-check-o"/>
|
||||||
|
</div>
|
||||||
|
<div class="fclk-schedule-info">
|
||||||
|
<div class="fclk-schedule-label">Today's Shift</div>
|
||||||
|
<div class="fclk-schedule-value">
|
||||||
|
<t t-if="today_schedule.get('is_off')">OFF</t>
|
||||||
|
<t t-else="">
|
||||||
|
<t t-esc="today_schedule.get('label') or 'Not scheduled'"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fclk-schedule-hours">
|
||||||
|
<t t-if="today_schedule.get('is_off')">0:00</t>
|
||||||
|
<t t-else="">
|
||||||
|
<t t-esc="'%.1f' % (today_schedule.get('hours') or 0.0)"/>h
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Timer Section -->
|
<!-- Timer Section -->
|
||||||
<div class="fclk-timer-section">
|
<div class="fclk-timer-section">
|
||||||
<div class="fclk-timer-label" id="fclk-timer-label">
|
<div class="fclk-timer-label" id="fclk-timer-label">
|
||||||
|
|||||||
BIN
fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
4
fusion_plating/.gitignore
vendored
4
fusion_plating/.gitignore
vendored
@@ -2,6 +2,10 @@
|
|||||||
# The companion server saves files here; not project source.
|
# The companion server saves files here; not project source.
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Claude Code preview-tooling state (launch.json for preview_start,
|
||||||
|
# throwaway HTML mockups from brainstorming sessions).
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Local Odoo dev artifacts
|
# Local Odoo dev artifacts
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -166,18 +166,83 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
|||||||
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
|
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
|
||||||
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
|
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
|
||||||
|
|
||||||
## Shop-floor action endpoints — credit the correct tech via `tablet_tech_id`
|
## Shop-floor action endpoints — attribution is automatic via `request.env.user`
|
||||||
The tablet sits on a long-lived "shopfloor service" Odoo session shared by many techs. The actual tech-of-record is established via the PIN unlock (Phase 6); their id lives in the OWL `fp_shopfloor_tech_store` service and is sent as `tablet_tech_id` on every action RPC.
|
|
||||||
|
|
||||||
When writing a NEW shop-floor controller endpoint that **writes** (creates a record, calls a `button_*` method, posts to chatter):
|
As of `fusion_plating_shopfloor 19.0.33.1.0` (2026-05-24 Tablet PIN session redesign, Phase G cleanup), tablet writes are attributed via **real per-tech Odoo sessions**, not via a `tablet_tech_id` kwarg.
|
||||||
1. Add `tablet_tech_id=None` as a kwarg on the route handler.
|
|
||||||
2. At the top, call: `env = env_for_tablet_tech(request.env, tablet_tech_id)` (from `fusion_plating_shopfloor/controllers/_tablet_audit.py`).
|
|
||||||
3. Use `env` (not `request.env`) for all subsequent writes. `env.with_user(...)` is applied internally so `create_uid` / `write_uid` / chatter authorship carry the right uid.
|
|
||||||
4. Read-only endpoints (load / kanban / funnel / overview) don't need this — leave them as `request.env`.
|
|
||||||
|
|
||||||
On the client side: use `fpRpc()` from `services/fp_rpc.js` (drop-in for `rpc()`) for action calls. It auto-injects `tablet_tech_id`. Read calls can keep using plain `rpc()`.
|
**How it works now:**
|
||||||
|
- The tablet browser holds a session as the kiosk user (xmlid `fusion_plating_shopfloor.user_fp_tablet_kiosk`, id 141 on entech) when nobody is unlocked.
|
||||||
|
- PIN unlock POSTs to `/fp/tablet/unlock_session`, which calls `request.session.authenticate(type='fp_tablet_pin', login, pin)`. The custom `_check_credentials` override on `res.users` validates the PIN hash + `all_group_ids` shop-branch membership and mints a real session AS the tech. Browser cookie swaps.
|
||||||
|
- Subsequent writes use `request.env.user` (= the tech) automatically. `create_uid` / `write_uid` / chatter authorship are correct with zero plumbing.
|
||||||
|
- Lock-back (`/fp/tablet/lock_session`) destroys the tech's session and re-auths the browser as the kiosk via the password stored in `ir.config_parameter['fp.tablet.kiosk_password']`.
|
||||||
|
|
||||||
If `tablet_tech_id` is missing or invalid, `env_for_tablet_tech` falls back to the session uid — old callers and pre-Phase-6.3 endpoints continue working.
|
**When writing a NEW shop-floor controller endpoint that writes:**
|
||||||
|
1. Use `env = request.env` directly. No `tablet_tech_id` kwarg, no `env_for_tablet_tech` helper.
|
||||||
|
2. Read-only endpoints — same thing, `request.env` is fine.
|
||||||
|
|
||||||
|
**Gone post-Phase-G** — do NOT re-introduce:
|
||||||
|
- `tablet_tech_id` kwarg on any HTTP route
|
||||||
|
- `env_for_tablet_tech(...)` helper (the `_tablet_audit.py` file is deleted)
|
||||||
|
- `fp_shopfloor_tech_store` OWL service (the `services/tech_store.js` file is deleted)
|
||||||
|
- Legacy `/fp/tablet/unlock` route (the new one is `/fp/tablet/unlock_session`)
|
||||||
|
- `fp.shopfloor.tablet_session_mode` feature flag (`session_swap` is the only flow; flag was for the 1-week overlap window during rollout — now retired)
|
||||||
|
|
||||||
|
**Kiosk password lives in TWO places — keep them in sync:**
|
||||||
|
The kiosk user's actual `res.users.password` AND `ir.config_parameter['fp.tablet.kiosk_password']` must match. The lock_session endpoint reads ICP to re-auth as kiosk after the tech session is destroyed. If they diverge (e.g. someone resets the password on the user form without updating ICP), lock-back fails and the endpoint returns `needs_kiosk_relogin=True` — the tablet then needs a manual login. Two valid states:
|
||||||
|
- **Both set to the same value** — kiosk password is plaintext-readable in DB but lock-back works automatically.
|
||||||
|
- **ICP key deleted entirely** — `DELETE FROM ir_config_parameter WHERE key = 'fp.tablet.kiosk_password';` — accepts manual re-login after every lock event in exchange for no plaintext in DB or backups.
|
||||||
|
|
||||||
|
**Identify the kiosk user by xmlid, NEVER by login string:**
|
||||||
|
The kiosk login (`fp_tablet_kiosk@enplating.local` at creation time) is a `noupdate="1"` data record — admins can rename it on the user form for memorability (entech's actual kiosk login is `tablet@enplating.ca` as of 2026-05-24), and the rename PERSISTS through `-u`. Any code that hardcoded `'fp_tablet_kiosk@enplating.local'` as a string silently breaks after a rename — caught when Phase G's `lock_session` had the login hardcoded and broke after the user renamed the kiosk; fixed by resolving via `env.ref('fusion_plating_shopfloor.user_fp_tablet_kiosk').sudo().login`. Same pattern applies to any other user/group/record an admin might rename on the form. The xmlid is the stable identity; the display fields are not.
|
||||||
|
|
||||||
|
**Audit log** (`fp.tablet.session.event`): append-only model with Owner-only read ACL + Python `write`/`unlink` overrides (only the force-lock cron + retention crons bypass via context flags `fp_tablet_audit_admin_write` / `fp_tablet_audit_admin_purge`). Captures every unlock / failed_unlock / manual_lock / idle_lock / ceiling_lock / force_lock / admin_reset event with sha256(session sid), ip, user-agent, acting_uid, duration. View under Plating → Configuration → Tablet Audit Log (Owner-only menu). Per-user 7-day count smart button on `res.users` form.
|
||||||
|
|
||||||
|
## Mail templates: `email_from` MUST match the active mail server's `from_filter` (or M365 greylists)
|
||||||
|
Entech relays through Gmail OAuth as `orders@enplating.ca` (the mail server's `from_filter`). When a `mail.template` renders `email_from` to ANY other address (e.g. `{{ object.company_id.email }}` → `sales@enplating.ca`), Odoo logs `WARNING ir_mail_server: No mail server matches the from_filter, using <X> as fallback` and ships the message anyway — but the message has misaligned authentication:
|
||||||
|
- SMTP-AUTH = `orders@enplating.ca`
|
||||||
|
- `From:` header = `sales@enplating.ca`
|
||||||
|
- DKIM signs the `mail-from` domain, NOT the `From:` domain
|
||||||
|
- DMARC alignment check at recipient FAILS
|
||||||
|
|
||||||
|
Recipients on Microsoft 365 (like nexasystems.ca) react to DMARC fail by **greylisting for 5–15 minutes** before delivery — or routing straight to junk. The user feels this as "the email takes a while" or "I never got it."
|
||||||
|
|
||||||
|
**Rule:** every mail.template's `email_from` must resolve to an address inside the mail server's `from_filter`. Easiest pattern — add a helper on the template's target model that picks the active mail server's `from_filter` dynamically, then reference it from the template:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<field name="email_from">{{ object._fp_resolve_from_header() }}</field>
|
||||||
|
<field name="reply_to">{{ object._fp_resolve_from_header() }}</field>
|
||||||
|
```
|
||||||
|
|
||||||
|
`res.users._fp_resolve_from_header()` (in `fusion_plating_shopfloor/models/res_users.py`) is the reference implementation — sudo-search `ir.mail_server`, prefer `from_filter` (if it's an `addr@domain` form, not a wildcard), then `smtp_user`, then fall back to `company.email`. Reuse it on other models by either inheriting `res.users`-style helpers or duplicating the same lookup pattern (the lookup is 6 lines).
|
||||||
|
|
||||||
|
**Also avoid emojis in subject lines for cross-provider mail.** M365's spam classifier bumps emoji-containing subjects ~1.5–2 points; combined with cross-provider routing it pushes mail to junk or delay. PIN-reset codes / invoice notifications / shipment alerts — keep the subject plain.
|
||||||
|
|
||||||
|
If you must use a different From for branding reasons, the proper fix is multi-step (add the From address as a verified "Send as" alias on the Gmail account, ensure SPF lists Gmail's IPs for that domain, set up DKIM signing for the From domain). That's a config-side change, not a code change — flag it for the admin instead of working around it in the template.
|
||||||
|
|
||||||
|
## `send_mail(force_send=False)` is broken for interactive flows on entech
|
||||||
|
Entech's `Mail: Email Queue Manager` cron (id 3) runs every **1 hour**, not the per-minute default that vanilla Odoo demos use. A controller that queues an email with `force_send=False` for an interactive flow (PIN reset code, password reset, "click here for a one-time link", any flow where the user is staring at the screen waiting for the email) will sit in the outbox for up to 60 minutes. The mail row stays at `state='outgoing'`, no error is logged, the user thinks it never sent. Bit us 2026-05-25 on tablet PIN reset — codes 2253 and 7780 sat queued for 36+ minutes before we noticed.
|
||||||
|
|
||||||
|
**Rule:** for any interactive email flow, use `force_send=True` in `template.send_mail(res_id, force_send=True)`. The synchronous send adds ~1s of latency but the user gets the email before they can tab to their inbox. The cron is for batch / fire-and-forget notifications where the user isn't watching (NCR escalations, daily digests, etc.).
|
||||||
|
|
||||||
|
**Don't change the cron interval to "fix" this** — the hourly schedule is intentional on entech (Gmail SMTP daily quota mitigation + reduced relay overhead for the 95% of notifications that aren't time-sensitive). Per-flow `force_send=True` is the right knob.
|
||||||
|
|
||||||
|
When `force_send=True`, errors propagate (Gmail SMTP refusal, from_filter mismatch, etc.). Wrap with try/except and log, but consider returning a user-visible `{ok: false, error: 'email_send_failed'}` so the operator knows to retry or ask the manager — better than silent success that never arrives.
|
||||||
|
|
||||||
|
## Brainstorming visual previews — the user is on Mac, this Windows host can't show them
|
||||||
|
The user runs Claude Code from a **Mac** via Tailscale into this Windows host (`Home`). Any browser preview server bound to `localhost` on the Windows side (`http://localhost:8765`, the brainstorm script's preview server, `python -m http.server`, etc.) is unreachable from the Mac browser. Has bitten us three times now — Quality Dashboard redesign (2026-05-23), and twice during the Express Orders brainstorm (2026-05-25).
|
||||||
|
|
||||||
|
**Rule:** when running on this Windows host, do NOT spin up the `superpowers:brainstorming` visual companion (or any other browser-preview-style server) unless the user explicitly asks for it. Default to text-based design discussion — ASCII tables, structured lists, reference to existing files. The Excel mockup or screenshot the user provides is plenty of reference. If a visual companion IS requested anyway, the only path that works is binding to the Windows host's Tailscale IP (`100.87.38.59` on `Home`) — but even that requires firewall coordination and isn't worth the friction.
|
||||||
|
|
||||||
|
**Mac-side sessions:** localhost previews work fine; this rule doesn't apply. The user typically switches to a native Mac Claude Code session for visual-heavy work.
|
||||||
|
|
||||||
|
## Deleting an OWL component — also audit the localStorage / shared state it wrote
|
||||||
|
When you delete an OWL component (delete .js/.xml/.scss + drop manifest entries), the component's code is gone, but **any localStorage keys it wrote remain on every browser that ever rendered it**. If another live component reads those keys (with the deleted component's name in the key), the stale value still feeds into requests.
|
||||||
|
|
||||||
|
Concrete failure 2026-05-25: deleted `fp_shopfloor_landing` (which used `localStorage.fp_landing_station_id` to pair the tablet to a station). `tablet_lock.js` was reading the same key to scope the lock-screen tile query (`/fp/tablet/tiles?station_id=…`). After the delete, every tablet that had ever paired via the old component kept sending that stale id; the kiosk session can't read `fusion.plating.shopfloor.station` (locked-down ACL), so the endpoint hit AccessError and returned an empty tile list. The lock screen rendered "no operators." Took us ten minutes of "but my code didn't break anything" before finding it.
|
||||||
|
|
||||||
|
**Mandatory grep before deleting an OWL component:** `grep -rn '<key-the-component-wrote>' --include='*.js' static/src/`. For every hit in OTHER files: decide (a) read a different source, (b) clear-on-read and read a different source, or (c) keep the key and add a server-side endpoint that writes it. Also clear the key from the surviving components on next load so existing tablets self-heal — don't make the user clear browser storage.
|
||||||
|
|
||||||
|
Same audit applies to: window globals the component attached (`window.fpFoo = …`), CustomEvents it dispatched, IndexedDB stores it created, ServiceWorker registrations, BroadcastChannel topics.
|
||||||
|
|
||||||
## Removing menus/records — Odoo does NOT auto-delete orphans
|
## Removing menus/records — Odoo does NOT auto-delete orphans
|
||||||
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
|
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
|
||||||
@@ -186,6 +251,8 @@ Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove
|
|||||||
```
|
```
|
||||||
Caught 2026-05-22 when the Phase 3 Plant Overview menu kept showing alongside the new Workstation menu after deploy.
|
Caught 2026-05-22 when the Phase 3 Plant Overview menu kept showing alongside the new Workstation menu after deploy.
|
||||||
|
|
||||||
|
**`<delete>` is single-use — remove it after the deploy that fires it.** Subsequent `-u` runs against a missing xmlid raise `ValueError: External ID not found in the system: <module>.<xmlid>` because the XML loader evaluates the `id="..."` ref at parse time. The error is non-fatal (load continues), but it bloats the log on every restart and obscures real failures. Workflow: ship the `<delete>` directive in deploy N, then DELETE the directive itself in deploy N+1 (or replace with a comment noting when the row was removed). The `<delete>` is not idempotent against an already-missing row. Caught 2026-05-25 when `<delete model="ir.ui.menu" id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview"/>` in legacy_menu_hide.xml had been firing this error for weeks after the menu was already gone.
|
||||||
|
|
||||||
## Odoo 19 ir.cron — `numbercall` and `doall` are gone
|
## Odoo 19 ir.cron — `numbercall` and `doall` are gone
|
||||||
The legacy `numbercall=-1` (run-forever) and `doall=False` (catch-up-missed) fields were removed from `ir.cron` in Odoo 19. Including them in `<record model="ir.cron">` data XML produces:
|
The legacy `numbercall=-1` (run-forever) and `doall=False` (catch-up-missed) fields were removed from `ir.cron` in Odoo 19. Including them in `<record model="ir.cron">` data XML produces:
|
||||||
```
|
```
|
||||||
@@ -208,6 +275,104 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
|
|||||||
11. **XML data ordering**: Window actions must be defined BEFORE `<menuitem>` elements that reference them in the same file.
|
11. **XML data ordering**: Window actions must be defined BEFORE `<menuitem>` elements that reference them in the same file.
|
||||||
12. **Module install on new modules**: Use `--update=base` alongside `-i MODULE` to ensure Odoo rescans the addons path and finds the new module directory.
|
12. **Module install on new modules**: Use `--update=base` alongside `-i MODULE` to ensure Odoo rescans the addons path and finds the new module directory.
|
||||||
13. **Implied group cascade**: `implied_ids` on `res.groups` does NOT reliably propagate to users on module install. Always include `user_ids` to explicitly assign admin, or fix via SQL post-install.
|
13. **Implied group cascade**: `implied_ids` on `res.groups` does NOT reliably propagate to users on module install. Always include `user_ids` to explicitly assign admin, or fix via SQL post-install.
|
||||||
|
13b. **Kanban template name — Odoo 19 wants `<t t-name="card">`, NOT `<t t-name="kanban-box">`**. Old name silently fails at render: `Error: Missing 'card' template`. Use the new structure with semantic `<aside>` + `<main>`:
|
||||||
|
```xml
|
||||||
|
<templates>
|
||||||
|
<t t-name="card" class="flex-row align-items-center">
|
||||||
|
<aside><field name="image_128" widget="image"/></aside>
|
||||||
|
<main class="ms-2"><field name="name"/></main>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
```
|
||||||
|
Reference: `/usr/lib/python3/dist-packages/odoo/addons/web/static/src/views/kanban/kanban_arch_parser.js`. Pre-existing `fp_rack_views.xml` still uses the old name and would also fail at render — fix when next touched. Caught 2026-05-24 by final reviewer of permissions-overhaul branch.
|
||||||
|
13m. **Tablet / kanban / dashboard controllers that surface DENORMALIZED cross-module data must `sudo()` the source recordset** at the top of the rendering helper. Low-privilege roles (Technician / Sales Rep) can read `fp.job` but NOT the cross-module fields it links to (sale.order, fp.part.catalog, fusion.plating.customer.spec, etc.) — naive `job.sale_order_id.x_fc_po_number` AccessErrors at render time and the kanban returns empty. The output is safe-to-expose display data; ACL gating is enforced by the CALLER's access to fp.job itself. Pattern:
|
||||||
|
```python
|
||||||
|
def _render_card(job, paired):
|
||||||
|
job = job.sudo() # cross-module reads now bypass ACL
|
||||||
|
so = job.sale_order_id # was AccessError for Technician
|
||||||
|
...
|
||||||
|
```
|
||||||
|
Caught 2026-05-24 when Technicians saw an empty Shop Floor kanban post-migration (log: `Access Denied by ACLs ... model: sale.order`). Same pattern likely needed in any controller returning a job-centric card payload to a non-Manager user.
|
||||||
|
13l. **`res.groups.user_ids` returns DIRECT members only — implied/transitive memberships are NOT stored in `user.groups_id`**. When you `user.write({'groups_id': [(4, owner_group.id)]})`, Odoo adds JUST the Owner group to the user — it does NOT cascade and write the implied Manager/Shop Manager/Technician group rows into res_groups_users_rel. The implication chain is resolved at READ time by `has_group()` and `_get_trans_implied_groups()`, but NOT materialized in storage. So:
|
||||||
|
- `env.ref('fusion_plating.group_fp_technician').user_ids` returns ONLY the 18 direct Technicians, NOT the Owners/QMs/Managers/Shop Managers who reach Technician via implication.
|
||||||
|
- `env.ref('fusion_plating.group_fusion_plating_operator').user_ids` (the deprecated group) returns EMPTY post-migration for the same reason — no user holds it directly.
|
||||||
|
|
||||||
|
**Fix for "enumerate everyone with role X or higher":** search `res.users` directly with the union of group ids:
|
||||||
|
```python
|
||||||
|
shop_branch_ids = [env.ref(x).id for x in (
|
||||||
|
'fusion_plating.group_fp_technician',
|
||||||
|
'fusion_plating.group_fp_shop_manager_v2',
|
||||||
|
'fusion_plating.group_fp_manager',
|
||||||
|
'fusion_plating.group_fp_quality_manager',
|
||||||
|
'fusion_plating.group_fp_owner',
|
||||||
|
)]
|
||||||
|
users = env['res.users'].sudo().search([
|
||||||
|
('group_ids', 'in', shop_branch_ids), # NB: group_ids not groups_id in Odoo 19, see rule 13c
|
||||||
|
('share', '=', False), ('active', '=', True),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
`('groups_id', 'in', [...])` is the right operator — it matches the DIRECT membership rel table without trying to follow implications. Since each user has exactly one primary plating role (Phase F `x_fc_plating_role` Selection enforces this), this returns every shop-branch user with no duplicates.
|
||||||
|
|
||||||
|
For `has_group`-style intent ("does this single user have role X or any role that implies X"), use `user.has_group('fusion_plating.group_fp_X')` — that DOES follow the implication chain at read time.
|
||||||
|
|
||||||
|
Caught 2026-05-24 in two waves: (1) tablet lock screen showed "No operators configured" because it queried the deprecated `group_fusion_plating_operator.user_ids`; (2) after fixing to `group_fp_technician.user_ids`, it still missed Owners/Managers because implication chains don't populate `user_ids`. Final fix: search-based query across the 5 shop-branch role ids. Audit for other instances: `grep -rn "env\.ref.*\.user_ids\b" --include='*.py'` (skip test files which intentionally exercise backward-compat).
|
||||||
|
13k. **Custom fields on `res.users` must be added to `SELF_WRITEABLE_FIELDS` (and often `SELF_READABLE_FIELDS`) or non-admin users can't save their own Preferences dialog**. Odoo 19's User Preferences dialog goes through `res.users.write` on the user's own record — Odoo bypasses the standard write ACL ONLY IF every field being written is in `SELF_WRITEABLE_FIELDS`. Any unknown field forces fallback to the standard ACL (admin-only on entech) → `AccessError: You are not allowed to modify 'User' records. Required group: Access Rights`.
|
||||||
|
|
||||||
|
**In Odoo 19, `SELF_WRITEABLE_FIELDS` and `SELF_READABLE_FIELDS` are `@property`-decorated methods, NOT class attributes.** Extend via super(), not list concatenation on `models.Model.SELF_*` (that AttributeErrors at module load — Model base doesn't define them, only res.users does). Canonical pattern (matches hr/res_users.py and mail/res_users.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def SELF_WRITEABLE_FIELDS(self):
|
||||||
|
return super().SELF_WRITEABLE_FIELDS + [
|
||||||
|
'x_fc_plating_landing_action_id', 'x_fc_signature_image',
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def SELF_READABLE_FIELDS(self):
|
||||||
|
return super().SELF_READABLE_FIELDS + [
|
||||||
|
'x_fc_plating_role', 'x_fc_tablet_pin_set_date', ...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Readonly fields on the preferences form ALSO need SELF_READABLE_FIELDS (the form fetches them before the user clicks Save). Methods invoked by buttons that do their own `sudo().write()` bypass this — only DIRECT form-level writes hit the check. Caught 2026-05-24 when Technician tried to save their preferences after the plating landing field was added; the initial fix used the wrong class-attribute syntax and crashed odoo at module load.
|
||||||
|
13j. **Non-stored Many2many computes STILL require user-level read access on the comodel** for field-assignment cache fill, even when the compute body is wrapped in `sudo()`. The `user.field = [(6, 0, ids)]` assignment populates the cache by relating to comodel records the CURRENT USER must be able to read — `sudo()` on the lookup doesn't help because the assignment is per-record-context. If the comodel is admin-only (like `ir.actions.actions` / `ir.actions.act_window` on entech), a non-admin opening their own preferences will fail with `Failed to write field X. You are not allowed to access 'Y' records.` Two fixes: (a) drop the Many2many compute and use a static domain filter instead, plus add an ACL row granting read on the comodel to whichever role group needs to evaluate the picker domain; (b) replace the Many2many with a Json/Char that stores IDs, lose the auto-validation. Option (a) is simpler — Odoo's design assumes pickers' comodels are user-readable. Caught 2026-05-24 when a Technician tried to open their Preferences after the per-user `accessible_landing_action_ids` field was added.
|
||||||
|
13i. **`res.users` does NOT have `message_post()`** — chatter posting must go through `user.partner_id.message_post(...)`. `res.users` uses `_inherits = {'res.partner': 'partner_id'}` (delegation), which proxies FIELDS through partner_id but NOT METHODS. `user.message_post(...)` raises `AttributeError: 'res.users' object has no attribute 'message_post'`. Note that mail's tracking IS recorded on the user record (via partner) — the chatter widget on user form displays partner's chatter — but the post call itself targets the partner. Caught 2026-05-24 during Owner approval click on the migration preview screen.
|
||||||
|
13c. **`res.users.group_ids` NOT `groups_id`**: Odoo 19 renamed the m2m field. Old name doesn't resolve; `@api.depends('groups_id')` raises `ValueError` at module load. Also: domain on relational pickers should use `all_group_ids` (transitive set incl. implied) instead of `group_ids` (only directly-assigned) — otherwise an Owner user won't match a domain looking for QM members. See `feedback_odoo19_groups_id_renamed.md`.
|
||||||
|
13d. **`post_init_hook` ONLY fires on INSTALL, not UPGRADE** in Odoo 19. For logic that must run on `-u` of an existing install (entech case), add a `migrations/<version>/post-migrate.py` with a `migrate(cr, version)` function that calls the same helper. The hook still works on fresh install; the migration script bridges the gap on `-u`. Both should be idempotent so re-runs are safe.
|
||||||
|
13g. **Odoo 19's `sale.view_order_form` uses a single `<field name="tax_totals" widget="account-tax-totals-field"/>` widget instead of separate `amount_total` / `amount_untaxed` / `amount_tax` fields**. Inheriting xpaths targeting any of the three separate fields will fail at view load: `Element '<xpath expr="//field[@name='amount_total']">' cannot be located in parent view`. To gate or modify totals, target the `tax_totals` widget (one xpath hides the whole totals block). Other views in the file (kanban, list, pivot) DO still have the individual fields — only the FORM view consolidated to the widget. Same likely applies to `purchase.purchase_order_form` and `account.view_move_form` — verify per-view before porting Odoo 17/18 xpaths. Caught 2026-05-24.
|
||||||
|
13h. **`user_has_groups('xmlid')` is NOT available inside Odoo 19's `invisible=`/`readonly=`/`required=` attribute expressions**. The view validator parses `user_has_groups` as a field name on the host model and fails: `field 'user_has_groups' does not exist in model 'X'`. Group-based UI gating must use the `groups=` attribute on the element instead. To combine group-AND-state logic, EITHER split into two elements with mutually-exclusive `invisible` AND different `groups=`, OR enforce one half at the model layer (ir.rule / @api.constrains) and the other in the view. Caught 2026-05-24 when a single button used `invisible="state != 'draft' or (cert_type == 'nadcap' and not user_has_groups(...))"` — rewrote as a single button with `groups="group_fp_manager"` + `invisible="state != 'draft'"` and let the ir.rule enforce the Nadcap-write restriction (Manager clicking Issue on a Nadcap cert now raises AccessError).
|
||||||
|
13f. **Odoo 19 view validator rejects `ref('xmlid')` inside `<field domain="...">`**: the validator parses `ref(...)` as a field-access on the host model and fails with `field 'ref' does not exist in model 'X'`. Even though `ref()` IS resolved at runtime by the client, validation fires first and aborts module load. Workarounds (pick one):
|
||||||
|
- **Drop the domain** and enforce eligibility via `@api.constrains` on the Python side (simplest — used for `res.company.x_fc_cgp_designated_official_id` in this project; the Owner makes a deliberate choice and Python validates at save time).
|
||||||
|
- **Pre-compute eligible IDs** in a stored `Many2many` compute on the host model, then `domain="[('id', 'in', eligible_ids_field)]"`.
|
||||||
|
- Move the domain into the field definition in Python (`fields.Many2one(..., domain="[...]")`) — but Python-side domains have the same `ref()` limitation, so this isn't always an escape.
|
||||||
|
Caught 2026-05-24 deploying permissions-overhaul to entech.
|
||||||
|
13e. **`res_groups_name_uniq` constraint is `(privilege_id, name)` — cross-module display-name collisions during `-u` need a `pre-migrate.py` rename**. If a base module's new XML defines a group with the same display name as a DOWNSTREAM module's existing group (e.g. core adds new `Shop Manager (v2)` while configurator already has old `Shop Manager`), the new INSERT collides with the still-named-the-same downstream row, because Odoo loads modules in dep order and the downstream rename via XML hasn't happened yet. The fix is a `migrations/<version>/pre-migrate.py` in the BASE module that SQL-renames the downstream row before the new XML loads:
|
||||||
|
```python
|
||||||
|
def migrate(cr, version):
|
||||||
|
cr.execute(\"\"\"
|
||||||
|
UPDATE res_groups
|
||||||
|
SET name = jsonb_build_object('en_US', '[DEPRECATED] Shop Manager (...)')
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT res_id FROM ir_model_data
|
||||||
|
WHERE module = 'fusion_plating_configurator'
|
||||||
|
AND name = 'group_fp_shop_manager'
|
||||||
|
AND model = 'res.groups'
|
||||||
|
)
|
||||||
|
AND (name IS NULL OR name->>'en_US' NOT LIKE '[DEPRECATED]%');
|
||||||
|
\"\"\")
|
||||||
|
```
|
||||||
|
Pre-migrate scripts run BEFORE the module's data files reload, so the constraint is clear by the time the new group XML INSERTs. Caught 2026-05-24 during permissions-overhaul deploy — `fp_security_v2.xml` claimed `'Shop Manager'` while old configurator's `group_fp_shop_manager` still held that display name in the DB. Same pattern applies to ANY base-module XML adding groups with names that overlap downstream-module groups.
|
||||||
|
13a. **Cross-module xmlid refs — base modules CANNOT forward-ref downstream xmlids**: A BASE module's data XML cannot `ref('downstream_module.some_xmlid')` because at fresh install, the base module loads FIRST and `ir.model.data` has no row for the downstream xmlid yet → `ValueError: External ID not found`. This bites on entech (existing DB has the row) but breaks fresh CI/test/demo/new-client installs. **Fix pattern: relocate the cross-module link to the downstream module's own security/data file, using an additive write to the BASE module's record:**
|
||||||
|
```xml
|
||||||
|
<!-- In downstream module's security XML -->
|
||||||
|
<record id="fusion_plating.group_fp_sales_rep" model="res.groups">
|
||||||
|
<field name="implied_ids" eval="[(4, ref('fusion_plating_configurator.group_fp_estimator'))]"/>
|
||||||
|
</record>
|
||||||
|
```
|
||||||
|
Odoo's XML loader treats `id="other_module.xmlid"` as an additive update to the existing record, and `(4, ref(...))` (Command.link) stacks idempotently across install/-u cycles. Use this whenever a base module group/record needs to imply or reference something defined in a downstream module. Caught 2026-05-24 when `fusion_plating/security/fp_security_v2.xml` referenced groups from configurator/receiving/invoicing/cgp — worked on entech, would have broken fresh installs.
|
||||||
14a. **FP report palette + border rendering**: `fusion_plating_reports/report/report_base_styles.xml` uses **`#c1c1c1`** for section-header backgrounds and **`#1d1f1e`** (th text on grey) / **`#4e4e4e`** (h2/h4 on white) — NOT `res.company.primary_color`. Per-customer request (2026-05-17) the FP reports stopped following the company brand colour so every shop gets the same neutral look. The `fp_primary` template variable is still computed in the styles block so per-report templates can opt back in if needed, but the default `.fp-report` / `.fp-landscape` rules use the hardcoded greys. **Don't "fix" this back to `fp_primary` without confirming.**
|
14a. **FP report palette + border rendering**: `fusion_plating_reports/report/report_base_styles.xml` uses **`#c1c1c1`** for section-header backgrounds and **`#1d1f1e`** (th text on grey) / **`#4e4e4e`** (h2/h4 on white) — NOT `res.company.primary_color`. Per-customer request (2026-05-17) the FP reports stopped following the company brand colour so every shop gets the same neutral look. The `fp_primary` template variable is still computed in the styles block so per-report templates can opt back in if needed, but the default `.fp-report` / `.fp-landscape` rules use the hardcoded greys. **Don't "fix" this back to `fp_primary` without confirming.**
|
||||||
|
|
||||||
**Border-rendering gotcha** (entech wkhtmltopdf): with the standard `border-collapse: collapse` + `border: 1px solid #000` pattern, vertical borders can render slightly softer than horizontal borders because of how wkhtmltopdf rounds sub-pixels in its collapse-adjudication. Cells with a `background-color` also paint over the border edge unless clipped. Mitigations in place:
|
**Border-rendering gotcha** (entech wkhtmltopdf): with the standard `border-collapse: collapse` + `border: 1px solid #000` pattern, vertical borders can render slightly softer than horizontal borders because of how wkhtmltopdf rounds sub-pixels in its collapse-adjudication. Cells with a `background-color` also paint over the border edge unless clipped. Mitigations in place:
|
||||||
@@ -228,6 +393,20 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
|
|||||||
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
|
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
|
||||||
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
|
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
|
||||||
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
|
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
|
||||||
|
20. **OWL templates expose `Math` but NOT `String` / `Number` / `Array` / `Object` / `Boolean` / `JSON` / `parseInt` / `parseFloat`**: writing `t-on-click="() => this._press(String(d))"` (or similar coercion inside any template expression) throws `Uncaught TypeError: v2 is not a function` at click time — `v2` is OWL's compiled reference to a global that doesn't exist in template scope. The click handler dies before its body runs, so the bug looks like "nothing happens when I press" (no error in the UI, only DevTools shows the trace). **Fixes, in order of preference**: (a) eliminate the coercion entirely — store data in the right type up front, e.g. `t-foreach="['1','2','3']"` instead of `[1,2,3]` so `d` is already a string. (b) Use a JS-side coercion: pass the raw value to the handler and call `String(digit)` inside the component method. (c) Use a pure-expression workaround like string concatenation: `'' + d` does work because `+` is an operator, not a function. **Do NOT try to monkey-patch `String` onto the component (e.g. `this.String = String`) or onto `env` — leaks the global into every component and is fragile across OWL upgrades.** Bit us 2026-05-23 on `pin_pad.xml` — operators couldn't tap PIN digits at all because the click handler died on `String(d)`; the SCSS, reactivity, and `_press` method were all fine, the template scope was the entire bug. Same trap applies to OWL templates anywhere in the codebase: `move_parts_dialog.xml`, `manager_dashboard.xml`, `fp_record_inputs_dialog.xml`, etc. — grep all `t-on-click`, `t-att-*`, and `t-out` expressions for `String(`, `Number(`, `Array(`, `parseInt(`, `parseFloat(`, `JSON.` before merging.
|
||||||
|
21. **`ir.actions.act_window_close` is a no-op when the current action was opened with `target: "current"`**: replacing the current action wipes the breadcrumb backstack, so there's nothing to close back to. The user clicks "Back" and nothing happens (no error, no navigation). This bites every OWL client-action surface that calls another client action via `doAction({..., target: "current"})` — the destination has no way to return to the source. **Fix pattern for "Back" buttons in OWL client actions**: navigate EXPLICITLY to the landing/parent action by tag, e.g. `this.action.doAction({ type: "ir.actions.client", tag: "fp_plant_kanban", target: "current" })` — works regardless of how the action was reached (kanban tap, QR scan, smart button, direct URL). **Do NOT rely on `act_window_close`, `history.back()`, or `this.env.config.breadcrumbs`** — all three are unreliable across navigation paths. Bit us 2026-05-23 on the Job Workspace Back button after the kanban opened the workspace with `target: "current"`. The same pattern applies to every other "Back" button in shopfloor / manager / portal OWL surfaces — explicit destination via `tag:` is the only robust answer.
|
||||||
|
22. **Odoo 19 HTML fields auto-wrap plain-string writes**: writing `co.report_header = 'Plating & Finishing'` to an HTML field (like `res.company.report_header`, `res.partner.comment`, `mail.template.body_html`, `product.template.description_sale`) stores `<p>Plating & Finishing</p>` after Odoo's HTML sanitizer runs. Equality tests against the raw input string FAIL (`payload['tagline'] != 'Plating & Finishing'`). **Three implications**: (a) **In tests**, don't `assertEqual` against the literal string you wrote — strip tags first, OR write the wrapped form (`<p>Plating & Finishing</p>`), OR write an explicit `Markup('<p>...</p>')` so the round-trip stays stable. (b) **In display code**, render HTML fields with `t-out` (QWeb) or `markup(...)` (OWL) — `t-esc` would render the literal `<p>` tags as text. (c) **In comparison logic**, normalize first: `from markupsafe import escape; escape(input_str)` produces the same shape the field stores. Bit us 2026-05-24 testing the lock-screen tagline source (`_lock_company_payload` reads `res.company.report_header`); the test that wrote a plain string and asserted equality failed because the value came back wrapped. The fix was to delete the brittle equality test — the helper's responsibility is just "use the field's value when present, else fall back," which is covered by the empty-field test. Generalizes to ANY HTML-typed Odoo field. Distinct from the `mail.template.body_html is Markup + jsonb` gotcha noted earlier in this file — that's about Markup objects vs strings; this is about the sanitizer wrapping plain strings on write.
|
||||||
|
23. **`res.users.group_ids` vs `all_group_ids` for domain filters**: in Odoo 19, `res.users` carries TWO M2M-to-`res.groups` fields and they have different membership semantics. `group_ids` is the user's DIRECTLY-assigned groups (what the user record literally wrote). `all_group_ids` is the TRANSITIVE set — direct groups PLUS every group implied via `implied_ids` chains. **For domain filters on user pickers** (e.g. "show users who can act as a Quality Manager"), ALWAYS use `all_group_ids`, never `group_ids`. An Owner user only carries `group_fp_owner` directly; the QM capability comes via `implied_ids → group_fp_quality_manager`, so a `domain="[('group_ids', 'in', [ref('...quality_manager')])]"` excludes Owners and the picker looks empty. Use `domain="[('all_group_ids', 'in', [ref('...quality_manager'), ref('...owner')])]"` instead. Compute helpers (`@api.depends('group_ids')`) and write vals (`{'group_ids': [(4, gid)]}`) still use `group_ids` because those operate on direct assignments — only domain filters need the transitive set. Bit us 2026-05-24 on the CGP DO + Nadcap Authority pickers on `res.company`. Same gotcha applies to ANY domain that needs "does this user effectively have role X" semantics across user-facing pickers, ACL rules, server actions, and search filters.
|
||||||
|
24. **`env.get('model.name')` returns an EMPTY recordset (falsy), NOT None — never use it as a presence check**: `self.env.get('fp.notification.template')` returns `fp.notification.template()` (empty recordset) when the model IS registered. Empty recordsets are falsy in Python, so `if not Template: return` silently exits even when the model exists and the call should proceed. Same gotcha for `env.get('any.model')` — they all return empty recordsets. **Fix: use the membership check first, then index:**
|
||||||
|
```python
|
||||||
|
if 'fp.notification.template' not in self.env:
|
||||||
|
return # model not installed
|
||||||
|
Template = self.env['fp.notification.template']
|
||||||
|
# now Template is the model class; use it
|
||||||
|
```
|
||||||
|
The `Template.sudo()._some_classmethod()` call works on the empty recordset because `@api.model` methods run on the class. The breakage is purely the truthy-check. Bit us 2026-05-25 deploying `_fp_schedule_cert_activity` — the helper hit `env.get(...)` and immediately returned without ever attempting `activity_schedule`, so the QM never got their Issue-CoC activity. Took a monkey-patch trace through the helper to surface, because the function was silently no-oping with no exception. Same pattern likely scattered in any code that gates on `if env.get(...): ...` — grep for it.
|
||||||
|
|
||||||
|
25. **`mail.template` data files validate templates at PARSE time — only reference CORE-module fields on the target model**: when Odoo loads a `<record model="mail.template">` from XML, it eagerly RENDERS the `subject`/`body_html` once against a sample `object` to validate the inline_template renders cleanly. If the template references a field defined in a DOWNSTREAM module (one that loads AFTER the data-file's home module), the field isn't on the model yet and you get `AttributeError: 'fp.job' object has no attribute 'X'` → `ParseError: Failed to render inline_template template` → module install/upgrade ABORTS. Bit us 2026-05-25 deploying the cert authority templates: `fusion_plating_notifications` loads BEFORE `fusion_plating_jobs` in dep order, and the templates referenced `object.display_wo_name` and `object.part_catalog_id` (both added by `fusion_plating_jobs` via `_inherit`). Even though the columns exist in the DB from previous installs, the Python class hadn't registered the field yet at parse time. **Fix:** mail.template files in upstream modules must only reference fields defined in the SAME module's classes or earlier-loading deps. For `fp.job` references in `fusion_plating_notifications/data/`, that means CORE-only fields: `name`, `partner_id`, `qty_done`, `recipe_id`, `state`, `date_*`, `company_id` — NOT `display_wo_name`, `part_catalog_id`, `customer_spec_id`, `delivery_id`, `portal_job_id` (all jobs-module fields). Same trap for any other cross-module template (`account.move`, `sale.order`, `stock.picking`). **Two structural alternatives** if you really need downstream fields: (a) move the mail.template + fp.notification.template data records into the downstream module so they load after the field is registered (cleanest); (b) compute the value in the calling Python code and pass via `email_values` to the dispatch — no template-time rendering.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||||
@@ -372,7 +551,7 @@ Spec: [docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md](do
|
|||||||
Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md)
|
Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md)
|
||||||
|
|
||||||
**Three OWL client actions** (registered under `registry.category("actions")`):
|
**Three OWL client actions** (registered under `registry.category("actions")`):
|
||||||
- `fp_shopfloor_landing` — Workstation kanban entry. Station-scoped or All-Plant mode toggle. Tap a card → JobWorkspace. Replaces the legacy `fp_shopfloor_tablet` and folds in `fp_plant_overview`.
|
- `fp_plant_kanban` — sole Shop Floor surface as of 2026-05-25. One card per `fp.job` grouped into 9 fixed columns. Inline QR scanner (camera + wedge text drawer) + station pairing via `/fp/landing/pair_work_centre`. Tap a card → JobWorkspace. (The legacy `fp_shopfloor_landing` component was deleted entirely on 2026-05-25 — its inline QR feature was ported here. The earlier `fp_shopfloor_tablet` and `fp_plant_overview` xmlids still exist but their `tag` re-points at `fp_plant_kanban` for bookmark back-compat.)
|
||||||
- `fp_job_workspace` — Full-screen single-WO surface. Sticky header (WO #, customer, qty, workflow chip), sticky 9-stage workflow bar, step list with GateViz blockers, side panel (spec/attachments/chatter), sticky action rail (Hold/Note/Milestone). Opens from kanban tap, smart button, QR scan, or manager card tap.
|
- `fp_job_workspace` — Full-screen single-WO surface. Sticky header (WO #, customer, qty, workflow chip), sticky 9-stage workflow bar, step list with GateViz blockers, side panel (spec/attachments/chatter), sticky action rail (Hold/Note/Milestone). Opens from kanban tap, smart button, QR scan, or manager card tap.
|
||||||
- `fp_manager_dashboard` — Manager Desk with 4 sibling tabs: **Workflow Funnel** (default), **Approval Inbox**, **Plant Board** (existing 3-column), **At-Risk** (trending late + hold reasons + bottleneck heatmap).
|
- `fp_manager_dashboard` — Manager Desk with 4 sibling tabs: **Workflow Funnel** (default), **Approval Inbox**, **Plant Board** (existing 3-column), **At-Risk** (trending late + hold reasons + bottleneck heatmap).
|
||||||
|
|
||||||
@@ -410,7 +589,12 @@ Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs
|
|||||||
**Deprecated but still live** (cleanup is Phase 5):
|
**Deprecated but still live** (cleanup is Phase 5):
|
||||||
- OWL components: `fp_shopfloor_tablet`, `fp_plant_overview` — registered but no menu points at them
|
- OWL components: `fp_shopfloor_tablet`, `fp_plant_overview` — registered but no menu points at them
|
||||||
- Endpoints: `/fp/shopfloor/tablet_overview`, `plant_overview`, `queue` — marked DEPRECATED with INFO log lines, bodies intact for back-compat
|
- Endpoints: `/fp/shopfloor/tablet_overview`, `plant_overview`, `queue` — marked DEPRECATED with INFO log lines, bodies intact for back-compat
|
||||||
- `/fp/shopfloor/plant_overview/move_card` is **NOT** deprecated — the new Landing component uses it for drag-and-drop
|
- `/fp/shopfloor/plant_overview/move_card` is **NOT** deprecated — the new plant kanban uses it for drag-and-drop
|
||||||
|
|
||||||
|
**Retired entirely 2026-05-25** (do NOT re-introduce):
|
||||||
|
- OWL component `fp_shopfloor_landing` + its JS / XML / SCSS files — deleted. The inline QR scanner (text/wedge drawer + camera component) was ported into `plant_kanban`. The landing resolver always returns `action_fp_plant_kanban` for technicians + shop managers regardless of the orphaned `fusion_plating_shopfloor.layout` ir.config_parameter.
|
||||||
|
- The `/fp/landing/kanban` endpoint is no longer used by any live client (was only consumed by `fp_shopfloor_landing`). The new endpoint is `/fp/landing/plant_kanban`. Don't accidentally bind a new client to the old one.
|
||||||
|
- Station pairing via `localStorage[fp_landing_station_id]` is gone — pairing now writes `res.users.paired_work_centre_ids` server-side via the new `/fp/landing/pair_work_centre` endpoint, and the kanban reads it back via `request.env.user.paired_work_centre_ids[:1]`. Per-tablet localStorage pairing won't survive a browser cache wipe; per-user server-side pairing does.
|
||||||
|
|
||||||
**Old patterns to avoid:**
|
**Old patterns to avoid:**
|
||||||
- Don't read `fp.job.name` for display — use `display_wo_name` everywhere on tablet/dashboard
|
- Don't read `fp.job.name` for display — use `display_wo_name` everywhere on tablet/dashboard
|
||||||
@@ -418,6 +602,143 @@ Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs
|
|||||||
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
|
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
|
||||||
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
|
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
|
||||||
|
|
||||||
|
## Shop Floor — Plant View kanban (2026-05-23 redesign)
|
||||||
|
|
||||||
|
**Sole Shop Floor surface** for every install as of 2026-05-25. The
|
||||||
|
legacy per-step kanban (`fp_shopfloor_landing`) was deleted the same
|
||||||
|
day, after porting its inline QR scanner into plant_kanban. The
|
||||||
|
`ir.config_parameter['fusion_plating_shopfloor.layout']` flag is now
|
||||||
|
orphaned — flipping it has no effect on the landing surface. The
|
||||||
|
setting UI stays for one release cycle so it can be ripped out in a
|
||||||
|
separate sweep without breaking migrations.
|
||||||
|
|
||||||
|
**Why redesign:** the per-step kanban produced one card per recipe step
|
||||||
|
per column, so a 14-step recipe spawned 9+ cards for ONE job across the
|
||||||
|
board. With 17 active jobs the board showed 100+ duplicate cards across
|
||||||
|
narrow columns. The new design is **one card per `fp.job`** at the
|
||||||
|
**department level** — recipe step count no longer drives layout width.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md`
|
||||||
|
**Plan:** `docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md`
|
||||||
|
|
||||||
|
### Layout — 9 fixed columns in process sequence
|
||||||
|
|
||||||
|
`Receiving → Masking → Blasting → Racking → Plating → Baking →
|
||||||
|
De-Racking → Final inspection → Shipping`
|
||||||
|
|
||||||
|
Columns are first-class — they always render in this exact order, never
|
||||||
|
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
|
||||||
|
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
|
||||||
|
(stored) from `work_centre.area_kind` with a fallback to a step-kind
|
||||||
|
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
|
||||||
|
|
||||||
|
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
|
||||||
|
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
|
||||||
|
Drying) roll up into the **Plating** column. The tank chip on the card
|
||||||
|
distinguishes them.
|
||||||
|
|
||||||
|
**Spec D4:** De-Masking folds into De-Racking (no separate column).
|
||||||
|
|
||||||
|
**Spec D5:** Contract Review (paperwork) cards live in Receiving with a
|
||||||
|
purple "📋 QA-005" chip — they're admin gates, not physical work.
|
||||||
|
|
||||||
|
### Card state catalog — 13 mutually-exclusive states
|
||||||
|
|
||||||
|
`fp.job.card_state` is a stored Char computed in `_compute_card_state`
|
||||||
|
(see `fusion_plating_jobs/models/fp_job.py`). Explicit precedence
|
||||||
|
dispatch matching spec §6.2 — first match wins:
|
||||||
|
|
||||||
|
`no_parts → on_hold → awaiting_signoff → awaiting_qc → bake_due →
|
||||||
|
predecessor_locked → idle_warning → done → contract_review →
|
||||||
|
running_mine/running → ready_mine/ready`
|
||||||
|
|
||||||
|
Each state has a distinct background tint + left-border color + chip +
|
||||||
|
mini-timeline marker color. See `_plant_card.scss` for the mapping. The
|
||||||
|
"mine" variants (`ready_mine`, `running_mine`) light up only when the
|
||||||
|
active step's work centre is in `res.users.paired_work_centre_ids` (the
|
||||||
|
M2M holds one row in MVP, mirrors the existing single-station picker).
|
||||||
|
|
||||||
|
### Backend — single endpoint, denormalized payload
|
||||||
|
|
||||||
|
`/fp/landing/plant_kanban` (controller in
|
||||||
|
`fusion_plating_shopfloor/controllers/plant_kanban.py`) returns
|
||||||
|
`{ok, mode, paired_station, kpis, columns, cards}` in one JSONRPC call.
|
||||||
|
Frontend has zero per-card RPCs — every card field comes pre-formatted
|
||||||
|
from the controller's `_render_card`. State-chip text (with elapsed
|
||||||
|
times, operator names, hours-idle) is interpolated server-side.
|
||||||
|
|
||||||
|
### Frontend — OWL component tree
|
||||||
|
|
||||||
|
```
|
||||||
|
FpPlantKanban (client action 'fp_plant_kanban')
|
||||||
|
└── FpTabletLock (PIN gate wrapper)
|
||||||
|
├── PlantHeader (KPIs + filter chips + mode toggle + station picker)
|
||||||
|
└── Board (9 × Column)
|
||||||
|
├── FpColumnHeader (with 'You're here' badge for paired column)
|
||||||
|
└── FpPlantCard[] (each with FpMiniTimeline)
|
||||||
|
```
|
||||||
|
|
||||||
|
Polls every 10s. Filter state persists in localStorage. All 13 card
|
||||||
|
states styled via `.state-<name>` CSS modifier classes on a single
|
||||||
|
shared `.o_fp_plant_card` base. The mini-timeline renders 9 colored
|
||||||
|
dots driven by `fp.job.mini_timeline_json` (Python emits the array
|
||||||
|
shape — frontend just maps state → CSS class).
|
||||||
|
|
||||||
|
### Critical implementation gotchas (project rules applied)
|
||||||
|
|
||||||
|
- **OWL templates only expose `Math` as a JS global** (Rule 20). All
|
||||||
|
coercion (String, Number, parseInt) MUST happen in JS — `tag_chip_class()`
|
||||||
|
/ `progress_style` etc. live in plant_card.js, not in the XML.
|
||||||
|
- **SCSS @import is forbidden** (Rule 8). `_plant_tokens.scss` loads
|
||||||
|
FIRST in the manifest's `web.assets_backend`; subsequent component
|
||||||
|
partials get the `$plant-*` vars via the concatenated bundle.
|
||||||
|
- **Dark mode** via `$o-webclient-color-scheme == dark` compile-time
|
||||||
|
branch in `_plant_tokens.scss` (NOT runtime class selectors).
|
||||||
|
|
||||||
|
### How to switch back to legacy
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE ir_config_parameter SET value = 'legacy'
|
||||||
|
WHERE key = 'fusion_plating_shopfloor.layout';
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Settings → Fusion Plating → Shop Floor Layout. Both surfaces
|
||||||
|
write the same `ir.config_parameter` key.
|
||||||
|
|
||||||
|
### Legacy-action redirect (general rule for OWL component swaps)
|
||||||
|
|
||||||
|
When replacing an OWL client-action component with a new one, **don't
|
||||||
|
just register the new action's XMLID**. There are usually 2-5 legacy
|
||||||
|
`ir.actions.client` data records scattered across the module pointing
|
||||||
|
at the old tag (`action_fp_plant_overview`, `action_fp_shopfloor_tablet`,
|
||||||
|
etc. — every "old bookmarks keep working" record). The landing-action
|
||||||
|
resolver only sees one entry point. Bookmarks, breadcrumbs, QR-scan
|
||||||
|
landings, and "Plant Overview" / "Tablet Station" menu items go
|
||||||
|
through the OTHER actions and load the old component.
|
||||||
|
|
||||||
|
**Fix: change every legacy data record's `tag` to the new tag.** Grep
|
||||||
|
the views/ and data/ dirs for the old tag, and update each `<field
|
||||||
|
name="tag">` to the new one. The old OWL component stays registered
|
||||||
|
(no code removed), but no `ir.actions.client` row points at it
|
||||||
|
anymore. Caught 2026-05-23 when the plant-view rollout dispatched
|
||||||
|
the resolver correctly but a user clicking via the legacy "Shop Floor"
|
||||||
|
menu still saw the per-step kanban — `action_fp_shopfloor_tablet`
|
||||||
|
and `action_fp_plant_overview` were both still hard-coded to
|
||||||
|
`fp_shopfloor_landing` tag.
|
||||||
|
|
||||||
|
**Also grep JS for hardcoded `doAction({tag: ...})` calls** — XML
|
||||||
|
data records are only half the story. OWL components that wire up
|
||||||
|
"Back" buttons / navigation often hardcode the destination tag in
|
||||||
|
JS (e.g. `this.action.doAction({type: "ir.actions.client", tag:
|
||||||
|
"fp_shopfloor_landing", target: "current"})`). These bypass the
|
||||||
|
data layer entirely, so the redirect trick above doesn't cover
|
||||||
|
them. Caught 2026-05-24 — the Job Workspace `onBack()` still
|
||||||
|
pointed at `fp_shopfloor_landing`, so tapping a card in the new
|
||||||
|
plant kanban → opening the workspace → clicking Back dropped the
|
||||||
|
user into the deprecated per-step kanban. Fix: `grep -rn
|
||||||
|
'tag: ["\x27]<old_tag>' static/src/js/` before considering the
|
||||||
|
swap complete; rewrite every match to point at the new tag.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### odoo-entech (LXC 111 on pve-worker5)
|
### odoo-entech (LXC 111 on pve-worker5)
|
||||||
@@ -1132,6 +1453,9 @@ Each script is self-contained — builds a fresh SO + job, walks the scenario, a
|
|||||||
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
|
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
|
||||||
| **S20** | Tablet Station UX hardening — three real-world UX gaps surfaced during a persona walk on the Tablet + Manager Desk client actions | (a) **Scrap reason dropped**: `/fp/shopfloor/bump_qty_scrapped` accepted operator's typed reason via `window.prompt`, passed it through context as `fp_scrap_reason` — but `fp.job.write` never read it, so the auto-spawned Hold's description had the generic "OPERATOR: replace this text with the actual reason" placeholder instead of what Carlos typed. Audit trail lost what just happened on the floor. (b) **KPI/panel mismatch**: tablet KPI strip showed plant-wide totals ("Quality Holds: 12") but the Holds panel below was scoped to the operator's own jobs (might show 0). Operator stares at a big red 12, scrolls down, sees nothing — confused/distrustful. (c) **UserError stack-trace leak**: when `start_wo` hit an S14 predecessor lock (or any other `button_start`-side guard), the raw `UserError` propagated through the JSON-RPC handler and operator got a Python stack-trace dialog instead of the nice `setMessage("...", "danger")` flash. Same hole on `stop_wo`, `start_bake`, `end_bake`, `mark_gate`, `bump_qty_done`, `bump_qty_scrapped`. | (a) `fp.job.write` now reads `self.env.context.get('fp_scrap_reason')` and prepends `Operator reason: <text>` to the Hold description so the audit row captures what the operator actually typed. (b) Tablet KPI strip now reuses `my_job_ids_for_kpi` (the operator's own steps) for `awaiting_bakes`, `bake_in_progress`, `missed`, `open_holds` — same scope as the panels below, so the strip never lies. Manager dashboard keeps its own plant-wide KPI set. (c) Wrapped every action endpoint in `try: ... except UserError as e: return {'ok': False, 'error': str(e.args[0])}` — operator now gets the clean `setMessage` flash with the real guard text ("Step 'X' requires predecessors done first…") instead of a stack-trace popup. | `fusion_plating_jobs 19.0.6.22.0`, `fusion_plating_shopfloor 19.0.24.4.0` | persona walk via `sim_tablet_actions.py` + `sim_reverify.py` (asserts: typed reason ends up in hold.description, KPI=panel for holds, `start_wo` returns `{ok:False, error:"..."}` for locked step) |
|
| **S20** | Tablet Station UX hardening — three real-world UX gaps surfaced during a persona walk on the Tablet + Manager Desk client actions | (a) **Scrap reason dropped**: `/fp/shopfloor/bump_qty_scrapped` accepted operator's typed reason via `window.prompt`, passed it through context as `fp_scrap_reason` — but `fp.job.write` never read it, so the auto-spawned Hold's description had the generic "OPERATOR: replace this text with the actual reason" placeholder instead of what Carlos typed. Audit trail lost what just happened on the floor. (b) **KPI/panel mismatch**: tablet KPI strip showed plant-wide totals ("Quality Holds: 12") but the Holds panel below was scoped to the operator's own jobs (might show 0). Operator stares at a big red 12, scrolls down, sees nothing — confused/distrustful. (c) **UserError stack-trace leak**: when `start_wo` hit an S14 predecessor lock (or any other `button_start`-side guard), the raw `UserError` propagated through the JSON-RPC handler and operator got a Python stack-trace dialog instead of the nice `setMessage("...", "danger")` flash. Same hole on `stop_wo`, `start_bake`, `end_bake`, `mark_gate`, `bump_qty_done`, `bump_qty_scrapped`. | (a) `fp.job.write` now reads `self.env.context.get('fp_scrap_reason')` and prepends `Operator reason: <text>` to the Hold description so the audit row captures what the operator actually typed. (b) Tablet KPI strip now reuses `my_job_ids_for_kpi` (the operator's own steps) for `awaiting_bakes`, `bake_in_progress`, `missed`, `open_holds` — same scope as the panels below, so the strip never lies. Manager dashboard keeps its own plant-wide KPI set. (c) Wrapped every action endpoint in `try: ... except UserError as e: return {'ok': False, 'error': str(e.args[0])}` — operator now gets the clean `setMessage` flash with the real guard text ("Step 'X' requires predecessors done first…") instead of a stack-trace popup. | `fusion_plating_jobs 19.0.6.22.0`, `fusion_plating_shopfloor 19.0.24.4.0` | persona walk via `sim_tablet_actions.py` + `sim_reverify.py` (asserts: typed reason ends up in hold.description, KPI=panel for holds, `start_wo` returns `{ok:False, error:"..."}` for locked step) |
|
||||||
| **S20** | **Tablet usability pass** — operators were squinting at the tablet, scanning back-and-forth between recipe binders and the screen because the tablet showed step names but no targets, no live timer, no predecessor visibility. QC fail left parts in limbo with no Hold record. Manager Desk showed feel-good KPIs but hid the compliance bombs (missed bakes, stale steps, locked steps, holds, pending QC missing PDF) | Tablet `My Queue` rows had no `instructions`, `thickness_target`, `dwell_time_minutes`, `bake_setpoint_temp`, `requires_signoff` — operators kept scanning the QR code just to read the bake temperature. Steps with `requires_predecessor_done=True` (S14) showed a green Start that always failed with a UserError. Active step "duration" was a stale number that only refreshed every 30s. Holds and bake windows showed plant-wide noise from other crews. **No banner alerted Carlos when his job had a pending QC** (Lisa was not called → QC sat for hours). **No way to bump qty_done or scrap from the tablet** → S17 hold auto-spawn never fired because operators didn't update the field. **`action_fail` on QC marked the check failed but spawned no Hold** — AS9100 disposition trail broken. **Manager Desk KPIs were missing 7 compliance metrics**: stale paused/in-progress steps (cron data), missed bake windows, open holds, predecessor-locked steps, pending QCs, QCs missing Fischerscope PDF, draft cert pipeline | **Carlos's Shopfloor Tablet** — every queue row now carries the recipe-author fields (instructions snippet, thickness target chip, dwell-time chip, bake-temp chip, sign-off badge) so operators read the targets inline. Predecessor-blocked steps render with a 🔒 lock icon, an "Awaiting [step name]" notice, and a disabled `Locked` button (no more Start-then-fail). Active step now shows a **live ticking HH:MM:SS clock** (1s interval, computed from `date_started_iso` JS-side; flips to red on >1.5× planned duration) plus `+1 Done` and `Scrap` buttons that hit two new endpoints (`/fp/shopfloor/bump_qty_done`, `/fp/shopfloor/bump_qty_scrapped` — scrap prompts for reason and S17 auto-spawns the Hold). New **Pending QC banner** lists open QCs for my jobs with line-progress + Fischerscope-PDF status badge, and a tap deep-links into Lisa's mobile QC checklist. Holds and bake windows are now **scoped to my jobs first** (fall back to facility-wide for managers). **QC checklist** — `action_fail` now auto-creates a `fusion.plating.quality.hold` with `hold_reason='qc_failure'` (new selection value), populated description listing the failed checks, idempotent on retry. **Manager Desk** — 7 new clickable compliance KPI tiles: Missed Bakes (S15), Open Holds (S17 + QC fail), Stale Steps (S10/S16 cron data), Locked Steps (S14), Pending QC + "X need PDF" (S19 + missing-Fischerscope), Draft Certs + "Y today" (cert pipeline). Each tile drills into a list filtered to the relevant exception | `fusion_plating_shopfloor 19.0.24.3.0`, `fusion_plating_quality 19.0.4.8.0` | `sim_tablet_walk.py`, `sim_timer_pred_test.py`, `sim_qc_fail_hold.py`, `sim_manager_qc_fail.py` (one-off persona walkthroughs) |
|
| **S20** | **Tablet usability pass** — operators were squinting at the tablet, scanning back-and-forth between recipe binders and the screen because the tablet showed step names but no targets, no live timer, no predecessor visibility. QC fail left parts in limbo with no Hold record. Manager Desk showed feel-good KPIs but hid the compliance bombs (missed bakes, stale steps, locked steps, holds, pending QC missing PDF) | Tablet `My Queue` rows had no `instructions`, `thickness_target`, `dwell_time_minutes`, `bake_setpoint_temp`, `requires_signoff` — operators kept scanning the QR code just to read the bake temperature. Steps with `requires_predecessor_done=True` (S14) showed a green Start that always failed with a UserError. Active step "duration" was a stale number that only refreshed every 30s. Holds and bake windows showed plant-wide noise from other crews. **No banner alerted Carlos when his job had a pending QC** (Lisa was not called → QC sat for hours). **No way to bump qty_done or scrap from the tablet** → S17 hold auto-spawn never fired because operators didn't update the field. **`action_fail` on QC marked the check failed but spawned no Hold** — AS9100 disposition trail broken. **Manager Desk KPIs were missing 7 compliance metrics**: stale paused/in-progress steps (cron data), missed bake windows, open holds, predecessor-locked steps, pending QCs, QCs missing Fischerscope PDF, draft cert pipeline | **Carlos's Shopfloor Tablet** — every queue row now carries the recipe-author fields (instructions snippet, thickness target chip, dwell-time chip, bake-temp chip, sign-off badge) so operators read the targets inline. Predecessor-blocked steps render with a 🔒 lock icon, an "Awaiting [step name]" notice, and a disabled `Locked` button (no more Start-then-fail). Active step now shows a **live ticking HH:MM:SS clock** (1s interval, computed from `date_started_iso` JS-side; flips to red on >1.5× planned duration) plus `+1 Done` and `Scrap` buttons that hit two new endpoints (`/fp/shopfloor/bump_qty_done`, `/fp/shopfloor/bump_qty_scrapped` — scrap prompts for reason and S17 auto-spawns the Hold). New **Pending QC banner** lists open QCs for my jobs with line-progress + Fischerscope-PDF status badge, and a tap deep-links into Lisa's mobile QC checklist. Holds and bake windows are now **scoped to my jobs first** (fall back to facility-wide for managers). **QC checklist** — `action_fail` now auto-creates a `fusion.plating.quality.hold` with `hold_reason='qc_failure'` (new selection value), populated description listing the failed checks, idempotent on retry. **Manager Desk** — 7 new clickable compliance KPI tiles: Missed Bakes (S15), Open Holds (S17 + QC fail), Stale Steps (S10/S16 cron data), Locked Steps (S14), Pending QC + "X need PDF" (S19 + missing-Fischerscope), Draft Certs + "Y today" (cert pipeline). Each tile drills into a list filtered to the relevant exception | `fusion_plating_shopfloor 19.0.24.3.0`, `fusion_plating_quality 19.0.4.8.0` | `sim_tablet_walk.py`, `sim_timer_pred_test.py`, `sim_qc_fail_hold.py`, `sim_manager_qc_fail.py` (one-off persona walkthroughs) |
|
||||||
|
| **S21** | Riya finished steps on WO-30051 without filling in mandatory recipe-author prompts — Incoming Inspection skipped "Take and Upload Photos" (1/5 missed), Check Sulfamate Nickel Area skipped both masking-verification booleans (2/3 missed). AS9100 audit trail broken on a per-step basis. | (a) `_fp_has_uncaptured_step_inputs` returned False as soon as ANY move with input values existed since `date_started` — too coarse, let operators clear the dialog re-open by saving a single prompt. (b) `button_finish` had NO gate enforcing required step_input coverage — only Contract Review + Receiving gates fired. (c) OWL `Record Inputs` dialog `onSave()` had no client-side check for required prompts either, so operators got zero feedback when leaving fields blank. (d) Also caught: `fp_job_step.py` had **two `def button_finish` in the same class** — Python silently kept only the second definition, so the bake.window auto-spawn + duration-overrun warning at line 596 had been dead code for the entire WO-30051 era. | **Server gate** — new `_fp_check_step_inputs_complete()` on `fp.job.step` raises `UserError` listing every missing required step_input by name. Hooked into `button_finish` ahead of the existing Contract Review + Receiving gates. New helper `_fp_missing_required_step_inputs()` returns the recordset of required prompts with NO recorded value across any move from this step (centralised — used by both the gate and the dialog re-open helper). `_fp_has_uncaptured_step_inputs()` tightened to delegate to the new helper. **Client gate** — `onSave()` on `FpRecordInputsDialog` mirrors the server check when `advanceAfter=true` (Finish & Next path) so operators see a sticky red "Cannot finish step — N required prompts missing: ..." notification instantly rather than after a server roundtrip. Partial saves via the per-row Record button (`advanceAfter=false`) remain unblocked — operators can still capture progress and come back to fill the rest. **Manager bypass** — `fp_skip_required_inputs_gate=True` (documented deviations / paper-form catch-up); posts chatter audit naming the user. **Dead-code merge** — the duplicate `button_finish` at line 596 was deleted; its bake.window auto-spawn + duration-overrun chatter logic was folded into the canonical `button_finish` (which now runs in order: required-inputs gate → CR gate → receiving gate → `super()` → post-finish side effects). **Critical lesson — never put two `def <name>` in the same `models.Model` class body**. Python silently keeps the last one; the earlier definition becomes dead code with no warning. Always grep for duplicates after any structural edit on a long model file. | `fusion_plating_jobs 19.0.10.22.0` | Smoke: `step._fp_missing_required_step_inputs()` on any in_progress step returns the prompt recordset that would block finish. Server: try `step.button_finish()` on a step with required prompts unrecorded — should raise UserError listing them. Manager bypass: `step.with_context(fp_skip_required_inputs_gate=True).button_finish()` succeeds + posts audit. |
|
||||||
|
| **S22** | Deep-audit finding F1 (2026-05-23) — `fp.job.step.requires_signoff` was 100% unenforced on entech: 42 of 42 done steps with the field set had `signoff_user_id IS NULL`. Recipe authors believed they'd gated aerospace / Nadcap steps; reality was the field was decorative. Pre-Sub-11 the `mrp.workorder.x_fc_signoff_user_id` had working logic, but Sub 11's MRP cutout removed bridge_mrp without porting the gate. | `signoff_user_id` was defined `readonly=True` on `fp.job.step` (from `fusion_plating/models/fp_job_step.py`) but **no code anywhere wrote to it**. No autosign on finish, no UI button, no `action_signoff`. Deep audit caught this because the 42/42 = 100% NULL ratio is the dead giveaway — when a "required" field has zero non-NULL rows across 42 records, the field's enforcement code is missing entirely. | **Three-piece fix on `fp.job.step`**: (1) `_fp_autosign_if_required()` — auto-sets `signoff_user_id = env.user.id` for the user clicking Finish, idempotent (preserves a supervisor's pre-sign via `action_signoff`). (2) `_fp_check_signoff_complete()` — raises `UserError` when `requires_signoff=True` and `signoff_user_id` is still NULL after the autosign helper has run (i.e. migration scripts, background crons with no env.user). (3) `action_signoff()` — explicit sign-off action for the case where a supervisor reviews and signs BEFORE the operator clicks Finish. Same-user re-click is a no-op; a DIFFERENT user re-signing overwrites the prior signer and posts a chatter reassignment ("Sign-off on step X reassigned from A to B"). Both helpers hook into `button_finish` AFTER `_fp_check_step_inputs_complete` and BEFORE the Contract-Review gate. **Manager bypass** — `fp_skip_signoff_gate=True` (documented deviations); posts chatter naming the user. **Lesson — for ANY "required" Boolean field that gates downstream behaviour, ALWAYS deep-audit the enforcement path: search the codebase for writes to the gated field, not just the boolean.** If zero writes exist, the gate is structural / decorative only. Grep the codebase periodically for `_check_*` helpers whose triggering field has no inverse writer. | `fusion_plating_jobs 19.0.10.23.0` | Verified end-to-end on entech: autosign sets signoff_user_id, gate raises UserError with the right message, bypass posts chatter audit, action_signoff sets + posts chatter, and the S21 required-inputs gate still fires (no regression). |
|
||||||
|
| **S23 (shipped)** | Deep-audit bonus finding (2026-05-23) — `fp.job.step.requires_transition_form` had the same dormant-field shape as S22's signoff bug. The bypass context flag `fp_skip_transition_form` was already wired into the move controller's audit trail, but **no actual gate ever fired** because `_blockers_for_move` only enumerated `rack_required` + `predecessor_lock`. 0 of 286 moves on entech had this set (recipe authors hadn't enabled it), so no current audit gap — but the next recipe author who flips the toggle would discover the same cosmetic-only behaviour Riya found on S21. Caught preventively rather than reactively. NB: numbering conflicts with the open-scenarios list (also lists S23) — accept; the open list will be renumbered in a future doc-cleanup pass. | (a) `_blockers_for_move` in `fusion_plating_shopfloor/controllers/move_controller.py` had no `transition_form_required` case, only rack + predecessor. (b) The Move Rack controller `_do_move_rack_commit` didn't capture transition prompts at all — even if `requires_transition_form` were enforced on Move Parts, rack moves silently bypassed it. (c) The model layer `fp.job.step.move` had no helper to compute "missing required transition inputs", so any backend caller (wizards, scripts) had no way to enforce the contract. | **Model layer** — added two helpers to `fp.job.step.move` (canonical location): `_fp_missing_required_transition_inputs()` returns the recordset of required transition_input prompts on `to_step.recipe_node_id` that have no captured value on the move. `_fp_check_transition_inputs_complete()` raises `UserError` listing the missing prompts, manager bypass via `fp_skip_transition_form=True` (consistent with the existing audit-trail flag, NOT a new flag name), posts chatter on the move record on bypass. **Controller wiring** — `move_parts_commit` calls the gate AFTER `_capture_prompt_value` (so the operator gets credit for whatever they filled in; rollback unwinds the move + values on failure). `move_rack_commit` pre-rejects with a clear message ("use Move Parts so the form can be filled in") because rack moves have no per-batch prompt-capture UI. **Design choice** — gate is invoked explicitly by callers rather than via `create()` override; values are written in a separate call after the move row, so a model-level `create()` hook would always misfire. Future backend wizards / scripts MUST call `_fp_check_transition_inputs_complete()` after capturing prompt values, or pass `fp_skip_transition_form=True` if intentionally bypassing. **Two-layer pattern lesson** — when a recipe-author flag (here `requires_transition_form`) has BOTH a quick path (Move Rack — no form UI) AND a rich path (Move Parts — full form UI), the quick path MUST either implement the form OR reject the operation. A silent quick-path bypass defeats the whole gate. | `fusion_plating 19.0.20.9.0`, `fusion_plating_shopfloor 19.0.30.3.0` | Verified live on entech: helpers callable, move-parts commit raises on missing required prompts, move-rack commit rejects up-front when `to_step.requires_transition_form=True`, manager bypass via context flag posts move-chatter audit. |
|
||||||
|
|
||||||
### Manager-bypass context flags
|
### Manager-bypass context flags
|
||||||
|
|
||||||
@@ -1145,6 +1469,9 @@ When you need to override a guard (documented customer deviation, emergency rewo
|
|||||||
| `fp_skip_bake_gate=True` | bake.window pending check on `button_mark_done` (S15) |
|
| `fp_skip_bake_gate=True` | bake.window pending check on `button_mark_done` (S15) |
|
||||||
| `fp_skip_predecessor_check=True` | requires_predecessor_done check on `button_start` (S14) |
|
| `fp_skip_predecessor_check=True` | requires_predecessor_done check on `button_start` (S14) |
|
||||||
| `fp_skip_missed_window=True` | missed_window block on `bake.window.action_start_bake` (S6) |
|
| `fp_skip_missed_window=True` | missed_window block on `bake.window.action_start_bake` (S6) |
|
||||||
|
| `fp_skip_required_inputs_gate=True` | required step_input prompts check on `fp.job.step.button_finish` (S21). Posts chatter audit naming the user. |
|
||||||
|
| `fp_skip_signoff_gate=True` | `requires_signoff` + `signoff_user_id` check on `fp.job.step.button_finish` (S22). Posts chatter audit naming the user. Note: button_finish auto-sets signoff_user_id to the finisher first (via `_fp_autosign_if_required`); this bypass only matters when even the autosign can't fire (migration scripts, background crons with no env.user). |
|
||||||
|
| `fp_skip_transition_form=True` | `requires_transition_form` + required transition_input coverage check on `fp.job.step.move._fp_check_transition_inputs_complete` (S23). Also drops the existing rack-vs-transition-form pre-reject on `move_rack_commit`. Posts chatter audit on the move record. Manager-only — controller checks the `fusion_plating.group_fusion_plating_manager` membership before honoring the flag. |
|
||||||
|
|
||||||
### Daily / hourly crons added by battle tests
|
### Daily / hourly crons added by battle tests
|
||||||
|
|
||||||
@@ -1156,13 +1483,13 @@ When you need to override a guard (documented customer deviation, emergency rewo
|
|||||||
|
|
||||||
### Open scenarios — flagged for next session
|
### Open scenarios — flagged for next session
|
||||||
|
|
||||||
- **S21** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
|
- **S23** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step (renumbered from S22 when S22 was claimed for the signoff gate)
|
||||||
- **S22** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
|
- **S24** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
|
||||||
- **S23** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
|
- **S25** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
|
||||||
- **S24** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
|
- **S26** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
|
||||||
- **S25** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
|
- **S27** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
|
||||||
- **S26** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
|
- **S28** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
|
||||||
- **S27** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
|
- **S29** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict; renumbered from S21 → S28 → S29)
|
||||||
|
|
||||||
### Tablet UI / persona-coverage gaps (S20 audit follow-ups)
|
### Tablet UI / persona-coverage gaps (S20 audit follow-ups)
|
||||||
|
|
||||||
@@ -1398,6 +1725,8 @@ Customer feedback: "too many top-level menus" + "configuration is unorganized".
|
|||||||
- Settings → Fusion Plating → Plating Landing Page block (company default).
|
- Settings → Fusion Plating → Plating Landing Page block (company default).
|
||||||
- `fusion_plating_configurator`'s earlier menu_fp_root override (action_fp_sale_orders direct) was removed — core's resolver now owns the routing.
|
- `fusion_plating_configurator`'s earlier menu_fp_root override (action_fp_sale_orders direct) was removed — core's resolver now owns the routing.
|
||||||
- Pickable list is curated via inline `<field name="x_fc_pickable_landing" eval="True"/>` on action records — currently flagged: `action_fp_sale_orders`, `action_fp_quotations`, `action_fp_process_recipe`. Add more by tagging the relevant act_window record at its source.
|
- Pickable list is curated via inline `<field name="x_fc_pickable_landing" eval="True"/>` on action records — currently flagged: `action_fp_sale_orders`, `action_fp_quotations`, `action_fp_process_recipe`. Add more by tagging the relevant act_window record at its source.
|
||||||
|
- **`x_fc_pickable_landing` lives on `ir.actions.actions` (BASE)** so the picker dropdown on `res.users.x_fc_plating_landing_action_id` can offer BOTH act_window records (Sale Orders, Quotations, Process Recipes) AND client-action records (Manager Desk, Plant Kanban, Quality Dashboard). The picker Many2one points at `ir.actions.actions` (not `act_window`); the domain `[('x_fc_pickable_landing', '=', True)]` filters across all action types. `_render_resolved()` on the base dispatches to the correct subclass by `type`. **Pickable accessibility compute MUST be `sudo()`'d** — non-admin users (Technician, Sales Rep) lack read access on `ir.actions.actions` and opening their own Preferences dialog would AccessError otherwise; the per-user `check_access_rights` per-action still runs unprivileged so the picklist filters correctly. Tag a new landing candidate by adding `<field name="x_fc_pickable_landing" eval="True"/>` to its `<record>` definition — works regardless of whether the model is `ir.actions.act_window` or `ir.actions.client`.
|
||||||
|
- **Role-based dispatch** (Phase E): the resolver now reads `res.users` group membership and routes by precedence — Owner → Manager Desk; QM → Quality Dashboard; Manager → Manager Desk; Sales Manager → Sale Orders; Shop Manager → Plant Kanban/Workstation; Sales Rep → Quotations; Technician → Plant Kanban/Workstation. `_fp_workstation_action_for_layout()` reads `ir.config_parameter['fusion_plating_shopfloor.layout']` (v2 vs legacy) so flipping the flag retargets every Tech/Shop Manager on next page load. Per-user override still wins. Picklist domain is tightened via `res.users.accessible_landing_action_ids` (compute that runs `check_access_rights('read')` per pickable action) so a Tech can't pick "Manager Desk" they can't see.
|
||||||
|
|
||||||
### Phase 2 — Configuration sub-folder grouping (`fusion_plating` 19.0.11.1.0, commits `3641b78` + `62c1315` + `4671541`)
|
### Phase 2 — Configuration sub-folder grouping (`fusion_plating` 19.0.11.1.0, commits `3641b78` + `62c1315` + `4671541`)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Express Orders — Brainstorming Handoff (2026-05-25)
|
||||||
|
|
||||||
|
**Status:** Mid-brainstorming. Clarifying questions answered. NOT yet at "propose 2-3 architectural approaches" or "present design sections" stages.
|
||||||
|
|
||||||
|
**Why this handoff exists:** Previous session ran on a Windows machine (the user works from a Mac via Tailscale). Browser preview kept fighting cross-network localhost issues. User asked to restart natively on Mac. This doc preserves everything the brainstorming had reached so the new Mac session resumes instantly without re-asking.
|
||||||
|
|
||||||
|
**How to resume:** When the user opens Claude Code on their Mac and points it at this repo, kick off with:
|
||||||
|
|
||||||
|
> "Resume the Express Orders brainstorming from `docs/superpowers/handoffs/2026-05-25-express-orders-brainstorm-handoff.md`. Skip the visual companion entirely — design in text using the Excel mockup the user already sent. Pick up at 'propose 2-3 architectural approaches'."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What "Express Orders" is
|
||||||
|
|
||||||
|
A new sale-order entry surface that will eventually replace the current Direct Order Wizard. UX inspiration: a customer-shared Excel mockup showing a spreadsheet-style flat entry — header grid on top with customer/PO/job#/material-process/lead-time/terms/delivery, line table in the middle with per-row Part-#/Description/Specification/Job#/Thickness/Masking-checkbox/Baking-pill/Notes/Qty/UOM/Price/Subtotal + per-line Upload-Drawing + Open-Part buttons, footer with sub-total/tooling/tax/currency-selector/grand-total.
|
||||||
|
|
||||||
|
Goal: faster repeat-customer order entry. Type once on the line, no jumping to a separate part screen for routine work, every column inline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clarifying questions ANSWERED
|
||||||
|
|
||||||
|
| # | Question | Answer | Implication |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Model strategy | **D — new view on existing `fp.direct.order.wizard`** | Reuse the 500+ lines of onchange / recipe-cloning / spec-auto-fill / thickness-carry / tax-seeding logic that already debugged. Add masking/baking/currency fields to the existing model. Write a new "Express" form view. Retire the old direct-order view. Open drafts seamlessly become Express Orders (same DB rows). |
|
||||||
|
| 2 | Specification text storage | **Free-text on the part** — `default_specification_text` Text field on `fp.part.catalog` | Type once → saves to part. Next order for same part → auto-fills. Cell value writes to `sale.order.line.name` (customer-facing). Bypasses the structured `fusion.plating.customer.spec` model entirely — simpler. |
|
||||||
|
| 3 | Per-line Job# column (ABC/DEF/GHJ) | **NEW per-line customer sub-job-ref field** | Header keeps `x_fc_customer_job_number` (e.g. 12345). Add NEW `x_fc_customer_line_ref` Char on sale.order.line. Both print on customer docs. |
|
||||||
|
| 4 | Masking checkbox scope | **Both masking AND de-masking together** | Unchecking creates override(included=False) for every node where `default_kind` IN ('masking', 'de_masking'). Logical pairing — can't unmask what was never masked. |
|
||||||
|
| 5 | Baking field shape | **Free-text input + auto-fill from part default** | Add `default_bake_instructions` Text on `fp.part.catalog`. Type once → saves. Next order → auto-fills. Empty cell = exclude baking node (override included=False on all baking-kind nodes). Non-empty cell = include baking node + write the text to `fp.job.step.instructions` for the bake step at job-creation time. |
|
||||||
|
| 6 | Currency mechanic | **Pricelist-per-currency, labelled "Currency"** | Selector shows currencies the company has pricelists for. Picking USD → looks up company's USD pricelist → sets `sale.order.pricelist_id`. `currency_id` flows from there. Admin must configure one pricelist per currency. |
|
||||||
|
| 7-14 | 8 default interpretations | **All 8 accepted** | PO Pending = keep existing flag + chase mechanism; Material Process = new informational Char; Upload Part Drawing = per-line button to part.drawing_attachment_ids; Create Part = per-line modal opening fp.part.catalog form; Lead Time = reuse min/max days; Blanket SO = reuse boolean; Delivery Method = reuse x_fc_delivery_method; phase-out path = both menus visible initially, retire old view after Express is stable on entech. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exploration findings (already verified by reading source — these are GROUND TRUTH)
|
||||||
|
|
||||||
|
### Existing `fp.direct.order.wizard` model
|
||||||
|
- Persistent (not transient) — state machine `draft → confirmed → cancelled`
|
||||||
|
- File: `fusion_plating_configurator/wizard/fp_direct_order_wizard.py`
|
||||||
|
- Line model: `fp.direct.order.line` in `fusion_plating_configurator/wizard/fp_direct_order_line.py`
|
||||||
|
- Creates SO in quotation state on confirm. Does NOT auto-confirm SO or auto-email.
|
||||||
|
|
||||||
|
### Existing `sale.order.line` x_fc_* fields (verified — keep using these)
|
||||||
|
- `x_fc_part_catalog_id` Many2one(fp.part.catalog)
|
||||||
|
- `x_fc_internal_description` Text REQUIRED — Notes column maps here
|
||||||
|
- `x_fc_description_template_id` Many2one
|
||||||
|
- `x_fc_serial_ids` Many2many(fp.serial) + `x_fc_serial_id` (primary, computed)
|
||||||
|
- `x_fc_thickness_range` Char — Thickness column maps here
|
||||||
|
- `x_fc_revision_snapshot` Char (frozen at line save time)
|
||||||
|
- `x_fc_process_variant_id` Many2one(fusion.plating.process.node) — recipe
|
||||||
|
- `x_fc_save_as_default_process` Boolean
|
||||||
|
- `x_fc_job_number` Char — shop's auto-sequenced ref (NOT customer's; that's the new x_fc_customer_line_ref)
|
||||||
|
- `x_fc_customer_job_number` related from order
|
||||||
|
- `x_fc_po_number` related from order
|
||||||
|
- `x_fc_part_deadline` Date + offset_days + effective computes
|
||||||
|
- `x_fc_archived` Boolean
|
||||||
|
- `name` (Odoo standard) = customer-facing description — Specification column writes here
|
||||||
|
|
||||||
|
### NEW fields the Express Orders feature must add
|
||||||
|
On `sale.order.line`:
|
||||||
|
- `x_fc_customer_line_ref` Char — per-line customer sub-job (the ABC/DEF/GHJ column)
|
||||||
|
- `x_fc_masking_enabled` Boolean default=True — Masking checkbox
|
||||||
|
- `x_fc_bake_instructions` Text — Baking free-text
|
||||||
|
|
||||||
|
On `fp.part.catalog`:
|
||||||
|
- `default_specification_text` Text — for Spec auto-fill
|
||||||
|
- `default_bake_instructions` Text — for Baking auto-fill
|
||||||
|
|
||||||
|
On `fp.direct.order.wizard` (header):
|
||||||
|
- `material_process` Char — informational order-level tag (ENP-STEEL-HP-ADVANCED)
|
||||||
|
|
||||||
|
On `fp.direct.order.line` (wizard mirror, to be carried to SO line on confirm):
|
||||||
|
- `customer_line_ref` Char
|
||||||
|
- `masking_enabled` Boolean default=True
|
||||||
|
- `bake_instructions` Text
|
||||||
|
|
||||||
|
### Existing `fp.job.node.override` model (verified — schema is exactly 3 fields)
|
||||||
|
File: `fusion_plating_jobs/models/fp_job_node_override.py`
|
||||||
|
- `job_id` Many2one(fp.job) required ondelete=cascade
|
||||||
|
- `node_id` Many2one(fusion.plating.process.node)
|
||||||
|
- `included` Boolean
|
||||||
|
|
||||||
|
**No instructions-text override field.** For the bake free-text feature, do NOT extend this model — instead write the typed text to `fp.job.step.instructions` directly at job-creation time. Simpler, less schema churn.
|
||||||
|
|
||||||
|
### Existing recipe model (`fusion.plating.process.node`)
|
||||||
|
File: `fusion_plating/models/fp_process_node.py`
|
||||||
|
- `default_kind` is a Char (line 526), not Selection — flexible
|
||||||
|
- Values seen: `masking`, `de_masking`, `baking`, plating, inspection, contract_review, racking, etc.
|
||||||
|
- `node_type` is a Selection (line 54): `opt_in`, `opt_out`, `mandatory`, `recipe`
|
||||||
|
|
||||||
|
### Job-creation hook
|
||||||
|
File: `fusion_plating_jobs/models/sale_order.py` — `action_confirm()` calls `_fp_auto_create_job()` which groups lines by recipe. New Express-Orders-driven overrides should be injected here:
|
||||||
|
- Walk each SO line
|
||||||
|
- Resolve the line's recipe
|
||||||
|
- If `x_fc_masking_enabled == False`, create `fp.job.node.override(included=False)` for every node in recipe where `default_kind IN ('masking', 'de_masking')`
|
||||||
|
- If `x_fc_bake_instructions` empty, create `fp.job.node.override(included=False)` for every node in recipe where `default_kind == 'baking'`
|
||||||
|
- If `x_fc_bake_instructions` non-empty, write the text to `fp.job.step.instructions` for the baking step (find by recipe_node_id.default_kind == 'baking')
|
||||||
|
|
||||||
|
### Currency
|
||||||
|
- `sale.order.currency_id` related from `pricelist_id.currency_id` (Odoo native)
|
||||||
|
- No multi-currency customisations in fusion_plating modules — using Odoo standard
|
||||||
|
- Per the answer to Q6, the Express Orders feature adds a selector on the wizard that looks up the matching pricelist by currency code
|
||||||
|
|
||||||
|
### `fp.part.catalog` (file: `fusion_plating_configurator/models/fp_part_catalog.py`)
|
||||||
|
Verified key fields:
|
||||||
|
- `x_fc_default_customer_spec_id` (added by quality module) — NOT used by Express Orders per Q2
|
||||||
|
- `x_fc_default_thickness_range` Char
|
||||||
|
- `description_template_ids` O2M(fp.sale.description.template) with internal + customer descriptions
|
||||||
|
- `drawing_attachment_ids` M2M(ir.attachment) — Upload Part Drawing button writes here
|
||||||
|
- `model_attachment_id` M2O — 3D model
|
||||||
|
- `x_fc_certificate_requirement` Selection — inherit/none/coc/coc_thickness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's LEFT to do (resume here)
|
||||||
|
|
||||||
|
1. ✅ Exploration complete
|
||||||
|
2. ✅ Clarifying questions complete
|
||||||
|
3. ❌ **Propose 2-3 architectural approaches** with the answered constraints baked in. Lead with recommendation. (Most of the architectural picture is already settled by Q1=D; this section should be brief — mostly the "where exactly does the recipe override logic live: in the wizard's `_prepare_order_vals`, or post-confirm in `_fp_auto_create_job`, or a model hook?")
|
||||||
|
4. ❌ **Present design in sections**, get approval after each:
|
||||||
|
- Section 1 — Header layout + field-to-model mapping
|
||||||
|
- Section 2 — Line widget design (the spreadsheet table behavior)
|
||||||
|
- Section 3 — Masking + baking override flow at job creation
|
||||||
|
- Section 4 — Currency switcher mechanic
|
||||||
|
- Section 5 — Inline part create + drawing upload buttons
|
||||||
|
- Section 6 — Phase-out path for direct order
|
||||||
|
5. ❌ **Write design doc** to `docs/superpowers/specs/2026-05-25-express-orders-design.md` and commit
|
||||||
|
6. ❌ **Spec self-review** + user review gate
|
||||||
|
7. ❌ **Transition to writing-plans** skill (after user approves spec)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lesson: SKIP the visual companion on this user's setup
|
||||||
|
|
||||||
|
The user runs Claude Code from their **Mac** via Tailscale into a **Windows** machine (this `Home` host). Browser previews bound to `localhost` on the Windows side are unreachable from the Mac browser. The bash-script brainstorm server (port 65170) hit this. The Python http.server (port 8765) hit this. We spent 20 minutes fighting it.
|
||||||
|
|
||||||
|
**Resolution:** the user said to switch to a native Mac Claude Code session entirely. On Mac the visual companion should "just work" — but consider whether it's necessary. The user already provided an Excel mockup as reference. Text-based design discussion using ASCII tables / structured lists is plenty for this feature. Don't push the visual companion unless the user explicitly asks for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent in-flight work (NOT Express Orders, but on the same Windows host)
|
||||||
|
|
||||||
|
For context: this Windows session also shipped these things today (2026-05-25) — they're DEPLOYED on entech but may need follow-up on the Mac:
|
||||||
|
|
||||||
|
1. **Tablet PIN self-service** (cycle 4) — fully shipped, code 4018 last sent. `fusion_plating_shopfloor` 19.0.36.0.3. Three improvements during the day: SCSS undefined-variable bug fix, switch to `force_send=True`, mail template `email_from` aligned to mail-server `from_filter` (fixes M365 DMARC misalignment / delivery delay).
|
||||||
|
2. **fp_shopfloor_landing removal** — entire OWL component deleted, QR scanner ported into `fp_plant_kanban`, all references cleaned up. Same module version above.
|
||||||
|
3. **Tablet lock-screen orphan localStorage** — cleared `fp_landing_station_id` to fix empty-tiles bug.
|
||||||
|
|
||||||
|
All committed to git on `main`. Pushed via the multi-remote (GitHub + Gitea). On Mac, you'll need to `git pull origin main` from the fresh local clone before doing anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files written this session that the new Mac session should know exist
|
||||||
|
|
||||||
|
- `K:\Github\Odoo-Modules\fusion_plating\.claude\launch.json` — Mockups preview config (port 8765). Mac equivalent path will be the same but on local Mac filesystem.
|
||||||
|
- `K:\Github\Odoo-Modules\fusion_plating\.claude\mockups\index.html` — 15KB layout-direction mockup with A/B comparison. Can be deleted (was for the failed Windows-side preview); if you want to show it on Mac it works fine.
|
||||||
|
- `K:\Github\Odoo-Modules\fusion_plating\.superpowers\brainstorm\944-1779751836\` — dead bash-script brainstorm server directory; safe to delete.
|
||||||
|
- This handoff doc.
|
||||||
|
|
||||||
|
End of handoff.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
|||||||
|
# Recipe Cleanup + Receiving Enforcement Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix recipe 3620 ENP-ALUM-BASIC's duplicate-sequence bug, delete all 24 per-part clone recipes, backfill `kind=other` nodes via an extended name resolver, add an auto-classify hook on every node create/write, and make `no_parts` cards always land in the Receiving column.
|
||||||
|
|
||||||
|
**Architecture:** One migration in `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` does all the data work in 5 phases (resequence 3620 → backfill kinds → delete clones → recompute step.area_kind → recompute job.active_step_id + card_state). Two code-side changes: extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping, add `_fp_autoclassify_kind()` to `fusion.plating.process.node.create/write` so future authoring + recipe duplication self-correct.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md](../specs/2026-05-24-recipe-cleanup-design.md)
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19, Python (ORM/migrations), PostgreSQL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Inventory
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME` aliases; add parenthetical-strip to `fp_resolve_step_kind()`; expose `RESOLVER_KIND_TO_ACTIVE_KIND` map |
|
||||||
|
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + create/write hooks |
|
||||||
|
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
|
||||||
|
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase data migration |
|
||||||
|
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
|
||||||
|
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving column override in `_resolve_card_area` |
|
||||||
|
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_plating/__init__.py:208-304`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `re` to imports**
|
||||||
|
|
||||||
|
At the top of `fusion_plating/__init__.py`, after the existing `import logging` line, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `_STARTER_KIND_BY_NAME`**
|
||||||
|
|
||||||
|
Find the dict at line 208. Inside the dict (before the closing `}`), add the following keys (preserve the existing entries):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 2026-05-24 — Recipe cleanup additions (live-step fix follow-up).
|
||||||
|
# Blasting variants
|
||||||
|
'blasting': 'blast',
|
||||||
|
'bead blast': 'blast',
|
||||||
|
'bead blasting': 'blast',
|
||||||
|
'media blast': 'blast',
|
||||||
|
'media blasting': 'blast',
|
||||||
|
# Inspection variants the resolver didn't know
|
||||||
|
'adhesion test coupon': 'inspect',
|
||||||
|
'adhesion testing': 'inspect',
|
||||||
|
'corrosion testing': 'inspect',
|
||||||
|
'lab testing': 'inspect',
|
||||||
|
'check sulfamate nickel area': 'inspect',
|
||||||
|
'pre-measurements': 'inspect',
|
||||||
|
'pre measurements': 'inspect',
|
||||||
|
'hot water porosity': 'inspect',
|
||||||
|
# Strip / chemical conversion / plugging (wet line)
|
||||||
|
'strip process': 'wet_process',
|
||||||
|
'strip process - al': 'wet_process',
|
||||||
|
'nickel strip - aluminum line': 'wet_process',
|
||||||
|
'chemical conversion': 'wet_process',
|
||||||
|
'trivalent chromate conversion': 'wet_process',
|
||||||
|
'plug the threaded holes': 'mask',
|
||||||
|
# Misc wet line variants seen on entech recipes
|
||||||
|
'air dry': 'dry',
|
||||||
|
'desmut': 'etch',
|
||||||
|
'soak clean': 'cleaning',
|
||||||
|
'cleaner': 'cleaning',
|
||||||
|
'nickel strike': 'plate',
|
||||||
|
'nickel strip': 'plate',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add parenthetical stripping inside `fp_resolve_step_kind()`**
|
||||||
|
|
||||||
|
Find the function around line 288. Replace its body:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fp_resolve_step_kind(name):
|
||||||
|
"""Resolve a step name to a default_kind, tolerant of whitespace and
|
||||||
|
case. Used by both the seeder and the migration backfill so we don't
|
||||||
|
have two slightly-different lookup paths.
|
||||||
|
|
||||||
|
Handles parenthetical suffixes like "(Standard)", "(If Required)",
|
||||||
|
"(A-14 / A)" by stripping them before the second lookup attempt.
|
||||||
|
|
||||||
|
Returns the kind str or None when no match.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
key = name.strip().lower()
|
||||||
|
if key in _STARTER_KIND_BY_NAME:
|
||||||
|
return _STARTER_KIND_BY_NAME[key]
|
||||||
|
# Parenthetical strip — "Masking (If Required)" → "Masking",
|
||||||
|
# "Incoming Inspection (Standard)" → "Incoming Inspection".
|
||||||
|
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
|
||||||
|
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
|
||||||
|
return _STARTER_KIND_BY_NAME[bare]
|
||||||
|
# Gating "Ready for / Ready For" prefix — anything starting with that
|
||||||
|
# is a gating node regardless of the destination step name.
|
||||||
|
if key.startswith('ready for ') or key.startswith('ready '):
|
||||||
|
return 'gating'
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `RESOLVER_KIND_TO_ACTIVE_KIND` translation map**
|
||||||
|
|
||||||
|
Right after the `fp_resolve_step_kind` function (around line 305), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Translates the resolver's kind output to the active fp.step.kind.code
|
||||||
|
# values. The resolver still returns the OLD vocabulary (cleaning,
|
||||||
|
# electroclean, etch, rinse, strike, dry, wbf_test) which were
|
||||||
|
# deactivated in 19.0.20.6.0 — those roll up to the active wet_process
|
||||||
|
# kind. Other codes pass through 1:1.
|
||||||
|
RESOLVER_KIND_TO_ACTIVE_KIND = {
|
||||||
|
# Wet-line kinds → wet_process (active rollup)
|
||||||
|
'cleaning': 'wet_process',
|
||||||
|
'electroclean': 'wet_process',
|
||||||
|
'etch': 'wet_process',
|
||||||
|
'rinse': 'wet_process',
|
||||||
|
'strike': 'wet_process',
|
||||||
|
'dry': 'wet_process',
|
||||||
|
'wbf_test': 'wet_process',
|
||||||
|
# 1:1 mappings (kind exists and is active)
|
||||||
|
'contract_review': 'contract_review',
|
||||||
|
'mask': 'mask',
|
||||||
|
'racking': 'racking',
|
||||||
|
'plate': 'plate',
|
||||||
|
'bake': 'bake',
|
||||||
|
'derack': 'derack',
|
||||||
|
'demask': 'demask',
|
||||||
|
'inspect': 'inspect',
|
||||||
|
'final_inspect': 'final_inspect',
|
||||||
|
'ship': 'ship',
|
||||||
|
'gating': 'gating',
|
||||||
|
'blast': 'blast',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Confirm import structure (no commit yet)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -n "^import re\|^from\|^import" fusion_plating/fusion_plating/__init__.py | head -5
|
||||||
|
```
|
||||||
|
Expected: `import re` appears before `from . import controllers`.
|
||||||
|
|
||||||
|
Commit happens in Task 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Auto-classify hook on `fusion.plating.process.node`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_plating/models/fp_process_node.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find an insertion point near the existing `create/write/copy` methods**
|
||||||
|
|
||||||
|
In [`fusion_plating/models/fp_process_node.py`](../../fusion_plating/models/fp_process_node.py), find the `copy()` method around line 789 (it's at the bottom of the FpProcessNode class). The autoclassify helper goes near it, and the create/write overrides slot in alongside copy.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the helper + create/write overrides**
|
||||||
|
|
||||||
|
In `FpProcessNode`, add this block right before the `copy()` method at line ~787. Insert AFTER all the other fields/methods but BEFORE `copy()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- Auto-classify kind from name (2026-05-24) ----------------------
|
||||||
|
# Safety net: when a node's kind is the catch-all 'other' AND its
|
||||||
|
# name resolves via fp_resolve_step_kind(), upgrade kind_id to the
|
||||||
|
# resolved active kind. Runs on create() and on write() when name
|
||||||
|
# or kind_id changes. Prevents recipe authoring + recipe duplication
|
||||||
|
# from silently leaving nodes as 'other' (which then routes them to
|
||||||
|
# the wrong Shop Floor column).
|
||||||
|
#
|
||||||
|
# Skip with context flag fp_skip_kind_autoclassify=True for admin
|
||||||
|
# workflows that need to keep kind=other despite a known name.
|
||||||
|
|
||||||
|
def _fp_autoclassify_kind(self):
|
||||||
|
"""Upgrade kind_id when current is 'other' and name resolves."""
|
||||||
|
if self.env.context.get('fp_skip_kind_autoclassify'):
|
||||||
|
return
|
||||||
|
from odoo.addons.fusion_plating import (
|
||||||
|
fp_resolve_step_kind,
|
||||||
|
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||||
|
)
|
||||||
|
Kind = self.env['fp.step.kind']
|
||||||
|
other = Kind.search([('code', '=', 'other')], limit=1)
|
||||||
|
if not other:
|
||||||
|
return
|
||||||
|
for node in self:
|
||||||
|
if not node.name or node.kind_id != other:
|
||||||
|
continue
|
||||||
|
resolver_code = fp_resolve_step_kind(node.name)
|
||||||
|
if not resolver_code:
|
||||||
|
continue
|
||||||
|
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||||
|
if not target_code:
|
||||||
|
continue
|
||||||
|
target = Kind.search([('code', '=', target_code)], limit=1)
|
||||||
|
if target:
|
||||||
|
node.with_context(
|
||||||
|
fp_skip_kind_autoclassify=True,
|
||||||
|
).write({'kind_id': target.id})
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
nodes = super().create(vals_list)
|
||||||
|
nodes._fp_autoclassify_kind()
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
if 'name' in vals or 'kind_id' in vals:
|
||||||
|
self._fp_autoclassify_kind()
|
||||||
|
return res
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the file parses (no commit yet)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating/models/fp_process_node.py').read()); print('OK')"
|
||||||
|
```
|
||||||
|
Expected: `OK`.
|
||||||
|
|
||||||
|
Commit happens in Task 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Version bump fusion_plating + commit Phase 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_plating/__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump the version**
|
||||||
|
|
||||||
|
In [`fusion_plating/__manifest__.py`](../../fusion_plating/__manifest__.py), change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'version': '19.0.21.2.0',
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'version': '19.0.21.3.0',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit Phase 1**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_plating/fusion_plating/__init__.py \
|
||||||
|
fusion_plating/fusion_plating/models/fp_process_node.py \
|
||||||
|
fusion_plating/fusion_plating/__manifest__.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(fusion_plating): extend resolver + auto-classify hook on process node
|
||||||
|
|
||||||
|
Resolver (fp_resolve_step_kind) extensions:
|
||||||
|
- New aliases: blasting/bead blast/media blast variants, adhesion
|
||||||
|
testing, corrosion testing, lab testing, strip process, chemical
|
||||||
|
conversion, trivalent chromate, plug the threaded holes, air dry,
|
||||||
|
desmut, soak clean, cleaner, nickel strike/strip
|
||||||
|
- Parenthetical suffix stripping — "Masking (If Required)" resolves
|
||||||
|
through "masking", "Incoming Inspection (Standard)" through
|
||||||
|
"incoming inspection"
|
||||||
|
- New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's
|
||||||
|
vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test
|
||||||
|
→ wet_process) so the resolver output lands on active kinds only
|
||||||
|
|
||||||
|
Auto-classify hook on fusion.plating.process.node:
|
||||||
|
- _fp_autoclassify_kind() upgrades kind_id when current is 'other'
|
||||||
|
AND name resolves via the resolver. Idempotent — never overrides
|
||||||
|
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify
|
||||||
|
- Wired into create() and write() (only fires when name or kind_id
|
||||||
|
changed on write)
|
||||||
|
- Side-effects: recipe duplication via copy() auto-corrects newly
|
||||||
|
copied nodes; Simple/Tree editor authoring auto-classifies as soon
|
||||||
|
as the name is saved
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Write the 19.0.10.26.0 migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the migration directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the migration file**
|
||||||
|
|
||||||
|
Create `fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""19.0.10.26.0 — Recipe cleanup + per-part clone delete.
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
|
||||||
|
|
||||||
|
Phases (in order):
|
||||||
|
1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the
|
||||||
|
duplicate empty ENP-Alum Line sub_process (id 4056).
|
||||||
|
2. Backfill kind on all kind=other nodes via the extended
|
||||||
|
fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND
|
||||||
|
translation.
|
||||||
|
3. Delete all 24 per-part clone recipes (name ILIKE '% — %').
|
||||||
|
CASCADE handles child nodes; SET NULL handles fp.job /
|
||||||
|
fp.job.step / fp.coating.config / fp.pricing.rule /
|
||||||
|
fp.part.catalog references.
|
||||||
|
4. Recompute fp.job.step.area_kind on all rows.
|
||||||
|
5. Recompute fp.job.active_step_id + card_state on in-flight jobs.
|
||||||
|
|
||||||
|
All phases idempotent — re-running -u is safe.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo.api import Environment, SUPERUSER_ID
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Recipe 3620's ops in the desired final order. Maps the existing node
|
||||||
|
# id (as documented in the spec) to its target sequence. The user
|
||||||
|
# decided mask-first-then-rack per spec Section "Mask vs Rack order".
|
||||||
|
RECIPE_3620_RESEQUENCE = [
|
||||||
|
# (node_id, new_sequence, expected_name)
|
||||||
|
(3853, 10, 'Contract Review'),
|
||||||
|
(3854, 20, 'Incoming Inspection (Standard)'),
|
||||||
|
(3877, 30, 'Masking'),
|
||||||
|
(3855, 40, 'Racking'),
|
||||||
|
(3858, 50, 'Ready for processing'),
|
||||||
|
(3859, 60, 'ENP-Alum Line'),
|
||||||
|
(3861, 70, 'De-Masking'),
|
||||||
|
(3864, 80, 'Oven baking'),
|
||||||
|
(3867, 90, 'De-racking'),
|
||||||
|
(4067, 100, 'Oven bake (Post de-rack)'),
|
||||||
|
(3873, 110, 'Post-plate Inspection'),
|
||||||
|
(3876, 120, 'Final Inspection'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no
|
||||||
|
# children — the real one is id 3859 with E-Nickel Plating as child).
|
||||||
|
RECIPE_3620_DUPLICATE_TO_DELETE = 4056
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
env = Environment(cr, SUPERUSER_ID, {})
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 1 — Resequence recipe 3620 + delete duplicate sub_process
|
||||||
|
# ============================================================
|
||||||
|
Node = env['fusion.plating.process.node']
|
||||||
|
recipe_3620 = Node.browse(3620).exists()
|
||||||
|
if not recipe_3620:
|
||||||
|
_logger.warning(
|
||||||
|
'[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; '
|
||||||
|
'skipping resequence phase'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Verify the expected nodes exist, then resequence them.
|
||||||
|
# We do this idempotently — only update if the sequence
|
||||||
|
# differs from the target.
|
||||||
|
renumbered = 0
|
||||||
|
for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE:
|
||||||
|
node = Node.browse(node_id).exists()
|
||||||
|
if not node:
|
||||||
|
_logger.warning(
|
||||||
|
'[recipe-cleanup] Recipe 3620: expected node %s '
|
||||||
|
'("%s") not found; skipping',
|
||||||
|
node_id, expected_name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if node.sequence != new_seq:
|
||||||
|
# Skip the autoclassify hook on this write (nothing
|
||||||
|
# changes about kind_id; we're only touching sequence).
|
||||||
|
node.with_context(
|
||||||
|
fp_skip_kind_autoclassify=True,
|
||||||
|
).write({'sequence': new_seq})
|
||||||
|
renumbered += 1
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Recipe 3620: %s nodes resequenced',
|
||||||
|
renumbered,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the empty duplicate ENP-Alum Line sub_process.
|
||||||
|
dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists()
|
||||||
|
if dup:
|
||||||
|
if dup.child_ids:
|
||||||
|
_logger.warning(
|
||||||
|
'[recipe-cleanup] Duplicate sub_process %s has '
|
||||||
|
'%s children — NOT deleting (safety check). '
|
||||||
|
'Expected an empty node.',
|
||||||
|
dup.id, len(dup.child_ids),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dup.unlink()
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Deleted empty duplicate '
|
||||||
|
'ENP-Alum Line sub_process (id %s)',
|
||||||
|
RECIPE_3620_DUPLICATE_TO_DELETE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 2 — Backfill kind on all kind=other nodes via resolver
|
||||||
|
# ============================================================
|
||||||
|
from odoo.addons.fusion_plating import (
|
||||||
|
fp_resolve_step_kind,
|
||||||
|
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||||
|
)
|
||||||
|
Kind = env['fp.step.kind']
|
||||||
|
other_kind = Kind.search([('code', '=', 'other')], limit=1)
|
||||||
|
if not other_kind:
|
||||||
|
_logger.error(
|
||||||
|
'[recipe-cleanup] No "other" kind found; skipping kind '
|
||||||
|
'backfill phase'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Build a cache of code → kind.id so we don't search per-row
|
||||||
|
kind_by_code = {k.code: k.id for k in Kind.search([])}
|
||||||
|
affected_nodes = Node.search([
|
||||||
|
('kind_id', '=', other_kind.id),
|
||||||
|
('name', '!=', False),
|
||||||
|
('node_type', 'in', ('operation', 'step', 'sub_process')),
|
||||||
|
])
|
||||||
|
fixed = 0
|
||||||
|
for node in affected_nodes:
|
||||||
|
resolver_code = fp_resolve_step_kind(node.name)
|
||||||
|
if not resolver_code:
|
||||||
|
continue
|
||||||
|
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||||
|
if not target_code or target_code not in kind_by_code:
|
||||||
|
continue
|
||||||
|
node.with_context(
|
||||||
|
fp_skip_kind_autoclassify=True,
|
||||||
|
).write({'kind_id': kind_by_code[target_code]})
|
||||||
|
fixed += 1
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Phase 2: backfilled kind on %s nodes '
|
||||||
|
'(of %s currently kind=other)',
|
||||||
|
fixed, len(affected_nodes),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 3 — Delete all 24 per-part clone recipes
|
||||||
|
# ============================================================
|
||||||
|
# Identify by name pattern. The configurator names clones
|
||||||
|
# "BASE_NAME — PART_NUMBER Rev X" with an em-dash separator.
|
||||||
|
# No base recipe uses em-dash in its name.
|
||||||
|
clone_recipes = Node.search([
|
||||||
|
('node_type', '=', 'recipe'),
|
||||||
|
('name', 'ilike', '% — %'),
|
||||||
|
])
|
||||||
|
if clone_recipes:
|
||||||
|
# Log what we're about to delete for forensic visibility.
|
||||||
|
clone_names = [c.name for c in clone_recipes]
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Phase 3: deleting %s clone recipes: %s',
|
||||||
|
len(clone_recipes),
|
||||||
|
', '.join(clone_names[:10])
|
||||||
|
+ (' …' if len(clone_names) > 10 else ''),
|
||||||
|
)
|
||||||
|
clone_recipes.unlink()
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Phase 3: deleted %s clone recipes '
|
||||||
|
'(CASCADE removed their child nodes; FK SET NULL applied '
|
||||||
|
'to historical fp.job + fp.job.step references)',
|
||||||
|
len(clone_recipes),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Phase 3: no clone recipes found '
|
||||||
|
'(already deleted on a prior run, or none exist)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 4 — Recompute area_kind on all fp.job.step rows
|
||||||
|
# ============================================================
|
||||||
|
# After Phase 2, many recipe nodes have new kinds. After Phase 3,
|
||||||
|
# some fp.job.step rows have NULL recipe_node_id (FK SET NULL'd
|
||||||
|
# when the clone got deleted). Recompute picks up the new kinds
|
||||||
|
# for active recipes and falls back to catch-all 'plating' for
|
||||||
|
# orphans (all historical / terminal jobs — won't show on board).
|
||||||
|
Step = env['fp.job.step']
|
||||||
|
steps = Step.search([])
|
||||||
|
if steps:
|
||||||
|
steps._compute_area_kind()
|
||||||
|
steps.flush_recordset(['area_kind'])
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Phase 4: recomputed area_kind on %s steps',
|
||||||
|
len(steps),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 5 — Recompute active_step_id + card_state on in-flight jobs
|
||||||
|
# ============================================================
|
||||||
|
Job = env['fp.job']
|
||||||
|
jobs = Job.search([
|
||||||
|
('state', 'in', ('confirmed', 'in_progress')),
|
||||||
|
])
|
||||||
|
if jobs:
|
||||||
|
jobs._compute_active_step_id()
|
||||||
|
jobs._compute_card_state()
|
||||||
|
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||||
|
_logger.info(
|
||||||
|
'[recipe-cleanup] Phase 5: recomputed active_step_id + '
|
||||||
|
'card_state on %s in-flight jobs',
|
||||||
|
len(jobs),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the file parses**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py').read()); print('OK')"
|
||||||
|
```
|
||||||
|
Expected: `OK`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Version bump fusion_plating_jobs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_plating_jobs/__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump the version**
|
||||||
|
|
||||||
|
In [`fusion_plating_jobs/__manifest__.py`](../../fusion_plating_jobs/__manifest__.py), change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'version': '19.0.10.25.0',
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'version': '19.0.10.26.0',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: No commit yet — grouped with Task 6's commit.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: `no_parts` cards always show in Receiving column
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py:165-180`
|
||||||
|
- Modify: `fusion_plating_shopfloor/__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `_resolve_card_area`**
|
||||||
|
|
||||||
|
In [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../fusion_plating_shopfloor/controllers/plant_kanban.py), find `_resolve_card_area` (around line 165). Replace its body with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _resolve_card_area(job):
|
||||||
|
"""Pick the column a card lives in.
|
||||||
|
|
||||||
|
Active-step area_kind wins, EXCEPT for no_parts cards which always
|
||||||
|
land in Receiving regardless of active step — the receiver is who
|
||||||
|
needs to act, and they work the Receiving column. With the live-step
|
||||||
|
priority chain (see fp.job._compute_active_step_id), active_step_id
|
||||||
|
is False only when the job has NO steps at all (recipe not assigned)
|
||||||
|
OR every step is `done`. Done jobs are filtered off the board
|
||||||
|
upstream, so the orphan fallback fires only for truly orphaned cards.
|
||||||
|
|
||||||
|
See spec 2026-05-24-recipe-cleanup-design.md Change 6.
|
||||||
|
"""
|
||||||
|
# no_parts cards belong in Receiving regardless of where the active
|
||||||
|
# step is — the receiver is who acts.
|
||||||
|
if job.card_state == 'no_parts':
|
||||||
|
return 'receiving'
|
||||||
|
if job.active_step_id and job.active_step_id.area_kind:
|
||||||
|
return job.active_step_id.area_kind
|
||||||
|
# Orphan fallback — represents a data integrity issue, not a
|
||||||
|
# normal state. Cards here have NO steps assigned at all.
|
||||||
|
return 'receiving'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Bump fusion_plating_shopfloor manifest**
|
||||||
|
|
||||||
|
In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
'version': '19.0.33.1.3',
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'version': '19.0.33.1.4',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit Phase 2 (Tasks 4-6)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
|
||||||
|
fusion_plating/fusion_plating_jobs/__manifest__.py \
|
||||||
|
fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py \
|
||||||
|
fusion_plating/fusion_plating_shopfloor/__manifest__.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix
|
||||||
|
|
||||||
|
Migration 19.0.10.26.0/post-migrate.py runs in 5 phases:
|
||||||
|
1. Resequence recipe 3620 ENP-ALUM-BASIC ops (fixes the duplicate-
|
||||||
|
sequence bug that caused WO-30057 to skip Receiving)
|
||||||
|
2. Backfill kind on all kind=other nodes via the extended resolver
|
||||||
|
from fusion_plating 19.0.21.3.0
|
||||||
|
3. Delete all 24 per-part clone recipes
|
||||||
|
4. Recompute fp.job.step.area_kind on all steps
|
||||||
|
5. Recompute fp.job.active_step_id + card_state on in-flight jobs
|
||||||
|
|
||||||
|
Plant kanban: no_parts cards now always land in the Receiving column
|
||||||
|
regardless of active_step area_kind. The receiver works Receiving;
|
||||||
|
that's where the card belongs when parts haven't arrived.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Deploy to entech + verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fetch + check concurrent commits**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git log HEAD..origin/main --oneline
|
||||||
|
```
|
||||||
|
Expected: empty (we're ahead, not behind). If anything shows, rebase first.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Copy modified files to entech**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in \
|
||||||
|
fusion_plating/__init__.py \
|
||||||
|
fusion_plating/models/fp_process_node.py \
|
||||||
|
fusion_plating/__manifest__.py \
|
||||||
|
fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
|
||||||
|
fusion_plating_jobs/__manifest__.py \
|
||||||
|
fusion_plating_shopfloor/controllers/plant_kanban.py \
|
||||||
|
fusion_plating_shopfloor/__manifest__.py; do
|
||||||
|
echo "Copying $f"
|
||||||
|
cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \"mkdir -p \\\$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f\""
|
||||||
|
done
|
||||||
|
echo "=== ALL COPIED ==="
|
||||||
|
```
|
||||||
|
|
||||||
|
(Run from `/Users/gurpreet/Github/Odoo-Modules/fusion_plating/` so the file paths line up.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Upgrade modules + restart**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -60 && systemctl start odoo && sleep 3 && systemctl is-active odoo'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected log lines (in order):
|
||||||
|
- `[recipe-cleanup] Recipe 3620: N nodes resequenced`
|
||||||
|
- `[recipe-cleanup] Deleted empty duplicate ENP-Alum Line sub_process (id 4056)`
|
||||||
|
- `[recipe-cleanup] Phase 2: backfilled kind on N nodes …`
|
||||||
|
- `[recipe-cleanup] Phase 3: deleting 24 clone recipes: …`
|
||||||
|
- `[recipe-cleanup] Phase 3: deleted 24 clone recipes …`
|
||||||
|
- `[recipe-cleanup] Phase 4: recomputed area_kind on N steps`
|
||||||
|
- `[recipe-cleanup] Phase 5: recomputed active_step_id + card_state on N in-flight jobs`
|
||||||
|
- Service prints `active` at the end.
|
||||||
|
|
||||||
|
No tracebacks. If you see one, STOP and report it.
|
||||||
|
|
||||||
|
- [ ] **Step 4: SQL spot-check — clones deleted**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT COUNT(*) AS clones_remaining FROM fusion_plating_process_node WHERE node_type='\\''recipe'\\'' AND name ILIKE '\\''% — %'\\'';\" | sudo -u postgres psql -d admin'"
|
||||||
|
```
|
||||||
|
Expected: `clones_remaining = 0`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: SQL spot-check — recipe 3620 resequenced**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT sequence, name FROM fusion_plating_process_node WHERE parent_id = 3620 AND node_type IN ('\\''operation'\\'', '\\''sub_process'\\'') ORDER BY sequence;\" | sudo -u postgres psql -d admin'"
|
||||||
|
```
|
||||||
|
Expected output (12 unique-sequence rows):
|
||||||
|
```
|
||||||
|
10 | Contract Review
|
||||||
|
20 | Incoming Inspection (Standard)
|
||||||
|
30 | Masking
|
||||||
|
40 | Racking
|
||||||
|
50 | Ready for processing
|
||||||
|
60 | ENP-Alum Line
|
||||||
|
70 | De-Masking
|
||||||
|
80 | Oven baking
|
||||||
|
90 | De-racking
|
||||||
|
100 | Oven bake (Post de-rack)
|
||||||
|
110 | Post-plate Inspection
|
||||||
|
120 | Final Inspection
|
||||||
|
```
|
||||||
|
NO duplicate sequences. NO second ENP-Alum Line row.
|
||||||
|
|
||||||
|
- [ ] **Step 6: SQL spot-check — kind=other nodes backfilled**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT n.name, COUNT(*) AS still_other FROM fusion_plating_process_node n JOIN fp_step_kind k ON k.id = n.kind_id WHERE k.code = '\\''other'\\'' AND n.node_type IN ('\\''operation'\\'', '\\''step'\\'', '\\''sub_process'\\'') GROUP BY n.name ORDER BY still_other DESC;\" | sudo -u postgres psql -d admin'"
|
||||||
|
```
|
||||||
|
Expected: very few rows, only names like `ENP-Alum Line - HP` (sub_process with no clear category) or genuinely-niche operation names. Should NOT include `Contract Review`, `Masking`, `Racking`, `Incoming Inspection`, `E-Nickel Plating`, `Final Inspection`, `Shipping`, `Bake`, `Blasting`, `De-Masking`, `De-racking`, `Hot Water Porosity`, etc.
|
||||||
|
|
||||||
|
- [ ] **Step 7: End-to-end smoke**
|
||||||
|
|
||||||
|
On the entech UI:
|
||||||
|
1. Open Plating → Sales & Quoting → Sale Orders → New
|
||||||
|
2. Add a customer + a part whose default recipe is `ENP-ALUM-BASIC` (id 3620)
|
||||||
|
3. Confirm the SO
|
||||||
|
4. Check the new WO on Plating → Operations → Plating Jobs:
|
||||||
|
a. Recipe should be a fresh clone named `ENP-ALUM-BASIC — <PART#> Rev <X>`
|
||||||
|
b. The clone's first 4 operations should be: Contract Review (10), Incoming Inspection (20), Masking (30), Racking (40)
|
||||||
|
5. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' AND/OR because Incoming Inspection is now the next live step after Contract Review auto-completes)
|
||||||
|
6. Open Plating → Configuration → Recipes & Steps → Recipes — confirm no recipe has " — " in its name (the clones are gone)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Autoclassify hook smoke**
|
||||||
|
|
||||||
|
In the Simple Editor on any recipe:
|
||||||
|
1. Drop a new step, type name "Masking" without picking a kind
|
||||||
|
2. Save
|
||||||
|
3. Refresh the page
|
||||||
|
4. Confirm the step's kind reads "Masking" (not "Other")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Commit spec + plan, push to origin
|
||||||
|
|
||||||
|
- [ ] **Step 1: Stage and commit the spec + plan docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md \
|
||||||
|
fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
docs(plating): spec + plan for recipe cleanup + receiving enforcement
|
||||||
|
|
||||||
|
Spec documents:
|
||||||
|
- Root cause 1: duplicate sequences on recipe 3620 ENP-ALUM-BASIC
|
||||||
|
- Root cause 2: 24 per-part clone recipes carrying the broken order
|
||||||
|
- Root cause 3: ~10 kind=other stragglers across base recipes
|
||||||
|
- Root cause 4: recipe duplication has no kind safety net
|
||||||
|
|
||||||
|
Implementation shipped in commits referenced from the plan's task list.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Final fetch + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git log HEAD..origin/main --oneline # expect empty
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If anything fails on entech:
|
||||||
|
|
||||||
|
1. `git reset --hard <prior-commit>` locally, force-copy the prior files back to entech.
|
||||||
|
2. Force-rerun the prior version's post-migrate by setting `ir_module_module.latest_version` back to `19.0.10.25.0` for fusion_plating_jobs and `19.0.21.2.0` for fusion_plating, then `-u`.
|
||||||
|
|
||||||
|
(Migration is idempotent so re-running the broken version is safe; you may need to manually re-create the deleted clones from a DB backup if rollback needed clones back — out of scope per "we don't need to worry about current data".)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,858 @@
|
|||||||
|
# Fusion Plating — Permissions Overhaul (Phase 1)
|
||||||
|
|
||||||
|
**Date:** 2026-05-23
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Owner module:** `fusion_plating` (with co-changes in 9 dependent modules)
|
||||||
|
**Brainstorm transcript:** session with @gsinghpal, 2026-05-23
|
||||||
|
**Linked plan:** TBD (writing-plans skill, next step)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current Fusion Plating permission system has 12 `res.groups` defined across 6 modules. An audit (2026-05-23) found:
|
||||||
|
|
||||||
|
- **3 groups are zero-reference orphans** — Shop Manager, CGP Designated Official, Plating Legacy Menus
|
||||||
|
- **1 group is functionally orphaned** — Administrator (the 2 Python checks that reference it use a typo'd XML ID `_administrator` instead of `_admin`, so the gate never fires)
|
||||||
|
- The role dropdown in the user form lists 10 entries with confusing ordering (sequence ties at 50 and 60 cause Estimator/CGP Officer and Shop Manager/CGP DO to render in arbitrary alphabetical order)
|
||||||
|
- Default landing page is hardcoded to "Shop view" for everyone — Managers complain about being dumped into a Workstation tablet when they open the Plating app
|
||||||
|
- The landing-page picklist in user preferences offers only 3 options (Quotations, Sale Orders, Process Recipes) — missing Manager Desk, Plant View, Quality Dashboard
|
||||||
|
- Menu visibility relies on a mix of explicit `groups=` attributes and implicit action-level ACLs — fragile and inconsistent
|
||||||
|
|
||||||
|
This Phase 1 work consolidates the 12 groups into **8 well-defined roles**, fixes the landing-page UX with role-based defaults, and ships an Owner-only "Team" page for clean role assignment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | Quality Manager vs Manager — what quality permissions split? | **Option B** — Manager handles reactive Quality (NCR/Hold/Check/routine Cert/RMA). Quality Manager owns strategic Quality (CAPA closure, audit sign-off, FAIR/Nadcap signing, AVL approval, Customer Spec library, Doc Control approval, all CGP). |
|
||||||
|
| Q2 | CGP/Aerospace/Nuclear verticals — fold or keep as add-on flags? | **Option A** — All vertical ACLs gate on Quality Manager. CGP Officer group dropped (folds into QM). CGP Designated Official becomes `res.company.x_fc_cgp_designated_official_id` field (Many2one to res.users, domain `[Owner, QM]`). Aerospace/Nuclear/Safety unchanged (already on Manager backbone). |
|
||||||
|
| Q3 | Landing page per role — hardcoded, configurable, or seeded? | **Option B** — Hardcoded role→action mapping in the resolver. Per-user override stays in preferences. Company default stays as a final fallback. |
|
||||||
|
| Q4 | Owner-only Permissions config page — yes/no, and what does it configure? | **Yes, Interpretation A only** — Owner-only "Team" page for role assignment, designated officials, and audit log. NO permission-definition editing (Interpretation B explicitly killed — would defeat the 8-role spec). |
|
||||||
|
| Q5 | Migration of existing users — auto-map or force manual? | **Option B** — Dry-run preview + Owner approval. Auto-map runs on `-u`, creates a `fp.migration.preview` in `pending` state, schedules a `mail.activity` on every Owner. Migration only applies after Owner clicks "Approve & Run". 30-day rollback window via archived old groups. |
|
||||||
|
| Q4b | Menu/submenu/field visibility — explicit `groups=` or inherit from parent? | **Confirmed by user pre-spec-write** — All three layers (top-level menus, submenus, fields/buttons) get explicit `groups=` matching the new roles. No reliance on action-level ACLs for menu visibility. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1 — Role Hierarchy & XML IDs
|
||||||
|
|
||||||
|
### The 8 new roles
|
||||||
|
|
||||||
|
All under the existing `fusion_plating.res_groups_privilege_fusion_plating` privilege block (same place in the user form). Sequence numbers picked uniquely to avoid the current audit's "tied at 50/60" rendering bug.
|
||||||
|
|
||||||
|
| Seq | Display Name | XML ID | Implies | Auto-assigned to |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 10 | Technician | `fusion_plating.group_fp_technician` | `base.group_user` | — |
|
||||||
|
| 20 | Sales Representative | `fusion_plating.group_fp_sales_rep` | `base.group_user` | — |
|
||||||
|
| 30 | Shop Manager | `fusion_plating.group_fp_shop_manager_v2` | Technician | — |
|
||||||
|
| 40 | Sales Manager | `fusion_plating.group_fp_sales_manager` | Sales Representative | — |
|
||||||
|
| 50 | Manager | `fusion_plating.group_fp_manager` | Shop Manager + Sales Manager | — |
|
||||||
|
| 60 | Quality Manager | `fusion_plating.group_fp_quality_manager` | Manager | — |
|
||||||
|
| 70 | Owner | `fusion_plating.group_fp_owner` | Quality Manager + `base.group_system` | uid 1, uid 2 |
|
||||||
|
| — | (No) | — implicit (no Fusion Plating group held) | — | — |
|
||||||
|
|
||||||
|
### Design notes
|
||||||
|
|
||||||
|
1. **`group_fp_shop_manager_v2` suffix** — the existing `fusion_plating_configurator.group_fp_shop_manager` (today's 0-ref label bundle) gets retired. Suffix `_v2` avoids xmlid collision during migration; we rename to `_shop_manager` in a follow-up housekeeping pass once old refs are confirmed dead.
|
||||||
|
|
||||||
|
2. **Sales branch and Shop branch are parallel** — both inherit only `base.group_user`. A Technician can't see Quotations; a Sales Rep can't see the Workstation. They cross-join at Manager.
|
||||||
|
|
||||||
|
3. **Diamond at Manager** — Manager implies BOTH Shop Manager AND Sales Manager; gets the union.
|
||||||
|
|
||||||
|
4. **"No" is the absence of any group** — no `res.groups` record needed. The Plating menu root gates on an OR of all 7 plating roles. An internal user with none sees no plating menu.
|
||||||
|
|
||||||
|
5. **Owner implies `base.group_system`** — replaces today's broken Administrator pattern. Owners get Settings access, can install modules, etc.
|
||||||
|
|
||||||
|
6. **Old groups stay defined post-migration but become "DEPRECATED" + auto-archived.** 30-day rollback window. `_cron_purge_expired_migrations` deletes them after 30 days.
|
||||||
|
|
||||||
|
7. **CGP Designated Official is no longer a group** — `res.company.x_fc_cgp_designated_official_id` (Many2one to res.users, domain `[('groups_id', 'in', [QM_id, Owner_id])]`).
|
||||||
|
|
||||||
|
8. **Field Technician (`fusion_tasks.group_field_technician`) is untouched** — orthogonal to plating roles, separate privilege block.
|
||||||
|
|
||||||
|
### Hierarchy visual
|
||||||
|
|
||||||
|
```
|
||||||
|
base.group_user (Internal User)
|
||||||
|
├── (no plating group) = "No"
|
||||||
|
├── Technician [10]
|
||||||
|
│ └── Shop Manager v2 [30]
|
||||||
|
│ └── Manager [50] ←──┐ (diamond)
|
||||||
|
│ │
|
||||||
|
└── Sales Representative [20]│
|
||||||
|
└── Sales Manager [40] │
|
||||||
|
└── Manager [50] ←───┘
|
||||||
|
└── Quality Manager [60]
|
||||||
|
└── Owner [70] (also implies base.group_system)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2 — ACL Re-gating Plan
|
||||||
|
|
||||||
|
### 2.A Standard mapping pattern
|
||||||
|
|
||||||
|
Applies to ~80% of the ~475 ACL refs mechanically:
|
||||||
|
|
||||||
|
| Old gate | New gate | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `group_fusion_plating_operator` | `group_fp_technician` | Pure rename |
|
||||||
|
| `group_fusion_plating_supervisor` | `group_fp_shop_manager_v2` | Supervisor's daily-floor leadership IS Shop Manager's job |
|
||||||
|
| `group_fusion_plating_manager` | `group_fp_manager` | Pure rename |
|
||||||
|
| `group_fp_estimator` | `group_fp_sales_rep` | Pure rename, but lose order-confirm (Section 2.B) |
|
||||||
|
| `group_fp_receiving` | `group_fp_shop_manager_v2` | Receiving folds in |
|
||||||
|
| `group_fp_accounting` | `group_fp_manager` | Accounting folds in |
|
||||||
|
| `group_fusion_plating_admin` | `group_fp_owner` | Pure rename + fixes `_administrator` typo bug |
|
||||||
|
|
||||||
|
Implied-chain handles the rest (e.g., Manager auto-gets everything Shop Manager has, so a model gated on Shop Manager is automatically accessible to Manager+).
|
||||||
|
|
||||||
|
### 2.B New gates ADDED
|
||||||
|
|
||||||
|
| Action | Today | New gate |
|
||||||
|
|---|---|---|
|
||||||
|
| `sale.order.action_confirm` | Any internal user | `group_fp_sales_manager` |
|
||||||
|
| `sale.order` set `x_fc_account_hold_override` | Manager (with `_administrator` typo) | `group_fp_manager` (clean) |
|
||||||
|
| `account.move.action_post` for FP-invoiced SOs | Implicit | `group_fp_manager` |
|
||||||
|
| Owner-only Team page menu | Doesn't exist | `group_fp_owner` |
|
||||||
|
|
||||||
|
Sales Reps can still save Sale Orders in `draft`; the confirm button is hidden in the view and the model-level gate raises `UserError` if called directly: *"Only Sales Manager or higher can confirm orders."*
|
||||||
|
|
||||||
|
### 2.C Quality split — Manager vs Quality Manager
|
||||||
|
|
||||||
|
| Model | Manager rights | QM-only rights |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion.plating.ncr` | CRUD + state transitions through `closed` | — |
|
||||||
|
| `fusion.plating.capa` | **Read + comment only** | CRUD + `action_close` + effectiveness verification |
|
||||||
|
| `fusion.plating.quality.hold` | CRUD + release | — |
|
||||||
|
| `fusion.plating.quality.check` | CRUD + pass/fail | — |
|
||||||
|
| `fp.certificate` (routine CoC, thickness) | CRUD + sign + issue + send | — |
|
||||||
|
| `fp.certificate` where `cert_type='fair'` | Read + create | Sign + issue (record rule on cert_type) |
|
||||||
|
| `fp.certificate` where `cert_type='nadcap'` | Read + create | Sign + issue (record rule on cert_type) |
|
||||||
|
| `fusion.plating.rma` | CRUD + authorise + resolve | — |
|
||||||
|
| `fusion.plating.audit` | Read | CRUD + close |
|
||||||
|
| `fusion.plating.customer.spec` | Read + attach to parts | CRUD (library curator) |
|
||||||
|
| `fp.approved.vendor.list` | Read | Add / approve / disqualify |
|
||||||
|
| `fp.contract.review` (QA-005) | Complete reviews assigned to them | Set QA Manager roster + override gates |
|
||||||
|
| Doc Control + Doc Approval | Read + request approval | Approve / supersede / retire |
|
||||||
|
| Calibration equipment | Log events + view schedule | Configure equipment + set intervals + dispose out-of-tolerance |
|
||||||
|
| **All `fp.cgp.*` models** (8 ACLs + 2 ir.rules) | None | All (entire CGP fold-in lands here) |
|
||||||
|
|
||||||
|
**Implementation note:** FAIR/Nadcap cert split uses an `ir.rule` on `fp.certificate` (domain `[('cert_type', 'in', ['fair','nadcap'])]`) restricted to QM for write. Routine CoCs (cert_type = `coc` or `thickness_report`) stay open to Manager.
|
||||||
|
|
||||||
|
### 2.D Verticals (Aerospace / Nuclear / Safety)
|
||||||
|
|
||||||
|
No change. Their ACLs already gate on `group_fusion_plating_manager` (now `group_fp_manager`). The standard mapping in 2.A covers them. No new vertical-specific gates needed.
|
||||||
|
|
||||||
|
### 2.E Three-layer menu / submenu / field hiding policy
|
||||||
|
|
||||||
|
**Rule:** if a user can't use it, they don't see it. No reliance on action-level ACLs for visibility — explicit `groups=` at every layer.
|
||||||
|
|
||||||
|
#### Layer 1 — Top-level menus
|
||||||
|
|
||||||
|
| Top-level menu | `groups=` |
|
||||||
|
|---|---|
|
||||||
|
| **Plating** (root) | OR of all 7 plating roles |
|
||||||
|
| **Sales & Quoting** | `group_fp_sales_rep` |
|
||||||
|
| **Shop Floor** | `group_fp_technician` |
|
||||||
|
| **Operations** | `group_fp_technician` |
|
||||||
|
| **Receiving & Shipping** | `group_fp_shop_manager_v2` |
|
||||||
|
| **Quality** | `group_fp_manager` |
|
||||||
|
| **Compliance** (hub) | `group_fp_quality_manager` |
|
||||||
|
| **KPIs** | `group_fp_manager` |
|
||||||
|
| **Configuration** | `group_fp_manager` |
|
||||||
|
|
||||||
|
#### Layer 2 — Submenus (explicit on every child)
|
||||||
|
|
||||||
|
| Submenu | New gate |
|
||||||
|
|---|---|
|
||||||
|
| Quality > Audits | `group_fp_quality_manager` |
|
||||||
|
| Quality > Customer Specs | `group_fp_quality_manager` |
|
||||||
|
| Quality > Approved Vendor List | `group_fp_quality_manager` |
|
||||||
|
| Quality > NCRs / Holds / Checks / RMAs / Certs | `group_fp_manager` |
|
||||||
|
| Quality > CAPAs | `group_fp_manager` (visibility); QM-only for close button (Layer 3) |
|
||||||
|
| Operations > Maintenance | `group_fp_shop_manager_v2` |
|
||||||
|
| Operations > Move Log | `group_fp_shop_manager_v2` |
|
||||||
|
| Operations > Labor History | `group_fp_shop_manager_v2` |
|
||||||
|
| Operations > Replenishment Suggestions | `group_fp_manager` |
|
||||||
|
| Configuration > Team | `group_fp_owner` |
|
||||||
|
| Configuration > Settings | `group_fp_manager` (explicit) |
|
||||||
|
| Configuration > all 7 themed folders | `group_fp_manager` (explicit) |
|
||||||
|
| Sales & Quoting > Configurator | `group_fp_sales_rep` |
|
||||||
|
| Sales & Quoting > Sale Orders | `group_fp_sales_rep` (visibility); SM+ for confirm (Layer 3) |
|
||||||
|
| Receiving & Shipping > all children | `group_fp_shop_manager_v2` |
|
||||||
|
| Compliance > CGP | `group_fp_quality_manager` |
|
||||||
|
| Compliance > General / Safety / Aerospace / Nuclear | `group_fp_quality_manager` |
|
||||||
|
|
||||||
|
#### Layer 3 — Fields, buttons, smart buttons
|
||||||
|
|
||||||
|
| View element | New gate |
|
||||||
|
|---|---|
|
||||||
|
| `sale.order` view — Confirm button | `group_fp_sales_manager` |
|
||||||
|
| `sale.order` view — `x_fc_account_hold_override` | `group_fp_manager` (was broken Administrator typo) |
|
||||||
|
| `sale.order` form — pricing columns on lines | `group_fp_sales_rep` (defense in depth — Technician/Shop Manager don't see pricing) |
|
||||||
|
| `fp.certificate` form — Sign button (FAIR / Nadcap) | `group_fp_quality_manager` |
|
||||||
|
| `fusion.plating.capa` form — Close button + edit fields | `group_fp_quality_manager` |
|
||||||
|
| `fusion.plating.audit` form — all buttons | `group_fp_quality_manager` |
|
||||||
|
| `fp.approved.vendor.list` form — Approve / Disqualify | `group_fp_quality_manager` |
|
||||||
|
| `fusion.plating.customer.spec` form — edit fields | `group_fp_quality_manager` |
|
||||||
|
| All CGP form buttons | `group_fp_quality_manager` |
|
||||||
|
| Smart buttons (cross-record navigation) | Match the underlying action's visibility |
|
||||||
|
|
||||||
|
### 2.F Per-role menu visibility matrix (sanity check)
|
||||||
|
|
||||||
|
| Menu | No | Tech | SR | SM | SalesMgr | Mgr | QM | Owner |
|
||||||
|
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|
||||||
|
| Plating (root) | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Sales & Quoting | — | — | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Shop Floor | — | ✓ | — | ✓ | — | ✓ | ✓ | ✓ |
|
||||||
|
| Operations | — | ✓ | — | ✓ | — | ✓ | ✓ | ✓ |
|
||||||
|
| Receiving & Shipping | — | — | — | ✓ | — | ✓ | ✓ | ✓ |
|
||||||
|
| Quality | — | — | — | — | — | ✓ | ✓ | ✓ |
|
||||||
|
| Compliance | — | — | — | — | — | — | ✓ | ✓ |
|
||||||
|
| KPIs | — | — | — | — | — | ✓ | ✓ | ✓ |
|
||||||
|
| Configuration | — | — | — | — | — | ✓ | ✓ | ✓ |
|
||||||
|
| Configuration > Team | — | — | — | — | — | — | — | ✓ |
|
||||||
|
|
||||||
|
(SR = Sales Rep, SM = Shop Manager, SalesMgr = Sales Manager, Mgr = Manager)
|
||||||
|
|
||||||
|
### 2.G Manager-bypass context flags (no ownership change)
|
||||||
|
|
||||||
|
All 9 existing bypass flags from the battle tests remain gated on Manager+:
|
||||||
|
|
||||||
|
`fp_skip_step_gate`, `fp_skip_qc_gate`, `fp_skip_qty_reconcile`, `fp_skip_bake_gate`, `fp_skip_predecessor_check`, `fp_skip_missed_window`, `fp_skip_required_inputs_gate`, `fp_skip_signoff_gate`, `fp_skip_transition_form`.
|
||||||
|
|
||||||
|
New name in the check: `user.has_group('fusion_plating.group_fp_manager')`.
|
||||||
|
|
||||||
|
Shop Manager CANNOT bypass these gates — matches spec ("Technicians cannot override system"). Override authority sits at Manager.
|
||||||
|
|
||||||
|
### 2.H ir.rules (record rules)
|
||||||
|
|
||||||
|
| Rule | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| `fp.cgp.psa` Officer-only | CGP Officer | `group_fp_quality_manager` |
|
||||||
|
| `fp.cgp.security.incident` Officer-only | CGP Officer | `group_fp_quality_manager` |
|
||||||
|
| `fusion.technician.task` Field-Tech-own | Field Technician | Unchanged (orthogonal) |
|
||||||
|
| **NEW:** `fp.certificate` write-gate for cert_type in ('fair','nadcap') | — | `group_fp_quality_manager` |
|
||||||
|
| **NEW:** `sale.order` write-gate for state→'sale' transition | — | `group_fp_sales_manager` |
|
||||||
|
|
||||||
|
No multi-company changes — existing multi-company rules untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3 — Landing Resolver
|
||||||
|
|
||||||
|
### Resolver flow (server action `action_fp_resolve_plating_landing`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_resolve_landing(self):
|
||||||
|
user = self.env.user
|
||||||
|
company = self.env.company
|
||||||
|
|
||||||
|
# 1. Per-user override (set in preferences)
|
||||||
|
if user.x_fc_plating_landing_action_id:
|
||||||
|
return user.x_fc_plating_landing_action_id._render_action()
|
||||||
|
|
||||||
|
# 2. Role-based default (precedence: highest role wins)
|
||||||
|
role_landing = self._fp_role_default_landing(user, company)
|
||||||
|
if role_landing:
|
||||||
|
return role_landing._render_action()
|
||||||
|
|
||||||
|
# 3. Company default (admin fallback)
|
||||||
|
if company.x_fc_default_landing_action_id:
|
||||||
|
return company.x_fc_default_landing_action_id._render_action()
|
||||||
|
|
||||||
|
# 4. Hardcoded last-ditch
|
||||||
|
return self.env.ref('fusion_plating_configurator.action_fp_sale_orders')._render_action()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role → action mapping (Step 2)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_role_default_landing(self, user, company):
|
||||||
|
workstation_action = self._fp_workstation_action_for_layout(company)
|
||||||
|
|
||||||
|
if user.has_group('fusion_plating.group_fp_owner'):
|
||||||
|
return self.env.ref('fusion_plating_shopfloor.action_fp_manager_dashboard',
|
||||||
|
raise_if_not_found=False)
|
||||||
|
if user.has_group('fusion_plating.group_fp_quality_manager'):
|
||||||
|
return self.env.ref('fusion_plating_quality.action_fp_quality_dashboard',
|
||||||
|
raise_if_not_found=False)
|
||||||
|
if user.has_group('fusion_plating.group_fp_manager'):
|
||||||
|
return self.env.ref('fusion_plating_shopfloor.action_fp_manager_dashboard',
|
||||||
|
raise_if_not_found=False)
|
||||||
|
if user.has_group('fusion_plating.group_fp_sales_manager'):
|
||||||
|
return self.env.ref('fusion_plating_configurator.action_fp_sale_orders',
|
||||||
|
raise_if_not_found=False)
|
||||||
|
if user.has_group('fusion_plating.group_fp_shop_manager_v2'):
|
||||||
|
return workstation_action
|
||||||
|
if user.has_group('fusion_plating.group_fp_sales_rep'):
|
||||||
|
return self.env.ref('fusion_plating_configurator.action_fp_quotations',
|
||||||
|
raise_if_not_found=False)
|
||||||
|
if user.has_group('fusion_plating.group_fp_technician'):
|
||||||
|
return workstation_action
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workstation = layout-flag aware (single source of truth)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_workstation_action_for_layout(self, company):
|
||||||
|
"""Single source of truth: which Shop Floor surface is active on this DB?"""
|
||||||
|
param = self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_plating_shopfloor.layout', 'v2')
|
||||||
|
if param == 'v2':
|
||||||
|
return self.env.ref('fusion_plating_shopfloor.action_fp_plant_kanban',
|
||||||
|
raise_if_not_found=False)
|
||||||
|
return self.env.ref('fusion_plating_shopfloor.action_fp_shopfloor_landing',
|
||||||
|
raise_if_not_found=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
Flipping `ir.config_parameter['fusion_plating_shopfloor.layout']` instantly changes the default landing for every Technician and Shop Manager on the next page load.
|
||||||
|
|
||||||
|
### Pickable actions (`x_fc_pickable_landing=True`)
|
||||||
|
|
||||||
|
Adding 4 net-new (3 are already pickable). Total picklist = **7 entries**.
|
||||||
|
|
||||||
|
| Action XML ID | Display in dropdown | Default for |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_plating_shopfloor.action_fp_manager_dashboard` | Manager Desk | Owner / QM / Manager |
|
||||||
|
| `fusion_plating_shopfloor.action_fp_plant_kanban` | Plant View Kanban | Shop Mgr / Tech (v2 layout) |
|
||||||
|
| `fusion_plating_shopfloor.action_fp_shopfloor_landing` | Workstation (Legacy) | Shop Mgr / Tech (legacy layout) |
|
||||||
|
| `fusion_plating_quality.action_fp_quality_dashboard` | Quality Dashboard | QM |
|
||||||
|
| `fusion_plating_configurator.action_fp_quotations` | Quotations | Sales Rep (already pickable) |
|
||||||
|
| `fusion_plating_configurator.action_fp_sale_orders` | Sale Orders | Sales Manager (already pickable) |
|
||||||
|
| `fusion_plating.action_fp_process_recipe` | Process Recipes | (niche option, already pickable) |
|
||||||
|
|
||||||
|
### Per-user override picklist domain
|
||||||
|
|
||||||
|
Today: `[('x_fc_pickable_landing', '=', True)]`. **Tightened**: also filter by user's accessible actions, so a Technician can't pick "Manager Desk" as their landing if they can't see it.
|
||||||
|
|
||||||
|
Domain becomes computed: `[('x_fc_pickable_landing', '=', True), ('id', 'in', user_accessible_action_ids)]`. The `user_accessible_action_ids` list comes from a compute that runs `env['ir.ui.menu']._visible_menu_ids()` mapped to action IDs.
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
1. **Multi-role user (Manager promoted to QM):** precedence chain picks higher role. Deterministic.
|
||||||
|
2. **User in "No" state opening resolver directly:** falls through to company default → hardcoded Sale Orders → standard Odoo home.
|
||||||
|
3. **xmlref deleted or module uninstalled:** `raise_if_not_found=False` returns False, resolver falls through.
|
||||||
|
4. **First-login user with no preference / company default / roles:** lands on Sale Orders.
|
||||||
|
5. **Demo / fresh DB:** Sale Orders fallback works without any FP modules beyond core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4 — Owner-only Team Page
|
||||||
|
|
||||||
|
### Implementation — standard Odoo views, not custom OWL
|
||||||
|
|
||||||
|
Single new field on `res.users` + standard kanban/form views. Zero custom JS.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# fusion_plating/models/res_users.py
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
x_fc_plating_role = fields.Selection([
|
||||||
|
('no', 'No'),
|
||||||
|
('technician', 'Technician'),
|
||||||
|
('sales_rep', 'Sales Representative'),
|
||||||
|
('shop_manager', 'Shop Manager'),
|
||||||
|
('sales_manager', 'Sales Manager'),
|
||||||
|
('manager', 'Manager'),
|
||||||
|
('quality_manager', 'Quality Manager'),
|
||||||
|
('owner', 'Owner'),
|
||||||
|
], compute='_compute_plating_role',
|
||||||
|
inverse='_inverse_plating_role',
|
||||||
|
store=True,
|
||||||
|
string='Fusion Plating Role')
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Compute** reads `groups_id`, returns the highest-precedence plating role
|
||||||
|
- **Inverse** clears all plating groups + writes only the chosen one + posts a `Markup()` chatter audit
|
||||||
|
- **Stored** so kanban `default_group_by="x_fc_plating_role"` and drag-and-drop work
|
||||||
|
|
||||||
|
### Menu placement
|
||||||
|
|
||||||
|
```
|
||||||
|
Plating
|
||||||
|
└── Configuration (Manager+)
|
||||||
|
└── ⚡ Settings (existing)
|
||||||
|
└── 👥 Team (NEW — Owner-only)
|
||||||
|
└── (opens action_fp_team)
|
||||||
|
```
|
||||||
|
|
||||||
|
XML ID: `fusion_plating.menu_fp_team`, `groups="fusion_plating.group_fp_owner"`.
|
||||||
|
|
||||||
|
### 4 tabs
|
||||||
|
|
||||||
|
| Tab | View type | Domain | What it does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Active Team** | Kanban grouped by `x_fc_plating_role` | `[('share','=',False), ('active','=',True)]` | 8 columns; drag-and-drop role changes; click card → user form |
|
||||||
|
| **Designated Officials** | Form on `res.company` | — | CGP DO + Nadcap Authority Many2one fields |
|
||||||
|
| **Role Reference** | QWeb static template | — | 8 cards with plain-English "can / cannot" per role |
|
||||||
|
| **Audit Log** | List on `mail.message` | `[('model','=','res.users'), ('subtype_id','=',mt_note), ('body','ilike','plating role')]` | 90-day role-change history |
|
||||||
|
|
||||||
|
All 4 tabs are separate `ir.actions.act_window` records reached via a tabbed notebook. Each has its own xmlid for direct linking.
|
||||||
|
|
||||||
|
### Active Team kanban — card layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ [avatar] Jane Doe │
|
||||||
|
│ jdoe@enplating.com │
|
||||||
|
│ Last seen: 2h ago │
|
||||||
|
│ ───────────────────────────── │
|
||||||
|
│ ⭐ CGP DO │ (only if user.id == company.x_fc_cgp_designated_official_id)
|
||||||
|
│ 🏆 Nadcap Authority │ (only if user.id == company.x_fc_nadcap_authority_user_id)
|
||||||
|
│ ───────────────────────────── │
|
||||||
|
│ Created: 2025-03-14 │
|
||||||
|
│ Last role change: 2026-05-01 │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Columns (left-to-right by sequence):
|
||||||
|
`No` · `Technician` · `Sales Rep` · `Shop Manager` · `Sales Manager` · `Manager` · `QM` · `Owner`
|
||||||
|
|
||||||
|
Folded by default: `No`, `Sales Rep` (less common in plating shops). Owner can unfold.
|
||||||
|
|
||||||
|
Search: name / email / department. Filters: Active (default), With Archived, Has Login Last 30 Days, Has Never Logged In.
|
||||||
|
|
||||||
|
### Designated Officials tab — single form
|
||||||
|
|
||||||
|
Form on `res.company` with two fields:
|
||||||
|
- `x_fc_cgp_designated_official_id` (Many2one res.users, domain `[QM, Owner]`)
|
||||||
|
- `x_fc_nadcap_authority_user_id` (Many2one res.users, domain `[QM, Owner]`)
|
||||||
|
|
||||||
|
Save posts to `res.company` chatter for auditability:
|
||||||
|
> "CGP Designated Official changed: Jane Doe → John Smith by owner@enplating.com on 2026-05-23."
|
||||||
|
|
||||||
|
### Role Reference tab — auto-generated
|
||||||
|
|
||||||
|
Single source of truth in `fusion_plating/models/res_users.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PLATING_ROLE_DESCRIPTIONS = {
|
||||||
|
'technician': {
|
||||||
|
'icon': 'fa-wrench',
|
||||||
|
'tagline': 'Runs the shop floor.',
|
||||||
|
'can': [
|
||||||
|
'See and operate the Workstation tablet',
|
||||||
|
'Start/finish/pause job steps',
|
||||||
|
'Capture quality checks and step inputs',
|
||||||
|
'Issue routine Certificates of Conformance',
|
||||||
|
'Log scrap and bake events',
|
||||||
|
],
|
||||||
|
'cannot': [
|
||||||
|
'See pricing, quotations, or sales orders',
|
||||||
|
'Edit recipes or process configurations',
|
||||||
|
'Override system gates (predecessor lock, signoff, bake window, etc.)',
|
||||||
|
'Approve CAPAs or sign FAIR/Nadcap certs',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ... 7 more entries
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
QWeb tab renders cards from this dict. Same dict used by the spec doc generator and by future onboarding wizards.
|
||||||
|
|
||||||
|
### What the page does NOT do
|
||||||
|
|
||||||
|
- ❌ No editing individual permissions (Q4 Interpretation B — killed)
|
||||||
|
- ❌ No custom role definitions
|
||||||
|
- ❌ No per-user exception flags
|
||||||
|
- ❌ No role hand-off workflows (transfer Bob's open jobs to Alice) — Phase 3
|
||||||
|
- ❌ No bulk import of roles — defer until 100+ employee shops
|
||||||
|
|
||||||
|
### Phase 2 hooks (designed-in, not built)
|
||||||
|
|
||||||
|
The 4-tab structure leaves room for:
|
||||||
|
- **Audit Dashboard** tab — read-only ACL matrix for CGP/Nadcap audit prep
|
||||||
|
- **Departure Handoff** tab — wizard for terminating users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5 — Migration Workflow
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
```python
|
||||||
|
# fusion_plating/models/fp_migration.py
|
||||||
|
|
||||||
|
class FpMigrationPreview(models.Model):
|
||||||
|
_name = 'fp.migration.preview'
|
||||||
|
_description = 'Fusion Plating Role Migration Preview'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char(default=lambda s: _('Migration %s') % fields.Datetime.now())
|
||||||
|
state = fields.Selection([
|
||||||
|
('pending', 'Pending Review'),
|
||||||
|
('approved', 'Approved & Applied'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
('rolled_back','Rolled Back'),
|
||||||
|
], default='pending', tracking=True)
|
||||||
|
line_ids = fields.One2many('fp.migration.preview.line', 'preview_id')
|
||||||
|
user_count = fields.Integer(compute='_compute_counts', store=True)
|
||||||
|
warning_count = fields.Integer(compute='_compute_counts', store=True)
|
||||||
|
approved_by_id = fields.Many2one('res.users', readonly=True)
|
||||||
|
approved_at = fields.Datetime(readonly=True)
|
||||||
|
rollback_deadline = fields.Datetime(compute='_compute_rollback_deadline')
|
||||||
|
|
||||||
|
class FpMigrationPreviewLine(models.Model):
|
||||||
|
_name = 'fp.migration.preview.line'
|
||||||
|
_description = 'Migration Preview Line'
|
||||||
|
|
||||||
|
preview_id = fields.Many2one('fp.migration.preview', required=True, ondelete='cascade')
|
||||||
|
user_id = fields.Many2one('res.users', required=True)
|
||||||
|
current_groups = fields.Char(compute='_compute_current_groups')
|
||||||
|
proposed_role = fields.Selection(_FP_ROLE_SELECTION)
|
||||||
|
capability_delta = fields.Char()
|
||||||
|
warning = fields.Boolean()
|
||||||
|
notes = fields.Text()
|
||||||
|
applied_groups_snapshot = fields.Text() # JSON of pre-migration groups_id for rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trigger — runs ONCE on `-u`, enters pending
|
||||||
|
|
||||||
|
```python
|
||||||
|
# fusion_plating/__manifest__.py
|
||||||
|
'post_init_hook': '_fp_post_init_role_migration',
|
||||||
|
|
||||||
|
# fusion_plating/__init__.py
|
||||||
|
def _fp_post_init_role_migration(env):
|
||||||
|
"""Idempotent: only creates a preview if one isn't already pending."""
|
||||||
|
pending = env['fp.migration.preview'].search([('state','=','pending')], limit=1)
|
||||||
|
if pending:
|
||||||
|
return
|
||||||
|
completed = env['fp.migration.preview'].search([('state','=','approved')], limit=1)
|
||||||
|
if completed:
|
||||||
|
users = env['res.users'].search(_fp_unmigrated_user_domain(env))
|
||||||
|
if not users:
|
||||||
|
return
|
||||||
|
preview = env['fp.migration.preview'].create({})
|
||||||
|
preview._fp_build_lines()
|
||||||
|
preview._fp_notify_owners()
|
||||||
|
```
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
1. **Idempotent** — `-u` re-runs don't duplicate previews
|
||||||
|
2. **Non-destructive** — only creates preview, never touches users
|
||||||
|
3. **Owner-gated** — actual migration only on Owner click
|
||||||
|
|
||||||
|
### Mapping table (in code)
|
||||||
|
|
||||||
|
```python
|
||||||
|
_FP_ROLE_MAPPING = [
|
||||||
|
# (predicate_fn, new_role, capability_delta_or_None)
|
||||||
|
(lambda u: u.id in (1, 2), 'owner', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating.group_fusion_plating_admin'), 'owner', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_designated_official'),
|
||||||
|
'owner', 'Was CGP DO; field set on res.company'),
|
||||||
|
(lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_officer'),
|
||||||
|
'quality_manager', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating.group_fusion_plating_manager'),
|
||||||
|
'manager', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating_configurator.group_fp_shop_manager'),
|
||||||
|
'manager', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating_invoicing.group_fp_accounting'),
|
||||||
|
'manager', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating_configurator.group_fp_estimator')
|
||||||
|
and not u.has_group('fusion_plating.group_fusion_plating_manager'),
|
||||||
|
'sales_rep', 'Loses order-confirm authority'), # ⚠️
|
||||||
|
(lambda u: u.has_group('fusion_plating.group_fusion_plating_supervisor'),
|
||||||
|
'shop_manager', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating_receiving.group_fp_receiving'),
|
||||||
|
'shop_manager', None),
|
||||||
|
(lambda u: u.has_group('fusion_plating.group_fusion_plating_operator'),
|
||||||
|
'technician', None),
|
||||||
|
(lambda u: True, 'no', None),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
First matching predicate wins (highest-precedence first).
|
||||||
|
|
||||||
|
### Preview screen UX
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Fusion Plating Role Migration — Preview │
|
||||||
|
│ Created: 2026-05-23 14:22 by system upgrade │
|
||||||
|
│ State: Pending Review │
|
||||||
|
│ │
|
||||||
|
│ Summary: │
|
||||||
|
│ • 28 users will be migrated │
|
||||||
|
│ • 2 will lose capabilities (highlighted ⚠️) │
|
||||||
|
│ • 1 will become CGP Designated Official (Jane Doe) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ User │ Current Groups │ → New Role │ Notes ││
|
||||||
|
│ ├──────────────────────────────────────────────────────────────────┤│
|
||||||
|
│ │ admin │ Administrator, … │ Owner │ ││
|
||||||
|
│ │ Jane Doe │ Manager, CGP DO │ Owner │ DO set ││
|
||||||
|
│ │ John Smith │ Estimator │ Sales Rep │ ⚠️ loses││
|
||||||
|
│ │ │ │ │ confirm ││
|
||||||
|
│ │ Carlos Lopez │ Operator │ Technician │ ││
|
||||||
|
│ │ Bob Chen │ Supervisor, Receiving │ Shop Mgr │ ││
|
||||||
|
│ │ … 23 more … ││
|
||||||
|
│ └──────────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
|
│ [ Approve & Run ] [ Cancel ] [ Export to CSV ] │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-line `Edit role to:` dropdown lets Owner override any auto-mapping inline before approving.
|
||||||
|
|
||||||
|
### Approve & Run
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_approve_and_run(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.env.user.has_group('fusion_plating.group_fp_owner'):
|
||||||
|
raise UserError(_('Only Owners can approve role migrations.'))
|
||||||
|
for line in self.line_ids:
|
||||||
|
user = line.user_id
|
||||||
|
line.applied_groups_snapshot = json.dumps(user.groups_id.ids)
|
||||||
|
old_group_ids = self.env['res.groups'].search([
|
||||||
|
('id', 'in', _FP_OLD_GROUP_IDS(self.env))]).ids
|
||||||
|
user.write({'groups_id': [(3, gid) for gid in old_group_ids]})
|
||||||
|
target_group = self.env.ref(_NEW_ROLE_XMLID[line.proposed_role])
|
||||||
|
if target_group:
|
||||||
|
user.write({'groups_id': [(4, target_group.id)]})
|
||||||
|
user.message_post(body=Markup(_(
|
||||||
|
'Plating role assigned by migration: <b>%s</b>'
|
||||||
|
)) % line.proposed_role, message_type='notification')
|
||||||
|
if line.notes and 'CGP DO' in line.notes:
|
||||||
|
user.company_id.x_fc_cgp_designated_official_id = user.id
|
||||||
|
self.write({
|
||||||
|
'state': 'approved',
|
||||||
|
'approved_by_id': self.env.user.id,
|
||||||
|
'approved_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback — 30-day undo
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_rollback(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'approved':
|
||||||
|
raise UserError(_('Only approved migrations can be rolled back.'))
|
||||||
|
if fields.Datetime.now() > self.rollback_deadline:
|
||||||
|
raise UserError(_('Rollback window has expired (30 days after approval).'))
|
||||||
|
for line in self.line_ids:
|
||||||
|
if line.applied_groups_snapshot:
|
||||||
|
old_ids = json.loads(line.applied_groups_snapshot)
|
||||||
|
line.user_id.write({'groups_id': [(6, 0, old_ids)]})
|
||||||
|
self.state = 'rolled_back'
|
||||||
|
|
||||||
|
def _cron_purge_expired_migrations(self):
|
||||||
|
deadline = fields.Datetime.now() - timedelta(days=30)
|
||||||
|
expired = self.search([
|
||||||
|
('state', '=', 'approved'),
|
||||||
|
('approved_at', '<', deadline)])
|
||||||
|
for preview in expired:
|
||||||
|
preview.line_ids.write({'applied_groups_snapshot': False})
|
||||||
|
self.env['res.groups'].browse(_FP_OLD_GROUP_IDS(self.env)).unlink()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Owner activity notification
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_notify_owners(self):
|
||||||
|
owners = self.env['res.users'].search([
|
||||||
|
('groups_id', 'in', self.env.ref('fusion_plating.group_fp_owner').ids)])
|
||||||
|
for owner in owners:
|
||||||
|
self.env['mail.activity'].create({
|
||||||
|
'res_model_id': self.env.ref('fusion_plating.model_fp_migration_preview').id,
|
||||||
|
'res_id': self.id,
|
||||||
|
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||||
|
'summary': _('Review Fusion Plating role migration'),
|
||||||
|
'note': _('A role migration is pending review. %d users affected, %d with capability changes.') % (
|
||||||
|
self.user_count, self.warning_count),
|
||||||
|
'user_id': owner.id,
|
||||||
|
'date_deadline': fields.Date.today(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Failure modes handled
|
||||||
|
|
||||||
|
1. Approver isn't Owner → UserError, no changes
|
||||||
|
2. Approver clicks twice → second click no-ops (state check)
|
||||||
|
3. User deleted between dry-run and approval → skipped, logged
|
||||||
|
4. New group xmlid missing → migration aborts at that line, logs warning
|
||||||
|
5. Rollback past 30 days → UserError, point Owner at Settings → Users
|
||||||
|
6. Multiple Owners approve simultaneously → record lock; second sees "Already approved"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (Phase 2+)
|
||||||
|
|
||||||
|
| Item | Why deferred | Trigger to build |
|
||||||
|
|---|---|---|
|
||||||
|
| Read-only Team page for Manager+ | Owner-only is sufficient for now; can add later as a filter+view variant | If Manager complains about not seeing org chart |
|
||||||
|
| Audit Dashboard (ACL matrix) | Useful for compliance audits but not blocking | First CGP/Nadcap audit preparation |
|
||||||
|
| Departure Handoff wizard | Useful when shop grows; one-off manual reassignment works for now | 50+ employee shop |
|
||||||
|
| Bulk CSV import of roles | Overkill for current shop size | 100+ employee shop |
|
||||||
|
| Per-permission override page (Interpretation B from Q4) | Killed — defeats the 8-role spec | Never — fundamentally against design |
|
||||||
|
| Sales Rep "own quotes only" record rule | Sales Reps see all quotes today; adding per-rep ownership is a separate feature | If client requests it |
|
||||||
|
| Role expiration / time-bound permissions | Out of scope for the consolidation | If contract-employee workflows emerge |
|
||||||
|
| Per-customer permission overrides | Out of scope | If multi-company tenancy is added |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
Phase 1 is complete when ALL of these are true on `entech`:
|
||||||
|
|
||||||
|
1. **Group inventory**: exactly 8 plating roles defined under the Fusion Plating privilege block, in the correct sequence order (10, 20, 30, 40, 50, 60, 70). No `_administrator` typo'd references remain in Python.
|
||||||
|
|
||||||
|
2. **Old groups archived**: `group_fusion_plating_operator`, `_supervisor`, `_manager`, `_admin`, `group_fp_estimator`, `_receiving`, `_accounting`, `_shop_manager`, `_cgp_officer`, `_cgp_designated_official`, `_legacy_menus` all set `active=False`. No user holds them post-migration.
|
||||||
|
|
||||||
|
3. **ACL coverage**: every `ir.model.access.csv` row that previously referenced an old plating group now references its mapped new group. `grep` for old group xmlids in CSV files returns zero results.
|
||||||
|
|
||||||
|
4. **Menu visibility**: opening Plating as each of the 7 roles shows the expected menu tree per Section 2.F. No "ghost" menus (visible but click → error).
|
||||||
|
|
||||||
|
5. **Landing resolver**: each role lands on the correct default action:
|
||||||
|
- Owner / Manager / QM → Manager Desk
|
||||||
|
- Sales Manager → Sale Orders
|
||||||
|
- Sales Rep → Quotations
|
||||||
|
- Shop Manager / Technician → Plant View Kanban (v2 layout) or Workstation (legacy)
|
||||||
|
- "No" user → company default → Sale Orders fallback
|
||||||
|
|
||||||
|
6. **Picklist contains 7 entries**, filtered per user's accessible actions.
|
||||||
|
|
||||||
|
7. **Team page reachable** at Plating → Configuration → Team. Drag-and-drop role change posts to user chatter. Visible to Owner only.
|
||||||
|
|
||||||
|
8. **Designated Officials field** on `res.company` set; CGP records gated to QM via ir.rule.
|
||||||
|
|
||||||
|
9. **Sales Manager + gate works**: Sales Rep saves SO in draft, sees no Confirm button, can't post via API. Sales Manager can confirm.
|
||||||
|
|
||||||
|
10. **Quality split works**: Manager can create/close NCRs but CAPAs are read-only for them. QM can close CAPAs, sign FAIR/Nadcap certs.
|
||||||
|
|
||||||
|
11. **Bypass flags**: Shop Manager cannot bypass any of the 9 gates; Manager can. Bypass posts chatter audit.
|
||||||
|
|
||||||
|
12. **Migration round-trip**: on a test DB, run `-u`, see pending preview, approve, see all users migrated, run rollback within 30 days, see all users restored to original groups.
|
||||||
|
|
||||||
|
13. **CLAUDE.md updated** with the new role names + which group implies which (canonical hierarchy doc).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Affected (high-level count)
|
||||||
|
|
||||||
|
| Module | Files changed | Type |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_plating` | ~15 | security XML, models (res.users, fp_migration), views (team page, settings), data (role-description dict), post-init hook, migration file |
|
||||||
|
| `fusion_plating_configurator` | ~8 | ACL CSV updates, view button gates, group XMLs to archive |
|
||||||
|
| `fusion_plating_invoicing` | ~3 | ACL CSV updates, group archive |
|
||||||
|
| `fusion_plating_receiving` | ~3 | ACL CSV updates, group archive |
|
||||||
|
| `fusion_plating_cgp` | ~5 | ACL CSV updates, ir.rule updates, group archives, ResCompany field for DO |
|
||||||
|
| `fusion_plating_quality` | ~6 | ACL CSV updates for QM/Manager split, ir.rules for FAIR/Nadcap, view button gates |
|
||||||
|
| `fusion_plating_aerospace` / `_nuclear` / `_safety` | ~3 each | ACL CSV updates (mechanical rename) |
|
||||||
|
| `fusion_plating_shopfloor` | ~3 | Landing resolver updates, picklist tagging on action_fp_manager_dashboard + action_fp_plant_kanban |
|
||||||
|
| `fusion_plating_jobs` | ~4 | Legacy menus group archive, ACL CSV updates |
|
||||||
|
| `fusion_plating_certificates` | ~2 | FAIR/Nadcap signing button gates |
|
||||||
|
|
||||||
|
**Estimated total: ~55 files**. Most are mechanical CSV updates (`grep`-and-replace pattern from Section 2.A).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes for entech
|
||||||
|
|
||||||
|
### Pre-deploy checklist
|
||||||
|
|
||||||
|
1. **Backup `admin` DB on entech** — full pg_dump before `-u` (rollback safety beyond the 30-day archive)
|
||||||
|
2. **Read `_FP_OLD_GROUP_IDS(env)` count** — log expected pre-migration group membership counts
|
||||||
|
3. **Confirm no other migration running** — `SELECT count(*) FROM fp_migration_preview WHERE state='pending';` returns 0
|
||||||
|
|
||||||
|
### Deploy command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
||||||
|
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
||||||
|
-u fusion_plating,fusion_plating_configurator,fusion_plating_invoicing,\
|
||||||
|
fusion_plating_receiving,fusion_plating_cgp,fusion_plating_quality,\
|
||||||
|
fusion_plating_aerospace,fusion_plating_nuclear,fusion_plating_safety,\
|
||||||
|
fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating_certificates \
|
||||||
|
--stop-after-init\" && systemctl start odoo'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Bump every module version to `+0.1.0` to ensure the migration scripts fire.
|
||||||
|
|
||||||
|
### Post-deploy verification
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Pending migration?
|
||||||
|
SELECT state, user_count, warning_count, create_date
|
||||||
|
FROM fp_migration_preview ORDER BY id DESC LIMIT 1;
|
||||||
|
|
||||||
|
-- Verify Owner activity scheduled
|
||||||
|
SELECT count(*) FROM mail_activity
|
||||||
|
WHERE res_model = 'fp.migration.preview'
|
||||||
|
AND date_deadline >= CURRENT_DATE;
|
||||||
|
```
|
||||||
|
|
||||||
|
Login as Owner → see activity in the home dashboard → click → review preview → approve.
|
||||||
|
|
||||||
|
### Post-approval verification
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- All users mapped to new roles?
|
||||||
|
SELECT u.login, ARRAY_AGG(g.name) AS groups
|
||||||
|
FROM res_users u
|
||||||
|
JOIN res_groups_users_rel r ON r.uid = u.id
|
||||||
|
JOIN res_groups g ON g.id = r.gid
|
||||||
|
WHERE g.privilege_id IS NOT NULL
|
||||||
|
GROUP BY u.id, u.login
|
||||||
|
ORDER BY u.login;
|
||||||
|
|
||||||
|
-- No one still holds old groups?
|
||||||
|
SELECT count(*) FROM res_groups_users_rel r
|
||||||
|
WHERE r.gid IN (SELECT id FROM res_groups WHERE name IN (
|
||||||
|
'Operator','Supervisor','Manager','Administrator','Estimator',
|
||||||
|
'Receiving','Accounting','Shop Manager','CGP Officer','CGP Designated Official'));
|
||||||
|
-- Expected: 0 (or low number of stale rows that the migration intentionally left)
|
||||||
|
|
||||||
|
-- CGP DO set?
|
||||||
|
SELECT name, x_fc_cgp_designated_official_id FROM res_company;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback plan
|
||||||
|
|
||||||
|
If migration goes wrong within 30 days:
|
||||||
|
1. Login as Owner → Plating → Configuration → migrations list (or direct URL `/odoo/action-fp.migration.preview`)
|
||||||
|
2. Click most recent approved migration
|
||||||
|
3. Click "Rollback" button → all users restored to pre-migration groups
|
||||||
|
4. Old plating groups remain active (archived after 30 days; rollback un-archives them)
|
||||||
|
|
||||||
|
If migration goes wrong AFTER 30 days (cron has purged):
|
||||||
|
1. Restore from pg_dump backup taken pre-deploy
|
||||||
|
2. File a follow-up issue to extend the rollback window if this happens repeatedly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Risks
|
||||||
|
|
||||||
|
1. **Inverse handler on `x_fc_plating_role`** must be robust against partial state. If a user holds NO plating group and gets assigned to `manager`, the inverse adds Manager group; the compute then reads `manager`. If a user holds BOTH `manager` and `technician` somehow (e.g., bug), compute should pick the higher one and the inverse should clean up. Unit tests required for: assign role with no prior role, assign role overwriting prior role, assign 'no' role (should clear all plating groups).
|
||||||
|
|
||||||
|
2. **Group rename window**: between archive of old groups and unlink (30 days), the old XMLIDs are still resolvable via `env.ref`. Code that hardcodes old xmlids will keep working accidentally — caught only when groups are finally deleted. **Mitigation:** add a deprecation log to the old groups' `_check_company_auto` or a model-load-time grep to flag any old-xmlid usage that survived the migration.
|
||||||
|
|
||||||
|
3. **Landing-page action visibility**: if a new role's hardcoded default action (e.g. `action_fp_manager_dashboard` for Manager) is itself gated by a different group, the resolver returns it but the user gets a permission error on render. **Mitigation:** the picklist domain filter (Section 3) already checks user accessibility. Apply the same check inside `_fp_role_default_landing` — if the role's default action isn't accessible, fall through to the next step instead of returning it.
|
||||||
|
|
||||||
|
4. **Mail-template references**: a few mail templates reference Manager / Estimator by xmlid (e.g., notification routing). These must be updated in the same deploy or chatter routing breaks. Grep all `mail_template_*.xml` for old group xmlids during implementation.
|
||||||
|
|
||||||
|
5. **CLAUDE.md drift**: after deploy, the role hierarchy in CLAUDE.md must be updated. If skipped, future sessions will reason from stale assumptions. Mandatory part of the implementation plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status & Next Steps
|
||||||
|
|
||||||
|
- ✅ Brainstorm complete (5 questions answered + Q4b menu-hiding policy)
|
||||||
|
- ✅ Design doc written
|
||||||
|
- ⏳ Self-review (next)
|
||||||
|
- ⏳ User review of this spec
|
||||||
|
- ⏳ Invoke `writing-plans` skill to create the implementation plan
|
||||||
|
- ⏳ Execute implementation per the plan
|
||||||
|
- ⏳ Deploy + verify on entech
|
||||||
|
- ⏳ Update CLAUDE.md with new role hierarchy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of design document.*
|
||||||
@@ -0,0 +1,779 @@
|
|||||||
|
# Shop Floor Plant View — Redesign
|
||||||
|
|
||||||
|
**Date:** 2026-05-23
|
||||||
|
**Status:** Design — approved through brainstorming, awaiting plan
|
||||||
|
**Replaces:** the current Shop Floor kanban (per-step grouping, one card per step)
|
||||||
|
**Affects:** `fusion_plating_shopfloor` (primary), `fusion_plating` (work centre taxonomy), `fusion_plating_jobs` (active-step + workflow-state computes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
The current Shop Floor kanban groups cards by individual `fp.job.step.work_centre_id`. Every ready/pending step of a job spawns a separate card in its respective column. A 14-step recipe (e.g. `ENP-ALUM-BASIC` on WO-30019) produces **9 cards across 9 columns for ONE job**. With 17 active jobs on the floor, the board shows 100+ cards across 10+ narrow columns, most of which contain duplicates of the same WO.
|
||||||
|
|
||||||
|
Confirmed by the user via screenshots taken 2026-05-23:
|
||||||
|
|
||||||
|
> "the same job is appearing in multiple places, there can be 20 steps in any job and we cannot just make 20 columns for those jobs"
|
||||||
|
|
||||||
|
Net effect:
|
||||||
|
- Operators can't scan the board — duplicates drown the signal
|
||||||
|
- Recipes with many steps (15+) make the board explode horizontally
|
||||||
|
- "Where is WO-30019 right now?" is impossible to answer at a glance
|
||||||
|
- The mode toggle (Station / All Plant) is cosmetic — both produce the same cluttered output
|
||||||
|
|
||||||
|
The redesign re-anchors the kanban on **one card per job** at the **department level**, and scales to any recipe step count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals & non-goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
1. **Every active fp.job appears in EXACTLY ONE column** at all times. No duplication.
|
||||||
|
2. **Fixed 9-column layout** that doesn't grow with recipe step count.
|
||||||
|
3. **Columns always render in process sequence** (Receiving → … → Shipping), regardless of card distribution. Empty columns still show.
|
||||||
|
4. **Operator paired to a station sees their work highlighted** but can also see the whole plant — "Where is everything right now?" is the central operator question.
|
||||||
|
5. **Every floor state the audit + battle-test catalog exposes is visually distinguishable on the card** (13 states total).
|
||||||
|
6. **Scales infinitely**: a 5-step recipe and a 30-step recipe both produce single cards moving across the same 9 columns.
|
||||||
|
7. **Tablet-first** — readable on a 1080p wall-mounted tablet without horizontal scroll.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **Replacing the Job Workspace** (the full-screen single-WO surface). The kanban is the entry point; the Workspace remains the place where work happens. Card tap opens the Workspace.
|
||||||
|
- **Replacing the Manager Dashboard** (`fp_manager_dashboard` with workflow funnel + at-risk + heatmap). The kanban's "Manager" mode is a filter on the same board; the dedicated dashboard stays separate.
|
||||||
|
- **Drag-and-drop step advancement** from the kanban. State transitions happen inside the Workspace or via Move dialogs. The kanban reflects state, doesn't drive it.
|
||||||
|
- **Per-tank columns**. Tanks are surfaced as chips on the card, not as columns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Decisions locked during brainstorming (2026-05-23)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| D1 | **Plant-wide view with mine highlighted** is the operator default (over "filter to my station only"). Operators help each other and cover stations; visibility matters more than filtering. |
|
||||||
|
| D2 | **9 fixed columns** by process area (Receiving, Masking, Blasting, Racking, Plating, Baking, De-Racking, Final inspection, Shipping). |
|
||||||
|
| D3 | **All wet steps roll up into Plating** — Soak Clean, Electroclean, Acid Dip, Etch, Desmut, Zincate, Rinse, Water Break Test, E-Nickel Plating, Chrome, Anodize, Black Oxide, Drying. The tank chip on the card distinguishes them. |
|
||||||
|
| D4 | **De-Masking folds into De-Racking** — same operator action in this shop's workflow; no separate column. |
|
||||||
|
| D5 | **Contract Review (paperwork) cards live in Receiving** with a purple paperwork chip. Same for any pre-physical-work admin gate. |
|
||||||
|
| D6 | **Variant C card design** — full-width vertical card with WO header, customer/PN/qty/PO line, recipe + spec, tag chips (Rush/FAIR/VIP), current step name, tank + state chip row, 9-column mini-timeline, progress bar + operator pill + icons. |
|
||||||
|
| D7 | **13 card states** distinguishable by background tint, left-border color, state chip text/color, and timeline marker color. Full catalog in §6. |
|
||||||
|
| D8 | **Columns appear in sequence and never reorder** — even empty columns show. The sequence is the visual mental model of the floor. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Column layout
|
||||||
|
|
||||||
|
### 4.1 Fixed column sequence
|
||||||
|
|
||||||
|
The board always renders these 9 columns in this exact order, left-to-right:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Receiving 2. Masking 3. Blasting 4. Racking 5. Plating
|
||||||
|
6. Baking 7. De-Racking 8. Final inspection 9. Shipping
|
||||||
|
```
|
||||||
|
|
||||||
|
Columns are first-class entities, not derived from data. If no jobs are in Blasting, the column still appears with a "0" badge — it's a placeholder reminding the operator where Blasting sits in the flow.
|
||||||
|
|
||||||
|
### 4.2 Step-kind → column mapping
|
||||||
|
|
||||||
|
Each `fp.job.step` routes to exactly one column based on its `recipe_node_id.default_kind`. The mapping table:
|
||||||
|
|
||||||
|
| Column | Step kinds routed here |
|
||||||
|
|---|---|
|
||||||
|
| **Receiving** | `incoming_inspection`, `contract_review`, `gating`, `ready_for_processing`, any step where `state = 'pending'` and the job's first physical step hasn't started |
|
||||||
|
| **Masking** | `masking` |
|
||||||
|
| **Blasting** | `blasting`, `bead_blast`, `media_blast` |
|
||||||
|
| **Racking** | `racking` |
|
||||||
|
| **Plating** | `soak_clean`, `electroclean`, `acid_dip`, `etch`, `desmut`, `zincate`, `rinse`, `water_break_test`, `e_nickel_plate`, `chrome`, `anodize`, `black_oxide`, `drying`, `activation`, any step whose `work_centre.kind = 'wet_line'` |
|
||||||
|
| **Baking** | `bake`, `oven_bake`, `post_bake_relief` |
|
||||||
|
| **De-Racking** | `de_rack`, `de_mask`, `unrack` |
|
||||||
|
| **Final inspection** | `post_plate_inspection`, `final_inspection`, `thickness_qc`, `fair`, `dimensional_check`, any step whose `work_centre.kind = 'inspect'` |
|
||||||
|
| **Shipping** | `shipping`, `pack_ship` |
|
||||||
|
|
||||||
|
### 4.3 Implementation — `area_kind` field
|
||||||
|
|
||||||
|
Add a new Selection field on `fp.work.centre`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
area_kind = fields.Selection([
|
||||||
|
('receiving', 'Receiving'),
|
||||||
|
('masking', 'Masking'),
|
||||||
|
('blasting', 'Blasting'),
|
||||||
|
('racking', 'Racking'),
|
||||||
|
('plating', 'Plating'),
|
||||||
|
('baking', 'Baking'),
|
||||||
|
('de_racking', 'De-Racking'),
|
||||||
|
('inspection', 'Final inspection'),
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
], string='Floor Column', help='Which Shop Floor column this work centre belongs to. Drives the plant-view kanban.')
|
||||||
|
```
|
||||||
|
|
||||||
|
`fp.job.step` already carries a `recipe_node_id` and (optionally) a `work_centre_id`. The kanban grouping resolves a step's column via:
|
||||||
|
|
||||||
|
```
|
||||||
|
step.area_kind = step.work_centre_id.area_kind
|
||||||
|
or _DEFAULT_KIND_BY_RECIPE_KIND.get(step.recipe_node_id.default_kind)
|
||||||
|
or 'plating' # safe catch-all for unmapped wet steps
|
||||||
|
```
|
||||||
|
|
||||||
|
A `post_init_hook` backfills `area_kind` on existing `fp.work.centre` records by matching their `kind` (`wet_line`/`bake`/`mask`/`rack`/`inspect`) against the new taxonomy. Unmapped centres get flagged for manual review.
|
||||||
|
|
||||||
|
### 4.4 Column visibility rules
|
||||||
|
|
||||||
|
- Always show all 9 columns in order.
|
||||||
|
- Show the column-header count even when zero (`0` in grey, less prominent).
|
||||||
|
- The operator's paired-station column gets a yellow tint + "📍 You're here" badge — see §7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Card design — Variant C
|
||||||
|
|
||||||
|
### 5.1 Anatomy
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ WO-30049 ⭐ Due May 16 · 3d │ ← WO + due
|
||||||
|
│ ABC Manufacturing │ ← customer
|
||||||
|
│ PN 9876699373 Rev A · Qty 5 · PO 4501882 │ ← part/qty/PO
|
||||||
|
│ Recipe: ENP-ALUM-BASIC · AMS-2404 Type II │ ← recipe + spec
|
||||||
|
│ [RUSH] [FAIR] │ ← tag chips
|
||||||
|
│ Racking │ ← current step name
|
||||||
|
│ [Rack Station 1] [● Ready] │ ← tank + state chips
|
||||||
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← mini-timeline
|
||||||
|
│ Rec Mask Blast [Rack] Plat Bake D-R Insp Ship│ ← timeline labels
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Step 4/14 ▓▓░░░░░░░░░ [GS] 🔏 │ ← progress + operator + icons
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Field-by-field
|
||||||
|
|
||||||
|
| Element | Source | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| WO # | `fp.job.display_wo_name` | Big, bold. `⭐` suffix appears when card is at operator's paired station. Tappable — opens Job Workspace. |
|
||||||
|
| Due date | `fp.job.commitment_date` | Format "Due May 16 · 3d" (relative). Turns red + `⚠` when overdue. |
|
||||||
|
| Customer | `fp.job.partner_id.name` | Single line; truncate with ellipsis if too long. |
|
||||||
|
| PN / Qty / PO | `fp.job.part_catalog_id.part_number` + `.revision` · `fp.job.qty` · `fp.job.sale_order_id.x_fc_po_number` | One line, comma-separated. |
|
||||||
|
| Recipe + spec | `fp.job.recipe_id.name` · `fp.job.customer_spec_id.code` | Muted small text. |
|
||||||
|
| Tag chips | derived | Multi: Rush (partner flag) / FAIR (customer_spec.x_fc_requires_first_article) / VIP (partner flag) / AS9100 (job aerospace flag). Only renders when applicable. |
|
||||||
|
| Current step name | `fp.job.active_step_id.name` or first ready step | Operator-facing label of the step the job is at. |
|
||||||
|
| Tank chip | `fp.job.active_step_id.work_centre_id.code` or `.tank_id.code` | Blue chip. Specific tank/station. |
|
||||||
|
| State chip | computed (§6) | One of 13 states. Color matches state. |
|
||||||
|
| Mini-timeline | derived (§8) | 9-step bar showing the journey across columns. |
|
||||||
|
| Step X / Y | `fp.job.active_step_id.sequence` / `count(fp.job.step_ids)` | Recipe progress, not the same as the 9-col timeline. |
|
||||||
|
| Progress bar | computed | Filled to `active_step.sequence / total_steps`. Color matches state. |
|
||||||
|
| Operator pill | `fp.job.active_step_id.assigned_user_id` | Initials avatar. Hidden when ready (no operator engaged yet). |
|
||||||
|
| Icon row | derived | Compact status flags (see §5.3). |
|
||||||
|
|
||||||
|
### 5.3 Icon row catalog
|
||||||
|
|
||||||
|
| Icon | Meaning | Trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| 🔏 | Sign-off required | `step.requires_signoff` AND not yet signed |
|
||||||
|
| ⏰ | Bake window approaching | upstream wet step done, `bake_required_by - now < 1h` |
|
||||||
|
| 🔥 | Bake compliance gate | active step kind = `bake` |
|
||||||
|
| 💬 | Recent chatter activity | `job.message_post` in last 24h |
|
||||||
|
| 🔒 | Predecessor locked | `step.requires_predecessor_done` AND upstream not done |
|
||||||
|
| 📋 | Required inputs unrecorded | `step._fp_missing_required_step_inputs()` returns non-empty |
|
||||||
|
| 📷 | Photo required but missing | step has a `photo` input prompt unrecorded |
|
||||||
|
| 🚚 | Inbound shipment tracking | `state = no_parts` AND `x_fc_receiving.x_fc_carrier_tracking` present |
|
||||||
|
| 📜 | Cert ready / issued | `state = done` AND `fp.certificate.state in ('issued','sent')` |
|
||||||
|
| ↳ | Jump to blocker | tappable; navigates to the predecessor step in the Workspace |
|
||||||
|
|
||||||
|
Icons only render when their condition is true. Max 3-4 visible per card; overflow into a `⋯` tooltip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Card states — exhaustive catalog
|
||||||
|
|
||||||
|
13 mutually-exclusive states, computed server-side per job. Each card carries exactly one state; precedence rules below resolve conflicts.
|
||||||
|
|
||||||
|
### 6.1 State definitions
|
||||||
|
|
||||||
|
| # | State | Background | Left border | State chip | Timeline marker | Triggered when |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 1 | `ready_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "● Ready to start" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND `active_step.work_centre_id IN operator_paired_stations` |
|
||||||
|
| 2 | `running_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "▶ Running 8m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND `active_step.work_centre_id IN operator_paired_stations` |
|
||||||
|
| 3 | `ready` | `#ffffff` (white) | none | "● Ready" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND NOT mine |
|
||||||
|
| 4 | `running` | `#ffffff` (white) | none | "▶ Running 3m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND NOT mine |
|
||||||
|
| 5 | `on_hold` | `#fff5f5` (red) | `#dc3545` (red, 4px) | "🔴 Quality Hold" (red) | `current.hold` (red) | `fusion.plating.quality.hold` exists on the job with `state = 'open'` |
|
||||||
|
| 6 | `predecessor_locked` | `#f8f9fa` (grey) | none | "🔒 Waiting on Blasting" (grey) | `current.locked` (grey) | `step._fp_should_block_predecessors()` returns True AND any earlier-sequence step not done/skipped/cancelled |
|
||||||
|
| 7 | `bake_due` | `#fff8e1` (orange) | `#ff9800` (orange, 4px) | "⏰ Bake window in 23m" (orange) | `current.bake` (orange) | `fusion.plating.bake.window` for this job has `bake_required_by - now < 1h` AND `state = 'awaiting_bake'` |
|
||||||
|
| 8 | `awaiting_signoff` | `#f5f0ff` (purple) | `#6f42c1` (purple, 4px) | "🔏 Awaiting QA sign-off" (purple) | `current.signoff` (purple) | `step.requires_signoff` AND `step.state = 'done'` AND `step.signoff_user_id IS NULL` (S22 gate) |
|
||||||
|
| 9 | `idle_warning` | `#fef9e7` (amber) | `#e6a800` (amber, 4px) | "⏸ Idle 14h · Carlos" (amber) | `current.idle` (amber) | `step.state = 'in_progress'` AND `now - step.last_activity_at > 8h` (S16 cron) |
|
||||||
|
| 10 | `awaiting_qc` | `#e7f5fc` (cyan) | `#17a2b8` (cyan, 4px) | "🔬 QC pending · 2/6 items" (cyan) | `current.qc` (cyan) | `fusion.plating.quality.check` exists with `state IN ('draft','in_progress')` AND no other higher-precedence state |
|
||||||
|
| 11 | `no_parts` | `#f5f5f5` (grey, dashed) | `#6c757d` (grey, 4px, dashed) | "📦 Parts in transit · 2d" (grey) | `current.no_parts` (grey) | `fp.job.state = 'confirmed'` AND inbound `fp.receiving.state = 'draft'` AND no step has started yet |
|
||||||
|
| 12 | `contract_review` | `#ffffff` (white) | none | "📋 QA-005 Awaiting QA Manager" (purple) | `current.paperwork` (purple) | `active_step.recipe_node_id.default_kind = 'contract_review'` AND not complete |
|
||||||
|
| 13 | `done` | `#f0f9f4` (green) | `#28a745` (green, 4px) | "✓ Ready for pickup" (green) | `current.done` (green) | active step is in `Shipping` column AND `fp.job.state = 'done'` |
|
||||||
|
|
||||||
|
### 6.2 Precedence rules
|
||||||
|
|
||||||
|
When multiple state triggers fire simultaneously, the resolver iterates through this **explicit precedence list** and takes the first match:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. no_parts (can't co-occur with anything else; checked first)
|
||||||
|
2. on_hold (compliance bomb — always wins over operational states)
|
||||||
|
3. awaiting_signoff (S22 gate — blocks advancement even when step.state='done')
|
||||||
|
4. awaiting_qc (quality gate — sticky until QC closes)
|
||||||
|
5. bake_due (time-sensitive compliance window)
|
||||||
|
6. predecessor_locked (soft block on a step that's data-ready but workflow-locked)
|
||||||
|
7. idle_warning (long-running supersedes plain running)
|
||||||
|
8. done (terminal state — only reached if none of the above apply)
|
||||||
|
9. contract_review (paperwork — used at job entry before physical work)
|
||||||
|
10. running_mine (more specific than running)
|
||||||
|
11. ready_mine (more specific than ready)
|
||||||
|
12. running (operational default for active work)
|
||||||
|
13. ready (operational default for next-up work)
|
||||||
|
```
|
||||||
|
|
||||||
|
The numeric ordering here is the dispatch order in `_fp_resolve_card_state`, not a severity ranking. Examples:
|
||||||
|
|
||||||
|
- Job both on-hold AND awaiting-signoff → `on_hold` (rule 2 fires before rule 3)
|
||||||
|
- Job both bake-due AND running_mine → `bake_due` (rule 5 fires before rule 10)
|
||||||
|
- Job in_progress for 14h at the operator's station → `idle_warning` (rule 7 fires before rule 10)
|
||||||
|
|
||||||
|
Implementation in §9.3 mirrors this list exactly — keep them synchronized.
|
||||||
|
|
||||||
|
### 6.3 Mine resolution
|
||||||
|
|
||||||
|
A card is "mine" when **any of the following** is true:
|
||||||
|
|
||||||
|
1. `active_step.work_centre_id.id IN operator.paired_work_centre_ids` (operator paired to that specific station)
|
||||||
|
2. `active_step.assigned_user_id == operator.id` (job is personally assigned to operator)
|
||||||
|
3. `active_step.area_kind == operator.preferred_area_kind` (operator's profile lists this department, for cross-trained operators)
|
||||||
|
|
||||||
|
For **MVP, use rule 1 only**. Rules 2-3 are post-MVP enhancements.
|
||||||
|
|
||||||
|
The `res.users.paired_work_centre_ids` is a Many2many on the data model (so it's forward-compatible with cross-trained operators), but the **MVP pairing UX keeps the existing single-station dropdown** (`fp_shopfloor_tech_store.currentStationId`). On unlock, the M2M holds exactly one record — the selected station. A Phase 2 enhancement adds a multi-select picker so cross-trained operators can pair to 2-4 stations at once; the resolver above already supports that without further code change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Sticky header
|
||||||
|
|
||||||
|
The header pins to the top of the kanban and remains visible during scroll.
|
||||||
|
|
||||||
|
### 7.1 Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🏭 Shop Floor [📍 Racking — Garry Singh ▾] [Station|All Plant|Manager]│
|
||||||
|
│ [📷 Scan QR] [🔓 Hand Off] [⚙] │
|
||||||
|
├────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [17 Active] [3 At My Station] [2 Bakes Due ≤2h] [1 On Hold] [2 Overdue]│
|
||||||
|
├────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [🔎 Search WO #, customer, part #, PO…] │
|
||||||
|
│ [All] [My Station] [Running] [Blocked] [Overdue] [FAIR] │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 KPI strip — 5 tiles, clickable filters
|
||||||
|
|
||||||
|
| Tile | Source | Click behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| **Active Jobs** | `count(fp.job WHERE state IN ('confirmed','in_progress'))` | Filter chip "All" → shows everything |
|
||||||
|
| **At My Station** | count of cards with `state IN ('ready_mine','running_mine')` | Filter chip "My Station" → only mine |
|
||||||
|
| **Bakes Due ≤2h** | count of cards with `state = 'bake_due'` AND `bake_required_by - now < 2h` | Highlights orange cards |
|
||||||
|
| **On Hold** | count of cards with `state = 'on_hold'` | Filter to red cards; clicking opens Quality Holds list |
|
||||||
|
| **Overdue** | count of cards where `commitment_date < today` AND `state != 'done'` | Filter to overdue |
|
||||||
|
|
||||||
|
Each tile is a button. Active tile shows a darker border + filled chip indicator.
|
||||||
|
|
||||||
|
### 7.3 Filter chips
|
||||||
|
|
||||||
|
Below KPIs, a row of toggleable filter chips. Multiple can be active (intersected with AND):
|
||||||
|
|
||||||
|
- **All** (default; clears others)
|
||||||
|
- **My Station** (cards where `state IN ('ready_mine','running_mine')`)
|
||||||
|
- **Running** (`active_step.state = 'in_progress'`)
|
||||||
|
- **Blocked** (`state IN ('on_hold','predecessor_locked','awaiting_signoff','awaiting_qc','no_parts')`)
|
||||||
|
- **Overdue** (`commitment_date < today` AND `state != 'done'`)
|
||||||
|
- **FAIR** (partner or spec requires FAIR; flagged via tag)
|
||||||
|
|
||||||
|
Chip state persists per operator per browser session (localStorage), so an operator who always filters to "My Station" doesn't have to re-set it each shift.
|
||||||
|
|
||||||
|
### 7.4 Station picker
|
||||||
|
|
||||||
|
The `[📍 Racking — Garry Singh ▾]` button:
|
||||||
|
- Shows the operator's current paired station + their name
|
||||||
|
- Dropdown lets them switch to a different station they're certified on (from their `paired_work_centre_ids`)
|
||||||
|
- "All stations" option clears pairing
|
||||||
|
- Disabled when the operator hasn't signed in (lock screen takes precedence)
|
||||||
|
|
||||||
|
### 7.5 Mode toggle
|
||||||
|
|
||||||
|
Three modes:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| **Station** | Cards at the paired station's column get the yellow `mine` treatment. Column header shows "📍 You're here". Other columns visible but neutral. |
|
||||||
|
| **All Plant** | No "mine" highlight anywhere. Pure plant overview. Use case: supervisor walking the floor without paired station. |
|
||||||
|
| **Manager** | Same as All Plant + adds bottleneck heatmap row at top (`fp.work.centre.bottleneck_score` driven). KPI strip swaps to manager-specific tiles (Late Risk, Avg Wait, etc.). |
|
||||||
|
|
||||||
|
Manager mode is gated by `fusion_plating.group_fusion_plating_manager`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Mini-timeline derivation
|
||||||
|
|
||||||
|
The 9-step bar on each card is **not** the recipe step count — it's a fixed 9-element array keyed by the 9 columns. Logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _compute_mini_timeline(self):
|
||||||
|
"""Returns list of 9 dicts, one per column, with state in {'done','current','upcoming','hold','locked','bake','signoff','idle','qc','no_parts','done','paperwork'}."""
|
||||||
|
timeline = []
|
||||||
|
job_steps = self.step_ids.sorted('sequence')
|
||||||
|
active = self.active_step_id
|
||||||
|
active_area = active.area_kind if active else None
|
||||||
|
for area in COLUMN_SEQUENCE: # ['receiving', 'masking', 'blasting', ...]
|
||||||
|
steps_in_area = job_steps.filtered(lambda s: s.area_kind == area)
|
||||||
|
if not steps_in_area:
|
||||||
|
# area not used by this recipe — still show as 'upcoming' to keep alignment
|
||||||
|
timeline.append({'area': area, 'state': 'upcoming'})
|
||||||
|
continue
|
||||||
|
if all(s.state in ('done', 'skipped') for s in steps_in_area):
|
||||||
|
timeline.append({'area': area, 'state': 'done'})
|
||||||
|
elif area == active_area:
|
||||||
|
# The card's state determines the current marker color
|
||||||
|
timeline.append({'area': area, 'state': 'current', 'variant': self.card_state})
|
||||||
|
else:
|
||||||
|
timeline.append({'area': area, 'state': 'upcoming'})
|
||||||
|
return timeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Recipes that skip a column (e.g. a job that doesn't need Masking) still render that column slot as "upcoming" grey — visual alignment matters more than perfect accuracy.
|
||||||
|
- The `variant` field on the current marker tells the renderer which color to use (matches the card-state color: yellow / red / orange / purple / etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Backend changes
|
||||||
|
|
||||||
|
### 9.1 New / modified fields
|
||||||
|
|
||||||
|
| Model | Field | Type | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `fp.work.centre` | `area_kind` | Selection (9 values) | Routes each work centre to one of the 9 columns |
|
||||||
|
| `fp.job.step` | `area_kind` | Char, computed, stored, indexed | Related from `work_centre_id.area_kind` with fallback to `recipe_node_id.default_kind` lookup |
|
||||||
|
| `fp.job` | `card_state` | Char, computed, stored, indexed | The 13-state classifier; computed via `_compute_card_state` with the precedence rules in §6.2 |
|
||||||
|
| `fp.job` | `mini_timeline_json` | Text, computed | JSON-serialized output of `_compute_mini_timeline` |
|
||||||
|
| `fp.job.step` | `last_activity_at` | Datetime, indexed | Updated on any state transition / move / chatter post; drives idle-warning detection (S16) |
|
||||||
|
| `res.users` | `paired_work_centre_ids` | M2M `fp.work.centre` | Operator's certified stations; resolved on PIN unlock |
|
||||||
|
|
||||||
|
`area_kind` Selection values (used by both `fp.work.centre` and `fp.job.step`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
COLUMN_SEQUENCE = [
|
||||||
|
('receiving', 'Receiving'),
|
||||||
|
('masking', 'Masking'),
|
||||||
|
('blasting', 'Blasting'),
|
||||||
|
('racking', 'Racking'),
|
||||||
|
('plating', 'Plating'),
|
||||||
|
('baking', 'Baking'),
|
||||||
|
('de_racking', 'De-Racking'),
|
||||||
|
('inspection', 'Final inspection'),
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 New endpoint — `/fp/landing/plant_kanban`
|
||||||
|
|
||||||
|
Replaces the existing `/fp/landing/kanban`. Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"mode": "station",
|
||||||
|
"paired_station": {"id": 12, "name": "Rack Station 1", "area_kind": "racking"},
|
||||||
|
"kpis": {
|
||||||
|
"active_jobs": 17,
|
||||||
|
"at_my_station": 3,
|
||||||
|
"bakes_due_soon": 2,
|
||||||
|
"on_hold": 1,
|
||||||
|
"overdue": 2
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"area_kind": "receiving",
|
||||||
|
"label": "Receiving",
|
||||||
|
"is_mine": false,
|
||||||
|
"card_ids": [2885, 2886, 2887]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area_kind": "masking",
|
||||||
|
"label": "Masking",
|
||||||
|
"is_mine": false,
|
||||||
|
"card_ids": [2884]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"cards": {
|
||||||
|
"2885": {
|
||||||
|
"wo_name": "WO-30049",
|
||||||
|
"is_mine": true,
|
||||||
|
"card_state": "ready_mine",
|
||||||
|
"due_date": "2026-05-16",
|
||||||
|
"due_label": "Due May 16 · 3d",
|
||||||
|
"is_overdue": false,
|
||||||
|
"customer": "ABC Manufacturing",
|
||||||
|
"part_number": "9876699373",
|
||||||
|
"part_revision": "A",
|
||||||
|
"qty": 5,
|
||||||
|
"po_number": "4501882",
|
||||||
|
"recipe_name": "ENP-ALUM-BASIC",
|
||||||
|
"spec_code": "AMS-2404 Type II",
|
||||||
|
"tags": ["rush", "fair"],
|
||||||
|
"step_name": "Racking",
|
||||||
|
"step_seq": 4,
|
||||||
|
"step_total": 14,
|
||||||
|
"tank_label": "Rack Station 1",
|
||||||
|
"state_chip": {"label": "● Ready to start", "kind": "ready"},
|
||||||
|
"operator": {"id": null, "name": null, "initials": null},
|
||||||
|
"duration_label": null,
|
||||||
|
"icons": ["signoff_required"],
|
||||||
|
"mini_timeline": [
|
||||||
|
{"area": "receiving", "state": "done"},
|
||||||
|
{"area": "masking", "state": "done"},
|
||||||
|
{"area": "blasting", "state": "done"},
|
||||||
|
{"area": "racking", "state": "current", "variant": "ready_mine"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Design choices:
|
||||||
|
- **Two-tier structure** (`columns` + `cards`) keeps payload small when 2 cards happen to be at the same step — no per-column-per-card duplication.
|
||||||
|
- **`card_state` is server-computed** — frontend just maps state → CSS class.
|
||||||
|
- **`mini_timeline` is server-computed** — frontend renders the 9 dots without knowing the recipe shape.
|
||||||
|
- **Operator info is denormalized** — initials, name, color hash all in the payload so the frontend doesn't fan out RPCs.
|
||||||
|
|
||||||
|
### 9.3 State computation — `_compute_card_state`
|
||||||
|
|
||||||
|
Matches the precedence list in §6.2 exactly. Both must stay in sync.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _compute_card_state(self):
|
||||||
|
for job in self:
|
||||||
|
# Edge: job has no active step (all pending or all done)
|
||||||
|
if not job.active_step_id:
|
||||||
|
# rule 1
|
||||||
|
if job.state == 'confirmed' and job._fp_inbound_not_received():
|
||||||
|
job.card_state = 'no_parts'
|
||||||
|
else:
|
||||||
|
# Fallback to first pending step's kind; otherwise contract_review
|
||||||
|
job.card_state = 'contract_review'
|
||||||
|
continue
|
||||||
|
|
||||||
|
step = job.active_step_id
|
||||||
|
|
||||||
|
# rule 1 — no_parts (even with an active step, if inbound is still draft)
|
||||||
|
if job._fp_inbound_not_received():
|
||||||
|
job.card_state = 'no_parts'
|
||||||
|
continue
|
||||||
|
# rule 2 — on_hold
|
||||||
|
if job._fp_has_open_hold():
|
||||||
|
job.card_state = 'on_hold'
|
||||||
|
continue
|
||||||
|
# rule 3 — awaiting_signoff (S22)
|
||||||
|
if (step.requires_signoff and step.state == 'done'
|
||||||
|
and not step.signoff_user_id):
|
||||||
|
job.card_state = 'awaiting_signoff'
|
||||||
|
continue
|
||||||
|
# rule 4 — awaiting_qc
|
||||||
|
if job._fp_has_pending_qc():
|
||||||
|
job.card_state = 'awaiting_qc'
|
||||||
|
continue
|
||||||
|
# rule 5 — bake_due
|
||||||
|
if job._fp_bake_window_due_soon():
|
||||||
|
job.card_state = 'bake_due'
|
||||||
|
continue
|
||||||
|
# rule 6 — predecessor_locked
|
||||||
|
if (step._fp_should_block_predecessors()
|
||||||
|
and step._fp_has_unfinished_predecessors()):
|
||||||
|
job.card_state = 'predecessor_locked'
|
||||||
|
continue
|
||||||
|
# rule 7 — idle_warning (S16)
|
||||||
|
if step.state == 'in_progress' and step._fp_is_idle(threshold_hours=8):
|
||||||
|
job.card_state = 'idle_warning'
|
||||||
|
continue
|
||||||
|
# rule 8 — done (terminal, only reached when nothing above fires)
|
||||||
|
if step.area_kind == 'shipping' and job.state == 'done':
|
||||||
|
job.card_state = 'done'
|
||||||
|
continue
|
||||||
|
# rule 9 — contract_review
|
||||||
|
if step.recipe_node_id.default_kind == 'contract_review':
|
||||||
|
job.card_state = 'contract_review'
|
||||||
|
continue
|
||||||
|
# rules 10/12 — running (mine vs not)
|
||||||
|
if step.state == 'in_progress':
|
||||||
|
job.card_state = ('running_mine' if job._fp_is_mine()
|
||||||
|
else 'running')
|
||||||
|
continue
|
||||||
|
# rules 11/13 — ready (mine vs not)
|
||||||
|
if step.state == 'ready':
|
||||||
|
job.card_state = ('ready_mine' if job._fp_is_mine()
|
||||||
|
else 'ready')
|
||||||
|
continue
|
||||||
|
# Safe default
|
||||||
|
job.card_state = 'ready'
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `_fp_*` helper is a small method on `fp.job` (or `fp.job.step`) that encapsulates one precedence check. Centralizing them this way means future audits can extend the catalog without touching the dispatch.
|
||||||
|
|
||||||
|
### 9.4 Helpers
|
||||||
|
|
||||||
|
| Helper | Returns | Source data |
|
||||||
|
|---|---|---|
|
||||||
|
| `_fp_inbound_not_received()` | bool | `fp.receiving` linked via SO; `state = 'draft'` |
|
||||||
|
| `_fp_has_open_hold()` | bool | `fusion.plating.quality.hold` with `state = 'open'` linked via `job_id` |
|
||||||
|
| `_fp_has_pending_qc()` | bool | `fusion.plating.quality.check` with `state IN ('draft','in_progress')` linked via `job_id` |
|
||||||
|
| `_fp_bake_window_due_soon()` | bool | `fusion.plating.bake.window` linked, `bake_required_by - now < 1h`, `state = 'awaiting_bake'` |
|
||||||
|
| `step._fp_is_idle(threshold_hours=8)` | bool | `now - last_activity_at > threshold` |
|
||||||
|
| `_fp_is_mine()` | bool | `active_step.work_centre_id IN env.user.paired_work_centre_ids` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Frontend changes
|
||||||
|
|
||||||
|
### 10.1 OWL component structure
|
||||||
|
|
||||||
|
New / modified files in `fusion_plating_shopfloor/static/src/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
js/
|
||||||
|
plant_kanban.js (new — replaces shopfloor_landing.js)
|
||||||
|
components/
|
||||||
|
plant_card.js (new — Variant C card component)
|
||||||
|
mini_timeline.js (new — 9-step horizontal bar)
|
||||||
|
column_header.js (new — column header with "📍 You're here" badge)
|
||||||
|
kpi_tile.js (new — clickable KPI button)
|
||||||
|
filter_chip.js (new — toggleable filter chip)
|
||||||
|
xml/
|
||||||
|
plant_kanban.xml (new)
|
||||||
|
components/
|
||||||
|
plant_card.xml (new)
|
||||||
|
mini_timeline.xml (new)
|
||||||
|
column_header.xml (new)
|
||||||
|
kpi_tile.xml (new)
|
||||||
|
filter_chip.xml (new)
|
||||||
|
scss/
|
||||||
|
plant_kanban.scss (new — board layout + sticky header)
|
||||||
|
components/
|
||||||
|
_plant_card.scss (new — 13 card-state styles)
|
||||||
|
_mini_timeline.scss (new — timeline dots)
|
||||||
|
_column_header.scss (new)
|
||||||
|
_kpi_tile.scss (new)
|
||||||
|
_filter_chip.scss (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Component tree
|
||||||
|
|
||||||
|
```
|
||||||
|
FpPlantKanban (top-level client action)
|
||||||
|
├── FpTabletLock (existing wrapper for PIN gate)
|
||||||
|
└── (when unlocked)
|
||||||
|
├── PlantHeader
|
||||||
|
│ ├── StationPicker
|
||||||
|
│ ├── ModeToggle
|
||||||
|
│ ├── ToolbarButtons (Scan / Hand Off / Settings)
|
||||||
|
│ ├── KpiStrip (5 × KpiTile)
|
||||||
|
│ └── FilterRow (search input + 6 × FilterChip)
|
||||||
|
└── Board
|
||||||
|
└── 9 × Column
|
||||||
|
├── ColumnHeader
|
||||||
|
└── PlantCard[]
|
||||||
|
├── CardHeader (WO, due)
|
||||||
|
├── CardBody (customer, PN, recipe, tags)
|
||||||
|
├── CardStep (step name + chips)
|
||||||
|
├── MiniTimeline
|
||||||
|
└── CardFooter (progress + operator + icons)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Card state CSS
|
||||||
|
|
||||||
|
All 13 states share the base `.plant-card` class with state-specific modifier classes:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.plant-card {
|
||||||
|
background: $card-bg;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
// ... base layout
|
||||||
|
|
||||||
|
&.state-ready_mine, &.state-running_mine {
|
||||||
|
background: #fffaeb;
|
||||||
|
border-left: 4px solid #f0a500;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
&.state-on_hold {
|
||||||
|
background: #fff5f5;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
&.state-bake_due {
|
||||||
|
background: #fff8e1;
|
||||||
|
border-left: 4px solid #ff9800;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
&.state-awaiting_signoff {
|
||||||
|
background: #f5f0ff;
|
||||||
|
border-left: 4px solid #6f42c1;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
&.state-idle_warning {
|
||||||
|
background: #fef9e7;
|
||||||
|
border-left: 4px solid #e6a800;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
&.state-awaiting_qc {
|
||||||
|
background: #e7f5fc;
|
||||||
|
border-left: 4px solid #17a2b8;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
&.state-predecessor_locked {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
&.state-no_parts {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px dashed #999;
|
||||||
|
border-left: 4px solid #6c757d;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
&.state-done {
|
||||||
|
background: #f0f9f4;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding-left: 9px;
|
||||||
|
}
|
||||||
|
// state-ready, state-running, state-contract_review: default neutral white
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dark-mode SCSS branch follows the project pattern (`$o-webclient-color-scheme == dark` block) with adjusted hex values.
|
||||||
|
|
||||||
|
### 10.4 Auto-refresh
|
||||||
|
|
||||||
|
Polling every 10s via `setInterval`. On each tick:
|
||||||
|
1. Fetch `/fp/landing/plant_kanban` with current mode + filter state in the request payload.
|
||||||
|
2. Diff against current state.
|
||||||
|
3. Apply changes to OWL reactive state — cards that moved columns animate the transition (fade-out from old column, fade-in at new column over 200ms).
|
||||||
|
|
||||||
|
Hand-Off, mode toggle, station-picker, and filter chip changes trigger an immediate refresh.
|
||||||
|
|
||||||
|
### 10.5 Card tap behavior
|
||||||
|
|
||||||
|
Single tap on a card → opens Job Workspace (`fp_job_workspace` client action) with the WO pre-loaded. No quick-action sheet on tablet (would compete with the Workspace's own action rail).
|
||||||
|
|
||||||
|
Card has a small "ℹ" icon in the top-right that opens a quick-info popover (for supervisor walk-bys who want details without leaving the kanban). Post-MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Migration & rollout
|
||||||
|
|
||||||
|
### 11.1 Database migration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# fusion_plating/migrations/19.0.21.0.0/post-migrate.py
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
"""Backfill fp.work.centre.area_kind from existing kind values."""
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_work_centre
|
||||||
|
SET area_kind = CASE kind
|
||||||
|
WHEN 'wet_line' THEN 'plating'
|
||||||
|
WHEN 'bake' THEN 'baking'
|
||||||
|
WHEN 'mask' THEN 'masking'
|
||||||
|
WHEN 'rack' THEN 'racking'
|
||||||
|
WHEN 'inspect' THEN 'inspection'
|
||||||
|
ELSE 'plating'
|
||||||
|
END
|
||||||
|
WHERE area_kind IS NULL
|
||||||
|
""")
|
||||||
|
# Log unmapped centres for manual review
|
||||||
|
cr.execute("""
|
||||||
|
SELECT id, name FROM fp_work_centre WHERE area_kind IS NULL
|
||||||
|
""")
|
||||||
|
for row in cr.fetchall():
|
||||||
|
_logger.warning("Work centre %s (%s) has no area_kind — defaulted to 'plating'", row[0], row[1])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Feature flag
|
||||||
|
|
||||||
|
New config setting `x_fc_shopfloor_layout` on `res.config.settings`:
|
||||||
|
- `legacy` (default during rollout) — existing landing
|
||||||
|
- `v2` — new plant view
|
||||||
|
|
||||||
|
Once validated on entech, default flips to `v2` and legacy code can be removed in a follow-up cleanup.
|
||||||
|
|
||||||
|
The client action `fp_shopfloor_landing` resolver chooses which OWL component to mount based on this setting.
|
||||||
|
|
||||||
|
### 11.3 Rollout sequence
|
||||||
|
|
||||||
|
1. Ship migration + backend (`area_kind`, `card_state`, `mini_timeline_json`, helpers, endpoint) under the v2 flag.
|
||||||
|
2. Ship OWL components under the v2 flag. Both screens coexist.
|
||||||
|
3. QA on entech: flip `x_fc_shopfloor_layout = 'v2'`, validate end-to-end.
|
||||||
|
4. Run battle-test scenarios (S1-S23) against the new view to confirm no regression.
|
||||||
|
5. Flip default to `v2` site-wide.
|
||||||
|
6. After 2 weeks of stable v2, remove legacy code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Testing strategy
|
||||||
|
|
||||||
|
### 12.1 Unit tests
|
||||||
|
|
||||||
|
- `test_card_state_computation` — for each of the 13 states, construct an `fp.job` in that exact data shape, assert `card_state` resolves correctly
|
||||||
|
- `test_card_state_precedence` — overlay multiple triggers (e.g. on-hold + bake-due), assert precedence rules produce the documented winner
|
||||||
|
- `test_area_kind_routing` — for each step kind in the mapping table, assert it routes to the correct column
|
||||||
|
- `test_mini_timeline` — for a 14-step recipe at various points, assert the 9-element output matches expectations (including skipped columns rendered as upcoming)
|
||||||
|
- `test_one_card_per_job_invariant` — across a realistic 17-job board, assert no two entries in `cards{}` share the same `fp.job.id`
|
||||||
|
|
||||||
|
### 12.2 Persona walks
|
||||||
|
|
||||||
|
Re-run the battle-test scenarios that drove this redesign:
|
||||||
|
|
||||||
|
- **S20 walk** — operator persona traversal of the tablet. Confirm: card density readable, "mine" highlight obvious, can find a specific WO in <5s via search.
|
||||||
|
- **S22 / S23 simulations** — finish a step that needs sign-off / transition form, confirm the card transitions to `awaiting_signoff` / `awaiting_qc` state correctly.
|
||||||
|
- **20-step-recipe regression** — load a synthetic job with 25+ recipe steps, confirm it occupies one and only one card on the board.
|
||||||
|
|
||||||
|
### 12.3 Visual snapshot tests
|
||||||
|
|
||||||
|
Per state, a Playwright/headless-chromium snapshot of a single card at fixed viewport. Diff against checked-in golden images on every PR. Catches accidental CSS regressions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Open questions (deferred)
|
||||||
|
|
||||||
|
These don't block MVP but should be tracked for the follow-up plan.
|
||||||
|
|
||||||
|
| # | Question | Suggested resolution |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | Drag-and-drop card between columns? | **No for MVP.** State transitions happen via the Workspace action rail or Move dialogs. The kanban reflects state, doesn't drive it. |
|
||||||
|
| Q2 | Empty-column auto-collapse? | **No.** Column position = mental model. Collapsing breaks the sequence. |
|
||||||
|
| Q3 | Sort within column? | **MVP: most urgent first** — overdue → bake-due → ready → running → idle → locked → done. Post-MVP: operator-toggleable. |
|
||||||
|
| Q4 | Card tap → quick-action sheet vs. open Workspace? | **MVP: open Workspace.** Quick-action sheet is a post-MVP enhancement. |
|
||||||
|
| Q5 | Manager mode KPI tile swap? | **Phase 2.** MVP ships with the same 5 KPI tiles in all modes. Phase 2 adds manager-specific tiles (late-risk %, avg wait per station, bottleneck score). |
|
||||||
|
| Q6 | Sibling jobs (WO-30029-01 / -02) visual grouping? | **No special treatment for MVP.** Each is its own card. If siblings clutter, post-MVP adds a "group siblings" toggle. |
|
||||||
|
| Q7 | Bottleneck heatmap row in manager mode? | **Phase 2.** Reuses existing `fp.work.centre.bottleneck_score`. |
|
||||||
|
| Q8 | Mobile (phone) breakpoint? | **Phase 2.** MVP optimized for 1080p tablet. Phone view = collapse to single-column scroll. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Summary
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Layout | 9 fixed columns in sequence (Receiving → … → Shipping) |
|
||||||
|
| Card model | One card per `fp.job`, always in the column matching the active step's `area_kind` |
|
||||||
|
| Card density | Variant C — full info with mini-timeline |
|
||||||
|
| State catalog | 13 mutually-exclusive states with precedence rules |
|
||||||
|
| Operator focus | Plant-wide view, paired-station column + "mine" cards highlighted |
|
||||||
|
| Backend touch | New `area_kind` Selection, new `card_state` compute, new `/fp/landing/plant_kanban` endpoint |
|
||||||
|
| Frontend touch | New OWL component tree under `fp_plant_kanban` client action |
|
||||||
|
| Rollout | Feature flag `x_fc_shopfloor_layout`, parallel deployment, flip default after entech validation |
|
||||||
|
| Recipe-step scaling | Doesn't matter — 5-step or 50-step recipes both produce one card moving across 9 fixed columns |
|
||||||
|
|
||||||
|
The redesign solves the "one job in N columns" problem by re-anchoring grouping at the department level and decoupling the kanban from recipe step count. Every floor scenario in the audit + battle-test catalog (S1-S23) maps to one of the 13 documented states.
|
||||||
|
|
||||||
|
Implementation plan to follow.
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# Recipe Cleanup + Receiving Enforcement
|
||||||
|
|
||||||
|
**Date:** 2026-05-24
|
||||||
|
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
|
||||||
|
**Status:** Approved, awaiting implementation plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
User created SO-30057, confirmed it, and the resulting WO-30057 went **straight to the Plating column** on the Shop Floor board — skipping Receiving entirely. The card-state was `no_parts` (correctly: parts hadn't arrived yet) but the column resolved to `plating`, so:
|
||||||
|
|
||||||
|
- The receiver, who watches the Receiving column, never sees the job
|
||||||
|
- The Masking operator sees a card they can't start
|
||||||
|
- The parts physically can't move forward because nobody knows they need to be received
|
||||||
|
|
||||||
|
The auto-complete contract-review logic (`_fp_autocomplete_repeat_order_contract_review`) is **NOT the bug** — it correctly marks Contract Review as done when the part has a complete QA-005 history. The real problems are deeper.
|
||||||
|
|
||||||
|
## Root causes
|
||||||
|
|
||||||
|
### Root cause 1 — `ENP-ALUM-BASIC` (id 3620) has DUPLICATE SEQUENCES
|
||||||
|
|
||||||
|
```
|
||||||
|
seq 10: Contract Review (id 3853, kind=contract_review)
|
||||||
|
seq 10: Masking (id 3877, kind=mask) ← TIE
|
||||||
|
seq 20: Incoming Insp. (id 3854, kind=receiving)
|
||||||
|
seq 20: Racking (id 3855, kind=racking) ← TIE
|
||||||
|
seq 40: ENP-Alum Line (id 3859, sub_process, has E-Nickel Plating child)
|
||||||
|
seq 40: ENP-Alum Line (id 4056, sub_process, empty) ← DUPLICATE
|
||||||
|
seq 50: De-Masking
|
||||||
|
seq 60: Oven baking
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
When this base recipe is cloned per-part by the configurator (`fp.process.node.copy()`), tied sequences resolve by id. So in the clone:
|
||||||
|
|
||||||
|
- Position 10: Contract Review (id 3853 < id 3877 → wins)
|
||||||
|
- Position 20: Masking (the second one at 10 → promoted to 20)
|
||||||
|
- Position 30: Incoming Inspection (one of the seq-20 ties → promoted to 30)
|
||||||
|
- Position 40: Racking (the other seq-20 → promoted to 40)
|
||||||
|
|
||||||
|
After Contract Review auto-completes, the live step is **Masking** (kind=mask, area=masking) — which our prior live-step fix routes to the Masking column, not Receiving. The clone for WO-30057 (recipe 4649) followed exactly this pattern.
|
||||||
|
|
||||||
|
### Root cause 2 — 24 per-part clone recipes accumulated, all carrying the broken ordering
|
||||||
|
|
||||||
|
Each clone is its own `fusion.plating.process.node` row with `node_type='recipe'` and a name like `BASE_NAME — PART_NUMBER Rev X`. There are 24 such clones on entech. Several are referenced by historical jobs (24 cancelled + 7 done jobs use them), but all those jobs are terminal — none are in-flight.
|
||||||
|
|
||||||
|
### Root cause 3 — ~10 nodes across base recipes still have `kind=other`
|
||||||
|
|
||||||
|
Mostly niche names the existing `fp_resolve_step_kind()` resolver doesn't know:
|
||||||
|
|
||||||
|
| Recipe | Node | Currently | Should be |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3645 ENP-STEEL-MP-BASIC | Blasting (If Required) | other | blast |
|
||||||
|
| 3645 ENP-STEEL-MP-BASIC | Adhesion Test Coupon | other | inspect |
|
||||||
|
| 3689 ENP-SP | Adhesion Test Coupon | other | inspect |
|
||||||
|
| 3689 ENP-SP | Adhesion Testing | other | inspect |
|
||||||
|
| 3689 ENP-SP | Corrosion Testing | other | inspect |
|
||||||
|
| 3689 ENP-SP | Lab Testing | other | inspect |
|
||||||
|
| 3945 ENP ALUM BASIC HP SC2 | ENP-Alum Line - HP | other | other (intentional — sub_process) |
|
||||||
|
| 3782 Chemical Conversion Process | Strip Process - AL | other | wet_process |
|
||||||
|
| 3782 Chemical Conversion Process | Plug The Threaded Holes | other | mask |
|
||||||
|
| 3782 Chemical Conversion Process | Chemical Conversion (sub_process) | other | wet_process |
|
||||||
|
| 3782 Chemical Conversion Process | Trivalent Chromate Conversion (A-14 / A) | other | wet_process |
|
||||||
|
|
||||||
|
### Root cause 4 — Recipe duplication has no kind safety net
|
||||||
|
|
||||||
|
`fp.process.node.copy()` uses the standard Odoo deep-copy which inherits all fields including `kind_id`. So if the source has bad kinds, the clone inherits bad kinds. Even after we fix the base recipes, future authoring mistakes will propagate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approved fix
|
||||||
|
|
||||||
|
### Change 1 — Delete all 24 per-part clone recipes
|
||||||
|
|
||||||
|
Identify clones by name pattern (em-dash with spaces — the configurator's separator): `name ILIKE '% — %' AND node_type='recipe'`.
|
||||||
|
|
||||||
|
FK constraints verified:
|
||||||
|
- `fp.job.recipe_id` → SET NULL (historical job loses recipe ref, step data persists)
|
||||||
|
- `fp.job.start_at_node_id` → SET NULL
|
||||||
|
- `fp.job.step.recipe_node_id` → SET NULL
|
||||||
|
- `fusion.plating.process.node.parent_id` → CASCADE (child nodes auto-deleted)
|
||||||
|
- `fp.coating.config.recipe_id` → SET NULL
|
||||||
|
- `fp.pricing.rule.recipe_id` → SET NULL
|
||||||
|
- `fp.part.catalog.default_process_id` → SET NULL
|
||||||
|
- Zero rows in the 2 RESTRICT FKs (`fp.quote.configurator.recipe_id`, `fp.job.node.override.node_id`) point at clones → no blockers
|
||||||
|
|
||||||
|
One DELETE statement:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM fusion_plating_process_node
|
||||||
|
WHERE node_type = 'recipe'
|
||||||
|
AND name ILIKE '% — %';
|
||||||
|
```
|
||||||
|
|
||||||
|
CASCADE handles all child operations + steps + sub_processes via the `parent_id` chain. SET NULL handles all the historical job references.
|
||||||
|
|
||||||
|
### Change 2 — Fix recipe 3620 ENP-ALUM-BASIC
|
||||||
|
|
||||||
|
**a. Resequence operations** so each has a unique sequence and Receiving precedes physical work:
|
||||||
|
|
||||||
|
| New sequence | Operation | id | Was at |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 10 | Contract Review | 3853 | 10 |
|
||||||
|
| 20 | Incoming Inspection (Standard) | 3854 | 20 (tied) |
|
||||||
|
| 30 | Masking | 3877 | 10 (tied) |
|
||||||
|
| 40 | Racking | 3855 | 20 (tied) |
|
||||||
|
| 50 | Ready for processing | 3858 | 30 |
|
||||||
|
| 60 | ENP-Alum Line | 3859 | 40 (tied) |
|
||||||
|
| 70 | De-Masking | 3861 | 50 |
|
||||||
|
| 80 | Oven baking | 3864 | 60 |
|
||||||
|
| 90 | De-racking | 3867 | 70 |
|
||||||
|
| 100 | Oven bake (Post de-rack) | 4067 | 80 |
|
||||||
|
| 110 | Post-plate Inspection | 3873 | 90 |
|
||||||
|
| 120 | Final Inspection | 3876 | 120 |
|
||||||
|
|
||||||
|
Per the user decision (mask first, then rack — matches the existing De-Masking step's position between Plating and Bake; de-mask before de-rack would be illogical).
|
||||||
|
|
||||||
|
**b. Delete duplicate empty ENP-Alum Line sub_process** (id 4056, no children). The real one (id 3859, contains E-Nickel Plating) survives.
|
||||||
|
|
||||||
|
### Change 3 — Extend `fp_resolve_step_kind()`
|
||||||
|
|
||||||
|
In [`fusion_plating/__init__.py`](../../../fusion_plating/__init__.py):
|
||||||
|
|
||||||
|
**a. Add aliases to `_STARTER_KIND_BY_NAME`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Blasting variants
|
||||||
|
'blasting': 'blast',
|
||||||
|
'bead blast': 'blast',
|
||||||
|
'bead blasting': 'blast',
|
||||||
|
'media blast': 'blast',
|
||||||
|
'media blasting': 'blast',
|
||||||
|
# Inspection variants the resolver didn't know
|
||||||
|
'adhesion test coupon': 'inspect',
|
||||||
|
'adhesion testing': 'inspect',
|
||||||
|
'corrosion testing': 'inspect',
|
||||||
|
'lab testing': 'inspect',
|
||||||
|
# Strip + chemical conversion + plugging (mostly wet line)
|
||||||
|
'strip process': 'wet_process',
|
||||||
|
'strip process - al': 'wet_process',
|
||||||
|
'nickel strip - aluminum line': 'wet_process',
|
||||||
|
'chemical conversion': 'wet_process',
|
||||||
|
'trivalent chromate conversion': 'wet_process',
|
||||||
|
'plug the threaded holes': 'mask',
|
||||||
|
```
|
||||||
|
|
||||||
|
**b. Add parenthetical stripping** to `fp_resolve_step_kind()` so `"Incoming Inspection (Standard)"`, `"Blasting (If Required)"`, `"Trivalent Chromate Conversion (A-14 / A)"` etc. resolve through their base name. Strip first, look up second, fall through to the resolver's other rules:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fp_resolve_step_kind(name):
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
key = name.strip().lower()
|
||||||
|
if key in _STARTER_KIND_BY_NAME:
|
||||||
|
return _STARTER_KIND_BY_NAME[key]
|
||||||
|
# NEW: strip parenthetical suffixes — "Masking (If Required)" →
|
||||||
|
# "Masking", "Incoming Inspection (Standard)" → "Incoming
|
||||||
|
# Inspection".
|
||||||
|
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
|
||||||
|
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
|
||||||
|
return _STARTER_KIND_BY_NAME[bare]
|
||||||
|
if key.startswith('ready for ') or key.startswith('ready '):
|
||||||
|
return 'gating'
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
**c. Translate resolver kinds to active `fp.step.kind.code` values.** Several resolver outputs (`cleaning`, `electroclean`, `etch`, `rinse`, `strike`, `dry`, `wbf_test`) map to kinds that are inactive in the dropdown — those should roll up to the active `wet_process` kind. Add a translation in the migration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
RESOLVER_KIND_TO_ACTIVE_KIND = {
|
||||||
|
# Wet-line kinds → wet_process (active rollup)
|
||||||
|
'cleaning': 'wet_process',
|
||||||
|
'electroclean': 'wet_process',
|
||||||
|
'etch': 'wet_process',
|
||||||
|
'rinse': 'wet_process',
|
||||||
|
'strike': 'wet_process',
|
||||||
|
'dry': 'wet_process',
|
||||||
|
'wbf_test': 'wet_process',
|
||||||
|
# 1:1 mappings (kind exists and is active)
|
||||||
|
'contract_review': 'contract_review',
|
||||||
|
'mask': 'mask',
|
||||||
|
'racking': 'racking',
|
||||||
|
'plate': 'plate',
|
||||||
|
'bake': 'bake',
|
||||||
|
'derack': 'derack',
|
||||||
|
'demask': 'demask',
|
||||||
|
'inspect': 'inspect',
|
||||||
|
'final_inspect': 'final_inspect',
|
||||||
|
'ship': 'ship',
|
||||||
|
'gating': 'gating',
|
||||||
|
'blast': 'blast',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change 4 — Backfill `kind=other` nodes via the extended resolver
|
||||||
|
|
||||||
|
For every `fusion.plating.process.node` where `kind.code='other'` and `name` is set:
|
||||||
|
- Call `fp_resolve_step_kind(name)`
|
||||||
|
- Translate via `RESOLVER_KIND_TO_ACTIVE_KIND`
|
||||||
|
- If a match: look up `fp.step.kind` by code, write `kind_id`
|
||||||
|
- If no match: leave as-is (admin can pick later)
|
||||||
|
|
||||||
|
Idempotent — only affects nodes currently at `kind=other`.
|
||||||
|
|
||||||
|
### Change 5 — Auto-classify hook on `fusion.plating.process.node`
|
||||||
|
|
||||||
|
In [`fusion_plating/models/fp_process_node.py`](../../../fusion_plating/models/fp_process_node.py), add a post-write helper that runs after `create()` and `write()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_autoclassify_kind(self):
|
||||||
|
"""If kind_id is 'other' AND name resolves via fp_resolve_step_kind,
|
||||||
|
upgrade to the resolved active kind. Idempotent — never overrides
|
||||||
|
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify=True.
|
||||||
|
"""
|
||||||
|
if self.env.context.get('fp_skip_kind_autoclassify'):
|
||||||
|
return
|
||||||
|
from odoo.addons.fusion_plating import fp_resolve_step_kind
|
||||||
|
Kind = self.env['fp.step.kind']
|
||||||
|
other = Kind.search([('code', '=', 'other')], limit=1)
|
||||||
|
if not other:
|
||||||
|
return
|
||||||
|
for node in self:
|
||||||
|
if not node.name or node.kind_id != other:
|
||||||
|
continue
|
||||||
|
resolver_code = fp_resolve_step_kind(node.name)
|
||||||
|
if not resolver_code:
|
||||||
|
continue
|
||||||
|
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||||
|
if not target_code:
|
||||||
|
continue
|
||||||
|
target = Kind.search([('code', '=', target_code)], limit=1)
|
||||||
|
if target:
|
||||||
|
node.with_context(fp_skip_kind_autoclassify=True).write(
|
||||||
|
{'kind_id': target.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
nodes = super().create(vals_list)
|
||||||
|
nodes._fp_autoclassify_kind()
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
# Only re-run autoclassify when name OR kind_id changed
|
||||||
|
if 'name' in vals or 'kind_id' in vals:
|
||||||
|
self._fp_autoclassify_kind()
|
||||||
|
return res
|
||||||
|
```
|
||||||
|
|
||||||
|
Two side-effects this guarantees:
|
||||||
|
- Recipe duplication via `copy()` → after super().copy() runs, the hook fires on the new node and upgrades the kind if applicable. So future per-part clones get correct kinds even if the source was sloppy.
|
||||||
|
- Authors typing a step name in the Simple/Tree editor → kind auto-upgrades as soon as the name is saved (provided they hadn't already picked a specific kind).
|
||||||
|
|
||||||
|
### Change 6 — `no_parts` cards always land in Receiving column
|
||||||
|
|
||||||
|
In [`fusion_plating_shopfloor/controllers/plant_kanban.py:165`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _resolve_card_area(job):
|
||||||
|
"""..."""
|
||||||
|
# NEW — Defect: no_parts cards belong in Receiving regardless of
|
||||||
|
# active step. The receiver is who acts; the receiver works the
|
||||||
|
# Receiving column.
|
||||||
|
if job.card_state == 'no_parts':
|
||||||
|
return 'receiving'
|
||||||
|
if job.active_step_id and job.active_step_id.area_kind:
|
||||||
|
return job.active_step_id.area_kind
|
||||||
|
return 'receiving'
|
||||||
|
```
|
||||||
|
|
||||||
|
Belt-and-suspenders so even if a job slips through with a bad area_kind or before kinds are recomputed, "no parts" cards still show where they belong.
|
||||||
|
|
||||||
|
### Change 7 — Unified migration
|
||||||
|
|
||||||
|
New file: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`. Runs AFTER fusion_plating's data files load (so the resolver extensions are available).
|
||||||
|
|
||||||
|
Phases, in order:
|
||||||
|
|
||||||
|
1. **Resequence recipe 3620** ops + delete duplicate empty `ENP-Alum Line` sub_process (id 4056).
|
||||||
|
2. **Backfill `kind=other` nodes** using the extended resolver + active-kind translation. Affects ~10 nodes across recipes 3645/3689/3945/3782.
|
||||||
|
3. **Delete the 24 clone recipes** — single DELETE on `fusion_plating_process_node` where `name ILIKE '% — %' AND node_type='recipe'`. CASCADE cleans up children; SET NULL handles job refs.
|
||||||
|
4. **Recompute `fp.job.step.area_kind`** on all rows. After the kind-backfill + clone delete, some steps lose their `recipe_node_id` (NULL); those fall to the catch-all `'plating'`. Acceptable — those are all done/cancelled jobs.
|
||||||
|
5. **Recompute `fp.job.active_step_id` + `card_state`** on in-flight jobs (currently 0 on entech, but defensive).
|
||||||
|
|
||||||
|
All phases idempotent — re-running `-u` is safe.
|
||||||
|
|
||||||
|
### Change 8 — Version bumps
|
||||||
|
|
||||||
|
| Module | From | To |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_plating` | `19.0.21.2.0` | `19.0.21.3.0` (resolver + autoclassify hook + new aliases) |
|
||||||
|
| `fusion_plating_jobs` | `19.0.10.25.0` | `19.0.10.26.0` (migration only) |
|
||||||
|
| `fusion_plating_shopfloor` | `19.0.33.1.3` | `19.0.33.1.4` (no_parts override) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (explicit)
|
||||||
|
|
||||||
|
- **Reordering the other 6 base recipes.** Only recipe 3620 has the documented duplicate-sequence problem. The others have sane sequences and acceptable ordering.
|
||||||
|
- **Backfilling historical jobs' `area_kind`.** All 31 historical jobs are terminal (cancelled/done). They drop off the live board so their stored area_kind is decorative.
|
||||||
|
- **Manual kind picks for the ~5 nodes left as `other`** (e.g. `ENP-Alum Line - HP` sub_process). The resolver can't classify them reliably; admin can pick manually if needed.
|
||||||
|
- **Removing the per-part clone path itself.** The configurator still clones recipes per-part — that's the intended flow. We're just removing existing clones; future SOs will create fresh clones from the fixed base recipes.
|
||||||
|
- **Battle test for this fix.** The flow (SO confirm → job create → recipe clone → step gen → auto-complete → card-area resolve) is covered by manual smoke. A scripted battle test for this would duplicate significant configurator + auto-complete logic — disproportionate to the fix size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
### Manual smoke (after deploy)
|
||||||
|
|
||||||
|
1. **Confirm clones gone:**
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM fusion_plating_process_node
|
||||||
|
WHERE node_type='recipe' AND name ILIKE '% — %';
|
||||||
|
-- expected: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Confirm 3620 reordered:**
|
||||||
|
```sql
|
||||||
|
SELECT sequence, name FROM fusion_plating_process_node
|
||||||
|
WHERE parent_id=3620 ORDER BY sequence;
|
||||||
|
-- expected: 10=Contract Review, 20=Incoming Inspection, 30=Masking,
|
||||||
|
-- 40=Racking, 50=Ready for processing, 60=ENP-Alum Line,
|
||||||
|
-- 70=De-Masking, 80=Oven baking, 90=De-racking,
|
||||||
|
-- 100=Oven bake (Post de-rack), 110=Post-plate Inspection,
|
||||||
|
-- 120=Final Inspection
|
||||||
|
-- NO duplicate sequences. ENP-Alum Line appears ONCE (not twice).
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Confirm kinds backfilled:**
|
||||||
|
```sql
|
||||||
|
SELECT n.name, k.code FROM fusion_plating_process_node n
|
||||||
|
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||||
|
WHERE k.code = 'other'
|
||||||
|
AND n.node_type IN ('operation','step')
|
||||||
|
ORDER BY n.name;
|
||||||
|
-- expected: only ENP-Alum Line - HP (or similar genuinely-other
|
||||||
|
-- nodes that resolver can't classify) — NOT Adhesion Test
|
||||||
|
-- Coupon, Corrosion Testing, Lab Testing, Plug The Threaded
|
||||||
|
-- Holes, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **End-to-end flow:**
|
||||||
|
a. Create a new SO with a part whose default recipe is `ENP-ALUM-BASIC`.
|
||||||
|
b. Confirm the SO.
|
||||||
|
c. Check: the cloned recipe has Contract Review at sequence 10, Incoming Inspection at sequence 20, Masking at 30, Racking at 40.
|
||||||
|
d. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' from the no_parts override OR because Incoming Inspection is the active step after Contract Review auto-completes).
|
||||||
|
e. Mark Incoming Inspection done → card moves to Masking column.
|
||||||
|
|
||||||
|
5. **Auto-classify hook:**
|
||||||
|
a. Open the Simple Editor on any recipe.
|
||||||
|
b. Drop a new step, type name "Masking" (don't pick a kind).
|
||||||
|
c. Save the recipe.
|
||||||
|
d. Refresh the page.
|
||||||
|
e. Confirm the kind dropdown shows "Masking" (not "Other").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roll-out
|
||||||
|
|
||||||
|
1. Implement Changes 1-8 in one branch.
|
||||||
|
2. Local dev test — no local container available, so skip; verify directly on entech.
|
||||||
|
3. Deploy to entech via the standard `pct exec 111` flow.
|
||||||
|
4. SQL spot-checks per the test plan.
|
||||||
|
5. Manual smoke (steps 4 + 5).
|
||||||
|
6. Commit + push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME`, add parenthetical-strip in `fp_resolve_step_kind()` |
|
||||||
|
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + hooks in `create()` and `write()` |
|
||||||
|
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
|
||||||
|
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase migration (Changes 2, 4, 1, recompute, recompute) |
|
||||||
|
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
|
||||||
|
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving override in `_resolve_card_area` |
|
||||||
|
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
|
||||||
|
|
||||||
|
Estimated diff: ~250 lines added, ~20 modified.
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
# Shop Floor — Live Step + Kind/Library Cleanup
|
||||||
|
|
||||||
|
**Date:** 2026-05-24
|
||||||
|
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
|
||||||
|
**Status:** Revised after step-library audit. Awaiting implementation plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
All 7 jobs on entech are stuck in the **Receiving** column of the Shop Floor
|
||||||
|
plant kanban, each tagged with a purple "📋 QA-005 review" chip, even though
|
||||||
|
every step on every one of them is `done`. The board doesn't reflect shop
|
||||||
|
state.
|
||||||
|
|
||||||
|
Investigation surfaced **four code defects**, a **structural vocabulary
|
||||||
|
mismatch** between the user-extensible step kind taxonomy and the hardcoded
|
||||||
|
`area_kind` mapping, **gaps in the kind taxonomy** (no `blast` kind, three
|
||||||
|
relevant kinds inactive), and **30 step-library templates missing codes,
|
||||||
|
descriptions, and meaningful icons**.
|
||||||
|
|
||||||
|
### Defect 1 — `_compute_card_state` edge case mislabels done jobs
|
||||||
|
|
||||||
|
[`fusion_plating_jobs/models/fp_job.py:261-267`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||||
|
|
||||||
|
A job whose `active_step_id` is False (all steps done OR no steps at all)
|
||||||
|
defaults to `'contract_review'` regardless of `job.state`. Done jobs get a
|
||||||
|
QA-005 chip they don't deserve.
|
||||||
|
|
||||||
|
### Defect 2 — `_compute_active_step_id` is too narrow
|
||||||
|
|
||||||
|
[`fusion_plating_jobs/models/fp_job.py:386-391`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||||
|
|
||||||
|
Only matches `state == 'in_progress'`. Between-step / paused / ready jobs
|
||||||
|
have `active_step_id = False`. Combined with Defect 3, these teleport to
|
||||||
|
Receiving.
|
||||||
|
|
||||||
|
### Defect 3 — column-resolve fallback is `'receiving'`
|
||||||
|
|
||||||
|
[`fusion_plating_shopfloor/controllers/plant_kanban.py:161-170`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
|
||||||
|
|
||||||
|
When `active_step_id` is False this fallback fires for every non-running
|
||||||
|
job. Receiving becomes a parking lot.
|
||||||
|
|
||||||
|
### Defect 4 — done jobs aren't filtered off the board
|
||||||
|
|
||||||
|
Done + cancelled jobs stay visible forever. The 7 stuck cards on entech are
|
||||||
|
all `state='done'` jobs that shipped weeks ago.
|
||||||
|
|
||||||
|
### Defect 5 (structural) — kind→area_kind vocabulary mismatch
|
||||||
|
|
||||||
|
`fp.step.kind` is a user-extensible taxonomy (28 records, 12 active in the
|
||||||
|
dropdown post 2026-05-24 dedup). `kind_id` is `required=True` on both
|
||||||
|
`fp.step.template` and `fusion.plating.process.node`, defaulting to
|
||||||
|
`code='other'`.
|
||||||
|
|
||||||
|
`fp.job.step._compute_area_kind` reads `recipe_node.default_kind` (the kind
|
||||||
|
code) through the hardcoded `_STEP_KIND_TO_AREA` dict in
|
||||||
|
[`fp_job_step.py:25-73`](../../../fusion_plating_jobs/models/fp_job_step.py).
|
||||||
|
|
||||||
|
The two vocabularies overlap on **7 of 28 codes**. Adoption on entech:
|
||||||
|
|
||||||
|
| `kind.code` | Nodes | Mapping exists? | Falls to |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `other` | 240 | ❌ | `'plating'` |
|
||||||
|
| `racking` | 122 | ✅ | `'racking'` ✓ |
|
||||||
|
| `wet_process` | 105 | ❌ | `'plating'` (lucky — wet line IS plating) |
|
||||||
|
| `bake` | 103 | ✅ | `'baking'` ✓ |
|
||||||
|
| `mask` | 92 | ❌ (dict has `'masking'`) | `'plating'` (wrong) |
|
||||||
|
| `inspect` | 52 | ❌ (dict has `'inspection'`) | `'plating'` (wrong) |
|
||||||
|
| `plate` | 35 | ❌ (dict has `'e_nickel_plate'`) | `'plating'` (lucky) |
|
||||||
|
| `final_inspect` | 31 | ❌ (dict has `'final_inspection'`) | `'plating'` (wrong) |
|
||||||
|
| `contract_review` | 17 | ✅ | `'receiving'` ✓ |
|
||||||
|
| `receiving` | 16 | ✅ | `'receiving'` ✓ |
|
||||||
|
| `ship` | 3 | ❌ (dict has `'shipping'`) | `'plating'` (wrong) |
|
||||||
|
|
||||||
|
The structural fix: make `area_kind` a required field on `fp.step.kind`
|
||||||
|
itself so each kind self-declares its column.
|
||||||
|
|
||||||
|
### Defect 6 (taxonomy) — kinds that should exist but don't / are inactive
|
||||||
|
|
||||||
|
| Kind | Currently | Needed because |
|
||||||
|
|---|---|---|
|
||||||
|
| `blast` | Does not exist | 11 recipe nodes named "Blasting" can't be classified correctly. There's no kind that maps to the Blasting column. |
|
||||||
|
| `derack` | Exists but `active=False` | 23+ recipe nodes named "De-racking" / "DeRacking" need their own kind for tablet routing clarity (`area_kind='de_racking'`). |
|
||||||
|
| `demask` | Exists but `active=False` | 33 recipe nodes named "De-Masking" are misclassified as `mask` → land in Masking column. Per spec §D4 De-Masking folds into De-Racking. |
|
||||||
|
| `gating` | Exists but `active=False` | 50+ "Ready For X" recipe nodes are unclassified gates. Without `gating` they fall back to `other` → catch-all. |
|
||||||
|
|
||||||
|
### Defect 7 (library) — 30 step-library templates missing metadata
|
||||||
|
|
||||||
|
Step Library audit (38 active templates):
|
||||||
|
|
||||||
|
| Field | Has it | Missing |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | 8 | 30 |
|
||||||
|
| `description` | 8 | 30 |
|
||||||
|
| Meaningful icon (not `fa-cog`) | 13 | 25 |
|
||||||
|
| `material_callout` | 0 | 38 |
|
||||||
|
| `process_type_id` | 0 | 38 |
|
||||||
|
|
||||||
|
The 8 well-formed templates (`RECV_STD`, `ELEC_CLEAN_STD`, `STRIKE_STD`, etc.)
|
||||||
|
came from the XML data file. The remaining 30 came from
|
||||||
|
`_seed_step_library_if_empty()` (programmatic seed from ENP-ALUM-BASIC recipe)
|
||||||
|
without their library-management metadata.
|
||||||
|
|
||||||
|
Several library templates are also classified to the wrong kind. Examples:
|
||||||
|
|
||||||
|
| Template | Currently `kind` | Should be `kind` |
|
||||||
|
|---|---|---|
|
||||||
|
| Blasting | `other` | `blast` (kind we're creating) |
|
||||||
|
| De-Masking | `mask` | `demask` (per spec §D4) |
|
||||||
|
| Ready for Plating / Ready for processing | `plate` / `other` | `gating` |
|
||||||
|
| Pre-Measurements / Check Sulfamate Nickel Area | `other` | `inspect` |
|
||||||
|
| Nickel Strip / Nickel Strip - Steel Line | `plate` | `wet_process` (it's a strip, not plating) |
|
||||||
|
|
||||||
|
### Defect 8 (recipe nodes) — in-the-wild misclassifications
|
||||||
|
|
||||||
|
Once kinds are fixed and library is corrected, the EXISTING ~880 recipe
|
||||||
|
nodes still point at the wrong kind in well-defined patterns:
|
||||||
|
|
||||||
|
| Pattern | Affected nodes | Re-point to |
|
||||||
|
|---|---|---|
|
||||||
|
| `name = 'Blasting'` AND `kind = other` | 11 | `kind = blast` |
|
||||||
|
| `name ILIKE 'Ready %'` AND `kind != gating` | ~50+ | `kind = gating` |
|
||||||
|
| `name ILIKE '%De-Masking%' OR '%DeMasking%'` AND `kind = mask` | 33 | `kind = demask` |
|
||||||
|
| `name = 'Scheduling'` AND `kind = other` | 5 | `kind = gating` |
|
||||||
|
| `name ILIKE '%Nickel Strip%'` AND `kind = plate` | ~10 | `kind = wet_process` |
|
||||||
|
| `name ILIKE '%Pre-Measurement%' OR '%Check Sulfamate%'` AND `kind = other` | ~10 | `kind = inspect` |
|
||||||
|
|
||||||
|
These are auto-migratable because the patterns are unambiguous. The harder
|
||||||
|
calls (e.g. "Post Plate Inspection" — `inspect` or `final_inspect`?) stay
|
||||||
|
manual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approved fix
|
||||||
|
|
||||||
|
### Change 1 — `_compute_active_step_id` priority chain
|
||||||
|
|
||||||
|
Replace the single-state filter with a priority lookup over `step_ids`
|
||||||
|
sorted by sequence. First match wins:
|
||||||
|
|
||||||
|
```
|
||||||
|
in_progress > paused > ready > first pending
|
||||||
|
```
|
||||||
|
|
||||||
|
If every step is `done` (or no steps exist), returns False — handled by
|
||||||
|
Change 2.
|
||||||
|
|
||||||
|
**Why this order:**
|
||||||
|
|
||||||
|
- `in_progress` is the most informative.
|
||||||
|
- `paused` means someone was working and stopped; the card belongs at that station so the next operator can pick it up.
|
||||||
|
- `ready` is the next-up step waiting on an operator.
|
||||||
|
- The first `pending` after a `done` is the "next gate" — where the card visually waits.
|
||||||
|
|
||||||
|
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||||
|
|
||||||
|
### Change 2 — `_compute_card_state` edge case
|
||||||
|
|
||||||
|
Replace the buggy "no active step → contract_review" fallback with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not job.active_step_id:
|
||||||
|
if job.state == 'done':
|
||||||
|
job.card_state = 'done'
|
||||||
|
elif job._fp_inbound_not_received():
|
||||||
|
job.card_state = 'no_parts'
|
||||||
|
else:
|
||||||
|
job.card_state = 'ready' # no steps yet — recipe not assigned
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||||
|
|
||||||
|
### Change 3 — Board state filter
|
||||||
|
|
||||||
|
Add `('state', 'in', ('confirmed', 'in_progress'))` to the `fp.job` search
|
||||||
|
domain in `/fp/landing/plant_kanban`. Done + cancelled jobs disappear from
|
||||||
|
the board; they remain reachable elsewhere.
|
||||||
|
|
||||||
|
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
|
||||||
|
|
||||||
|
### Change 4 — Column-resolve fallback (comment only)
|
||||||
|
|
||||||
|
`_resolve_card_area`'s `'receiving'` fallback stays but updates inline
|
||||||
|
comment to explain the new semantics (truly orphaned cards only).
|
||||||
|
|
||||||
|
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
|
||||||
|
|
||||||
|
### Change 5 — `fp.step.kind.area_kind` field (structural)
|
||||||
|
|
||||||
|
Add a required Selection field to `fp.step.kind`. Each kind self-declares
|
||||||
|
which plant-view column its steps belong in.
|
||||||
|
|
||||||
|
```python
|
||||||
|
area_kind = fields.Selection(
|
||||||
|
[
|
||||||
|
('receiving', 'Receiving'),
|
||||||
|
('masking', 'Masking'),
|
||||||
|
('blasting', 'Blasting'),
|
||||||
|
('racking', 'Racking'),
|
||||||
|
('plating', 'Plating'),
|
||||||
|
('baking', 'Baking'),
|
||||||
|
('de_racking', 'De-Racking'),
|
||||||
|
('inspection', 'Final Inspection'),
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
],
|
||||||
|
string='Shop Floor Column',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
tracking=True,
|
||||||
|
help='Determines which column on the Shop Floor plant kanban shows '
|
||||||
|
'cards whose active step uses this kind.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** [`fusion_plating/models/fp_step_kind.py`](../../../fusion_plating/models/fp_step_kind.py)
|
||||||
|
|
||||||
|
### Change 6 — `_compute_area_kind` priority chain
|
||||||
|
|
||||||
|
Simplify `fp.job.step._compute_area_kind`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api.depends(
|
||||||
|
'work_centre_id.area_kind',
|
||||||
|
'recipe_node_id.kind_id.area_kind',
|
||||||
|
)
|
||||||
|
def _compute_area_kind(self):
|
||||||
|
for step in self:
|
||||||
|
# 1. work_centre.area_kind (explicit operator setup)
|
||||||
|
if step.work_centre_id and step.work_centre_id.area_kind:
|
||||||
|
step.area_kind = step.work_centre_id.area_kind
|
||||||
|
continue
|
||||||
|
# 2. recipe_node.kind_id.area_kind (kind taxonomy is authoritative)
|
||||||
|
node = step.recipe_node_id
|
||||||
|
if node and node.kind_id and node.kind_id.area_kind:
|
||||||
|
step.area_kind = node.kind_id.area_kind
|
||||||
|
continue
|
||||||
|
# 3. Catch-all — data integrity issue if we land here
|
||||||
|
step.area_kind = 'plating'
|
||||||
|
```
|
||||||
|
|
||||||
|
The legacy `_STEP_KIND_TO_AREA` dict is deleted.
|
||||||
|
|
||||||
|
**File:** [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py)
|
||||||
|
|
||||||
|
### Change 7 — Step Kind UI surfaces `area_kind`
|
||||||
|
|
||||||
|
- **Form view** ([`fp_step_kind_views.xml`](../../../fusion_plating/views/fp_step_kind_views.xml)) — add `area_kind` as a prominent picker next to `code` + `name`, with a help-text inline ("Cards whose active step uses this kind appear in this column on the Shop Floor board").
|
||||||
|
- **List view** — add `area_kind` as a chip column.
|
||||||
|
- **Simple Editor kind picker** ([`simple_recipe_editor.xml:506-522`](../../../fusion_plating/static/src/xml/simple_recipe_editor.xml)) — option label becomes "Masking — Masking column" so authors see the routing at pick time. Requires updating `kindOptions` payload in [`simple_recipe_controller.py`](../../../fusion_plating/controllers/simple_recipe_controller.py) to include `area_kind` + a human-readable column label per kind.
|
||||||
|
|
||||||
|
### Change 8 — Step Kind taxonomy expansion (Cat A)
|
||||||
|
|
||||||
|
XML data file additions / updates in
|
||||||
|
[`fusion_plating/data/fp_step_kind_data.xml`](../../../fusion_plating/data/fp_step_kind_data.xml):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- NEW: Blasting kind -->
|
||||||
|
<record id="step_kind_blast" model="fp.step.kind">
|
||||||
|
<field name="code">blast</field>
|
||||||
|
<field name="name">Blasting / Media Blast</field>
|
||||||
|
<field name="sequence">35</field>
|
||||||
|
<field name="icon">fa-bullseye</field>
|
||||||
|
<field name="area_kind">blasting</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Activate existing kinds + set area_kind. The records already exist
|
||||||
|
from 19.0.20.6.0 with active=False; here we flip + classify.
|
||||||
|
noupdate=1 protects user edits, so use a one-shot migration to
|
||||||
|
do the flip on existing installs (Change 10). -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration (Change 10) handles the flip on existing installs since the data
|
||||||
|
file has `noupdate="1"`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Activate kinds that were dropped in 19.0.20.6.0 but are needed
|
||||||
|
# for the area_kind taxonomy to be complete.
|
||||||
|
for code, area in (
|
||||||
|
('derack', 'de_racking'),
|
||||||
|
('demask', 'de_racking'),
|
||||||
|
('gating', 'receiving'),
|
||||||
|
):
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_step_kind
|
||||||
|
SET active = TRUE, area_kind = %s
|
||||||
|
WHERE code = %s AND active = FALSE
|
||||||
|
""", (area, code))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change 9 — Step Template metadata backfill + additions (Cat B)
|
||||||
|
|
||||||
|
Migration backfills metadata on the 30 templates seeded without it.
|
||||||
|
Idempotent — only fills NULL/empty fields, doesn't overwrite human edits.
|
||||||
|
|
||||||
|
```python
|
||||||
|
TEMPLATE_BACKFILL = {
|
||||||
|
# name : (code, icon, kind_code, description_snippet)
|
||||||
|
'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'),
|
||||||
|
'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'),
|
||||||
|
'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'),
|
||||||
|
'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'),
|
||||||
|
'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'),
|
||||||
|
'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'),
|
||||||
|
'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'),
|
||||||
|
'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'),
|
||||||
|
'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'),
|
||||||
|
'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'),
|
||||||
|
'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'),
|
||||||
|
'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'),
|
||||||
|
'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'),
|
||||||
|
'Final Inspection': ('FINAL_INSP_STD','fa-check-circle','final_inspect','Final visual + dimensional QA before packing.'),
|
||||||
|
'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'),
|
||||||
|
'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'),
|
||||||
|
'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'),
|
||||||
|
'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'),
|
||||||
|
'Nickel Strip - Steel Line': ('NI_STRIP_SL','fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'),
|
||||||
|
'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle','inspect', 'Post-plate inspection — thickness sample + visual.'),
|
||||||
|
'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'),
|
||||||
|
'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'),
|
||||||
|
'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step — parts staged ready for the plating line.'),
|
||||||
|
'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step — parts staged ready for the next operation.'),
|
||||||
|
'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'),
|
||||||
|
'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'),
|
||||||
|
'Soak Clean': ('SOAK_CLEAN_STD','fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'),
|
||||||
|
'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'),
|
||||||
|
'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'),
|
||||||
|
'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New templates (XML data file additions, `noupdate="1"`):
|
||||||
|
|
||||||
|
| Name | Code | Kind | Why add |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Hot Water Porosity Test (A-15)` | `HWP_A15` | `inspect` | 7 recipe nodes use it — should be in the library |
|
||||||
|
| `Final Inspection / Packaging` | `FINAL_PKG_STD` | `final_inspect` | 3 recipe nodes use it; library has separate inspection + packaging but not the combined one |
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- [`fusion_plating/data/fp_step_template_data.xml`](../../../fusion_plating/data/fp_step_template_data.xml) — 2 new template records
|
||||||
|
- Migration (Change 10) — TEMPLATE_BACKFILL loop, idempotent
|
||||||
|
|
||||||
|
### Change 10 — Unified migration
|
||||||
|
|
||||||
|
New file: [`fusion_plating/migrations/19.0.21.2.0/pre-migrate.py`](../../../fusion_plating/migrations/19.0.21.2.0/pre-migrate.py)
|
||||||
|
|
||||||
|
Pre-migrate runs BEFORE the `area_kind NOT NULL` constraint hits the
|
||||||
|
schema, so it fills values first.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KIND_TO_AREA = {
|
||||||
|
'other': 'plating', # catch-all default
|
||||||
|
'wet_process': 'plating',
|
||||||
|
'receiving': 'receiving',
|
||||||
|
'contract_review':'receiving',
|
||||||
|
'gating': 'receiving',
|
||||||
|
'racking': 'racking',
|
||||||
|
'derack': 'de_racking',
|
||||||
|
'mask': 'masking',
|
||||||
|
'demask': 'de_racking', # spec §D4
|
||||||
|
'cleaning': 'plating',
|
||||||
|
'electroclean': 'plating',
|
||||||
|
'etch': 'plating',
|
||||||
|
'rinse': 'plating',
|
||||||
|
'strike': 'plating',
|
||||||
|
'plate': 'plating',
|
||||||
|
'replenishment': 'plating',
|
||||||
|
'wbf_test': 'plating',
|
||||||
|
'dry': 'plating',
|
||||||
|
'bake': 'baking',
|
||||||
|
'inspect': 'inspection',
|
||||||
|
'final_inspect': 'inspection',
|
||||||
|
'hardness_test': 'inspection',
|
||||||
|
'adhesion_test': 'inspection',
|
||||||
|
'salt_spray': 'inspection',
|
||||||
|
'packaging': 'shipping',
|
||||||
|
'ship': 'shipping',
|
||||||
|
'blast': 'blasting',
|
||||||
|
'bead_blast': 'blasting',
|
||||||
|
'media_blast': 'blasting',
|
||||||
|
}
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
# Phase 1 — seed area_kind on existing kinds BEFORE NOT NULL hits.
|
||||||
|
for code, area in KIND_TO_AREA.items():
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_step_kind SET area_kind = %s
|
||||||
|
WHERE code = %s AND (area_kind IS NULL OR area_kind = '')
|
||||||
|
""", (area, code))
|
||||||
|
# Anything still NULL: default to 'plating' to clear the constraint.
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_step_kind SET area_kind = 'plating'
|
||||||
|
WHERE area_kind IS NULL OR area_kind = ''
|
||||||
|
""")
|
||||||
|
_logger.info('[live-step-fix] kind.area_kind seeded')
|
||||||
|
|
||||||
|
# Phase 2 — activate the three inactive kinds we need (Cat A).
|
||||||
|
for code in ('derack', 'demask', 'gating'):
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_step_kind SET active = TRUE
|
||||||
|
WHERE code = %s AND active = FALSE
|
||||||
|
""", (code,))
|
||||||
|
_logger.info('[live-step-fix] derack/demask/gating activated')
|
||||||
|
```
|
||||||
|
|
||||||
|
New file: [`fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py`](../../../fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py)
|
||||||
|
|
||||||
|
Post-migrate runs AFTER schema sync, so all fields exist with values.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Library template metadata backfill — copied from spec Change 9.
|
||||||
|
TEMPLATE_BACKFILL = { ... } # full dict per Change 9
|
||||||
|
|
||||||
|
# Recipe node patterns to repoint (Cat C).
|
||||||
|
NODE_REPOINTING = [
|
||||||
|
# (name_filter_sql, current_kind_code, new_kind_code, description)
|
||||||
|
("name = 'Blasting'", 'other', 'blast', 'Blasting → blast'),
|
||||||
|
("name ILIKE 'Ready %%'", None, 'gating', 'Ready For X → gating'),
|
||||||
|
("name ILIKE '%%De-Masking%%' OR name ILIKE '%%DeMasking%%'", 'mask', 'demask', 'De-Masking → demask'),
|
||||||
|
("name = 'Scheduling'", 'other', 'gating', 'Scheduling → gating'),
|
||||||
|
("name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip → wet_process'),
|
||||||
|
("name ILIKE '%%Pre-Measurement%%' OR name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate → inspect'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
from odoo.api import Environment, SUPERUSER_ID
|
||||||
|
env = Environment(cr, SUPERUSER_ID, {})
|
||||||
|
|
||||||
|
# Phase 1 — template metadata backfill (Cat B). Idempotent.
|
||||||
|
Tpl = env['fp.step.template']
|
||||||
|
Kind = env['fp.step.kind']
|
||||||
|
fixed = 0
|
||||||
|
for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items():
|
||||||
|
tpl = Tpl.search([('name', '=', name)], limit=1)
|
||||||
|
if not tpl:
|
||||||
|
continue
|
||||||
|
vals = {}
|
||||||
|
if not tpl.code:
|
||||||
|
vals['code'] = code
|
||||||
|
if not tpl.description or tpl.description in ('', '<p><br></p>'):
|
||||||
|
vals['description'] = f'<p>{desc}</p>'
|
||||||
|
if tpl.icon == 'fa-cog':
|
||||||
|
vals['icon'] = icon
|
||||||
|
kind = Kind.search([('code', '=', kind_code)], limit=1)
|
||||||
|
if kind and tpl.kind_id.code != kind_code:
|
||||||
|
vals['kind_id'] = kind.id
|
||||||
|
if vals:
|
||||||
|
tpl.write(vals)
|
||||||
|
fixed += 1
|
||||||
|
_logger.info('[live-step-fix] template backfill: %s templates updated', fixed)
|
||||||
|
|
||||||
|
# Phase 2 — recipe node repointing (Cat C). Pattern-driven SQL.
|
||||||
|
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
|
||||||
|
params = []
|
||||||
|
sql = f"""
|
||||||
|
UPDATE fusion_plating_process_node n
|
||||||
|
SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1)
|
||||||
|
FROM fp_step_kind k
|
||||||
|
WHERE n.kind_id = k.id
|
||||||
|
AND ({filter_sql})
|
||||||
|
"""
|
||||||
|
params.append(new_code)
|
||||||
|
if cur_code is not None:
|
||||||
|
sql += " AND k.code = %s"
|
||||||
|
params.append(cur_code)
|
||||||
|
sql += " AND k.code != %s"
|
||||||
|
params.append(new_code)
|
||||||
|
cr.execute(sql, params)
|
||||||
|
_logger.info('[live-step-fix] repointed %s nodes: %s',
|
||||||
|
cr.rowcount, desc)
|
||||||
|
|
||||||
|
# Phase 3 — recompute area_kind on all fp.job.step rows.
|
||||||
|
steps = env['fp.job.step'].search([])
|
||||||
|
steps._compute_area_kind()
|
||||||
|
steps.flush_recordset(['area_kind'])
|
||||||
|
_logger.info('[live-step-fix] recomputed area_kind on %s steps', len(steps))
|
||||||
|
|
||||||
|
# Phase 4 — recompute active_step_id + card_state on in-flight jobs.
|
||||||
|
jobs = env['fp.job'].search([('state', 'in', ('confirmed', 'in_progress'))])
|
||||||
|
jobs._compute_active_step_id()
|
||||||
|
jobs._compute_card_state()
|
||||||
|
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||||
|
_logger.info('[live-step-fix] recomputed jobs: %s', len(jobs))
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent across the board: phase 1 only fills NULLs / fa-cog defaults;
|
||||||
|
phase 2 includes `AND k.code != %s` so re-running won't re-do already
|
||||||
|
correct rows; phases 3-4 are pure recomputes.
|
||||||
|
|
||||||
|
### Change 11 — Version bumps
|
||||||
|
|
||||||
|
| Module | From | To |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_plating` | `19.0.21.1.3` | `19.0.21.2.0` (schema change on fp.step.kind + data file additions) |
|
||||||
|
| `fusion_plating_jobs` | `19.0.10.23.0` | `19.0.10.24.0` (compute change + migration) |
|
||||||
|
| `fusion_plating_shopfloor` | `19.0.33.1.2` | `19.0.33.1.3` (controller filter + comment) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this approach replaces
|
||||||
|
|
||||||
|
| Dropped from the original (pre-restructure) spec | Why |
|
||||||
|
|---|---|
|
||||||
|
| `_RESOLVER_KIND_TO_AREA` translation dict | Kind self-declares its column — no translation needed |
|
||||||
|
| `_resolve_area_kind_from_name` helper | Kind taxonomy is authoritative; name resolution is unnecessary |
|
||||||
|
| `_STARTER_KIND_BY_NAME` extensions for column routing | The starter resolver is for `default_kind` seeding (Sub 12a library), not column routing — stays as-is for that purpose |
|
||||||
|
| Parenthetical stripping regex | Not needed when we read the kind directly |
|
||||||
|
| Backfill of `default_kind` on existing recipe nodes via name resolver | Recipe nodes already have `kind_id` populated by 19.0.20.6.0 pre-migrate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
### Manual smoke (on entech after deploy)
|
||||||
|
|
||||||
|
1. Open Shop Floor tablet/desktop — confirm the 7 done jobs are GONE from the board.
|
||||||
|
2. Plating → Configuration → Recipes & Steps → **Step Kind catalog** — confirm:
|
||||||
|
- `blast` exists, active, area_kind=`blasting`
|
||||||
|
- `derack`, `demask`, `gating` are now `active=True`, area_kinds correct
|
||||||
|
- Every kind has area_kind set
|
||||||
|
3. Plating → Configuration → Recipes & Steps → **Step Library** — confirm:
|
||||||
|
- All 38 templates now have a code, description, meaningful icon
|
||||||
|
- `Hot Water Porosity Test (A-15)` and `Final Inspection / Packaging` are listed
|
||||||
|
- "Blasting" is `kind=blast`, "De-Masking" is `kind=demask`, "Ready for ..." are `kind=gating`
|
||||||
|
4. Open the Simple Recipe Editor; click "+ Add new kind" — confirm area_kind picker is visible/required in the inline-create flow.
|
||||||
|
5. Create a fresh test job from any recipe (e.g. ENP-ALUM-BASIC):
|
||||||
|
a. Confirm it lands in Receiving column with `card_state='ready'`.
|
||||||
|
b. Walk through all steps — confirm column transitions follow area_kind sequence.
|
||||||
|
c. Mark job done → confirm card drops off the board.
|
||||||
|
6. Verify a step with `state='paused'` keeps the card at its column.
|
||||||
|
|
||||||
|
### Spot-check existing data
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Every node should have a kind with area_kind set.
|
||||||
|
SELECT n.id, n.name, k.code, k.area_kind
|
||||||
|
FROM fusion_plating_process_node n
|
||||||
|
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||||
|
WHERE k.area_kind IS NULL OR k.area_kind = '';
|
||||||
|
-- expected: 0 rows
|
||||||
|
|
||||||
|
-- Blasting nodes should now use blast kind.
|
||||||
|
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
|
||||||
|
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||||
|
WHERE n.name = 'Blasting' GROUP BY k.code;
|
||||||
|
-- expected: all rows have k.code = 'blast'
|
||||||
|
|
||||||
|
-- Ready For X gating nodes.
|
||||||
|
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
|
||||||
|
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||||
|
WHERE n.name ILIKE 'Ready %' GROUP BY k.code;
|
||||||
|
-- expected: all rows have k.code = 'gating'
|
||||||
|
|
||||||
|
-- De-Masking nodes use demask.
|
||||||
|
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
|
||||||
|
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||||
|
WHERE n.name ILIKE '%De-Masking%' OR n.name ILIKE '%DeMasking%'
|
||||||
|
GROUP BY k.code;
|
||||||
|
-- expected: all rows have k.code = 'demask'
|
||||||
|
|
||||||
|
-- Template code coverage.
|
||||||
|
SELECT COUNT(*) FROM fp_step_template
|
||||||
|
WHERE active = TRUE AND (code IS NULL OR code = '');
|
||||||
|
-- expected: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated battle test
|
||||||
|
|
||||||
|
New script: `fusion_plating_quality/scripts/bt_s24_between_steps.py` covering
|
||||||
|
the live-step priority chain end-to-end (see prior version of the spec for
|
||||||
|
full pseudocode — unchanged).
|
||||||
|
|
||||||
|
### Existing tests
|
||||||
|
|
||||||
|
Existing tests in `fusion_plating_shopfloor/tests/` and
|
||||||
|
`fusion_plating_jobs/tests/` may need updates for:
|
||||||
|
- The new `state` filter in `/fp/landing/plant_kanban`.
|
||||||
|
- The new `active_step_id` priority chain.
|
||||||
|
|
||||||
|
Re-run all `bt_s*.py` scripts to confirm no regressions in S1-S23.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roll-out
|
||||||
|
|
||||||
|
1. Implement Changes 1-11 in a single branch.
|
||||||
|
2. Local dev test (`docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init`).
|
||||||
|
3. Deploy to entech using the standard `pct exec 111` flow. Pre-migrate seeds run automatically.
|
||||||
|
4. Verify on entech with manual smoke + SQL spot-checks.
|
||||||
|
5. Commit + push to GitHub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-goals (explicit)
|
||||||
|
|
||||||
|
- **Re-assigning historical steps to `work_centre_id`.** The 85+ steps with NULL `work_centre_id` stay that way. The kind→area_kind lookup gives them correct `area_kind` without needing a work_centre.
|
||||||
|
- **Recipe authoring UX changes** beyond the kind picker hint. Required-field enforcement on `kind_id` already exists.
|
||||||
|
- **Removing the "Other" kind.** Stays as a catch-all default mapped to `'plating'`.
|
||||||
|
- **Card_state precedence rework.** Rules 1-13 stay; only the edge-case fallback changes.
|
||||||
|
- **Mini-timeline rendering.** Separate compute (`mini_timeline_json`), out of scope.
|
||||||
|
- **Hidden-but-recent done jobs.** No "recent shipments" filter.
|
||||||
|
- **Subjective node re-classification.** "Post Plate Inspection" stays whatever the recipe author picked (`inspect` vs `final_inspect`). Only the unambiguous patterns in Change 10 phase 2 are auto-migrated.
|
||||||
|
- **process_type_id / material_callout backfill on templates.** Out of scope for this spec — those need recipe-author input per template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched (summary)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating/models/fp_step_kind.py` | New `area_kind` Selection field (Change 5) |
|
||||||
|
| `fusion_plating/views/fp_step_kind_views.xml` | Add area_kind to form + list (Change 7) |
|
||||||
|
| `fusion_plating/controllers/simple_recipe_controller.py` | Include area_kind + label in kindOptions (Change 7) |
|
||||||
|
| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | Kind picker shows "→ Column" suffix (Change 7) |
|
||||||
|
| `fusion_plating/data/fp_step_kind_data.xml` | New `step_kind_blast` record (Change 8) |
|
||||||
|
| `fusion_plating/data/fp_step_template_data.xml` | New `Hot Water Porosity Test` + `Final Inspection / Packaging` templates (Change 9) |
|
||||||
|
| `fusion_plating/migrations/19.0.21.2.0/pre-migrate.py` | NEW — seed area_kind, activate kinds (Change 10 phase 1-2) |
|
||||||
|
| `fusion_plating/__manifest__.py` | Version bump (Change 11) |
|
||||||
|
| `fusion_plating_jobs/models/fp_job.py` | Rewrite `_compute_active_step_id` (Change 1) + `_compute_card_state` edge case (Change 2) |
|
||||||
|
| `fusion_plating_jobs/models/fp_job_step.py` | Simplify `_compute_area_kind` (Change 6); drop `_STEP_KIND_TO_AREA` dict |
|
||||||
|
| `fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py` | NEW — template backfill + node repointing + recomputes (Change 10 phase 1-4) |
|
||||||
|
| `fusion_plating_jobs/__manifest__.py` | Version bump (Change 11) |
|
||||||
|
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | Add state filter (Change 3) + comment (Change 4) |
|
||||||
|
| `fusion_plating_shopfloor/__manifest__.py` | Version bump (Change 11) |
|
||||||
|
| `fusion_plating_quality/scripts/bt_s24_between_steps.py` | NEW — battle test |
|
||||||
|
|
||||||
|
Estimated diff: ~400 lines added (most in the migration data tables), ~30 modified, ~50 deleted (the `_STEP_KIND_TO_AREA` dict goes away).
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# Tablet Lock Screen Redesign
|
||||||
|
|
||||||
|
**Date:** 2026-05-24
|
||||||
|
**Status:** Design — approved through brainstorming, awaiting plan
|
||||||
|
**Affects:** `fusion_plating_shopfloor` (FpTabletLock OWL component + tablet_controller)
|
||||||
|
**Scope:** Visual + interaction redesign only. PIN gate, unlock RPC, lockout timer, idle warning all unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
The current FpTabletLock tile screen looks like a placeholder. Two operators per row stretch their tiles across 900px max-width; the screen is mostly empty whitespace; there's no branding; the "Tap your name to unlock" prompt is the only header; no animations; no clock/date. Functionally correct but feels unfinished on a wall-mounted shop-floor tablet.
|
||||||
|
|
||||||
|
User feedback after live testing (2026-05-24):
|
||||||
|
|
||||||
|
> "i want company logo and other nice customization, add some animation, reduce the card width so its just enough, there may be many employees, i do not want a lot of scrolling but not cramped at the same time"
|
||||||
|
|
||||||
|
Target: tablet that looks like a deliberately-designed shop terminal, fits ~10-15 operators per screen without scrolling, brands the device with the company logo, and has subtle motion that signals "alive."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals & non-goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
1. **Brand the screen** — pull the company logo from `res.company.logo`, surface the company name + tagline.
|
||||||
|
2. **Tighter tile grid** — 3 columns max-width 480px, ~140px tile width. Fits 6 tiles per visible row; small shops (10-15 ops) show everything without scroll.
|
||||||
|
3. **Real-time clock + date** — operators glance at the lock screen for the time; big tabular-nums clock front-and-center.
|
||||||
|
4. **Subtle motion** — staggered entrance, hover lift, clocked-in pulse. Doesn't distract; signals freshness.
|
||||||
|
5. **Dark + light mode parity** — single SCSS source, branches at compile time via `$o-webclient-color-scheme`. No JS-side theme code.
|
||||||
|
6. **Accessibility** — `prefers-reduced-motion` respected, touch targets ≥ 44px, contrast WCAG AA in both modes.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **Replacing the PIN gate.** The 4-digit PIN flow (FpPinPad component, hash + lockout, /fp/tablet/unlock endpoint) stays identical.
|
||||||
|
- **Multi-tenant theming.** Each company sees its own logo via `res.company.logo`; we don't build a theme editor for accent colors. The amber accent is a hardcoded brand token in this design.
|
||||||
|
- **Search box on the lock screen.** For ~10-15 operators, scanning the grid is faster than typing. Search returns as a Phase 2 enhancement if a customer scales to 25+ ops.
|
||||||
|
- **Custom tile sort.** Existing rule stays: clocked-in operators first, then alphabetical.
|
||||||
|
- **Welcome animations / video / mascot.** Subtle motion only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Decisions locked during brainstorming
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| D1 | **Hybrid A+B vibe** — Industrial Bold structure (dark gradient bg, bold tabular clock, amber accent) wearing Premium Glassmorphism finish (frosted-glass tiles with backdrop-filter, smooth cubic-bezier hover). |
|
||||||
|
| D2 | **Company logo** sourced from `res.company.logo` (Odoo's standard company logo binary field) via `/web/image/res.company/<id>/logo`. Letter-mark fallback when no logo is uploaded — built from `res.company.name` initials. |
|
||||||
|
| D3 | **Company name + tagline** below the logo. Name = `res.company.name`. Tagline = `res.company.report_header` (existing field, also drives invoice letterheads — natural reuse) with fallback "Shop Floor Terminal" if empty. |
|
||||||
|
| D4 | **3-column tile grid**, max-width 480px on the grid container. Tile ~140px wide. Avatar 52px circular with status pulse-dot overlay. |
|
||||||
|
| D5 | **Dark + light mode parity.** Same OWL component + same XML; SCSS branches at compile time on `$o-webclient-color-scheme`. No runtime theme code. |
|
||||||
|
| D6 | **Animation catalogue** (full list in §6) — entrance stagger, hover lift, click scale, pulse on clocked-in dot, real-time clock update. `prefers-reduced-motion` disables all of these. |
|
||||||
|
| D7 | **Sort order unchanged** — clocked-in operators first, then alphabetical by name. |
|
||||||
|
| D8 | **No search box** for MVP — scoped for the ~10-15-operator small-shop case. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layout
|
||||||
|
|
||||||
|
The screen is a full-viewport flex column, centered, with this vertical sequence:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ │ ← logo frame (84×84)
|
||||||
|
│ │ LOGO │ (rounded 20, glass) │ glassmorphic
|
||||||
|
│ └─────────┘ │
|
||||||
|
│ Company Name │ ← logo-text (19px, 700)
|
||||||
|
│ PLATING · ESTD 1985 │ ← logo-sub (11px upper)
|
||||||
|
│ │
|
||||||
|
│ 21:09 │ ← clock (40px, 800, tabular)
|
||||||
|
│ SATURDAY · MAY 23 │ ← clock-date (12px upper)
|
||||||
|
│ │
|
||||||
|
│ [ 🔒 TAP YOUR NAME ] │ ← prompt pill
|
||||||
|
│ │
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ GS ● │ │ JM │ │ CV ● │ │
|
||||||
|
│ │Garry │ │Johnny│ │Carlos│ │ ← 3-column tile grid,
|
||||||
|
│ │CIN │ │PIN │ │CIN │ │ max-width 480px
|
||||||
|
│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ LB ● │ │ RB ● │ │ KP ● │ │
|
||||||
|
│ │ Lisa │ │ Ravi │ │ Kris │ │
|
||||||
|
│ │ CIN │ │ CIN │ │ CIN │ │
|
||||||
|
│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Spacing between sections: 22px gap. Logo block top margin: 28px. Outer padding: 28px 20px.
|
||||||
|
|
||||||
|
### 4.1 Logo block
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="o_fp_lock_logo_block">
|
||||||
|
<div class="o_fp_lock_logo_frame">
|
||||||
|
<img t-att-src="logoUrl" t-att-alt="companyName" t-if="logoUrl"/>
|
||||||
|
<div t-else="" class="o_fp_lock_logo_placeholder" t-esc="companyInitials"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_lock_logo_text" t-esc="companyName"/>
|
||||||
|
<div class="o_fp_lock_logo_sub" t-esc="companyTagline"/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `logoUrl`: `/web/image/res.company/<id>/logo` — Odoo serves the binary directly. Always 200 if the field is populated (even 1×1 transparent on empty record), so probe the field server-side before emitting the URL.
|
||||||
|
- `companyInitials`: first 1-2 letters of `res.company.name` (e.g. "EN" for "EN Technologies", "ABC" capped to 2 chars). Computed server-side, sent in the tiles-endpoint payload.
|
||||||
|
- `companyTagline`: from `res.company.report_header` field; defaults to "Shop Floor Terminal" when empty.
|
||||||
|
|
||||||
|
The logo frame is a 84×84 rounded-20 glassmorphic container — same frosted treatment as the tiles. Looks great whether the logo is a sharp PNG, transparent SVG, or the letter-mark fallback.
|
||||||
|
|
||||||
|
### 4.2 Clock block
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="o_fp_lock_clock_block">
|
||||||
|
<div class="o_fp_lock_clock" t-esc="state.clockText"/>
|
||||||
|
<div class="o_fp_lock_clock_date" t-esc="state.dateText"/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `state.clockText`: `HH:MM` (24h, configurable via `intl.DateTimeFormat`). Updates every minute via `setInterval` in `tablet_lock.js`.
|
||||||
|
- `state.dateText`: `WEEKDAY · MMM D` uppercase (e.g. "SATURDAY · MAY 23"). Recomputed on date change.
|
||||||
|
- Tabular numbers so digits don't jitter when changing.
|
||||||
|
- Initial render uses `new Date()` synchronously so there's no flash of empty content.
|
||||||
|
|
||||||
|
### 4.3 Prompt
|
||||||
|
|
||||||
|
A small pill, not a header:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="o_fp_lock_prompt">🔒 Tap your name</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Amber-tinted background (matches brand accent), uppercase with 0.18em letter-spacing. Sits between the clock and the tile grid as a visual anchor.
|
||||||
|
|
||||||
|
### 4.4 Tile grid
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="o_fp_lock_tiles">
|
||||||
|
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
|
||||||
|
<button class="o_fp_lock_tile"
|
||||||
|
t-att-style="'animation-delay: ' + tile.animDelay + 'ms'"
|
||||||
|
t-on-click="() => this.onTileClick(tile.user_id)">
|
||||||
|
<div t-att-class="tile.is_clocked_in ? 'o_fp_lock_avatar is-clocked' : 'o_fp_lock_avatar'"
|
||||||
|
t-att-style="'background: ' + tile.avatar_gradient">
|
||||||
|
<img t-if="tile.has_photo" t-att-src="tile.avatar_url" t-att-alt="tile.name"/>
|
||||||
|
<span t-else="" t-esc="tile.initials"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_lock_name" t-esc="tile.name"/>
|
||||||
|
<div t-if="tile.is_clocked_in" class="o_fp_lock_status status-clocked">Clocked in</div>
|
||||||
|
<div t-elif="!tile.has_pin" class="o_fp_lock_status status-pin">PIN required</div>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Grid: `grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 480px`.
|
||||||
|
- Animation delay computed JS-side per tile (50ms × index, capped at 300ms) so the stagger ripples without dragging.
|
||||||
|
- Avatar gradient (per-tile color): server-computed as `user.id % len(_AVATAR_GRADIENTS)` (8 colors). Deterministic — same operator gets the same color across sessions, so operators learn their own tile color. See §7.3 for the gradient list.
|
||||||
|
- `has_photo` is true when `res.users.image_128` is non-empty. Falls back to initials when empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Color system
|
||||||
|
|
||||||
|
All colors live in `_tablet_lock_tokens.scss` (new file, loaded before `tablet_lock.scss`). Same pattern as the plant-view tokens shipped earlier.
|
||||||
|
|
||||||
|
### Light-mode defaults
|
||||||
|
|
||||||
|
| Token | Hex | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `$_lock-bg-top` | `#fafafa` | Gradient top |
|
||||||
|
| `$_lock-bg-bottom` | `#f0f0f3` | Gradient bottom |
|
||||||
|
| `$_lock-accent` | `rgba(240,165,0,0.12)` | Top-radial ambient glow |
|
||||||
|
| `$_lock-accent-2` | `rgba(99,102,241,0.06)` | Bottom-radial ambient glow |
|
||||||
|
| `$_lock-text` | `#1d1f1e` | Primary text |
|
||||||
|
| `$_lock-muted` | `#71717a` | Secondary text |
|
||||||
|
| `$_lock-prompt` | `#b45309` | Prompt text |
|
||||||
|
| `$_lock-prompt-bg` | `rgba(240,165,0,0.10)` | Prompt pill bg |
|
||||||
|
| `$_lock-prompt-border` | `rgba(240,165,0,0.25)` | Prompt pill border |
|
||||||
|
| `$_lock-tile-bg` | `rgba(255,255,255,0.7)` | Tile bg (frosted) |
|
||||||
|
| `$_lock-tile-border` | `rgba(0,0,0,0.05)` | Tile border |
|
||||||
|
| `$_lock-tile-hover-bg` | `rgba(255,255,255,0.95)` | Tile hover bg |
|
||||||
|
| `$_lock-tile-hover-border` | `rgba(240,165,0,0.5)` | Tile hover border |
|
||||||
|
| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.18)` | Tile hover shadow |
|
||||||
|
| `$_lock-frame-bg` | `rgba(255,255,255,0.85)` | Logo frame bg |
|
||||||
|
| `$_lock-status-clocked` | `#16a34a` | Clocked-in green |
|
||||||
|
| `$_lock-status-pin` | `#d97706` | PIN required amber |
|
||||||
|
| `$_lock-pulse-dot-border` | `#fff` | Pulse-dot ring |
|
||||||
|
|
||||||
|
### Dark-mode overrides
|
||||||
|
|
||||||
|
| Token | Hex |
|
||||||
|
|---|---|
|
||||||
|
| `$_lock-bg-top` | `#1a1d21` (gradient base) |
|
||||||
|
| `$_lock-bg-bottom` | `#2d3138` |
|
||||||
|
| `$_lock-accent` | `rgba(240,165,0,0.08)` |
|
||||||
|
| `$_lock-accent-2` | `rgba(99,102,241,0.06)` |
|
||||||
|
| `$_lock-text` | `#f5f5f7` |
|
||||||
|
| `$_lock-muted` | `#adb5bd` |
|
||||||
|
| `$_lock-prompt` | `#f0a500` |
|
||||||
|
| `$_lock-prompt-bg` | `rgba(240,165,0,0.08)` |
|
||||||
|
| `$_lock-prompt-border` | `rgba(240,165,0,0.20)` |
|
||||||
|
| `$_lock-tile-bg` | `rgba(255,255,255,0.06)` |
|
||||||
|
| `$_lock-tile-border` | `rgba(255,255,255,0.08)` |
|
||||||
|
| `$_lock-tile-hover-bg` | `rgba(240,165,0,0.10)` |
|
||||||
|
| `$_lock-tile-hover-border` | `rgba(240,165,0,0.4)` |
|
||||||
|
| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.15), 0 0 0 1px rgba(240,165,0,0.2)` |
|
||||||
|
| `$_lock-frame-bg` | `rgba(255,255,255,0.08)` |
|
||||||
|
| `$_lock-status-clocked` | `#34c759` (brighter — needs to pop on dark) |
|
||||||
|
| `$_lock-status-pin` | `#ff9f0a` |
|
||||||
|
| `$_lock-pulse-dot-border` | `#2d3138` (so the dot reads as overlapping the dark tile, not floating) |
|
||||||
|
|
||||||
|
The full-screen background is a stack of two radial gradients (the ambient accent glows) over a linear gradient (the base), per `lock-final.html` from brainstorm:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top, $_lock-accent, transparent 50%),
|
||||||
|
radial-gradient(ellipse at bottom, $_lock-accent-2, transparent 50%),
|
||||||
|
linear-gradient(135deg, $_lock-bg-top 0%, $_lock-bg-bottom 100%);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Animation catalogue
|
||||||
|
|
||||||
|
All animations use `cubic-bezier(0.4, 0, 0.2, 1)` for consistency (the "standard easing" curve). Every animation is gated by `@media (prefers-reduced-motion: no-preference)` — operators who set reduced motion in OS preferences see the same screen with no movement.
|
||||||
|
|
||||||
|
| # | Name | What it does | Duration | Trigger |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `lockLogoEnter` | Logo block fades down + slides in | 500ms | onMount |
|
||||||
|
| 2 | `lockClockEnter` | Clock + prompt fade up | 500ms (100ms delay) | onMount |
|
||||||
|
| 3 | `lockTileEnter` | Each tile fades + slides up + scales from 0.96 | 400ms (50ms staggered per index, max 6) | onMount |
|
||||||
|
| 4 | `lockTileHover` | Lift translateY(-3px) + colored shadow + border glow | 250ms | hover/focus |
|
||||||
|
| 5 | `lockTilePress` | Quick scale(0.97) | 50ms | active/click |
|
||||||
|
| 6 | `lockPulseDot` | Green clocked-in dot pulses (ring expands + fades) | 2s loop | clocked-in state present |
|
||||||
|
| 7 | `lockClockTick` | (no animation — just text content update each minute) | — | `setInterval(60000)` |
|
||||||
|
|
||||||
|
### Reduced-motion override
|
||||||
|
|
||||||
|
```scss
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.o_fp_lock_logo_block,
|
||||||
|
.o_fp_lock_clock_block,
|
||||||
|
.o_fp_lock_prompt,
|
||||||
|
.o_fp_lock_tile,
|
||||||
|
.o_fp_lock_avatar.is-clocked::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stagger cap
|
||||||
|
|
||||||
|
For very large operator counts the per-tile delay caps at 300ms (6 tiles × 50ms) so the screen doesn't take 3 seconds to settle. Compute `animDelay = Math.min(index * 50, 300)` JS-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backend changes
|
||||||
|
|
||||||
|
### 7.1 Extend `/fp/tablet/tiles` payload
|
||||||
|
|
||||||
|
Currently returns:
|
||||||
|
```json
|
||||||
|
{"ok": true, "tiles": [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
After redesign:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"company": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "EN Technologies",
|
||||||
|
"tagline": "Plating & Finishing",
|
||||||
|
"logo_url": "/web/image/res.company/1/logo",
|
||||||
|
"has_logo": true,
|
||||||
|
"initials": "EN"
|
||||||
|
},
|
||||||
|
"tiles": [
|
||||||
|
{
|
||||||
|
"user_id": 5,
|
||||||
|
"name": "Garry Singh",
|
||||||
|
"initials": "GS",
|
||||||
|
"avatar_url": "/web/image/res.users/5/avatar_128",
|
||||||
|
"has_photo": true,
|
||||||
|
"is_clocked_in": true,
|
||||||
|
"has_pin": true,
|
||||||
|
"avatar_gradient": "linear-gradient(135deg, #ef4444, #dc2626)"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New fields per tile:
|
||||||
|
- `initials`: server-computed from `res.users.name` (first letter of first + last word, capped 2 chars).
|
||||||
|
- `has_photo`: true when `res.users.image_128` is non-empty (avoids the 1×1 default-image flash).
|
||||||
|
- `avatar_gradient`: deterministic from hash of user.id. Same gradient across sessions so operators recognize "their" tile color.
|
||||||
|
|
||||||
|
The company block is one query: `env.company.id`. Read `name`, `report_header`, check `logo` non-empty.
|
||||||
|
|
||||||
|
### 7.2 `_lock_company_payload` helper
|
||||||
|
|
||||||
|
A small module-level helper in `tablet_controller.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _lock_company_payload(env):
|
||||||
|
"""Returns the company info block for the lock screen."""
|
||||||
|
co = env.company
|
||||||
|
return {
|
||||||
|
'id': co.id,
|
||||||
|
'name': co.name or '',
|
||||||
|
'tagline': co.report_header or _('Shop Floor Terminal'),
|
||||||
|
'logo_url': f'/web/image/res.company/{co.id}/logo',
|
||||||
|
'has_logo': bool(co.logo),
|
||||||
|
'initials': _initials_from(co.name),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _initials_from(name):
|
||||||
|
"""First letter of first + last word, capped at 2 chars uppercase."""
|
||||||
|
if not name:
|
||||||
|
return '?'
|
||||||
|
words = name.strip().split()
|
||||||
|
if len(words) == 1:
|
||||||
|
return words[0][:2].upper()
|
||||||
|
return (words[0][0] + words[-1][0]).upper()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 `_avatar_gradient_for` helper
|
||||||
|
|
||||||
|
```python
|
||||||
|
_AVATAR_GRADIENTS = [
|
||||||
|
'linear-gradient(135deg, #ef4444, #dc2626)', # red
|
||||||
|
'linear-gradient(135deg, #f59e0b, #d97706)', # amber
|
||||||
|
'linear-gradient(135deg, #10b981, #059669)', # emerald
|
||||||
|
'linear-gradient(135deg, #3b82f6, #2563eb)', # blue
|
||||||
|
'linear-gradient(135deg, #8b5cf6, #7c3aed)', # violet
|
||||||
|
'linear-gradient(135deg, #ec4899, #db2777)', # pink
|
||||||
|
'linear-gradient(135deg, #14b8a6, #0d9488)', # teal
|
||||||
|
'linear-gradient(135deg, #f97316, #ea580c)', # orange
|
||||||
|
]
|
||||||
|
|
||||||
|
def _avatar_gradient_for(user_id):
|
||||||
|
return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)]
|
||||||
|
```
|
||||||
|
|
||||||
|
8 colors, modulo user_id — same operator gets the same color forever. Sufficient variety for a small shop (10-15 ops have <2 collisions on average).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend changes
|
||||||
|
|
||||||
|
### 8.1 Files modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `static/src/scss/_tablet_lock_tokens.scss` | **new** — design tokens (loads first) |
|
||||||
|
| `static/src/scss/tablet_lock.scss` | full rewrite — gradient bg, logo block, clock block, prompt, tile grid, animations, dark/light branches |
|
||||||
|
| `static/src/xml/tablet_lock.xml` | wrap existing tile loop with new logo + clock + prompt blocks; add fallback structures |
|
||||||
|
| `static/src/js/tablet_lock.js` | add `state.clockText` + `state.dateText` + `_tickClock` setInterval; add `state.company`; consume new payload fields |
|
||||||
|
|
||||||
|
### 8.2 OWL component reactivity for the clock
|
||||||
|
|
||||||
|
The clock updates every 60 seconds:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
setup() {
|
||||||
|
// ... existing setup ...
|
||||||
|
this.state = useState({
|
||||||
|
// ... existing state ...
|
||||||
|
clockText: this._formatTime(new Date()),
|
||||||
|
dateText: this._formatDate(new Date()),
|
||||||
|
company: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// ... existing onMounted ...
|
||||||
|
this._clockInterval = setInterval(() => {
|
||||||
|
const now = new Date();
|
||||||
|
this.state.clockText = this._formatTime(now);
|
||||||
|
this.state.dateText = this._formatDate(now);
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillUnmount(() => {
|
||||||
|
// ... existing cleanup ...
|
||||||
|
if (this._clockInterval) clearInterval(this._clockInterval);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatTime(d) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatDate(d) {
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long', month: 'short', day: 'numeric'
|
||||||
|
}).toUpperCase().replace(',', ' ·');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per project rule 20:** all the date/number formatting happens in JS (`_formatTime`, `_formatDate`). The template only renders `state.clockText` / `state.dateText` via `t-esc`. No `String()` / `Number()` / `padStart` calls inside the XML.
|
||||||
|
|
||||||
|
### 8.3 Stagger delay computed JS-side
|
||||||
|
|
||||||
|
In `_loadTiles`, after fetching, decorate each tile with its `animDelay`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async _loadTiles() {
|
||||||
|
this.state.loadingTiles = true;
|
||||||
|
try {
|
||||||
|
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
|
||||||
|
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
|
||||||
|
if (res && res.ok) {
|
||||||
|
this.state.company = res.company || null;
|
||||||
|
this.state.tiles = res.tiles.map((tile, idx) => ({
|
||||||
|
...tile,
|
||||||
|
animDelay: Math.min(idx * 50, 300), // cap at 300ms
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Existing quiet fail
|
||||||
|
} finally {
|
||||||
|
this.state.loadingTiles = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Manifest registration
|
||||||
|
|
||||||
|
Adding two SCSS files. Per project rule 8 (SCSS @import forbidden), tokens must register BEFORE the consumer:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In fusion_plating_shopfloor/__manifest__.py, the lock screen block:
|
||||||
|
'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss', # NEW — load first
|
||||||
|
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss', # existing — rewritten
|
||||||
|
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml', # existing — extended
|
||||||
|
'fusion_plating_shopfloor/static/src/js/tablet_lock.js', # existing — extended
|
||||||
|
```
|
||||||
|
|
||||||
|
The tokens file lives in `scss/` (not `scss/components/`) because it's session-level — one tokens file for the whole lock-screen experience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Accessibility
|
||||||
|
|
||||||
|
- **Touch targets**: avatar 52px + 14px padding = 80px tile content; tile itself extends to grid cell width ~140px × 110px tall. Both axes well above the 44×44 WCAG minimum.
|
||||||
|
- **Focus rings**: visible 2px solid amber outline on `:focus-visible`. Distinguishes keyboard navigation from mouse hover.
|
||||||
|
- **Contrast**:
|
||||||
|
- Dark mode: white text on `#1a1d21` background = 16.7:1 (AAA).
|
||||||
|
- Light mode: `#1d1f1e` text on `#fafafa` background = 17.8:1 (AAA).
|
||||||
|
- Amber prompt text on its tinted bg: 5.2:1 (AA passes).
|
||||||
|
- **Reduced motion**: full media-query gate documented in §6.
|
||||||
|
- **Alt text**: logo `<img alt="Company Name">` so screen readers announce the brand on focus.
|
||||||
|
- **Keyboard navigation**: tab order = logo (skip) → tiles in DOM order → first tile receives initial focus on mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing strategy
|
||||||
|
|
||||||
|
### 10.1 Unit / integration
|
||||||
|
|
||||||
|
- `test_tablet_tiles_endpoint_includes_company` — call `/fp/tablet/tiles`, assert response has `company` block with required keys.
|
||||||
|
- `test_initials_from_helper` — edge cases: empty name, single-word name, multi-word name with hyphens.
|
||||||
|
- `test_avatar_gradient_deterministic` — same user.id returns same gradient across calls.
|
||||||
|
|
||||||
|
### 10.2 Visual snapshot tests
|
||||||
|
|
||||||
|
Per state, a Playwright snapshot of the lock screen at `1366×768` (typical tablet) in both light and dark mode. Snapshots checked in; PR diff catches accidental CSS regressions.
|
||||||
|
|
||||||
|
### 10.3 Persona walks
|
||||||
|
|
||||||
|
- **Cold start** — operator approaches tablet with no recent session. Clock displays current time; tiles fade in; clicking own tile opens PIN pad immediately (no visible loading state).
|
||||||
|
- **Mid-shift unlock** — operator returns after auto-lock. Same flow; their tile shows the pulsing clocked-in dot.
|
||||||
|
- **No logo configured** — companies that haven't set `res.company.logo`. Letter-mark renders cleanly; layout unchanged.
|
||||||
|
- **Reduced motion** — toggle the OS preference; verify all animations disabled, layout still works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Migration & rollout
|
||||||
|
|
||||||
|
No database migration needed — this is a presentation-layer change reusing existing fields (`res.company.logo`, `res.company.report_header`, `res.users.image_128`).
|
||||||
|
|
||||||
|
### Rollout sequence
|
||||||
|
|
||||||
|
1. Add tokens SCSS + extend tablet_controller payload — backend deploy.
|
||||||
|
2. Rewrite tablet_lock.scss + extend XML + extend JS — frontend deploy + asset cache bust.
|
||||||
|
3. Verify on entech: open the tablet lock URL on a real iPad and a desktop browser.
|
||||||
|
4. Iterate on visual details (logo padding, gradient intensity, accent color) based on shop-floor feedback.
|
||||||
|
|
||||||
|
No feature flag — the redesign is a strict visual improvement, no behavioral changes. Reverting is `git revert <commit>` if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Open questions (deferred)
|
||||||
|
|
||||||
|
| # | Question | Resolution |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | Search box for 25+ operator shops? | **Phase 2.** MVP scoped to ~10-15 ops. Re-evaluate when a customer scales. |
|
||||||
|
| Q2 | Custom accent color per company? | **Phase 2.** Amber is hardcoded in tokens for MVP. Could be a `res.company.x_fc_shopfloor_accent` field later. |
|
||||||
|
| Q3 | Weather / news widget on lock screen? | **No.** Out of scope; clutters the screen. Operators don't need it. |
|
||||||
|
| Q4 | Multi-language toggle visible on lock screen? | **No for MVP.** Existing user.lang flow handles this server-side; lock screen renders in the user's language once they're identified post-PIN. |
|
||||||
|
| Q5 | Operator photo upload UX? | **Existing flow stays** — managers upload via Preferences → My Profile. Lock screen consumes whatever's there. |
|
||||||
|
| Q6 | Animation when transitioning tile → PIN pad? | **Phase 2 polish.** Currently the existing FpPinPad just appears; could add a crossfade. Subjective; ship clean first. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Summary
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Layout | Vertical centered flex column: logo (84px) → clock (40px) → prompt pill → 3-column tile grid (max 480px) |
|
||||||
|
| Card model | One tile per `res.users` with tablet PIN configured (existing rule); deterministic per-user color gradient |
|
||||||
|
| Card density | 3 columns, ~140px tiles — fits ~9-12 visible without scroll on a 1366×768 tablet |
|
||||||
|
| Animation | 7 named animations (entrance stagger, hover lift, click press, status pulse) all bezier-eased, all gated by `prefers-reduced-motion` |
|
||||||
|
| Dark / Light mode | Single SCSS source with compile-time `$o-webclient-color-scheme` branch — same component, two bundles, no JS theme code |
|
||||||
|
| Backend touch | Extend `/fp/tablet/tiles` payload with `company` block + per-tile `initials`/`avatar_gradient`/`has_photo`. Two small helper functions. |
|
||||||
|
| Frontend touch | New `_tablet_lock_tokens.scss`. Full rewrite of `tablet_lock.scss`. Extend XML + JS for clock + company block. |
|
||||||
|
| Rollout | No DB migration. Plain code deploy + asset cache bust. No feature flag. |
|
||||||
|
|
||||||
|
The redesign solves the "looks like a placeholder" feel by branding the screen with the company logo, adding a real-time clock, tightening the tile grid for the small-shop case, and layering glassmorphic finishes + cubic-bezier animations on a hybrid Industrial Bold + Premium structure. Dark and light modes share one source.
|
||||||
|
|
||||||
|
Implementation plan to follow.
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# Tablet PIN Session Redesign — Design Document
|
||||||
|
|
||||||
|
**Date:** 2026-05-24
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Owner module:** `fusion_plating_shopfloor` (with minor changes in `fusion_plating` for ACL data)
|
||||||
|
**Brainstorm:** session with @gsinghpal, 2026-05-24
|
||||||
|
**Linked plan:** TBD (writing-plans skill, next step)
|
||||||
|
**Related:** `docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md` (Phase 1 permissions overhaul which surfaced this gap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current tablet PIN system on entech is **theatre**. It looks like a security/audit gate but doesn't actually enforce attribution:
|
||||||
|
|
||||||
|
- The shop-floor tablet PC logs in ONCE as a persistent "shopfloor service" Odoo user. That session never changes.
|
||||||
|
- When a tech taps their tile and enters the PIN, the OWL frontend stores a `current_tech_id` in an in-memory service (`fp_shopfloor_tech_store`). The underlying Odoo session cookie does NOT change.
|
||||||
|
- ~15 of the ~30 shop-floor write endpoints accept a `tablet_tech_id` kwarg and use `env_for_tablet_tech` to attribute the write to the tech via `env(user=tech_id)`. The OTHER ~15 endpoints write under the session user (the shopfloor service user). The audit trail is incomplete.
|
||||||
|
- There is no idle timeout, no re-lock on walk-away. Once unlocked, the `tech_id` sits in OWL state until someone manually swaps it. A tech walks away, the next person clicks a job card → Odoo records the work under the prior tech.
|
||||||
|
- A tech (or anyone with browser access) can type any URL — `/odoo/settings`, `/web/...` — and act under the shopfloor service user's full backend privileges. The "lock" is only an OWL overlay over the kanban; it doesn't gate URL navigation.
|
||||||
|
|
||||||
|
**The whole point of adding a PIN was to enforce "log who did what."** Today it doesn't. AS9100/Nadcap audit trails are unreliable because attribution can be wrong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Locked Design Decisions
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | Session model | **Real per-tech sessions (impersonation).** After PIN unlock the backend creates a REAL Odoo session AS the tech (cookie swap, server-side session row). They literally ARE that user for the duration of the unlock. |
|
||||||
|
| Q2 | Lock-back trigger | **Idle timeout + manual lock + hard ceiling.** Default 10 min idle, 8 hr hard ceiling regardless of activity. Manual "Lock" / "Hand-Off" button always available. |
|
||||||
|
| Q3 | Kiosk identity | **Dedicated kiosk user.** New user `fp_tablet_kiosk@enplating.local` in a new `group_fp_tablet_kiosk` group with near-zero ACL (read `res.users` for tile grid + read `ir.config_parameter` for idle settings). |
|
||||||
|
| Q4 | Manager override / impersonation | **No special path.** Manager wanting to chip in must PIN in as themselves. Simpler, strongest audit. |
|
||||||
|
| Q5 | OLD endpoint lifecycle | **Remove after successful rollout.** Two-step deploy with 1-week overlap, then Step 3 cleanup commits remove the legacy `tablet_tech_id` plumbing. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1 — Architecture Overview
|
||||||
|
|
||||||
|
### Three identities, two state transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ STATE: KIOSK │
|
||||||
|
│ ──────────── │
|
||||||
|
│ Browser cookie = kiosk session_id │
|
||||||
|
│ Session uid = fp_tablet_kiosk (new user, near-zero ACL) │
|
||||||
|
│ Visible = lock screen + tile grid only │
|
||||||
|
│ Idle timer = OFF │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Tech taps tile + enters correct PIN
|
||||||
|
│ POST /fp/tablet/unlock_session
|
||||||
|
│ → server: verify hash, mint new session
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ STATE: TECH │
|
||||||
|
│ ─────────── │
|
||||||
|
│ Browser cookie = tech session_id (fresh Set-Cookie from unlock) │
|
||||||
|
│ Session uid = tech.id (real Odoo session as the tech) │
|
||||||
|
│ Visible = full Plating UI per tech's normal ACLs │
|
||||||
|
│ Idle timer = ON (10 min default) │
|
||||||
|
│ Hard ceiling = 8 hr from session_started_at │
|
||||||
|
│ │
|
||||||
|
│ EVERY Odoo write naturally attributed via session.uid: │
|
||||||
|
│ create_uid = tech.id │
|
||||||
|
│ write_uid = tech.id │
|
||||||
|
│ chatter authorship = tech.partner_id │
|
||||||
|
│ NO MORE tablet_tech_id plumbing needed │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Any of: manual Lock button / 10 min idle /
|
||||||
|
│ 8 hr ceiling
|
||||||
|
│ POST /fp/tablet/lock_session
|
||||||
|
│ → server: destroy session, re-auth as kiosk
|
||||||
|
▼
|
||||||
|
back to KIOSK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why this is fundamentally different from today
|
||||||
|
|
||||||
|
Today the tablet has ONE persistent session. The PIN is an OWL overlay; the underlying Odoo session never changes. New design: every PIN unlock creates a NEW Odoo session AS the tech. Lock-back DESTROYS that session and creates a fresh kiosk session.
|
||||||
|
|
||||||
|
Result: Odoo's standard `create_uid` / `write_uid` / chatter authorship already attributes everything correctly. We drop the `tablet_tech_id` kwarg + `env_for_tablet_tech` helper entirely — they become dead code.
|
||||||
|
|
||||||
|
**Security gain:** If the tech navigates to ANY Odoo URL (Settings, Users, anything), they're acting under their own permissions, not the kiosk's. The kiosk user has near-zero ACLs so even if someone hits `/fp/...` URLs before PIN-ing in, they see nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2 — Components & Files
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml` | Idempotent create of `fp_tablet_kiosk@enplating.local` + assignment to `group_fp_tablet_kiosk`. Default password = random secret stored in `ir.config_parameter['fp.tablet.kiosk_password']` (sysadmin can reset). |
|
||||||
|
| `fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml` | New `res.groups` `group_fp_tablet_kiosk`. NOT under the Fusion Plating privilege block — orthogonal to the 8-role hierarchy. `privilege_id` stays empty so it doesn't pollute the role dropdown. |
|
||||||
|
| `fusion_plating_shopfloor/security/ir.model.access.csv` (rows added) | 2 ACL rows for kiosk: READ `res.users` (tile grid) + READ `ir.config_parameter` (idle settings). Nothing else. No fp.job, no sale.order, no anything. |
|
||||||
|
| `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | New model `fp.tablet.session.event` — append-only audit log. Owner-only read; only base.group_system can ever write/unlink (we don't grant that). |
|
||||||
|
| `fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml` | List + form views. Owner-only menu under Plating → Configuration → Tablet Audit Log. Smart button on `res.users` form. |
|
||||||
|
| `fusion_plating_shopfloor/models/res_users.py` (existing — add auth check) | Override `_check_credentials` to handle `type='fp_tablet_pin'` (custom auth manager). Verifies PIN hash + recordset state; raises `AccessDenied` on any failure. |
|
||||||
|
| `fusion_plating_shopfloor/data/fp_tablet_cron.xml` | New cron `_cron_force_lock_stale_sessions` (every 5 min). Finds any active fp.tablet.session.event past 8hr ceiling with no `session_ended_at`, force-marks it ended. |
|
||||||
|
| `fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js` | OWL service. Tracks idle time via DOM event listeners. Fires lock-back at 10min idle or 8hr ceiling. Replaces `fp_shopfloor_tech_store`. |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|---|---|
|
||||||
|
| `controllers/tablet_controller.py` | Replace `/fp/tablet/unlock` with two new endpoints: `/fp/tablet/unlock_session` (verifies PIN → mints new Odoo session via `request.session.authenticate(db, {type:'fp_tablet_pin', ...})`), `/fp/tablet/lock_session` (destroys tech session → re-auths as kiosk). OLD `/fp/tablet/unlock` kept alive during the 1-week overlap. |
|
||||||
|
| `controllers/_tablet_audit.py` | `env_for_tablet_tech` becomes a one-line no-op pass-through. Marked deprecated; deleted in Step 3 cleanup. |
|
||||||
|
| All ~15 endpoints with `tablet_tech_id` kwarg | Step 3 (post-overlap) — remove the kwarg. Endpoints run under `request.env.user` which IS the tech (because session swap). |
|
||||||
|
| OWL `FpTabletLock` component | Use new `tablet_session_manager` service. On successful unlock, `window.location.reload()` so the entire app re-bootstraps with the tech's session cookie. (Cleanest — no half-state.) |
|
||||||
|
| `services/fp_rpc.js` | Stop auto-injecting `tablet_tech_id`. Becomes a thin wrapper, removable once endpoints drop the kwarg in Step 3. |
|
||||||
|
|
||||||
|
### Auth path — custom auth manager
|
||||||
|
|
||||||
|
**Method:** Register a new auth type `fp_tablet_pin` via `res.users._check_credentials`.
|
||||||
|
|
||||||
|
1. PIN unlock endpoint calls `request.session.authenticate(request.db, {'type': 'fp_tablet_pin', 'login': tech.login, 'pin': pin})`.
|
||||||
|
2. Odoo's standard auth flow takes over: `_check_credentials` is invoked, sees `type='fp_tablet_pin'`, calls our handler.
|
||||||
|
3. Our handler hashes the PIN and compares against `tech.x_fc_tablet_pin_hash`. Validates `tech.active` and that the tech holds any shop-branch group.
|
||||||
|
4. On success, Odoo issues the session, sets cookie, returns response — **same code path Odoo uses for password login**. We get session lifecycle hooks, validation chain, and security for free.
|
||||||
|
|
||||||
|
**Alternative considered:** direct `request.session.uid = tech.id` + manual cookie. Faster to implement but bypasses Odoo's `_check_credentials` validation chain (2FA, IP gating, future security modules). Picked the slower-to-implement but correct path.
|
||||||
|
|
||||||
|
### Idle-timer mechanics (OWL service)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// tablet_session_manager.js (sketch)
|
||||||
|
class TabletSessionManager {
|
||||||
|
setup(env) {
|
||||||
|
this.idleMs = 10 * 60 * 1000; // 10 min, configurable via ir.config_parameter
|
||||||
|
this.ceilingMs = 8 * 60 * 60 * 1000; // 8 hr hard
|
||||||
|
this.lastActivity = Date.now();
|
||||||
|
this.sessionStartedAt = ...; // from server on bootstrap
|
||||||
|
['click', 'touchstart', 'keydown', 'mousemove'].forEach(ev =>
|
||||||
|
document.addEventListener(ev, () => this.touch(), { passive: true })
|
||||||
|
);
|
||||||
|
setInterval(() => this.tick(), 5000);
|
||||||
|
}
|
||||||
|
touch() { this.lastActivity = Date.now(); }
|
||||||
|
tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastActivity > this.idleMs ||
|
||||||
|
now - this.sessionStartedAt > this.ceilingMs) {
|
||||||
|
this.lockBack(now - this.lastActivity > this.idleMs ? 'idle' : 'ceiling');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async lockBack(reason) {
|
||||||
|
await rpc("/fp/tablet/lock_session", { reason });
|
||||||
|
window.location.reload(); // fresh page → fresh kiosk session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side belt-and-suspenders: cron `_cron_force_lock_stale_sessions` runs every 5 min and force-destroys any tablet session past the 8-hr ceiling — handles browser crashes, tablet reboots with stale cookie, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3 — Session Lifecycle in Detail
|
||||||
|
|
||||||
|
### Unlock flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Tablet OWL Server
|
||||||
|
────── ─── ──────
|
||||||
|
Tap tile ──────────────▶ PIN pad opens
|
||||||
|
Enter 4 digits ────────────▶ collect PIN
|
||||||
|
POST /fp/tablet/unlock_session
|
||||||
|
{ tech_id, pin }
|
||||||
|
cookie: kiosk_session_id
|
||||||
|
────────────────────▶
|
||||||
|
1. verify kiosk session active
|
||||||
|
2. browse(tech_id), exists+active
|
||||||
|
3. lockout check (failed_count,
|
||||||
|
locked_until)
|
||||||
|
4. verify_tablet_pin via hash
|
||||||
|
│
|
||||||
|
├─ FAIL → fp.tablet.session.event
|
||||||
|
│ (failed_unlock,
|
||||||
|
│ attempted_user_id=tech_id,
|
||||||
|
│ failure_reason='wrong_pin',
|
||||||
|
│ ip, ua)
|
||||||
|
│ + increment failed_count
|
||||||
|
│ + maybe set locked_until
|
||||||
|
│ + return {ok:false, error}
|
||||||
|
│
|
||||||
|
└─ PASS:
|
||||||
|
5. session.authenticate(db, {
|
||||||
|
type:'fp_tablet_pin',
|
||||||
|
login:tech.login,
|
||||||
|
pin:pin })
|
||||||
|
→ Odoo issues new sid,
|
||||||
|
uid=tech.id
|
||||||
|
→ response Set-Cookie
|
||||||
|
6. fp.tablet.session.event
|
||||||
|
(unlock, user_id=tech_id,
|
||||||
|
session_id_hash=sha256(sid),
|
||||||
|
session_started_at=now,
|
||||||
|
ip, ua)
|
||||||
|
7. reset failed_count=0
|
||||||
|
8. return {ok:true, tech_name}
|
||||||
|
◀────────────────────
|
||||||
|
window.location.reload()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lock-back flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Trigger (any of):
|
||||||
|
- User taps Lock button
|
||||||
|
- 10 min no activity (idleMs)
|
||||||
|
- 8 hr since session_started_at (ceilingMs)
|
||||||
|
|
||||||
|
POST /fp/tablet/lock_session { reason: 'manual' | 'idle' | 'ceiling' }
|
||||||
|
cookie: tech_session_id
|
||||||
|
|
||||||
|
──────▶ server:
|
||||||
|
1. read session.uid (current tech)
|
||||||
|
2. fp.tablet.session.event
|
||||||
|
(event_type matches reason,
|
||||||
|
user_id=tech_id, session_id_hash,
|
||||||
|
session_ended_at=now,
|
||||||
|
duration_seconds=now - session_started_at)
|
||||||
|
3. request.session.logout()
|
||||||
|
4. response Set-Cookie: clear tech session
|
||||||
|
5. session.authenticate(db, {type:'password',
|
||||||
|
login:'fp_tablet_kiosk',
|
||||||
|
password:KIOSK_SECRET_from_ir_config})
|
||||||
|
→ response carries new kiosk Set-Cookie
|
||||||
|
6. return {ok:true, locked_at:now}
|
||||||
|
|
||||||
|
◀────── browser receives Set-Cookie (kiosk session)
|
||||||
|
window.location.reload()
|
||||||
|
App re-bootstraps as kiosk → lock screen renders
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge cases (must work)
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Tech walks away, browser crashes | Cron `_cron_force_lock_stale_sessions` (every 5 min) finds the stale session, writes `event_type='force_lock'`. Next tablet boot starts as kiosk regardless. |
|
||||||
|
| Two techs race-tap two tiles simultaneously | First unlock wins. Second tech's POST sent with the FIRST tech's NEW cookie (Set-Cookie applied) → backend sees them as tech 1, returns access-denied to mint another session. UI debounces with spinner. |
|
||||||
|
| Wrong PIN 5 times | `failed_count` → 5, `locked_until` set to now+5min. Subsequent PIN attempts return `{ok:false, locked_until}` → UI shows countdown. Audit event each attempt. |
|
||||||
|
| Network drops mid-unlock | OWL gets timeout → retry button. Session NOT created server-side if request never completed. Single DB transaction guarantees atomicity. |
|
||||||
|
| Browser pre-fills cached cookie from old session | Server validates session row; if invalid, returns 401, OWL forces page reload → re-authenticates as kiosk. |
|
||||||
|
| Manager force-resets a tech's PIN while tech is unlocked | Tech's current session keeps working (sessions independent of PIN hash). Next PIN entry requires the new PIN. Manager action logged via `admin_reset` event. |
|
||||||
|
| Tech navigates to `/odoo/settings` or other URLs | They're a real Odoo user with their own ACLs. Standard ACLs apply. Technician sees what a Technician would see (mostly nothing — Manager+ only menus). |
|
||||||
|
| Tablet PC reboots mid-shift | Boots to login page (kiosk session cookie may have expired). Stored kiosk credential auto-fills. Reaches lock screen, ready for PIN. |
|
||||||
|
|
||||||
|
### Concurrency / race protection
|
||||||
|
|
||||||
|
- `unlock_session` takes a DB row lock on `res.users(id=tech_id)` for the duration of `verify_tablet_pin` + `failed_count` write. Prevents double-counting failed attempts.
|
||||||
|
- `fp.tablet.session.event` writes are sudo'd and append-only. Race conditions produce two adjacent audit rows (sortable by `create_date`) — never lose data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4 — Audit Log Model + UI
|
||||||
|
|
||||||
|
### Model: `fp.tablet.session.event`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FpTabletSessionEvent(models.Model):
|
||||||
|
_name = 'fp.tablet.session.event'
|
||||||
|
_description = 'Tablet Session Event (audit log)'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
_rec_name = 'event_type'
|
||||||
|
|
||||||
|
event_type = fields.Selection([
|
||||||
|
('unlock', 'Unlock (PIN success)'),
|
||||||
|
('failed_unlock', 'Failed PIN attempt'),
|
||||||
|
('manual_lock', 'Manual lock (Hand-Off button)'),
|
||||||
|
('idle_lock', 'Idle timeout lock'),
|
||||||
|
('ceiling_lock', '8-hour ceiling lock'),
|
||||||
|
('force_lock', 'Force lock (cron, stale session)'),
|
||||||
|
('admin_reset', 'Admin force-reset PIN'),
|
||||||
|
], required=True, readonly=True, index=True)
|
||||||
|
|
||||||
|
user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict',
|
||||||
|
help='The tech whose session was unlocked/locked. NULL for failed '
|
||||||
|
'attempts where the tile was tapped but unlock never succeeded.')
|
||||||
|
attempted_user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict',
|
||||||
|
help='For failed_unlock: which tile was tapped. user_id stays empty.')
|
||||||
|
|
||||||
|
session_id_hash = fields.Char(readonly=True,
|
||||||
|
help='sha256 hash of the Odoo session sid. Lets us correlate '
|
||||||
|
'events for the same session without storing the raw token.')
|
||||||
|
session_started_at = fields.Datetime(readonly=True)
|
||||||
|
session_ended_at = fields.Datetime(readonly=True)
|
||||||
|
duration_seconds = fields.Integer(readonly=True)
|
||||||
|
|
||||||
|
ip_address = fields.Char(readonly=True)
|
||||||
|
user_agent = fields.Char(readonly=True, help='Trimmed to 256 chars.')
|
||||||
|
|
||||||
|
failure_reason = fields.Selection([
|
||||||
|
('wrong_pin', 'Wrong PIN'),
|
||||||
|
('locked_out', 'Locked out (too many failures)'),
|
||||||
|
('no_pin_set', 'No PIN configured'),
|
||||||
|
('user_inactive', 'User archived or disabled'),
|
||||||
|
('no_role', 'User has no shop-branch role'),
|
||||||
|
], readonly=True)
|
||||||
|
|
||||||
|
acting_uid = fields.Many2one('res.users', readonly=True,
|
||||||
|
help='The user the SERVER saw at request time. Usually fp_tablet_kiosk '
|
||||||
|
'for unlocks; the manager for admin_reset; base.user_root for cron.')
|
||||||
|
|
||||||
|
notes = fields.Text(readonly=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design rules:**
|
||||||
|
- Append-only. No `_inherit = 'mail.thread'`. No `write()` or `unlink()` ACL granted to any group except `base.group_system` (which we DON'T grant — root SQL access required to tamper).
|
||||||
|
- Hashed session sid, not raw — if DB leaks, attackers can't replay sessions.
|
||||||
|
- `attempted_user_id` cleanly distinguishes "Carlos's tile was tapped" from "Carlos was authenticated."
|
||||||
|
|
||||||
|
### Lifecycle: who writes what
|
||||||
|
|
||||||
|
| Trigger | Endpoint | event_type | user_id | attempted_user_id | acting_uid |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Successful PIN unlock | `/fp/tablet/unlock_session` | unlock | tech | — | kiosk |
|
||||||
|
| Wrong PIN | `/fp/tablet/unlock_session` (fail) | failed_unlock | — | tech | kiosk |
|
||||||
|
| 5th wrong PIN → lockout | same | failed_unlock (`locked_out`) | — | tech | kiosk |
|
||||||
|
| Lock button | `/fp/tablet/lock_session` | manual_lock | tech | — | tech |
|
||||||
|
| 10 min idle | `/fp/tablet/lock_session` | idle_lock | tech | — | tech |
|
||||||
|
| 8 hr ceiling | `/fp/tablet/lock_session` or cron | ceiling_lock | tech | — | tech or cron |
|
||||||
|
| Cron force-lock | `_cron_force_lock_stale_sessions` | force_lock | tech | — | base.user_root |
|
||||||
|
| Manager resets PIN | `/fp/tablet/reset_pin_for` | admin_reset | tech | — | manager |
|
||||||
|
|
||||||
|
### UI: 3 surfaces
|
||||||
|
|
||||||
|
1. **Owner-only menu** — Plating → Configuration → Tablet Audit Log. List view with badges, filters (today/week/month, event_type, user), group-by, default 90-day window.
|
||||||
|
2. **Smart button on `res.users` form (Owner-only)** — "Tablet Events" with last-7-days count, opens audit list filtered to that user (`user_id` OR `attempted_user_id`).
|
||||||
|
3. **Chatter linkback (deferred to follow-up)** — tooltip on chatter messages linking to the unlock event. Phase 2, not blocking.
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
|
||||||
|
- Default: indefinite. `_cron_purge_old_session_events` exists but is DISABLED. Configurable via `ir.config_parameter['fp.tablet.audit.retention_days']` (unset = forever).
|
||||||
|
- AS9100 retention typically 3-7 years. Safe default for small shops.
|
||||||
|
|
||||||
|
### What this audit log does NOT replace
|
||||||
|
|
||||||
|
- Standard `create_uid` / `write_uid` on every model — that's the primary audit.
|
||||||
|
- Chatter authorship — still primary on individual records.
|
||||||
|
- This log catches what `create_uid` can't: failed attempts, session lengths, idle vs manual locks, gaps between sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5 — Migration & Rollout
|
||||||
|
|
||||||
|
### Two-step deploy with overlap window
|
||||||
|
|
||||||
|
To avoid downtime, OLD and NEW endpoints coexist for ~1 week. Tablets switch over individually as they reboot.
|
||||||
|
|
||||||
|
**Step 1 — Day 0 deploy (NEW code, OLD still alive)**
|
||||||
|
|
||||||
|
`-u` brings up:
|
||||||
|
- kiosk user + group + ACL
|
||||||
|
- new `/fp/tablet/unlock_session` and `/fp/tablet/lock_session`
|
||||||
|
- `fp.tablet.session.event` model + audit views
|
||||||
|
- new OWL `tablet_session_manager` service
|
||||||
|
- custom `fp_tablet_pin` auth manager
|
||||||
|
|
||||||
|
OLD endpoints stay:
|
||||||
|
- `/fp/tablet/unlock` returns `current_tech_id` (no session swap)
|
||||||
|
- `env_for_tablet_tech` still routes endpoints
|
||||||
|
- OWL `fp_shopfloor_tech_store` still bypassed when new manager is active
|
||||||
|
|
||||||
|
Frontend feature flag `ir.config_parameter['fp.shopfloor.tablet_session_mode']`:
|
||||||
|
- `'legacy'` (Day 0 default) → OWL uses old flow
|
||||||
|
- `'session_swap'` → OWL uses new flow
|
||||||
|
|
||||||
|
**Step 2 — Days 1-7 cutover, one tablet at a time**
|
||||||
|
|
||||||
|
- Flip flag to `'session_swap'` on entech
|
||||||
|
- Per tablet:
|
||||||
|
1. Reboot or hard-refresh browser
|
||||||
|
2. Sysadmin enters kiosk credential ONCE (stored in 1Password)
|
||||||
|
3. Bookmark `/odoo/action-fusion_plating_shopfloor.action_fp_plant_kanban`
|
||||||
|
4. Lock screen renders under kiosk user
|
||||||
|
5. Test: tech taps tile, enters PIN, full flow works as them
|
||||||
|
6. Track in spreadsheet (2-3 tablets total)
|
||||||
|
|
||||||
|
**Step 3 — Day 14 cleanup commits**
|
||||||
|
|
||||||
|
- Sweep `tablet_tech_id` kwargs out of all ~15 endpoints
|
||||||
|
- Delete `_tablet_audit.env_for_tablet_tech` (becomes import error → forces final cleanup)
|
||||||
|
- Remove OLD `/fp/tablet/unlock` endpoint
|
||||||
|
- Remove OLD `fp_shopfloor_tech_store` OWL service
|
||||||
|
- Strip auto-injection from `services/fp_rpc.js`
|
||||||
|
- Archive the legacy "shopfloor service" user
|
||||||
|
|
||||||
|
### Auto-login pattern for kiosk
|
||||||
|
|
||||||
|
Three options, cheapest first:
|
||||||
|
|
||||||
|
1. **Browser-stored cookie + long session_lifetime (recommended for entech)** — set `session_db.session_lifetime` to 90 days for kiosk. Sysadmin logs in once, cookie lasts 3 months. Cheap, manual.
|
||||||
|
2. **Kiosk browser extension** — KioWare or Chromium kiosk-mode auto-fills credential. Auto-recovers from reboots.
|
||||||
|
3. **Odoo SSO with stored token** — overkill for 2-3 tablets.
|
||||||
|
|
||||||
|
### Rollback plan
|
||||||
|
|
||||||
|
- Set `tablet_session_mode = 'legacy'` → all OWL switches back. No redeploy.
|
||||||
|
- If kiosk user is broken, legacy "shopfloor service" still has its permissions and the OLD endpoints — system keeps working.
|
||||||
|
|
||||||
|
### What WON'T be touched
|
||||||
|
|
||||||
|
- `res.users.x_fc_tablet_pin_hash` — same field, same hash format, same `verify_tablet_pin()`. PINs don't need to be reset.
|
||||||
|
- Lockout state (`failed_count`, `locked_until`) — preserved.
|
||||||
|
- All other shop-floor functionality (kanban, workspace, recipes) — unaffected.
|
||||||
|
|
||||||
|
### Timing on entech
|
||||||
|
|
||||||
|
| Day | Action |
|
||||||
|
|---|---|
|
||||||
|
| 0 (deploy day) | `-u fusion_plating_shopfloor` + 4 related modules. Verify new endpoints. `tablet_session_mode='legacy'`. |
|
||||||
|
| 0 (evening) | Flip flag on one test tablet. Manual test 5 unlock/lock cycles. |
|
||||||
|
| 1-2 | Roll to second + third tablets. Owner watches audit log. |
|
||||||
|
| 3-7 | Operators use new flow. Owner reviews audit log daily. |
|
||||||
|
| 7 | Decision: keep `'session_swap'` permanently OR roll back. |
|
||||||
|
| 14 | If kept: Step 3 cleanup commits. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 6 — Acceptance, Risks, Deferred
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. **Identity** — pick any `create_uid` on `fp.job.step` change in last 30 days. Cross-reference `fp.tablet.session.event` for active session at that timestamp under that user. Match rate target: 100% of tablet-originated writes.
|
||||||
|
2. **Failed attempts** — query "every wrong-PIN attempt for user X in last week" returns rows with `attempted_user_id=X`, `failure_reason='wrong_pin'`, IP, UA, timestamp.
|
||||||
|
3. **Session length** — longest session ≤ 8hr (within 5-min cron grace).
|
||||||
|
4. **Gap detection** — adjacent lock → next unlock delta computable; "was anyone on the floor at 2pm?" answerable.
|
||||||
|
5. **No silent attribution** — post-Step 3, action endpoint without `tablet_tech_id` runs under `request.env.user` which IS the tech. `create_uid = tech.id`.
|
||||||
|
6. **Kiosk privilege check** — log in as `fp_tablet_kiosk` in private browser. Try to navigate any plating URL. Result: access denied / blank menu.
|
||||||
|
7. **Browser navigation under tech** — as the unlocked tech, hit `/odoo` main menu. They see ONLY menus their role allows.
|
||||||
|
8. **Idle lockout fires** — PIN-unlock, wait 11 min touching nothing. Auto-lock. `event_type='idle_lock'` row appears.
|
||||||
|
9. **Hard ceiling fires** — backdate `session_started_at` past 8 hrs. Run cron. `session_ended_at` populates, `event_type='force_lock'`.
|
||||||
|
10. **Audit log append-only** — try `event.write({...})` as Owner. AccessError. Only root SQL access can tamper.
|
||||||
|
|
||||||
|
### Open risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Custom auth manager conflicts with Odoo's session_lifetime / 2FA / IP modules | Medium | Distinctive type name (`fp_tablet_pin`, not `tablet_pin`). Tests for pwd + 2FA paths unchanged. Document in CLAUDE.md. |
|
||||||
|
| Browser cookie misbehavior on rapid lock/unlock | Low | `window.location.reload()` after every transition kills half-state. |
|
||||||
|
| Tablet PC reboot mid-shift with stale cookie | Medium | Long-lived kiosk cookie (90-day session_lifetime). Sysadmin re-login if expired (~30s downtime). |
|
||||||
|
| Backend cron clock drift | Low | All timestamps `fields.Datetime.now()` (UTC, server). Server cron is source of truth for ceiling. |
|
||||||
|
| Network drop mid-unlock | Low | One DB transaction — atomic. Either session+audit row both commit, or neither. |
|
||||||
|
| Operator hits backend URLs | Expected behavior | They're real Odoo users with their own ACLs. Standard ACLs apply. Working as designed. |
|
||||||
|
| Brute-force PIN attempts as DoS | Low (insider only) | 5-attempt → 5-min lockout. Cron clears `failed_count` after 1hr of no failures. 10000 possible PINs + lockout → ~3.5 years to brute-force on average. |
|
||||||
|
|
||||||
|
### Deferred to follow-up
|
||||||
|
|
||||||
|
- **Chatter linkback** (Section 4, surface 3) — useful but not blocking. Phase 2.
|
||||||
|
- **2FA on lock screen** (badge tap, biometric) — out of scope.
|
||||||
|
- **Per-tablet identity** — currently every tablet uses same kiosk credential. If you ever want to track which physical tablet did what, add `tablet_device_id`. Deferred — small shop doesn't need it.
|
||||||
|
- **SAML/SSO integration** — out of scope.
|
||||||
|
- **Manager override mode** — explicitly killed in Q4. Manager wanting to chip in must PIN in as themselves.
|
||||||
|
- **Time-clock integration** — separate concern. The `session_started_at` could feed time-tracking but that integration is its own design.
|
||||||
|
- **Mobile (non-tablet) access** — Technician on phone uses standard Odoo login. PIN flow is tablet-only.
|
||||||
|
|
||||||
|
### Estimated effort
|
||||||
|
|
||||||
|
- Phase 1: server-side (kiosk user, auth manager, endpoints, audit model, cron) — **~1.5 days**
|
||||||
|
- Phase 2: OWL (session manager, lock-back UI, reload-on-transition) — **~0.5 days**
|
||||||
|
- Phase 3: audit views + Owner menu + smart button — **~0.5 days**
|
||||||
|
- Phase 4: entech rollout (deploy, feature-flag test, per-tablet cutover, validation week) — **~1 day spread over 7 days**
|
||||||
|
- Phase 5: Step 3 cleanup (rip out tablet_tech_id) — **~0.5 days**
|
||||||
|
|
||||||
|
**Total: ~4 development days + 1 calendar week observation.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- ✅ Brainstorm complete (5 locked decisions)
|
||||||
|
- ✅ Design doc written
|
||||||
|
- ⏳ Self-review (next)
|
||||||
|
- ⏳ User reviews this spec
|
||||||
|
- ⏳ Invoke `writing-plans` to create the implementation plan
|
||||||
|
- ⏳ Execute the plan per `subagent-driven-development`
|
||||||
|
- ⏳ Deploy + validate on entech
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of design document.*
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
# Job Workspace — Per-Kind Step Actions
|
||||||
|
|
||||||
|
**Date:** 2026-05-24
|
||||||
|
**Modules:** `fusion_plating_jobs`, `fusion_plating_shopfloor`
|
||||||
|
**Status:** Approved, awaiting implementation plan.
|
||||||
|
**Sub-project:** A of 2. Sub-B (Record Inputs tablet polish — `inputmode`, prefill,
|
||||||
|
date/time pickers, signature pad, camera) is brainstormed but DEFERRED.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Operator opens WO-30057 in the Job Workspace tablet view. Step 1 (Contract Review)
|
||||||
|
shows ✓ (auto-completed from prior QA-005). Steps 2-12 each show only a bare
|
||||||
|
`○ Step N <name>` row — **no Start button, no action of any kind**. The operator
|
||||||
|
has no way to advance the job from this screen, even though every step is
|
||||||
|
`state='ready'` and `can_start=True` on the backend.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
|
||||||
|
In [`job_workspace.xml:105`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml),
|
||||||
|
the expanded step-detail block is gated:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
|
||||||
|
<div class="o_fp_ws_step_detail">
|
||||||
|
...
|
||||||
|
<!-- Start button is INSIDE this parent gate -->
|
||||||
|
<div t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
|
||||||
|
<button t-on-click="() => this.onStartStep(step.id)">Start</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
```
|
||||||
|
|
||||||
|
`isStepActive` returns true only when `step.state === 'in_progress'`. For a
|
||||||
|
`state='ready'` step with no blocker, the parent `<t t-if>` is false — the whole
|
||||||
|
detail block (incl. the inner Start button) never renders. **Dead code.**
|
||||||
|
|
||||||
|
### Secondary gaps
|
||||||
|
|
||||||
|
Even if Start were reachable, certain step kinds need different actions, not a
|
||||||
|
generic Start/Finish chain:
|
||||||
|
|
||||||
|
| Kind | Today (broken) | What it actually needs |
|
||||||
|
|---|---|---|
|
||||||
|
| `contract_review` | Hidden Start button | **Open QA-005 Form** button (uses existing `_fp_open_contract_review`) |
|
||||||
|
| `gating` | Hidden Start, then operator clicks Finish too | **1-click "Mark Passed"** (no work to do — it's an admin gate) |
|
||||||
|
| `requires_rack_assignment=True` | Hidden Start | Start should open the **Rack Parts** dialog first |
|
||||||
|
| `state='paused'` | Hidden Start | Should show **Resume** + Finish + Record Inputs |
|
||||||
|
| all kinds, `state='in_progress'` | Shows Finish, Record Inputs | Missing a **Pause** button |
|
||||||
|
|
||||||
|
### Operator can't see what's coming
|
||||||
|
|
||||||
|
The recipe-author info (thickness target, dwell time, bake temp, sign-off
|
||||||
|
required) currently only renders on the active step. Operators can't read ahead
|
||||||
|
to know what they're about to start. CLAUDE.md S20 "Tablet usability pass" called
|
||||||
|
this out for the per-step kanban; the same gap exists in the Job Workspace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approved fix
|
||||||
|
|
||||||
|
### Change 1 — Template restructure
|
||||||
|
|
||||||
|
Replace the parent-gated detail block in [`job_workspace.xml:88-170`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml)
|
||||||
|
with three independent rendering layers per step:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-att-class="...">
|
||||||
|
<!-- [ALWAYS] Line 1: icon + step# + name + meta -->
|
||||||
|
<div class="o_fp_ws_step_l1">
|
||||||
|
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||||
|
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||||
|
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||||
|
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||||
|
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
|
||||||
|
<span class="o_fp_ws_step_meta">...assignee, duration...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [NON-TERMINAL] Read-ahead detail: chips + instructions + GateViz -->
|
||||||
|
<div class="o_fp_ws_step_detail"
|
||||||
|
t-if="step.state not in ('done', 'skipped', 'cancelled')">
|
||||||
|
<!-- chips (thickness/dwell/bake/signoff) -->
|
||||||
|
<div class="o_fp_ws_step_chips">
|
||||||
|
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">🎯 Thickness ...</span>
|
||||||
|
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">⏱ Dwell ...</span>
|
||||||
|
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">🔥 Bake ...°</span>
|
||||||
|
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">✎ Sign-off</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- recipe instructions -->
|
||||||
|
<div t-if="step.instructions" class="o_fp_ws_step_instr"><t t-esc="step.instructions"/></div>
|
||||||
|
|
||||||
|
<!-- opt-out -->
|
||||||
|
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
|
||||||
|
<i class="fa fa-ban"/> Skipped per recipe override
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- blocker viz -->
|
||||||
|
<GateViz t-if="step.blocker_kind !== 'none'"
|
||||||
|
canStart="false"
|
||||||
|
blockerKind="step.blocker_kind"
|
||||||
|
blockerReason="step.blocker_reason"
|
||||||
|
jumpTargetModel="step.blocker_jump_target_model"
|
||||||
|
jumpTargetId="step.blocker_jump_target_id"
|
||||||
|
onJump.bind="onJumpToBlocker"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [ACTIONABLE] Action row — per-kind buttons per the dispatcher -->
|
||||||
|
<div class="o_fp_ws_step_actions"
|
||||||
|
t-if="!step.override_excluded
|
||||||
|
and step.blocker_kind === 'none'
|
||||||
|
and step.state not in ('done', 'skipped', 'cancelled')">
|
||||||
|
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
|
||||||
|
<button t-att-class="action.cssClass"
|
||||||
|
t-on-click="() => this.dispatchStepAction(step, action.key)">
|
||||||
|
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the existing `isStepActive(step)` helper for the ACTIVE badge but **don't**
|
||||||
|
let it gate the detail block.
|
||||||
|
|
||||||
|
### Change 2 — `getStepActions(step)` per-kind dispatcher
|
||||||
|
|
||||||
|
New JS helper in [`job_workspace.js`](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js).
|
||||||
|
Returns an array of action descriptors based on `step.state` + `step.kind` +
|
||||||
|
`step.requires_rack_assignment` + `step.requires_signoff`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
getStepActions(step) {
|
||||||
|
// Done/skipped/cancelled → no actions (caller already hides)
|
||||||
|
if (['done', 'skipped', 'cancelled'].includes(step.state)) return [];
|
||||||
|
// Blocked → no actions (caller already shows GateViz)
|
||||||
|
if (step.blocker_kind && step.blocker_kind !== 'none') return [];
|
||||||
|
if (step.override_excluded) return [];
|
||||||
|
|
||||||
|
const actions = [];
|
||||||
|
if (step.state === 'in_progress') {
|
||||||
|
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
|
||||||
|
actions.push({ key: 'pause', label: 'Pause', icon: 'fa fa-pause', cssClass: 'btn btn-light' });
|
||||||
|
actions.push({
|
||||||
|
key: 'finish',
|
||||||
|
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
|
||||||
|
icon: 'fa fa-check', cssClass: 'btn btn-success'
|
||||||
|
});
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
if (step.state === 'paused') {
|
||||||
|
actions.push({ key: 'resume', label: 'Resume', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
|
||||||
|
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
|
||||||
|
actions.push({
|
||||||
|
key: 'finish',
|
||||||
|
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
|
||||||
|
icon: 'fa fa-check', cssClass: 'btn btn-success'
|
||||||
|
});
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
// state in ('pending', 'ready') — entry-point per kind
|
||||||
|
if (step.kind === 'contract_review') {
|
||||||
|
actions.push({ key: 'open_contract_review', label: 'Open QA-005 Form',
|
||||||
|
icon: 'fa fa-file-text-o', cssClass: 'btn btn-primary' });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
if (step.kind === 'gating') {
|
||||||
|
actions.push({ key: 'mark_passed', label: 'Mark Passed',
|
||||||
|
icon: 'fa fa-check-circle', cssClass: 'btn btn-success' });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
if (step.requires_rack_assignment) {
|
||||||
|
actions.push({ key: 'start_with_rack', label: 'Start (Assign Rack)',
|
||||||
|
icon: 'fa fa-server', cssClass: 'btn btn-primary' });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
// Default
|
||||||
|
actions.push({ key: 'start', label: 'Start', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change 3 — `dispatchStepAction(step, key)`
|
||||||
|
|
||||||
|
Single router method that delegates to handler methods:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async dispatchStepAction(step, key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'start': return this.onStartStep(step.id);
|
||||||
|
case 'resume': return this.onResumeStep(step); // button_resume — distinct from button_start
|
||||||
|
case 'pause': return this.onPauseStep(step);
|
||||||
|
case 'record_inputs': return this.onRecordInputs(step);
|
||||||
|
case 'finish': return this.onFinishStep(step);
|
||||||
|
case 'mark_passed': return this.onMarkPassed(step);
|
||||||
|
case 'open_contract_review': return this.onOpenContractReview(step);
|
||||||
|
case 'start_with_rack': return this.onStartWithRack(step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change 4 — New JS handlers
|
||||||
|
|
||||||
|
**`onPauseStep(step)`** — calls `fp.job.step.button_pause` via ORM RPC.
|
||||||
|
(No `/fp/shopfloor/pause_wo` HTTP endpoint exists; the legacy stop_wo
|
||||||
|
endpoint's docstring claims pause isn't implemented but `button_pause`
|
||||||
|
does exist in `fusion_plating/models/fp_job_step.py:320`. Using ORM
|
||||||
|
RPC sidesteps the need to add a new HTTP route.)
|
||||||
|
|
||||||
|
```js
|
||||||
|
async onPauseStep(step) {
|
||||||
|
const reason = window.prompt(`Pause reason for "${step.name}"?`, '');
|
||||||
|
if (reason === null) return; // operator cancelled
|
||||||
|
try {
|
||||||
|
await rpc('/web/dataset/call_kw', {
|
||||||
|
model: 'fp.job.step', method: 'button_pause',
|
||||||
|
args: [[step.id]],
|
||||||
|
kwargs: { reason: reason || 'no reason given' },
|
||||||
|
});
|
||||||
|
this.notification.add('Step paused.', { type: 'success' });
|
||||||
|
await this.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message, { type: 'danger' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`onResumeStep(step)`** — calls `fp.job.step.button_resume` via ORM RPC.
|
||||||
|
Distinct from `onStartStep` because the model has separate methods:
|
||||||
|
`button_start` is for state=ready → in_progress; `button_resume` is for
|
||||||
|
state=paused → in_progress (preserves accrued time + reason audit).
|
||||||
|
|
||||||
|
```js
|
||||||
|
async onResumeStep(step) {
|
||||||
|
try {
|
||||||
|
await rpc('/web/dataset/call_kw', {
|
||||||
|
model: 'fp.job.step', method: 'button_resume',
|
||||||
|
args: [[step.id]], kwargs: {},
|
||||||
|
});
|
||||||
|
this.notification.add('Step resumed.', { type: 'success' });
|
||||||
|
await this.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message, { type: 'danger' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`onMarkPassed(step)`** — calls a new ORM method `action_mark_gating_passed`
|
||||||
|
which does `button_start` + `button_finish` in one server call:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async onMarkPassed(step) {
|
||||||
|
try {
|
||||||
|
await rpc('/web/dataset/call_kw', {
|
||||||
|
model: 'fp.job.step', method: 'action_mark_gating_passed',
|
||||||
|
args: [[step.id]], kwargs: {},
|
||||||
|
});
|
||||||
|
this.notification.add('Gate marked passed.', { type: 'success' });
|
||||||
|
await this.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message, { type: 'danger' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`onOpenContractReview(step)`** — calls the existing `_fp_open_contract_review`
|
||||||
|
helper on `fp.job.step` (per CLAUDE.md Policy B section). Returns an act_window
|
||||||
|
that the action service opens. After dialog close, refresh:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async onOpenContractReview(step) {
|
||||||
|
try {
|
||||||
|
const result = await rpc('/web/dataset/call_kw', {
|
||||||
|
model: 'fp.job.step', method: '_fp_open_contract_review',
|
||||||
|
args: [[step.id]], kwargs: {},
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
await this.action.doAction(result, { onClose: () => this.refresh() });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message || "Couldn't open QA-005", { type: 'danger' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`onStartWithRack(step)`** — opens the existing Rack Parts dialog from
|
||||||
|
`move_controller.py`. On commit (rack assigned + parts loaded), calls
|
||||||
|
`onStartStep(step.id)`. Implementation reuses `FpRackPartsDialog` from
|
||||||
|
`fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async onStartWithRack(step) {
|
||||||
|
this.dialog.add(FpRackPartsDialog, {
|
||||||
|
jobId: this.state.jobId,
|
||||||
|
stepId: step.id,
|
||||||
|
partRef: this.state.data.job.part_number || '',
|
||||||
|
defaultQty: this.state.data.job.qty || 1,
|
||||||
|
onCommitted: async () => {
|
||||||
|
// Rack assigned → now start the step
|
||||||
|
await this.onStartStep(step.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change 5 — New backend method `action_mark_gating_passed`
|
||||||
|
|
||||||
|
In [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py),
|
||||||
|
add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_mark_gating_passed(self):
|
||||||
|
"""1-click pass for gating steps (kind=='gating'). Performs
|
||||||
|
button_start() then button_finish() in the same transaction.
|
||||||
|
Posts chatter ("Gate marked passed by <user>") on the parent job.
|
||||||
|
|
||||||
|
Only valid for state in (ready, pending, paused) — defensive
|
||||||
|
NOOP otherwise (idempotent on repeat clicks).
|
||||||
|
"""
|
||||||
|
for step in self:
|
||||||
|
if step.state in ('done', 'skipped', 'cancelled'):
|
||||||
|
continue
|
||||||
|
kind_code = step.recipe_node_id.kind_id.code if (
|
||||||
|
step.recipe_node_id and step.recipe_node_id.kind_id
|
||||||
|
) else None
|
||||||
|
if kind_code != 'gating':
|
||||||
|
raise UserError(_(
|
||||||
|
"action_mark_gating_passed is only valid for gating "
|
||||||
|
"steps (this step has kind=%s).") % (kind_code or 'unknown'))
|
||||||
|
if step.state not in ('ready', 'pending', 'paused'):
|
||||||
|
continue
|
||||||
|
# Resume if paused, then start, then finish — bypass the input
|
||||||
|
# gate (gating steps have no required inputs by design).
|
||||||
|
if step.state == 'paused':
|
||||||
|
step.button_resume()
|
||||||
|
if step.state != 'in_progress':
|
||||||
|
step.button_start()
|
||||||
|
step.with_context(
|
||||||
|
fp_skip_required_inputs_gate=True,
|
||||||
|
).button_finish()
|
||||||
|
step.job_id.message_post(body=_(
|
||||||
|
'Gate "%(name)s" marked passed by %(user)s.'
|
||||||
|
) % {'name': step.name, 'user': self.env.user.name})
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change 6 — Verify controller payload has `requires_rack_assignment`
|
||||||
|
|
||||||
|
The workspace controller payload at
|
||||||
|
[`workspace_controller.py:75-95`](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)
|
||||||
|
already includes `kind`, `state`, `can_start`, `requires_signoff`,
|
||||||
|
`blocker_kind`. Verify `requires_rack_assignment` is included; if not, add it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
'requires_rack_assignment': bool(getattr(step, 'requires_rack_assignment', False)),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change 7 — Version bumps
|
||||||
|
|
||||||
|
| Module | From | To |
|
||||||
|
|---|---|---|
|
||||||
|
| `fusion_plating_jobs` | `19.0.10.26.0` | `19.0.10.27.0` (new `action_mark_gating_passed` method) |
|
||||||
|
| `fusion_plating_shopfloor` | `19.0.33.1.4` | `19.0.33.1.5` (JS + XML restructure + controller payload) |
|
||||||
|
|
||||||
|
No data migration needed — purely behavioural / UX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
### Manual smoke (after deploy)
|
||||||
|
|
||||||
|
1. Open WO-30057 in Job Workspace.
|
||||||
|
2. Confirm Step 1 (Contract Review, done) shows ✓ + name, NO buttons.
|
||||||
|
3. Confirm Step 2 (Masking, ready) shows **Start** button.
|
||||||
|
4. Click Start → confirm step transitions to `in_progress` → buttons swap to Record Inputs, Pause, Finish.
|
||||||
|
5. Click Pause → confirm prompt → confirm step transitions to `paused` → buttons swap to Resume, Record Inputs, Finish.
|
||||||
|
6. Click Resume → confirm back to `in_progress` + correct buttons.
|
||||||
|
7. Click Finish → confirm step completes → next step (Incoming Inspection, ready) now shows Start.
|
||||||
|
8. Locate a job with a Contract Review step that hasn't been auto-completed (rare — most parts have prior QA-005). Confirm **Open QA-005 Form** button. Click → form opens. Submit → refresh → step completes.
|
||||||
|
9. Locate or create a job with a Gating step (kind='gating'). Confirm **✓ Mark Passed** button. Click → step jumps from ready to done in one click.
|
||||||
|
10. Find a step where `requires_signoff=True`. Click Finish → signature pad opens (existing behaviour). Sign → step completes.
|
||||||
|
11. Find a blocked step (predecessor not done). Confirm GateViz renders, NO action buttons.
|
||||||
|
12. Find an opt-out step (`override_excluded=True`). Confirm "Skipped per recipe override" notice, NO action buttons.
|
||||||
|
|
||||||
|
### Smoke for chip / instructions visibility
|
||||||
|
|
||||||
|
13. On any in-flight job, confirm chips (🎯 thickness, ⏱ dwell, 🔥 bake, ✎ sign-off) + recipe instructions render on **every non-done step** (not just the active one). Operator can read ahead.
|
||||||
|
|
||||||
|
### Battle test followup
|
||||||
|
|
||||||
|
Defer to Sub B (no new automated test for this UX-only change — covered by manual smoke).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (explicit)
|
||||||
|
|
||||||
|
- **`inputmode` attributes / number keyboards / prefill / date/time pickers /
|
||||||
|
signature pad in Record Inputs / camera capture** — all deferred to Sub B
|
||||||
|
(record-inputs tablet polish).
|
||||||
|
- **Auditing every kind's default input prompts** — deferred to Sub B. The
|
||||||
|
existing dialog renders all 15 input_types; Sub B verifies each is good UX.
|
||||||
|
- **Skip step button** — supervisor-only, accessible via backend form. Not
|
||||||
|
adding to operator workspace.
|
||||||
|
- **Reassign step** — supervisor-only.
|
||||||
|
- **Per-recipe ordering or kind fixes** — already covered by recent recipe
|
||||||
|
cleanup spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_shopfloor/static/src/xml/job_workspace.xml` | Template restructure — always-visible action row + non-terminal detail block (Change 1) |
|
||||||
|
| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `getStepActions`, `dispatchStepAction`, `onPauseStep`, `onMarkPassed`, `onOpenContractReview`, `onStartWithRack` (Changes 2-4) |
|
||||||
|
| `fusion_plating_shopfloor/static/src/scss/job_workspace.scss` | Minor styling for the action row (consistent spacing across button counts) |
|
||||||
|
| `fusion_plating_shopfloor/controllers/workspace_controller.py` | Add `requires_rack_assignment` to step payload if missing (Change 6) |
|
||||||
|
| `fusion_plating_shopfloor/__manifest__.py` | Bump to `19.0.33.1.5` (Change 7) |
|
||||||
|
| `fusion_plating_jobs/models/fp_job_step.py` | Add `action_mark_gating_passed()` method (Change 5) |
|
||||||
|
| `fusion_plating_jobs/__manifest__.py` | Bump to `19.0.10.27.0` (Change 7) |
|
||||||
|
|
||||||
|
Estimated diff: ~200 lines added, ~50 modified, ~10 deleted.
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
# Post-Shop Cert + Shipping Job States
|
||||||
|
|
||||||
|
**Date:** 2026-05-25
|
||||||
|
**Status:** Approved for implementation (brainstorming gate)
|
||||||
|
**Author:** Brainstorming session (gsinghpal)
|
||||||
|
**Triggering incident:** Job WO-30058 (SO-30058) finished all recipe steps on entech and **disappeared from the Shop Floor kanban**. CoC was auto-spawned in `draft` but nobody was notified, no surface listed pending certs for the Quality Manager, and there was no kanban column for "completed-but-not-shipped" jobs. Operators reported jobs they had finished feeling "lost" — same risk that a job could leave the building without a CoC.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Three things, decided as one unit of work:
|
||||||
|
|
||||||
|
1. **Stop completed-but-uncertified jobs from disappearing** from the Plant Kanban — they must stay visible to shop staff so jobs aren't forgotten or shipped without paperwork.
|
||||||
|
2. **Give the Quality Manager a dedicated surface** (Quality Dashboard tab + email + in-app activity) for CoC issuance, with hard ACL gating so Technicians cannot self-issue.
|
||||||
|
3. **Model the actual lifecycle** in `fp.job.state` so reporting, queries, and future workflow gates derive from a single source of truth instead of cross-module joins.
|
||||||
|
|
||||||
|
## Out of scope (deferred to follow-on work)
|
||||||
|
|
||||||
|
- **Shipping label printing, carrier dispatch, tracking-number capture, BoL generation.** These remain manual for now; the new `awaiting_ship` state is a parking column so jobs are visible to the shipping crew. `awaiting_ship → done` is a manual button click. (User: *"lets first finish this certification step then we will look into shipping"*.)
|
||||||
|
- **Auto-transition from `awaiting_ship → done` on `delivery.action_mark_delivered`.** Scaffolded as a future hook in the design; not wired in this scope.
|
||||||
|
- **RMA-aware regression** (job re-opening on RMA receive). Already handled by existing RMA flow — not touched here.
|
||||||
|
- **Per-cert-type ACL** (e.g. only QM can issue Nadcap, but Manager can issue CoC). Out of scope; single QM-or-higher gate for all cert types in v1.
|
||||||
|
|
||||||
|
## Decisions reached during brainstorming
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | Use **Approach A** — add two new `fp.job.state` values (`awaiting_cert`, `awaiting_ship`) | Cleanest semantics; `state='done'` will once again mean "fully complete and shipped". Single source of truth for kanban, dashboard, reporting, and future delivery automation. |
|
||||||
|
| D2 | **Auto-advance** `in_progress → awaiting_cert` when every recipe step is terminal AND a cert is required | No new operator button; removes risk of forgetting to advance. Hooks into existing `all_steps_terminal` computed field. |
|
||||||
|
| D3 | **Auto-advance** `awaiting_cert → awaiting_ship` from `fp.certificate.action_issue` when every required cert is `issued` | The QM clicking Issue is the natural trigger; no separate "mark inspection complete" button needed. The recipe's final-inspection step already captures inspection data via custom prompts. |
|
||||||
|
| D4 | **Cert void regresses state** (`awaiting_ship → awaiting_cert` if a previously-issued cert is voided) | Defensive against late-discovered issues. Re-fires `cert_awaiting_issuance` notification under a `cert_voided_re_notify` event so dedupe doesn't suppress it. |
|
||||||
|
| D5 | **Manual `awaiting_ship → done` button** in this scope; auto-hook on delivery deferred | User explicitly scoped shipping work out. The button is repurposed from the existing milestone-advance "Mark Done" button (renamed "Mark Shipped"), restricted to Manager/Owner. |
|
||||||
|
| D6 | **ACL gate on `action_issue`** — Manager / Quality Manager / Owner only | User: *"certifications can only be issued by Manager, Quality Manager and Owner, i don't want technicians to issue certifications"*. Two-layer enforcement: Python `AccessError` + view-level `groups=` on the Issue button. |
|
||||||
|
| D7 | **Group-membership resolved via `all_group_ids`** (transitive) — Owners reach QM authority via implication chain | Rule 23 (CLAUDE.md). Owners don't carry `group_fp_quality_manager` directly; the `all_group_ids` lookup catches them via implication. |
|
||||||
|
| D8 | **Notification fires on the auto-transition**, not on the operator's last step-finish click | Decouples notification from operator UX; transition is the authoritative trigger so all paths (UI, RPC, scripted) notify consistently. |
|
||||||
|
| D9 | **Belt-and-suspenders: in-app `mail.activity`** in addition to email | If email bounces / spam-folders, the red activity badge on the QM's home is the floor-of-truth signal. Activity auto-resolves on `awaiting_ship` transition. |
|
||||||
|
| D10 | **Jobs that don't require certs skip `awaiting_cert`** and land directly in `awaiting_ship` | Visibility consistency — every completed-but-unshipped job is in the Shipping column, regardless of cert requirements. No silent path to `done`. |
|
||||||
|
| D11 | **Plant kanban shows the two new states in `Final inspection` and `Shipping` columns**; old per-step `area_kind` logic untouched for `in_progress` | Repurposes the two right-most columns that are currently almost always empty. State drives column for the new values; nothing else changes. |
|
||||||
|
| D12 | **Gates from `button_mark_done` (bake/qty/QC) move UP into `fp.job.step.button_finish` on the LAST step** instead of running at auto-transition time | The auto-transition itself must not raise — if it raised when the operator finishes their last step, the step would finish but the auto-advance would silently fail with no error path. Better: when finishing the last step, surface the pending gates as UserError on the finish click. Operator fixes (qty, bake, QC), retries finish, transition fires cleanly. Step-completion gate is implied (the step IS finishing). |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ STATE MACHINE ─────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ confirmed → in_progress → awaiting_cert → awaiting_ship → done│
|
||||||
|
│ │ ▲ ▲ ▲ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ all steps terminal QM Issue (last cert) Mark Shipped│ │
|
||||||
|
│ + cert required button │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──── (no cert required) ─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ awaiting_ship ──(cert voided after issue)──► awaiting_cert
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ KANBAN VISIBILITY (PLANT VIEW) ────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Receiving · Masking · Blasting · Racking · Plating │
|
||||||
|
│ · Baking · De-Racking · ★Final inspection · ★Shipping │
|
||||||
|
│ ▲ ▲ │
|
||||||
|
│ │ │ │
|
||||||
|
│ awaiting_cert awaiting_ship │
|
||||||
|
│ (column = state, not active_step) │
|
||||||
|
│ │
|
||||||
|
│ Domain widens: state IN (confirmed, in_progress, │
|
||||||
|
│ awaiting_cert, awaiting_ship) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ QUALITY DASHBOARD ─────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Holds | Checks | NCRs | CAPAs | RMAs | ★Certificates │
|
||||||
|
│ (new 6th tab) │
|
||||||
|
│ │
|
||||||
|
│ Tab content: kanban grouped by state, Draft folded open, │
|
||||||
|
│ filters (My Customer / Today / Overdue >24h / │
|
||||||
|
│ Missing Fischerscope), buttons Open Cert / Open Job. │
|
||||||
|
│ │
|
||||||
|
│ Header KPI: "Certificates Awaiting Issuance: N (M overdue)" │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ NOTIFICATION (when state hits awaiting_cert) ──────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Email via fp.notification.template, event: │
|
||||||
|
│ cert_awaiting_issuance (first notification) │
|
||||||
|
│ cert_voided_re_notify (re-fires after cert void) │
|
||||||
|
│ │
|
||||||
|
│ Recipients: every active non-share user with all_group_ids │
|
||||||
|
│ containing QM | Manager | Owner. Resolved by helper │
|
||||||
|
│ _fp_resolve_cert_authority_users() (see Rule 13l pattern). │
|
||||||
|
│ │
|
||||||
|
│ + mail.activity ("To Do") assigned to one QM, round-robin │
|
||||||
|
│ by last_activity_at. Auto-marks done on awaiting_ship. │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema changes (additive)
|
||||||
|
|
||||||
|
### `fp.job` model
|
||||||
|
|
||||||
|
| Change | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `state` selection — add `('awaiting_cert', 'Awaiting Cert')` and `('awaiting_ship', 'Awaiting Ship')` | Selection extension | Selection extension via `_inherit` keeps tracking + chatter audit working. Sequence: confirmed → in_progress → awaiting_cert → awaiting_ship → done → cancelled. |
|
||||||
|
| New method `_fp_check_advance_post_shop()` | Method | Called from step-state-change hooks (specifically from `fp.job.step.button_finish` after `super()`). If all `step_ids` are in `('done','skipped','cancelled')` AND state is `in_progress`: transitions to `awaiting_cert` (if `_resolve_required_cert_types()` non-empty) or `awaiting_ship` (if empty). Idempotent. Does NOT raise — gates moved into step.button_finish per D12. |
|
||||||
|
| Hardened `fp.job.step.button_finish` for the LAST open step | Method | When finishing a step that would leave all steps terminal, run the bake-window / qty-reconciliation / QC gates from the old `button_mark_done` BEFORE allowing the finish. On failure: raise UserError, step stays open, operator fixes + retries. Same manager-bypass context flags as today (`fp_skip_bake_gate`, `fp_skip_qty_reconcile`, `fp_skip_qc_gate`). |
|
||||||
|
| New method `_fp_check_advance_after_cert_issue()` | Method | Called from `fp.certificate.action_issue`. If every required cert for the job is `issued`: transition `awaiting_cert → awaiting_ship`. Idempotent. |
|
||||||
|
| New method `_fp_check_regress_after_cert_void()` | Method | Called from `fp.certificate.write({'state':'voided'})`. If any required cert is no longer `issued`: transition `awaiting_ship → awaiting_cert` and re-fire notification under `cert_voided_re_notify`. |
|
||||||
|
| New method `button_mark_shipped()` | Method | Manual transition `awaiting_ship → done`. Restricted via `groups=` on the form button (Manager/Owner). Reuses the existing `button_mark_done` body for side effects (delivery wiring, notifications, chatter), but **does not include the step/QC/bake/qty gates** (those already passed when we transitioned to `awaiting_cert`). |
|
||||||
|
| Repurpose existing `button_mark_done()` | Method | Becomes an internal-only method called from the auto-transitions. Visible operator action is now `button_mark_shipped`. Existing callers remain valid but are now triggered by the state machine rather than direct user clicks. |
|
||||||
|
|
||||||
|
### `fp.certificate` model
|
||||||
|
|
||||||
|
| Change | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Hardened `action_issue()` | Method | Adds Python-side `AccessError` if user lacks QM/Manager/Owner. Calls `job._fp_check_advance_after_cert_issue()` after successful issue. Manager bypass via context flag `fp_skip_cert_authority_gate=True` (posts chatter audit). |
|
||||||
|
| `write({'state':'voided'})` override | Method | Calls `job._fp_check_regress_after_cert_void()` after the void completes. |
|
||||||
|
| `x_fc_age_hours` | Float, non-stored, computed | Drives the Quality Dashboard age chip + overdue filter. `(now - create_date).total_seconds() / 3600`. |
|
||||||
|
|
||||||
|
### `fp.notification.template` data
|
||||||
|
|
||||||
|
| Change | Notes |
|
||||||
|
|---|---|
|
||||||
|
| New selection values on `trigger_event`: `('cert_awaiting_issuance', 'Cert Awaiting Issuance')`, `('cert_voided_re_notify', 'Cert Voided — Please Re-Issue')` | Loaded via `data/fp_notification_events_data.xml`. |
|
||||||
|
| Two seeded `fp.notification.template` records (one per event) | `data/fp_cert_authority_templates.xml`. Default body shown under "Notification changes" further below. Editable via UI (Plating → Configuration → Quality & Documents → Notification Templates). |
|
||||||
|
| New recipient resolver helper `_fp_resolve_cert_authority_users(job)` on `fp.notification.template` | Wraps the group-membership search (Rule 13l pattern). Dispatched when `trigger_event` is `cert_awaiting_issuance` or `cert_voided_re_notify`. |
|
||||||
|
|
||||||
|
### `mail.activity` integration
|
||||||
|
|
||||||
|
| Change | Notes |
|
||||||
|
|---|---|
|
||||||
|
| New `mail.activity.type` xmlid `fusion_plating_jobs.activity_type_issue_coc` | Title: "Issue CoC". Default summary template: `Issue CoC for {{ job.display_wo_name }}`. |
|
||||||
|
| `_fp_schedule_cert_activity(job)` helper on `fp.job` | Picks one QM from `_fp_resolve_cert_authority_users`, sorted by `login_date asc nulls first` — the QM who logged in least recently (likely least busy / hasn't been on the system in a while). Creates the activity. Auto-resolves on `awaiting_ship` transition. `login_date` chosen over a custom `last_activity_at` field because it's standard on `res.users` and always populated. |
|
||||||
|
|
||||||
|
## Plant Kanban changes
|
||||||
|
|
||||||
|
### Controller — `fusion_plating_shopfloor/controllers/plant_kanban.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Line 73-75 — widen the domain
|
||||||
|
domain = [
|
||||||
|
('state', 'in', ('confirmed', 'in_progress',
|
||||||
|
'awaiting_cert', 'awaiting_ship')),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Line ~165 — extend _resolve_card_area
|
||||||
|
def _resolve_card_area(job):
|
||||||
|
if job.card_state == 'no_parts':
|
||||||
|
return 'receiving'
|
||||||
|
if job.state == 'awaiting_cert':
|
||||||
|
return 'inspection'
|
||||||
|
if job.state == 'awaiting_ship':
|
||||||
|
return 'shipping'
|
||||||
|
if job.active_step_id and job.active_step_id.area_kind:
|
||||||
|
return job.active_step_id.area_kind
|
||||||
|
return 'receiving' # orphan fallback (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card-state catalog — `fusion_plating_jobs/models/fp_job.py`
|
||||||
|
|
||||||
|
Two new values added to the `_compute_card_state` precedence chain. Inserted BEFORE the existing `done` rule (which is now unreachable from `state='done'` jobs anyway because they're filtered off the board):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before existing Rule 8 (done):
|
||||||
|
if job.state == 'awaiting_cert':
|
||||||
|
job.card_state = 'awaiting_cert'
|
||||||
|
continue
|
||||||
|
if job.state == 'awaiting_ship':
|
||||||
|
job.card_state = 'awaiting_ship'
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chip rendering — `_state_chip()` in plant_kanban.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
if card_state == 'awaiting_cert':
|
||||||
|
return {'label': _('🏷️ Awaiting CoC'), 'kind': 'awaiting_cert'}
|
||||||
|
if card_state == 'awaiting_ship':
|
||||||
|
return {'label': _('📦 Ready to ship'), 'kind': 'awaiting_ship'}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sort priority — `_SORT_PRIORITY` in plant_kanban.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
'awaiting_cert': 3.5, # right after awaiting_signoff
|
||||||
|
'awaiting_ship': 8.5, # right after running
|
||||||
|
```
|
||||||
|
|
||||||
|
(Floats are fine — `_sort_key` returns a tuple sorted ascending, no integer assumption.)
|
||||||
|
|
||||||
|
### SCSS — `fusion_plating_shopfloor/static/src/scss/_plant_card.scss`
|
||||||
|
|
||||||
|
Add two new state modifier classes following the existing pattern:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.o_fp_plant_card.state-awaiting_cert {
|
||||||
|
background-color: var(--fp-state-awaiting-cert-bg, #fff3cd);
|
||||||
|
border-left: 4px solid var(--fp-state-awaiting-cert-border, #ff9800);
|
||||||
|
}
|
||||||
|
.o_fp_plant_card.state-awaiting_ship {
|
||||||
|
background-color: var(--fp-state-awaiting-ship-bg, #d1f1d4);
|
||||||
|
border-left: 4px solid var(--fp-state-awaiting-ship-border, #2e7d32);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens go in `_plant_tokens.scss` first (per Rule 8), with `@if $o-webclient-color-scheme == dark` darker variants (per Rule 9).
|
||||||
|
|
||||||
|
### KPI strip + filter chips — `fusion_plating_shopfloor/static/src/js/plant_kanban.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Two new KPI tiles
|
||||||
|
{ key: 'awaiting_cert', label: _t('Awaiting CoC'),
|
||||||
|
count: kpis.awaiting_cert, kind: 'awaiting_cert' },
|
||||||
|
{ key: 'awaiting_ship', label: _t('Ready to Ship'),
|
||||||
|
count: kpis.awaiting_ship, kind: 'awaiting_ship' },
|
||||||
|
|
||||||
|
// Two new filter chip
|
||||||
|
{ key: 'awaiting_cert', label: _t('Awaiting CoC') },
|
||||||
|
{ key: 'awaiting_ship', label: _t('Ready to Ship') },
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side KPI compute (in `plant_kanban` endpoint):
|
||||||
|
|
||||||
|
```python
|
||||||
|
'awaiting_cert': sum(1 for j in jobs if j.state == 'awaiting_cert'),
|
||||||
|
'awaiting_ship': sum(1 for j in jobs if j.state == 'awaiting_ship'),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mini-timeline strip — `_compute_mini_timeline_json` in fp_job.py
|
||||||
|
|
||||||
|
When `state='awaiting_cert'`: the `inspection` dot renders as `current` with `variant='awaiting_cert'`; all 7 earlier dots render `done`; shipping dot renders `upcoming`. Same shape when `state='awaiting_ship'` — shipping is `current` with `variant='awaiting_ship'`, inspection is `done`. Lets the QM see at a glance "this card has cleared the whole line, just waiting on paperwork/shipping."
|
||||||
|
|
||||||
|
## Quality Dashboard changes
|
||||||
|
|
||||||
|
### Counts endpoint — `fusion_plating_quality/controllers/fp_quality_dashboard.py`
|
||||||
|
|
||||||
|
Extend the existing `/fp/quality/dashboard/counts` response with one block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Cert = env['fp.certificate']
|
||||||
|
return {
|
||||||
|
# existing blocks (holds, checks, ncrs, capas, rmas) unchanged
|
||||||
|
'certificates': {
|
||||||
|
'open': Cert.search_count([('state', '=', 'draft')]),
|
||||||
|
'overdue': Cert.search_count([
|
||||||
|
('state', '=', 'draft'),
|
||||||
|
('create_date', '<', d1), # >24h = overdue
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tab UI — `fusion_plating_quality/static/src/{js,xml,scss}/fp_quality_dashboard.*`
|
||||||
|
|
||||||
|
Sixth tab "Certificates" with:
|
||||||
|
|
||||||
|
- **Kanban view** grouped by `state` (Draft / Issued / Voided), Draft folded open by default.
|
||||||
|
- **Card content** (per cert): WO# + part# (large), customer name, cert-type chip (CoC / CoC+Thickness / Nadcap), age chip (`Created 4h ago`, red past 24h), status badges (🟢 Thickness PDF ready · 📋 Inspection prompts captured · ⚠ Missing spec ref).
|
||||||
|
- **Two card actions**: `Open Cert` (cert form) · `Open Job` (source job, lets QM audit inspection prompts before issuing).
|
||||||
|
- **Filters above kanban**: My Customer / Today / Overdue (>24h) / Missing Fischerscope (status='pending' from S19) / High-severity Customer.
|
||||||
|
- **Group-by option**: Customer.
|
||||||
|
|
||||||
|
**Header KPI** card on the dashboard gains: `Certificates Awaiting Issuance: <count>` with overdue sub-count in red.
|
||||||
|
|
||||||
|
**Header KPI strip** across all 6 tabs stays glanceable — six small tiles in a row.
|
||||||
|
|
||||||
|
### Menu entry
|
||||||
|
|
||||||
|
No new menu — the Quality Dashboard already lives at Plating → Quality → Dashboard. New tab is a sibling render inside the existing client action.
|
||||||
|
|
||||||
|
### Deep-link from notification email
|
||||||
|
|
||||||
|
The email body includes `{{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}`. New URL param `?tab=certificates` is parsed by the OWL action's `setup()` to focus the right tab on load.
|
||||||
|
|
||||||
|
## ACL changes
|
||||||
|
|
||||||
|
### `fp.certificate.action_issue` Python guard
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_issue(self):
|
||||||
|
if not self.env.context.get('fp_skip_cert_authority_gate'):
|
||||||
|
cert_authority_gids = [
|
||||||
|
self.env.ref('fusion_plating.group_fp_quality_manager').id,
|
||||||
|
self.env.ref('fusion_plating.group_fp_manager').id,
|
||||||
|
self.env.ref('fusion_plating.group_fp_owner').id,
|
||||||
|
]
|
||||||
|
if not (set(self.env.user.all_group_ids.ids)
|
||||||
|
& set(cert_authority_gids)):
|
||||||
|
raise AccessError(_(
|
||||||
|
"Only Quality Managers, Managers, and Owners can issue "
|
||||||
|
"certificates. Ask your QM to review and issue this CoC."
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.message_post(body=Markup(_(
|
||||||
|
'Cert authority gate <b>bypassed</b> by '
|
||||||
|
'<b>%(u)s</b> (context flag fp_skip_cert_authority_gate).'
|
||||||
|
)) % {'u': self.env.user.name})
|
||||||
|
# existing issue logic...
|
||||||
|
```
|
||||||
|
|
||||||
|
### View-level button gating
|
||||||
|
|
||||||
|
`fp_certificate_views.xml` — Issue button on form:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<button name="action_issue" string="Issue" type="object"
|
||||||
|
invisible="state != 'draft'"
|
||||||
|
groups="fusion_plating.group_fp_quality_manager,
|
||||||
|
fusion_plating.group_fp_manager,
|
||||||
|
fusion_plating.group_fp_owner"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tablet impact
|
||||||
|
|
||||||
|
S18-era cert flow ran any operator session. Post-change: remove the Issue affordance from operator-facing tablet surfaces. The cert form is still openable (read-only for Technicians); the action button is hidden. Surface a "Send to QM" hint on the job workspace footer when state is `awaiting_cert`, but the actual notification fires automatically — no operator click required.
|
||||||
|
|
||||||
|
## Notification changes
|
||||||
|
|
||||||
|
### New trigger events
|
||||||
|
|
||||||
|
Add to `fp.notification.template.trigger_event` selection:
|
||||||
|
|
||||||
|
```python
|
||||||
|
('cert_awaiting_issuance', _('Cert Awaiting Issuance')),
|
||||||
|
('cert_voided_re_notify', _('Cert Voided — Please Re-Issue')),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seeded template — `data/fp_cert_authority_templates.xml`
|
||||||
|
|
||||||
|
Single template per event (subject, body, recipient_resolver = `cert_authority_group_members`). Default body (per the Architecture diagram above and the section that follows on the recipient resolver):
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: 🏷️ Job {{ object.display_wo_name }} ready for CoC issuance
|
||||||
|
|
||||||
|
Hi {{ recipient.name|first_name }},
|
||||||
|
|
||||||
|
Job {{ object.display_wo_name }} ({{ object.partner_id.name }})
|
||||||
|
has finished the shop floor and is awaiting CoC issuance.
|
||||||
|
|
||||||
|
Part: {{ object.part_catalog_id.part_number }}
|
||||||
|
Quantity: {{ object.qty_done }}
|
||||||
|
Recipe: {{ object.recipe_id.name }}
|
||||||
|
|
||||||
|
Review the inspection prompts captured by the operator on the Final
|
||||||
|
Inspection step, then issue the CoC from the Quality Dashboard:
|
||||||
|
|
||||||
|
→ {{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}
|
||||||
|
|
||||||
|
Or open the job directly:
|
||||||
|
|
||||||
|
→ {{ object.x_fc_record_url }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Marked `noupdate="1"` so admin edits in the UI survive `-u` (Rule 22).
|
||||||
|
|
||||||
|
### Recipient resolver — `_fp_resolve_cert_authority_users()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api.model
|
||||||
|
def _fp_resolve_cert_authority_users(self, job=None):
|
||||||
|
"""Return active, non-share users with QM | Manager | Owner role
|
||||||
|
(transitive via all_group_ids). See Rule 13l for the rationale —
|
||||||
|
direct user_ids on group records does NOT include implied
|
||||||
|
memberships."""
|
||||||
|
gids = [
|
||||||
|
self.env.ref('fusion_plating.group_fp_quality_manager').id,
|
||||||
|
self.env.ref('fusion_plating.group_fp_manager').id,
|
||||||
|
self.env.ref('fusion_plating.group_fp_owner').id,
|
||||||
|
]
|
||||||
|
return self.env['res.users'].sudo().search([
|
||||||
|
('all_group_ids', 'in', gids),
|
||||||
|
('share', '=', False),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Throttling / dedupe
|
||||||
|
|
||||||
|
`fp.notification.log` dedupe key is `(template_id, source_record_id, event)`. The two events have distinct keys, so a `cert_voided_re_notify` after `cert_awaiting_issuance` re-fires (correct — different signal). Repeated `cert_awaiting_issuance` for the same job is suppressed (correct — already on the QM's radar).
|
||||||
|
|
||||||
|
### `mail.activity` belt + suspenders
|
||||||
|
|
||||||
|
After firing the notification, schedule one activity per job (not per QM — round-robin assignment to a single QM, who can re-assign if needed). Auto-resolves when state transitions to `awaiting_ship`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_schedule_cert_activity(self):
|
||||||
|
self.ensure_one()
|
||||||
|
activity_type = self.env.ref(
|
||||||
|
'fusion_plating_jobs.activity_type_issue_coc',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not activity_type:
|
||||||
|
return
|
||||||
|
qm = self.env['fp.notification.template']._fp_resolve_cert_authority_users(self)
|
||||||
|
if not qm:
|
||||||
|
return
|
||||||
|
# Round-robin: pick the QM who logged in least recently (likely
|
||||||
|
# least busy). NULL login_date sorts first.
|
||||||
|
qm = qm.sorted(lambda u: u.login_date or fields.Datetime.from_string('1970-01-01'))[:1]
|
||||||
|
self.activity_schedule(
|
||||||
|
activity_type_id=activity_type.id,
|
||||||
|
user_id=qm.id,
|
||||||
|
summary=_('Issue CoC for %s') % (self.display_wo_name or self.name),
|
||||||
|
note=_('Job has finished the shop floor. Review the inspection '
|
||||||
|
'prompts captured on the final step, then issue the CoC.'),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-resolve on `awaiting_ship` transition:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fp_resolve_cert_activities(self):
|
||||||
|
self.ensure_one()
|
||||||
|
activity_type = self.env.ref(
|
||||||
|
'fusion_plating_jobs.activity_type_issue_coc',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not activity_type:
|
||||||
|
return
|
||||||
|
self.activity_ids.filtered(
|
||||||
|
lambda a: a.activity_type_id == activity_type
|
||||||
|
).action_feedback(feedback=_('Cert issued — auto-resolved.'))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration plan
|
||||||
|
|
||||||
|
### Pre-migration state assessment
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Count jobs currently affected
|
||||||
|
SELECT state, count(*)
|
||||||
|
FROM fp_job
|
||||||
|
WHERE state IN ('confirmed', 'in_progress', 'done')
|
||||||
|
GROUP BY state;
|
||||||
|
|
||||||
|
-- Jobs in-progress with all steps terminal (will migrate to awaiting_cert or awaiting_ship)
|
||||||
|
SELECT j.id, j.name, j.state,
|
||||||
|
count(s.id) AS total_steps,
|
||||||
|
count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled')) AS terminal_steps
|
||||||
|
FROM fp_job j
|
||||||
|
LEFT JOIN fp_job_step s ON s.job_id = j.id
|
||||||
|
WHERE j.state = 'in_progress'
|
||||||
|
GROUP BY j.id, j.name, j.state
|
||||||
|
HAVING count(s.id) > 0
|
||||||
|
AND count(s.id) = count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled'));
|
||||||
|
|
||||||
|
-- Done jobs with draft certs (would need backfill in the new world, but we leave them alone)
|
||||||
|
SELECT j.id, j.name, count(c.id) AS draft_certs
|
||||||
|
FROM fp_job j
|
||||||
|
JOIN fp_certificate c ON c.x_fc_job_id = j.id AND c.state = 'draft'
|
||||||
|
WHERE j.state = 'done'
|
||||||
|
GROUP BY j.id, j.name;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration script — `fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def migrate(cr, version):
|
||||||
|
"""Backfill new states for jobs caught mid-transition by the upgrade.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- in_progress + all steps terminal + draft cert exists → awaiting_cert
|
||||||
|
- in_progress + all steps terminal + no cert required → awaiting_ship
|
||||||
|
- done jobs LEFT ALONE — they're historical (already shipped)
|
||||||
|
"""
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_job
|
||||||
|
SET state = 'awaiting_cert'
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT j.id
|
||||||
|
FROM fp_job j
|
||||||
|
JOIN fp_job_step s ON s.job_id = j.id
|
||||||
|
WHERE j.state = 'in_progress'
|
||||||
|
GROUP BY j.id
|
||||||
|
HAVING count(*) FILTER (
|
||||||
|
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||||
|
) = 0
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM fp_certificate c
|
||||||
|
WHERE c.x_fc_job_id = fp_job.id AND c.state = 'draft'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_job
|
||||||
|
SET state = 'awaiting_ship'
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT j.id
|
||||||
|
FROM fp_job j
|
||||||
|
JOIN fp_job_step s ON s.job_id = j.id
|
||||||
|
WHERE j.state = 'in_progress'
|
||||||
|
GROUP BY j.id
|
||||||
|
HAVING count(*) FILTER (
|
||||||
|
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||||
|
) = 0
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM fp_certificate c
|
||||||
|
WHERE c.x_fc_job_id = fp_job.id
|
||||||
|
AND c.state IN ('draft', 'issued')
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent: re-running on a fresh upgrade is a no-op because no `in_progress` job will match the all-terminal predicate after the first run.
|
||||||
|
|
||||||
|
### Card_state recompute
|
||||||
|
|
||||||
|
After the migration script runs, force a recompute of `fp.job.card_state` (stored compute) so the kanban renders correctly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In post-migrate.py, after the state UPDATEs:
|
||||||
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||||
|
affected = env['fp.job'].search([
|
||||||
|
('state', 'in', ('awaiting_cert', 'awaiting_ship')),
|
||||||
|
])
|
||||||
|
affected.invalidate_recordset(['card_state'])
|
||||||
|
affected._compute_card_state()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge cases + defensive design
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Job with no recipe steps at all | `_fp_check_advance_post_shop` returns early (nothing to check). State stays at whatever it was — operator can still manually move it via the existing milestone-advance button. |
|
||||||
|
| Recipe doesn't have a Final Inspection step | Card still lands in Final Inspection column (state drives column, not recipe shape). The recipe author probably forgot to add the step — Quality Dashboard surface catches it. |
|
||||||
|
| Customer not flagged for any cert | `_resolve_required_cert_types()` returns empty set → state transitions `in_progress → awaiting_ship` directly. Card visible in Shipping column. No notification fires (no QM action needed). |
|
||||||
|
| QM voids a cert AFTER `awaiting_ship` | `_fp_check_regress_after_cert_void()` flips state back to `awaiting_cert`, re-fires `cert_voided_re_notify` (different dedupe key from initial notification, so not suppressed). Card visibly moves left from Shipping back to Final Inspection. |
|
||||||
|
| Operator marks a step as `cancelled` mid-flow that bricks the all-terminal check | `cancelled` IS terminal per the existing logic — so cancelling the last open step DOES trigger the transition. Operator-error path: if a manager opens a previously-terminal step (e.g. `_fp_reopen_step`) the state should regress to `in_progress`. Add inverse trigger on step reopen. |
|
||||||
|
| Cron-triggered cert issuance (e.g. some future auto-issue) with no `env.user` | The Python guard hits `self.env.user` which would be the cron user. Need cron-safe bypass: use `fp_skip_cert_authority_gate=True` in the cron's `with_context(...)` call. Documented but no cron exists today. |
|
||||||
|
| Migration: existing `state='done'` jobs that haven't shipped | Left alone — historically completed jobs are out of scope. Backfill action (`action_backfill_missing_certs` in fp_job.py) already exists for cert gaps; that path is unchanged. |
|
||||||
|
| Multiple QMs assigned a single activity | Round-robin picks ONE QM (oldest `login_date`). They can reassign via the activity widget if needed. Activity auto-resolves on `awaiting_ship` regardless of which QM acted. |
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Smoke test (entech-style, scriptable)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/bt_post_shop_states.py
|
||||||
|
# 1. Create SO + job with cert-requiring customer
|
||||||
|
# 2. Walk every step to terminal → assert state='awaiting_cert'
|
||||||
|
# 3. Assert card appears in plant_kanban under 'inspection' column
|
||||||
|
# 4. Assert email + activity scheduled on a QM
|
||||||
|
# 5. As a Technician, call cert.action_issue() → assert AccessError
|
||||||
|
# 6. As a QM, call cert.action_issue() → state='issued', job state→'awaiting_ship'
|
||||||
|
# 7. Assert card moves to 'shipping' column, activity auto-resolves
|
||||||
|
# 8. Void the cert → assert state back to 'awaiting_cert', activity re-scheduled
|
||||||
|
# 9. Re-issue → 'awaiting_ship' again
|
||||||
|
# 10. Click button_mark_shipped (as Manager) → state='done', card off board
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unit-level
|
||||||
|
|
||||||
|
- `fp.job._fp_check_advance_post_shop` — exhaustive matrix of (state, all_terminal, certs_required) → expected new state
|
||||||
|
- `fp.certificate.action_issue` ACL — separate tests per role (Tech/Mgr/QM/Owner/Sales Rep)
|
||||||
|
- `_fp_resolve_cert_authority_users` — entech-realistic group setup, confirm Owners are returned via implication
|
||||||
|
|
||||||
|
### Manual QA on entech
|
||||||
|
|
||||||
|
1. Pick a currently-done WO-30058 (the triggering job) → run migration → confirm it stays at `state='done'` (untouched).
|
||||||
|
2. Find an `in_progress` job with all steps terminal — confirm migration script moves it to the right state.
|
||||||
|
3. Walk a fresh SO end-to-end: confirm card visibility at each column transition.
|
||||||
|
4. Try issuing a cert as a Technician via the JS-rpc console → confirm AccessError.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
### `fusion_plating_jobs/`
|
||||||
|
- `models/fp_job.py` — state extension, new methods, repurpose button_mark_done, new button_mark_shipped, hooks for cert state changes, mini-timeline update, card-state extension
|
||||||
|
- `views/fp_job_views.xml` — Mark Shipped button (replaces Mark Done), groups gating
|
||||||
|
- `data/fp_activity_types_data.xml` — new mail.activity.type `activity_type_issue_coc`
|
||||||
|
- `migrations/19.0.<next>/post-migrate.py` — state backfill
|
||||||
|
- `__manifest__.py` — version bump, data file additions
|
||||||
|
|
||||||
|
### `fusion_plating_certificates/`
|
||||||
|
- `models/fp_certificate.py` — Python ACL guard in action_issue, write override for state=voided, x_fc_age_hours computed
|
||||||
|
- `views/fp_certificate_views.xml` — `groups=` on Issue button
|
||||||
|
- `__manifest__.py` — version bump
|
||||||
|
|
||||||
|
### `fusion_plating_shopfloor/`
|
||||||
|
- `controllers/plant_kanban.py` — domain widen, column resolution, chip rendering, KPI compute, sort priority
|
||||||
|
- `static/src/js/plant_kanban.js` — KPI tile + filter chip additions
|
||||||
|
- `static/src/scss/_plant_card.scss` + `_plant_tokens.scss` — new state modifier classes (light + dark via `$o-webclient-color-scheme`)
|
||||||
|
- `__manifest__.py` — version bump
|
||||||
|
|
||||||
|
### `fusion_plating_quality/`
|
||||||
|
- `controllers/fp_quality_dashboard.py` — certificates block in counts response
|
||||||
|
- `static/src/{js,xml,scss}/fp_quality_dashboard.*` — sixth tab, header KPI, deep-link `?tab=certificates` parsing
|
||||||
|
- `__manifest__.py` — version bump (and add `fusion_plating_certificates` to depends if not already)
|
||||||
|
|
||||||
|
### `fusion_plating_notifications/`
|
||||||
|
- `models/fp_notification_template.py` — new trigger_event selection values, recipient resolver helper
|
||||||
|
- `data/fp_cert_authority_templates.xml` — seeded templates for both events
|
||||||
|
- `__manifest__.py` — version bump
|
||||||
|
|
||||||
|
## Open questions for implementation phase
|
||||||
|
|
||||||
|
1. **Where exactly does the auto-transition fire?** Most likely `fp.job.step.write` post-hook when `state` changes — needs centralization so all step-completion paths (button_finish, action_skip, action_cancel, etc.) trigger consistently. Implementation plan should validate by grepping for every site that sets `step.state`.
|
||||||
|
2. **Should `button_mark_shipped` add ANY gates** (e.g. delivery exists / draft cert isn't lingering)? Default answer: no — the state machine has already validated correctness; the button is just "yes, shipped". But worth re-confirming.
|
||||||
|
3. **Tablet "Send to QM" footer hint** — exact wording and link target. Minor UX; can be settled during implementation.
|
||||||
|
4. **Mini-timeline dot for `done` (state at end-of-lifecycle)** — currently the timeline is always 9 dots regardless of state. After shipping the card is off the board, so this only matters for historical viewers. Probably no change needed; flag for implementation review.
|
||||||
|
|
||||||
|
## Status & deployment notes
|
||||||
|
|
||||||
|
Target version bumps (suggestion, finalize at implementation time):
|
||||||
|
- `fusion_plating_jobs` 19.0.10.24.0 → 19.0.11.0.0 (state extension is a minor-version bump per existing convention)
|
||||||
|
- `fusion_plating_certificates` 19.0.5.4.0 → 19.0.6.0.0
|
||||||
|
- `fusion_plating_shopfloor` 19.0.34.0.0 → 19.0.35.0.0
|
||||||
|
- `fusion_plating_quality` 19.0.5.0.0 → 19.0.6.0.0
|
||||||
|
- `fusion_plating_notifications` minor bump
|
||||||
|
|
||||||
|
Deploy order: notifications → jobs (post-migrate runs here) → certificates → shopfloor → quality. Each gets its own `-u` step to keep blast radius small per [CLAUDE.md → Sub 12 build order rule 11](../../CLAUDE.md).
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
# Quality Dashboard Redesign — Action Surface
|
||||||
|
|
||||||
|
**Date:** 2026-05-25
|
||||||
|
**Status:** Approved for implementation (brainstorming gate)
|
||||||
|
**Author:** Brainstorming session (gsinghpal)
|
||||||
|
**Triggering incident:** The current Quality Dashboard is a tab-router — it surfaces 6 numeric tiles and forces the QM to click into per-model kanbans to actually see and act on items. Two complaints: (1) lots of empty whitespace below the tile row, (2) flagged tasks that need QM attention aren't visible at a glance. The Quality Manager wants "all quality related updates at glance, all the flagged tasks need to show right here so the manager can quickly follow up and complete the task" (verbatim from session 2026-05-25).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the Quality Dashboard from a **router** ("click here to see N records") into an **action surface** ("here are the records that need attention, click Open to act"). Specifically:
|
||||||
|
|
||||||
|
1. **Surface flagged items directly on the page** — no extra navigation for the things that need the QM's eyes today.
|
||||||
|
2. **Distinguish urgent from routine** — red "Needs Attention Today" banner at the top draws the eye to overdue + critical-customer items across all types; per-type sections below hold the routine queue.
|
||||||
|
3. **Preserve the existing per-model kanbans** — "Open all →" links route to them unchanged. We're rebuilding the dashboard surface, not the underlying record management.
|
||||||
|
4. **Keep the existing notification deep-link working** — the `?tab=certificates` URL param from the awaiting-cert email (spec 2026-05-25-post-shop-cert-shipping-job-states) still lands the QM on the right section.
|
||||||
|
|
||||||
|
## Out of scope (deferred to follow-on work)
|
||||||
|
|
||||||
|
- **Real-time push** (bus.bus / WebSocket). 60-second poll is the cadence; instant updates would add infrastructure complexity for marginal benefit on a QM dashboard.
|
||||||
|
- **Filter chips on the dashboard** (e.g. "show only my customer"). Sections themselves are the filter; per-section search lives inside the per-model kanban.
|
||||||
|
- **Per-QM saved layout** (drag-reorder sections). Fixed order in v1 — Settings field is YAGNI for one user role.
|
||||||
|
- **Export / print of the dashboard**. The underlying kanbans support print.
|
||||||
|
- **"My quality day" personalization** ("Hi Lisa — 4 critical items…"). Fixed shared view in v1.
|
||||||
|
- **Inline action buttons that fire actions directly** (e.g. one-click "Issue" on the cert row). Considered and rejected — the cert form is where Fischerscope review + sign-off prereqs are validated; bypassing the form risks issuing a cert before checks complete. Every row uses a single `Open →` button that navigates to the form.
|
||||||
|
|
||||||
|
## Decisions reached during brainstorming
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | **Hybrid layout** — red "Needs Attention Today" banner on top + grouped sections per type below | Banner forces urgent items to the front of the eye; sections preserve type semantics for routine work. User picked from a 4-option visual mockup over Unified Inbox / Grouped Only / Priority Stacks. |
|
||||||
|
| D2 | **Banner rule** = (overdue per type) OR (critical customer + open) | Catches the high-stakes Boeing CoC at 6h before it crosses the 24h overdue line. Simple enough to predict, more responsive than overdue-only. |
|
||||||
|
| D3 | **Critical customer** = `partner.x_fc_rush` OR `partner.x_fc_vip` OR aerospace/regulated indicator (`part_catalog_id.name ILIKE '%aerospace%'` OR `customer_spec_id.code ILIKE 'AS9100%'` OR `'NADCAP%'`) | Reuses existing partner flags (no new field to maintain). Aerospace/regulated catches high-stakes regulatory work even on non-rush/VIP customers. |
|
||||||
|
| D4 | **Banner size** = up to 6 items in a 2×3 grid; if zero qualify, render a green "✓ All caught up" card instead of hiding | Predictable layout, positive reinforcement when the queue is clear. "Showing 6 of N" footer when more than 6 qualify. |
|
||||||
|
| D5 | **Inline row action** = single `Open →` button per row that opens the record form | Safe (no one-click bypasses of in-form checks), consistent across all 6 types, easiest to implement. The verb-specific button alternative (Issue / Disposition / Review / etc.) was rejected as visual clutter without functional value. |
|
||||||
|
| D6 | **Drop the existing "Quality Overview" header strip** (Open across all 6 / Overdue / 6 tab tiles) | Redundant after the redesign — banner shows urgent, each section shows its own count + overdue subtotal. Recovered vertical space goes to actual content. |
|
||||||
|
| D7 | **Section order** = Certificates → Holds → NCRs → RMAs → CAPAs → QC Checks | QM-urgency order: Certs first because they block shipment + are time-sensitive post-shop; Holds second because they block production; then NCRs / RMAs / CAPAs / Checks in decreasing time-pressure. NOT alphabetical, NOT the existing tab order. |
|
||||||
|
| D8 | **Section content** = top 5 items inline + "Open all →" link to the existing per-model kanban | Top 5 covers the daily attention budget; deeper drill uses the kanban the QM already knows. |
|
||||||
|
| D9 | **Section header shows count + overdue subtotal** | Section is its own micro-summary — no need for the dropped header strip to repeat this. |
|
||||||
|
| D10 | **Critical-customer items get a badge in the banner** (`[RUSH]`, `[VIP]`, `[AS9100]`) | Tells the QM WHY the item is in the banner when it's not yet overdue. No badge = banner reason is overdue. |
|
||||||
|
| D11 | **Section that shows zero items still renders** with an italic "No open items" row | Predictable layout. The QM trusts the dashboard isn't lying about types being absent. |
|
||||||
|
| D12 | **An item may appear in BOTH the banner and its section** | Intentional. The banner is "urgent across all types"; the section is "your queue per type". Visual reinforcement of urgency, not duplication. |
|
||||||
|
| D13 | **Refresh cadence** = keep the existing 60-second JS poll | No bus.bus / WebSocket. 60s matches the existing pattern + is fine for a QM surface. |
|
||||||
|
| D14 | **`?tab=certificates` deep-link preserved as scroll-into-view** | The notification email from spec 2026-05-25-post-shop-cert-shipping-job-states links here. Translate `?tab=<id>` to `document.getElementById('section-<id>').scrollIntoView()` on mount. |
|
||||||
|
| D15 | **ACL enforced at click time, not pre-filtered in snapshot** | Pre-filtering would 4x the query cost. Odoo's standard ACL fires when `act_window` navigates to the record — user gets the usual access error if blocked. Acceptable since dashboard is QM-facing and QMs have read on all 6 models. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ PAGE LAYOUT (top → bottom) ─────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌─ NEEDS ATTENTION TODAY · N ────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [ITEM] [ITEM] [ITEM] ← 2 × 3 grid, up to 6 items │ │
|
||||||
|
│ │ [ITEM] [ITEM] [ITEM] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ (or green "✓ All caught up" card when zero) │ │
|
||||||
|
│ │ (or "Showing 6 of N — see sections" when overflow) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ 🏷️ CERTIFICATES · X open · Y overdue ── Open all → ───┐ │
|
||||||
|
│ │ ROW · ROW · ROW · ROW · ROW ← top 5 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ 🛑 HOLDS · X open ───────────────────── Open all → ───┐ │
|
||||||
|
│ │ ROW · ROW · ROW │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ 🔬 NCRs · X open · Y overdue ──────── Open all → ──┐ │
|
||||||
|
│ │ ROW · ROW │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ ↩️ RMAs · X open ────────────────── Open all → ────┐ │
|
||||||
|
│ │ ROW │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ 📋 CAPAs · X open ─────────────────── Open all → ──┐ │
|
||||||
|
│ │ ROW │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ ✓ QC CHECKS · X open ─────────────── Open all → ──┐ │
|
||||||
|
│ │ ROW │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ DATA FLOW (one endpoint, one render) ───────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ browser POST /fp/quality/dashboard/snapshot │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ FpQualityDashboardSnapshot._build() │
|
||||||
|
│ │ │
|
||||||
|
│ ├── _critical_customer_domain() → reusable filter clause │
|
||||||
|
│ ├── _overdue_filter(type) → per-type thresholds │
|
||||||
|
│ │ │
|
||||||
|
│ ├── _build_section('cert') ─┐ │
|
||||||
|
│ ├── _build_section('hold') │ │
|
||||||
|
│ ├── _build_section('ncr') ├─ per-type queries │
|
||||||
|
│ ├── _build_section('rma') │ (sequential, ~50ms each) │
|
||||||
|
│ ├── _build_section('capa') │ │
|
||||||
|
│ └── _build_section('check') ─┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ _build_banner(candidates) → ranked top 6 │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ { banner: {...}, sections: [...] } returned as JSON │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ OWL component renders BannerCard + 6 SectionCards in order │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend — snapshot endpoint
|
||||||
|
|
||||||
|
### Replace `/fp/quality/dashboard/counts` with `/fp/quality/dashboard/snapshot`
|
||||||
|
|
||||||
|
**Why replace, not extend:** the counts endpoint returned only numbers; the new endpoint returns numbers + actual records. The shape is incompatible. Grep confirms no other module calls the counts endpoint — it's only used by the dashboard JS we're rewriting.
|
||||||
|
|
||||||
|
### Response shape
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"banner": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "cert", // cert|hold|ncr|rma|capa|check
|
||||||
|
"id": 123,
|
||||||
|
"name": "CoC-30058",
|
||||||
|
"customer": "ABC Manufactoring",
|
||||||
|
"subtitle": "14h overdue · awaiting issuance",
|
||||||
|
"urgency": "overdue", // "overdue" or "critical_customer"
|
||||||
|
"critical_badge": null, // "RUSH" | "VIP" | "AS9100" | null
|
||||||
|
"open_action": {
|
||||||
|
"res_model": "fp.certificate",
|
||||||
|
"res_id": 123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... up to 6 items
|
||||||
|
],
|
||||||
|
"all_clear": false, // true when items list is empty
|
||||||
|
"total_matching": 9 // count BEFORE the top-6 truncation
|
||||||
|
},
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "cert",
|
||||||
|
"label": "Certificates",
|
||||||
|
"icon": "🏷️",
|
||||||
|
"open": 5,
|
||||||
|
"overdue": 3,
|
||||||
|
"items": [ // top 5 by urgency
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"name": "CoC-30058",
|
||||||
|
"customer": "ABC Manufactoring",
|
||||||
|
"subtitle": "14h overdue",
|
||||||
|
"urgency": "overdue",
|
||||||
|
"open_action": {
|
||||||
|
"res_model": "fp.certificate",
|
||||||
|
"res_id": 123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... up to 5
|
||||||
|
],
|
||||||
|
"open_kanban_xmlid": "fusion_plating_certificates.action_fp_certificate"
|
||||||
|
}
|
||||||
|
// ... 6 sections in this order: cert, hold, ncr, rma, capa, check
|
||||||
|
],
|
||||||
|
"computed_at": "2026-05-25T16:42:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
```python
|
||||||
|
# fusion_plating_quality/controllers/fp_quality_dashboard.py
|
||||||
|
|
||||||
|
# Per-type "overdue" thresholds (reused from existing counts endpoint —
|
||||||
|
# battle-tested):
|
||||||
|
# CAPA is the special case — its overdue rule is due_date < today,
|
||||||
|
# not create_date < cutoff. Marked with use_due_date=True so the
|
||||||
|
# overdue-filter dispatcher branches correctly.
|
||||||
|
OVERDUE_THRESHOLDS = {
|
||||||
|
'cert': {'days': 1, 'use_due_date': False, 'domain': [('state', '=', 'draft')]},
|
||||||
|
'hold': {'days': 3, 'use_due_date': False, 'domain': [('state', 'in', ('on_hold', 'under_review'))]},
|
||||||
|
'ncr': {'days': 7, 'use_due_date': False, 'domain': [('state', 'in', ('open', 'containment', 'disposition'))]},
|
||||||
|
'rma': {'days': 5, 'use_due_date': False, 'domain': [('state', '=', 'received')]},
|
||||||
|
'capa': {'days': None, 'use_due_date': True, 'domain': [('state', 'not in', ('closed', 'effective'))]},
|
||||||
|
'check': {'days': 1, 'use_due_date': False, 'domain': [('state', '=', 'pending')]},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-type config for the snapshot builder
|
||||||
|
TYPE_CONFIG = {
|
||||||
|
'cert': {'label': 'Certificates', 'icon': '🏷️',
|
||||||
|
'model': 'fp.certificate',
|
||||||
|
'kanban_xmlid': 'fusion_plating_certificates.action_fp_certificate',
|
||||||
|
'partner_field': 'partner_id', 'name_field': 'name'},
|
||||||
|
# ... etc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Canonical order
|
||||||
|
SECTION_ORDER = ['cert', 'hold', 'ncr', 'rma', 'capa', 'check']
|
||||||
|
|
||||||
|
|
||||||
|
class FpQualityDashboardSnapshot:
|
||||||
|
def __init__(self, env):
|
||||||
|
self.env = env
|
||||||
|
self.now = fields.Datetime.now()
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
candidates_for_banner = []
|
||||||
|
sections = []
|
||||||
|
for type_code in SECTION_ORDER:
|
||||||
|
section = self._build_section(type_code)
|
||||||
|
if section is None:
|
||||||
|
continue # model not installed (e.g. fp.certificate)
|
||||||
|
sections.append(section)
|
||||||
|
# Pull banner candidates: overdue OR critical-customer + open
|
||||||
|
banner_candidates = self._fetch_banner_candidates(type_code)
|
||||||
|
candidates_for_banner.extend(banner_candidates)
|
||||||
|
banner = self._build_banner(candidates_for_banner)
|
||||||
|
return {
|
||||||
|
'banner': banner,
|
||||||
|
'sections': sections,
|
||||||
|
'computed_at': self.now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _critical_customer_domain(self, type_code):
|
||||||
|
"""Per-type domain fragment that matches records with critical-
|
||||||
|
customer signals. ORed via |. Per D3 — uses existing partner
|
||||||
|
flags + aerospace/regulated indicators. The per-type field
|
||||||
|
names differ (e.g. cert uses partner_id directly; check uses
|
||||||
|
job_id.partner_id), so the method dispatches per type.
|
||||||
|
|
||||||
|
Returns a list-of-clauses ready to be combined with the
|
||||||
|
type's overdue filter via Odoo OR domain prefix notation.
|
||||||
|
Returns empty list when none of the optional fields exist.
|
||||||
|
"""
|
||||||
|
# cert → partner_id.x_fc_rush | partner_id.x_fc_vip |
|
||||||
|
# part_catalog_id.name ILIKE '%aerospace%' |
|
||||||
|
# customer_spec_id.code ILIKE 'AS9100%' | 'NADCAP%'
|
||||||
|
# hold → partner_id.x_fc_rush | x_fc_vip (no part/spec on hold)
|
||||||
|
# ncr → partner_id.x_fc_rush | x_fc_vip + part via job_id link
|
||||||
|
# rma → partner_id.x_fc_rush | x_fc_vip
|
||||||
|
# capa → partner_id.x_fc_rush | x_fc_vip (via linked ncr/rma)
|
||||||
|
# check → job_id.partner_id.x_fc_rush | x_fc_vip + part via job_id
|
||||||
|
...
|
||||||
|
|
||||||
|
def _overdue_filter(self, type_code):
|
||||||
|
"""Build overdue domain for the type. CAPA uses due_date < today;
|
||||||
|
all others use create_date < (now - threshold_days)."""
|
||||||
|
cfg = OVERDUE_THRESHOLDS[type_code]
|
||||||
|
base = list(cfg['domain'])
|
||||||
|
if type_code == 'capa':
|
||||||
|
base += [('due_date', '<', self.now.date()),
|
||||||
|
('due_date', '!=', False)]
|
||||||
|
else:
|
||||||
|
cutoff = self.now - timedelta(days=cfg['days'])
|
||||||
|
base += [('create_date', '<', cutoff)]
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _fetch_banner_candidates(self, type_code):
|
||||||
|
"""Per-type pull of records that qualify for the banner:
|
||||||
|
(overdue) OR (critical-customer AND still-open).
|
||||||
|
Returns list of dicts in the shape that _build_banner can sort.
|
||||||
|
"""
|
||||||
|
# ... runs two searches per type (overdue + critical-customer-open),
|
||||||
|
# dedupes by id, returns shaped dicts with urgency + critical_badge.
|
||||||
|
...
|
||||||
|
|
||||||
|
def _build_section(self, type_code):
|
||||||
|
"""Return the section dict with top-5 items + counts.
|
||||||
|
Returns None when the model isn't installed."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def _build_banner(self, candidates):
|
||||||
|
"""Rank candidates: overdue first (oldest first), then
|
||||||
|
critical-customer-non-overdue (oldest first). Take top 6.
|
||||||
|
Returns {items: [...], all_clear: bool, total_matching: int}."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sudo strategy** — per Rule 13m, the snapshot reads cross-module fields (`partner_id.x_fc_rush`, `part_catalog_id.name`, `customer_spec_id.code`) that low-privilege roles might not have read on. The controller does `request.env['fp.certificate'].sudo()` etc. for the snapshot build. The `open_action` payload navigates via standard `act_window` which re-enforces ACL on click.
|
||||||
|
|
||||||
|
**Defensive field-existence checks** — the cross-module field reads are guarded:
|
||||||
|
```python
|
||||||
|
partner = rec.partner_id
|
||||||
|
is_rush = bool(getattr(partner, 'x_fc_rush', False))
|
||||||
|
is_vip = bool(getattr(partner, 'x_fc_vip', False))
|
||||||
|
```
|
||||||
|
If a field doesn't exist (module uninstalled), that signal is unavailable — the item can still qualify via the overdue path.
|
||||||
|
|
||||||
|
## Frontend — OWL component tree
|
||||||
|
|
||||||
|
```
|
||||||
|
FpQualityDashboard // top-level client action
|
||||||
|
├── BannerCard // one card; shows items or all-clear
|
||||||
|
│ └── BannerItem[] // 0-6 grid cells
|
||||||
|
└── SectionCard[] // 6 in fixed order
|
||||||
|
└── SectionRow[] // up to 5 rows, or 1 italic "no open items"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sub-components live in the same JS file** (`fp_quality_dashboard.js`) — they're not reused anywhere else. If `BannerItem` later moves to a manager dashboard, *then* it migrates to `components/`.
|
||||||
|
|
||||||
|
### Component state
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
snapshot: null, // the whole response shape
|
||||||
|
error: null, // shown if RPC fails
|
||||||
|
});
|
||||||
|
|
||||||
|
// On mount:
|
||||||
|
// 1. await rpc('/fp/quality/dashboard/snapshot') → state.snapshot
|
||||||
|
// 2. if action.params.tab → scroll to section after render
|
||||||
|
// 3. start 60s setInterval to re-poll
|
||||||
|
//
|
||||||
|
// On row click — build the act_window dict explicitly. doAction
|
||||||
|
// accepts either a full action dict OR an xmlid string; we use the
|
||||||
|
// dict shape here because we're synthesising a form view from the
|
||||||
|
// snapshot payload (no act_window xmlid exists for "this specific
|
||||||
|
// record's form").
|
||||||
|
// this.action.doAction({
|
||||||
|
// type: 'ir.actions.act_window',
|
||||||
|
// res_model: item.open_action.res_model,
|
||||||
|
// res_id: item.open_action.res_id,
|
||||||
|
// view_mode: 'form',
|
||||||
|
// views: [[false, 'form']],
|
||||||
|
// target: 'current',
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// On "Open all →" click — pass the xmlid string directly. The
|
||||||
|
// action service in Odoo 19 resolves it via the registry. Confirmed
|
||||||
|
// pattern used by the existing dashboard's openTab() method which
|
||||||
|
// already passes a full dict; we use the xmlid form here because
|
||||||
|
// the snapshot ships pre-resolved xmlids and we don't want to
|
||||||
|
// re-encode the kanban view config in the snapshot payload.
|
||||||
|
// this.action.doAction(section.open_kanban_xmlid);
|
||||||
|
//
|
||||||
|
// Note: if `doAction(xmlid_string)` ever stops working in a future
|
||||||
|
// Odoo version, the fallback is to ship the full act_window dict in
|
||||||
|
// the snapshot instead of just the xmlid string — change is local
|
||||||
|
// to the snapshot builder.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deep-link preservation
|
||||||
|
|
||||||
|
The notification email from spec 2026-05-25-post-shop-cert-shipping-job-states links to `/odoo/action-fp_quality_dashboard?tab=certificates`. The new dashboard reads `this.props.action.context.params.tab` on mount and, after first render, calls `document.getElementById('section-<tab>').scrollIntoView({behavior: 'smooth'})`.
|
||||||
|
|
||||||
|
**Template requirement (don't forget at implementation):** each `<SectionCard>` MUST render `<div t-att-id="'section-' + section.type">`. Without the IDs the scrollIntoView call no-ops silently and the deep-link still lands on the dashboard but doesn't focus the right section. The tab → section_id mapping is direct (`'certificates'` from the URL maps to `'cert'` from the type code — the email's `?tab=certificates` arrives as `params.tab='certificates'` so the JS needs a one-line normalize: `const sectionType = tab.startsWith('cert') ? 'cert' : tab;`).
|
||||||
|
|
||||||
|
No change to the email body needed.
|
||||||
|
|
||||||
|
## SCSS — file structure + dark-mode
|
||||||
|
|
||||||
|
**Single file**: `fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss`. No partials — the dashboard is small enough.
|
||||||
|
|
||||||
|
**Tokens**: reuse `$plant-card-bg`, `$plant-bg`, `$plant-card-border`, `$plant-text`, `$plant-muted` from `_plant_tokens.scss` (loaded earlier in the manifest). Dark mode auto-flips via the existing `@if $o-webclient-color-scheme == dark` global override.
|
||||||
|
|
||||||
|
**New scoped tokens** (defined locally with light + dark variants):
|
||||||
|
|
||||||
|
```scss
|
||||||
|
$_qd-urgent-bg-light: #fee2e2;
|
||||||
|
$_qd-urgent-bg-dark: #3a1818;
|
||||||
|
$_qd-urgent-border: #dc2626;
|
||||||
|
|
||||||
|
$_qd-good-bg-light: #d1fae5;
|
||||||
|
$_qd-good-bg-dark: #14281a;
|
||||||
|
$_qd-good-border: #22c55e;
|
||||||
|
|
||||||
|
$_qd-section-head-bg-light: #fef3c7;
|
||||||
|
$_qd-section-head-bg-dark: #3a2f15;
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
$_qd-urgent-bg-light: $_qd-urgent-bg-dark !global;
|
||||||
|
$_qd-good-bg-light: $_qd-good-bg-dark !global;
|
||||||
|
$_qd-section-head-bg-light: $_qd-section-head-bg-dark !global;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Banner styling**:
|
||||||
|
- When items present: `background: linear-gradient(135deg, $_qd-urgent-bg-light, $plant-card-bg);` + `border: 1px solid $_qd-urgent-border;`
|
||||||
|
- When zero items (all-clear): swap to green tokens.
|
||||||
|
- Grid: `display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;` collapsing to 1fr below 900px.
|
||||||
|
|
||||||
|
**Section styling**:
|
||||||
|
- Card pattern with header strip + body, gradients matching the existing plant kanban polish (Rule 9, no hardcoded hex outside the dark-branch defs).
|
||||||
|
- Section header bg uses `$_qd-section-head-bg-light` (amber gradient).
|
||||||
|
- Row hover: subtle `background: rgba(0,0,0,0.03)` lift.
|
||||||
|
|
||||||
|
## File inventory
|
||||||
|
|
||||||
|
### Modify
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_quality/controllers/fp_quality_dashboard.py` | **Rewrite.** Delete `counts()` route. Add `snapshot()` route + `FpQualityDashboardSnapshot` helper class. Same file, expanded. |
|
||||||
|
| `fusion_plating_quality/static/src/js/fp_quality_dashboard.js` | **Rewrite.** Drop `TABS` array, `selectTab`, `openTab`. New shape: `setup` fetches snapshot, `onOpenItem` / `onOpenSection` actions. Add BannerCard + BannerItem + SectionCard + SectionRow sub-components in same file. Keep the `?tab=` param parsing but translate to scrollIntoView. |
|
||||||
|
| `fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml` | **Rewrite.** New template structure: outer wrapper + banner card + 6 section cards via `t-foreach="snapshot.sections"`. No tab row. |
|
||||||
|
| `fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss` | **Rewrite.** New token block, banner styles, section styles, row styles, mobile breakpoint. Reuse `$plant-*` base tokens. |
|
||||||
|
| `fusion_plating_quality/__manifest__.py` | Version bump `19.0.7.0.0` → `19.0.8.0.0`. |
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_quality/tests/test_dashboard_snapshot.py` | NEW — unit tests for the snapshot endpoint (algorithms, edge cases, missing-module guards) |
|
||||||
|
| `fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py` | NEW — entech smoke script (RPC call, response-shape assertions, click-through smoke) |
|
||||||
|
|
||||||
|
### Untouched
|
||||||
|
|
||||||
|
- All per-model kanban views — `fusion.plating.quality.hold`, `fusion.plating.quality.check`, `fusion.plating.ncr`, `fusion.plating.capa`, `fusion.plating.rma`, `fp.certificate`
|
||||||
|
- All per-model form views + their action_* methods (Issue, Disposition, etc.)
|
||||||
|
- The cert authority ACL guard (Rule 24 / spec 2026-05-25-post-shop) — fires unchanged from the cert form
|
||||||
|
- The `fp.notification.template` + `mail.activity` infrastructure
|
||||||
|
- The existing menu entry — same `ir.actions.client` xmlid, same menu entry, just different template/JS/CSS/controller behind it
|
||||||
|
|
||||||
|
## Edge cases + defensive design
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Zero banner items | Banner renders green `✓ All caught up — no critical items right now` card. Sections still render below. |
|
||||||
|
| > 6 banner-eligible items | Top 6 by urgency rank shown; footer line `Showing 6 of N urgent items — see sections below` |
|
||||||
|
| Section with zero open items | Renders the card with one italic row `No open items` — predictable layout, no hidden states |
|
||||||
|
| Item appears in BOTH banner and section | Intentional — banner is across-types urgency, section is per-type queue. Visual reinforcement. |
|
||||||
|
| `fp.certificate` (or any type's model) not installed | `_build_section('cert')` returns None; section omitted from response. Banner skips cert candidates. |
|
||||||
|
| Cross-module field missing (e.g. `partner.x_fc_rush` not defined) | `getattr(partner, 'x_fc_rush', False)` falls back to False — item only qualifies via overdue path |
|
||||||
|
| Cert created in the second BEFORE snapshot fires | Negligible — 60s poll catches it next refresh. No real-time correctness requirement. |
|
||||||
|
| User lacks ACL on a record in their snapshot | `Open →` opens the record via `act_window`; Odoo's standard ACL fires at navigation; user gets the standard access error. Not pre-filtered in the snapshot (would 4x query cost). |
|
||||||
|
| Snapshot RPC fails (network blip, DB lock) | Frontend shows `Couldn't refresh dashboard — retry in 60s` banner. Last-known snapshot stays on screen. Same pattern as existing `_refreshCounts`. |
|
||||||
|
| Mobile / tablet < 900px | Banner grid collapses 3-col → 1-col. Sections stay full-width. Row buttons keep ≥32px tap target. |
|
||||||
|
| Banner item's source section is below the fold | No special handling — banner is its own surface. Click navigates via `act_window` regardless of section visibility. |
|
||||||
|
| Dark mode toggle mid-session | Browser reload required (Odoo standard behavior). Tokens flip automatically via SCSS compile-time branch. |
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Unit tests — `fusion_plating_quality/tests/test_dashboard_snapshot.py` (NEW)
|
||||||
|
|
||||||
|
| Test | Asserts |
|
||||||
|
|---|---|
|
||||||
|
| `test_empty_db_returns_all_clear` | Empty DB → `banner.all_clear == True`, all 6 sections present with `open == 0` |
|
||||||
|
| `test_overdue_cert_in_banner` | Cert created 25h ago → `banner.items[0].type == 'cert'`, `urgency == 'overdue'` |
|
||||||
|
| `test_vip_cert_in_banner_before_overdue` | Cert created 1h ago, customer.x_fc_vip=True → in banner with `urgency == 'critical_customer'` and `critical_badge == 'VIP'` |
|
||||||
|
| `test_rush_partner_banner_badge` | partner.x_fc_rush=True → `critical_badge == 'RUSH'` |
|
||||||
|
| `test_aerospace_part_banner_badge` | part.name='Aerospace Bracket' → `critical_badge == 'AS9100'` (or similar) |
|
||||||
|
| `test_banner_caps_at_6_with_overflow_count` | 8 overdue items → `banner.items` length 6, `total_matching == 8` |
|
||||||
|
| `test_banner_ranks_overdue_before_critical_customer` | Mix of 3 overdue + 5 VIP-non-overdue → first 3 are overdue, next 3 are VIP |
|
||||||
|
| `test_section_order_is_canonical` | Response `sections` list ordered: cert, hold, ncr, rma, capa, check |
|
||||||
|
| `test_section_top_5_only` | 8 items of one type → section.items length is 5; section.open == 8 |
|
||||||
|
| `test_missing_certificate_model_omits_section` | Mock `fp.certificate` not in env → no cert section, no traceback |
|
||||||
|
| `test_missing_partner_field_falls_through` | Partner without `x_fc_rush` field → item evaluated via overdue path only, no AttributeError |
|
||||||
|
| `test_snapshot_includes_computed_at_iso` | Response has `computed_at` as parseable ISO timestamp |
|
||||||
|
|
||||||
|
### Entech smoke script — `fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py` (NEW)
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Hit `/fp/quality/dashboard/snapshot` via odoo-shell-style RPC
|
||||||
|
2. Assert response shape (banner + sections present, all keys present)
|
||||||
|
3. Assert section order is canonical
|
||||||
|
4. Assert each section has `open_kanban_xmlid` that resolves to a real `ir.actions.act_window`
|
||||||
|
5. Pick first banner item → build the equivalent `act_window` from `open_action` → verify it resolves
|
||||||
|
6. Print summary: open/overdue per section + banner item count
|
||||||
|
|
||||||
|
### Manual QA on entech
|
||||||
|
|
||||||
|
1. `/odoo/action-fp_quality_dashboard` as admin — verify banner + sections render in dark mode
|
||||||
|
2. As a Sales Rep (lower ACL) — verify items render; click an item → expect ACL error if blocked
|
||||||
|
3. Issue a draft cert via the cert form → reload dashboard → cert disappears from Certificates section
|
||||||
|
4. Flag a partner `x_fc_rush=True` → reload → their open items get `[RUSH]` badge in banner
|
||||||
|
5. Dark mode toggle in user prefs → reload → confirm gradient + green/red flips correctly
|
||||||
|
6. Resize browser to < 900px → confirm banner collapses to 1 column, sections stay readable
|
||||||
|
|
||||||
|
## Migration / rollback
|
||||||
|
|
||||||
|
- **No DB migration** — purely a UI replacement. No schema changes.
|
||||||
|
- **No data backfill** — the snapshot is computed at request time from existing data.
|
||||||
|
- **Rollback**: `git revert` the implementation commits, `-u fusion_plating_quality`, asset cache bust. Affected users see the old tab-router until the revert deploys.
|
||||||
|
|
||||||
|
## Files touched summary
|
||||||
|
|
||||||
|
```
|
||||||
|
fusion_plating_quality/
|
||||||
|
├── controllers/
|
||||||
|
│ └── fp_quality_dashboard.py MODIFY (rewrite endpoint + helper class)
|
||||||
|
├── static/src/
|
||||||
|
│ ├── js/fp_quality_dashboard.js MODIFY (rewrite component + sub-components)
|
||||||
|
│ ├── xml/fp_quality_dashboard.xml MODIFY (rewrite template)
|
||||||
|
│ └── scss/fp_quality_dashboard.scss MODIFY (rewrite styles)
|
||||||
|
├── tests/
|
||||||
|
│ └── test_dashboard_snapshot.py CREATE (unit tests)
|
||||||
|
├── scripts/
|
||||||
|
│ └── bt_quality_dashboard_redesign.py CREATE (entech smoke)
|
||||||
|
└── __manifest__.py MODIFY (version 19.0.7.0.0 → 19.0.8.0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
5 files modified, 2 created.
|
||||||
|
|
||||||
|
## Open questions for implementation phase
|
||||||
|
|
||||||
|
1. **`subtitle` text** per type — what's the second line on each row? For certs `"14h overdue · awaiting issuance"` is obvious; for NCRs `"7d · disposition pending"` works; CAPAs `"due 5/30"`; RMAs `"received 2d ago"`. Settle the exact format during implementation. Not a design decision.
|
||||||
|
2. **Icon choice** — emojis (🏷️ 🛑 🔬 ↩️ 📋 ✓) vs Font Awesome (`fa-certificate`, `fa-stop-circle`, etc.). Plant kanban uses emojis; consistency argues for emojis. Trivially swappable later.
|
||||||
|
3. **Per-section search/sort** within the dashboard — out of scope for v1 (the per-model kanban has these via the standard search bar). Revisit if QM asks.
|
||||||
|
4. **Polling pause when tab is hidden** — `document.visibilityState` check could pause the poll when the user is on another tab. Nice-to-have, not in v1.
|
||||||
|
|
||||||
|
## Status & deployment notes
|
||||||
|
|
||||||
|
Target version bump: `fusion_plating_quality` 19.0.7.0.0 → **19.0.8.0.0**.
|
||||||
|
|
||||||
|
Deploy steps (mirrors the post-shop redesign flow):
|
||||||
|
1. Sync the 5 modified files + 2 new files to entech `/mnt/extra-addons/custom/fusion_plating_quality/`
|
||||||
|
2. `-u fusion_plating_quality` (no other modules — this is self-contained)
|
||||||
|
3. Asset cache bust: `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';`
|
||||||
|
4. Restart odoo
|
||||||
|
5. Run the smoke script via odoo-shell
|
||||||
|
6. Manual browser verification at `/odoo/action-fp_quality_dashboard`
|
||||||
|
|
||||||
|
No coordination with other modules required — the dashboard's only callers are humans clicking the menu entry.
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
# Tablet PIN Self-Service (Create + Reset via Email)
|
||||||
|
|
||||||
|
**Date:** 2026-05-25
|
||||||
|
**Status:** Approved for implementation (brainstorming gate)
|
||||||
|
**Author:** Brainstorming session (gsinghpal)
|
||||||
|
**Triggering incident:** The Shop Floor Terminal at https://enplating.com/odoo/action-fp_shopfloor_landing shows every shop-branch user as "PIN required" — only Garry Singh has one set on entech (admin set it manually). The existing PIN setup flow (`FpPinSetup` OWL via `res.users.action_open_tablet_pin_setup`) requires the user to already be logged in via the Preferences form — but a user with no PIN cannot get there. They're stuck on the lock screen with no recovery path. Likewise, a user who forgot their PIN has no self-service way back in — they have to find a Manager to call `clear_tablet_pin()` for them.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add two self-service flows accessible **directly from the tablet lock screen**, so users can manage their own PIN without manager intervention:
|
||||||
|
|
||||||
|
1. **Create flow** — for users with no PIN yet. Tapping their tile shows a "Send temporary PIN to my email" button instead of demanding a PIN they don't have.
|
||||||
|
2. **Reset flow** — for users who forgot their PIN. After 3 failed PIN attempts on the keypad, a "Forgot? Reset PIN via email" button appears.
|
||||||
|
|
||||||
|
Both flows merge at the same point: server emails a temporary 4-digit code → user enters code → user sets a new permanent PIN → auto-login.
|
||||||
|
|
||||||
|
## Out of scope (deferred to follow-on work)
|
||||||
|
|
||||||
|
- **SMS as a second factor.** Email only in v1. Some shop-floor users may not have personal email but DO have a phone number — SMS could be a future enhancement once Twilio/equivalent integration is in place.
|
||||||
|
- **Security questions as fallback.** No "What's your mother's maiden name?" — adds question-management overhead for marginal benefit.
|
||||||
|
- **Magic-link login** (click a link in the email to bypass the lock screen entirely). 4-digit temp PIN is simpler + matches existing keypad UI.
|
||||||
|
- **Manager-approval reset flow** as an alternative to email. Manager can still use the existing `clear_tablet_pin()` from the user form — out-of-band reset stays available.
|
||||||
|
- **Tablet-side email preview** ("we sent your code to g***@nexasystems.ca, switch device to read it"). Mention the masked email in the response but don't render an inline email preview component.
|
||||||
|
- **Personal phone number as alternative recipient.** Email pulled from `res.users.login` (or `partner_id.email`) — no new field.
|
||||||
|
|
||||||
|
## Decisions reached during brainstorming
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | **Create-flow trigger:** tile of a PIN-less user shows a "Send temporary PIN" button as the primary action instead of the PIN pad | A user with no PIN never has to encounter a useless PIN pad. Less confusion, no error states. |
|
||||||
|
| D2 | **Reset-flow trigger:** after 3 failed PIN attempts in the SAME tablet-lock session, a "Forgot? Reset PIN via email" button appears below the keypad | Doesn't expose the reset path to passing eyes (the button only appears when someone has actively tried + failed). 3 fails is below the existing 5-fail server-side lockout, so the reset path is reachable before lockout. |
|
||||||
|
| D3 | **Temp code format:** 4-digit numeric, same as a permanent PIN | Reuses the existing FpPinPad component without modification. Single mental model for the operator. User explicitly chose 4-digit over 6. |
|
||||||
|
| D4 | **Temp code expiry:** 72 hours from generation | User explicitly chose 72h. Forgiving for shift workers, PTO, weekend gaps. Operator can request Friday 5pm and use Monday 8am without the code dying. |
|
||||||
|
| D5 | **Per-code attempt cap:** 5 wrong attempts invalidates the code | Limits brute-force window in the 72h validity period. 10,000 / 5 = 2,000 codes per attacker per user before they exhaust legitimate codes. Combined with rate limit (D6), effectively un-brute-forceable via the front door. |
|
||||||
|
| D6 | **Rate limit on `request_reset_code`:** max 3 requests per user per rolling 60 minutes | Prevents spam-the-email-then-spam-the-pad attacks. Fourth request shows "Wait XX minutes before requesting another code." |
|
||||||
|
| D7 | **One-time use:** code invalidated on first successful verify AND replaced if user requests a new one before consuming | A user who clicks "Send code" twice in a row gets a fresh code; old one is dead. |
|
||||||
|
| D8 | **No-email-on-file handling:** tile selection still works, but the "Send temporary PIN" button is REPLACED with a "Contact your manager — no email on file" message naming the company's owner | Graceful degradation. Manager still has `clear_tablet_pin()` from the user form as an out-of-band reset. |
|
||||||
|
| D9 | **Email delivery:** new `mail.template` + new `fp.notification.template` trigger event `tablet_pin_reset_requested`, dispatched via existing `_dispatch()` machinery | Mirrors the cert-authority pattern shipped earlier. Admin can edit the template body in the UI (Plating → Configuration → Quality & Documents → Notification Templates) without touching code. |
|
||||||
|
| D10 | **Recipient:** the user's own `login` email (Odoo standard — `res.users.login` is the email login) | Direct, no extra field, already populated for every active user. Falls back to `partner_id.email` if `login` doesn't look like an email. |
|
||||||
|
| D11 | **Email subject:** `🔒 Your ENTECH tablet temporary PIN: 1234` | 4-digit code visible in the mobile-notification glance. Operator can read the PIN from their phone's lock screen without opening the email. |
|
||||||
|
| D12 | **Audit:** every `request_reset_code`, `verify_reset_code`, and post-verify `set_pin_after_reset` written to the existing `fp.tablet.session.event` table with new `event_type` values | One audit table, one query for compliance. Captures IP, kiosk sid, user-agent, acting/target uid. |
|
||||||
|
| D13 | **Failed-attempts counter coordination:** client-side counter (in the OWL component state) resets on page reload + shows the reset button at 3 fails. Server-side `x_fc_tablet_pin_failed_count` keeps incrementing per existing logic up to the 5-fail lockout | The reset button is for THIS session ("I just tried 3 times and got it wrong"). The server lockout is for cross-session brute-force protection. Independent concerns, both kept. |
|
||||||
|
| D14 | **3-fail client counter resets** when the user successfully enters a PIN OR navigates back to the tile selection screen | Predictable: a fresh start on the tile screen = fresh counter. |
|
||||||
|
| D15 | **Temp code storage:** new dedicated model `fp.tablet.pin.reset` (one active row per user, hashed code via same PBKDF2 helper) | Hashed-at-rest. SQL unique partial index enforces "one active code per user". Easy to query for expiry-cleanup cron. |
|
||||||
|
| D16 | **`set_pin` endpoint accepts a `reset_token` alternative to `old_pin`** | After verifying the temp code, the server hands back a short-lived (5 min) signed token that proves the verify happened. The final "Set new PIN" call passes that token to set_pin instead of an old_pin. No state on the client; no race conditions. |
|
||||||
|
| D17 | **After successful PIN set, auto-login via the same path as normal unlock** | User went through email verification + chose a PIN — they've earned the session. No "PIN set! Please tap your tile and log in." extra step. |
|
||||||
|
| D18 | **Reset-flow returns the user to tile selection if they cancel mid-flow** | Cancel button on every wizard step. Half-completed flows abandon cleanly; the temp code stays valid for 72h in case they come back. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ TABLET LOCK SCREEN (existing) ───────────────────────────────────┐
|
||||||
|
│ [Tile Amad] [Tile Andrew] [Tile Bernice] ... │
|
||||||
|
│ │
|
||||||
|
│ Tap a tile → │
|
||||||
|
│ ├─ User has PIN set → PIN-entry screen (existing flow) │
|
||||||
|
│ └─ User has no PIN → "Send temp PIN" screen (NEW) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ PIN-ENTRY SCREEN (existing, EXTENDED) ───────────────────────────┐
|
||||||
|
│ [Tile + name + colored avatar] │
|
||||||
|
│ [4-cell PIN pad] │
|
||||||
|
│ │
|
||||||
|
│ Wrong PIN entered: │
|
||||||
|
│ ├─ fails < 3 in this session → red error, keypad clears │
|
||||||
|
│ └─ fails ≥ 3 in this session → "Forgot? Reset PIN via email" │
|
||||||
|
│ button appears below keypad │
|
||||||
|
│ │
|
||||||
|
│ Tap "Forgot?" → joins the temp-code email flow (below) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ SEND TEMP CODE SCREEN (NEW) ─────────────────────────────────────┐
|
||||||
|
│ [Tile + name] │
|
||||||
|
│ │
|
||||||
|
│ "We'll email a temporary PIN to your address on file." │
|
||||||
|
│ Email shown masked: g***@nexasystems.ca │
|
||||||
|
│ │
|
||||||
|
│ [Send temporary PIN] ← primary button │
|
||||||
|
│ [Back to tile selection] │
|
||||||
|
│ │
|
||||||
|
│ No email on file edge case: │
|
||||||
|
│ "No email on file. Contact your manager: <Owner Name>" │
|
||||||
|
│ [Back] │
|
||||||
|
│ │
|
||||||
|
│ Rate-limited edge case: │
|
||||||
|
│ "Too many requests. Try again in 47 minutes." │
|
||||||
|
│ [Back] │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ ENTER TEMP CODE SCREEN (NEW) ────────────────────────────────────┐
|
||||||
|
│ [Tile + name] │
|
||||||
|
│ │
|
||||||
|
│ "Check your email for the temporary PIN." │
|
||||||
|
│ "Code expires in 72 hours." │
|
||||||
|
│ │
|
||||||
|
│ [4-cell pad — same component as regular PIN entry] │
|
||||||
|
│ │
|
||||||
|
│ [Resend code] (subject to rate limit) │
|
||||||
|
│ [Cancel — back to tile selection] │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ SET NEW PIN SCREEN (REUSE FpPinSetup) ───────────────────────────┐
|
||||||
|
│ [Tile + name] │
|
||||||
|
│ │
|
||||||
|
│ Stage 1: "Choose your new PIN" [4-cell pad] │
|
||||||
|
│ Stage 2: "Confirm your PIN" [4-cell pad] │
|
||||||
|
│ │
|
||||||
|
│ On match → server sets hash → auto-login (same path as │
|
||||||
|
│ /fp/tablet/unlock_session) → redirect to landing. │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
### New model: `fp.tablet.pin.reset`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FpTabletPinReset(models.Model):
|
||||||
|
_name = 'fp.tablet.pin.reset'
|
||||||
|
_description = 'Tablet PIN Email-Reset Code'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users', required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
code_hash = fields.Char(
|
||||||
|
required=True,
|
||||||
|
groups='fusion_plating.group_fusion_plating_manager',
|
||||||
|
help='PBKDF2-SHA256 hash of the 4-digit temp code. Never plaintext.',
|
||||||
|
)
|
||||||
|
expires_at = fields.Datetime(required=True)
|
||||||
|
used_at = fields.Datetime(
|
||||||
|
help='Set when the code is successfully verified. After this, '
|
||||||
|
'the row is considered consumed; a new code must be requested.',
|
||||||
|
)
|
||||||
|
attempt_count = fields.Integer(
|
||||||
|
default=0,
|
||||||
|
help='Per-code wrong-guess counter. 5 wrong attempts invalidate '
|
||||||
|
'the code regardless of expires_at (D5).',
|
||||||
|
)
|
||||||
|
requester_ip = fields.Char(help='IP that requested the code (audit).')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
# At most ONE active (used_at IS NULL) row per user. Forces the
|
||||||
|
# "request new = invalidate old" behavior (D7).
|
||||||
|
('one_active_per_user',
|
||||||
|
"EXCLUDE (user_id WITH =) WHERE (used_at IS NULL)",
|
||||||
|
'A user may have at most one outstanding tablet PIN reset code.'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing fields used (no changes)
|
||||||
|
|
||||||
|
- `res.users.x_fc_tablet_pin_hash` — written by the post-verify set-new-PIN call
|
||||||
|
- `res.users.x_fc_tablet_pin_set_date` — refreshed on set
|
||||||
|
- `res.users.x_fc_tablet_pin_failed_count` — server-side counter (separate from client-side 3-fail counter per D13)
|
||||||
|
- `res.users.x_fc_tablet_locked_until` — existing 5-fail lockout (untouched)
|
||||||
|
- `fp.tablet.session.event` — audit log (existing table); new `event_type` values: `pin_reset_requested`, `pin_reset_code_verified`, `pin_set_after_reset`
|
||||||
|
|
||||||
|
### Existing helpers reused
|
||||||
|
|
||||||
|
- `ResUsers._hash_tablet_pin(pin, salt=None)` — same algorithm for code_hash
|
||||||
|
- `ResUsers._verify_tablet_pin_hash(pin, stored)` — same constant-time comparison
|
||||||
|
- `ResUsers.set_tablet_pin(pin)` — writes new hash + clears lockout (called after verify)
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `POST /fp/tablet/request_reset_code`
|
||||||
|
|
||||||
|
**Request:** `{login: str}`
|
||||||
|
**Response:** `{ok: bool, masked_email: str | None, error: str | None}`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Lookup user by `login`, sudo (kiosk session is the kiosk user, not the target user).
|
||||||
|
2. Verify user is active + holds a shop-branch group (same check as `_check_credentials`).
|
||||||
|
3. Resolve recipient email: `user.login` if it looks like email, else `user.partner_id.email`. If neither → return `{ok: False, error: 'no_email', masked_email: None, manager_name: '<owner>'}`.
|
||||||
|
4. Rate-limit check: count `fp.tablet.pin.reset` rows for this user where `create_date > now - 60min`. If ≥ 3 → return `{ok: False, error: 'rate_limited', cooldown_minutes: <minutes until oldest of the 3 ages out>}`.
|
||||||
|
5. Generate 4-digit code with `secrets.randbelow(10000)` zero-padded.
|
||||||
|
6. Hash the code; write `fp.tablet.pin.reset` row with `expires_at = now + 72h`. SQL constraint replaces any existing active row.
|
||||||
|
7. Dispatch email via `fp.notification.template._dispatch('tablet_pin_reset_requested', user, partner=user.partner_id, extra_context={'code': '1234'})`.
|
||||||
|
8. Write `fp.tablet.session.event` row with `event_type='pin_reset_requested'`, IP, sid, target uid.
|
||||||
|
9. Return `{ok: True, masked_email: 'g***@nexasystems.ca'}`.
|
||||||
|
|
||||||
|
### `POST /fp/tablet/verify_reset_code`
|
||||||
|
|
||||||
|
**Request:** `{login: str, code: str}`
|
||||||
|
**Response:** `{ok: bool, reset_token: str | None, error: str | None}`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Lookup user by login, sudo.
|
||||||
|
2. Find active reset row for user: `domain = [('user_id', '=', uid), ('used_at', '=', False)]`, latest.
|
||||||
|
3. No active code → `{ok: False, error: 'no_active_code'}`.
|
||||||
|
4. Expired (`expires_at < now`) → `{ok: False, error: 'expired'}`.
|
||||||
|
5. Attempt-cap (`attempt_count >= 5`) → invalidate (set used_at=now), `{ok: False, error: 'too_many_attempts'}`.
|
||||||
|
6. Increment `attempt_count` regardless of result (so wrong codes count against the cap).
|
||||||
|
7. `_verify_tablet_pin_hash(code, row.code_hash)` → if wrong, return `{ok: False, error: 'wrong_code', attempts_left: 5 - attempt_count}`.
|
||||||
|
8. Mark `used_at = now`.
|
||||||
|
9. Generate `reset_token`: signed JWT-like string with payload `{user_id, exp: now+5min, purpose: 'tablet_pin_reset'}`. Signed with `ir.config_parameter['database.secret']`.
|
||||||
|
10. Audit: `fp.tablet.session.event` row with `event_type='pin_reset_code_verified'`.
|
||||||
|
11. Return `{ok: True, reset_token: '<token>'}`.
|
||||||
|
|
||||||
|
### `POST /fp/tablet/set_pin` (EXTEND existing)
|
||||||
|
|
||||||
|
Currently accepts `{new_pin, old_pin?}`. Extend to accept `reset_token` as a third alternative:
|
||||||
|
|
||||||
|
**Request (extended):** `{new_pin: str, old_pin: str?, reset_token: str?}`
|
||||||
|
|
||||||
|
**New branch:**
|
||||||
|
1. If `reset_token` provided AND `old_pin` not provided:
|
||||||
|
- Verify token signature + expiry + purpose claim.
|
||||||
|
- Resolve `user_id` from token.
|
||||||
|
- Call `user.set_tablet_pin(new_pin)` (sudo — verified user via token).
|
||||||
|
- Audit: `event_type='pin_set_after_reset'`.
|
||||||
|
- Return `{ok: True}`.
|
||||||
|
2. Existing branches (`old_pin` check, no-pin-yet-and-no-token-and-no-old-pin reject) untouched.
|
||||||
|
|
||||||
|
### Existing endpoint — `/fp/tablet/unlock_session`
|
||||||
|
|
||||||
|
After `set_pin` succeeds in the reset flow, the client immediately calls `/fp/tablet/unlock_session` with `{login, pin: new_pin}` to mint the actual Odoo session. **No endpoint change.** Auto-login is a client-side chain.
|
||||||
|
|
||||||
|
## Frontend — OWL component changes
|
||||||
|
|
||||||
|
### `FpTabletLock` (existing) state machine
|
||||||
|
|
||||||
|
Add four new states to the existing tile / pin-entry state machine:
|
||||||
|
|
||||||
|
| State | Triggered by | Renders |
|
||||||
|
|---|---|---|
|
||||||
|
| `request_code` | Tap tile of a no-PIN user, OR tap "Forgot?" button at 3 fails | Send-temp-code screen |
|
||||||
|
| `enter_temp_code` | After successful `request_reset_code` | 4-cell pad for the temp code |
|
||||||
|
| `set_new_pin` | After successful `verify_reset_code` | 4-cell pad — "Choose your new PIN" |
|
||||||
|
| `confirm_new_pin` | After first new PIN entered | 4-cell pad — "Confirm your PIN" |
|
||||||
|
|
||||||
|
Three new state fields:
|
||||||
|
```javascript
|
||||||
|
this.state = useState({
|
||||||
|
// ... existing
|
||||||
|
failedAttempts: 0, // resets on successful PIN OR back-to-tiles
|
||||||
|
pendingResetToken: null, // from verify_reset_code
|
||||||
|
pendingNewPin: null, // from set_new_pin step
|
||||||
|
cooldownMinutes: 0, // from rate-limit error
|
||||||
|
maskedEmail: '',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Two new event handlers:
|
||||||
|
```javascript
|
||||||
|
async onForgotPinClick() {
|
||||||
|
// Navigate to request_code state with current selected user
|
||||||
|
this.state.mode = 'request_code';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPinFail() {
|
||||||
|
this.state.failedAttempts += 1;
|
||||||
|
if (this.state.failedAttempts >= 3) {
|
||||||
|
this.state.showForgotButton = true;
|
||||||
|
}
|
||||||
|
// ... existing fail handling
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `FpPinPad` (existing) — minor mode prop
|
||||||
|
|
||||||
|
Add a `mode` prop (`'pin' | 'temp_code' | 'new_pin' | 'confirm_pin'`) that drives:
|
||||||
|
- Label above the pad ("Enter your PIN" / "Temporary PIN from email" / "Choose your new PIN" / "Confirm new PIN")
|
||||||
|
- Different submit handler the parent injects via callback
|
||||||
|
|
||||||
|
No structural change — same 4-cell pad, same digit buttons.
|
||||||
|
|
||||||
|
## Email template
|
||||||
|
|
||||||
|
**File:** `data/fp_tablet_pin_reset_template.xml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<record id="fp_mail_template_tablet_pin_reset" model="mail.template">
|
||||||
|
<field name="name">FP: Tablet PIN Reset Code</field>
|
||||||
|
<field name="model_id" ref="base.model_res_users"/>
|
||||||
|
<field name="subject">🔒 Your ENTECH tablet temporary PIN: {{ ctx.code }}</field>
|
||||||
|
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||||
|
<field name="email_to">{{ object.email or object.login }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||||
|
<div style="height: 4px; background-color: #1d4ed8; margin-bottom: 28px;"></div>
|
||||||
|
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #1d4ed8; font-weight: 600; margin-bottom: 8px;">
|
||||||
|
Electroless Nickel Technologies Inc. (ENTECH)
|
||||||
|
</div>
|
||||||
|
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your tablet temporary PIN</h2>
|
||||||
|
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
|
||||||
|
Hi <t t-out="object.name"/>, use this 4-digit PIN to unlock the
|
||||||
|
shop-floor tablet and set a new permanent PIN.
|
||||||
|
</p>
|
||||||
|
<div style="text-align: center; margin: 32px 0; padding: 24px; background: #f3f4f6; border-radius: 8px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 48px; font-weight: 700; letter-spacing: 0.3em; color: #1d4ed8;">
|
||||||
|
<t t-out="ctx.code"/>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 16px 0; font-size: 13px; opacity: 0.65;">
|
||||||
|
This code expires in 72 hours. If you didn't request it, ignore
|
||||||
|
this email — no action needed. The previous PIN (if any) stays
|
||||||
|
valid until you successfully complete the reset on the tablet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a `fp.notification.template` row pointing at the mail.template:
|
||||||
|
```xml
|
||||||
|
<record id="fp_notif_tablet_pin_reset" model="fp.notification.template">
|
||||||
|
<field name="name">Tablet PIN Reset Code</field>
|
||||||
|
<field name="trigger_event">tablet_pin_reset_requested</field>
|
||||||
|
<field name="mail_template_id" ref="fp_mail_template_tablet_pin_reset"/>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
```
|
||||||
|
|
||||||
|
Per CLAUDE.md Rule 25, the mail template references ONLY core `res.users` fields (`object.name`, `object.email`, `object.login`, `object.company_id`). The `ctx.code` is the dispatched extra_context, passed by the controller — not a model field. Safe at parse-time.
|
||||||
|
|
||||||
|
## File inventory
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_shopfloor/models/fp_tablet_pin_reset.py` | The new model + helpers (generate, verify, cleanup-expired cron entrypoint) |
|
||||||
|
| `fusion_plating_shopfloor/security/ir.model.access.csv` (extend) | ACL rows for `fp.tablet.pin.reset` — manager-only read, no user read |
|
||||||
|
| `fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml` | mail.template + fp.notification.template + cron for cleanup |
|
||||||
|
| `fusion_plating_shopfloor/tests/test_pin_reset_flow.py` | TransactionCase covering: rate-limit, expiry, wrong-code attempt cap, one-active-per-user constraint, set_pin via reset_token |
|
||||||
|
| `fusion_plating_shopfloor/scripts/bt_pin_reset.py` | Entech smoke — full lifecycle via odoo-shell |
|
||||||
|
|
||||||
|
### Modify
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_shopfloor/controllers/tablet_controller.py` | Add `request_reset_code` + `verify_reset_code` endpoints; extend `set_pin` to accept `reset_token` |
|
||||||
|
| `fusion_plating_shopfloor/static/src/js/components/tablet_lock.js` | New states + handlers (`onForgotPinClick`, `onPinFail` counter, `onSendCodeClick`, `onCodeSubmit`, `onNewPinSubmit`, `onConfirmNewPinSubmit`) |
|
||||||
|
| `fusion_plating_shopfloor/static/src/js/components/pin_pad.js` | New `mode` prop drives label text |
|
||||||
|
| `fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` | New screens (request_code, enter_temp_code, set_new_pin, confirm_new_pin) |
|
||||||
|
| `fusion_plating_shopfloor/static/src/scss/tablet_lock.scss` | Styles for new screens (existing tokens reused) |
|
||||||
|
| `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | Add 3 new selection values to `event_type` (existing values: `unlock`, `failed_unlock`, `manual_lock`, `idle_lock`, `ceiling_lock`, `force_lock`, `admin_reset`). New values: `pin_reset_requested`, `pin_reset_code_verified`, `pin_set_after_reset`. |
|
||||||
|
| `fusion_plating_shopfloor/__init__.py` | Register the new model file |
|
||||||
|
| `fusion_plating_shopfloor/__manifest__.py` | Version bump + new data files |
|
||||||
|
|
||||||
|
### Untouched
|
||||||
|
|
||||||
|
- `res.users` model (existing PIN hash fields + helpers cover everything)
|
||||||
|
- `FpPinSetup` component (Preferences-form-launched setup is a separate code path)
|
||||||
|
- The existing `set_pin` endpoint's old-PIN-verify branch (preserved)
|
||||||
|
- The existing 5-fail server lockout
|
||||||
|
|
||||||
|
## Edge cases + defensive design
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| User taps Send-code, then taps it again before email arrives | Second call invalidates the first row (SQL constraint) + sends a new email. Old code in inbox no longer works. Counts against rate limit. |
|
||||||
|
| User enters wrong temp code 5 times | Code invalidated. Screen shows "Code expired due to too many wrong attempts. Request a new one." Counts toward rate limit. |
|
||||||
|
| User requests 3 codes in 30 min, then hits the limit | 4th request returns `{ok: False, cooldown_minutes: 30}`. Screen shows "Wait 30 min before requesting another code." Existing active code (if any) stays valid for use within its 72h window. |
|
||||||
|
| User on PTO requests code Friday, comes back Monday | 72h covers Friday-noon to Monday-noon. Tuesday return = expired, request new one. |
|
||||||
|
| User has no email anywhere (`login` not email-shaped + no `partner_id.email`) | Tile shows "Contact your manager — no email on file. (Manager: <Owner.name>)" pulled from `res.company.x_fc_owner_user_id`. Manager uses existing user-form `clear_tablet_pin()` for the out-of-band reset. |
|
||||||
|
| Tablet network blip mid-set-PIN | `reset_token` has 5-min expiry, so the user can retry within that window without re-doing email. After 5 min, they need a new code (existing 72h code still valid + still has remaining attempts). |
|
||||||
|
| User completes reset on tablet, then their lockout (`x_fc_tablet_locked_until`) was active | `set_tablet_pin()` already clears `x_fc_tablet_locked_until` AND `x_fc_tablet_pin_failed_count`. The reset path inherits that — successful reset unlocks the user too. |
|
||||||
|
| Manager runs `clear_tablet_pin()` while user has an outstanding code | Cleanup cron will eventually remove expired codes; no immediate conflict. Manager's clear doesn't invalidate the email code, so user could still complete the reset via their email. Acceptable. |
|
||||||
|
| Attacker steals tablet, taps a tile, gets to send-code screen, requests code | Code goes to the LEGITIMATE user's email — attacker doesn't have it. They could try to brute force (5 attempts before invalidation, 10k combinations). After 5 wrong → code dead + rate-limit consumed. Effectively unbreakable via the front door. |
|
||||||
|
| User opens email on their phone, sees "g***@nexasystems.ca" doesn't match their actual address | Means the masked-email display has a bug OR the wrong user was selected. They can cancel + start over. Adds visible confirmation that the right account is being reset. |
|
||||||
|
| Two operators try to reset the same user (admin error) | SQL unique-active constraint allows only one row; second `request` call replaces the first. Both see the masked email; whoever has access to the inbox wins. |
|
||||||
|
| Reset token leaked via browser history | Token is short-lived (5 min), single-use (consumed by `set_pin`), and signed with the database secret. Even if intercepted, can only set ONE new PIN within 5 min, and the user notices their PIN change. |
|
||||||
|
|
||||||
|
## Cleanup cron
|
||||||
|
|
||||||
|
Daily `ir.cron` (`_cron_purge_expired_pin_resets`) deletes rows where `expires_at < now - 7d` OR `used_at < now - 7d`. Keeps the table tidy without losing audit-window data (the audit trail is in `fp.tablet.session.event`, not in the reset rows themselves).
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Unit tests — `fusion_plating_shopfloor/tests/test_pin_reset_flow.py` (NEW)
|
||||||
|
|
||||||
|
| Test | Asserts |
|
||||||
|
|---|---|
|
||||||
|
| `test_request_creates_active_row` | After `request_reset_code`, exactly one row with `used_at=False`, `expires_at` 72h out |
|
||||||
|
| `test_request_replaces_prior_active` | After two `request` calls, exactly one active row (newer replaces older) |
|
||||||
|
| `test_rate_limit_kicks_in_at_4th_request` | 3 requests succeed, 4th returns `{ok: False, error: 'rate_limited'}` |
|
||||||
|
| `test_verify_with_correct_code_returns_token` | `verify_reset_code` with the correct code returns `{ok: True, reset_token}`; row's `used_at` now set |
|
||||||
|
| `test_verify_wrong_code_increments_attempt_count` | Wrong code → `attempt_count` goes from 0 → 1; returns `attempts_left: 4` |
|
||||||
|
| `test_5_wrong_attempts_invalidates_code` | Five wrong codes → `used_at` set even though never successfully verified |
|
||||||
|
| `test_expired_code_rejects_even_if_correct` | Backdate `expires_at` to past; verify with correct code returns `{ok: False, error: 'expired'}` |
|
||||||
|
| `test_set_pin_with_valid_reset_token` | After verify, calling `set_pin({new_pin, reset_token})` writes the new hash |
|
||||||
|
| `test_set_pin_with_expired_token_rejects` | Token > 5min old → set_pin returns `{ok: False, error: 'token_expired'}` |
|
||||||
|
| `test_set_pin_with_token_for_wrong_user_rejects` | Token signed for user A used for user B → rejected |
|
||||||
|
| `test_set_pin_clears_lockout` | User with `x_fc_tablet_locked_until` set → after reset, locked_until is null |
|
||||||
|
| `test_no_email_user_returns_specific_error` | User without email → request returns `{ok: False, error: 'no_email'}` |
|
||||||
|
| `test_user_without_shop_branch_role_rejects` | Non-shop user → request rejected (matches `_check_credentials` security model) |
|
||||||
|
| `test_audit_event_written_on_request` | After request, one `fp.tablet.session.event` row with the right event_type |
|
||||||
|
|
||||||
|
### Entech smoke script — `fusion_plating_shopfloor/scripts/bt_pin_reset.py`
|
||||||
|
|
||||||
|
End-to-end via odoo-shell:
|
||||||
|
1. Pick a real user with no PIN (e.g. Bernice Boakye)
|
||||||
|
2. Call `request_reset_code` → assert email sent (check `mail.mail`)
|
||||||
|
3. Pull the code from the most recent reset row via test-only shim
|
||||||
|
4. Call `verify_reset_code` with the code → assert token returned
|
||||||
|
5. Call `set_pin` with token + new PIN → assert hash set
|
||||||
|
6. Call `unlock_session` with new PIN → assert session returned
|
||||||
|
7. Clean up: `clear_tablet_pin()`, delete reset rows
|
||||||
|
8. Print pass/fail per step
|
||||||
|
|
||||||
|
### Manual QA on entech
|
||||||
|
|
||||||
|
1. Open tablet at `https://enplating.com/odoo/action-fp_shopfloor_landing` (or wherever the landing renders)
|
||||||
|
2. Tap a real no-PIN tile (e.g. Bernice Boakye) → verify "Send temporary PIN" button appears
|
||||||
|
3. Tap button → verify masked email shown + email arrives in user's inbox
|
||||||
|
4. Tap a tile with no email on file → verify "Contact your manager" message
|
||||||
|
5. Enter temp code → set new PIN → verify auto-login lands on the workstation
|
||||||
|
6. Lock + tap same user → verify normal PIN entry works with the new PIN
|
||||||
|
7. Enter wrong PIN 3 times → verify "Forgot?" button appears below keypad
|
||||||
|
8. Tap "Forgot?" → repeats the email flow
|
||||||
|
9. Toggle dark mode → verify all new screens flip cleanly
|
||||||
|
|
||||||
|
## Migration / rollback
|
||||||
|
|
||||||
|
- **No data migration.** Pure schema-addition + new endpoints.
|
||||||
|
- **Rollback path:** `git revert` the implementation commits + `-u fusion_plating_shopfloor` + asset bust. New model table stays (`DROP TABLE fp_tablet_pin_reset` only needed if you uninstall the module entirely). No production data loss.
|
||||||
|
|
||||||
|
## Status & deployment notes
|
||||||
|
|
||||||
|
Target version bump: `fusion_plating_shopfloor 19.0.34.2.0` → **19.0.35.0.0**.
|
||||||
|
|
||||||
|
Deploy steps (mirrors prior session work):
|
||||||
|
1. Sync 8 modified + 5 new files to entech `/mnt/extra-addons/custom/fusion_plating_shopfloor/`
|
||||||
|
2. `-u fusion_plating_shopfloor` (no other modules)
|
||||||
|
3. Asset bust: `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';`
|
||||||
|
4. Restart odoo
|
||||||
|
5. Run battle test via odoo-shell
|
||||||
|
6. Manual browser QA at the shop-floor landing URL
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from . import controllers
|
from . import controllers
|
||||||
from . import models
|
from . import models
|
||||||
@@ -23,6 +24,8 @@ def post_init_hook(env):
|
|||||||
3. Sub 12a — seed fp.step.template with starter library entries
|
3. Sub 12a — seed fp.step.template with starter library entries
|
||||||
derived from ENP-ALUM-BASIC if the library is currently empty.
|
derived from ENP-ALUM-BASIC if the library is currently empty.
|
||||||
4. Sub 12b — seed 4 starter rack tags if the registry is empty.
|
4. Sub 12b — seed 4 starter rack tags if the registry is empty.
|
||||||
|
5. Phase H — create a pending fp.migration.preview if any user
|
||||||
|
still holds an old plating-role group + notify Owners.
|
||||||
"""
|
"""
|
||||||
_seed_default_timezone(env)
|
_seed_default_timezone(env)
|
||||||
_backfill_node_input_kind(env)
|
_backfill_node_input_kind(env)
|
||||||
@@ -31,6 +34,120 @@ def post_init_hook(env):
|
|||||||
_seed_rack_tags_if_empty(env)
|
_seed_rack_tags_if_empty(env)
|
||||||
_migrate_legacy_uom_columns(env)
|
_migrate_legacy_uom_columns(env)
|
||||||
_seed_starter_recipes_once(env)
|
_seed_starter_recipes_once(env)
|
||||||
|
_fp_post_init_role_migration(env)
|
||||||
|
_fp_apply_office_user_menu_visibility(env)
|
||||||
|
|
||||||
|
|
||||||
|
# Top-level app menus that technicians should NOT see. Each entry is an
|
||||||
|
# xmlid; env.ref(..., raise_if_not_found=False) silently skips menus
|
||||||
|
# from uninstalled modules so this is safe across configurations.
|
||||||
|
# Kept visible to technicians (NOT in this list): Discuss, To-do,
|
||||||
|
# Plating, AI, Maintenance, Time Off. Settings/Apps/Tests are admin-
|
||||||
|
# restricted upstream — also not in this list.
|
||||||
|
# See security/fp_menu_visibility.xml for the design rationale.
|
||||||
|
MENU_HIDE_FROM_TECHNICIANS = [
|
||||||
|
'calendar.mail_menu_calendar',
|
||||||
|
'contacts.menu_contacts',
|
||||||
|
'crm.crm_menu_root',
|
||||||
|
'sale.sale_menu_root',
|
||||||
|
'spreadsheet_dashboard.spreadsheet_dashboard_menu_root',
|
||||||
|
'fusion_ringcentral.menu_rc_root',
|
||||||
|
'fusion_faxes.menu_fusion_faxes_root',
|
||||||
|
'fusion_tasks.menu_field_service_root',
|
||||||
|
'fusion_clock.menu_fusion_clock_root',
|
||||||
|
'account.menu_finance',
|
||||||
|
'accountant.menu_accounting',
|
||||||
|
'project.menu_main_pm',
|
||||||
|
'hr_timesheet.timesheet_menu_root',
|
||||||
|
'planning.planning_menu_root',
|
||||||
|
'fusion_shipping.menu_fusion_shipping_root',
|
||||||
|
'website.menu_website_configuration',
|
||||||
|
'purchase.menu_purchase_root',
|
||||||
|
'stock.menu_stock_root',
|
||||||
|
'sign.menu_document',
|
||||||
|
'hr.menu_hr_root',
|
||||||
|
'hr_work_entry_enterprise.menu_hr_payroll_root',
|
||||||
|
'hr_attendance.menu_hr_attendance_root',
|
||||||
|
'hr_recruitment.menu_hr_recruitment_root',
|
||||||
|
'hr_expense.menu_hr_expense_root',
|
||||||
|
'iot.iot_menu_root',
|
||||||
|
'utm.menu_link_tracker_root',
|
||||||
|
'base.menu_management',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fp_apply_office_user_menu_visibility(env):
|
||||||
|
"""Set group_ids = [group_fp_office_user] on every menu in
|
||||||
|
MENU_HIDE_FROM_TECHNICIANS that exists in this DB.
|
||||||
|
|
||||||
|
Field is `group_ids` on ir.ui.menu in Odoo 19 (was `groups_id` in
|
||||||
|
earlier versions — Odoo 18 renamed it). Same naming-rename pattern
|
||||||
|
as res.users (CLAUDE.md Critical Rule 13c).
|
||||||
|
|
||||||
|
Idempotent: if a menu already has only the office_user group, no
|
||||||
|
change is made. If it has additional groups (e.g. a previous custom
|
||||||
|
restriction), they're REPLACED — the design accepts this trade-off
|
||||||
|
because office_user is implied by every fp role above Technician,
|
||||||
|
so non-fp users keep their access on entech.
|
||||||
|
|
||||||
|
Cross-module xmlids: env.ref(..., raise_if_not_found=False) returns
|
||||||
|
None for menus from uninstalled modules, which we silently skip.
|
||||||
|
"""
|
||||||
|
office = env.ref(
|
||||||
|
'fusion_plating.group_fp_office_user', raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not office:
|
||||||
|
_logger.warning(
|
||||||
|
'[menu-visibility] group_fp_office_user not found; skipping'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
touched = 0
|
||||||
|
for xmlid in MENU_HIDE_FROM_TECHNICIANS:
|
||||||
|
menu = env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if not menu:
|
||||||
|
continue
|
||||||
|
current_ids = set(menu.group_ids.ids)
|
||||||
|
if current_ids == {office.id}:
|
||||||
|
continue # already locked-down, nothing to do
|
||||||
|
menu.sudo().group_ids = [(6, 0, [office.id])]
|
||||||
|
touched += 1
|
||||||
|
_logger.info(
|
||||||
|
'[menu-visibility] restricted %s menu(s) to group_fp_office_user',
|
||||||
|
touched,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fp_post_init_role_migration(env):
|
||||||
|
"""Idempotent: creates a fp.migration.preview if none is pending or applied.
|
||||||
|
|
||||||
|
Called automatically on `-u fusion_plating`. The preview enters 'pending'
|
||||||
|
state and schedules a mail.activity on every Owner. Owner must explicitly
|
||||||
|
click 'Approve & Run' to actually apply the migration.
|
||||||
|
"""
|
||||||
|
Preview = env['fp.migration.preview']
|
||||||
|
if Preview.search_count([('state', '=', 'pending')]):
|
||||||
|
return
|
||||||
|
if Preview.search_count([('state', '=', 'approved')]):
|
||||||
|
# Already migrated previously; only re-fire if any unmigrated user remains
|
||||||
|
# An unmigrated user is one who still holds an OLD plating group directly
|
||||||
|
# AND does NOT hold any NEW role group. The compute on res.users.x_fc_plating_role
|
||||||
|
# returns 'no' for users without any new group regardless of their old groups.
|
||||||
|
# Heuristic: if any active user still holds an old group, re-fire.
|
||||||
|
from .models.fp_role_constants import _FP_OLD_GROUP_XMLIDS
|
||||||
|
any_unmigrated = False
|
||||||
|
for xmlid in _FP_OLD_GROUP_XMLIDS:
|
||||||
|
old_grp = env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if not old_grp:
|
||||||
|
continue
|
||||||
|
if old_grp.users.filtered(lambda u: u.active and not u.share):
|
||||||
|
# Found at least one user still on an old group → re-fire
|
||||||
|
any_unmigrated = True
|
||||||
|
break
|
||||||
|
if not any_unmigrated:
|
||||||
|
return # All users migrated; nothing to do
|
||||||
|
preview = Preview.create({})
|
||||||
|
preview._fp_build_lines()
|
||||||
|
preview._fp_notify_owners()
|
||||||
|
|
||||||
|
|
||||||
def _seed_starter_recipes_once(env):
|
def _seed_starter_recipes_once(env):
|
||||||
@@ -246,6 +363,38 @@ _STARTER_KIND_BY_NAME = {
|
|||||||
'ready for post-plate inspection': 'gating',
|
'ready for post-plate inspection': 'gating',
|
||||||
'ready for final inspection': 'gating',
|
'ready for final inspection': 'gating',
|
||||||
'ready for shipping': 'gating',
|
'ready for shipping': 'gating',
|
||||||
|
# 2026-05-24 — Recipe cleanup additions (spec
|
||||||
|
# 2026-05-24-recipe-cleanup-design.md). Covers names the existing
|
||||||
|
# resolver didn't know that turned up in the entech recipes audit.
|
||||||
|
# Blasting variants
|
||||||
|
'blasting': 'blast',
|
||||||
|
'bead blast': 'blast',
|
||||||
|
'bead blasting': 'blast',
|
||||||
|
'media blast': 'blast',
|
||||||
|
'media blasting': 'blast',
|
||||||
|
# Inspection variants
|
||||||
|
'adhesion test coupon': 'inspect',
|
||||||
|
'adhesion testing': 'inspect',
|
||||||
|
'corrosion testing': 'inspect',
|
||||||
|
'lab testing': 'inspect',
|
||||||
|
'check sulfamate nickel area': 'inspect',
|
||||||
|
'pre-measurements': 'inspect',
|
||||||
|
'pre measurements': 'inspect',
|
||||||
|
'hot water porosity': 'inspect',
|
||||||
|
# Strip / chemical conversion / plugging (wet line)
|
||||||
|
'strip process': 'wet_process',
|
||||||
|
'strip process - al': 'wet_process',
|
||||||
|
'nickel strip - aluminum line': 'wet_process',
|
||||||
|
'chemical conversion': 'wet_process',
|
||||||
|
'trivalent chromate conversion': 'wet_process',
|
||||||
|
'plug the threaded holes': 'mask',
|
||||||
|
# Misc wet-line variants seen on entech recipes
|
||||||
|
'air dry': 'dry',
|
||||||
|
'desmut': 'etch',
|
||||||
|
'soak clean': 'cleaning',
|
||||||
|
'cleaner': 'cleaning',
|
||||||
|
'nickel strike': 'plate',
|
||||||
|
'nickel strip': 'plate',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -254,6 +403,9 @@ def fp_resolve_step_kind(name):
|
|||||||
case. Used by both the seeder and the migration backfill so we don't
|
case. Used by both the seeder and the migration backfill so we don't
|
||||||
have two slightly-different lookup paths.
|
have two slightly-different lookup paths.
|
||||||
|
|
||||||
|
Handles parenthetical suffixes like "(Standard)", "(If Required)",
|
||||||
|
"(A-14 / A)" by stripping them and re-trying the lookup.
|
||||||
|
|
||||||
Returns the kind str or None when no match.
|
Returns the kind str or None when no match.
|
||||||
"""
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
@@ -261,6 +413,12 @@ def fp_resolve_step_kind(name):
|
|||||||
key = name.strip().lower()
|
key = name.strip().lower()
|
||||||
if key in _STARTER_KIND_BY_NAME:
|
if key in _STARTER_KIND_BY_NAME:
|
||||||
return _STARTER_KIND_BY_NAME[key]
|
return _STARTER_KIND_BY_NAME[key]
|
||||||
|
# Parenthetical strip — "Masking (If Required)" → "masking",
|
||||||
|
# "Incoming Inspection (Standard)" → "incoming inspection",
|
||||||
|
# "Trivalent Chromate Conversion (A-14 / A)" → "trivalent chromate conversion".
|
||||||
|
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
|
||||||
|
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
|
||||||
|
return _STARTER_KIND_BY_NAME[bare]
|
||||||
# Gating "Ready for / Ready For" prefix — anything starting with that
|
# Gating "Ready for / Ready For" prefix — anything starting with that
|
||||||
# is a gating node regardless of the destination step name.
|
# is a gating node regardless of the destination step name.
|
||||||
if key.startswith('ready for ') or key.startswith('ready '):
|
if key.startswith('ready for ') or key.startswith('ready '):
|
||||||
@@ -268,6 +426,44 @@ def fp_resolve_step_kind(name):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Translates resolver kind output to the active fp.step.kind.code values.
|
||||||
|
# The resolver still returns the OLD vocabulary (cleaning, electroclean,
|
||||||
|
# etch, rinse, strike, dry, wbf_test) which were deactivated in
|
||||||
|
# 19.0.20.6.0 — those roll up to the active wet_process kind. Other
|
||||||
|
# codes pass through 1:1. Used by the auto-classify hook on
|
||||||
|
# fusion.plating.process.node + the recipe-cleanup migration
|
||||||
|
# (fusion_plating_jobs 19.0.10.26.0).
|
||||||
|
RESOLVER_KIND_TO_ACTIVE_KIND = {
|
||||||
|
# Wet-line kinds → wet_process (active rollup)
|
||||||
|
'cleaning': 'wet_process',
|
||||||
|
'electroclean': 'wet_process',
|
||||||
|
'etch': 'wet_process',
|
||||||
|
'rinse': 'wet_process',
|
||||||
|
'strike': 'wet_process',
|
||||||
|
'dry': 'wet_process',
|
||||||
|
'wbf_test': 'wet_process',
|
||||||
|
'wet_process': 'wet_process', # the alias added in 19.0.21.3.0
|
||||||
|
# for "Strip Process - AL", "Chemical
|
||||||
|
# Conversion", "Trivalent Chromate
|
||||||
|
# Conversion" maps DIRECTLY to
|
||||||
|
# 'wet_process' — this passthrough
|
||||||
|
# entry lets those land correctly.
|
||||||
|
# 1:1 mappings (kind exists and is active)
|
||||||
|
'contract_review': 'contract_review',
|
||||||
|
'mask': 'mask',
|
||||||
|
'racking': 'racking',
|
||||||
|
'plate': 'plate',
|
||||||
|
'bake': 'bake',
|
||||||
|
'derack': 'derack',
|
||||||
|
'demask': 'demask',
|
||||||
|
'inspect': 'inspect',
|
||||||
|
'final_inspect': 'final_inspect',
|
||||||
|
'ship': 'ship',
|
||||||
|
'gating': 'gating',
|
||||||
|
'blast': 'blast',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _seed_step_library_if_empty(env):
|
def _seed_step_library_if_empty(env):
|
||||||
"""Sub 12a — seed fp.step.template starter library.
|
"""Sub 12a — seed fp.step.template starter library.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.20.8.0',
|
'version': '19.0.21.4.0',
|
||||||
'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': """
|
||||||
@@ -80,7 +80,15 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'security/fp_security.xml',
|
'security/fp_security.xml',
|
||||||
|
'security/fp_security_v2.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
# Menu visibility — loads after fp_security_v2.xml so the role
|
||||||
|
# group xmlids exist when we add office_user to their
|
||||||
|
# implied_ids. Loads after fp_menu.xml in spirit BUT references
|
||||||
|
# cross-module menus (calendar, sale, hr, etc.) which exist by
|
||||||
|
# the time fusion_plating loads, so safe to load here at
|
||||||
|
# security-config time.
|
||||||
|
'security/fp_menu_visibility.xml',
|
||||||
'data/fp_landing_data.xml',
|
'data/fp_landing_data.xml',
|
||||||
'data/fp_sequence_data.xml',
|
'data/fp_sequence_data.xml',
|
||||||
'data/fp_job_sequences.xml',
|
'data/fp_job_sequences.xml',
|
||||||
@@ -114,6 +122,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/fp_operator_certification_views.xml',
|
'views/fp_operator_certification_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/fp_landing_views.xml',
|
'views/fp_landing_views.xml',
|
||||||
|
# Phase F — Owner-only Team page + Designated Officials on res.company.
|
||||||
|
# Both reference menu_fp_config (Configuration root) and Phase 1
|
||||||
|
# role groups, all loaded earlier (fp_menu.xml + fp_security_v2.xml).
|
||||||
|
'views/fp_team_views.xml',
|
||||||
|
'views/res_company_views.xml',
|
||||||
'views/fp_work_centre_views.xml',
|
'views/fp_work_centre_views.xml',
|
||||||
'views/fp_job_views.xml',
|
'views/fp_job_views.xml',
|
||||||
'views/fp_job_step_views.xml',
|
'views/fp_job_step_views.xml',
|
||||||
@@ -134,6 +147,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
# 'data/fp_recipe_anodize.xml',
|
# 'data/fp_recipe_anodize.xml',
|
||||||
# 'data/fp_recipe_chem_conversion.xml',
|
# 'data/fp_recipe_chem_conversion.xml',
|
||||||
'data/fp_step_template_data.xml',
|
'data/fp_step_template_data.xml',
|
||||||
|
# Phase H — Owner-approval migration workflow.
|
||||||
|
# Views file declares the action + menu; cron declares the
|
||||||
|
# daily 30-day expiry purge. Both reference model_fp_migration_preview
|
||||||
|
# which Odoo's model autoload makes available before data load.
|
||||||
|
'views/fp_migration_views.xml',
|
||||||
|
'data/fp_migration_cron.xml',
|
||||||
],
|
],
|
||||||
'post_init_hook': 'post_init_hook',
|
'post_init_hook': 'post_init_hook',
|
||||||
'assets': {
|
'assets': {
|
||||||
|
|||||||
@@ -484,8 +484,15 @@ class SimpleRecipeController(http.Controller):
|
|||||||
type='jsonrpc', auth='user')
|
type='jsonrpc', auth='user')
|
||||||
def kinds_list(self):
|
def kinds_list(self):
|
||||||
"""Sub 14b — Step Kind dropdown options for the inline library
|
"""Sub 14b — Step Kind dropdown options for the inline library
|
||||||
form. User-extensible via /fp/simple_recipe/kinds/create."""
|
form. User-extensible via /fp/simple_recipe/kinds/create.
|
||||||
|
|
||||||
|
2026-05-24 — payload now includes `area_kind` + a humanized
|
||||||
|
`area_kind_label` so the Simple Editor picker can render
|
||||||
|
"Masking — Masking column" and authors see which Shop Floor
|
||||||
|
column they're routing the step to.
|
||||||
|
"""
|
||||||
Kind = request.env['fp.step.kind']
|
Kind = request.env['fp.step.kind']
|
||||||
|
area_labels = dict(Kind._fields['area_kind'].selection)
|
||||||
return {
|
return {
|
||||||
'kinds': [
|
'kinds': [
|
||||||
{
|
{
|
||||||
@@ -494,6 +501,8 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'name': k.name or '',
|
'name': k.name or '',
|
||||||
'icon': k.icon or '',
|
'icon': k.icon or '',
|
||||||
'sequence': k.sequence,
|
'sequence': k.sequence,
|
||||||
|
'area_kind': k.area_kind or '',
|
||||||
|
'area_kind_label': area_labels.get(k.area_kind, ''),
|
||||||
}
|
}
|
||||||
for k in Kind.search(
|
for k in Kind.search(
|
||||||
[('active', '=', True)], order='sequence, name',
|
[('active', '=', True)], order='sequence, name',
|
||||||
|
|||||||
@@ -24,25 +24,14 @@
|
|||||||
<field name="model_id" ref="base.model_res_users"/>
|
<field name="model_id" ref="base.model_res_users"/>
|
||||||
<field name="state">code</field>
|
<field name="state">code</field>
|
||||||
<field name="code"><![CDATA[
|
<field name="code"><![CDATA[
|
||||||
# Resolve in priority order: user pref → company default → Sale Orders fallback.
|
# Delegates to the role-based dispatch helper on ir.actions.act_window
|
||||||
user = env.user
|
# (and ir.actions.client for Manager Desk / Plant Kanban / Quality Dashboard).
|
||||||
target = False
|
# Resolution chain in the helper:
|
||||||
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
|
# 1. user.x_fc_plating_landing_action_id (per-user override)
|
||||||
target = user.x_fc_plating_landing_action_id.sudo()
|
# 2. role-based default per spec Section 3 (Owner→ManagerDesk, etc.)
|
||||||
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
|
# 3. company.x_fc_default_landing_action_id (company default)
|
||||||
target = env.company.x_fc_default_landing_action_id.sudo()
|
# 4. action_fp_sale_orders (hardcoded last-ditch)
|
||||||
if not target:
|
action = env['ir.actions.act_window'].sudo()._fp_resolve_landing_for_current_user() or False
|
||||||
target = env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False)
|
|
||||||
|
|
||||||
if target:
|
|
||||||
action = target.sudo().read()[0]
|
|
||||||
# Strip ids that confuse the act_window dispatcher.
|
|
||||||
action.pop('id', None)
|
|
||||||
else:
|
|
||||||
# Last-ditch — open the Plating app's process recipes if even
|
|
||||||
# the Sale Orders action is missing (e.g. configurator not installed).
|
|
||||||
action = env.ref('fusion_plating.action_fp_process_recipe').sudo().read()[0]
|
|
||||||
action.pop('id', None)
|
|
||||||
]]></field>
|
]]></field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
16
fusion_plating/fusion_plating/data/fp_migration_cron.xml
Normal file
16
fusion_plating/fusion_plating/data/fp_migration_cron.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<record id="ir_cron_purge_expired_migrations" model="ir.cron">
|
||||||
|
<field name="name">Fusion Plating: Purge Expired Role Migrations</field>
|
||||||
|
<field name="model_id" ref="model_fp_migration_preview"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_purge_expired_migrations()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -18,7 +18,15 @@
|
|||||||
(covers all bath-based steps).
|
(covers all bath-based steps).
|
||||||
- `mask` covers Masking + De-Masking, `racking` covers
|
- `mask` covers Masking + De-Masking, `racking` covers
|
||||||
Racking + De-Racking — operators differentiate by the
|
Racking + De-Racking — operators differentiate by the
|
||||||
step name. -->
|
step name.
|
||||||
|
|
||||||
|
2026-05-24 update (19.0.21.2.0 — Shop Floor live-step fix):
|
||||||
|
- New `area_kind` field on fp.step.kind drives plant-view
|
||||||
|
column routing. Every record below carries an
|
||||||
|
area_kind. New `blast` kind for the Blasting column.
|
||||||
|
- `derack`, `demask`, `gating` get re-activated via the
|
||||||
|
pre-migrate (they're listed under "ACTIVE KINDS" here
|
||||||
|
now since they're meant to be active going forward). -->
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- ACTIVE KINDS — visible in dropdown -->
|
<!-- ACTIVE KINDS — visible in dropdown -->
|
||||||
@@ -29,13 +37,7 @@
|
|||||||
<field name="name">Other</field>
|
<field name="name">Other</field>
|
||||||
<field name="sequence">5</field>
|
<field name="sequence">5</field>
|
||||||
<field name="icon">fa-circle-o</field>
|
<field name="icon">fa-circle-o</field>
|
||||||
</record>
|
<field name="area_kind">plating</field>
|
||||||
|
|
||||||
<record id="step_kind_wet_process" model="fp.step.kind">
|
|
||||||
<field name="code">wet_process</field>
|
|
||||||
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
|
|
||||||
<field name="sequence">55</field>
|
|
||||||
<field name="icon">fa-tint</field>
|
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="step_kind_receiving" model="fp.step.kind">
|
<record id="step_kind_receiving" model="fp.step.kind">
|
||||||
@@ -43,144 +45,182 @@
|
|||||||
<field name="name">Receiving / Incoming Inspection</field>
|
<field name="name">Receiving / Incoming Inspection</field>
|
||||||
<field name="sequence">10</field>
|
<field name="sequence">10</field>
|
||||||
<field name="icon">fa-truck</field>
|
<field name="icon">fa-truck</field>
|
||||||
|
<field name="area_kind">receiving</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_contract_review" model="fp.step.kind">
|
<record id="step_kind_contract_review" model="fp.step.kind">
|
||||||
<field name="code">contract_review</field>
|
<field name="code">contract_review</field>
|
||||||
<field name="name">Contract Review (QA-005)</field>
|
<field name="name">Contract Review (QA-005)</field>
|
||||||
<field name="sequence">20</field>
|
<field name="sequence">20</field>
|
||||||
<field name="icon">fa-file-text-o</field>
|
<field name="icon">fa-file-text-o</field>
|
||||||
|
<field name="area_kind">receiving</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_racking" model="fp.step.kind">
|
<record id="step_kind_racking" model="fp.step.kind">
|
||||||
<field name="code">racking</field>
|
<field name="code">racking</field>
|
||||||
<field name="name">Racking</field>
|
<field name="name">Racking</field>
|
||||||
<field name="sequence">30</field>
|
<field name="sequence">30</field>
|
||||||
<field name="icon">fa-server</field>
|
<field name="icon">fa-server</field>
|
||||||
|
<field name="area_kind">racking</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_blast" model="fp.step.kind">
|
||||||
|
<field name="code">blast</field>
|
||||||
|
<field name="name">Blasting / Media Blast</field>
|
||||||
|
<field name="sequence">35</field>
|
||||||
|
<field name="icon">fa-bullseye</field>
|
||||||
|
<field name="area_kind">blasting</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_mask" model="fp.step.kind">
|
<record id="step_kind_mask" model="fp.step.kind">
|
||||||
<field name="code">mask</field>
|
<field name="code">mask</field>
|
||||||
<field name="name">Masking</field>
|
<field name="name">Masking</field>
|
||||||
<field name="sequence">40</field>
|
<field name="sequence">40</field>
|
||||||
<field name="icon">fa-eye-slash</field>
|
<field name="icon">fa-eye-slash</field>
|
||||||
|
<field name="area_kind">masking</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_cleaning" model="fp.step.kind">
|
<record id="step_kind_cleaning" model="fp.step.kind">
|
||||||
<field name="code">cleaning</field>
|
<field name="code">cleaning</field>
|
||||||
<field name="name">Cleaning</field>
|
<field name="name">Cleaning</field>
|
||||||
<field name="sequence">50</field>
|
<field name="sequence">50</field>
|
||||||
<field name="icon">fa-tint</field>
|
<field name="icon">fa-tint</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
|
</record>
|
||||||
|
<record id="step_kind_wet_process" model="fp.step.kind">
|
||||||
|
<field name="code">wet_process</field>
|
||||||
|
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
|
||||||
|
<field name="sequence">55</field>
|
||||||
|
<field name="icon">fa-tint</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_electroclean" model="fp.step.kind">
|
<record id="step_kind_electroclean" model="fp.step.kind">
|
||||||
<field name="code">electroclean</field>
|
<field name="code">electroclean</field>
|
||||||
<field name="name">Electroclean</field>
|
<field name="name">Electroclean</field>
|
||||||
<field name="sequence">60</field>
|
<field name="sequence">60</field>
|
||||||
<field name="icon">fa-bolt</field>
|
<field name="icon">fa-bolt</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_etch" model="fp.step.kind">
|
<record id="step_kind_etch" model="fp.step.kind">
|
||||||
<field name="code">etch</field>
|
<field name="code">etch</field>
|
||||||
<field name="name">Etch / Activation</field>
|
<field name="name">Etch / Activation</field>
|
||||||
<field name="sequence">70</field>
|
<field name="sequence">70</field>
|
||||||
<field name="icon">fa-flask</field>
|
<field name="icon">fa-flask</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_rinse" model="fp.step.kind">
|
<record id="step_kind_rinse" model="fp.step.kind">
|
||||||
<field name="code">rinse</field>
|
<field name="code">rinse</field>
|
||||||
<field name="name">Rinse</field>
|
<field name="name">Rinse</field>
|
||||||
<field name="sequence">80</field>
|
<field name="sequence">80</field>
|
||||||
<field name="icon">fa-tint</field>
|
<field name="icon">fa-tint</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_strike" model="fp.step.kind">
|
<record id="step_kind_strike" model="fp.step.kind">
|
||||||
<field name="code">strike</field>
|
<field name="code">strike</field>
|
||||||
<field name="name">Strike (Wood's Nickel / Activation)</field>
|
<field name="name">Strike (Wood's Nickel / Activation)</field>
|
||||||
<field name="sequence">90</field>
|
<field name="sequence">90</field>
|
||||||
<field name="icon">fa-bolt</field>
|
<field name="icon">fa-bolt</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_plate" model="fp.step.kind">
|
<record id="step_kind_plate" model="fp.step.kind">
|
||||||
<field name="code">plate</field>
|
<field name="code">plate</field>
|
||||||
<field name="name">Plating</field>
|
<field name="name">Plating</field>
|
||||||
<field name="sequence">100</field>
|
<field name="sequence">100</field>
|
||||||
<field name="icon">fa-shield</field>
|
<field name="icon">fa-shield</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_replenishment" model="fp.step.kind">
|
<record id="step_kind_replenishment" model="fp.step.kind">
|
||||||
<field name="code">replenishment</field>
|
<field name="code">replenishment</field>
|
||||||
<field name="name">Tank Replenishment</field>
|
<field name="name">Tank Replenishment</field>
|
||||||
<field name="sequence">110</field>
|
<field name="sequence">110</field>
|
||||||
<field name="icon">fa-plus-circle</field>
|
<field name="icon">fa-plus-circle</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_wbf_test" model="fp.step.kind">
|
<record id="step_kind_wbf_test" model="fp.step.kind">
|
||||||
<field name="code">wbf_test</field>
|
<field name="code">wbf_test</field>
|
||||||
<field name="name">Water Break Free Test</field>
|
<field name="name">Water Break Free Test</field>
|
||||||
<field name="sequence">120</field>
|
<field name="sequence">120</field>
|
||||||
<field name="icon">fa-check-square-o</field>
|
<field name="icon">fa-check-square-o</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_dry" model="fp.step.kind">
|
<record id="step_kind_dry" model="fp.step.kind">
|
||||||
<field name="code">dry</field>
|
<field name="code">dry</field>
|
||||||
<field name="name">Drying</field>
|
<field name="name">Drying</field>
|
||||||
<field name="sequence">130</field>
|
<field name="sequence">130</field>
|
||||||
<field name="icon">fa-sun-o</field>
|
<field name="icon">fa-sun-o</field>
|
||||||
|
<field name="area_kind">plating</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_bake" model="fp.step.kind">
|
<record id="step_kind_bake" model="fp.step.kind">
|
||||||
<field name="code">bake</field>
|
<field name="code">bake</field>
|
||||||
<field name="name">Bake (HE Relief / Stress Relief)</field>
|
<field name="name">Bake (HE Relief / Stress Relief)</field>
|
||||||
<field name="sequence">140</field>
|
<field name="sequence">140</field>
|
||||||
<field name="icon">fa-fire</field>
|
<field name="icon">fa-fire</field>
|
||||||
|
<field name="area_kind">baking</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_demask" model="fp.step.kind">
|
<record id="step_kind_demask" model="fp.step.kind">
|
||||||
<field name="code">demask</field>
|
<field name="code">demask</field>
|
||||||
<field name="name">De-Masking</field>
|
<field name="name">De-Masking</field>
|
||||||
<field name="sequence">150</field>
|
<field name="sequence">150</field>
|
||||||
<field name="icon">fa-eye</field>
|
<field name="icon">fa-eye</field>
|
||||||
|
<field name="area_kind">de_racking</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_derack" model="fp.step.kind">
|
<record id="step_kind_derack" model="fp.step.kind">
|
||||||
<field name="code">derack</field>
|
<field name="code">derack</field>
|
||||||
<field name="name">De-Racking</field>
|
<field name="name">De-Racking</field>
|
||||||
<field name="sequence">160</field>
|
<field name="sequence">160</field>
|
||||||
<field name="icon">fa-server</field>
|
<field name="icon">fa-server</field>
|
||||||
|
<field name="area_kind">de_racking</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_inspect" model="fp.step.kind">
|
<record id="step_kind_inspect" model="fp.step.kind">
|
||||||
<field name="code">inspect</field>
|
<field name="code">inspect</field>
|
||||||
<field name="name">Inspection</field>
|
<field name="name">Inspection</field>
|
||||||
<field name="sequence">170</field>
|
<field name="sequence">170</field>
|
||||||
<field name="icon">fa-search</field>
|
<field name="icon">fa-search</field>
|
||||||
|
<field name="area_kind">inspection</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_hardness_test" model="fp.step.kind">
|
<record id="step_kind_hardness_test" model="fp.step.kind">
|
||||||
<field name="code">hardness_test</field>
|
<field name="code">hardness_test</field>
|
||||||
<field name="name">Hardness Test (HV / HK / HRC)</field>
|
<field name="name">Hardness Test (HV / HK / HRC)</field>
|
||||||
<field name="sequence">180</field>
|
<field name="sequence">180</field>
|
||||||
<field name="icon">fa-tachometer</field>
|
<field name="icon">fa-tachometer</field>
|
||||||
|
<field name="area_kind">inspection</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_adhesion_test" model="fp.step.kind">
|
<record id="step_kind_adhesion_test" model="fp.step.kind">
|
||||||
<field name="code">adhesion_test</field>
|
<field name="code">adhesion_test</field>
|
||||||
<field name="name">Adhesion Test</field>
|
<field name="name">Adhesion Test</field>
|
||||||
<field name="sequence">190</field>
|
<field name="sequence">190</field>
|
||||||
<field name="icon">fa-link</field>
|
<field name="icon">fa-link</field>
|
||||||
|
<field name="area_kind">inspection</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_salt_spray" model="fp.step.kind">
|
<record id="step_kind_salt_spray" model="fp.step.kind">
|
||||||
<field name="code">salt_spray</field>
|
<field name="code">salt_spray</field>
|
||||||
<field name="name">Salt Spray / Corrosion Test</field>
|
<field name="name">Salt Spray / Corrosion Test</field>
|
||||||
<field name="sequence">200</field>
|
<field name="sequence">200</field>
|
||||||
<field name="icon">fa-cloud</field>
|
<field name="icon">fa-cloud</field>
|
||||||
|
<field name="area_kind">inspection</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_final_inspect" model="fp.step.kind">
|
<record id="step_kind_final_inspect" model="fp.step.kind">
|
||||||
<field name="code">final_inspect</field>
|
<field name="code">final_inspect</field>
|
||||||
<field name="name">Final Inspection</field>
|
<field name="name">Final Inspection</field>
|
||||||
<field name="sequence">210</field>
|
<field name="sequence">210</field>
|
||||||
<field name="icon">fa-check-circle</field>
|
<field name="icon">fa-check-circle</field>
|
||||||
|
<field name="area_kind">inspection</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_packaging" model="fp.step.kind">
|
<record id="step_kind_packaging" model="fp.step.kind">
|
||||||
<field name="code">packaging</field>
|
<field name="code">packaging</field>
|
||||||
<field name="name">Packaging / Pre-Ship</field>
|
<field name="name">Packaging / Pre-Ship</field>
|
||||||
<field name="sequence">220</field>
|
<field name="sequence">220</field>
|
||||||
<field name="icon">fa-archive</field>
|
<field name="icon">fa-archive</field>
|
||||||
|
<field name="area_kind">shipping</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_ship" model="fp.step.kind">
|
<record id="step_kind_ship" model="fp.step.kind">
|
||||||
<field name="code">ship</field>
|
<field name="code">ship</field>
|
||||||
<field name="name">Shipping</field>
|
<field name="name">Shipping</field>
|
||||||
<field name="sequence">230</field>
|
<field name="sequence">230</field>
|
||||||
<field name="icon">fa-paper-plane</field>
|
<field name="icon">fa-paper-plane</field>
|
||||||
|
<field name="area_kind">shipping</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="step_kind_gating" model="fp.step.kind">
|
<record id="step_kind_gating" model="fp.step.kind">
|
||||||
<field name="code">gating</field>
|
<field name="code">gating</field>
|
||||||
<field name="name">Gating</field>
|
<field name="name">Gating</field>
|
||||||
<field name="sequence">240</field>
|
<field name="sequence">240</field>
|
||||||
<field name="icon">fa-pause-circle</field>
|
<field name="icon">fa-pause-circle</field>
|
||||||
|
<field name="area_kind">receiving</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
@@ -955,5 +995,8 @@
|
|||||||
|
|
||||||
<!-- gating: intentionally no default inputs -->
|
<!-- gating: intentionally no default inputs -->
|
||||||
|
|
||||||
|
<!-- blast: intentionally no default inputs (operator picks
|
||||||
|
approach by step name + recipe instructions) -->
|
||||||
|
|
||||||
</data>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -103,4 +103,30 @@
|
|||||||
]]></field>
|
]]></field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- 2026-05-24 additions (19.0.21.2.0 — Shop Floor live-step fix) -->
|
||||||
|
|
||||||
|
<record id="fp_step_template_hwp_a15" model="fp.step.template">
|
||||||
|
<field name="name">Hot Water Porosity Test (A-15)</field>
|
||||||
|
<field name="code">HWP_A15</field>
|
||||||
|
<field name="kind_id" ref="step_kind_inspect"/>
|
||||||
|
<field name="icon">fa-tint</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Hot-water porosity test for plated samples. Verify continuity
|
||||||
|
of the deposit across the test panel; record any porosity sites
|
||||||
|
and attach a photo when a defect is found.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_final_pkg_std" model="fp.step.template">
|
||||||
|
<field name="name">Final Inspection / Packaging</field>
|
||||||
|
<field name="code">FINAL_PKG_STD</field>
|
||||||
|
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||||
|
<field name="icon">fa-check-circle</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Combined final visual + dimensional inspection followed by
|
||||||
|
packaging into the customer's original boxes for shipment.
|
||||||
|
Verify part count, attach certs, photo the sealed load.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
#
|
||||||
|
# 19.0.21.0.0 — Plant-view Shop Floor kanban redesign.
|
||||||
|
# Backfill fp.work.centre.area_kind from the existing `kind` taxonomy so
|
||||||
|
# every routing station has a defined Floor Column on day 1. Admins can
|
||||||
|
# override afterwards via Configuration → Shop Setup → Routing Stations.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
"""Backfill area_kind on existing fp.work.centre rows.
|
||||||
|
|
||||||
|
Mapping is intentionally permissive: every existing kind maps to a
|
||||||
|
sensible default. Unmapped (e.g. 'other') falls to 'plating' as the
|
||||||
|
safe wet-shop catch-all and is logged for review.
|
||||||
|
"""
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_work_centre
|
||||||
|
SET area_kind = CASE kind
|
||||||
|
WHEN 'wet_line' THEN 'plating'
|
||||||
|
WHEN 'bake' THEN 'baking'
|
||||||
|
WHEN 'mask' THEN 'masking'
|
||||||
|
WHEN 'rack' THEN 'racking'
|
||||||
|
WHEN 'inspect' THEN 'inspection'
|
||||||
|
ELSE 'plating'
|
||||||
|
END
|
||||||
|
WHERE area_kind IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Log any rows that landed on the catch-all so the admin can review.
|
||||||
|
cr.execute("""
|
||||||
|
SELECT id, name, code, kind
|
||||||
|
FROM fp_work_centre
|
||||||
|
WHERE area_kind = 'plating'
|
||||||
|
AND kind = 'other'
|
||||||
|
""")
|
||||||
|
rows = cr.fetchall()
|
||||||
|
if rows:
|
||||||
|
_logger.warning(
|
||||||
|
"%d fp.work.centre rows had kind='other' and were defaulted "
|
||||||
|
"to area_kind='plating'; review and adjust if needed: %s",
|
||||||
|
len(rows),
|
||||||
|
', '.join(
|
||||||
|
'%s (id=%s, code=%s)' % (r[1], r[0], r[2])
|
||||||
|
for r in rows[:10]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_logger.info("Backfilled area_kind on fp.work.centre")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Phase H: fire role-migration preview creation on `-u fusion_plating`.
|
||||||
|
|
||||||
|
Odoo 19's `post_init_hook` ONLY fires on fresh install — never on
|
||||||
|
upgrade. So on entech (and any other already-installed deployment),
|
||||||
|
`-u fusion_plating` after this branch lands would otherwise leave the
|
||||||
|
post_init_hook's `_fp_post_init_role_migration` un-fired and the
|
||||||
|
migration preview never created.
|
||||||
|
|
||||||
|
This migration script bridges that gap: on every `-u` that crosses
|
||||||
|
this version boundary, it invokes the same idempotent helper. The
|
||||||
|
helper short-circuits if a preview is already pending or already
|
||||||
|
applied + all users migrated, so re-running is safe.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
from odoo import api, SUPERUSER_ID
|
||||||
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_plating import _fp_post_init_role_migration
|
||||||
|
_fp_post_init_role_migration(env)
|
||||||
|
_logger.info(
|
||||||
|
'Fusion Plating: role-migration preview check ran via post-migrate.py'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Migration scripts must not block module upgrade — log and swallow
|
||||||
|
_logger.exception(
|
||||||
|
'Failed to run role-migration preview check (non-fatal): %s', e
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Phase A bootstrap: rename old configurator's Shop Manager before new
|
||||||
|
core group_fp_shop_manager_v2 tries to claim the 'Shop Manager' display name.
|
||||||
|
|
||||||
|
Load order:
|
||||||
|
1. fusion_plating loads -> fp_security.xml renames its own old groups (Operator,
|
||||||
|
Supervisor, Manager, Administrator) to '[DEPRECATED] X'. Then fp_security_v2.xml
|
||||||
|
creates new groups (Technician, ..., Shop Manager v2 with display name 'Shop Manager').
|
||||||
|
2. fusion_plating_configurator loads later -> would rename its own
|
||||||
|
group_fp_shop_manager to '[DEPRECATED] Shop Manager'.
|
||||||
|
|
||||||
|
But step 1 crashes because the OLD configurator's group is still named just
|
||||||
|
'Shop Manager' in the DB (the rename in step 2 hasn't run yet), and the unique
|
||||||
|
constraint res_groups_name_uniq blocks the new 'Shop Manager'.
|
||||||
|
|
||||||
|
This pre-migrate script runs BEFORE any of fusion_plating's data files reload,
|
||||||
|
patching the old configurator row's display name via SQL. After that, the
|
||||||
|
constraint is clear and fp_security_v2.xml can create its new groups safely.
|
||||||
|
The configurator's later -u will then push the canonical '[DEPRECATED] Shop
|
||||||
|
Manager' display name from its XML data.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
# Find old configurator Shop Manager row via ir.model.data and rename
|
||||||
|
# its display name to avoid the constraint collision.
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE res_groups
|
||||||
|
SET name = jsonb_build_object('en_US', '[DEPRECATED] Shop Manager (Mgr+Estimator bundle)')
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT res_id FROM ir_model_data
|
||||||
|
WHERE module = 'fusion_plating_configurator'
|
||||||
|
AND name = 'group_fp_shop_manager'
|
||||||
|
AND model = 'res.groups'
|
||||||
|
)
|
||||||
|
AND (name IS NULL OR name->>'en_US' NOT LIKE '[DEPRECATED]%');
|
||||||
|
""")
|
||||||
|
rows = cr.rowcount
|
||||||
|
if rows:
|
||||||
|
_logger.info(
|
||||||
|
'Fusion Plating: pre-migrate renamed %d old configurator Shop Manager '
|
||||||
|
'row(s) to clear name collision with new group_fp_shop_manager_v2',
|
||||||
|
rows,
|
||||||
|
)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""19.0.21.2.0 — Shop Floor live-step + kind taxonomy.
|
||||||
|
|
||||||
|
Seeds fp.step.kind.area_kind on existing kinds BEFORE the required
|
||||||
|
NOT NULL constraint on the new field hits the schema. Also activates
|
||||||
|
the three kinds (derack/demask/gating) that were deactivated in
|
||||||
|
19.0.20.6.0 but are needed for the full area_kind taxonomy.
|
||||||
|
|
||||||
|
Idempotent: only fills NULL / inactive rows.
|
||||||
|
|
||||||
|
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KIND_TO_AREA = {
|
||||||
|
'other': 'plating',
|
||||||
|
'wet_process': 'plating',
|
||||||
|
'receiving': 'receiving',
|
||||||
|
'contract_review': 'receiving',
|
||||||
|
'gating': 'receiving',
|
||||||
|
'racking': 'racking',
|
||||||
|
'derack': 'de_racking',
|
||||||
|
'mask': 'masking',
|
||||||
|
'demask': 'de_racking',
|
||||||
|
'cleaning': 'plating',
|
||||||
|
'electroclean': 'plating',
|
||||||
|
'etch': 'plating',
|
||||||
|
'rinse': 'plating',
|
||||||
|
'strike': 'plating',
|
||||||
|
'plate': 'plating',
|
||||||
|
'replenishment': 'plating',
|
||||||
|
'wbf_test': 'plating',
|
||||||
|
'dry': 'plating',
|
||||||
|
'bake': 'baking',
|
||||||
|
'inspect': 'inspection',
|
||||||
|
'final_inspect': 'inspection',
|
||||||
|
'hardness_test': 'inspection',
|
||||||
|
'adhesion_test': 'inspection',
|
||||||
|
'salt_spray': 'inspection',
|
||||||
|
'packaging': 'shipping',
|
||||||
|
'ship': 'shipping',
|
||||||
|
'blast': 'blasting',
|
||||||
|
'bead_blast': 'blasting',
|
||||||
|
'media_blast': 'blasting',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
# Phase 1 — Pre-create the column NULL-permitting so we can seed it
|
||||||
|
# BEFORE Odoo's schema sync tries to enforce NOT NULL.
|
||||||
|
cr.execute(
|
||||||
|
"ALTER TABLE fp_step_kind "
|
||||||
|
"ADD COLUMN IF NOT EXISTS area_kind VARCHAR"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 2 — Seed area_kind on existing kinds. Idempotent: only fills
|
||||||
|
# NULLs, so re-running -u is safe.
|
||||||
|
seeded = 0
|
||||||
|
for code, area in KIND_TO_AREA.items():
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE fp_step_kind SET area_kind = %s "
|
||||||
|
"WHERE code = %s "
|
||||||
|
"AND (area_kind IS NULL OR area_kind = '')",
|
||||||
|
(area, code),
|
||||||
|
)
|
||||||
|
seeded += cr.rowcount
|
||||||
|
_logger.info(
|
||||||
|
'[live-step-fix] kind.area_kind seeded on %s known-code rows',
|
||||||
|
seeded,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 3 — Fallback: any user-created custom kinds not in our seed
|
||||||
|
# map → 'plating'. Clears the NOT NULL constraint for any leftover.
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE fp_step_kind SET area_kind = 'plating' "
|
||||||
|
"WHERE area_kind IS NULL OR area_kind = ''"
|
||||||
|
)
|
||||||
|
if cr.rowcount:
|
||||||
|
_logger.info(
|
||||||
|
'[live-step-fix] %s unknown kinds defaulted to plating',
|
||||||
|
cr.rowcount,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 4 — Activate kinds we need for full coverage.
|
||||||
|
activated = 0
|
||||||
|
for code in ('derack', 'demask', 'gating'):
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE fp_step_kind SET active = TRUE "
|
||||||
|
"WHERE code = %s AND active = FALSE",
|
||||||
|
(code,),
|
||||||
|
)
|
||||||
|
activated += cr.rowcount
|
||||||
|
_logger.info(
|
||||||
|
'[live-step-fix] %s kinds activated (derack/demask/gating)',
|
||||||
|
activated,
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""19.0.21.4.0 — Apply office-user menu visibility on -u.
|
||||||
|
|
||||||
|
post_init_hook only fires on FIRST install (CLAUDE.md Rule 13d).
|
||||||
|
This script runs the same helper on every -u so existing installs
|
||||||
|
get the menu restrictions applied without needing to uninstall +
|
||||||
|
reinstall. Idempotent — the helper checks current state and skips
|
||||||
|
already-restricted menus.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo.api import Environment, SUPERUSER_ID
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
from odoo.addons.fusion_plating import _fp_apply_office_user_menu_visibility
|
||||||
|
env = Environment(cr, SUPERUSER_ID, {})
|
||||||
|
_fp_apply_office_user_menu_visibility(env)
|
||||||
@@ -25,6 +25,7 @@ from . import fp_job_step_timelog
|
|||||||
from . import fp_operator_certification
|
from . import fp_operator_certification
|
||||||
from . import fp_tz
|
from . import fp_tz
|
||||||
from . import res_company
|
from . import res_company
|
||||||
|
from . import res_users
|
||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
|
|
||||||
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp via
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp via
|
||||||
@@ -48,3 +49,9 @@ from . import fp_job_step_move
|
|||||||
|
|
||||||
# Phase 1 — Plating landing-page resolver
|
# Phase 1 — Plating landing-page resolver
|
||||||
from . import fp_landing
|
from . import fp_landing
|
||||||
|
|
||||||
|
# Phase H — dry-run + Owner-approval role migration workflow.
|
||||||
|
# fp_role_constants MUST be imported before fp_migration (the latter
|
||||||
|
# imports the predicate chain + xmlid maps from the former).
|
||||||
|
from . import fp_role_constants
|
||||||
|
from . import fp_migration
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
# 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 odoo import api, fields, models
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class FpJobStepMove(models.Model):
|
class FpJobStepMove(models.Model):
|
||||||
@@ -74,6 +77,113 @@ class FpJobStepMove(models.Model):
|
|||||||
string='Transition Input Values',
|
string='Transition Input Values',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Stamp last_activity_at on from_step + to_step so the plant-view
|
||||||
|
idle gate (S16) sees moves as activity. Without this, a step that
|
||||||
|
only ever gets moves (no chatter, no state edits) eventually
|
||||||
|
trips the 8-hour idle warning falsely.
|
||||||
|
"""
|
||||||
|
moves = super().create(vals_list)
|
||||||
|
Step = self.env['fp.job.step']
|
||||||
|
step_ids = set()
|
||||||
|
for m in moves:
|
||||||
|
if m.from_step_id:
|
||||||
|
step_ids.add(m.from_step_id.id)
|
||||||
|
if m.to_step_id:
|
||||||
|
step_ids.add(m.to_step_id.id)
|
||||||
|
if step_ids:
|
||||||
|
Step.browse(list(step_ids)).sudo().with_context(
|
||||||
|
tracking_disable=True,
|
||||||
|
).write({'last_activity_at': fields.Datetime.now()})
|
||||||
|
return moves
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# S23 — required transition-input gate
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# When the destination step has requires_transition_form=True, the
|
||||||
|
# recipe author wants chain-of-custody attestations captured on the
|
||||||
|
# move (location, photo, customer WO #, etc.). Same dormant-field
|
||||||
|
# shape as S22's signoff bug — the field existed but nothing enforced
|
||||||
|
# it. Callers (tablet controllers, future backend wizards) MUST call
|
||||||
|
# _fp_check_transition_inputs_complete() after writing values to
|
||||||
|
# transition_input_value_ids.
|
||||||
|
#
|
||||||
|
# We can't gate on create() because values are written in a separate
|
||||||
|
# call after the move row. Model-level enforcement would require
|
||||||
|
# either a deferred-commit pattern or a write hook; explicit caller
|
||||||
|
# invocation is the simplest contract.
|
||||||
|
|
||||||
|
def _fp_missing_required_transition_inputs(self):
|
||||||
|
"""Return the recordset of required transition_input prompts on
|
||||||
|
the to_step's recipe node that have NO captured value on this
|
||||||
|
move. Centralised helper — used by the gate below and by future
|
||||||
|
diagnostics."""
|
||||||
|
self.ensure_one()
|
||||||
|
Prompt = self.env['fusion.plating.process.node.input']
|
||||||
|
to_step = self.to_step_id
|
||||||
|
if not to_step or not to_step.recipe_node_id:
|
||||||
|
return Prompt
|
||||||
|
if not to_step.requires_transition_form:
|
||||||
|
return Prompt
|
||||||
|
prompts = to_step.recipe_node_id.input_ids
|
||||||
|
if 'kind' in prompts._fields:
|
||||||
|
prompts = prompts.filtered(
|
||||||
|
lambda i: i.kind == 'transition_input')
|
||||||
|
if 'collect' in prompts._fields:
|
||||||
|
prompts = prompts.filtered(lambda i: i.collect)
|
||||||
|
required_prompts = prompts.filtered(lambda i: i.required)
|
||||||
|
if not required_prompts:
|
||||||
|
return Prompt
|
||||||
|
recorded_input_ids = set(
|
||||||
|
self.transition_input_value_ids.mapped('node_input_id.id')
|
||||||
|
)
|
||||||
|
return required_prompts.filtered(
|
||||||
|
lambda p: p.id not in recorded_input_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fp_check_transition_inputs_complete(self):
|
||||||
|
"""Raise UserError when the destination step has
|
||||||
|
requires_transition_form=True and required transition_input
|
||||||
|
prompts haven't been recorded on this move. Audit gate — same
|
||||||
|
shape as fp.job.step._fp_check_step_inputs_complete (S21) and
|
||||||
|
._fp_check_signoff_complete (S22).
|
||||||
|
|
||||||
|
Manager bypass via context fp_skip_transition_form=True
|
||||||
|
(consistent with the existing audit-trail flag on the tablet
|
||||||
|
controllers). Bypasses are posted to chatter on the move
|
||||||
|
record naming the user.
|
||||||
|
"""
|
||||||
|
if self.env.context.get('fp_skip_transition_form'):
|
||||||
|
for move in self:
|
||||||
|
if not move.to_step_id.requires_transition_form:
|
||||||
|
continue
|
||||||
|
move.message_post(body=Markup(_(
|
||||||
|
'Transition-form gate bypassed by %s. '
|
||||||
|
'Documented deviation — required prompts not '
|
||||||
|
'recorded on this move.'
|
||||||
|
)) % self.env.user.name)
|
||||||
|
return
|
||||||
|
for move in self:
|
||||||
|
missing = move._fp_missing_required_transition_inputs()
|
||||||
|
if not missing:
|
||||||
|
continue
|
||||||
|
names = ', '.join(
|
||||||
|
'"%s"' % (p.name or '').strip() for p in missing
|
||||||
|
)
|
||||||
|
raise UserError(_(
|
||||||
|
'Move to step "%(step)s" cannot be committed — '
|
||||||
|
'%(n)s required transition prompt(s) not recorded: '
|
||||||
|
'%(names)s. Fill them in the Move dialog before '
|
||||||
|
'committing. Managers can override via context flag '
|
||||||
|
'fp_skip_transition_form=True for documented '
|
||||||
|
'deviations.'
|
||||||
|
) % {
|
||||||
|
'step': move.to_step_id.name,
|
||||||
|
'n': len(missing),
|
||||||
|
'names': names,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class FpJobStepMoveInputValue(models.Model):
|
class FpJobStepMoveInputValue(models.Model):
|
||||||
"""Captured value for one transition-input prompt.
|
"""Captured value for one transition-input prompt.
|
||||||
|
|||||||
@@ -2,45 +2,213 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# 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.
|
||||||
"""Phase 1 — Plating landing-page resolver fields.
|
"""Phase 1 + Phase E — Plating landing-page resolver.
|
||||||
|
|
||||||
Three pieces:
|
Layers:
|
||||||
1. `ir.actions.act_window.x_fc_pickable_landing` — Boolean tag. Mark a
|
|
||||||
curated set of plating actions (Sale Orders, Plant Overview,
|
|
||||||
Quotations, Quality Dashboard, Manager Dashboard, Tablet Station,
|
|
||||||
Labor History) so the landing-page dropdown only offers sensible
|
|
||||||
options, not all 200 act_window records in the DB.
|
|
||||||
|
|
||||||
2. `res.company.x_fc_default_landing_action_id` — admin sets the
|
1. ``ir.actions.act_window.x_fc_pickable_landing`` AND
|
||||||
fallback for users who don't pick a preference.
|
``ir.actions.client.x_fc_pickable_landing`` — Boolean tag on BOTH
|
||||||
|
action types. Mark a curated set of plating actions (Sale Orders,
|
||||||
|
Quotations, Manager Desk, Plant Kanban, Quality Dashboard, etc.) so
|
||||||
|
the landing-page dropdown only offers sensible options, not all 200+
|
||||||
|
action records in the DB.
|
||||||
|
|
||||||
3. `res.users.x_fc_plating_landing_action_id` — each user's own
|
2. ``res.company.x_fc_default_landing_action_id`` — admin sets the
|
||||||
override.
|
fallback for users who don't pick a preference. References
|
||||||
|
``ir.actions.act_window`` (only act_window actions can be selected
|
||||||
|
as the company default since they're navigable from the menu tree).
|
||||||
|
|
||||||
The resolver server action (data/fp_landing_data.xml) reads these.
|
3. ``res.users.x_fc_plating_landing_action_id`` — each user's own
|
||||||
|
override. References ``ir.actions.act_window`` and is filtered by
|
||||||
|
the user's actually-accessible actions (Technician can't pick
|
||||||
|
"Manager Desk" if they can't see it).
|
||||||
|
|
||||||
|
4. ``ir.actions.act_window._fp_resolve_landing_for_current_user()`` —
|
||||||
|
role-based dispatch resolver. Section 3 of the permissions design
|
||||||
|
spec. Returns an action dict suitable for the
|
||||||
|
``action_fp_resolve_plating_landing`` server action.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class IrActionsActWindow(models.Model):
|
# ----------------------------------------------------------------------
|
||||||
_inherit = 'ir.actions.act_window'
|
# Pickable-landing tag on BOTH action types
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# The picklist needs to cover client actions (Manager Desk, Plant
|
||||||
|
# Kanban, Quality Dashboard) too, so we add the same Boolean column
|
||||||
|
# to ir.actions.client. The resolver returns either kind of action;
|
||||||
|
# the role dispatch helper uses env.ref(...) which is type-agnostic.
|
||||||
|
class IrActionsActions(models.Model):
|
||||||
|
"""Base ir.actions.actions extension so x_fc_pickable_landing is
|
||||||
|
available on BOTH ir.actions.act_window (Sale Orders, Quotations,
|
||||||
|
Process Recipes) AND ir.actions.client (Manager Desk, Plant Kanban,
|
||||||
|
Workstation, Quality Dashboard). The picker on res.users / res.company
|
||||||
|
is Many2one('ir.actions.actions') so it accepts either kind.
|
||||||
|
"""
|
||||||
|
_inherit = 'ir.actions.actions'
|
||||||
|
|
||||||
x_fc_pickable_landing = fields.Boolean(
|
x_fc_pickable_landing = fields.Boolean(
|
||||||
string='Pickable as Plating Landing',
|
string='Pickable as Plating Landing',
|
||||||
default=False,
|
default=False,
|
||||||
help='When True, this action appears in the Plating landing-'
|
help='When True, this action appears in the Plating landing-'
|
||||||
'page dropdown on res.users and res.company. Tag a small '
|
'page dropdown on res.users and res.company. Tag a small '
|
||||||
'curated list (Sale Orders, Plant Overview, etc.) to keep '
|
'curated list (Sale Orders, Manager Desk, etc.) to keep '
|
||||||
'the picker manageable.',
|
'the picker manageable.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _render_resolved(self):
|
||||||
|
"""Dispatcher — render this action as a dict for the landing resolver.
|
||||||
|
Routes to the correct subclass based on `type` so both act_window
|
||||||
|
and client actions resolve correctly."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.type == 'ir.actions.client':
|
||||||
|
return self.env['ir.actions.client'].browse(self.id)._render_resolved()
|
||||||
|
if self.type == 'ir.actions.act_window':
|
||||||
|
return self.env['ir.actions.act_window'].browse(self.id)._render_resolved()
|
||||||
|
# URL / server / report — generic dict
|
||||||
|
action = self.sudo().read()[0]
|
||||||
|
action.pop('id', None)
|
||||||
|
action['xml_id'] = self.get_external_id().get(self.id) or None
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
class IrActionsActWindow(models.Model):
|
||||||
|
_inherit = 'ir.actions.act_window'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Resolver — role-based dispatch (Phase E)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _fp_resolve_landing_for_current_user(self):
|
||||||
|
"""Resolve which action to open when the current user clicks the
|
||||||
|
Plating app.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. Per-user override (``res.users.x_fc_plating_landing_action_id``)
|
||||||
|
2. Role-based default (``_fp_role_default_landing``)
|
||||||
|
3. Company default (``res.company.x_fc_default_landing_action_id``)
|
||||||
|
4. Hardcoded last-ditch (Sale Orders)
|
||||||
|
"""
|
||||||
|
user = self.env.user
|
||||||
|
company = self.env.company
|
||||||
|
|
||||||
|
# 1. Per-user override
|
||||||
|
if 'x_fc_plating_landing_action_id' in user._fields \
|
||||||
|
and user.x_fc_plating_landing_action_id:
|
||||||
|
return user.x_fc_plating_landing_action_id._render_resolved()
|
||||||
|
|
||||||
|
# 2. Role-based default
|
||||||
|
role_action = self._fp_role_default_landing(user, company)
|
||||||
|
if role_action:
|
||||||
|
return role_action._render_resolved()
|
||||||
|
|
||||||
|
# 3. Company default
|
||||||
|
if 'x_fc_default_landing_action_id' in company._fields \
|
||||||
|
and company.x_fc_default_landing_action_id:
|
||||||
|
return company.x_fc_default_landing_action_id._render_resolved()
|
||||||
|
|
||||||
|
# 4. Hardcoded last-ditch — Sale Orders
|
||||||
|
fallback = self.env.ref(
|
||||||
|
'fusion_plating_configurator.action_fp_sale_orders',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if fallback:
|
||||||
|
return fallback._render_resolved()
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fp_role_default_landing(self, user, company):
|
||||||
|
"""Return the per-role default action (recordset, act_window OR
|
||||||
|
ir.actions.client) for ``user``, or False.
|
||||||
|
|
||||||
|
Precedence is highest role first so a multi-role user
|
||||||
|
(Manager promoted to QM) gets the upper role's landing.
|
||||||
|
"""
|
||||||
|
workstation = self._fp_workstation_action_for_layout(company)
|
||||||
|
|
||||||
|
def safe(xmlid):
|
||||||
|
return self.env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
|
||||||
|
if user.has_group('fusion_plating.group_fp_owner'):
|
||||||
|
return safe('fusion_plating_shopfloor.action_fp_manager_dashboard')
|
||||||
|
if user.has_group('fusion_plating.group_fp_quality_manager'):
|
||||||
|
return safe('fusion_plating_quality.action_fp_quality_dashboard')
|
||||||
|
if user.has_group('fusion_plating.group_fp_manager'):
|
||||||
|
return safe('fusion_plating_shopfloor.action_fp_manager_dashboard')
|
||||||
|
if user.has_group('fusion_plating.group_fp_sales_manager'):
|
||||||
|
return safe('fusion_plating_configurator.action_fp_sale_orders')
|
||||||
|
if user.has_group('fusion_plating.group_fp_shop_manager_v2'):
|
||||||
|
return workstation
|
||||||
|
if user.has_group('fusion_plating.group_fp_sales_rep'):
|
||||||
|
return safe('fusion_plating_configurator.action_fp_quotations')
|
||||||
|
if user.has_group('fusion_plating.group_fp_technician'):
|
||||||
|
return workstation
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fp_workstation_action_for_layout(self, company):
|
||||||
|
"""Resolve the Shop Floor surface for technicians + shop managers.
|
||||||
|
|
||||||
|
Returns ``action_fp_plant_kanban`` (the 2026-05-23 plant view).
|
||||||
|
The legacy ``fp_shopfloor_landing`` component was retired
|
||||||
|
2026-05-25 (one feature ported across — the inline QR scanner).
|
||||||
|
The ``fusion_plating_shopfloor.layout`` ir.config_parameter
|
||||||
|
survives orphaned for one release cycle so we can ship a
|
||||||
|
settings-UI cleanup separately; flipping it has no effect.
|
||||||
|
"""
|
||||||
|
return self.env.ref(
|
||||||
|
'fusion_plating_shopfloor.action_fp_plant_kanban',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_resolved(self):
|
||||||
|
"""Render this act_window record as an action dict that the
|
||||||
|
landing server action can return.
|
||||||
|
|
||||||
|
Mirrors ``self.sudo().read()[0]`` shape, plus injects ``xml_id``
|
||||||
|
so the resolver / tests / breadcrumbs know which curated action
|
||||||
|
this is. Strips ``id`` because the act_window dispatcher chokes
|
||||||
|
on it for fresh-load actions.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
action = self.sudo().read()[0]
|
||||||
|
action.pop('id', None)
|
||||||
|
action['xml_id'] = self.get_external_id().get(self.id) or None
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
class IrActionsClient(models.Model):
|
||||||
|
"""Client actions also need to be tagged as pickable landings —
|
||||||
|
Manager Desk, Plant Kanban, Quality Dashboard are all client
|
||||||
|
actions, not act_window records.
|
||||||
|
|
||||||
|
``_render_resolved`` is defined on this class too so the resolver
|
||||||
|
can polymorphically call ``action._render_resolved()`` regardless
|
||||||
|
of which kind of action came back from env.ref().
|
||||||
|
"""
|
||||||
|
_inherit = 'ir.actions.client'
|
||||||
|
|
||||||
|
# x_fc_pickable_landing moved to ir.actions.actions base — see IrActionsActions
|
||||||
|
# above. This subclass keeps _render_resolved for the dispatcher to call.
|
||||||
|
|
||||||
|
def _render_resolved(self):
|
||||||
|
"""Render this client action as a dict for the landing resolver."""
|
||||||
|
self.ensure_one()
|
||||||
|
action = self.sudo().read()[0]
|
||||||
|
action.pop('id', None)
|
||||||
|
action['xml_id'] = self.get_external_id().get(self.id) or None
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Company + User landing-action preference fields
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
class ResCompany(models.Model):
|
class ResCompany(models.Model):
|
||||||
_inherit = 'res.company'
|
_inherit = 'res.company'
|
||||||
|
|
||||||
x_fc_default_landing_action_id = fields.Many2one(
|
x_fc_default_landing_action_id = fields.Many2one(
|
||||||
'ir.actions.act_window',
|
'ir.actions.actions',
|
||||||
string='Default Plating Landing Page',
|
string='Default Plating Landing Page',
|
||||||
domain=[('x_fc_pickable_landing', '=', True)],
|
domain=[('x_fc_pickable_landing', '=', True)],
|
||||||
help='Page that opens when a user clicks the Plating app, '
|
help='Page that opens when a user clicks the Plating app, '
|
||||||
@@ -53,9 +221,18 @@ class ResUsers(models.Model):
|
|||||||
_inherit = 'res.users'
|
_inherit = 'res.users'
|
||||||
|
|
||||||
x_fc_plating_landing_action_id = fields.Many2one(
|
x_fc_plating_landing_action_id = fields.Many2one(
|
||||||
'ir.actions.act_window',
|
'ir.actions.actions',
|
||||||
string='My Plating Landing Page',
|
string='My Plating Landing Page',
|
||||||
|
# Picker shows ALL pickable landing actions. Per-user accessibility
|
||||||
|
# filtering was attempted via a Many2many compute but failed for
|
||||||
|
# non-admin users because the field assignment requires read on
|
||||||
|
# ir.actions.actions. Easier path: show all 6 pickable actions to
|
||||||
|
# everyone, let the resolver fall through gracefully if the user
|
||||||
|
# picks an action they can't reach (role-based default takes over).
|
||||||
|
# Read access on ir.actions.actions for plating roles is granted
|
||||||
|
# via a fusion_plating ACL row (security/ir.model.access.csv).
|
||||||
domain=[('x_fc_pickable_landing', '=', True)],
|
domain=[('x_fc_pickable_landing', '=', True)],
|
||||||
help='Personal override for the page that opens when you click '
|
help='Personal override for the page that opens when you click '
|
||||||
'the Plating app. When blank, follows the company default.',
|
'the Plating app. When blank, follows the company default '
|
||||||
|
'and then the role-based default per Section 3 of the spec.',
|
||||||
)
|
)
|
||||||
|
|||||||
265
fusion_plating/fusion_plating/models/fp_migration.py
Normal file
265
fusion_plating/fusion_plating/models/fp_migration.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Phase H — dry-run + Owner-approval migration workflow."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from .fp_role_constants import (
|
||||||
|
_FP_OLD_GROUP_XMLIDS,
|
||||||
|
_NEW_ROLE_XMLID,
|
||||||
|
fp_resolve_target_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_ROLE_SELECTION = [
|
||||||
|
('no', 'No'),
|
||||||
|
('technician', 'Technician'),
|
||||||
|
('sales_rep', 'Sales Representative'),
|
||||||
|
('shop_manager', 'Shop Manager'),
|
||||||
|
('sales_manager', 'Sales Manager'),
|
||||||
|
('manager', 'Manager'),
|
||||||
|
('quality_manager', 'Quality Manager'),
|
||||||
|
('owner', 'Owner'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FpMigrationPreview(models.Model):
|
||||||
|
_name = 'fp.migration.preview'
|
||||||
|
_description = 'Fusion Plating Role Migration Preview'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
default=lambda s: _('Migration %s') % fields.Datetime.now(),
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
state = fields.Selection(
|
||||||
|
[
|
||||||
|
('pending', 'Pending Review'),
|
||||||
|
('approved', 'Approved & Applied'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
('rolled_back', 'Rolled Back'),
|
||||||
|
],
|
||||||
|
default='pending',
|
||||||
|
required=True,
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
line_ids = fields.One2many('fp.migration.preview.line', 'preview_id')
|
||||||
|
user_count = fields.Integer(compute='_compute_counts', store=True)
|
||||||
|
warning_count = fields.Integer(compute='_compute_counts', store=True)
|
||||||
|
approved_by_id = fields.Many2one('res.users', readonly=True)
|
||||||
|
approved_at = fields.Datetime(readonly=True)
|
||||||
|
rollback_deadline = fields.Datetime(compute='_compute_rollback_deadline')
|
||||||
|
|
||||||
|
@api.depends('line_ids', 'line_ids.warning')
|
||||||
|
def _compute_counts(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.user_count = len(rec.line_ids)
|
||||||
|
rec.warning_count = sum(1 for ln in rec.line_ids if ln.warning)
|
||||||
|
|
||||||
|
@api.depends('approved_at')
|
||||||
|
def _compute_rollback_deadline(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.rollback_deadline = (
|
||||||
|
rec.approved_at + timedelta(days=30) if rec.approved_at else False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fp_build_lines(self):
|
||||||
|
"""Walk all active internal users; one line per user with the
|
||||||
|
proposed role + capability_delta."""
|
||||||
|
self.ensure_one()
|
||||||
|
Line = self.env['fp.migration.preview.line']
|
||||||
|
users = self.env['res.users'].search([
|
||||||
|
('share', '=', False),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
vals_list = []
|
||||||
|
for user in users:
|
||||||
|
role, delta = fp_resolve_target_role(user)
|
||||||
|
vals_list.append({
|
||||||
|
'preview_id': self.id,
|
||||||
|
'user_id': user.id,
|
||||||
|
'proposed_role': role,
|
||||||
|
'capability_delta': delta or '',
|
||||||
|
'warning': bool(delta),
|
||||||
|
})
|
||||||
|
if vals_list:
|
||||||
|
Line.create(vals_list)
|
||||||
|
|
||||||
|
def _fp_notify_owners(self):
|
||||||
|
"""Schedule a 'Review Fusion Plating role migration' activity on
|
||||||
|
every Owner user. Idempotent — won't double-schedule."""
|
||||||
|
self.ensure_one()
|
||||||
|
owner_grp = self.env.ref('fusion_plating.group_fp_owner', raise_if_not_found=False)
|
||||||
|
if not owner_grp:
|
||||||
|
return
|
||||||
|
owners = owner_grp.user_ids.filtered(lambda u: u.active and not u.share)
|
||||||
|
if not owners:
|
||||||
|
_logger.warning('Fusion Plating migration preview %s: no Owner users to notify', self.id)
|
||||||
|
return
|
||||||
|
activity_type = self.env.ref('mail.mail_activity_data_todo')
|
||||||
|
for owner in owners:
|
||||||
|
existing = self.env['mail.activity'].search([
|
||||||
|
('res_model_id', '=', self.env.ref('fusion_plating.model_fp_migration_preview').id),
|
||||||
|
('res_id', '=', self.id),
|
||||||
|
('user_id', '=', owner.id),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
self.env['mail.activity'].create({
|
||||||
|
'res_model_id': self.env.ref('fusion_plating.model_fp_migration_preview').id,
|
||||||
|
'res_id': self.id,
|
||||||
|
'activity_type_id': activity_type.id,
|
||||||
|
'summary': _('Review Fusion Plating role migration'),
|
||||||
|
'note': _('%(n)d users affected, %(w)d with capability changes.') % {
|
||||||
|
'n': self.user_count,
|
||||||
|
'w': self.warning_count,
|
||||||
|
},
|
||||||
|
'user_id': owner.id,
|
||||||
|
'date_deadline': fields.Date.today(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_approve_and_run(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.env.user.has_group('fusion_plating.group_fp_owner'):
|
||||||
|
raise UserError(_('Only Owners can approve role migrations.'))
|
||||||
|
if self.state != 'pending':
|
||||||
|
raise UserError(_(
|
||||||
|
'Migration is no longer pending - current state: %s'
|
||||||
|
) % self.state)
|
||||||
|
|
||||||
|
# Resolve old group ids once
|
||||||
|
old_group_ids = []
|
||||||
|
for xmlid in _FP_OLD_GROUP_XMLIDS:
|
||||||
|
g = self.env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if g:
|
||||||
|
old_group_ids.append(g.id)
|
||||||
|
|
||||||
|
for line in self.line_ids:
|
||||||
|
user = line.user_id
|
||||||
|
# Snapshot current group_ids for rollback
|
||||||
|
line.applied_groups_snapshot = json.dumps(user.group_ids.ids)
|
||||||
|
|
||||||
|
# Remove old plating-role groups
|
||||||
|
if old_group_ids:
|
||||||
|
user.sudo().write({
|
||||||
|
'group_ids': [(3, gid) for gid in old_group_ids]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add the new role group (no-op for 'no')
|
||||||
|
target_xmlid = _NEW_ROLE_XMLID.get(line.proposed_role)
|
||||||
|
if target_xmlid:
|
||||||
|
target = self.env.ref(target_xmlid, raise_if_not_found=False)
|
||||||
|
if target:
|
||||||
|
user.sudo().write({'group_ids': [(4, target.id)]})
|
||||||
|
|
||||||
|
# Audit chatter on the user
|
||||||
|
user.partner_id.message_post(
|
||||||
|
body=Markup(_(
|
||||||
|
'Plating role assigned by migration: <b>%s</b>'
|
||||||
|
)) % line.proposed_role,
|
||||||
|
message_type='notification',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Special: CGP DO becomes a res.company field, not a role
|
||||||
|
if line.capability_delta and 'CGP DO' in line.capability_delta:
|
||||||
|
user.company_id.x_fc_cgp_designated_official_id = user.id
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'approved',
|
||||||
|
'approved_by_id': self.env.user.id,
|
||||||
|
'approved_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'pending':
|
||||||
|
raise UserError(_('Only pending migrations can be cancelled.'))
|
||||||
|
self.state = 'cancelled'
|
||||||
|
|
||||||
|
def action_rollback(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'approved':
|
||||||
|
raise UserError(_('Only approved migrations can be rolled back.'))
|
||||||
|
if self.rollback_deadline and fields.Datetime.now() > self.rollback_deadline:
|
||||||
|
raise UserError(_(
|
||||||
|
'Rollback window has expired (30 days after approval). '
|
||||||
|
'Restore from pg_dump backup instead.'
|
||||||
|
))
|
||||||
|
for line in self.line_ids:
|
||||||
|
if line.applied_groups_snapshot:
|
||||||
|
old_ids = json.loads(line.applied_groups_snapshot)
|
||||||
|
line.user_id.sudo().write({'group_ids': [(6, 0, old_ids)]})
|
||||||
|
self.state = 'rolled_back'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_purge_expired_migrations(self):
|
||||||
|
"""After 30 days, clear snapshots + unlink old plating groups.
|
||||||
|
Runs daily via fp_migration_cron.xml."""
|
||||||
|
deadline = fields.Datetime.now() - timedelta(days=30)
|
||||||
|
expired = self.search([
|
||||||
|
('state', '=', 'approved'),
|
||||||
|
('approved_at', '<', deadline),
|
||||||
|
])
|
||||||
|
if not expired:
|
||||||
|
return
|
||||||
|
# Clear snapshots (no more rollback possible)
|
||||||
|
for preview in expired:
|
||||||
|
preview.line_ids.write({'applied_groups_snapshot': False})
|
||||||
|
# Unlink old plating groups (now confirmed unused — every user is
|
||||||
|
# on the new groups; backward-compat implied_ids chains can drop)
|
||||||
|
old_group_ids = []
|
||||||
|
for xmlid in _FP_OLD_GROUP_XMLIDS:
|
||||||
|
g = self.env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if g:
|
||||||
|
old_group_ids.append(g.id)
|
||||||
|
if old_group_ids:
|
||||||
|
# I6 safety check — never unlink a group that still has active
|
||||||
|
# internal users on it. If anyone still references the group
|
||||||
|
# we'd cascade-strip them silently from their permissions.
|
||||||
|
safe_to_unlink = []
|
||||||
|
skipped = []
|
||||||
|
for old_group in self.env['res.groups'].browse(old_group_ids).exists():
|
||||||
|
active_users = old_group.user_ids.filtered(lambda u: u.active and not u.share)
|
||||||
|
if active_users:
|
||||||
|
skipped.append((old_group.name, active_users.mapped('login')))
|
||||||
|
else:
|
||||||
|
safe_to_unlink.append(old_group.id)
|
||||||
|
if skipped:
|
||||||
|
_logger.warning(
|
||||||
|
'Fusion Plating migration purge: skipped %d old groups with active users: %s',
|
||||||
|
len(skipped), skipped)
|
||||||
|
if safe_to_unlink:
|
||||||
|
self.env['res.groups'].browse(safe_to_unlink).unlink()
|
||||||
|
_logger.info('Fusion Plating migration: purged %d expired old plating groups',
|
||||||
|
len(safe_to_unlink))
|
||||||
|
|
||||||
|
|
||||||
|
class FpMigrationPreviewLine(models.Model):
|
||||||
|
_name = 'fp.migration.preview.line'
|
||||||
|
_description = 'Migration Preview Line'
|
||||||
|
|
||||||
|
preview_id = fields.Many2one('fp.migration.preview', required=True, ondelete='cascade')
|
||||||
|
user_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||||
|
current_groups = fields.Char(compute='_compute_current_groups')
|
||||||
|
proposed_role = fields.Selection(_ROLE_SELECTION)
|
||||||
|
capability_delta = fields.Char()
|
||||||
|
warning = fields.Boolean()
|
||||||
|
notes = fields.Text(help='Owner may annotate before approving')
|
||||||
|
applied_groups_snapshot = fields.Text(help='JSON of pre-migration group_ids for rollback')
|
||||||
|
|
||||||
|
@api.depends('user_id', 'user_id.group_ids')
|
||||||
|
def _compute_current_groups(self):
|
||||||
|
for line in self:
|
||||||
|
if line.user_id:
|
||||||
|
line.current_groups = ', '.join(line.user_id.group_ids.mapped('name'))
|
||||||
|
else:
|
||||||
|
line.current_groups = ''
|
||||||
@@ -784,6 +784,62 @@ class FpProcessNode(models.Model):
|
|||||||
return self.action_open_simple_editor()
|
return self.action_open_simple_editor()
|
||||||
return self.action_open_tree_editor()
|
return self.action_open_tree_editor()
|
||||||
|
|
||||||
|
# ---- Auto-classify kind from name (2026-05-24, spec
|
||||||
|
# docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md) -------
|
||||||
|
# Safety net: when a node's kind is the catch-all 'other' AND its
|
||||||
|
# name resolves via fp_resolve_step_kind(), upgrade kind_id to the
|
||||||
|
# resolved active kind. Runs on create() and on write() when name
|
||||||
|
# or kind_id changes. Prevents recipe authoring + recipe
|
||||||
|
# duplication from silently leaving nodes as 'other' (which then
|
||||||
|
# routes them to the wrong Shop Floor column).
|
||||||
|
#
|
||||||
|
# Skip with context flag fp_skip_kind_autoclassify=True for admin
|
||||||
|
# workflows that need to keep kind=other despite a known name.
|
||||||
|
|
||||||
|
def _fp_autoclassify_kind(self):
|
||||||
|
"""Upgrade kind_id when current is 'other' and name resolves."""
|
||||||
|
if self.env.context.get('fp_skip_kind_autoclassify'):
|
||||||
|
return
|
||||||
|
from odoo.addons.fusion_plating import (
|
||||||
|
fp_resolve_step_kind,
|
||||||
|
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||||
|
)
|
||||||
|
Kind = self.env['fp.step.kind']
|
||||||
|
other = Kind.search([('code', '=', 'other')], limit=1)
|
||||||
|
if not other:
|
||||||
|
return
|
||||||
|
# Cache active-kind ids by code so we don't re-search per row.
|
||||||
|
kind_by_code = {}
|
||||||
|
for node in self:
|
||||||
|
if not node.name or node.kind_id != other:
|
||||||
|
continue
|
||||||
|
resolver_code = fp_resolve_step_kind(node.name)
|
||||||
|
if not resolver_code:
|
||||||
|
continue
|
||||||
|
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||||
|
if not target_code:
|
||||||
|
continue
|
||||||
|
if target_code not in kind_by_code:
|
||||||
|
tgt = Kind.search([('code', '=', target_code)], limit=1)
|
||||||
|
kind_by_code[target_code] = tgt.id if tgt else False
|
||||||
|
target_id = kind_by_code[target_code]
|
||||||
|
if target_id:
|
||||||
|
node.with_context(
|
||||||
|
fp_skip_kind_autoclassify=True,
|
||||||
|
).write({'kind_id': target_id})
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
nodes = super().create(vals_list)
|
||||||
|
nodes._fp_autoclassify_kind()
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
if 'name' in vals or 'kind_id' in vals:
|
||||||
|
self._fp_autoclassify_kind()
|
||||||
|
return res
|
||||||
|
|
||||||
# ---- Copy (deep-duplicate) -----------------------------------------------
|
# ---- Copy (deep-duplicate) -----------------------------------------------
|
||||||
|
|
||||||
def copy(self, default=None):
|
def copy(self, default=None):
|
||||||
|
|||||||
91
fusion_plating/fusion_plating/models/fp_role_constants.py
Normal file
91
fusion_plating/fusion_plating/models/fp_role_constants.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Single source of truth for migration mapping rules + old-group xmlids.
|
||||||
|
|
||||||
|
The mapping predicates are evaluated against res.users records. First match
|
||||||
|
wins (highest-precedence first). See spec Section 5 + plan Phase H.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Every plating role group xmlid that exists BEFORE the migration (deprecated
|
||||||
|
# but still defined for backward-compat during 30-day rollback window).
|
||||||
|
_FP_OLD_GROUP_XMLIDS = (
|
||||||
|
'fusion_plating.group_fusion_plating_operator',
|
||||||
|
'fusion_plating.group_fusion_plating_supervisor',
|
||||||
|
'fusion_plating.group_fusion_plating_manager',
|
||||||
|
'fusion_plating.group_fusion_plating_admin',
|
||||||
|
'fusion_plating_configurator.group_fp_estimator',
|
||||||
|
'fusion_plating_configurator.group_fp_shop_manager',
|
||||||
|
'fusion_plating_invoicing.group_fp_accounting',
|
||||||
|
'fusion_plating_receiving.group_fp_receiving',
|
||||||
|
'fusion_plating_cgp.group_fusion_plating_cgp_officer',
|
||||||
|
'fusion_plating_cgp.group_fusion_plating_cgp_designated_official',
|
||||||
|
'fusion_plating_jobs.group_fusion_plating_legacy_menus',
|
||||||
|
)
|
||||||
|
|
||||||
|
# New role -> the group xmlid to add when migration assigns this role.
|
||||||
|
# 'no' maps to None (no plating group added; old ones still get removed).
|
||||||
|
_NEW_ROLE_XMLID = {
|
||||||
|
'no': None,
|
||||||
|
'technician': 'fusion_plating.group_fp_technician',
|
||||||
|
'sales_rep': 'fusion_plating.group_fp_sales_rep',
|
||||||
|
'shop_manager': 'fusion_plating.group_fp_shop_manager_v2',
|
||||||
|
'sales_manager': 'fusion_plating.group_fp_sales_manager',
|
||||||
|
'manager': 'fusion_plating.group_fp_manager',
|
||||||
|
'quality_manager': 'fusion_plating.group_fp_quality_manager',
|
||||||
|
'owner': 'fusion_plating.group_fp_owner',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping rules: (label, predicate, new_role, capability_delta_or_None)
|
||||||
|
# Highest precedence first; first match wins.
|
||||||
|
# Predicate is a callable taking a res.users record; returns bool.
|
||||||
|
_FP_ROLE_MAPPING_RULES = [
|
||||||
|
# cgp_designated_official MUST be first so admin/uid_1/uid_2 users who ALSO
|
||||||
|
# hold the DO group still get the capability_delta marker — which is what
|
||||||
|
# triggers action_approve_and_run to set res.company.x_fc_cgp_designated_official_id.
|
||||||
|
# If admin matched first, the DO field would never get populated for shops
|
||||||
|
# where the admin is also the registered PSPC Designated Official.
|
||||||
|
('cgp_designated_official',
|
||||||
|
lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_designated_official'),
|
||||||
|
'owner', 'Was CGP DO; field set on res.company'),
|
||||||
|
('uid_1_or_2',
|
||||||
|
lambda u: u.id in (1, 2),
|
||||||
|
'owner', None),
|
||||||
|
('admin',
|
||||||
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_admin'),
|
||||||
|
'owner', None),
|
||||||
|
('cgp_officer',
|
||||||
|
lambda u: u.has_group('fusion_plating_cgp.group_fusion_plating_cgp_officer'),
|
||||||
|
'quality_manager', None),
|
||||||
|
('manager',
|
||||||
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_manager'),
|
||||||
|
'manager', None),
|
||||||
|
('shop_manager_old',
|
||||||
|
lambda u: u.has_group('fusion_plating_configurator.group_fp_shop_manager'),
|
||||||
|
'manager', None),
|
||||||
|
('accounting',
|
||||||
|
lambda u: u.has_group('fusion_plating_invoicing.group_fp_accounting'),
|
||||||
|
'manager', None),
|
||||||
|
('estimator_alone',
|
||||||
|
lambda u: (u.has_group('fusion_plating_configurator.group_fp_estimator')
|
||||||
|
and not u.has_group('fusion_plating.group_fusion_plating_manager')),
|
||||||
|
'sales_rep', 'Loses order-confirm authority'),
|
||||||
|
('supervisor',
|
||||||
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_supervisor'),
|
||||||
|
'shop_manager', None),
|
||||||
|
('receiving',
|
||||||
|
lambda u: u.has_group('fusion_plating_receiving.group_fp_receiving'),
|
||||||
|
'shop_manager', None),
|
||||||
|
('operator',
|
||||||
|
lambda u: u.has_group('fusion_plating.group_fusion_plating_operator'),
|
||||||
|
'technician', None),
|
||||||
|
('catchall',
|
||||||
|
lambda u: True,
|
||||||
|
'no', None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fp_resolve_target_role(user):
|
||||||
|
"""Returns (role_key, capability_delta_or_None). First predicate match wins."""
|
||||||
|
for _label, predicate, role, delta in _FP_ROLE_MAPPING_RULES:
|
||||||
|
if predicate(user):
|
||||||
|
return role, delta
|
||||||
|
return 'no', None
|
||||||
@@ -34,6 +34,31 @@ class FpStepKind(models.Model):
|
|||||||
string='Icon',
|
string='Icon',
|
||||||
default='fa-cog',
|
default='fa-cog',
|
||||||
)
|
)
|
||||||
|
# 2026-05-24 — Shop Floor live-step fix.
|
||||||
|
# Each kind self-declares which plant-view column its steps land in.
|
||||||
|
# Replaces the hardcoded _STEP_KIND_TO_AREA dict (removed from
|
||||||
|
# fusion_plating_jobs/models/fp_job_step.py). Pre-migrate
|
||||||
|
# 19.0.21.2.0 seeds existing rows before NOT NULL hits the schema.
|
||||||
|
area_kind = fields.Selection(
|
||||||
|
[
|
||||||
|
('receiving', 'Receiving'),
|
||||||
|
('masking', 'Masking'),
|
||||||
|
('blasting', 'Blasting'),
|
||||||
|
('racking', 'Racking'),
|
||||||
|
('plating', 'Plating'),
|
||||||
|
('baking', 'Baking'),
|
||||||
|
('de_racking', 'De-Racking'),
|
||||||
|
('inspection', 'Final Inspection'),
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
],
|
||||||
|
string='Shop Floor Column',
|
||||||
|
required=True,
|
||||||
|
index=True,
|
||||||
|
help='Determines which column on the Shop Floor plant kanban shows '
|
||||||
|
'cards whose active step uses this kind. Step kinds drive '
|
||||||
|
'routing automatically — picking a kind tells the system both '
|
||||||
|
'what gates fire AND where the card lives.',
|
||||||
|
)
|
||||||
company_id = fields.Many2one(
|
company_id = fields.Many2one(
|
||||||
'res.company', string='Company',
|
'res.company', string='Company',
|
||||||
default=lambda self: self.env.company,
|
default=lambda self: self.env.company,
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ class FpWorkCentre(models.Model):
|
|||||||
required=True,
|
required=True,
|
||||||
default='other',
|
default='other',
|
||||||
)
|
)
|
||||||
|
area_kind = fields.Selection(
|
||||||
|
[
|
||||||
|
('receiving', 'Receiving'),
|
||||||
|
('masking', 'Masking'),
|
||||||
|
('blasting', 'Blasting'),
|
||||||
|
('racking', 'Racking'),
|
||||||
|
('plating', 'Plating'),
|
||||||
|
('baking', 'Baking'),
|
||||||
|
('de_racking', 'De-Racking'),
|
||||||
|
('inspection', 'Final inspection'),
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
],
|
||||||
|
string='Floor Column',
|
||||||
|
help='Which Shop Floor column this work centre belongs to. '
|
||||||
|
'Drives the plant-view kanban grouping — any job whose '
|
||||||
|
'active step uses this work centre routes into this column. '
|
||||||
|
'See docs/superpowers/specs/2026-05-23-shopfloor-plant-view-'
|
||||||
|
'design.md §4.2 for the mapping rules.',
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
cost_per_hour = fields.Monetary(
|
cost_per_hour = fields.Monetary(
|
||||||
currency_field='currency_id',
|
currency_field='currency_id',
|
||||||
help='Used for fp.job.step cost rollups.',
|
help='Used for fp.job.step cost rollups.',
|
||||||
|
|||||||
@@ -185,3 +185,28 @@ class ResCompany(models.Model):
|
|||||||
'When BOTH are blank the report falls back to a hardcoded '
|
'When BOTH are blank the report falls back to a hardcoded '
|
||||||
'AS9100/ISO 9001 statement.',
|
'AS9100/ISO 9001 statement.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Phase F — Plating Designated Officials
|
||||||
|
# =====================================================================
|
||||||
|
# These are SPECIFIC NAMED PEOPLE registered with regulatory bodies.
|
||||||
|
# Stored as Many2one to res.users so the link survives renames.
|
||||||
|
# View-level domain restricts the picker to Owner or Quality Manager
|
||||||
|
# group members (a Python-side domain would resolve groups by id at
|
||||||
|
# recordset load and is fragile across DB migrations).
|
||||||
|
x_fc_cgp_designated_official_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='CGP Designated Official',
|
||||||
|
tracking=True,
|
||||||
|
help='Specific person registered with PSPC as Designated Official '
|
||||||
|
'under Defence Production Act §22. Must be Owner or Quality '
|
||||||
|
'Manager.',
|
||||||
|
)
|
||||||
|
|
||||||
|
x_fc_nadcap_authority_user_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='Nadcap Authority',
|
||||||
|
tracking=True,
|
||||||
|
help='Specific person who signs Nadcap-specific certificates and '
|
||||||
|
'audits. Must be Owner or Quality Manager.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -64,8 +64,12 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ----- Phase 1 — Plating landing page default -----------------------
|
# ----- Phase 1 — Plating landing page default -----------------------
|
||||||
|
# Comodel MUST match res.company.x_fc_default_landing_action_id, which
|
||||||
|
# was widened to ir.actions.actions in the post-deploy fixes so the
|
||||||
|
# picker accepts both window AND client actions (Manager Desk, Plant
|
||||||
|
# Kanban, Quality Dashboard are all client actions).
|
||||||
x_fc_default_landing_action_id = fields.Many2one(
|
x_fc_default_landing_action_id = fields.Many2one(
|
||||||
'ir.actions.act_window',
|
'ir.actions.actions',
|
||||||
related='company_id.x_fc_default_landing_action_id',
|
related='company_id.x_fc_default_landing_action_id',
|
||||||
readonly=False,
|
readonly=False,
|
||||||
string='Default Plating Landing Page',
|
string='Default Plating Landing Page',
|
||||||
|
|||||||
146
fusion_plating/fusion_plating/models/res_users.py
Normal file
146
fusion_plating/fusion_plating/models/res_users.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""Fusion Plating role helpers on res.users.
|
||||||
|
|
||||||
|
The x_fc_plating_role Selection field is a clean UX wrapper around the
|
||||||
|
seven plating-role groups. Owner-only Team page reads/writes this field
|
||||||
|
via drag-and-drop on a kanban grouped by role.
|
||||||
|
"""
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
_FP_PLATING_ROLE_TO_GROUP_XMLID = {
|
||||||
|
'technician': 'fusion_plating.group_fp_technician',
|
||||||
|
'sales_rep': 'fusion_plating.group_fp_sales_rep',
|
||||||
|
'shop_manager': 'fusion_plating.group_fp_shop_manager_v2',
|
||||||
|
'sales_manager': 'fusion_plating.group_fp_sales_manager',
|
||||||
|
'manager': 'fusion_plating.group_fp_manager',
|
||||||
|
'quality_manager': 'fusion_plating.group_fp_quality_manager',
|
||||||
|
'owner': 'fusion_plating.group_fp_owner',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Highest precedence first — first match wins
|
||||||
|
_FP_ROLE_PRECEDENCE = (
|
||||||
|
'owner', 'quality_manager', 'manager', 'sales_manager',
|
||||||
|
'shop_manager', 'sales_rep', 'technician',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
# Allow non-admin users to write their OWN plating-related fields
|
||||||
|
# from the standard User Preferences dialog. SELF_WRITEABLE_FIELDS is
|
||||||
|
# a @property in Odoo 19 (not a class attribute) — must override via
|
||||||
|
# @property + super(). See CLAUDE.md rule 13k.
|
||||||
|
@property
|
||||||
|
def SELF_WRITEABLE_FIELDS(self):
|
||||||
|
return super().SELF_WRITEABLE_FIELDS + [
|
||||||
|
'x_fc_plating_landing_action_id', # personal landing-page override
|
||||||
|
'x_fc_signature_image', # "Plating Signature" used on reports
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def SELF_READABLE_FIELDS(self):
|
||||||
|
return super().SELF_READABLE_FIELDS + [
|
||||||
|
'x_fc_plating_landing_action_id',
|
||||||
|
'x_fc_signature_image',
|
||||||
|
'x_fc_plating_role',
|
||||||
|
'x_fc_tablet_pin_set_date',
|
||||||
|
]
|
||||||
|
|
||||||
|
x_fc_plating_role = fields.Selection(
|
||||||
|
[
|
||||||
|
('no', 'No'),
|
||||||
|
('technician', 'Technician'),
|
||||||
|
('sales_rep', 'Sales Representative'),
|
||||||
|
('shop_manager', 'Shop Manager'),
|
||||||
|
('sales_manager', 'Sales Manager'),
|
||||||
|
('manager', 'Manager'),
|
||||||
|
('quality_manager', 'Quality Manager'),
|
||||||
|
('owner', 'Owner'),
|
||||||
|
],
|
||||||
|
compute='_compute_plating_role',
|
||||||
|
inverse='_inverse_plating_role',
|
||||||
|
store=True,
|
||||||
|
string='Fusion Plating Role',
|
||||||
|
help='Highest plating role currently held by this user. Changing this '
|
||||||
|
'field reassigns the user to the corresponding res.groups (clears '
|
||||||
|
'old plating groups, adds new). Posts an audit chatter message.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('group_ids')
|
||||||
|
def _compute_plating_role(self):
|
||||||
|
# Resolve xmlids once
|
||||||
|
role_to_group = {}
|
||||||
|
for role, xmlid in _FP_PLATING_ROLE_TO_GROUP_XMLID.items():
|
||||||
|
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if grp:
|
||||||
|
role_to_group[role] = grp
|
||||||
|
for user in self:
|
||||||
|
user.x_fc_plating_role = 'no'
|
||||||
|
for candidate in _FP_ROLE_PRECEDENCE:
|
||||||
|
grp = role_to_group.get(candidate)
|
||||||
|
if grp and grp in user.group_ids:
|
||||||
|
user.x_fc_plating_role = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
def _inverse_plating_role(self):
|
||||||
|
# Resolve all plating-role group ids
|
||||||
|
all_role_ids = []
|
||||||
|
role_to_group = {}
|
||||||
|
for role, xmlid in _FP_PLATING_ROLE_TO_GROUP_XMLID.items():
|
||||||
|
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if grp:
|
||||||
|
role_to_group[role] = grp
|
||||||
|
all_role_ids.append(grp.id)
|
||||||
|
|
||||||
|
# I4 fix — capture old roles BEFORE the cache mutates by reading
|
||||||
|
# the stored x_fc_plating_role column directly from PostgreSQL.
|
||||||
|
# `user._origin.x_fc_plating_role` returns the IN-CACHE new value
|
||||||
|
# (the assignment that triggered the inverse), not the prior DB
|
||||||
|
# value, so the chatter audit displayed "X -> X" instead of the
|
||||||
|
# actual old -> new transition.
|
||||||
|
self.env.cr.execute(
|
||||||
|
"SELECT id, x_fc_plating_role FROM res_users WHERE id IN %s",
|
||||||
|
(tuple(self.ids),) if self.ids else ((0,),),
|
||||||
|
)
|
||||||
|
old_role_by_id = dict(self.env.cr.fetchall())
|
||||||
|
|
||||||
|
for user in self:
|
||||||
|
old_role = old_role_by_id.get(user.id) or 'unset'
|
||||||
|
new_role = user.x_fc_plating_role
|
||||||
|
if old_role == new_role:
|
||||||
|
# No actual change — skip both the writes and the audit so
|
||||||
|
# we don't spam chatter with "X -> X" rows.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove every plating-role group (additive-by-default Odoo
|
||||||
|
# m2m write of (3, id) removes single rows)
|
||||||
|
user.sudo().write({
|
||||||
|
'group_ids': [(3, gid) for gid in all_role_ids]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add the chosen role (no-op for 'no')
|
||||||
|
if new_role and new_role != 'no':
|
||||||
|
target = role_to_group.get(new_role)
|
||||||
|
if target:
|
||||||
|
user.sudo().write({
|
||||||
|
'group_ids': [(4, target.id)]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Post audit (Markup so role names render bold, not literal HTML)
|
||||||
|
user.partner_id.message_post(
|
||||||
|
body=Markup(_(
|
||||||
|
'Plating role changed: <b>%(old)s</b> -> <b>%(new)s</b> by %(actor)s'
|
||||||
|
)) % {
|
||||||
|
'old': old_role,
|
||||||
|
'new': new_role or 'unset',
|
||||||
|
'actor': self.env.user.name,
|
||||||
|
},
|
||||||
|
message_type='notification',
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
2026-05-24 — Hide non-essential app menus from Technicians.
|
||||||
|
|
||||||
|
Per user request: technicians should see ONLY the apps they actually
|
||||||
|
need on the tablet — Discuss, To-do, Plating, AI, Maintenance, Time
|
||||||
|
Off. Every other top-level app menu is restricted to a new "office
|
||||||
|
user" group implied by every fp role ABOVE technician.
|
||||||
|
|
||||||
|
THIS FILE only declares the office_user group + the implied_ids
|
||||||
|
chain. The actual menu group-restriction is applied via a
|
||||||
|
post_init_hook / post-migrate script (see fusion_plating/__init__.py
|
||||||
|
and migrations/19.0.21.4.0/post-migrate.py), because cross-module
|
||||||
|
<menuitem id="other_module.X" groups="..."/> overrides require the
|
||||||
|
other module in `depends`, which would lock us into hard
|
||||||
|
dependencies on calendar/sale/hr/etc. The hook uses
|
||||||
|
env.ref(..., raise_if_not_found=False) — modules that aren't
|
||||||
|
installed are silently skipped.
|
||||||
|
|
||||||
|
Why a separate office_user group instead of !technician?
|
||||||
|
Manager → ... → Technician via implied_ids, so a Manager IS a
|
||||||
|
technician for group-matching purposes. A "!technician" filter would
|
||||||
|
hide menus from managers too. The office_user pattern flips that:
|
||||||
|
we add a new group that's implied by manager+ (and explicitly NOT
|
||||||
|
by technician), then require it on the menus we want to hide.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- New marker group: "Office User" — implied by every non- -->
|
||||||
|
<!-- technician fp role. -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<record id="group_fp_office_user" model="res.groups">
|
||||||
|
<field name="name">Plating: Office User (sees back-office menus)</field>
|
||||||
|
<field name="privilege_id"
|
||||||
|
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="sequence">90</field>
|
||||||
|
<field name="comment">Marker group that controls visibility of
|
||||||
|
non-tablet app menus (Calendar, Sales, Inventory, etc.).
|
||||||
|
Implied by every fp role above Technician (Owner, Manager,
|
||||||
|
Quality Manager, Shop Manager, Sales Rep, Estimator).
|
||||||
|
Pure Technicians don't have it, so they only see the
|
||||||
|
tablet apps (Plating, Discuss, To-do, AI, Maintenance,
|
||||||
|
Time Off).</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Add office_user to implied_ids of each above-technician role -->
|
||||||
|
<!-- These records UPDATE existing groups (additive Command.link) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<record id="group_fp_sales_rep" model="res.groups">
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="group_fp_shop_manager_v2" model="res.groups">
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="group_fp_manager" model="res.groups">
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="group_fp_quality_manager" model="res.groups">
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="group_fp_owner" model="res.groups">
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<!-- Reads most reference data, writes chemistry logs. -->
|
<!-- Reads most reference data, writes chemistry logs. -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="group_fusion_plating_operator" model="res.groups">
|
<record id="group_fusion_plating_operator" model="res.groups">
|
||||||
<field name="name">Operator</field>
|
<field name="name">[DEPRECATED] Operator</field>
|
||||||
<field name="sequence">10</field>
|
<field name="sequence">10</field>
|
||||||
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<!-- Can manage baths, schedule jobs, review logs. -->
|
<!-- Can manage baths, schedule jobs, review logs. -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="group_fusion_plating_supervisor" model="res.groups">
|
<record id="group_fusion_plating_supervisor" model="res.groups">
|
||||||
<field name="name">Supervisor</field>
|
<field name="name">[DEPRECATED] Supervisor</field>
|
||||||
<field name="sequence">20</field>
|
<field name="sequence">20</field>
|
||||||
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||||
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_operator'))]"/>
|
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_operator'))]"/>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<!-- Full CRUD on configuration objects. -->
|
<!-- Full CRUD on configuration objects. -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="group_fusion_plating_manager" model="res.groups">
|
<record id="group_fusion_plating_manager" model="res.groups">
|
||||||
<field name="name">Manager</field>
|
<field name="name">[DEPRECATED] Manager</field>
|
||||||
<field name="sequence">30</field>
|
<field name="sequence">30</field>
|
||||||
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||||
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_supervisor'))]"/>
|
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_supervisor'))]"/>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<!-- Everything a Manager can do, plus system-level settings. -->
|
<!-- Everything a Manager can do, plus system-level settings. -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="group_fusion_plating_admin" model="res.groups">
|
<record id="group_fusion_plating_admin" model="res.groups">
|
||||||
<field name="name">Administrator</field>
|
<field name="name">[DEPRECATED] Administrator</field>
|
||||||
<field name="sequence">40</field>
|
<field name="sequence">40</field>
|
||||||
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
<field name="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||||
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_manager'))]"/>
|
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_manager'))]"/>
|
||||||
|
|||||||
81
fusion_plating/fusion_plating/security/fp_security_v2.xml
Normal file
81
fusion_plating/fusion_plating/security/fp_security_v2.xml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Phase 1 Permissions Overhaul: 8 consolidated roles -->
|
||||||
|
<!-- See docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md -->
|
||||||
|
<!-- Cross-module implications (estimator, receiving, accounting, cgp_officer,
|
||||||
|
cgp_designated_official) live in the downstream modules' security files
|
||||||
|
to avoid fresh-install forward-ref errors. -->
|
||||||
|
|
||||||
|
<record id="group_fp_technician" model="res.groups">
|
||||||
|
<field name="name">Technician</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('base.group_user')),
|
||||||
|
(4, ref('fusion_plating.group_fusion_plating_operator')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fp_sales_rep" model="res.groups">
|
||||||
|
<field name="name">Sales Representative</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('base.group_user')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fp_shop_manager_v2" model="res.groups">
|
||||||
|
<field name="name">Shop Manager</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('group_fp_technician')),
|
||||||
|
(4, ref('fusion_plating.group_fusion_plating_supervisor')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fp_sales_manager" model="res.groups">
|
||||||
|
<field name="name">Sales Manager</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('group_fp_sales_rep')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fp_manager" model="res.groups">
|
||||||
|
<field name="name">Manager</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('group_fp_shop_manager_v2')),
|
||||||
|
(4, ref('group_fp_sales_manager')),
|
||||||
|
(4, ref('fusion_plating.group_fusion_plating_manager')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fp_quality_manager" model="res.groups">
|
||||||
|
<field name="name">Quality Manager</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('group_fp_manager')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fp_owner" model="res.groups">
|
||||||
|
<field name="name">Owner</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||||
|
<field name="implied_ids" eval="[
|
||||||
|
(4, ref('group_fp_quality_manager')),
|
||||||
|
(4, ref('fusion_plating.group_fusion_plating_admin')),
|
||||||
|
(4, ref('base.group_system')),
|
||||||
|
]"/>
|
||||||
|
<field name="user_ids" eval="[
|
||||||
|
(4, ref('base.user_root')),
|
||||||
|
(4, ref('base.user_admin')),
|
||||||
|
]"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -1,96 +1,99 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
access_fp_process_category_operator,fp.process.category.operator,model_fusion_plating_process_category,group_fusion_plating_operator,1,0,0,0
|
access_fp_process_category_operator,fp.process.category.operator,model_fusion_plating_process_category,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_process_category_manager,fp.process.category.manager,model_fusion_plating_process_category,group_fusion_plating_manager,1,1,1,1
|
access_fp_process_category_manager,fp.process.category.manager,model_fusion_plating_process_category,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_process_type_operator,fp.process.type.operator,model_fusion_plating_process_type,group_fusion_plating_operator,1,0,0,0
|
access_fp_process_type_operator,fp.process.type.operator,model_fusion_plating_process_type,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_process_type_manager,fp.process.type.manager,model_fusion_plating_process_type,group_fusion_plating_manager,1,1,1,1
|
access_fp_process_type_manager,fp.process.type.manager,model_fusion_plating_process_type,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_bath_parameter_operator,fp.bath.parameter.operator,model_fusion_plating_bath_parameter,group_fusion_plating_operator,1,0,0,0
|
access_fp_bath_parameter_operator,fp.bath.parameter.operator,model_fusion_plating_bath_parameter,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_bath_parameter_manager,fp.bath.parameter.manager,model_fusion_plating_bath_parameter,group_fusion_plating_manager,1,1,1,1
|
access_fp_bath_parameter_manager,fp.bath.parameter.manager,model_fusion_plating_bath_parameter,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_facility_operator,fp.facility.operator,model_fusion_plating_facility,group_fusion_plating_operator,1,0,0,0
|
access_fp_facility_operator,fp.facility.operator,model_fusion_plating_facility,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_facility_supervisor,fp.facility.supervisor,model_fusion_plating_facility,group_fusion_plating_supervisor,1,0,0,0
|
access_fp_facility_supervisor,fp.facility.supervisor,model_fusion_plating_facility,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||||
access_fp_facility_manager,fp.facility.manager,model_fusion_plating_facility,group_fusion_plating_manager,1,1,1,1
|
access_fp_facility_manager,fp.facility.manager,model_fusion_plating_facility,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_work_center_operator,fp.work.center.operator,model_fusion_plating_work_center,group_fusion_plating_operator,1,0,0,0
|
access_fp_work_center_operator,fp.work.center.operator,model_fusion_plating_work_center,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_work_center_supervisor,fp.work.center.supervisor,model_fusion_plating_work_center,group_fusion_plating_supervisor,1,1,0,0
|
access_fp_work_center_supervisor,fp.work.center.supervisor,model_fusion_plating_work_center,fusion_plating.group_fp_shop_manager_v2,1,1,0,0
|
||||||
access_fp_work_center_manager,fp.work.center.manager,model_fusion_plating_work_center,group_fusion_plating_manager,1,1,1,1
|
access_fp_work_center_manager,fp.work.center.manager,model_fusion_plating_work_center,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_tank_operator,fp.tank.operator,model_fusion_plating_tank,group_fusion_plating_operator,1,0,0,0
|
access_fp_tank_operator,fp.tank.operator,model_fusion_plating_tank,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_tank_supervisor,fp.tank.supervisor,model_fusion_plating_tank,group_fusion_plating_supervisor,1,1,0,0
|
access_fp_tank_supervisor,fp.tank.supervisor,model_fusion_plating_tank,fusion_plating.group_fp_shop_manager_v2,1,1,0,0
|
||||||
access_fp_tank_manager,fp.tank.manager,model_fusion_plating_tank,group_fusion_plating_manager,1,1,1,1
|
access_fp_tank_manager,fp.tank.manager,model_fusion_plating_tank,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_tank_section_operator,fp.tank.section.operator,model_fusion_plating_tank_section,group_fusion_plating_operator,1,0,0,0
|
access_fp_tank_section_operator,fp.tank.section.operator,model_fusion_plating_tank_section,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_tank_section_supervisor,fp.tank.section.supervisor,model_fusion_plating_tank_section,group_fusion_plating_supervisor,1,1,0,0
|
access_fp_tank_section_supervisor,fp.tank.section.supervisor,model_fusion_plating_tank_section,fusion_plating.group_fp_shop_manager_v2,1,1,0,0
|
||||||
access_fp_tank_section_manager,fp.tank.section.manager,model_fusion_plating_tank_section,group_fusion_plating_manager,1,1,1,1
|
access_fp_tank_section_manager,fp.tank.section.manager,model_fusion_plating_tank_section,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_tank_composition_operator,fp.tank.composition.operator,model_fusion_plating_tank_composition,group_fusion_plating_operator,1,0,0,0
|
access_fp_tank_composition_operator,fp.tank.composition.operator,model_fusion_plating_tank_composition,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_tank_composition_supervisor,fp.tank.composition.supervisor,model_fusion_plating_tank_composition,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_tank_composition_supervisor,fp.tank.composition.supervisor,model_fusion_plating_tank_composition,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_tank_composition_manager,fp.tank.composition.manager,model_fusion_plating_tank_composition,group_fusion_plating_manager,1,1,1,1
|
access_fp_tank_composition_manager,fp.tank.composition.manager,model_fusion_plating_tank_composition,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_tank_comp_ing_operator,fp.tank.composition.ingredient.operator,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_operator,1,0,0,0
|
access_fp_tank_comp_ing_operator,fp.tank.composition.ingredient.operator,model_fusion_plating_tank_composition_ingredient,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_tank_comp_ing_supervisor,fp.tank.composition.ingredient.supervisor,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_supervisor,1,1,1,1
|
access_fp_tank_comp_ing_supervisor,fp.tank.composition.ingredient.supervisor,model_fusion_plating_tank_composition_ingredient,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||||
access_fp_tank_comp_ing_manager,fp.tank.composition.ingredient.manager,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_manager,1,1,1,1
|
access_fp_tank_comp_ing_manager,fp.tank.composition.ingredient.manager,model_fusion_plating_tank_composition_ingredient,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_bath_operator,fp.bath.operator,model_fusion_plating_bath,group_fusion_plating_operator,1,0,0,0
|
access_fp_bath_operator,fp.bath.operator,model_fusion_plating_bath,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_bath_supervisor,fp.bath.supervisor,model_fusion_plating_bath,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_bath_supervisor,fp.bath.supervisor,model_fusion_plating_bath,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_bath_manager,fp.bath.manager,model_fusion_plating_bath,group_fusion_plating_manager,1,1,1,1
|
access_fp_bath_manager,fp.bath.manager,model_fusion_plating_bath,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_bath_target_operator,fp.bath.target.operator,model_fusion_plating_bath_target,group_fusion_plating_operator,1,0,0,0
|
access_fp_bath_target_operator,fp.bath.target.operator,model_fusion_plating_bath_target,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_bath_target_supervisor,fp.bath.target.supervisor,model_fusion_plating_bath_target,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_bath_target_supervisor,fp.bath.target.supervisor,model_fusion_plating_bath_target,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_bath_target_manager,fp.bath.target.manager,model_fusion_plating_bath_target,group_fusion_plating_manager,1,1,1,1
|
access_fp_bath_target_manager,fp.bath.target.manager,model_fusion_plating_bath_target,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_bath_log_operator,fp.bath.log.operator,model_fusion_plating_bath_log,group_fusion_plating_operator,1,1,1,0
|
access_fp_bath_log_operator,fp.bath.log.operator,model_fusion_plating_bath_log,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_bath_log_supervisor,fp.bath.log.supervisor,model_fusion_plating_bath_log,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_bath_log_supervisor,fp.bath.log.supervisor,model_fusion_plating_bath_log,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_bath_log_manager,fp.bath.log.manager,model_fusion_plating_bath_log,group_fusion_plating_manager,1,1,1,1
|
access_fp_bath_log_manager,fp.bath.log.manager,model_fusion_plating_bath_log,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_bath_log_line_operator,fp.bath.log.line.operator,model_fusion_plating_bath_log_line,group_fusion_plating_operator,1,1,1,0
|
access_fp_bath_log_line_operator,fp.bath.log.line.operator,model_fusion_plating_bath_log_line,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_bath_log_line_supervisor,fp.bath.log.line.supervisor,model_fusion_plating_bath_log_line,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_bath_log_line_supervisor,fp.bath.log.line.supervisor,model_fusion_plating_bath_log_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_bath_log_line_manager,fp.bath.log.line.manager,model_fusion_plating_bath_log_line,group_fusion_plating_manager,1,1,1,1
|
access_fp_bath_log_line_manager,fp.bath.log.line.manager,model_fusion_plating_bath_log_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_process_node_operator,fp.process.node.operator,model_fusion_plating_process_node,group_fusion_plating_operator,1,0,0,0
|
access_fp_process_node_operator,fp.process.node.operator,model_fusion_plating_process_node,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_process_node_supervisor,fp.process.node.supervisor,model_fusion_plating_process_node,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_process_node_supervisor,fp.process.node.supervisor,model_fusion_plating_process_node,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_process_node_manager,fp.process.node.manager,model_fusion_plating_process_node,group_fusion_plating_manager,1,1,1,1
|
access_fp_process_node_manager,fp.process.node.manager,model_fusion_plating_process_node,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_process_node_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,group_fusion_plating_operator,1,0,0,0
|
access_fp_process_node_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,group_fusion_plating_manager,1,1,1,1
|
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_rack_operator,fp.rack.operator,model_fusion_plating_rack,group_fusion_plating_operator,1,1,0,0
|
access_fp_rack_operator,fp.rack.operator,model_fusion_plating_rack,fusion_plating.group_fp_technician,1,1,0,0
|
||||||
access_fp_rack_supervisor,fp.rack.supervisor,model_fusion_plating_rack,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_rack_supervisor,fp.rack.supervisor,model_fusion_plating_rack,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_rack_manager,fp.rack.manager,model_fusion_plating_rack,group_fusion_plating_manager,1,1,1,1
|
access_fp_rack_manager,fp.rack.manager,model_fusion_plating_rack,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_replenishment_rule_operator,fp.replenishment.rule.operator,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_operator,1,0,0,0
|
access_fp_replenishment_rule_operator,fp.replenishment.rule.operator,model_fusion_plating_bath_replenishment_rule,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_replenishment_rule_supervisor,fp.replenishment.rule.supervisor,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_replenishment_rule_supervisor,fp.replenishment.rule.supervisor,model_fusion_plating_bath_replenishment_rule,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_manager,1,1,1,1
|
access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_plating_bath_replenishment_rule,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_replenishment_suggestion_operator,fp.replenishment.suggestion.operator,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_operator,1,1,1,0
|
access_fp_replenishment_suggestion_operator,fp.replenishment.suggestion.operator,model_fusion_plating_bath_replenishment_suggestion,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_replenishment_suggestion_supervisor,fp.replenishment.suggestion.supervisor,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_replenishment_suggestion_supervisor,fp.replenishment.suggestion.supervisor,model_fusion_plating_bath_replenishment_suggestion,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_manager,1,1,1,1
|
access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,model_fusion_plating_bath_replenishment_suggestion,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_operator_cert_operator,fp.operator.cert.operator,model_fp_operator_certification,group_fusion_plating_operator,1,0,0,0
|
access_fp_operator_cert_operator,fp.operator.cert.operator,model_fp_operator_certification,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_operator_cert_supervisor,fp.operator.cert.supervisor,model_fp_operator_certification,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_operator_cert_supervisor,fp.operator.cert.supervisor,model_fp_operator_certification,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certification,group_fusion_plating_manager,1,1,1,1
|
access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certification,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_work_centre_operator,fp.work.centre.operator,model_fp_work_centre,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_work_centre_operator,fp.work.centre.operator,model_fp_work_centre,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_work_centre_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_work_centre_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fusion_plating_operator,1,1,0,0
|
access_fp_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fp_technician,1,1,0,0
|
||||||
access_fp_job_supervisor,fp.job.supervisor,model_fp_job,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_supervisor,fp.job.supervisor,model_fp_job,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0
|
access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fp_technician,1,1,0,0
|
||||||
access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,group_fusion_plating_operator,1,0,0,0
|
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion_plating_manager,1,1,1,1
|
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
|
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
|
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,1,0,0,0
|
access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_manager,1,1,1,1
|
access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_step_kind_default_input_operator,fp.step.kind.default.input.operator,model_fp_step_kind_default_input,group_fusion_plating_operator,1,0,0,0
|
access_fp_step_kind_default_input_operator,fp.step.kind.default.input.operator,model_fp_step_kind_default_input,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_step_kind_default_input_supervisor,fp.step.kind.default.input.supervisor,model_fp_step_kind_default_input,group_fusion_plating_supervisor,1,1,1,1
|
access_fp_step_kind_default_input_supervisor,fp.step.kind.default.input.supervisor,model_fp_step_kind_default_input,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||||
access_fp_step_kind_default_input_manager,fp.step.kind.default.input.manager,model_fp_step_kind_default_input,group_fusion_plating_manager,1,1,1,1
|
access_fp_step_kind_default_input_manager,fp.step.kind.default.input.manager,model_fp_step_kind_default_input,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_step_template_operator,fp.step.template.operator,model_fp_step_template,group_fusion_plating_operator,1,0,0,0
|
access_fp_step_template_operator,fp.step.template.operator,model_fp_step_template,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_step_template_supervisor,fp.step.template.supervisor,model_fp_step_template,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_step_template_supervisor,fp.step.template.supervisor,model_fp_step_template,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_step_template_manager,fp.step.template.manager,model_fp_step_template,group_fusion_plating_manager,1,1,1,1
|
access_fp_step_template_manager,fp.step.template.manager,model_fp_step_template,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_step_template_input_operator,fp.step.template.input.operator,model_fp_step_template_input,group_fusion_plating_operator,1,0,0,0
|
access_fp_step_template_input_operator,fp.step.template.input.operator,model_fp_step_template_input,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_step_template_input_supervisor,fp.step.template.input.supervisor,model_fp_step_template_input,group_fusion_plating_supervisor,1,1,1,1
|
access_fp_step_template_input_supervisor,fp.step.template.input.supervisor,model_fp_step_template_input,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||||
access_fp_step_template_input_manager,fp.step.template.input.manager,model_fp_step_template_input,group_fusion_plating_manager,1,1,1,1
|
access_fp_step_template_input_manager,fp.step.template.input.manager,model_fp_step_template_input,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_step_template_transition_input_operator,fp.step.template.transition.input.operator,model_fp_step_template_transition_input,group_fusion_plating_operator,1,0,0,0
|
access_fp_step_template_transition_input_operator,fp.step.template.transition.input.operator,model_fp_step_template_transition_input,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_step_template_transition_input_supervisor,fp.step.template.transition.input.supervisor,model_fp_step_template_transition_input,group_fusion_plating_supervisor,1,1,1,1
|
access_fp_step_template_transition_input_supervisor,fp.step.template.transition.input.supervisor,model_fp_step_template_transition_input,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||||
access_fp_step_template_transition_input_manager,fp.step.template.transition.input.manager,model_fp_step_template_transition_input,group_fusion_plating_manager,1,1,1,1
|
access_fp_step_template_transition_input_manager,fp.step.template.transition.input.manager,model_fp_step_template_transition_input,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_rack_tag_operator,fp.rack.tag.operator,model_fp_rack_tag,group_fusion_plating_operator,1,0,0,0
|
access_fp_rack_tag_operator,fp.rack.tag.operator,model_fp_rack_tag,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
access_fp_rack_tag_supervisor,fp.rack.tag.supervisor,model_fp_rack_tag,group_fusion_plating_supervisor,1,1,1,1
|
access_fp_rack_tag_supervisor,fp.rack.tag.supervisor,model_fp_rack_tag,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||||
access_fp_rack_tag_manager,fp.rack.tag.manager,model_fp_rack_tag,group_fusion_plating_manager,1,1,1,1
|
access_fp_rack_tag_manager,fp.rack.tag.manager,model_fp_rack_tag,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_job_step_move_operator,fp.job.step.move.operator,model_fp_job_step_move,group_fusion_plating_operator,1,1,1,0
|
access_fp_job_step_move_operator,fp.job.step.move.operator,model_fp_job_step_move,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_job_step_move_supervisor,fp.job.step.move.supervisor,model_fp_job_step_move,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_step_move_supervisor,fp.job.step.move.supervisor,model_fp_job_step_move,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_job_step_move_manager,fp.job.step.move.manager,model_fp_job_step_move,group_fusion_plating_manager,1,1,1,1
|
access_fp_job_step_move_manager,fp.job.step.move.manager,model_fp_job_step_move,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
access_fp_job_step_move_input_value_operator,fp.job.step.move.input.value.operator,model_fp_job_step_move_input_value,group_fusion_plating_operator,1,1,1,0
|
access_fp_job_step_move_input_value_operator,fp.job.step.move.input.value.operator,model_fp_job_step_move_input_value,fusion_plating.group_fp_technician,1,1,1,0
|
||||||
access_fp_job_step_move_input_value_supervisor,fp.job.step.move.input.value.supervisor,model_fp_job_step_move_input_value,group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_step_move_input_value_supervisor,fp.job.step.move.input.value.supervisor,model_fp_job_step_move_input_value,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||||
access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager,model_fp_job_step_move_input_value,group_fusion_plating_manager,1,1,1,1
|
access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager,model_fp_job_step_move_input_value,fusion_plating.group_fp_manager,1,1,1,1
|
||||||
|
access_fp_migration_preview_owner,fp.migration.preview.owner,model_fp_migration_preview,fusion_plating.group_fp_owner,1,1,1,1
|
||||||
|
access_fp_migration_preview_line_owner,fp.migration.preview.line.owner,model_fp_migration_preview_line,fusion_plating.group_fp_owner,1,1,1,1
|
||||||
|
access_ir_actions_actions_plating,ir.actions.actions.plating.read,base.model_ir_actions_actions,fusion_plating.group_fp_technician,1,0,0,0
|
||||||
|
|||||||
|
@@ -321,7 +321,7 @@
|
|||||||
<label>Estimated Duration (min)</label>
|
<label>Estimated Duration (min)</label>
|
||||||
<input type="number" class="form-control" min="0" step="1"
|
<input type="number" class="form-control" min="0" step="1"
|
||||||
t-att-value="state.selectedNode.estimated_duration || 0"
|
t-att-value="state.selectedNode.estimated_duration || 0"
|
||||||
t-on-change="(ev) => { state.selectedNode.estimated_duration = parseFloat(ev.target.value) || 0; }"/>
|
t-on-change="(ev) => { state.selectedNode.estimated_duration = (+ev.target.value) || 0; }"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_re_field">
|
<div class="o_fp_re_field">
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
<label for="fp_re_workflow_state">Triggers Workflow State</label>
|
<label for="fp_re_workflow_state">Triggers Workflow State</label>
|
||||||
<select id="fp_re_workflow_state"
|
<select id="fp_re_workflow_state"
|
||||||
class="form-select"
|
class="form-select"
|
||||||
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
|
||||||
<option value=""
|
<option value=""
|
||||||
t-att-selected="!state.selectedNode.triggers_workflow_state_id">
|
t-att-selected="!state.selectedNode.triggers_workflow_state_id">
|
||||||
— None (use default-kind matching) —
|
— None (use default-kind matching) —
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user