Compare commits
25 Commits
phase6_3-a
...
c34dfce6c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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',
|
||||
'version': '19.0.3.3.0',
|
||||
'version': '19.0.3.5.6',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/clock_correction_views.xml',
|
||||
'views/clock_dashboard_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/clock_schedule_views.xml',
|
||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||
'wizard/clock_nfc_enrollment_views.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',
|
||||
],
|
||||
'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/js/fusion_clock_systray.js',
|
||||
'fusion_clock/static/src/xml/systray_clock.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
|
||||
'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_places.js',
|
||||
'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,
|
||||
'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_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 math
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||
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'))
|
||||
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
|
||||
|
||||
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
|
||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||
new_val = max(break_min, current)
|
||||
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
|
||||
|
||||
now = fields.Datetime.now()
|
||||
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 = {
|
||||
'latitude': latitude,
|
||||
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
|
||||
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
|
||||
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
@@ -359,8 +402,9 @@ class FusionClockAPI(http.Controller):
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
# Check for early clock-out penalty
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
# Log clock-out
|
||||
self._log_activity(
|
||||
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
'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:
|
||||
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,
|
||||
})
|
||||
|
||||
local_today = get_local_today(request.env, employee)
|
||||
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import logging
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
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 = {
|
||||
'latitude': latitude,
|
||||
@@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller):
|
||||
source='kiosk',
|
||||
)
|
||||
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
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)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller):
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
|
||||
@@ -8,6 +8,7 @@ import time
|
||||
import threading
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_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'
|
||||
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 = {
|
||||
'latitude': 0,
|
||||
@@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
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)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
@@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
|
||||
@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
|
||||
], limit=1)
|
||||
|
||||
# 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([
|
||||
('employee_id', '=', employee.id),
|
||||
('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)
|
||||
|
||||
# Week stats
|
||||
today = get_local_today(request.env, employee)
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
||||
week_atts = request.env['hr.attendance'].sudo().search([
|
||||
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
'current_attendance': current_attendance,
|
||||
'today_hours': round(today_hours, 1),
|
||||
'week_hours': round(week_hours, 1),
|
||||
'today_schedule': today_schedule,
|
||||
'recent_attendances': recent,
|
||||
'google_maps_key': google_maps_key,
|
||||
'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_leave_request
|
||||
from . import clock_shift
|
||||
from . import clock_schedule
|
||||
from . import clock_correction
|
||||
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'),
|
||||
('ip_fallback', 'IP Fallback Used'),
|
||||
('streak_milestone', 'Streak Milestone'),
|
||||
('unscheduled_shift', 'Unscheduled Shift'),
|
||||
('card_enrollment', 'Card Enrollment'),
|
||||
('unknown_card_tap', 'Unknown Card Tap'),
|
||||
],
|
||||
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
|
||||
'correction_request': 'Correction Request',
|
||||
'ip_fallback': 'IP Fallback Used',
|
||||
'streak_milestone': 'Streak Milestone',
|
||||
'unscheduled_shift': 'Unscheduled Shift',
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
|
||||
if net > scheduled_hours:
|
||||
@@ -264,11 +275,14 @@ class HrAttendance(models.Model):
|
||||
employee = att.employee_id
|
||||
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()
|
||||
_, 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)
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
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)
|
||||
|
||||
if now > effective_deadline:
|
||||
clock_out_time = min(effective_deadline, now)
|
||||
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
|
||||
# Apply break deduction
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
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().message_post(
|
||||
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
|
||||
|
||||
if yesterday.weekday() >= 5:
|
||||
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)
|
||||
|
||||
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
|
||||
|
||||
if today.weekday() >= 5:
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
|
||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||
)
|
||||
|
||||
def _get_fclk_break_minutes(self):
|
||||
"""Return effective break minutes for this employee.
|
||||
Priority: employee override > shift > global setting.
|
||||
def _get_fclk_schedule_for_date(self, date):
|
||||
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
||||
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()
|
||||
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:
|
||||
return self.x_fclk_break_minutes
|
||||
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):
|
||||
"""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
|
||||
timezone and converted to naive-UTC datetimes so they can be
|
||||
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
||||
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
|
||||
import pytz
|
||||
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
in_hour = self.x_fclk_shift_id.start_time
|
||||
out_hour = self.x_fclk_shift_id.end_time
|
||||
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'))
|
||||
plan = self._get_fclk_day_plan(date)
|
||||
in_hour = plan.get('start_time') or 0.0
|
||||
out_hour = plan.get('end_time') or 0.0
|
||||
|
||||
in_h = int(in_hour)
|
||||
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)
|
||||
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."""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
return self.x_fclk_shift_id.scheduled_hours
|
||||
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'))
|
||||
break_hrs = self._get_fclk_break_minutes() / 60.0
|
||||
return max((out_hour - in_hour) - break_hrs, 0.0)
|
||||
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
|
||||
if plan.get('is_off'):
|
||||
return 0.0
|
||||
return plan.get('hours') or 0.0
|
||||
|
||||
def _compute_absence_counts(self):
|
||||
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_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_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_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
|
||||
@@ -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_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_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
|
||||
|
||||
|
@@ -174,6 +174,49 @@
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</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
|
||||
================================================================ -->
|
||||
@@ -286,4 +329,15 @@
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</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>
|
||||
|
||||
@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
|
||||
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 ---- */
|
||||
.fclk-timer-section {
|
||||
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_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"
|
||||
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 -->
|
||||
<menuitem id="menu_fusion_clock_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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="fclk-timer-section">
|
||||
<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.
@@ -208,6 +208,14 @@ 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.
|
||||
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.
|
||||
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.**
|
||||
|
||||
**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 +236,9 @@ 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.
|
||||
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.
|
||||
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_shopfloor_landing", 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.
|
||||
|
||||
## Naming
|
||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||
@@ -418,6 +429,141 @@ 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 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)
|
||||
|
||||
**Default Shop Floor surface** for new installs (gated by feature flag
|
||||
`ir.config_parameter['fusion_plating_shopfloor.layout']`, values `legacy`
|
||||
or `v2`). Legacy per-step kanban (`fp_shopfloor_landing`) remains
|
||||
accessible by flipping the flag back to `legacy` in Settings → Fusion
|
||||
Plating.
|
||||
|
||||
**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
|
||||
|
||||
### odoo-entech (LXC 111 on pve-worker5)
|
||||
@@ -1132,6 +1278,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) |
|
||||
| **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) |
|
||||
| **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
|
||||
|
||||
@@ -1145,6 +1294,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_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_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
|
||||
|
||||
@@ -1156,13 +1308,13 @@ When you need to override a guard (documented customer deviation, emergency rewo
|
||||
|
||||
### Open scenarios — flagged for next session
|
||||
|
||||
- **S21** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
|
||||
- **S22** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
|
||||
- **S23** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
|
||||
- **S24** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
|
||||
- **S25** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
|
||||
- **S26** — Calibration + permit-expiry cron (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)
|
||||
- **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)
|
||||
- **S24** — 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?
|
||||
- **S26** — 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)
|
||||
- **S28** — 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)
|
||||
|
||||
|
||||
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,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.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.20.8.0',
|
||||
'version': '19.0.21.0.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -80,6 +80,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
],
|
||||
'data': [
|
||||
'security/fp_security.xml',
|
||||
'security/fp_security_v2.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_landing_data.xml',
|
||||
'data/fp_sequence_data.xml',
|
||||
|
||||
@@ -24,15 +24,37 @@
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code"><![CDATA[
|
||||
# Resolve in priority order: user pref → company default → Sale Orders fallback.
|
||||
# Resolve in priority order:
|
||||
# 1. user.x_fc_plating_landing_action_id (per-user override)
|
||||
# 2. company.x_fc_default_landing_action_id (company default)
|
||||
# 3. Shop Floor plant-view kanban (when x_fc_shopfloor_layout='v2')
|
||||
# 4. Sale Orders (when v2 flag unset / legacy)
|
||||
# 5. Process recipes (configurator absent)
|
||||
user = env.user
|
||||
target = False
|
||||
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
|
||||
target = user.x_fc_plating_landing_action_id.sudo()
|
||||
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
|
||||
target = env.company.x_fc_default_landing_action_id.sudo()
|
||||
|
||||
if not target:
|
||||
target = env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False)
|
||||
# 2026-05-23 — plant-view dispatch. Read the layout flag and pick the
|
||||
# appropriate Shop Floor action. Falls through to Sale Orders if no
|
||||
# client action is registered (e.g. shopfloor module not installed).
|
||||
layout = env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_plating_shopfloor.layout', default='legacy',
|
||||
)
|
||||
if layout == 'v2':
|
||||
target = env.ref(
|
||||
'fusion_plating_shopfloor.action_fp_plant_kanban',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
# Legacy or v2-missing → fall through to Sale Orders
|
||||
if not target:
|
||||
target = env.ref(
|
||||
'fusion_plating_configurator.action_fp_sale_orders',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
|
||||
if target:
|
||||
action = target.sudo().read()[0]
|
||||
|
||||
@@ -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")
|
||||
@@ -3,7 +3,10 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# 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):
|
||||
@@ -74,6 +77,113 @@ class FpJobStepMove(models.Model):
|
||||
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):
|
||||
"""Captured value for one transition-input prompt.
|
||||
|
||||
@@ -48,6 +48,26 @@ class FpWorkCentre(models.Model):
|
||||
required=True,
|
||||
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(
|
||||
currency_field='currency_id',
|
||||
help='Used for fp.job.step cost rollups.',
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<!-- Reads most reference data, writes chemistry logs. -->
|
||||
<!-- ================================================================== -->
|
||||
<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="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
@@ -43,7 +43,7 @@
|
||||
<!-- Can manage baths, schedule jobs, review logs. -->
|
||||
<!-- ================================================================== -->
|
||||
<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="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_operator'))]"/>
|
||||
@@ -54,7 +54,7 @@
|
||||
<!-- Full CRUD on configuration objects. -->
|
||||
<!-- ================================================================== -->
|
||||
<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="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_plating_supervisor'))]"/>
|
||||
@@ -65,7 +65,7 @@
|
||||
<!-- Everything a Manager can do, plus system-level settings. -->
|
||||
<!-- ================================================================== -->
|
||||
<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="privilege_id" ref="res_groups_privilege_fusion_plating"/>
|
||||
<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,96 @@
|
||||
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_manager,fp.process.category.manager,model_fusion_plating_process_category,group_fusion_plating_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_manager,fp.process.type.manager,model_fusion_plating_process_type,group_fusion_plating_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_manager,fp.bath.parameter.manager,model_fusion_plating_bath_parameter,group_fusion_plating_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_supervisor,fp.facility.supervisor,model_fusion_plating_facility,group_fusion_plating_supervisor,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_work_center_operator,fp.work.center.operator,model_fusion_plating_work_center,group_fusion_plating_operator,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_manager,fp.work.center.manager,model_fusion_plating_work_center,group_fusion_plating_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_supervisor,fp.tank.supervisor,model_fusion_plating_tank,group_fusion_plating_supervisor,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_section_operator,fp.tank.section.operator,model_fusion_plating_tank_section,group_fusion_plating_operator,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_manager,fp.tank.section.manager,model_fusion_plating_tank_section,group_fusion_plating_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_supervisor,fp.tank.composition.supervisor,model_fusion_plating_tank_composition,group_fusion_plating_supervisor,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_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_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_manager,fp.tank.composition.ingredient.manager,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_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_supervisor,fp.bath.supervisor,model_fusion_plating_bath,group_fusion_plating_supervisor,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_target_operator,fp.bath.target.operator,model_fusion_plating_bath_target,group_fusion_plating_operator,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_manager,fp.bath.target.manager,model_fusion_plating_bath_target,group_fusion_plating_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_supervisor,fp.bath.log.supervisor,model_fusion_plating_bath_log,group_fusion_plating_supervisor,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_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_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_manager,fp.bath.log.line.manager,model_fusion_plating_bath_log_line,group_fusion_plating_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_supervisor,fp.process.node.supervisor,model_fusion_plating_process_node,group_fusion_plating_supervisor,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_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_rack_operator,fp.rack.operator,model_fusion_plating_rack,group_fusion_plating_operator,1,1,0,0
|
||||
access_fp_rack_supervisor,fp.rack.supervisor,model_fusion_plating_rack,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_rack_manager,fp.rack.manager,model_fusion_plating_rack,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_replenishment_rule_operator,fp.replenishment.rule.operator,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_replenishment_rule_supervisor,fp.replenishment.rule.supervisor,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_replenishment_suggestion_operator,fp.replenishment.suggestion.operator,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_replenishment_suggestion_supervisor,fp.replenishment.suggestion.supervisor,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_operator_cert_operator,fp.operator.cert.operator,model_fp_operator_certification,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_operator_cert_supervisor,fp.operator.cert.supervisor,model_fp_operator_certification,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certification,group_fusion_plating_manager,1,1,1,1
|
||||
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_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fusion_plating_supervisor,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_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fusion_plating_operator,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_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_plating_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_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,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_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_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_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_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_manager,fp.work.role.manager,model_fp_work_role,group_fusion_plating_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_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,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_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,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_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_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_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_manager,fp.step.kind.default.input.manager,model_fp_step_kind_default_input,group_fusion_plating_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_supervisor,fp.step.template.supervisor,model_fp_step_template,group_fusion_plating_supervisor,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_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_supervisor,fp.step.template.input.supervisor,model_fp_step_template_input,group_fusion_plating_supervisor,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_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_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_manager,fp.step.template.transition.input.manager,model_fp_step_template_transition_input,group_fusion_plating_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_supervisor,fp.rack.tag.supervisor,model_fp_rack_tag,group_fusion_plating_supervisor,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_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_supervisor,fp.job.step.move.supervisor,model_fp_job_step_move,group_fusion_plating_supervisor,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_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_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_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_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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
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,fusion_plating.group_fp_technician,1,0,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,1,1,0,0
|
||||
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,fusion_plating.group_fp_technician,1,0,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,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,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,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,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
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,fusion_plating.group_fp_technician,1,0,0,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,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,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,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,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,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,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,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,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
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,fusion_plating.group_fp_technician,1,0,0,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
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_fp_technician,1,0,0,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_fp_manager,1,1,1,1
|
||||
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_fp_shop_manager_v2,1,1,1,0
|
||||
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_fp_technician,1,1,0,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_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_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_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_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
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,fusion_plating.group_fp_technician,1,0,0,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,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,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,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
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,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,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,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,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,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
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,fusion_plating.group_fp_shop_manager_v2,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,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,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,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,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,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,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -321,7 +321,7 @@
|
||||
<label>Estimated Duration (min)</label>
|
||||
<input type="number" class="form-control" min="0" step="1"
|
||||
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 class="o_fp_re_field">
|
||||
@@ -380,7 +380,7 @@
|
||||
<label for="fp_re_workflow_state">Triggers Workflow State</label>
|
||||
<select id="fp_re_workflow_state"
|
||||
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=""
|
||||
t-att-selected="!state.selectedNode.triggers_workflow_state_id">
|
||||
— None (use default-kind matching) —
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
t-if="state.workflowStates and state.workflowStates.length">
|
||||
<label>Triggers Workflow State</label>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value="" t-att-selected="!state.editTriggersWorkflowStateId">— None (use Step Type) —</option>
|
||||
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
|
||||
<option t-att-value="ws.id"
|
||||
@@ -598,7 +598,7 @@
|
||||
t-if="state.workflowStates and state.workflowStates.length">
|
||||
<label class="form-label">Triggers Workflow State</label>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value=""
|
||||
t-att-selected="!state.libraryEditor.triggers_workflow_state_id">
|
||||
— None (use default-kind matching) —
|
||||
|
||||
@@ -3,3 +3,6 @@ from . import test_fp_work_centre
|
||||
from . import test_fp_job_state_machine
|
||||
from . import test_fp_job_step_state_machine
|
||||
from . import test_simple_recipe_flatten
|
||||
from . import test_role_groups
|
||||
from . import test_acl_migration
|
||||
from . import test_quality_split
|
||||
|
||||
56
fusion_plating/fusion_plating/tests/test_acl_migration.py
Normal file
56
fusion_plating/fusion_plating/tests/test_acl_migration.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_perms')
|
||||
class TestAclMigration(TransactionCase):
|
||||
"""Sample-based ACL coverage: pick 1 model per role and verify access."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Users = self.env['res.users'].with_context(no_reset_password=True)
|
||||
def make(login, group_xmlid):
|
||||
return Users.create({
|
||||
'login': f'fp_test_{login}',
|
||||
'name': f'FP Test {login.title()}',
|
||||
'email': f'fp_test_{login}@example.com',
|
||||
'groups_id': [(6, 0, [self.env.ref(group_xmlid).id])],
|
||||
})
|
||||
|
||||
self.u_tech = make('tech', 'fusion_plating.group_fp_technician')
|
||||
self.u_sm = make('sm', 'fusion_plating.group_fp_shop_manager_v2')
|
||||
self.u_mgr = make('mgr', 'fusion_plating.group_fp_manager')
|
||||
self.u_qm = make('qm', 'fusion_plating.group_fp_quality_manager')
|
||||
self.u_sr = make('sr', 'fusion_plating.group_fp_sales_rep')
|
||||
self.u_smg = make('smg', 'fusion_plating.group_fp_sales_manager')
|
||||
|
||||
def test_technician_can_read_jobs(self):
|
||||
Jobs = self.env['fp.job'].with_user(self.u_tech)
|
||||
Jobs.check_access_rights('read')
|
||||
|
||||
def test_technician_cannot_read_part_catalog(self):
|
||||
Parts = self.env['fp.part.catalog'].with_user(self.u_tech)
|
||||
with self.assertRaises(AccessError):
|
||||
Parts.check_access_rights('read')
|
||||
|
||||
def test_sales_rep_can_read_part_catalog(self):
|
||||
Parts = self.env['fp.part.catalog'].with_user(self.u_sr)
|
||||
Parts.check_access_rights('read')
|
||||
|
||||
def test_shop_manager_can_read_receiving(self):
|
||||
Rec = self.env['fp.receiving'].with_user(self.u_sm)
|
||||
Rec.check_access_rights('read')
|
||||
|
||||
def test_manager_can_create_ncr(self):
|
||||
Ncr = self.env['fusion.plating.ncr'].with_user(self.u_mgr)
|
||||
Ncr.check_access_rights('create')
|
||||
|
||||
def test_manager_can_only_read_capa(self):
|
||||
Capa = self.env['fusion.plating.capa'].with_user(self.u_mgr)
|
||||
Capa.check_access_rights('read')
|
||||
with self.assertRaises(AccessError):
|
||||
Capa.check_access_rights('write')
|
||||
|
||||
def test_qm_can_write_capa(self):
|
||||
Capa = self.env['fusion.plating.capa'].with_user(self.u_qm)
|
||||
Capa.check_access_rights('write')
|
||||
90
fusion_plating/fusion_plating/tests/test_quality_split.py
Normal file
90
fusion_plating/fusion_plating/tests/test_quality_split.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_perms')
|
||||
class TestQualitySplit(TransactionCase):
|
||||
"""Section 2.C of spec: Manager handles reactive Quality;
|
||||
QM exclusively owns CAPA close, Audit, AVL, Customer Spec, FAIR/Nadcap signing."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Users = self.env['res.users'].with_context(no_reset_password=True)
|
||||
self.u_mgr = Users.create({
|
||||
'login': 'qsplit_mgr', 'name': 'QSplit Mgr',
|
||||
'email': 'qsplit_mgr@example.com',
|
||||
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_manager').id])],
|
||||
})
|
||||
self.u_qm = Users.create({
|
||||
'login': 'qsplit_qm', 'name': 'QSplit QM',
|
||||
'email': 'qsplit_qm@example.com',
|
||||
'groups_id': [(6, 0, [self.env.ref('fusion_plating.group_fp_quality_manager').id])],
|
||||
})
|
||||
|
||||
# CAPA: Manager read-only, QM full
|
||||
def test_manager_can_read_capa(self):
|
||||
self.env['fusion.plating.capa'].with_user(self.u_mgr).check_access_rights('read')
|
||||
|
||||
def test_manager_cannot_write_capa(self):
|
||||
with self.assertRaises(AccessError):
|
||||
self.env['fusion.plating.capa'].with_user(self.u_mgr).check_access_rights('write')
|
||||
|
||||
def test_manager_cannot_create_capa(self):
|
||||
with self.assertRaises(AccessError):
|
||||
self.env['fusion.plating.capa'].with_user(self.u_mgr).check_access_rights('create')
|
||||
|
||||
def test_qm_can_write_capa(self):
|
||||
self.env['fusion.plating.capa'].with_user(self.u_qm).check_access_rights('write')
|
||||
|
||||
# Audit: Manager read-only, QM full
|
||||
def test_manager_can_read_audit(self):
|
||||
Audit = self.env.get('fusion.plating.audit')
|
||||
if not Audit:
|
||||
self.skipTest('fusion.plating.audit model not available')
|
||||
Audit.with_user(self.u_mgr).check_access_rights('read')
|
||||
|
||||
def test_manager_cannot_write_audit(self):
|
||||
Audit = self.env.get('fusion.plating.audit')
|
||||
if not Audit:
|
||||
self.skipTest('fusion.plating.audit model not available')
|
||||
with self.assertRaises(AccessError):
|
||||
Audit.with_user(self.u_mgr).check_access_rights('write')
|
||||
|
||||
def test_qm_can_write_audit(self):
|
||||
Audit = self.env.get('fusion.plating.audit')
|
||||
if not Audit:
|
||||
self.skipTest('fusion.plating.audit model not available')
|
||||
Audit.with_user(self.u_qm).check_access_rights('write')
|
||||
|
||||
# NCR: Manager full
|
||||
def test_manager_can_create_ncr(self):
|
||||
self.env['fusion.plating.ncr'].with_user(self.u_mgr).check_access_rights('create')
|
||||
|
||||
def test_manager_can_write_ncr(self):
|
||||
self.env['fusion.plating.ncr'].with_user(self.u_mgr).check_access_rights('write')
|
||||
|
||||
# Hold: Manager full
|
||||
def test_manager_can_create_hold(self):
|
||||
self.env['fusion.plating.quality.hold'].with_user(self.u_mgr).check_access_rights('create')
|
||||
|
||||
# AVL: Manager read-only, QM full
|
||||
def test_manager_can_read_avl(self):
|
||||
Avl = self.env.get('fusion.plating.avl')
|
||||
if not Avl:
|
||||
self.skipTest('fusion.plating.avl model not available')
|
||||
Avl.with_user(self.u_mgr).check_access_rights('read')
|
||||
|
||||
def test_manager_cannot_write_avl(self):
|
||||
Avl = self.env.get('fusion.plating.avl')
|
||||
if not Avl:
|
||||
self.skipTest('fusion.plating.avl model not available')
|
||||
with self.assertRaises(AccessError):
|
||||
Avl.with_user(self.u_mgr).check_access_rights('write')
|
||||
|
||||
# Customer Spec: Manager read-only, QM full
|
||||
def test_manager_can_read_customer_spec(self):
|
||||
self.env['fusion.plating.customer.spec'].with_user(self.u_mgr).check_access_rights('read')
|
||||
|
||||
def test_manager_cannot_write_customer_spec(self):
|
||||
with self.assertRaises(AccessError):
|
||||
self.env['fusion.plating.customer.spec'].with_user(self.u_mgr).check_access_rights('write')
|
||||
104
fusion_plating/fusion_plating/tests/test_role_groups.py
Normal file
104
fusion_plating/fusion_plating/tests/test_role_groups.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_perms')
|
||||
class TestRoleGroupsStructure(TransactionCase):
|
||||
"""Verify the 8 new roles exist with correct implied_ids chains.
|
||||
|
||||
Part of Phase 1 permissions overhaul. See:
|
||||
docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md
|
||||
"""
|
||||
|
||||
def test_all_seven_groups_exist(self):
|
||||
"""The 7 new res.groups records must all be defined. (The 8th role 'No'
|
||||
is implicit — absence of any plating group.)"""
|
||||
xmlids = {
|
||||
'group_fp_technician', 'group_fp_sales_rep',
|
||||
'group_fp_shop_manager_v2', 'group_fp_sales_manager',
|
||||
'group_fp_manager', 'group_fp_quality_manager', 'group_fp_owner',
|
||||
}
|
||||
for xmlid in xmlids:
|
||||
grp = self.env.ref(f'fusion_plating.{xmlid}', raise_if_not_found=False)
|
||||
self.assertTrue(grp, f'Group {xmlid} not found')
|
||||
|
||||
def test_owner_implies_quality_manager(self):
|
||||
owner = self.env.ref('fusion_plating.group_fp_owner')
|
||||
qm = self.env.ref('fusion_plating.group_fp_quality_manager')
|
||||
self.assertIn(qm, owner.implied_ids)
|
||||
|
||||
def test_owner_implies_system(self):
|
||||
owner = self.env.ref('fusion_plating.group_fp_owner')
|
||||
system = self.env.ref('base.group_system')
|
||||
self.assertIn(system, owner.trans_implied_ids,
|
||||
'Owner must transitively imply base.group_system')
|
||||
|
||||
def test_manager_implies_both_branches(self):
|
||||
"""Manager is the diamond apex — must imply both Shop Manager and Sales Manager."""
|
||||
mgr = self.env.ref('fusion_plating.group_fp_manager')
|
||||
sm = self.env.ref('fusion_plating.group_fp_shop_manager_v2')
|
||||
sales_mgr = self.env.ref('fusion_plating.group_fp_sales_manager')
|
||||
self.assertIn(sm, mgr.implied_ids, 'Manager must imply Shop Manager (diamond)')
|
||||
self.assertIn(sales_mgr, mgr.implied_ids, 'Manager must imply Sales Manager (diamond)')
|
||||
|
||||
def test_technician_does_not_imply_sales_rep(self):
|
||||
"""Sales and Shop branches must remain orthogonal at the leaf."""
|
||||
tech = self.env.ref('fusion_plating.group_fp_technician')
|
||||
sales_rep = self.env.ref('fusion_plating.group_fp_sales_rep')
|
||||
self.assertNotIn(sales_rep, tech.trans_implied_ids,
|
||||
'Technician must NOT see Sales Rep menus')
|
||||
|
||||
def test_sales_rep_does_not_imply_technician(self):
|
||||
sales_rep = self.env.ref('fusion_plating.group_fp_sales_rep')
|
||||
tech = self.env.ref('fusion_plating.group_fp_technician')
|
||||
self.assertNotIn(tech, sales_rep.trans_implied_ids,
|
||||
'Sales Rep must NOT see Workstation')
|
||||
|
||||
def test_owner_auto_assigned_to_uid_1_and_2(self):
|
||||
owner = self.env.ref('fusion_plating.group_fp_owner')
|
||||
user_ids = owner.user_ids.ids
|
||||
self.assertIn(1, user_ids, 'Owner must include uid 1 (__system__)')
|
||||
self.assertIn(2, user_ids, 'Owner must include uid 2 (admin)')
|
||||
|
||||
def test_sequence_numbers_are_unique(self):
|
||||
seqs = [
|
||||
self.env.ref(f'fusion_plating.{x}').sequence
|
||||
for x in ('group_fp_technician', 'group_fp_sales_rep',
|
||||
'group_fp_shop_manager_v2', 'group_fp_sales_manager',
|
||||
'group_fp_manager', 'group_fp_quality_manager', 'group_fp_owner')
|
||||
]
|
||||
self.assertEqual(len(seqs), len(set(seqs)),
|
||||
f'All sequence numbers must be unique, got {seqs}')
|
||||
|
||||
def test_new_groups_imply_old_for_backward_compat(self):
|
||||
"""During the 30-day rollback window, new groups must trigger old ACLs."""
|
||||
tech = self.env.ref('fusion_plating.group_fp_technician')
|
||||
old_op = self.env.ref('fusion_plating.group_fusion_plating_operator')
|
||||
self.assertIn(old_op, tech.trans_implied_ids)
|
||||
|
||||
mgr = self.env.ref('fusion_plating.group_fp_manager')
|
||||
old_mgr = self.env.ref('fusion_plating.group_fusion_plating_manager')
|
||||
self.assertIn(old_mgr, mgr.trans_implied_ids)
|
||||
|
||||
def test_owner_implies_all_old_groups_via_cross_module_chain(self):
|
||||
"""Owner must transitively reach every old group (admin, manager, supervisor,
|
||||
operator, estimator, receiving, accounting, cgp_officer, cgp_designated_official)
|
||||
via the implication chain spread across fusion_plating + 4 downstream module
|
||||
security files."""
|
||||
owner = self.env.ref('fusion_plating.group_fp_owner')
|
||||
expected_old = [
|
||||
'fusion_plating.group_fusion_plating_admin',
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
'fusion_plating.group_fusion_plating_supervisor',
|
||||
'fusion_plating.group_fusion_plating_operator',
|
||||
'fusion_plating_configurator.group_fp_estimator',
|
||||
'fusion_plating_receiving.group_fp_receiving',
|
||||
'fusion_plating_invoicing.group_fp_accounting',
|
||||
'fusion_plating_cgp.group_fusion_plating_cgp_officer',
|
||||
'fusion_plating_cgp.group_fusion_plating_cgp_designated_official',
|
||||
]
|
||||
for xmlid in expected_old:
|
||||
old_grp = self.env.ref(xmlid, raise_if_not_found=False)
|
||||
if not old_grp:
|
||||
continue # Module not installed
|
||||
self.assertIn(old_grp, owner.trans_implied_ids,
|
||||
f'Owner must transitively imply {xmlid} for backward-compat')
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Aerospace (AS9100 + Nadcap)',
|
||||
'version': '19.0.1.1.0',
|
||||
'version': '19.0.1.1.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Aerospace industry pack: AS9100 Rev D clause library, Nadcap AC7108 '
|
||||
'audits, counterfeit parts prevention, config management, risk register, '
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_as9100_clause_operator,fp.as9100.clause.operator,model_fusion_plating_as9100_clause,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_as9100_clause_supervisor,fp.as9100.clause.supervisor,model_fusion_plating_as9100_clause,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_as9100_clause_manager,fp.as9100.clause.manager,model_fusion_plating_as9100_clause,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_nadcap_audit_operator,fp.nadcap.audit.operator,model_fusion_plating_nadcap_audit,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_nadcap_audit_supervisor,fp.nadcap.audit.supervisor,model_fusion_plating_nadcap_audit,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_nadcap_audit_manager,fp.nadcap.audit.manager,model_fusion_plating_nadcap_audit,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_counterfeit_operator,fp.counterfeit.operator,model_fusion_plating_counterfeit_prevention,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_counterfeit_supervisor,fp.counterfeit.supervisor,model_fusion_plating_counterfeit_prevention,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_counterfeit_manager,fp.counterfeit.manager,model_fusion_plating_counterfeit_prevention,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_config_item_operator,fp.config.item.operator,model_fusion_plating_config_item,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_config_item_supervisor,fp.config.item.supervisor,model_fusion_plating_config_item,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_config_item_manager,fp.config.item.manager,model_fusion_plating_config_item,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_risk_operator,fp.risk.operator,model_fusion_plating_risk,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_risk_supervisor,fp.risk.supervisor,model_fusion_plating_risk,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_risk_manager,fp.risk.manager,model_fusion_plating_risk,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_as9100_clause_operator,fp.as9100.clause.operator,model_fusion_plating_as9100_clause,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_as9100_clause_supervisor,fp.as9100.clause.supervisor,model_fusion_plating_as9100_clause,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_as9100_clause_manager,fp.as9100.clause.manager,model_fusion_plating_as9100_clause,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_nadcap_audit_operator,fp.nadcap.audit.operator,model_fusion_plating_nadcap_audit,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_nadcap_audit_supervisor,fp.nadcap.audit.supervisor,model_fusion_plating_nadcap_audit,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_nadcap_audit_manager,fp.nadcap.audit.manager,model_fusion_plating_nadcap_audit,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_counterfeit_operator,fp.counterfeit.operator,model_fusion_plating_counterfeit_prevention,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_counterfeit_supervisor,fp.counterfeit.supervisor,model_fusion_plating_counterfeit_prevention,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_counterfeit_manager,fp.counterfeit.manager,model_fusion_plating_counterfeit_prevention,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_config_item_operator,fp.config.item.operator,model_fusion_plating_config_item,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_config_item_supervisor,fp.config.item.supervisor,model_fusion_plating_config_item,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_config_item_manager,fp.config.item.manager,model_fusion_plating_config_item,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_risk_operator,fp.risk.operator,model_fusion_plating_risk,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_risk_supervisor,fp.risk.supervisor,model_fusion_plating_risk,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_risk_manager,fp.risk.manager,model_fusion_plating_risk,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Batch Processing',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.0.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Group parts into rack or barrel loads for tank processing.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_batch_operator,fp.batch.operator,model_fusion_plating_batch,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_batch_supervisor,fp.batch.supervisor,model_fusion_plating_batch,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_batch_manager,fp.batch.manager,model_fusion_plating_batch,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_batch_chemistry_operator,fp.batch.chemistry.operator,model_fusion_plating_batch_chemistry,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_batch_chemistry_supervisor,fp.batch.chemistry.supervisor,model_fusion_plating_batch_chemistry,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_batch_chemistry_manager,fp.batch.chemistry.manager,model_fusion_plating_batch_chemistry,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_batch_operator,fp.batch.operator,model_fusion_plating_batch,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_batch_supervisor,fp.batch.supervisor,model_fusion_plating_batch,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_batch_manager,fp.batch.manager,model_fusion_plating_batch,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_batch_chemistry_operator,fp.batch.chemistry.operator,model_fusion_plating_batch_chemistry,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_batch_chemistry_supervisor,fp.batch.chemistry.supervisor,model_fusion_plating_batch_chemistry,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_batch_chemistry_manager,fp.batch.chemistry.manager,model_fusion_plating_batch_chemistry,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Documents Bridge (EE)',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Enterprise bridge: auto-promotes Fusion Plating quality attachments '
|
||||
'(NCR, CAPA, FAIR, Doc Control) into Odoo EE Documents with a tagged '
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_documents_document_fp_operator,documents.document.fp.operator,documents.model_documents_document,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_documents_document_fp_supervisor,documents.document.fp.supervisor,documents.model_documents_document,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_documents_document_fp_manager,documents.document.fp.manager,documents.model_documents_document,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_documents_tag_fp_operator,documents.tag.fp.operator,documents.model_documents_tag,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_documents_tag_fp_manager,documents.tag.fp.manager,documents.model_documents_tag,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_documents_facet_fp_operator,documents.facet.fp.operator,documents.model_documents_facet,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_documents_facet_fp_manager,documents.facet.fp.manager,documents.model_documents_facet,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_documents_document_fp_operator,documents.document.fp.operator,documents.model_documents_document,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_documents_document_fp_supervisor,documents.document.fp.supervisor,documents.model_documents_document,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_documents_document_fp_manager,documents.document.fp.manager,documents.model_documents_document,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_documents_tag_fp_operator,documents.tag.fp.operator,documents.model_documents_tag,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_documents_tag_fp_manager,documents.tag.fp.manager,documents.model_documents_tag,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_documents_facet_fp_operator,documents.facet.fp.operator,documents.model_documents_facet,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_documents_facet_fp_manager,documents.facet.fp.manager,documents.model_documents_facet,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Maintenance Bridge',
|
||||
'version': '19.0.1.2.0',
|
||||
'version': '19.0.1.2.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge standard Odoo Maintenance with Fusion Plating equipment, '
|
||||
'plans, checklists, and sensor integration.',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_maintenance_plan_operator,fp.maintenance.plan.operator,model_fp_maintenance_plan,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_maintenance_plan_supervisor,fp.maintenance.plan.supervisor,model_fp_maintenance_plan,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_maintenance_plan_manager,fp.maintenance.plan.manager,model_fp_maintenance_plan,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_maintenance_node_operator,fp.maintenance.node.operator,model_fp_maintenance_node,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_maintenance_node_supervisor,fp.maintenance.node.supervisor,model_fp_maintenance_node,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_maintenance_node_manager,fp.maintenance.node.manager,model_fp_maintenance_node,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_maintenance_label_operator,fp.maintenance.label.operator,model_fp_maintenance_label,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_maintenance_label_supervisor,fp.maintenance.label.supervisor,model_fp_maintenance_label,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_maintenance_label_manager,fp.maintenance.label.manager,model_fp_maintenance_label,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_maintenance_plan_operator,fp.maintenance.plan.operator,model_fp_maintenance_plan,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_maintenance_plan_supervisor,fp.maintenance.plan.supervisor,model_fp_maintenance_plan,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_maintenance_plan_manager,fp.maintenance.plan.manager,model_fp_maintenance_plan,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_maintenance_node_operator,fp.maintenance.node.operator,model_fp_maintenance_node,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_maintenance_node_supervisor,fp.maintenance.node.supervisor,model_fp_maintenance_node,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_maintenance_node_manager,fp.maintenance.node.manager,model_fp_maintenance_node,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_maintenance_label_operator,fp.maintenance.label.operator,model_fp_maintenance_label,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_maintenance_label_supervisor,fp.maintenance.label.supervisor,model_fp_maintenance_label,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_maintenance_label_manager,fp.maintenance.label.manager,model_fp_maintenance_label,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.13.0.3',
|
||||
'version': '19.0.13.0.4',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_bridge_mrp_workcenter_manager,fp.bridge.mrp.workcenter.manager,mrp.model_mrp_workcenter,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workcenter_supervisor,fp.bridge.mrp.workcenter.supervisor,mrp.model_mrp_workcenter,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_job_node_override_legacy_operator,fusion.plating.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_job_node_override_legacy_supervisor,fusion.plating.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_node_override_legacy_manager,fusion.plating.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_bridge_mrp_workcenter_manager,fp.bridge.mrp.workcenter.manager,mrp.model_mrp_workcenter,fusion_plating.group_fp_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workcenter_supervisor,fp.bridge.mrp.workcenter.supervisor,mrp.model_mrp_workcenter,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_workorder.model_mrp_workorder,fusion_plating.group_fp_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fp_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_job_node_override_legacy_operator,fusion.plating.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_job_node_override_legacy_supervisor,fusion.plating.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_job_node_override_legacy_manager,fusion.plating.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality Bridge (EE)',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Enterprise bridge: mirrors Fusion Plating NCRs into Odoo EE '
|
||||
'quality.alert for native dashboard integration. Auto-installs '
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_bridge_quality_alert_manager,fp.bridge.quality.alert.manager,quality.model_quality_alert,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_quality_alert_supervisor,fp.bridge.quality.alert.supervisor,quality.model_quality_alert,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_quality_alert_stage_manager,fp.bridge.quality.alert.stage.manager,quality.model_quality_alert_stage,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_bridge_quality_alert_team_manager,fp.bridge.quality.alert.team.manager,quality.model_quality_alert_team,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_bridge_quality_alert_manager,fp.bridge.quality.alert.manager,quality.model_quality_alert,fusion_plating.group_fp_manager,1,1,1,0
|
||||
access_fp_bridge_quality_alert_supervisor,fp.bridge.quality.alert.supervisor,quality.model_quality_alert,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_bridge_quality_alert_stage_manager,fp.bridge.quality.alert.stage.manager,quality.model_quality_alert_stage,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_bridge_quality_alert_team_manager,fp.bridge.quality.alert.team.manager,quality.model_quality_alert_team,fusion_plating.group_fp_manager,1,0,0,0
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — E-Sign Bridge (EE)',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Enterprise bridge: wires Fusion Plating FAIR into Odoo EE Sign for '
|
||||
'legally-binding customer CoC acceptance. Auto-installs when Sign '
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_bridge_sign_request_read,fp.bridge.sign.request.read,sign.model_sign_request,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_bridge_sign_request_supervisor_read,fp.bridge.sign.request.supervisor.read,sign.model_sign_request,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_sign_request_read,fp.bridge.sign.request.read,sign.model_sign_request,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_bridge_sign_request_supervisor_read,fp.bridge.sign.request.supervisor.read,sign.model_sign_request,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.7.9.0',
|
||||
'version': '19.0.7.9.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
@@ -32,6 +32,7 @@ Includes Fischerscope thickness measurement data capture.
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'security/fp_cert_security.xml',
|
||||
'data/fp_certificate_sequence_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_certificate_views.xml',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<record id="rule_fp_certificate_fair_nadcap_qm_only" model="ir.rule">
|
||||
<field name="name">FP Certificate: FAIR/Nadcap edit restricted to Quality Manager</field>
|
||||
<field name="model_id" ref="model_fp_certificate"/>
|
||||
<field name="domain_force">[('cert_type', 'not in', ('fair', 'nadcap'))]</field>
|
||||
<field name="groups" eval="[(4, ref('fusion_plating.group_fp_manager'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fp_certificate_all_qm" model="ir.rule">
|
||||
<field name="name">FP Certificate: QM has full access to all certs</field>
|
||||
<field name="model_id" ref="model_fp_certificate"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('fusion_plating.group_fp_quality_manager'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,13 +1,13 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
|
||||
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fp_technician,1,1,0,0
|
||||
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_thickness_upload_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Controlled Goods Program',
|
||||
'version': '19.0.1.1.0',
|
||||
'version': '19.0.1.2.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Canadian Controlled Goods Program (CGP) compliance for plating '
|
||||
'shops handling defence work: registration, authorized individuals, '
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<!-- CGP OFFICER: day-to-day CGP compliance operator -->
|
||||
<record id="group_fusion_plating_cgp_officer" model="res.groups">
|
||||
<field name="name">CGP Officer</field>
|
||||
<field name="name">[DEPRECATED] CGP Officer</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="privilege_id"
|
||||
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<!-- CGP DESIGNATED OFFICIAL: legally accountable per PSPC registration -->
|
||||
<record id="group_fusion_plating_cgp_designated_official" model="res.groups">
|
||||
<field name="name">CGP Designated Official</field>
|
||||
<field name="name">[DEPRECATED] CGP Designated Official</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="privilege_id"
|
||||
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
@@ -35,6 +35,15 @@
|
||||
eval="[(4, ref('group_fusion_plating_cgp_officer'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Backward-compat: new Quality Manager implies old CGP Officer; new Owner implies old CGP DO. -->
|
||||
<record id="fusion_plating.group_fp_quality_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_plating_cgp.group_fusion_plating_cgp_officer'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="fusion_plating.group_fp_owner" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_plating_cgp.group_fusion_plating_cgp_designated_official'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- RECORD RULES -->
|
||||
<!-- -->
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_cgp_registration_manager,fp.cgp.registration.manager,model_fusion_plating_cgp_registration,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_cgp_registration_officer,fp.cgp.registration.officer,model_fusion_plating_cgp_registration,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_ai_manager,fp.cgp.ai.manager,model_fusion_plating_cgp_authorized_individual,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_cgp_ai_officer,fp.cgp.ai.officer,model_fusion_plating_cgp_authorized_individual,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_psa_officer,fp.cgp.psa.officer,model_fusion_plating_cgp_psa,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_visitor_supervisor,fp.cgp.visitor.supervisor,model_fusion_plating_cgp_visitor,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_cgp_visitor_manager,fp.cgp.visitor.manager,model_fusion_plating_cgp_visitor,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_cgp_visitor_officer,fp.cgp.visitor.officer,model_fusion_plating_cgp_visitor,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_controlled_good_supervisor,fp.cgp.good.supervisor,model_fusion_plating_cgp_controlled_good,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_cgp_controlled_good_manager,fp.cgp.good.manager,model_fusion_plating_cgp_controlled_good,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_cgp_controlled_good_officer,fp.cgp.good.officer,model_fusion_plating_cgp_controlled_good,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_receipt_supervisor,fp.cgp.receipt.supervisor,model_fusion_plating_cgp_receipt_shipment,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_cgp_receipt_manager,fp.cgp.receipt.manager,model_fusion_plating_cgp_receipt_shipment,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_cgp_receipt_officer,fp.cgp.receipt.officer,model_fusion_plating_cgp_receipt_shipment,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_incident_officer,fp.cgp.incident.officer,model_fusion_plating_cgp_security_incident,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_access_log_supervisor,fp.cgp.access.log.supervisor,model_fusion_plating_cgp_access_log,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_cgp_access_log_manager,fp.cgp.access.log.manager,model_fusion_plating_cgp_access_log,fusion_plating.group_fusion_plating_manager,1,0,0,0
|
||||
access_fp_cgp_access_log_officer,fp.cgp.access.log.officer,model_fusion_plating_cgp_access_log,group_fusion_plating_cgp_officer,1,1,1,1
|
||||
access_fp_cgp_registration_manager,fp.cgp.registration.manager,model_fusion_plating_cgp_registration,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_cgp_registration_officer,fp.cgp.registration.officer,model_fusion_plating_cgp_registration,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
access_fp_cgp_ai_manager,fp.cgp.ai.manager,model_fusion_plating_cgp_authorized_individual,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_cgp_ai_officer,fp.cgp.ai.officer,model_fusion_plating_cgp_authorized_individual,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
access_fp_cgp_psa_officer,fp.cgp.psa.officer,model_fusion_plating_cgp_psa,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
access_fp_cgp_visitor_supervisor,fp.cgp.visitor.supervisor,model_fusion_plating_cgp_visitor,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_cgp_visitor_manager,fp.cgp.visitor.manager,model_fusion_plating_cgp_visitor,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_cgp_visitor_officer,fp.cgp.visitor.officer,model_fusion_plating_cgp_visitor,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
access_fp_cgp_controlled_good_supervisor,fp.cgp.good.supervisor,model_fusion_plating_cgp_controlled_good,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_cgp_controlled_good_manager,fp.cgp.good.manager,model_fusion_plating_cgp_controlled_good,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_cgp_controlled_good_officer,fp.cgp.good.officer,model_fusion_plating_cgp_controlled_good,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
access_fp_cgp_receipt_supervisor,fp.cgp.receipt.supervisor,model_fusion_plating_cgp_receipt_shipment,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_cgp_receipt_manager,fp.cgp.receipt.manager,model_fusion_plating_cgp_receipt_shipment,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_cgp_receipt_officer,fp.cgp.receipt.officer,model_fusion_plating_cgp_receipt_shipment,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
access_fp_cgp_incident_officer,fp.cgp.incident.officer,model_fusion_plating_cgp_security_incident,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
access_fp_cgp_access_log_supervisor,fp.cgp.access.log.supervisor,model_fusion_plating_cgp_access_log,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_cgp_access_log_manager,fp.cgp.access.log.manager,model_fusion_plating_cgp_access_log,fusion_plating.group_fp_manager,1,0,0,0
|
||||
access_fp_cgp_access_log_officer,fp.cgp.access.log.officer,model_fusion_plating_cgp_access_log,fusion_plating.group_fp_quality_manager,1,1,1,1
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating - Compliance (Framework)',
|
||||
'version': '19.0.1.3.0',
|
||||
'version': '19.0.1.3.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
|
||||
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_jurisdiction_operator,fp.jurisdiction.operator,model_fusion_plating_jurisdiction,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_jurisdiction_supervisor,fp.jurisdiction.supervisor,model_fusion_plating_jurisdiction,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_jurisdiction_manager,fp.jurisdiction.manager,model_fusion_plating_jurisdiction,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_regulator_operator,fp.regulator.operator,model_fusion_plating_regulator,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_regulator_supervisor,fp.regulator.supervisor,model_fusion_plating_regulator,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_regulator_manager,fp.regulator.manager,model_fusion_plating_regulator,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_permit_operator,fp.permit.operator,model_fusion_plating_permit,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_permit_supervisor,fp.permit.supervisor,model_fusion_plating_permit,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_permit_manager,fp.permit.manager,model_fusion_plating_permit,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_permit_condition_operator,fp.permit.condition.operator,model_fusion_plating_permit_condition,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_permit_condition_supervisor,fp.permit.condition.supervisor,model_fusion_plating_permit_condition,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_permit_condition_manager,fp.permit.condition.manager,model_fusion_plating_permit_condition,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_discharge_limit_operator,fp.discharge.limit.operator,model_fusion_plating_discharge_limit,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_discharge_limit_supervisor,fp.discharge.limit.supervisor,model_fusion_plating_discharge_limit,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_discharge_limit_manager,fp.discharge.limit.manager,model_fusion_plating_discharge_limit,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_discharge_sample_operator,fp.discharge.sample.operator,model_fusion_plating_discharge_sample,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_discharge_sample_supervisor,fp.discharge.sample.supervisor,model_fusion_plating_discharge_sample,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_discharge_sample_manager,fp.discharge.sample.manager,model_fusion_plating_discharge_sample,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_discharge_sample_line_operator,fp.discharge.sample.line.operator,model_fusion_plating_discharge_sample_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_discharge_sample_line_supervisor,fp.discharge.sample.line.supervisor,model_fusion_plating_discharge_sample_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_discharge_sample_line_manager,fp.discharge.sample.line.manager,model_fusion_plating_discharge_sample_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_waste_stream_operator,fp.waste.stream.operator,model_fusion_plating_waste_stream,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_waste_stream_supervisor,fp.waste.stream.supervisor,model_fusion_plating_waste_stream,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_waste_stream_manager,fp.waste.stream.manager,model_fusion_plating_waste_stream,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_waste_manifest_operator,fp.waste.manifest.operator,model_fusion_plating_waste_manifest,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_waste_manifest_supervisor,fp.waste.manifest.supervisor,model_fusion_plating_waste_manifest,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_waste_manifest_manager,fp.waste.manifest.manager,model_fusion_plating_waste_manifest,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_pollutant_inventory_operator,fp.pollutant.inventory.operator,model_fusion_plating_pollutant_inventory,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_pollutant_inventory_supervisor,fp.pollutant.inventory.supervisor,model_fusion_plating_pollutant_inventory,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_pollutant_inventory_manager,fp.pollutant.inventory.manager,model_fusion_plating_pollutant_inventory,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_compliance_event_operator,fp.compliance.event.operator,model_fusion_plating_compliance_event,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_compliance_event_supervisor,fp.compliance.event.supervisor,model_fusion_plating_compliance_event,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_compliance_event_manager,fp.compliance.event.manager,model_fusion_plating_compliance_event,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_spill_register_operator,fp.spill.register.operator,model_fusion_plating_spill_register,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_spill_register_supervisor,fp.spill.register.supervisor,model_fusion_plating_spill_register,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_spill_register_manager,fp.spill.register.manager,model_fusion_plating_spill_register,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_jurisdiction_operator,fp.jurisdiction.operator,model_fusion_plating_jurisdiction,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_jurisdiction_supervisor,fp.jurisdiction.supervisor,model_fusion_plating_jurisdiction,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_jurisdiction_manager,fp.jurisdiction.manager,model_fusion_plating_jurisdiction,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_regulator_operator,fp.regulator.operator,model_fusion_plating_regulator,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_regulator_supervisor,fp.regulator.supervisor,model_fusion_plating_regulator,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_regulator_manager,fp.regulator.manager,model_fusion_plating_regulator,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_permit_operator,fp.permit.operator,model_fusion_plating_permit,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_permit_supervisor,fp.permit.supervisor,model_fusion_plating_permit,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_permit_manager,fp.permit.manager,model_fusion_plating_permit,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_permit_condition_operator,fp.permit.condition.operator,model_fusion_plating_permit_condition,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_permit_condition_supervisor,fp.permit.condition.supervisor,model_fusion_plating_permit_condition,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_permit_condition_manager,fp.permit.condition.manager,model_fusion_plating_permit_condition,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_discharge_limit_operator,fp.discharge.limit.operator,model_fusion_plating_discharge_limit,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_discharge_limit_supervisor,fp.discharge.limit.supervisor,model_fusion_plating_discharge_limit,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_discharge_limit_manager,fp.discharge.limit.manager,model_fusion_plating_discharge_limit,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_discharge_sample_operator,fp.discharge.sample.operator,model_fusion_plating_discharge_sample,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_discharge_sample_supervisor,fp.discharge.sample.supervisor,model_fusion_plating_discharge_sample,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_discharge_sample_manager,fp.discharge.sample.manager,model_fusion_plating_discharge_sample,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_discharge_sample_line_operator,fp.discharge.sample.line.operator,model_fusion_plating_discharge_sample_line,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_discharge_sample_line_supervisor,fp.discharge.sample.line.supervisor,model_fusion_plating_discharge_sample_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_discharge_sample_line_manager,fp.discharge.sample.line.manager,model_fusion_plating_discharge_sample_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_waste_stream_operator,fp.waste.stream.operator,model_fusion_plating_waste_stream,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_waste_stream_supervisor,fp.waste.stream.supervisor,model_fusion_plating_waste_stream,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_waste_stream_manager,fp.waste.stream.manager,model_fusion_plating_waste_stream,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_waste_manifest_operator,fp.waste.manifest.operator,model_fusion_plating_waste_manifest,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_waste_manifest_supervisor,fp.waste.manifest.supervisor,model_fusion_plating_waste_manifest,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_waste_manifest_manager,fp.waste.manifest.manager,model_fusion_plating_waste_manifest,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_pollutant_inventory_operator,fp.pollutant.inventory.operator,model_fusion_plating_pollutant_inventory,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_pollutant_inventory_supervisor,fp.pollutant.inventory.supervisor,model_fusion_plating_pollutant_inventory,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_pollutant_inventory_manager,fp.pollutant.inventory.manager,model_fusion_plating_pollutant_inventory,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_compliance_event_operator,fp.compliance.event.operator,model_fusion_plating_compliance_event,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_compliance_event_supervisor,fp.compliance.event.supervisor,model_fusion_plating_compliance_event,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_compliance_event_manager,fp.compliance.event.manager,model_fusion_plating_compliance_event,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_spill_register_operator,fp.spill.register.operator,model_fusion_plating_spill_register,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_spill_register_supervisor,fp.spill.register.supervisor,model_fusion_plating_spill_register,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_spill_register_manager,fp.spill.register.manager,model_fusion_plating_spill_register,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.21.7.2',
|
||||
'version': '19.0.21.8.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<odoo>
|
||||
|
||||
<record id="group_fp_estimator" model="res.groups">
|
||||
<field name="name">Estimator</field>
|
||||
<field name="name">[DEPRECATED] Estimator</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('fusion_plating.group_fusion_plating_supervisor'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_fp_shop_manager" model="res.groups">
|
||||
<field name="name">Shop Manager</field>
|
||||
<field name="name">[DEPRECATED] Shop Manager</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
<field name="implied_ids" eval="[
|
||||
@@ -24,4 +24,10 @@
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Backward-compat: new Sales Rep role implies old Estimator group so existing ACLs still resolve.
|
||||
Lives here (not in fusion_plating core) to avoid fresh-install forward-ref. -->
|
||||
<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>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_pricing_surcharge_operator,fp.pricing.complexity.surcharge.operator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_pricing_surcharge_estimator,fp.pricing.complexity.surcharge.estimator,model_fp_pricing_complexity_surcharge,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_direct_order_wizard_estimator,fp.direct.order.wizard.estimator,model_fp_direct_order_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_direct_order_wizard_manager,fp.direct.order.wizard.manager,model_fp_direct_order_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_direct_order_line_estimator,fp.direct.order.line.estimator,model_fp_direct_order_line,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_direct_order_line_manager,fp.direct.order.line.manager,model_fp_direct_order_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_add_from_so_wizard_estimator,fp.add.from.so.wizard.estimator,model_fp_add_from_so_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_quote_promote_wizard_estimator,fp.quote.promote.wizard.estimator,model_fp_quote_promote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_quote_promote_wizard_manager,fp.quote.promote.wizard.manager,model_fp_quote_promote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_pricing_surcharge_operator,fp.pricing.complexity.surcharge.operator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_pricing_surcharge_estimator,fp.pricing.complexity.surcharge.estimator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,model_fp_pricing_complexity_surcharge,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_direct_order_wizard_estimator,fp.direct.order.wizard.estimator,model_fp_direct_order_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_direct_order_wizard_manager,fp.direct.order.wizard.manager,model_fp_direct_order_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_direct_order_line_estimator,fp.direct.order.line.estimator,model_fp_direct_order_line,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_direct_order_line_manager,fp.direct.order.line.manager,model_fp_direct_order_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_add_from_so_wizard_estimator,fp.add.from.so.wizard.estimator,model_fp_add_from_so_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_quote_promote_wizard_estimator,fp.quote.promote.wizard.estimator,model_fp_quote_promote_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_quote_promote_wizard_manager,fp.quote.promote.wizard.manager,model_fp_quote_promote_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0
|
||||
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_sale_assembly_line_user,fp.sale.assembly.line.user,model_fp_sale_assembly_line,base.group_user,1,0,0,0
|
||||
access_fp_sale_assembly_line_estimator,fp.sale.assembly.line.estimator,model_fp_sale_assembly_line,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_sale_assembly_line_estimator,fp.sale.assembly.line.estimator,model_fp_sale_assembly_line,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_sale_desc_template_user,fp.sale.description.template.user,model_fp_sale_description_template,base.group_user,1,0,0,0
|
||||
access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_serial_user,fp.serial.user,model_fp_serial,base.group_user,1,0,0,0
|
||||
access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating.group_fp_sales_rep,1,1,1,1
|
||||
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
||||
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating.group_fp_sales_rep,1,1,1,0
|
||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0
|
||||
access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Culture & Values',
|
||||
'version': '19.0.1.1.0',
|
||||
'version': '19.0.1.1.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Configurable culture framework for plating shops: values, fundamentals, peer recognitions, rotation schedules. Each shop loads its own values.',
|
||||
'description': """
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_value_set_operator,fp.value.set.operator,model_fusion_plating_value_set,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_value_set_supervisor,fp.value.set.supervisor,model_fusion_plating_value_set,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_value_set_manager,fp.value.set.manager,model_fusion_plating_value_set,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_value_operator,fp.value.operator,model_fusion_plating_value,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_value_supervisor,fp.value.supervisor,model_fusion_plating_value,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_value_manager,fp.value.manager,model_fusion_plating_value,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_value_rotation_operator,fp.value.rotation.operator,model_fusion_plating_value_rotation,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_value_rotation_supervisor,fp.value.rotation.supervisor,model_fusion_plating_value_rotation,fusion_plating.group_fusion_plating_supervisor,1,1,0,0
|
||||
access_fp_value_rotation_manager,fp.value.rotation.manager,model_fusion_plating_value_rotation,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_value_recognition_operator,fp.value.recognition.operator,model_fusion_plating_value_recognition,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_value_recognition_supervisor,fp.value.recognition.supervisor,model_fusion_plating_value_recognition,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_value_recognition_manager,fp.value.recognition.manager,model_fusion_plating_value_recognition,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_value_set_operator,fp.value.set.operator,model_fusion_plating_value_set,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_value_set_supervisor,fp.value.set.supervisor,model_fusion_plating_value_set,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_value_set_manager,fp.value.set.manager,model_fusion_plating_value_set,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_value_operator,fp.value.operator,model_fusion_plating_value,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_value_supervisor,fp.value.supervisor,model_fusion_plating_value,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_value_manager,fp.value.manager,model_fusion_plating_value,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_value_rotation_operator,fp.value.rotation.operator,model_fusion_plating_value_rotation,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_value_rotation_supervisor,fp.value.rotation.supervisor,model_fusion_plating_value_rotation,fusion_plating.group_fp_shop_manager_v2,1,1,0,0
|
||||
access_fp_value_rotation_manager,fp.value.rotation.manager,model_fusion_plating_value_rotation,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_value_recognition_operator,fp.value.recognition.operator,model_fusion_plating_value_recognition,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_value_recognition_supervisor,fp.value.recognition.supervisor,model_fusion_plating_value_recognition,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_value_recognition_manager,fp.value.recognition.manager,model_fusion_plating_value_recognition,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Invoicing',
|
||||
'version': '19.0.3.5.0',
|
||||
'version': '19.0.3.6.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||
'description': """
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
<odoo>
|
||||
|
||||
<record id="group_fp_accounting" model="res.groups">
|
||||
<field name="name">Accounting</field>
|
||||
<field name="name">[DEPRECATED] Accounting</field>
|
||||
<field name="sequence">58</field>
|
||||
<field name="privilege_id" ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_plating.group_fusion_plating_supervisor'))]"/>
|
||||
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Backward-compat: new Manager role implies old Accounting group. -->
|
||||
<record id="fusion_plating.group_fp_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('fusion_plating_invoicing.group_fp_accounting'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user