mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-02 17:53:20 +00:00
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.
54 lines
2.2 KiB
C#
54 lines
2.2 KiB
C#
using Data.SeaHavenIndustries;
|
|
|
|
namespace SeaHaven.Services.Helpers
|
|
{
|
|
/// <summary>
|
|
/// SH-379 / SH-190: single resolution for "the Site's live contact" used by the
|
|
/// manual POC override. The same rules back the board projection fallback, the
|
|
/// completion snapshot fallback, the create-time baseline, and the
|
|
/// "changing away from the Site's live contact" lock comparison.
|
|
/// </summary>
|
|
public static class WorkOrderPocSiteContact
|
|
{
|
|
/// <summary>
|
|
/// The Site's primary live contact: first non-deleted contact ordered by
|
|
/// SiteContactOrder then Id. Null when the site has no live contacts.
|
|
/// </summary>
|
|
public static Contacts? ResolvePrimary(IEnumerable<Contacts>? contacts)
|
|
=> contacts?
|
|
.Where(c => c.IsDeleted != true)
|
|
.OrderBy(c => c.SiteContactOrder)
|
|
.ThenBy(c => c.Id)
|
|
.FirstOrDefault();
|
|
|
|
/// <summary>Display name matching the board projection: trimmed First + Last.</summary>
|
|
public static string? DisplayName(Contacts? contact)
|
|
=> contact == null
|
|
? null
|
|
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
|
|
|
/// <summary>
|
|
/// SH-190: a manual POC equal to one of the Site's live contacts (trimmed
|
|
/// name + phone) is not a change away from the Site and must not lock.
|
|
/// </summary>
|
|
public static bool MatchesAnySiteContact(
|
|
string? name,
|
|
string? phone,
|
|
IEnumerable<Contacts>? contacts)
|
|
{
|
|
if (contacts == null)
|
|
return false;
|
|
|
|
var normalizedName = Normalize(name);
|
|
var normalizedPhone = Normalize(phone);
|
|
|
|
return contacts
|
|
.Where(c => c.IsDeleted != true)
|
|
.Any(c => string.Equals(Normalize(((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim()), normalizedName, StringComparison.Ordinal)
|
|
&& string.Equals(Normalize(c.PhoneNumber), normalizedPhone, StringComparison.Ordinal));
|
|
}
|
|
|
|
private static string? Normalize(string? value)
|
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
}
|