Made record property nullable (?WorkOrder) and mount() calls find() +
explicit abort(404) instead of findOrFail() (which pass-through the
model's tenant-scoped scope but doesn't clarify the 404 source).
Added Feature test that boots the dashboard route via tenant subdomain
(psauto.service.mir.md-style host), authenticates a user, and verifies
the page returns non-404, non-500. Test passes both with and without
route:cache, so the routing itself is fine — user-side 404 is most
likely browser cache or CDN cache.
307/307 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filament auto-generates route names from the URL slug, so my custom
Page with slug 'work-orders/{record}/dashboard' got the ugly name
'filament.tenant.pages.work-orders.{record}.dashboard' — where
'{record}' is treated as a literal segment in the name, breaking
route() lookups.
Switch all links + the header-action URL to url('/app/work-orders/'
. \$id . '/dashboard') so they resolve correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Right column now shows:
- Timeline card: synthesised events from WO lifecycle fields (created,
opened, approved, closed) + payments + activity_log entries (spatie/
activitylog). Sorted newest first, scrollable.
- Chat client card: outbound notifications history (ClientNotificationLog)
+ inline send form. Prefers Telegram if client has telegram_chat_id,
else falls back to SMS/WhatsApp. Logged either way for UI history.
New bottom action bar (fixed, above footer):
- Previous / Next WO links (adjacent by id) with number preview
- Repeat order: pre-fills create form with same client/vehicle
- Close order (danger button): sets status='done' + closed_at=now, with
wire:confirm guard. Hidden when already closed.
+14 translations. Fixed ready_at reference (not in schema, removed).
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom Filament Page at /app/work-orders/{id}/dashboard renders a
Mitchell1-inspired 3-column layout:
- Top bar: WO#, status badge, actions (Edit / Tracking link)
- Meta header row: creation / opened / ETA / responsible / urgency /
paid amount (6 cells)
- Left sidebar (300px): Client card (avatar, phone, email, status
tag, 3-stat grid: visits/total/debt) + Vehicle card (photo, plate,
VIN, mileage, engine, gearbox) + Repair history (latest 5 for this
vehicle, links to dashboards)
- Middle: tab bar (Lucrări/Piese/Diagnostic/Foto/Documente/Note)
with Alpine-driven switching. Works & Parts show the tables read-
only; add/edit still goes through existing EditWorkOrder Filament
resource. Photos tab shows gallery from spatie/media-library.
- Right (300px): Finance summary card (works cost, parts cost,
discount, total, paid, balance) + placeholder for Timeline+Chat
(Phase 2)
- Responsive: right column collapses <1280px, left <900px.
'Vizualizare dashboard' button added on top of the existing
EditWorkOrder page so users can switch between edit form and info-
dense dashboard.
Fixed getSlug() signature (must match parent with ?Panel $panel).
+14 translations. All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
'PS' was baked in — wrong for multi-tenant. Now:
1. Honor $tenant->settings['protocol_prefix'] if set
2. Else use uppercase first 2 chars of tenant slug
('psauto' → 'PS', 'autoplus' → 'AP', 'plusrepair' → 'PL')
3. Fallback 'PR' if no tenant resolvable
Format changed from 'PS-{companyId}-{year}-{seq}' to '{PREFIX}-{year}-{seq}'
— dropped the redundant companyId since prefix already identifies the
tenant contextually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- WorksRelationManager Name column: getStateUsing(labor?->label()) so
the row shows the labor's localized name (not the raw snapshot);
fallback to stored snapshot when labor is missing.
- 4 explicit empty states with helper description + icon on Works,
Parts, SubcontractJobs, Payments — replaces Filament's auto-generated
'Не найдено X / Создать X для старта.'
- 4 CreateAction titles / modal headings for all four relation managers.
- Notification 'Piesa returnată în stoc' / 'Nimic de restituit' wrapped.
- +11 translation keys.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migration: add nullable `labors.name_en` alongside existing name_ro/
name_ru.
- Labor model: label(?locale) accessor returns name_{locale} with
name_ro fallback.
- LaborResource form: expose 3rd 'Nume (EN)' field.
- Table + Select displays now go through label() so options show the
locale-appropriate name:
/app/labors table column,
WorkOrderResource works Select ('[category] name (Nh)'),
ServiceTemplate items Select,
ServiceComposer WorkOrderWork snapshot on create.
- Snapshot name saved on wo_works.name at creation reflects the
current locale; existing rows keep their stored snapshot untouched.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Labor Select options now build '[__(category)] name_ro (hours __(h))'
so category chip and hour suffix translate on RU/EN. Labor name stays
as name_ro (user data — separate long-term concern).
- CreateAction gets explicit label + modalHeading (was 'Create Work
Order Work' auto-generated).
- Notification 'Piese implicite adăugate (N)' rewritten with __(':n')
placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MechanicKpi getPeriodLabel() was hardcoded to ->locale('ro') which
forced Romanian month names ('iulie 2026') regardless of the app
locale. Same on PDF appointments day-label. Both now switch to
app()->getLocale() so RU shows «июль 2026», EN 'July 2026'.
SetLocale middleware already calls Carbon::setLocale on every
request, so translatedFormat() elsewhere already respects the app
locale — no other files needed patching.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior 'Everything up-to-date' commit was a no-op because Edit failed.
Now the change lands. Splits specialization by '/' and __()-translates
each token, matching Reports.php + CalendarBoard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the row-meta on the master matrix rows and the legend list at
the bottom split specialization by '/' and translate each token via
__(), matching the pattern already used on Reports.php.
'Suspensie / Frâne' → «Подвеска / Тормоза», 'Motor / Cutie viteze' →
«Двигатель / КПП» etc. (all tokens already in the dict).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OnlineOrder::DELIVERY values (Ridicare din service / Curier / Poștă)
weren't translated; now go through I18n::opts and have RU/EN entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Currency Select ->options() had 4 raw entries (MDL/EUR/USD/UAH)
and 2 wrapped (RON/RUB); consolidated all 6 under I18n::opts()
- Push notification title 'Trimis pe N dispozitiv(e)' rebuilt with
__(':n')-placeholder; empty state + helper now go through __()
- Added 4 long helper description keys with the closing „…"
Romanian quotation marks (the previous keys were missing the
trailing quote and dot; now match Settings.php byte-for-byte)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filament default rendered 'Не найдено user permission overrides /
Создать user permission override для старта.' from the model class
name. Overridden with translated heading + explanatory description
+ shield-exclamation icon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior 'fix' commit was a no-op because git diff was empty. This time
the Edit lands the change on disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add Permissions::permissionLabels() mapping every slug to a
human-readable RO label (e.g. 'clients.view_all' → 'Vezi toți
clienții').
- RoleResource permission checkboxes now show translated labels
as titles + technical slug as description (so admins see both
the meaning and the key).
- PermissionOverridesRelationManager Select 'permission_id' now
shows 'Human label (technical.slug)' format, plus helper text
explaining GRANT/DENY. Same format on the display column.
- +57 translations (50 permission labels in RU/EN + role page
supporting keys + module labels).
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 'DENY — interzice dreptul' select option was missing __() wrap
- CreateAction/EditAction now have explicit label/modalHeading so
the modal shows 'Добавить исключение прав' instead of
'Создать User Permission Override' (auto-generated from class name)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Status column showed raw DB enum values (active/inactive/blocked) with
no formatStateUsing → readers saw 'active' instead of the translated
'Активный / Активно' visible in the Select. Added formatStateUsing with
a match() that returns the appropriate __() key.
Locale header re-translated to 'Язык' (RU) / 'Language' (EN) since
'Локаль' is unusual for end users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Both role selects (primary + additional roles multi) passed the raw
roleLabels() array without translation; wrap with I18n::opts() so
Proprietar/Administrator/… now render translated in RU/EN.
- 2FA status text ('✓ Activat (TOTP)' / '✗ Dezactivat') split so
the checkmark stays and label runs through __().
- +3 translations (Activat (TOTP), Dezactivat, Locale) — Locale is
Filament's auto-generated headline from the `locale` field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reports.php periods() had 'this_year' hardcoded; now wrapped in __()
- masters tab specialization cell: split by '/' and __()-translate each
token so 'Motor / Cutie viteze' auto-renders as «Двигатель / КПП»
on RU (and 'Engine / Gearbox' on EN). Users can enter any free-text
specialisation; the common keywords now have RU/EN translations.
- +12 specialisation vocabulary entries (Motor, Frâne, Suspensie,
Anvelope, Cutie viteze, Electrică, Diagnosticare, Vopsitorie,
Tinichigerie, Aer condiționat, Roți, Ambreiaj).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously .fi-main was capped at width-7xl (~1280px). Tables with
many columns overflowed and needed inner scroll. Now the entire main
area uses 100% width so tables render without truncation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notification 'Calculat salariul YYYY-MM pentru N utilizatori' was
built via string interpolation → couldn't translate. Now uses __()
with :period / :count placeholders. Adds 7 more translations for
section titles and column labels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Field 'method' auto-generated 'Method' as label. Since RO is the source
language, __('Method') fell back to English on RO locale. Now explicitly
labeled with 'Metodă' so all 3 langs resolve properly (Metodă / Метод /
Method).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- All 4 tabs (Overview/Cashflow/P&L/Balanță) built with __() around
the text portion, emoji stays in code
- 4 period presets wrapped with __()
- P&L table Marjă/Marjă piese footer wrapped
- Expense::CATEGORIES values look up through __() in the expensesByCat
loop so categories show translated (Salariu → Зарплата etc.)
- +25 translations covering all UI copy on /app/finance
306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two CheckboxList options passed raw Vehicle::BODY_TYPES and
Vehicle::TRANSMISSION_TYPES arrays — now they run through I18n::opts()
so the RO values translate at display. +6 translations for the enum
values that weren't yet in dict (Crossover, Pickup, Minivan, DSG,
DCT, AMT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrapped the dynamic Select 'key' label (Brand/Categorie) with __(),
switched the category options to I18n::opts(). Notification 'Recalculat
preț pentru N piese' now uses __(:n) placeholder.
+11 keys: regulă, reguli markup, Cheie, Mai mic = aplicat primul.,
'Va recalcula sell_price…' confirm text, notification, 'Interval preț'
enum, and missing Part.CATEGORIES (Ulei, Filtre, Lichide, Distribuție).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migration: create `units` table + nullable unit_id FK on
parts / wo_parts / purchase_items / labor_parts. Seeds ~11 standard
units (buc/set/l/ml/kg/g/m/cm/m²/oră/pack) for every existing
tenant and backfills unit_id by matching the legacy string `unit`
column. Keeps `unit` string as fallback so old code paths keep
rendering.
- Unit model: label(locale), forSelect(locale), labelFor(id, code)
helpers. All 4 owner models get unitModel() relation + unitLabel()
accessor.
- UnitResource under Depozit group with Filament UI: code, sort,
is_active + separate name_ro/name_ru/name_en fields.
- Filament forms/tables updated: Part / PurchaseItem / LaborPart /
WorkOrderPart now use Select('unit_id')->options(Unit::forSelect())
for input and TextColumn->getStateUsing(unitLabel()) for display.
Selecting a part auto-fills unit_id when the source has one.
- +10 translations for the new resource + defaults; nav.label
'Unități de măsură' added to all 3 lang files.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit added the translation entries but the Edit call
failed silently (file-not-read guard). Now the CreateAction has
->label(__('Adaugă articol')) + ->modalHeading(__('Adaugă articol
în comandă')) so RU/EN see translated modal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filament's default empty state used the model class name ('service
template items') and default 'Create service template item' modal
heading. Both are now explicit __() calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit's translation entries were added but the source change
was silently rejected by the Edit tool's file-not-read guard. Now the
CreateAction/EditAction overrides are in place; RU shows 'Добавить
запчасть по умолчанию' / 'Изменить запчасть по умолчанию' instead of
the auto-generated 'Create Labor Part'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous sed pass only matched \$state; missed \$s and other arg-name
variants. This time the regex is arg-name-agnostic and touches 21
files across Filament resources & relation managers.
Also wraps two special cases: UserResource role-labels lookup and
LaborResource pricing_mode ternary ('Fix' | 'Pe oră').
+27 human translations for the enum values that were still identity
fallback: WorkOrderWork.STATUSES (De făcut), Purchase.STATUSES,
OnlineOrder.STATUSES, Call.DIRECTIONS/STATUSES, BodyshopJob.TYPES/
STATUSES, TireSet.SEASONS, MessageTemplate.CHANNELS,
DamagePoint.SEVERITIES, ServiceTemplateItem.KINDS.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- getContentTabLabel: 'ⓘ Info & antet' now goes through __()
- Tracking-client modal heading translated ('Tracking client — WO #…')
- Both 'Închide' cancel labels wrapped in __()
- Tracking modal widened to 'lg' so the QR + link input fit without overlap
- Notification 'Șablon aplicat' template built with __() so RU/EN see
translated pieces
- +16 translations covering the QR modal, Info tab, Închide etc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit added the translation entries and commit message claimed
this, but the Edit tool call failed silently (file-not-read guard) so
the source change never landed. This time it's applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
max(...[]) throws ArgumentCountError. Split the intermediate array
and only call max() when it has elements. Fixes /app/finance 500
on tenants with no payments in the current period.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Post/mechanic meta 'h/zi' → __('h/zi') so RU shows 'ч/день', EN 'h/day'
- Wrap the 3 load-legend labels (0–5h/10, 5–8.5h/10, ≥9h/10) so 'h' → 'ч' on RU
- Split the long howto sentence so 'Pod'/'Mecanic' + 'Zile' translate
- Default fallback 'Pod 1 (default)' now uses __('Pod') + __('implicit')
Adds 5 new translations. Post NAMES themselves are DB rows (user data)
and are shown as-is — they're not translated by the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was created but never staged in the previous commit; deployment failed
with 'Class App\Support\I18n not found' on any page that uses the
I18n::opts() wrapper on Model::CONSTANT Select options.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of untranslated table headers: Filament's HasLabel::getLabel()
auto-generates a label from the field name (e.g. `name` → "Name") but only
calls __() on it if you also invoke ->translateLabel(). Without that, the
auto-generated label always renders in English regardless of app locale.
Fix: configureUsing() in AppServiceProvider makes translateLabel() the
default for every Column, BaseFilter, Field, Action and BulkAction.
Then added 175 human RU/EN translations for Filament's auto-generated
keys (Name, Phone, Email, Status, Balance, Created at, Plate, Make,
Model, Year, VIN, Mileage, Owner, Client, Vehicle, Amount, Total, Qty,
Actions, Search, Filter, Export, Import, plus enums/relationships).
All 306 tests pass. Deploy will fix table headers on
Clients / Vehicles / Work orders / Payments / Purchases / Suppliers etc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Broad sweep across the whole codebase:
- Blade views (39 files, 315 wraps): tag-text and title/placeholder/alt
attributes wrapped with {{ __() }}. Excludes scripts, styles, @php,
@verbatim, {{ }}, {!! !!}, comments to avoid touching interpolations.
- PHP (54 files, 179 wraps): array 'key' => 'RO value' patterns and
list items with diacritics wrapped with __(). Reverted __() inside
const arrays (PHP disallows non-constant expressions).
- Added 269 new keys to lang/{ru,en}.json (identity fallback for
unknowns → 161 human RU + 163 EN translations added for the most
common enums, stages, roles, statuses, payment methods, vehicle
categories, warehouse, portal, form actions.
Missing translations fall back to RO so the UI never breaks. All 306
tests pass; view cache compiles cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Convert static $modelLabel/$pluralModelLabel/$title to getter methods
- Wrap ->label()/->placeholder()/->helperText()/->description()/->title()/->body() args
- Wrap Section::make()/Fieldset::make()/Notification::make()->title() args
- Fix RelationManagers::getTitle() signature to match parent (Model, string)
- Fix Pages::getTitle() to instance method (BasePage::getTitle is non-static)
- Extend lang/ru.json + lang/en.json with 700+ common terms; identity fallback for the rest
- Remove duplicate getters in 5 resources that had manual getModelLabel already
All 306 tests pass. Missing translations fall back to the RO key so the UI never breaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User screenshots showed the tenant admin panel (Filament) had sidebar
labels stuck in Romanian even when switching to Russian: 'Cereri',
'Calendar vizual', 'Atelierul meu', 'KPI mecanici', 'Fișe lucru',
'Norme-ore', 'Tehnicieni', 'Șabloane servicii', 'Depozite', 'Scaner',
'Depozit', 'VIN-căutare', 'Furnizori', 'Achiziții', 'Procentaj',
'Coeficienți preț', plus all group headers.
Root cause: every Filament Resource and Page had static properties
'protected static ?string $navigationLabel = "Fișe lucru"' — string
literals baked into class definitions. Static properties don't run
through the translation layer.
Fix in two parts:
1. New translation files with 52 label keys + 12 group keys:
- lang/ro/nav.php — Romanian (identity)
- lang/ru/nav.php — full Russian translations (Заказ-наряды,
Автомобили, Клиенты, Календарь, Моя мастерская, Механики KPI,
Настройки, etc.)
- lang/en/nav.php — English translations (Work orders, Vehicles,
Clients, Calendar, My workshop, Mechanic KPI, Settings, etc.)
Keyed by the Romanian original so lookups map 1:1 —
'nav.label.Fișe lucru' returns 'Заказ-наряды' in RU, 'Work orders'
in EN, 'Fișe lucru' in RO.
2. Python transformer converted 54 files:
- 33 Filament Tenant Resources
- 15 Filament Tenant Pages
- 4 Filament Central Resources
- 1 Filament Central Page
- 1 Widget
Each 'protected static ?string $navigationLabel = "X";' became
'public static function getNavigationLabel(): string { return
__("nav.label.X"); }'. Same treatment for $navigationGroup.
Cleanup: 6 resources already had manually-added getNavigationLabel
methods from an earlier partial effort — those used flat JSON keys
(__("Cereri")) that never resolved. Deduped so only the nav.label.*
version remains.
Untouched (intentional):
- $modelLabel / $pluralModelLabel (used in breadcrumbs and headings —
still hardcoded, next tier of work)
- Section titles, column headers, form field labels (medium priority)
- $navigationSort (numeric, no translation needed)
- $navigationIcon (icon reference)
Suite: 306 passed (853 assertions). Unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SetLocale::resolve() edit was dropped from the previous commit.
Cookie priority is essential for the switcher to actually take effect
— without this the /locale route was setting a cookie that nothing
read.
Now resolve order:
1. app_locale cookie (persistent, survives session regen)
2. session locale
3. auth user.locale
4. tenant.settings.language
5. hard-coded 'ro'
Suite: 306 unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous POST-based switcher failed silently: CSRF token check
returned 419 when clicking, and the session never persisted.
Root causes:
- POST requires @csrf token, but the test path fetched pages that had
no matching form so the token in the DOM didn't match the session
- Some tenant subdomains had SESSION_DOMAIN scoped differently, so
the cookie set by POST didn't come back on the follow-up GET
- Prod .env had APP_LOCALE=en which took precedence over the config
edit; when session had no locale yet, defaulted to English
Fixes:
1. Route accepts BOTH GET and POST via Route::match(['get', 'post']).
Setting your own language is not a security concern — GET is fine.
2. Route explicitly calls $request->session()->save() before redirect,
forcing the session store to write before the redirect fires.
Also honors ?redirect=<url> query so the user lands back on their
original page rather than referer-guessing.
3. lang-switcher partial rewrites to plain <a href> tags (no @csrf,
no forms). Each link points at /locale/{code}?redirect={current-url}
so the switch happens in a single hop with predictable target.
4. SetLocale middleware hard-codes 'ro' as the ultimate fallback,
ignoring config/env. The Romanian portal is the default
client-facing surface; if a client has no session locale set and
no user account, they see Romanian (safer than English which has
no portal translations).
Suite: 306 passed (853 assertions). Unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for the WO-level "Aplică marjă internă" toggle:
== 1. Hide description text when WO toggle is off ==
WorksRelationManager's Total column showed a gray subtitle line
"Bază salariu: 320 MDL · marjă 20%" that persisted even after
apply_margin was toggled OFF at the WO level. Confusing — the user
expected "off means invisible".
Fix: description callback now short-circuits to null when
$record->workOrder->apply_margin === false, hiding the entire text.
Also hides when applied_margin_pct is 0 (nothing meaningful to show).
Result: OFF at WO level → zero margin details anywhere in the
Manopere tab. ON → same as before.
== 2. Auto-recompute salary_base on all lines when toggle flips ==
Previously, salary_base was frozen at line save-time. Flipping
apply_margin from on→off left existing lines with the old
20%-reduced salary_base, so payroll still used the reduced amount
even though the user had visually decided "no margin".
Fix: WorkOrder::updated hook detects wasChanged(['apply_margin',
'override_margin_pct']) and iterates through works():
- apply_margin=false → salary_base = total, applied_margin_pct = 0
- apply_margin=true → resolver chain (WO override → mechanic → default)
saveQuietly() on each line so we don't retrigger the works() booted
hooks that would recompute again.
This is DIFFERENT semantic from user.internal_margin_pct changes —
those DON'T rewrite history (test still passes). The distinction:
- User margin change: personnel decision, must not touch closed WOs
- WO apply_margin change: explicit per-Fișă decision, must affect
every line on that same Fișă
InternalMarginRecomputeTest (3):
- Flipping WO.apply_margin off recomputes both existing lines to at-cost
- Flipping back on recomputes to margined
- Changing WO.override_margin_pct recomputes with new % (40 → 60% base)
Suite: 306 passed (853 assertions). Was 303. +3 recompute tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>