A work-order request stores the requested increase, not a total. Letting
the vendor revise one after changes were requested rewrote RequestedNTE
as a total while it still read as a work-order request, corrupting its
amount and the NTE it would be approved to. Revise now answers not-found
for any request the vendor did not raise and leaves the row untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Work-order requests store the requested increase in RequestedNTE; vendor
portal requests store the requested NTE total. The queue Delta, the
pending and approved exposure totals, the work-order uplift list, the
board summary and the notification Delta now all read one definition
(UpliftAmount) that honours both meanings and translates to SQL.
Approving a work-order request now adds its increase to the dispatch NTE
instead of replacing the NTE with the increase; vendor requests still end
at their requested total.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The approvals header summed the whole RequestedNTE for work-order-path
requests, while each Pending row shows RequestedNTE - CurrentNTE. When a
work order already had an NTE the header overstated exposure by that NTE.
The header now sums the same Delta the rows display, over the same rows
the Pending tab lists (non-deleted request on a non-deleted dispatch).
The unused duplicate aggregate is removed so one definition remains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Approve, reject, request-changes, expiry and escalation staged their work
order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs
requires a real work order, so any uplift on a dispatch without an owning
work order failed to save: the decision returned a 500 and the request stayed
Pending, and the expiry sweep failed on it every run.
The audit now goes to the work order the uplift resolves to through the
existing owner-or-linked read that revoke already uses. When none resolves,
the status change is saved on the request and no audit row is written.
UserCanApprove only accepted roles listed in Approvals:Tier1Roles/Tier2Roles,
so an environment whose config omits Admin denied every approval surface to
admins: can-approve, per-row CanDecide, approve/reject/request-changes and
evidence download. Admin now short-circuits the check; tier-role config still
governs every other role.
Board create left the new primary dispatch with no WorkOrderId, so uplifts
created on those work orders were written to a dispatch the work order's
uplift reads never resolve: not listed, allowance never consumed, queue WO
number blank. The same orphan made ApptDate/vendor patches fail with
"A primary dispatch is required".
- Board create backfills Dispatch.WorkOrderId after the first save.
- Uplift create resolves its dispatch through the read-side scope
(non-deleted, owned or linked); otherwise the stable
"no primary dispatch" error.
- Data-only migration assigns existing orphaned primaries to the single
work order naming them primary; idempotent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use the operation work order when staging uplift audit logs so a dispatch linked through DispatchWorkOrders cannot write the event to its primary work order. Add coverage for the cross-work-order fallback case.
The manual POC "follow the site" clear-rule compared the edit against every
live Site contact. A work order only ever displays one of them, so editing to
a different live contact (Site has Alice primary and Bob; WO shows Alice; edit
to Bob) matched, cleared the override, and left the row showing Alice with no
audit row written — the SH-379 symptom on a different input. A work order with
a linked WorkOrderContacts POC hit the same bug when the dispatcher typed the
Site's primary: the override cleared and the linked contact showed instead.
Compare the edit against the single contact the work order actually follows —
the linked WorkOrderContacts POC, or else the Site primary (ResolvePrimary) —
matching the board projection's override -> linked -> site precedence, and
store the override whenever the edit differs from it. The create path in
WorkOrderBoardCreateService had the same any-contact rule and gets the same
fix; a supplied PocContactId that is not a live Site contact leaves no follow
target, so the typed POC is stored.
Replaces MatchesAnySiteContact with Matches(name, phone, contact); comment and
PR-body wording updated to state the followed-contact rule. Adds tests for the
second-site-contact edit, the linked-contact-differs edit, and the
second-site-contact create case.
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.
- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
+ WorkOrderPocDataService: persists the override, stages FieldChanged audit
entries (which also write field locks so sync never overwrites a manual POC),
and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
Adds the personal producers behind GET /api/notifications without changing its shape:
new assignments (SH-288), unanswered comments on work the user takes part in (SH-289),
@mentions, and decisions on uplifts the user requested (SH-215).
No Vendor treated any non-terminal dispatch as an assigned vendor without
checking IsDeleted, so a soft-deleted dispatch hid the work order from both
No Vendor and Vendor Conflict (the conflict query already drops deleted
dispatches). GetNoVendorAsync and the vendor-reminder assigned-work-order rule
now skip soft-deleted dispatches, keeping the asserted parity between the feed
and the reminders.
Vendor Conflict applied the section cap in vendor-sweep order, so when overlaps
exceeded the limit newer conflicts could be dropped while Count still counted
every work order. VendorConflictsAsync now orders pairs by recency before the
cap, matching the other per-work-order sections.
The uplift detail modal needs the work order's assigned dispatcher, the
requesting vendor's technician and the scheduled date. They now resolve
from the same effective work order and vendor as the existing queue row,
so the modal no longer depends on a separate work-order fetch that
account-scoped staff cannot read.
Adds GET /api/notifications, a per-user read model derived from live
work-order state: Unassigned (grouped, High), No Vendor and Aveta Missing
(per work order, Medium) and Vendor Conflict, account-scoped from claims
and ordered by section severity with a fixed reason tie-break.
Storing the submitted full name in FirstName while leaving a seeded
LastName intact made an unchanged save project a duplicated name
(e.g. "Legacy Manager Manager"). Leave FirstName/LastName untouched when
the submitted name already matches the stored first+last projection, and
otherwise split the submitted name across FirstName/LastName so renames
round-trip cleanly.
ResolveDispatcherScope now admits the Scheduler role, which owns the
viewAllDispatchersOnDashboard permission, so it can load Stats, Workload,
Performance, Regions and Trend instead of being denied.
GetTrendAsync now resolves scope via the shared ResolveDispatcherScope so a
picker DispatcherId selection scopes Trend consistently with the other
endpoints and unauthorized roles fail closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateAsync checks FindByEmailAsync and then inserts, so two concurrent
creates with the same email can both pass the check and hit the unique
user-name index. That DbUpdateException was unhandled and surfaced as a
500. Catch it at the CreateAsync call and return the existing
"Email is already in use." message, matching the check-then-insert
converge pattern already used by SetOverrideCoreAsync.