merge: sync phase 3 with updated phase 2 base

Reconcile migration/docs deletions from phase 2, adopt async audit staging and ApptTime lock contracts, and keep phase 3 create/cancel transactional behavior.
This commit is contained in:
Arthur Bassi 2026-07-23 13:28:54 -03:00
commit 385812c5c3
59 changed files with 343 additions and 4640 deletions

View file

@ -19,6 +19,7 @@ using static System.Runtime.InteropServices.JavaScript.JSType;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -100,6 +101,11 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] List<WorkOrderType>? types = null,
[FromQuery] string? search = null)
{
var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4);
var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd);
if (weekValidationError != null)
return BadRequest(weekValidationError);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var query = new WorkOrderBoardQueryDto
{

View file

@ -1,43 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
public partial class AddWorkOrderTypeAndScheduledDateIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL
ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL;
""");
migrationBuilder.Sql("""
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
BEGIN
CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
END
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
""");
migrationBuilder.Sql("""
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NOT NULL
ALTER TABLE workOrders DROP COLUMN WorkOrderType;
""");
}
}
}

View file

@ -12,10 +12,9 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Data.SeaHavenIndustries.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260619150000_AddWorkOrderTypeAndScheduledDateIndex")]
partial class AddWorkOrderTypeAndScheduledDateIndex
[Migration("20260701120000_Phase1_BoardIndexes")]
partial class Phase1_BoardIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
@ -2034,10 +2033,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo");
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
b.HasIndex("ScheduledDate");
b.HasIndex("WorkOrderType");
b.ToTable("workOrders");
});

View file

@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public class Phase1_BoardIndexes : Migration
public partial class Phase1_BoardIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)

View file

@ -1,9 +0,0 @@
namespace SeaHaven.DataServices.Exceptions
{
public class BoardQueryException : Exception
{
public BoardQueryException(string message) : base(message)
{
}
}
}

View file

@ -27,7 +27,7 @@ namespace SeaHaven.DataServices.Helpers
Name = c.POC != null
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
: null,
c.POC!.PhoneNumber,
PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null,
c.Notes
})
.FirstOrDefault(),

View file

@ -47,6 +47,8 @@ namespace SeaHaven.DataServices.Helpers
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
}
#region Phase 2 filter helpers (not wired in Phase 1 board API)
public static IQueryable<WorkOrder> ApplySiteFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? sites)
@ -135,6 +137,8 @@ namespace SeaHaven.DataServices.Helpers
&& w.TargetWeek <= dateTo));
}
#endregion
public static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> query, string? search)
{
if (string.IsNullOrWhiteSpace(search))

View file

@ -1,9 +0,0 @@
namespace SeaHaven.DataServices.Interfaces
{
public record PrimaryDispatchProjection(
int WorkOrderId,
int? DispatchId,
string? VendorName,
DateTime? ApptDate,
string? DispatchStatus);
}

View file

@ -1,8 +0,0 @@
namespace SeaHaven.Services.Board
{
public static class WorkOrderBoardConstants
{
public const int MaxWindowDays = 90;
public const int ClientSideThreshold = 300;
}
}

View file

@ -5,6 +5,7 @@ namespace SeaHaven.Services.Constants
public const string LifecycleStatus = nameof(LifecycleStatus);
public const string AssignTo = nameof(AssignTo);
public const string ScheduledDate = nameof(ScheduledDate);
public const string ScheduledStart = nameof(ScheduledStart);
public const string ScheduledEnd = nameof(ScheduledEnd);
public const string TargetWeek = nameof(TargetWeek);
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);

View file

@ -0,0 +1,20 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Shared terminal lifecycle statuses for board filters, read-only checks, and jobs.
/// Keep this as the single source of truth (EF expressions should copy these values inline).
/// </summary>
public static class LifecycleStatusSets
{
public static readonly LifecycleStatus[] Terminal =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
}
}

View file

@ -4,21 +4,27 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardMutationRules
{
public static bool IsReadOnly(LifecycleStatus? status)
=> status.HasValue && LifecycleStatusSets.Terminal.Contains(status);
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses =
LifecycleStatusSets.Terminal.Cast<LifecycleStatus?>().ToHashSet();
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
/// <summary>
/// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled.
/// Week-only targets do not auto-schedule (same as <see cref="WorkOrderDerivedFields.ApplyAutoScheduleIfEligible"/>).
/// </summary>
public static bool ShouldAutoSchedule(
LifecycleStatus? status,
DateTime? scheduledDate,
string? assignTo,
bool? scheduleWeekOnly = null)
=> status == LifecycleStatus.Incomplete
&& scheduleWeekOnly != true
&& scheduledDate.HasValue
&& !string.IsNullOrWhiteSpace(assignTo)
&& scheduleWeekOnly != true;
&& !string.IsNullOrWhiteSpace(assignTo);
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
=> previousDate.HasValue

View file

@ -20,6 +20,10 @@ namespace SeaHaven.Services.Helpers
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
}
/// <summary>
/// Board read-model past-due check. Phase 1 compares UTC calendar dates;
/// business/location timezone support is planned for a later phase.
/// </summary>
public static bool IsPastDue(
DateTime? scheduledDate,
LifecycleStatus? lifecycleStatus,
@ -66,6 +70,10 @@ namespace SeaHaven.Services.Helpers
return true;
}
/// <summary>
/// Maps scheduled date to Mon–Fri board column. Phase 1 uses the stored
/// date's day-of-week (UTC); timezone-aware grouping is planned for a later phase.
/// </summary>
public static string? GetDayGroup(DateTime? scheduledDate)
{
if (!scheduledDate.HasValue)

View file

@ -16,5 +16,20 @@ namespace SeaHaven.Services.Helpers
public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
=> $"week:{sourceWeekStart:yyyy-MM-dd}";
/// <summary>
/// Returns a validation error message when the week window is invalid; otherwise null.
/// </summary>
public static string? ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
{
if (weekEnd < weekStart)
return "weekEnd must be on or after weekStart.";
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
if (spanDays > 6)
return "Week window cannot exceed 7 days.";
return null;
}
}
}

View file

@ -12,6 +12,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderFieldNames.LifecycleStatus,
WorkOrderFieldNames.AssignTo,
WorkOrderFieldNames.ScheduledDate,
WorkOrderFieldNames.ScheduledStart,
WorkOrderFieldNames.ScheduledEnd,
WorkOrderFieldNames.TargetWeek,
WorkOrderFieldNames.ScheduleWeekOnly,
@ -68,6 +69,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderFieldNames.Status => wo.Status,
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
WorkOrderFieldNames.ScheduledStart => wo.ScheduledStart?.ToString("O"),
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
@ -105,6 +107,9 @@ namespace SeaHaven.Services.Implementation
case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
wo.ScheduledDate = scheduledDate;
return true;
case WorkOrderFieldNames.ScheduledStart when TryParseDateTime(incomingValue, out var scheduledStart):
wo.ScheduledStart = scheduledStart;
return true;
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
wo.ScheduledEnd = scheduledEnd;
return true;

View file

@ -28,17 +28,17 @@ namespace SeaHaven.Services.Implementation
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
AuditActorType.Sync, fieldName, oldValue, newValue);
public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public void StageCreated(int workOrderId, string? actorId, string? woNumber)
=> StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber)
=> StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public async Task LogAsync(
int workOrderId,
@ -65,7 +65,7 @@ namespace SeaHaven.Services.Implementation
await _context.SaveChangesAsync();
}
private void StageAudit(
private async Task StageAuditAsync(
int workOrderId,
AuditActionType action,
string fieldName,
@ -76,7 +76,13 @@ namespace SeaHaven.Services.Implementation
{
AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher,
fieldName, oldValue, newValue, actorId, dispatchId);
StageFieldLock(workOrderId, fieldName, actorId);
if (action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged)
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
}
private void AddAuditEntry(
@ -107,21 +113,5 @@ namespace SeaHaven.Services.Implementation
});
}
private void StageFieldLock(int workOrderId, string fieldName, string? userId)
{
var alreadyTracked = _context.WorkOrderFieldLocks.Local
.Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
if (alreadyTracked)
return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = workOrderId,
FieldName = fieldName,
LockedAt = DateTime.UtcNow,
LockedByUserId = userId
});
}
}
}

View file

@ -47,7 +47,7 @@ namespace SeaHaven.Services.Implementation
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
_auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _context.SaveChangesAsync();
var row = await _boardService.GetBoardRowAsync(workOrderId);

View file

@ -139,8 +139,8 @@ namespace SeaHaven.Services.Implementation
dispatch.WorkOrderId = workOrder.Id;
}
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
StageChanges(workOrder.Id, changes, actorId, dispatch);
await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber);
await StageChangesAsync(workOrder.Id, changes, actorId, dispatch);
if (request.PocContactId.HasValue)
{
@ -200,7 +200,7 @@ namespace SeaHaven.Services.Implementation
return normalized;
}
private void StageChanges(
private async Task StageChangesAsync(
int workOrderId,
IEnumerable<WorkOrderBoardFieldMutations.BoardFieldChange> changes,
string? actorId,
@ -212,13 +212,13 @@ namespace SeaHaven.Services.Implementation
switch (change.Action)
{
case AuditActionType.StatusChanged:
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
break;
}
}

View file

@ -22,7 +22,9 @@ namespace SeaHaven.Services.Implementation
var weekStart = query.WeekStart;
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
ValidateWeekWindow(weekStart, weekEnd);
var validationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, weekEnd);
if (validationError != null)
throw new ArgumentException(validationError);
var dataQuery = new WorkOrderBoardQuery(
weekStart,
@ -68,18 +70,10 @@ namespace SeaHaven.Services.Implementation
return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
}
private static void ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
{
if (weekEnd < weekStart)
throw new ArgumentException("weekEnd must be on or after weekStart.");
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
if (spanDays > 6)
throw new ArgumentException("Week window cannot exceed 7 days.");
}
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
{
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
// Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime.
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
var apptEnd = row.ScheduledEnd;

View file

@ -2,6 +2,7 @@ using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
@ -37,8 +38,8 @@ namespace SeaHaven.Services.Implementation
try
{
if (string.IsNullOrWhiteSpace(request.Field))
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
if (string.IsNullOrWhiteSpace(request.Field))
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
var field = request.Field.Trim();
var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field);
@ -98,57 +99,58 @@ namespace SeaHaven.Services.Implementation
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
changes = changes.Where(c => c.HasChanged).ToList();
if (changes.Count == 0 && !_context.ChangeTracker.HasChanges())
{
var unchanged = await LoadBoardRowAsync(workOrderId);
if (transaction is not null)
await transaction.CommitAsync();
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
if (changes.Count == 0)
{
await _context.SaveChangesAsync();
if (transaction is not null)
await transaction.CommitAsync();
var persisted = await LoadBoardRowAsync(workOrderId);
return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
foreach (var change in changes)
{
switch (change.Action)
{
case AuditActionType.StatusChanged:
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId);
break;
}
}
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (transaction is not null)
await transaction.RollbackAsync();
_context.ChangeTracker.Clear();
var currentState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(currentState);
}
if (changes.Count == 0 && !_context.ChangeTracker.HasChanges())
{
var unchanged = await LoadBoardRowAsync(workOrderId);
if (transaction is not null)
await transaction.CommitAsync();
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
var row = await LoadBoardRowAsync(workOrderId);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (changes.Count == 0)
{
await _context.SaveChangesAsync();
if (transaction is not null)
await transaction.CommitAsync();
var persisted = await LoadBoardRowAsync(workOrderId);
return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
foreach (var change in changes)
{
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
switch (change.Action)
{
case AuditActionType.StatusChanged:
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId);
break;
}
}
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (transaction is not null)
await transaction.RollbackAsync();
_context.ChangeTracker.Clear();
var currentState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(currentState);
}
if (transaction is not null)
await transaction.CommitAsync();
var row = await LoadBoardRowAsync(workOrderId);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
catch (WorkOrderBoardConcurrencyException)
{
@ -182,7 +184,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
};
@ -430,10 +432,10 @@ namespace SeaHaven.Services.Implementation
var old = dispatch.VendorId.ToString();
if (dispatch.VendorId == vendorId.Value)
return FieldChange.Unchanged(auditField, dispatch.Id);
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
dispatch.VendorId = vendorId.Value;
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id);
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch));
}
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
@ -449,13 +451,15 @@ namespace SeaHaven.Services.Implementation
var old = FormatDate(dispatch.ScheduledDate);
var newVal = FormatDate(parsed);
if (old == newVal)
return FieldChange.Unchanged(auditField, dispatch.Id);
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
dispatch.ScheduledDate = parsed;
return FieldChange.ForField(auditField, old, newVal, dispatch.Id);
return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch));
}
private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField)
// apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so
// SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock).
private static List<FieldChange> ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
{
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
@ -463,19 +467,24 @@ namespace SeaHaven.Services.Implementation
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
var oldStart = FormatDateTime(workOrder.ScheduledStart);
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
var dispatchId = ResolveDispatchIdForAudit(dispatch);
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
var newStart = FormatDateTime(workOrder.ScheduledStart);
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
var oldCombined = FormatApptRange(oldStart, oldEnd);
var newCombined = FormatApptRange(newStart, newEnd);
var changes = new List<FieldChange>();
if (oldCombined == newCombined)
return FieldChange.Unchanged(auditField, dispatch.Id);
if (oldStart != newStart)
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId));
if (oldEnd != newEnd)
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId));
return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id);
if (changes.Count == 0)
changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId));
return changes;
}
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
@ -542,6 +551,9 @@ namespace SeaHaven.Services.Implementation
return vendorId;
}
private static int? ResolveDispatchIdForAudit(Dispatch dispatch)
=> dispatch.Id > 0 ? dispatch.Id : null;
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
private static string? FormatApptRange(string? start, string? end)

View file

@ -20,9 +20,9 @@ namespace SeaHaven.Services.Interfaces
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId);
void StageCreated(int workOrderId, string? actorId, string? woNumber);
Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber);
}
}

View file

@ -21,8 +21,10 @@
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
<ProjectReference Include="..\SeaHaven.DataServices\SeaHaven.DataServices.csproj" />
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
</ItemGroup>

View file

@ -43,7 +43,7 @@ public class WorkOrderBoardMutationRulesTests
}
[Fact]
public void ShouldAutoSchedule_FalseWhenWeekOnly()
public void ShouldAutoSchedule_FalseWhenScheduleWeekOnly()
{
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
LifecycleStatus.Incomplete,

View file

@ -2,10 +2,12 @@ using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
@ -180,6 +182,56 @@ public class WorkOrderBoardUpdateServiceTests
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
}
[Fact]
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
SiteCode = "OLD",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var updated = await context.workOrders.FindAsync(1);
var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK6",
WorkOrderVersion = ToVersion(updated!)
}, "actor-1");
Assert.Equal("BK6", secondResult.SiteCode);
Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync());
Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync());
}
[Fact]
public async Task PatchField_NotFound_ThrowsNotFoundCode()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
}, "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task PatchField_NormalizesWoNumber()
{
@ -292,6 +344,110 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
}
[Fact]
public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "-30",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
var reloaded = await context.workOrders.FindAsync(1);
Assert.NotNull(reloaded!.ScheduledStart);
Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd);
Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart);
}
[Fact]
public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync()
{
var (context, service) = CreateSut();
var dispatch = new Dispatch
{
Id = 10,
WorkOrderId = 1,
VendorId = 1,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
};
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
ScheduledDate = new DateTime(2026, 6, 25),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
context.Dispatches.Add(dispatch);
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptTime,
Value = "09:00-10:00",
WorkOrderVersion = ToVersion(wo),
DispatchVersion = ToVersion(dispatch),
PrimaryDispatchId = 10
}, "actor-1");
var lockNames = await context.WorkOrderFieldLocks
.Where(l => l.WorkOrderId == 1)
.Select(l => l.FieldName)
.ToListAsync();
Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames);
Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames);
Assert.DoesNotContain("ApptTime", lockNames);
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = reloaded,
FieldLocks = locks,
Audit = audit
};
var appliedStart = await policy.TryApplyAsync(
syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00");
var appliedEnd = await policy.TryApplyAsync(
syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00");
Assert.False(appliedStart);
Assert.False(appliedEnd);
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded.ScheduledStart);
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
}
[Fact]
public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired()
{

View file

@ -1,194 +0,0 @@
# ADR: Work Orders Board API (Fase 1 read-only)
**Status:** Proposed — spikes G0, G1, G3 concluídas
**Data:** 2026-06-19
**Deciders:** Backend + FE (aprovação Fase 1 pending)
---
## Contexto
O Schedule Board (FE) organiza work orders por `ScheduledDate` em visão semanal. O backend atual expõe `GetWorkOrderList` — listagem CRUD paginada sem janela temporal, sem `siteCode`, sem vendor inline, sem `ScheduledDate` na projeção.
Spikes de referência:
- [Consumer Audit](../spikes/consumer-audit-getworkorderlist.md)
- [WorkOrderType](../spikes/work-order-type.md)
- [Search Strategy](../spikes/search-strategy.md)
---
## Decisão
### 1. Endpoint dedicado (sem `projection=`)
Criar endpoint **separado** para o Schedule Board. **Não alterar** `GetWorkOrderList`.
```http
GET /api/workorders/GetWorkOrdersBoard
?scheduledFrom=2026-06-01
&scheduledTo=2026-06-07
&assignee=guid1&assignee=guid2
&search=BK5
&status=Open&status=InProgress
&locationId=42
```
Rota alias: `GET /api/WorkOrder/GetWorkOrdersBoard` (consistente com controller existente).
**Rejeitado:**
- `GET /api/workorders/table`
- `GET /GetWorkOrderList?projection=table`
### 2. Window-based fetch (sem paginação Fase 1)
| Abordagem | Fase 1 |
|-----------|--------|
| `scheduledFrom` + `scheduledTo` | **Obrigatório** |
| `page` / `pageSize` | **Ausente** |
| Limite janela | Max **90 dias** (validação server-side) |
Response envelope:
```json
{
"scheduledFrom": "2026-06-01",
"scheduledTo": "2026-06-07",
"totalCount": 42,
"items": [
{
"row": { /* WorkOrderBoardRowDto */ },
"presentation": { /* BoardRowPresentation */ }
}
]
}
```
### 3. DTO — domínio vs presentation
**`WorkOrderBoardRowDto`** — dados persistidos / joináveis:
```csharp
public record WorkOrderBoardRowDto(
int Id,
string? WoNumber,
string? SiteCode,
string? LocationLabel,
DateOnly? ScheduledDate,
TimeOnly? ScheduledStart,
DateOnly? DueDate,
string? WorkOrderType,
string? ServiceType,
string? AssigneeId,
string? AssigneeName,
string? VendorCompany,
string? VendorTechnician,
string Status,
string? PocName,
string? PocPhone
);
```
**`BoardRowPresentation`** — calculado no mapper backend:
```csharp
public record BoardRowPresentation(
string StatusDisplay,
bool IsPastDue,
string? WorkOrderTypeDisplay
);
```
Mapper: `IWorkOrderBoardMapper` centraliza Past Due e type display.
### 4. Query EF (Fase 1)
- Filtro base: `istemplate != true`
- Janela: `ScheduledDate >= from && ScheduledDate < to.AddDays(1)`
- Includes: `Locations`, `AssignToUser`, `Dispatches` → `Vendor` (1 vendor por WO — first active dispatch)
- POC: `WorkOrderContacts` (primary)
- Service type: `Trade` ou category join (mínimo: `Trade`)
- **Não incluir** subquery `lastUpdated` (Comments/AuditLog)
### 5. Search (conforme spike)
- Search scoped à janela temporal
- Servidor: `StartsWith` em `InternalWONumber`, `SiteCode`
- Count ≤ **300**: retornar semana inteira; FE filtra title/location/serviceType
- Count > 300: exigir termo indexável ou retornar 400
- Assignee: `assigneeId[]` multi — **não** filtrar por nome
Índices (migration separada pós-aprovação):
- `IX_workOrders_ScheduledDate` (P1)
- `IX_workOrders_InternalWONumber`, `IX_workOrders_SiteCode` (P2, após fix tamanho coluna)
### 6. WorkOrderType
- Mapear coluna legada `WorkOrderType varchar(50)` na entidade EF
- Expor em `WorkOrderBoardRowDto`
- `IsPastDue` em presentation — **não** confundir com tipo
- Enum C# adiado até amostragem prod
### 7. Status mapping
- API retorna status **canônico** (`Open`, `InProgress`, `Completed`, `Cancelled`, `OnHold`)
- `StatusDisplay` via `IWorkOrderStatusMapper` (workshop G2 separado)
- Sem migration de enum DB
---
## Consequências
### Positivas
- 1 HTTP call por mudança de semana (FE)
- Contrato estável para board sem acoplar CRUD legado
- Performance previsível (janela + threshold 300)
- Blazor legado inalterado (EF local)
### Negativas / trade-offs
- Duplicação parcial de lógica de listagem vs `GetWorkOrderList` (aceitável — bounded contexts distintos)
- Vendor technician pode ser null Fase 1 se dispatch spike incompleto
- Setup DB local necessário para validação integrada
---
## Compliance com spikes
| Spike | Gate | Resultado |
|-------|------|-----------|
| Consumer Audit | G0 | `GetWorkOrderList` órfão no repo — endpoint dedicado aprovado |
| WorkOrderType | G1 | Mapear coluna EF; Overdue = presentation |
| Search Strategy | G3 | Window-first, threshold 300, StartsWith identificadores |
---
## Fora de escopo Fase 1
- PATCH inline edit
- Completion doc, media, flags, reorder
- Search vendor/tech server-side
- Paginação cross-week
- Alteração de `GetWorkOrderList`
---
## Próximos passos (implementação)
1. Aprovação explícita **Fase 1 only**
2. Migration: `WorkOrderType` + índice `ScheduledDate`
3. `GetWorkOrdersBoard` em `WorkOrderController` + `WorkOrderDataService`
4. DTOs + `IWorkOrderBoardMapper`
5. FE: `useWorkOrdersBoard({ from, to })` substituindo `RAW_ORDERS`
---
## Referências
- Gap analysis v3: `work_orders_api_gap_analysis_ce735946.plan.md`
- [`WorkOrderController.cs`](../../Api.SeaHavenIndustries/Controllers/WorkOrderController.cs)
- [`WorkOrderDataService.cs`](../../SeaHaven.DataServices/Implementation/WorkOrderDataService.cs)
- [`DispatchController.List`](../../Api.SeaHavenIndustries/Controllers/DispatchController.cs) — padrão janela temporal

View file

@ -1,299 +0,0 @@
# Auditoria — API Work Orders vs Frontend (WorkOrders.tsx)
**Data:** 2026-06-19
**Fonte da verdade (FE):** `seahaven.desing/src/pages/WorkOrders.tsx`
**Backend auditado:** `seaheven.api`
---
## Resumo executivo
| Métrica | Valor |
|---------|-------|
| Compatibility Score | **24/100** |
| Backend Readiness | **Not Ready** |
| Confidence | **High** |
A API atual foi construída para **CRUD legado** (listagem paginada, detalhe rico, dispatch). O frontend `WorkOrders.tsx` define um **Schedule Board operacional** (visão semanal, 14 colunas, edição inline, advanced search, completion doc WO-level). Os contratos são fundamentalmente diferentes.
**O que funciona hoje:** id, dueDate, location (parcial), CRUD básico, detalhe rico, comments, audit field-based, mídia WO-level, reatribuição, dispatch.
**O que falta:** listagem window-based, 14 colunas board, PATCH inline, campos derivados, advanced search, completion doc WO-level, jobs Past Due/Carried Over, status 10 labels, WO# 11 dígitos.
---
## Endpoints que existem hoje
| Endpoint | O que entrega |
|----------|----------------|
| `GET GetWorkOrderList` | Lista **paginada** (default 12): id, número, título, location, priority, status, dueDate, assignedTo, lastUpdated |
| `GET Getworkorders` / `GetworkordersDD` | Lista legada para admin/dropdown |
| `GET GetFilteredWorkorder` / `2` | Filtros legados (assignee, location, priority, status, due date buckets) — carrega tudo em memória |
| `GET GetWorkorderById` / `{id}` | Detalhe: título, descrição, datas, status, problem, trade, POC, attachments, comments, audit, dispatches |
| `POST AddWorkorder` | Criação via form: título, descrição, assignee, dueDate, location, priority, mídia, contacts/categories |
| `POST/PUT EditWorkorder` | Update parcial via form (subset mapeado ao service) |
| `POST ChangeStatus` | Muda status (string livre) + audit |
| `POST ChangeAssignment` | Reatribui dispatcher + audit |
| `POST AddComment` / `AddCommentJson` | Comentário + upload opcional |
| `GET GetComments` / `GetCommentsByWorkorderId` | Lista comentários |
| `DELETE DeleteWorkorder` | **Delete físico** (não soft cancel) |
| Dispatch endpoints | Vendor, checklist, signoff, verify — **nível dispatch**, não board |
**Lookups relacionados (outros controllers):** `Common/GetUsers`, `Location/GetLocationList`, `Vendor/GetVendorList` — paginados, contrato diferente do FE.
**Documentado mas não implementado no código:** `GET GetWorkOrdersBoard`, `IWorkOrderBoardMapper`, `IWorkOrderStatusMapper` (ver `docs/adr/work-orders-board-api.md`).
---
## 1. Listagem da tela principal (visão semanal)
| Frontend espera | API entrega hoje | Gap |
|-----------------|------------------|-----|
| 1 call por semana (`weekStart` / `weekEnd`) | Paginação `page` / `pageSize` sem janela temporal | Sem fetch window-based |
| WOs agrupados por dia (Mon–Fri) | Lista flat paginada | Sem `scheduledDate` na projeção de listagem |
| Seção **Unscheduled** sempre visível no topo | Só WOs da página atual | Sem WOs sem data retornados independente da semana |
| `targetWeek` (scheduling week-only) | Campo inexistente na entidade/API | Não implementado |
| Contador `X of Y` da semana ativa | `totalCount` global paginado | Contagem inadequada para o board |
| Skeleton Mon–Fri vazios | N/A (FE-only) | OK — responsabilidade do FE |
**Evidência BE:** `WorkOrderDataService.GetWorkOrderListPagedAsync` — projeção L163-183, sem `ScheduledDate`, `SiteCode`, vendor, type.
---
## 2. Colunas da tabela (`COLS`)
Definidas no FE em `WorkOrders.tsx` L4234-4249.
| Coluna FE | Campo(s) FE | API hoje | Gap |
|-----------|-------------|----------|-----|
| Grip | drag reorder | Ausente | FE local-only; BE não persiste ordem |
| Flag | flag pessoal | Ausente | FE session-only |
| **SITE** | `site`, `pocName`, `pocPhone`, `pocNotes` | List: `location` (name); Detail: POC via `WorkOrderContacts` | Sem `siteCode` na listagem; sem POC na lista |
| **WO** | `woNumber`, `rescheduleCount`, `carriedOver` | `internalWONumber` / `workerOrderNumber` | Formato ≠ 11 dígitos; sem badges ↻ ↷N |
| **TYPE OF WO** | `type` (PM / Reactive / Emergency / Add-On / Overdue) | Não exposto (`WorkOrderType` em `db.txt`, não mapeado em EF) | Coluna inoperante |
| **ASSIGNED TO** | `dispatcherId` + avatar/cor | `assignedTo` (nome completo); `AssignTo` = GUID Identity | Sem id/initials/color para avatar |
| **SCHEDULE ON** | `scheduledOn`, `targetWeek` | Detail: `scheduledDate`; **list: ausente** | Listagem sem data de agendamento |
| **DUE DATE** | `dueDate` | `dueDate` | **OK** na listagem |
| **SERVICE** | `pm` | Detail: `trade` / `problem`; list: ausente | Coluna vazia na lista |
| **VENDOR** | `company`, `tech`, `techPhone` | Detail: `dispatches` → vendor; list: ausente | Coluna vazia na lista |
| **APPT TIME** | `apptTime` (ex: "07:00 – 09:00") | Entidade: `ScheduledStart`; sem `ScheduledEnd` | Janela de horário incompleta |
| **STATUS** | 10 labels + overlay Past Due | String legada DB + enum 5 valores API | Sem mapping para status operacionais do FE |
| **COMP DOC** | `docStatus` (Yes / No / NN) | Ausente no WO | Completion existe só no **dispatch** (`VerifyDispatch`) |
| Actions | View / Edit | `GetWorkorderById` separado | Detalhe existe; contrato diferente |
---
## 3. Filtros e busca
### Barra principal (FE L5083-5099)
| Frontend | API hoje | Gap |
|----------|----------|-----|
| **Dispatcher** multi-select + `__unassigned__` + default usuário logado | `assignee` single string; `__unassigned` suportado em `GetWorkOrderList` | Multi-select e default "My WOs" não cobertos |
| **Semana ativa** | Sem filtro `ScheduledDate` | Filtro semanal inexistente |
| **Tipo** (All / PM / Reactive / Emergency / Add-On / Overdue) | Sem filtro por work order type | Inexistente |
| **Busca contextual** na semana (site, wo#, dispatcher, location, pm, company, tech, status) | Search em 5 campos (`InternalWONumber`, `WorkerOrderNumber`, `Title`, `Location`), escopo global paginado | Campos e escopo incompatíveis |
### Advanced Search (FE L4255-4324)
| Frontend | API hoje | Gap |
|----------|----------|-----|
| Lista plana cross-week | Ausente | Sem endpoint dedicado |
| Date range (this-week, last-week, this-month, last-3-months, next-week, next-month, custom) | `GetFilteredWorkorder` com due date buckets legados | Semântica diferente |
| Filtros: sites[], types[], dispatchers[], statuses[], pmTypes[], vendorTechs[], docs[] | Parcial em endpoints legados | Cobertura incompleta |
---
## 4. Edição inline (spreadsheet-style)
| Frontend | API hoje | Gap |
|----------|----------|-----|
| PATCH por campo (site, wo#, type, dispatcher, schedule, due, service, vendor, appt, status…) | `EditWorkorder` (form multipart) + `ChangeStatus` + `ChangeAssignment` separados | Sem update granular por campo |
| Auto-schedule: `Incomplete` → `Scheduled` quando data + dispatcher | Ausente no BE | Regra `maybeAutoSchedule` só no FE |
| `rescheduleCount++` ao mudar `scheduledOn` | Campo inexistente | Badge ↻ impossível |
| Limpar `isPastDue` ao reagendar para data futura | Campo derivado inexistente | Past Due não limpa via API |
| Bloquear mudança de status se `isPastDue` | `ChangeStatus` aceita qualquer string | Sem validação 422 |
| WO# único 11 dígitos normalizado | Gerador `WO-{yyyyMMdd}-{random}` ou sync sequencial `10000001` | Formato e unicidade incompatíveis |
| WO cancelado read-only | Sem enforcement no BE | Só lógica no FE (`WOSlideOver` L2380) |
**Evidência BE:** `WorkOrderController.Editworkorder` L182-192 mapeia subset para `UpdateWorkOrderDTO` — ignora `Problem`, `Trade`, `ScheduledDate`, `Source` apesar de existirem em `EditWorkorder_DTO`.
---
## 5. Criação de WO
| Frontend | API hoje | Gap |
|----------|----------|-----|
| Wizard `NewWOWizard` + inline `InlineRow` | `POST AddWorkorder` | Campos do wizard majoritariamente ausentes |
| Campos: site, type, scheduledOn, targetWeek, pm, company, tech, appt, POC | `CreateWorkOrderDTO`: title, description, assignTo, dueDate, location | Contrato incompleto |
| Status inicial `Incomplete` | Cria com `WorkOrderStatus.Open` | Status inicial diferente |
| `scheduleWeekOnly` / `targetWeek` | Ausente | Week-only scheduling impossível |
| Navegação automática para semana do WO criado | Depende de listagem semanal | Listagem incompatível |
---
## 6. Cancelamento
| Frontend | API hoje | Gap |
|----------|----------|-----|
| Soft cancel → status `Canceled` | `POST ChangeStatus(id, "Canceled")` manual | Sem endpoint `POST cancel` dedicado |
| WO cancelado não editável | Sem bloqueio no BE | Enforcement só no FE |
| Delete permanente (Admin) | `DELETE DeleteWorkorder` — delete físico | Comportamento diverge do soft cancel FE |
---
## 7. Slide-over (`WOSlideOver`)
| Tab FE | API hoje | Gap |
|--------|----------|-----|
| **Info** | `GET GetWorkorderById` | Omite `siteCode`; sem campos derivados FE (`isPastDue`, `rescheduleCount`, etc.) |
| **Comments** | `AddComment` + `GetCommentsByWorkorderId` | Formato FE `{authorId, text, time}` vs BE `{Commenttext, FirstName, Documents}` |
| **Audit Log** | `WorkOrderAuditLog` retornado no detalhe | Schema `{fieldName, oldValue, newValue, action}` vs FE `{type: manual\|system, dispatcherId?, action, time}` |
| **Completion Doc** | `SignOffName` / `SignOffAttachment` no WO; `VerifyDispatch` no dispatch | Sem template por service type, PDF, `docStatus` WO-level |
| **Extra Docs / Media** | `BeforPhoto`, `AfterPhoto`, `workOrderAttachments` | Sem `MediaFile.category`; sem API extra docs dedicada |
---
## 8. Regras de sistema (background jobs)
| Frontend assume | API hoje | Gap |
|-----------------|----------|-----|
| Job Past Due: `scheduledOn < hoje` + não terminal → `isPastDue=true` | Ausente (zero `BackgroundService` / Hangfire / Quartz no repo) | Flag nunca setada automaticamente |
| Job Carried Over: virada de semana → `carriedOver++` | Ausente | Contador ↷N nunca incrementado |
| Type Overdue promotion | Ausente | Filtro/tipo Overdue não automático |
| Sync APM | `SyncController` POST manual (DynamoDB → SQL) | On-demand, não integrado à UX do board |
---
## 9. Lookups (catálogos)
| FE usa (mock estático) | API relacionada | Gap |
|------------------------|-----------------|-----|
| `DISPATCHERS` (id, name, initials, color) | `Common/GetUsers` | Sem initials/color; paginado; ids são GUIDs |
| `SITE_OPTIONS` (code → city, state) | `Location/GetLocationList` | Sem lookup por site code; sem mapeamento BK5 → Dallas |
| `PM_TYPES` | Ausente | Sem catálogo de service types |
| `TECHNICIANS` (name, company, phone) | `Vendor/GetVendorList` | Sem endpoint technicians |
| `WEEK_RANGES` (Mon–Fri + flag LIVE) | Ausente | Sem endpoint weeks |
---
## Contrato de dados — campo a campo
| Campo FE (`WorkOrder`) | Campo BE | Status |
|------------------------|----------|--------|
| `id` | `WorkOrder.Id` | SUPPORTED |
| `woNumber` | `InternalWONumber` / `WorkerOrderNumber` | PARTIALLY_SUPPORTED — formatos conflitantes |
| `site` | `SiteCode` | PARTIALLY_SUPPORTED — entidade tem; API omite na listagem |
| `type` | — (`WorkOrderType` só em SQL) | NOT_SUPPORTED |
| `dispatcherId` | `AssignTo` (GUID) | PARTIALLY_SUPPORTED |
| `scheduledOn` | `ScheduledDate` | PARTIALLY_SUPPORTED — list omite |
| `dayGroup` / `dayLabel` | — | NOT_SUPPORTED (derivado) |
| `location` | `Locations.Name` | SUPPORTED (via join) |
| `pm` | `Trade` / `Problem` | PARTIALLY_SUPPORTED |
| `company` / `tech` | `Dispatches` → `Vendor` | PARTIALLY_SUPPORTED — não inline na lista |
| `techPhone` | — | NOT_SUPPORTED |
| `apptTime` | `ScheduledStart` (sem end) | PARTIALLY_SUPPORTED |
| `status` | `Status` (string livre) | PARTIALLY_SUPPORTED — 10 vs 5+ formatos |
| `docStatus` | — | NOT_SUPPORTED |
| `pocName` / `pocPhone` / `pocNotes` | `WorkOrderContacts` | PARTIALLY_SUPPORTED — notes ausente |
| `dueDate` | `DueDate` | SUPPORTED |
| `rescheduleCount` | — | NOT_SUPPORTED |
| `carriedOver` | — | NOT_SUPPORTED |
| `originalWeek` / `originalDate` | — | NOT_SUPPORTED |
| `isPastDue` | — | NOT_SUPPORTED (derivado) |
| `targetWeek` | — | NOT_SUPPORTED |
---
## Regras de negócio — FE vs BE
| Regra FE | Evidência FE | BE hoje | Status |
|----------|--------------|---------|--------|
| Auto-schedule Incomplete → Scheduled | `maybeAutoSchedule` L500-506 | Ausente | NOT_SUPPORTED |
| Reschedule incrementa contador | `updateScheduledOn` L4856 | Ausente | NOT_SUPPORTED |
| Reschedule limpa Past Due | L4851-4857 | Ausente | NOT_SUPPORTED |
| Status bloqueado em Past Due | `StatusCell` L1120-1159 | `ChangeStatus` sem validação | NOT_SUPPORTED |
| WO# único 11 dígitos | `findDuplicateWONumber` L4897 | Gerador random; `InternalNumberExistsAsync` sem normalização | NOT_SUPPORTED |
| Cancel → Canceled, read-only | `cancelWO` L4946; `WOSlideOver` L2380 | `ChangeStatus` manual | PARTIALLY_SUPPORTED |
| Carried over job semanal | Audit derivado L2414-2416 | Sem job | NOT_SUPPORTED |
| Completion doc por tipo de serviço | `CompDocDialog` L3902+ | Dispatch verify only | NOT_SUPPORTED (WO-level) |
| Emergency/Reactive → media flow | `isMediaWO` L332-334 | Mídia WO genérica | PARTIALLY_SUPPORTED |
---
## Diagrama — contrato atual vs esperado
```
HOJE (GetWorkOrderList) FRONTEND (WorkOrders.tsx)
───────────────────── ─────────────────────────
Paginação 12/page 1 call = semana inteira
Sem ScheduledDate Agrupamento Mon–Fri
Sem siteCode, type, vendor 14 colunas preenchidas
Sem unscheduled semantics Seção Unscheduled fixa
5 status legados 10 status + Past Due overlay
CRUD admin Schedule Board operacional
EditWorkorder (form) PATCH inline por célula
```
---
## Cobertura por área
| Área | Cobertura estimada |
|------|-------------------|
| Listagem board (visão semanal) | ~5% |
| Colunas da tabela (14) | ~15% |
| Filtros e busca | ~10% |
| Edição inline | ~0% |
| Criação (wizard/inline) | ~25% |
| Slide-over (detalhe) | ~40% |
| Completion doc WO-level | ~0% |
| Background jobs | ~0% |
| Lookups/catálogos | ~20% |
---
## Critical blockers
1. **Sem API window-based** para visão semanal + Unscheduled — tela principal não carrega.
2. **Contrato de listagem incompatível** com 14 colunas do board.
3. **Campos persistidos ausentes** — `rescheduleCount`, `carriedOver`, `targetWeek`, `docStatus`, `isPastDue`.
4. **Sem PATCH inline** — edição spreadsheet-style impossível.
5. **Advanced search cross-week inexistente.**
6. **Status 10 valores + Past Due** sem mapping layer implementado.
7. **Completion doc WO-level inexistente** — coluna COMP DOC inoperante.
8. **WO# 11 dígitos único** não suportado.
9. **Jobs Past Due / Carried Over ausentes.**
---
## Non-critical gaps
- Reorder intra-dia persistido (FE já local-only)
- Flags pessoais (FE session-only)
- Paginação 12/24/48/96 (FE não implementou)
- Bulk select (FE não implementou)
- `[Authorize]` comentado no `WorkOrderController` (risco de segurança)
---
## Referências
- Frontend: `seahaven.desing/src/pages/WorkOrders.tsx`
- Controller: `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs`
- Data service: `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs`
- Entidade: `Data.SeaHavenIndustries/Models/WorkerOrder.cs`
- ADR (proposto, não implementado): `docs/adr/work-orders-board-api.md`
- Spikes: `docs/spikes/consumer-audit-getworkorderlist.md`, `search-strategy.md`, `status-mapping.md`, `work-order-type.md`
---
## Classificação final
| Métrica | Valor |
|---------|-------|
| Compatibility Level | **Minimally Compatible** |
| Backend Readiness | **Not Ready** |
| Confidence | **High** |
Este documento reporta apenas **findings** — gaps entre o que a API entrega hoje e o que o frontend especifica. Não inclui propostas de implementação.

View file

@ -1,907 +0,0 @@
# Roadmap de Implementação — Work Orders API vs Frontend Board
**Documento:** Relatório consolidado de arquitetura e entrega
**Fonte da verdade (frontend):** `seahaven.desing/src/pages/WorkOrders.tsx`
**Backend:** `seaheven.api`
**Auditoria base:** [auditoria-work-orders-api-vs-frontend.md](auditoria-work-orders-api-vs-frontend.md)
**Estado atual:** Compatibility Score **24/100** — backend **Not Ready**
**Nota técnica do programa:** **9.0/10** (meta kickoff enterprise **9.5+**)
**Escopo:** capacidades de negócio, domínio, governança, fases, riscos e validação — sem design de API, schema ou código
---
## 1. Executive Summary
### 1.1 Readiness geral
O backend é **maduro para CRUD legado, dispatch, vendor portal, checklist/sign-off e audit**, mas foi construído para **listagem paginada administrativa**, não para o **Schedule Board operacional** que o frontend define. A tela principal do board **não pode funcionar** com os contratos atuais.
| Dimensão | Avaliação |
|----------|-----------|
| Tela principal (visão semanal) | ~5% |
| 14 colunas do board | ~15% |
| Edição inline | ~0% |
| Completion doc WO-level | ~0% |
| Background jobs | ~0% |
| Detalhe / slide-over | ~40% |
| **Média ponderada** | **~24%** |
### 1.2 Problemas arquiteturais principais
1. **Paradigma de contrato incompatível** — paginação global vs. janela semanal com seção Unscheduled fixa.
2. **Projeção de listagem insuficiente** — 9 das 14 colunas do board não são alimentadas pela listagem atual.
3. **Ausência de camada de domínio operacional** — campos derivados, tipo de WO e status operacionais ausentes ou não expostos.
4. **Database drift** — `WorkOrderType` existe no SQL legado mas não no EF; possíveis outras colunas desalinhadas.
5. **Modelo de mutação inadequado** — form multipart vs. edição granular por célula.
6. **Sem automação de sistema** — zero infraestrutura de jobs; regras do FE nunca executam no servidor.
7. **Cinco consumidores paralelos** — SHOC, Blazor EF, Vendor Portal, Sync/Lambda, e-mails — sem governança de ownership.
8. **Duas trilhas paralelas** — Blazor acessa EF direto; API REST é integração do SHOC.
### 1.3 Maior risco do programa
**Governança de ownership de dados** — quem pode alterar cada campo entre SHOC, Vendor Portal, Sync, Blazor e Jobs. A arquitetura de domínio resolve fontes da verdade; a governança operacional é o gate final.
### 1.4 Decisões de produto registradas
- **Novas funcionalidades do board** → somente via API REST para o frontend React (SHOC).
- **Blazor (`SeaHavenIndustries`)** → não recebe board, inline edit, jobs, advanced search nem completion doc WO-level; manutenção mínima até sunset do módulo WO.
- **Vendor Portal** → permanece ativo; não-regressão obrigatória.
- **DynamoDB/Sync** → ponte temporária; aposentar após SHOC ingerir direto ([TODO.md](../TODO.md)).
### 1.5 Complexidade e esforço
| Métrica | Valor |
|---------|-------|
| Complexidade geral | **Alta** |
| Esforço total | **XL** (6–9 meses, 2 squads; ou 4–6 meses, 3 squads) |
| Fase 0 — Fundação domínio + governança | L |
| Fase 1 — Board semanal | L |
| Fase 2 — Edição inline + concorrência | L |
| Fase 3 — Criação e cancelamento | M |
| Fase 4 — Busca avançada | M |
| Fase 5 — Domain events (jobs) | M |
| Fase 6 — Completion doc e slide-over | M |
| Fase 7 — Rollout e produção | M |
### 1.6 Gates bloqueantes da Fase 0
Nenhuma implementação de board inicia sem:
1. Domain Architecture Review (DAR) aprovado
2. **Data Ownership Model** assinado
3. **Field Ownership Matrix** assinada
4. **Audit Event Contract** aprovado
5. Volume Discovery Report (sem premissa de volume)
6. Database drift report (SQL vs EF)
7. Spike vendor/dispatch
8. Data migration dry-run
9. RowVersion multi-agregado definido
---
## 2. Capability Gap Assessment
### 2.1 Weekly Scheduling Board — **Critical**
- **Atual:** paginação sem janela temporal, sem Unscheduled.
- **Desejado:** 1 call/semana, Mon–Fri + Unscheduled, contador `X of Y`.
### 2.2 Work Order Lifecycle — **High**
- **Atual:** form parcial, status `Open`, delete físico.
- **Desejado:** wizard/inline completo, `Incomplete`, soft cancel read-only.
### 2.3 Inline Editing — **Critical**
- **Atual:** `EditWorkorder` multipart; endpoints separados.
- **Desejado:** update granular por célula com regras embutidas.
### 2.4 Search & Filtering — **High**
- **Atual:** 5 campos, escopo global, assignee single-select.
- **Desejado:** busca contextual na semana + advanced search cross-week.
### 2.5 Status Management — **Critical**
- **Atual:** enum 5 valores + string livre; sem Past Due.
- **Desejado:** 10 labels + flag Past Due separada; auto-schedule; bloqueios.
### 2.6 Completion Documentation — **High**
- **Atual:** sign-off básico; verify só no dispatch.
- **Desejado:** `docStatus` WO-level (Yes/No/NN); templates por serviço.
### 2.7 Vendor Management (board) — **High**
- **Atual:** vendor só no detalhe via dispatch.
- **Desejado:** colunas VENDOR/APPT na lista; edição via dispatch primário.
### 2.8 Scheduling Intelligence — **Critical**
- **Atual:** `ScheduledDate` no detalhe; sem `targetWeek`, `ScheduledEnd`.
- **Desejado:** agendamento semântico completo; badges ↻ e ↷N.
### 2.9 Audit & Tracking — **Medium**
- **Atual:** audit field-based legado.
- **Desejado:** `{type: manual|system, dispatcherId?, action, time}` + eventos de sistema.
### 2.10 Background Automation — **Critical** (como otimização, não fonte da verdade)
- **Atual:** zero jobs.
- **Desejado:** WeekRolled, cache opcional PastDue; domínio recalcula on-read.
### 2.11 Lookup Data — **High**
- **Atual:** lookups paginados incompatíveis; sem PM types, technicians.
- **Desejado:** dispatchers (initials/color), sites por code, PM types, technicians.
### 2.12 Reporting / Contadores — **Medium**
- **Atual:** `totalCount` global.
- **Desejado:** `X of Y` semanal.
### 2.13 Security — **High**
- **Atual:** `[Authorize]` comentado no `WorkOrderController`.
- **Desejado:** auth ativa; default "My WOs".
---
## 3. Dependency Map
```mermaid
flowchart TD
subgraph phase0 [Fase0_Fundacao]
DAR[DAR e 3 artefatos]
DOM[Agregados dominio]
VOL[Volume Discovery]
OWN[Data Ownership]
end
subgraph phase1 [Fase1_Board]
B1[Listagem semanal]
B2[14 colunas]
end
subgraph phase2 [Fase2_Mutacao]
M1[Inline edit]
M2[Concorrencia]
end
subgraph phase3 [Fase3_Lifecycle]
L1[Criacao wizard]
L2[Cancelamento]
end
subgraph phase45 [Fase4_5]
S1[Search]
E1[Domain events]
end
subgraph phase6 [Fase6_Completion]
C1[DocStatus slideover]
end
DAR --> DOM
DOM --> B1
OWN --> M1
VOL --> B1
B1 --> B2 --> M1
M1 --> L1
B2 --> S1
DOM --> E1
B2 --> C1
```
**Ordem obrigatória:** Fase 0 → Board → Inline → Lifecycle → Search/Events → Completion → Rollout.
---
## 4. Domain Model
### 4.1 Agregados e limites (anti God Entity)
```mermaid
flowchart TB
subgraph root [Aggregate Root WorkOrder]
CORE[Core Lifecycle Assignment]
SCH[Scheduling slice]
TRK[Tracking slice]
COMP[Completion slice]
ANA[Analytics slice]
end
subgraph separate [Agregado separado]
DISP[Dispatch fonte vendor]
end
AUD[WorkOrderAuditLog append-only]
CORE --> SCH
CORE --> TRK
CORE --> ANA
CORE --> COMP
CORE --> DISP
CORE --> AUD
```
| Agregado | Responsabilidade |
|----------|------------------|
| **WorkOrder (core)** | Id, WoNumber, Type, LifecycleStatus, SiteCode, LocationId, DueDate, AssignTo, Title, RowVersion |
| **Scheduling slice** | ScheduledDate, ScheduledStart/End, TargetWeek, ScheduleWeekOnly |
| **Tracking slice** | OriginalWeek, OriginalDate (set-once) |
| **Analytics slice** | RescheduleCount, CarriedOver (contadores históricos) |
| **Completion slice** | DocStatus, refs attachments |
| **Dispatch** | Vendor, tech, status portal — **fonte da verdade vendor** |
| **Audit** | Rastreabilidade imutável |
**Proibido:** `VendorId`, `TechName`, `TechPhone` canônicos na WO.
### 4.2 Scheduling Aggregate Growth Watchlist
Campos que **não** entram em Scheduling sem ARB review: `MoveReason`, `MoveUser`, `MoveCategory`, `MoveSource`. Metadados de movimentação → Audit. Gate: slice Scheduling > 8 campos operacionais → ARB obrigatório.
### 4.3 Campos reaproveitados (sem nova coluna)
`SiteCode`, `ScheduledDate`, `DueDate`, `AssignTo`, `Trade`/`Problem`, `LocationId`, POC via `WorkOrderContacts`.
### 4.4 Database drift conhecido
| Coluna SQL | Ação |
|------------|------|
| `WorkOrderType` | Mapear no Core |
| `AvettaTask` | Inventariar; mapear ou deprecar |
| `AssignDate` | Inventariar; mapear ou deprecar |
Gate Fase 0: diff completo SQL vs `ApplicationDbContextModelSnapshot`.
### 4.5 Entidades relacionadas
| Entidade | Mudança | Fase |
|----------|---------|------|
| `WorkOrderContacts` | `Notes` (pocNotes) | 0 |
| `WorkOrderAuditLog` | EventType Manual/System/Vendor/Sync | 0 |
| `ApplicationUser` | Initials, Color | 0–1 |
| `Locations` | SiteCode | 0–1 |
| `WorkOrderAttachments` | Category | 2–6 |
| `WorkOrderEnums` | LifecycleStatus, WorkOrderType, DocStatus, OperationalFlags | 0 |
| `Dispatch` | Proteção regression | contínuo |
Fora de escopo imediato: `FollowUps`, `Quotes`, `PMSchedules`, `Employee`, `WorkOrderCategories` no board.
---
## 5. Domain Architecture Review (DAR)
### 5.1 Fonte da verdade por conceito
| Conceito | Fonte da verdade | Proibido |
|----------|------------------|----------|
| Status | `LifecycleStatus` (10 valores) | String livre; status composto |
| Past Due | `OperationalFlags.PastDue` | Misturar no enum lifecycle |
| Vendor/Tech | Dispatch primário | Duplicar na WO |
| Schedule | Scheduling slice | Duplicar no core |
| Completion | Completion.DocStatus | Só inferir de dispatch |
| Reschedule/Carried | Analytics + audit | Job blind overwrite |
| WO# | Campo canônico único | Dois números sem regra |
| POC | WorkOrderContacts | Só no detalhe |
### 5.2 Matriz Persistido vs Derivado
| Campo FE | Persistido | Derivado | Fonte / Regra |
|----------|------------|----------|---------------|
| `lifecycleStatus` | Sim | Não | Core |
| `isPastDue` | Não | **Sim** | `ScheduledDate < hoje` AND NOT terminal; job = cache opcional |
| `type` Overdue | Não | **Sim** | Regra type + schedule + status |
| `carriedOver` | Sim | Não | Métrica histórica; incremento via `WeekRolled` |
| `rescheduleCount` | Sim | Parcial | Domain event em mudança de `ScheduledDate` |
| `docStatus` | Sim | Não | Completion slice |
| `dayGroup`, `apptTime` | Não | Sim | Calculados na leitura |
| vendor/tech | Via Dispatch | Projeção | Join dispatch primário |
**Regra de ouro:** Jobs nunca são a única fonte da verdade. Leitura sempre recalcula derivados.
### 5.3 Lifecycle Status vs Operational Flags
```text
LifecycleStatus → enum único (10 valores FE)
OperationalFlags → PastDue, (futuro: Escalated)
```
Filtro status = LifecycleStatus. Overlay Past Due = flag. Bloqueio de edição = validação sobre flag.
### 5.4 CarriedOver — justificativa
| | `isPastDue` | `carriedOver` |
|--|-------------|---------------|
| Natureza | Estado pontual | **Métrica histórica acumulada** |
| Persistir | Não (derivado) | **Sim** (contador) |
| Por quê | Calculável da data atual | Não recuperável só do schedule atual |
Invariante: incremento **somente** via domain event `WeekRolled`.
### 5.5 Vendor / Dispatch — spike obrigatório (Fase 0)
1. WO mantém `PrimaryDispatchId`
2. Colunas VENDOR = projeção do dispatch primário
3. Edição inline vendor = mutação no Dispatch
4. WO sem dispatch → edição cria dispatch primário
Entregável: ADR "Vendor Source of Truth". Critério: zero `Tech*` canônicos na WO.
### 5.6 Jobs — domínio primeiro
| Job | Papel |
|-----|-------|
| Past Due diário | Cache refresh opcional; on-read sempre correto |
| Carried Over semanal | Dispara `WeekRolled` → incrementa contador + audit system |
| Promoção Overdue | Derivado on-read |
| Audit | Síncrono em toda mutação desde Fase 0 |
### 5.7 Database drift investigation
Diff SQL vs EF; classificar: mapear, deprecar, mover para agregado satélite. Tabelas: `workOrders`, `Comments`, `Locations`, `Dispatch`.
---
## 6. Data Ownership Model
### 6.1 Ownership por ator
| Ator | Pode alterar | Não pode alterar |
|------|--------------|------------------|
| **SHOC** | Lifecycle, Schedule, Assignment, Completion, vendor via Dispatch, POC, WO# | Checklist/signoff vendor |
| **Vendor Portal** | Dispatch status, checklist, signoff, comments | Schedule, lifecycle, assignment, DueDate |
| **Sync/Lambda** | Campos ingest (Description, ExternalId, etc.) — só owner Sync | Campos SHOC-owned após 1ª edição manual |
| **Blazor** | Campos legados até sunset | Campos novos board |
| **Jobs** | Incremento CarriedOver; audit system | Lifecycle, Schedule, Dispatch |
### 6.2 Field Ownership Matrix
| Campo | Agregado | Owner escritor | Sync sobrescreve? | Conflito SHOC vs Sync |
|-------|----------|----------------|-------------------|----------------------|
| LifecycleStatus | Core | SHOC | Não após 1ª edição SHOC | SHOC vence |
| AssignTo | Core | SHOC | Não após 1ª edição SHOC | SHOC vence |
| ScheduledDate / janela | Scheduling | SHOC | Não | SHOC vence |
| TargetWeek | Scheduling | SHOC | Não | SHOC vence |
| DueDate | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
| Description | Core | SHOC + Sync | Merge / SHOC priority | SHOC se editado |
| SiteCode / Location | Core | SHOC + Sync | Sim na criação; não após SHOC | SHOC após flag |
| WorkOrderType | Core | SHOC | Sim na criação ingest | SHOC após flag |
| WoNumber | Core | SHOC | Não | SHOC only |
| Vendor / tech | Dispatch | SHOC + Vendor | Não | Domínios separados |
| Checklist/signoff | Dispatch | Vendor | Não | Vendor vence |
| DocStatus | Completion | SHOC | Não | SHOC only |
| RescheduleCount / CarriedOver | Analytics | Domain events | Não | N/A |
| ExternalWorkOrderId | Core | Sync | Sim (idempotente) | Sync only |
**ManualEditFlag:** primeira edição SHOC marca campo; Sync subsequente skip + audit `SyncRejected`.
### 6.3 Sync merge — cenários
| Cenário | Comportamento |
|---------|---------------|
| Sync DueDate + SHOC ScheduledDate | Merge — campos distintos |
| Sync Status + SHOC Status | SHOC vence; sync skip |
| Sync AssignTo + SHOC AssignTo | SHOC vence |
| Sync Description + SHOC Description | SHOC vence se ManualEditFlag |
| Sync cria WO novo | Full ingest |
| Sync atualiza por ExternalWorkOrderId | Field-level merge por matriz |
---
## 7. Concurrency Strategy
### 7.1 Atores
Dispatcher (SHOC), Vendor (Portal), Sync/Lambda, Blazor (congelado), Jobs.
### 7.2 RowVersion multi-agregado
| Agregado | RowVersion | Quando muda |
|----------|------------|-------------|
| **WorkOrder (root)** | No Core | Mutação Core, Scheduling, Completion, Analytics na mesma transação |
| **Dispatch** | Próprio | Mutação vendor/tech/checklist/status dispatch |
| **Projeção board** | N/A | FE envia `workOrderVersion` + `dispatchVersion` |
**Propagação:**
- SHOC edita ScheduledDate → bump WO.RowVersion
- SHOC edita vendor → bump Dispatch.RowVersion
- Vendor edita checklist → bump Dispatch.RowVersion only
- Sync altera Description → bump WO se merge aplicado
- DocStatus → bump WO (Completion na mesma transação)
Conflito → 409 com `currentState` para refresh.
### 7.3 Concurrency Ownership Rules
| Cenário | Resultado |
|---------|-----------|
| SHOC vs SHOC (mesmo campo) | 409; último RowVersion válido |
| SHOC vs Sync (mesmo campo) | Vence owner do campo |
| SHOC vs Sync (campos diferentes) | Merge |
| Vendor vs Sync | Domínios separados |
| SHOC vs Vendor | Merge se campos distintos; impossível mesmo conceito por design |
| Job WeekRolled vs SHOC reschedule | Idempotência WO+semana |
| Blazor vs SHOC | SHOC vence; Blazor congelado |
### 7.4 Testes
Fase 2: conflito paralelo (2 dispatchers, sync durante edição); dual RowVersion; audit count = fields changed.
---
## 8. Audit Event Contract
### 8.1 Princípios
- Toda mutação gera audit **síncrono** desde Fase 0
- Edição inline = **1 evento por campo**
- Audit para investigação operacional, não só compliance
### 8.2 Schema por evento
| Campo | Obrigatório |
|-------|-------------|
| WorkOrderId | Sim |
| EventType | Sim: Manual / System / Sync / Vendor |
| Action | Sim: FieldChanged, StatusChanged, WeekRolled, SyncRejected |
| FieldName | Sim para inline |
| OldValue, NewValue | Sim |
| ActorId, ActorType | Sim para Manual |
| Timestamp | Sim UTC |
| CorrelationId | Recomendado |
| DispatchId | Quando evento é no dispatch |
### 8.3 Mutação → eventos
| Mutação | Eventos |
|---------|---------|
| ChangeStatus | 1× StatusChanged |
| Inline cell | 1× FieldChanged por campo |
| Auto-schedule | StatusChanged + FieldChanged se aplicável |
| Reschedule | FieldChanged + RescheduleCount + audit analytics |
| WeekRolled | System WeekRolled + CarriedOver old/new |
| Sync skip | Sync SyncRejected |
| Vendor checklist | Vendor FieldChanged no Dispatch |
### 8.4 Contrato FE (Audit tab)
`{ type, dispatcherId?, action, fieldName?, oldValue, newValue, time }`
---
## 9. Data Migration Plan
### 9.1 Status
| Legado | LifecycleStatus alvo |
|--------|------------------------|
| Open | Incomplete |
| InProgress | In Progress |
| Completed | Complete |
| Cancelled | Canceled |
| OnHold | On Hold |
| Desconhecido | Incomplete + NeedsReview |
Processo: dry-run staging; 100% classificados; `LegacyStatus` read-only 1 release; rollback documentado.
### 9.2 WO#
| Formato legado | Estratégia |
|----------------|------------|
| Sequencial sync `10000001` | Normalizar 11 dígitos |
| `WO-{date}-{random}` | Coexistência; novos só 11 dígitos |
| Duplicatas | Resolução manual pré-go-live |
### 9.3 WorkOrderType
Mapear SQL existente → enum; NULL → default PO; Overdue = derivado.
### 9.4 Rollback
Migrations reversíveis; feature flag SHOC; snapshot pré-migration.
---
## 10. Volume Discovery e Search Scalability
### 10.1 Volume Discovery (Fase 0 — gate Fase 1)
**Sem premissa de volume.** Métricas obrigatórias:
| Métrica | Gate |
|---------|------|
| Total WOs produção/staging | Obrigatório |
| WOs/semana (p95) | Obrigatório |
| WOs Unscheduled | Obrigatório |
| Crescimento mensal | Desejável |
### 10.2 Tiers e impacto
| Tier | Total WOs | Listagem semanal | Advanced search |
|------|-----------|------------------|-----------------|
| **S** | < 25k | Índices simples | SQL filtros compostos |
| **M** | 25k–250k | Índices covering | Paginação obrigatória |
| **L** | 250k–1M | Read model candidato | Full-text |
| **XL** | > 1M | Materialized view | Search dedicado (ARB) |
Fases 1–3: índices conservadores compatíveis com qualquer tier. Fase 4: implementação conforme tier + load test.
---
## 11. Coexistência Multi-Consumidor
### 11.1 Inventário
| Consumidor | Conexão | Status |
|------------|---------|--------|
| **SHOC React** (`seahaven.desing`) | REST JWT | Em desenvolvimento — **único alvo novas features** |
| **Blazor** (`SeaHavenIndustries`) | EF direto, não REST | Ativo — manutenção mínima; sunset WO |
| **Vendor Portal UI** | REST `X-Vendor-Token` | API ativa; UI externa |
| **SyncController + Lambda** | DynamoDB → SQL | Ponte temporária |
| **E-mails SendGrid** | Deep links SHOC/Portal | Ativo |
| **Mobile** | — | Sem evidência — confirmar com PO |
```mermaid
flowchart TB
SHOC[SHOC React] -->|REST JWT| API[Api REST]
VPortal[Vendor Portal] -->|X-Vendor-Token| API
Lambda[Lambda ingest] --> DynamoDB[(DynamoDB)]
DynamoDB --> Sync[SyncController]
Sync --> DB[(SQL Server)]
API --> DB
Blazor[Blazor EF] -->|legado| DB
```
**Nota:** DynamoDB é **ingestão externa**, não banco operacional. SQL Server é fonte para board, Blazor e Portal.
### 11.2 Matriz de coexistência
| Consumidor | Novas features board | Schema | Sunset |
|------------|---------------------|--------|--------|
| SHOC | Recebe tudo | Consome novos campos | Destino final |
| Blazor | Não recebe | Colunas nullable; backward compatible | Módulo WO congela |
| Vendor Portal | Não usa board | Dispatch intacto | Mantém |
| Sync/Lambda | Indireto | Defaults para novos campos | Após cutover API |
| E-mails | Links SHOC | FrontendBaseUrl prod | Atualizar templates |
### 11.3 Trilhas de entrega
- **Trilha A SHOC:** fases 0–7; feature flags; piloto dispatchers
- **Trilha B Blazor:** smoke EF pós-migration; sem board; bugfix crítico only
- **Trilha C Vendor Portal:** regression suite cada release que toca WO/Dispatch
- **Trilha D Lambda:** manter até SHOC produção; cutover Fase 7; critério: criação WO + auth serviço estáveis
### 11.4 Endpoints legado vs board
- Legado (`GetWorkOrderList`, etc.): manter durante coexistência; congelar contrato
- Board: novos contratos; não estender legado com hacks
- Deprecação: após 100% dispatchers SHOC + sunset Blazor WO
### 11.5 Ordem de rollout
1. Fase 0 staging — gates + migrations
2. Gate Blazor smoke EF
3. Fase 1 staging — SHOC board feature flag
4. Gate Vendor Portal regression
5. Fases 2–6 incrementais
6. Piloto 1–2 dispatchers
7. Rollout gradual
8. Sunset Blazor WO
9. Cutover Lambda → API
10. Deprecação legado + SyncController
### 11.6 Monitoramento coexistência
Métricas por consumidor; drift DynamoDB vs SQL; adoção SHOC vs Blazor; alertas job failure.
### 11.7 Checklist pré-go-live PO
- App mobile externo?
- Data sunset Blazor WO
- FrontendBaseUrl produção → SHOC
- Owner Lambda cutover
---
## 12. Implementation Phases
### Fase 0 — Domain Foundation + Governança
**Objetivo:** Kickoff enterprise — domínio, ownership, volume, audit antes de board.
**Escopo:** DAR; 3 artefatos (Ownership, Field Matrix, Audit Contract); Volume Discovery; drift SQL/EF; spike vendor; agregados Core+Scheduling+Tracking+Analytics+Completion; enums; RowVersion root+Dispatch; migration dry-run; ManualEditFlag design; audit baseline ChangeStatus/ChangeAssignment; smoke Blazor/Sync/Portal.
**Não inicia:** Board API, inline edit, jobs.
**Validação:** 9 gates assinados; audit granularidade testada; tier volume definido.
---
### Fase 1 — Weekly Board (leitura)
**Objetivo:** Tela principal carrega semana operacional.
**Escopo:** Listagem window-based; Unscheduled; 14 colunas como projeção; `X of Y`; filtros (dispatcher multi, `__unassigned__`, My WOs, tipo, semana); `isPastDue` derivado on-read; vendor via dispatch primário; índices conforme tier volume.
**Validação:** 1 call = semana + Unscheduled; 12/14 colunas mínimo; gate Vendor Portal regression.
---
### Fase 2 — Inline Edit + Concorrência
**Objetivo:** Edição spreadsheet com regras e conflitos tratados.
**Escopo:** Update granular; dual RowVersion; audit 1 evento/campo; auto-schedule; rescheduleCount++; bloqueio PastDue flag; cancel read-only; WO# 11 dígitos; testes concorrência.
---
### Fase 3 — Criação e Cancelamento
**Escopo:** Wizard/inline completo; Incomplete inicial; targetWeek; soft cancel; ManualEditFlag na criação; delete admin documentado.
---
### Fase 4 — Search
**Escopo:** Busca contextual semana; advanced search cross-week conforme tier; load test; sem implementar advanced sem tier definido.
---
### Fase 5 — Scheduled Domain Events
**Escopo:** WeekRolled → CarriedOver++; cache opcional PastDue; idempotência; monitoramento falha; on-read sempre correto.
---
### Fase 6 — Completion Doc e Slide-over
**Escopo:** docStatus WO-level; templates por serviço; Comments/Audit/Media alinhados ao FE.
---
### Fase 7 — Rollout e Produção
**Escopo:** Piloto; rollout gradual; sunset Blazor WO; cutover Lambda; deprecação legado; UAT; zero P1 duas semanas; dashboards saúde.
---
## 13. Critical Path Analysis
### Must Have
1. Listagem window-based + Unscheduled
2. Projeção 14 colunas
3. Agregados domínio + enums
4. Update granular por campo
5. Status 10 labels + flag PastDue
6. Lookups
7. Criação wizard
8. WO# 11 dígitos
9. DAR + 3 artefatos Fase 0
### Should Have
10. Domain events CarriedOver
11. Advanced search
12. Regras mutação completas
13. Soft cancel enforcement
14. docStatus WO-level
15. Auth ativa
### Nice to Have
16. Reorder intra-dia persistido (FE local)
17. Flags pessoais session-only
18. Paginação 12/24/48/96
19. Bulk select
---
## 14. Frontend Impact Assessment
| Gap | UX | Severidade | Workaround |
|-----|-----|------------|------------|
| Sem listagem semanal | Tela inutilizável | Critical | Mock FE |
| 14 colunas incompletas | Board ilegível | Critical | Nenhum em prod |
| Sem inline edit | Core inoperante | Critical | Modal legado |
| Sem advanced search | WOs históricos invisíveis | High | Busca por ID |
| Status incompatíveis | Decisões erradas | Critical | Hardcode FE |
| Sem jobs (derivados) | Badges stale se só client | High | On-read BE resolve PastDue |
| Sem docStatus | COMP DOC vazia | High | Ocultar coluna |
| WO# formato errado | Criação bloqueada | Critical | — |
| Ownership Sync vs SHOC | Overwrite silencioso | Critical | Field Matrix Fase 0 |
| Auth desabilitada | Risco segurança | High | API gateway |
---
## 15. Business Rules Assessment
| Regra | Status | Impacto |
|-------|--------|---------|
| Auto-schedule Incomplete→Scheduled | Unsupported | Status incorreto |
| Reschedule → rescheduleCount++ | Unsupported | Badge ↻ ausente |
| Reschedule limpa PastDue (derivado) | Unsupported | Flag stale até re-read |
| Status bloqueado se PastDue | Unsupported | Bypass via API |
| WO# único 11 dígitos | Unsupported | Duplicatas |
| Cancel read-only | Partial | Edição pós-cancel |
| WeekRolled → carriedOver | Unsupported | Badge ↷N ausente |
| Completion doc por serviço | Unsupported | Workflow manual |
| Week-only scheduling | Unsupported | Wizard incompleto |
---
## 16. Data Contract Readiness
| Área | Readiness % | Gap principal |
|------|-------------|---------------|
| List Views board | 5% | 14 colunas, schedule, vendor, flags |
| Detail slide-over | 40% | Derivados, docStatus, schema |
| Search | 10% | Cross-week, date ranges |
| Lookups | 20% | initials, site code, PM, tech |
| Scheduling | 25% | targetWeek, ScheduledEnd |
| Status | 30% | 10 labels + flag |
| Completion | 0% | docStatus, templates |
| Comments | 60% | Schema |
| Audit | 50% | type system, granularidade |
| Media | 45% | category |
**Média ponderada: ~28%**
---
## 17. Testing Strategy
### Functional
Board semanal, 14 colunas, filtros, inline por campo, regras negócio, criação/cancelamento, docStatus, status+flag, ownership rejection sync.
### Integration
SHOC E2E por fase; Vendor Portal regression; Sync; auth; dual RowVersion.
### Regression
Endpoints legados; Blazor smoke EF; Portal checklist/signoff.
### UAT
Roteiros dispatchers reais; sign-off PO por fase.
### Data validation
WO# migration; status mapping; derivados on-read vs contadores; referencial.
### Migration validation
Dump produção espelho; antes/depois contagens; rollback por fase.
### Fase-specific
- **0:** volume queries; audit granularidade; sync skip
- **2:** concurrency suite; 409 handling
- **4:** load test tier
- **5:** job failure injection; on-read correctness
---
## 18. Risk Matrix
| Risco | Prob. | Impacto | Mitigação |
|-------|-------|---------|-----------|
| Board inoperante em prod | Alta | Crítico | Fase 1 gate; E2E |
| Ownership ambíguo Sync vs SHOC | Alta | Crítico | Field Ownership Matrix |
| God Entity creep | Média | Alto | Growth Watchlist; ARB |
| Conflito cross-agregado | Média | Alto | RowVersion root + Dispatch |
| Job falha estado errado | Alta | Alto | Derivados on-read |
| Vendor drift Dispatch vs WO | Alta | Crítico | Spike; projeção only |
| Status migration incorreta | Média | Crítico | LegacyStatus; dry-run |
| Volume desconhecido → índices errados | Alta | Alto | Volume Discovery Fase 0 |
| Audit inútil investigação | Média | Médio | Event Contract |
| Regressão Vendor Portal | Média | Crítico | Suite dedicada |
| Blazor vs SHOC divergência | Alta | Alto | Congelar Blazor WO |
| Auth bloqueia integrações | Média | Alto | Inventário consumidores |
| CarriedOver recalculado errado | Baixa | Médio | Só via WeekRolled |
| Scheduling aggregate creep | Média | Alto | Watchlist §4.2 |
---
## 19. Prioritized Backlog
Ordenado por sequência de implementação.
| # | Item | P | Fase | Dep | Complex. | Risco |
|---|------|---|------|-----|----------|-------|
| 1 | DAR completo + sign-off CTO | P0 | 0 | — | M | Alto |
| 2 | Data Ownership Model | P0 | 0 | — | S | Alto |
| 3 | Field Ownership Matrix + ManualEditFlag | P0 | 0 | 2 | M | Alto |
| 4 | Audit Event Contract + baseline | P0 | 0 | — | M | Médio |
| 5 | Volume Discovery Report | P0 | 0 | — | S | Alto |
| 6 | Database drift SQL vs EF | P0 | 0 | — | M | Médio |
| 7 | Spike vendor/dispatch source of truth | P0 | 0 | — | M | Alto |
| 8 | Agregados domínio (anti God Entity) | P0 | 0 | 1 | L | Alto |
| 9 | Matriz Persistido vs Derivado | P0 | 0 | 1 | S | Médio |
| 10 | RowVersion root + Dispatch | P0 | 0 | 8 | M | Alto |
| 11 | Enums Lifecycle + Flags + WorkOrderType | P0 | 0 | 8 | M | Médio |
| 12 | Data migration dry-run | P0 | 0 | 8 | M | Alto |
| 13 | Inventário consumidores + coexistência | P0 | 0 | — | S | Médio |
| 14 | Smoke Blazor EF + Sync + Portal | P0 | 0 | 8 | M | Alto |
| 15 | Normalização WO# 11 dígitos | P0 | 0 | 12 | M | Alto |
| 16 | Reativar auth endpoints WO | P0 | 0 | — | S | Médio |
| 17 | Lookups (dispatchers, sites, PM, tech) | P0 | 0–1 | — | M | Baixo |
| 18 | Listagem window-based + Unscheduled | P0 | 1 | 8,11,17 | L | Alto |
| 19 | Projeção 14 colunas | P0 | 1 | 18 | L | Alto |
| 20 | isPastDue derivado on-read | P0 | 1 | 19 | S | Baixo |
| 21 | Vendor via dispatch primário | P0 | 1 | 7,19 | M | Alto |
| 22 | Filtros principais + My WOs | P0 | 1 | 18 | M | Médio |
| 23 | Contador X of Y | P1 | 1 | 18 | S | Baixo |
| 24 | Sync merge por field ownership | P0 | 0–1 | 3 | M | Alto |
| 25 | Update granular inline | P0 | 2 | 19 | L | Alto |
| 26 | Audit 1 evento por campo | P0 | 2 | 4,25 | M | Médio |
| 27 | Concurrency integration tests | P0 | 2 | 10,25 | M | Alto |
| 28 | Auto-schedule Incomplete→Scheduled | P0 | 2 | 25 | M | Médio |
| 29 | RescheduleCount++ domain event | P0 | 2 | 25 | S | Baixo |
| 30 | Bloqueio status se PastDue flag | P0 | 2 | 25 | S | Baixo |
| 31 | Cancel read-only enforcement | P1 | 2 | 25 | S | Baixo |
| 32 | Criação wizard/inline completa | P0 | 3 | 25 | M | Médio |
| 33 | Week-only scheduling targetWeek | P1 | 3 | 32 | M | Médio |
| 34 | Soft cancel dedicado | P1 | 3 | 31 | S | Baixo |
| 35 | Busca contextual semana | P1 | 4 | 19 | M | Baixo |
| 36 | Advanced search cross-week | P1 | 4 | 5,19 | L | Médio |
| 37 | Load test search por tier | P0 | 4 | 5 | M | Alto |
| 38 | WeekRolled domain event + CarriedOver | P1 | 5 | 8 | M | Médio |
| 39 | Cache opcional PastDue | P2 | 5 | 20 | S | Baixo |
| 40 | docStatus WO-level | P1 | 6 | 8 | M | Médio |
| 41 | Completion templates por serviço | P2 | 6 | 40 | L | Médio |
| 42 | Slide-over Comments/Audit/Media | P2 | 6 | 4,40 | M | Baixo |
| 43 | Regression suite legado + Portal | P0 | cont. | — | M | Alto |
| 44 | UAT dispatchers por fase | P0 | cont. | — | S | Médio |
| 45 | Piloto + rollout gradual SHOC | P0 | 7 | 1–42 | M | Alto |
| 46 | Sunset Blazor WO | P1 | 7 | 45 | S | Médio |
| 47 | Cutover Lambda → API | P1 | 7 | 32 | M | Alto |
| 48 | Deprecação endpoints legado + Sync | P2 | 7 | 45 | M | Médio |
| 49 | Monitoramento coexistência | P1 | 7 | 45 | S | Baixo |
---
## 20. Cronograma indicativo
```mermaid
gantt
title Work Orders Board
dateFormat YYYY-MM-DD
section Fundacao
DAR_arteFatos_dominio :2026-07-01, 8w
section Board
Listagem_14colunas :2026-09-01, 8w
section Mutacao
Inline_concorrencia :2026-11-01, 8w
Criacao_cancelamento :2026-11-15, 6w
section Inteligencia
Search :2027-01-01, 6w
Domain_events :2027-01-15, 4w
section Completion
DocStatus_slideover :2027-02-15, 6w
section Producao
Piloto_rollout :2027-04-01, 8w
```
---
## 21. Stakeholders
| Papel | Responsabilidade |
|-------|------------------|
| CEO / Sponsor | Investimento XL; prioridade SHOC sobre Blazor |
| Product Owner | Aceite por fase; semana operacional; regras ambíguas |
| CTO / Arquiteto | DAR; 3 artefatos; ownership; cutover Lambda |
| Backend | Fases 0–6 domínio e contratos |
| Frontend SHOC | Integração; feature flags; remover mocks |
| QA | Testes §17; UAT; concurrency |
| Documentação | Contratos; guia consumidores legados |
| Operações | Jobs; alertas; monitoramento coexistência |
---
## 22. Critérios de prontidão para kickoff (9.5+)
| Área | Critério |
|------|----------|
| Domain Design | DAR aprovado; agregados; Watchlist |
| Source of Truth | Field Ownership Matrix assinada |
| Data Governance | Data Ownership Model completo |
| Concurrency | RowVersion root + Dispatch; testes Fase 2 |
| Sync | Cenários explícitos; ManualEditFlag |
| Audit | Event Contract; 1 evento/campo inline |
| Migration | Dry-run; LegacyStatus rollback |
| Volume | Discovery Fase 0; tier definido |
| Jobs | Domain events; on-read correto se job falha |
| Coexistência | Inventário; trilhas; smoke Blazor/Portal/Sync |
---
## 23. Conclusão
Programa **viável e de grande porte (XL)**. O caminho crítico é:
```text
Resolver domínio e governança (Fase 0)
→ Board semanal (Fase 1)
→ Inline edit + concorrência (Fase 2)
→ Paridade funcional progressiva (Fases 3–6)
→ Rollout seguro (Fase 7)
```
O documento integra: gap assessment da auditoria, modelo de domínio com agregados, governança de ownership, concorrência multi-agregado, migração de dados, coexistência SHOC/Blazor/Portal/Lambda, e backlog executável — pronto para Architecture Review Board e kickoff de implementação enterprise.
**Próximo passo imediato:** executar Fase 0 — produzir e assinar os três artefatos (Data Ownership Model, Field Ownership Matrix, Audit Event Contract) antes de qualquer migration de schema.

View file

@ -1,181 +0,0 @@
# Spike Search Strategy — `GetWorkOrdersBoard`
**Data:** 2026-06-19
**Gate:** G3 (pré-requisito Fase 1)
**Objetivo:** Definir busca mínima viável para o Schedule Board sem degradar performance.
---
## Estado atual (`GetWorkOrderList`)
Implementação em `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs` (`GetWorkOrderListPagedAsync`):
| Aspecto | Comportamento atual |
|---------|---------------------|
| Paginação | `page` / `pageSize` (default 12), sem cap |
| Janela temporal | **Ausente** — sem filtro `ScheduledDate` |
| Search | `Contains` + `ToLower()` em 5 campos |
| Campos search | `InternalWONumber`, `WorkerOrderNumber`, `WorkerOrderTitle`, `Locations.Title`, `Locations.Name` |
| Campos omitidos | `SiteCode`, `Trade`, `Problem`, vendor, dispatcher por ID |
| Joins | `Locations`, `AssignToUser` (AspNetUsers) |
| Vendor/dispatch | **Não incluídos** |
| Projeção extra | Subquery `lastUpdated` via Comments + WorkOrderAuditLogs por linha |
### Padrão SQL gerado (search)
```sql
WHERE LOWER(InternalWONumber) LIKE '%term%'
OR LOWER(WorkerOrderNumber) LIKE '%term%'
OR LOWER(WorkerOrderTitle) LIKE '%term%'
OR LOWER(Locations.Name) LIKE '%term%'
...
```
Leading wildcard → table scan em colunas `nvarchar(max)`.
---
## Respostas às perguntas do spike
| # | Pergunta | Decisão Fase 1 |
|---|----------|----------------|
| S1 | Volume típico por semana | Assumir **< 200 WOs/semana** até medir em prod; threshold fallback = **300** |
| S2 | Campos pesquisáveis mínimos | `internalWONumber`, `siteCode`, `assigneeId[]` (exato), `locationId` |
| S3 | LIKE vs full-text | **Sem full-text** Fase 1; `StartsWith` em WO# e siteCode; evitar `Contains` em title/location no servidor |
| S4 | Escopo | Search **sempre scoped** a `scheduledFrom` / `scheduledTo` (obrigatório no endpoint) |
| S5 | Vendor/tech no search | **Fase 2** — exige join `Dispatches → Vendor` |
---
## Estratégia em 2 camadas
```
Request GetWorkOrdersBoard
│
├─► 1. Filtro janela ScheduledDate (obrigatório)
│
├─► 2. Filtros exatos: status, assigneeId[], locationId
│
├─► 3. COUNT no escopo
│
├─► count <= 300?
│ ├─ SIM → retornar semana inteira (sem paginação Fase 1)
│ │ search textual extra no FE: title, locationLabel, serviceType
│ └─ NÃO → exigir critério indexável no servidor
│ (woNumber ou siteCode StartsWith)
│
└─► Response: { scheduledFrom, scheduledTo, totalCount, items[] }
```
### Campos Fase 1 vs Fase 2
| Campo | Fase 1 (servidor) | Fase 1 (client fallback) | Fase 2 |
|-------|-------------------|--------------------------|--------|
| `internalWONumber` / `workerOrderNumber` | `StartsWith` | — | — |
| `siteCode` | `StartsWith` ou igualdade | — | — |
| `assigneeId` | multi-select exato | — | — |
| `locationId` | exato | — | — |
| `status` | exato / multi | pills client-side | — |
| `workerOrderTitle` | — | client-side se count ≤ 300 | server `Contains` opcional |
| `locationLabel` | — | client-side | — |
| `serviceType` (trade/category) | — | client-side | server join |
| `vendorCompany` / `vendorTechnician` | — | — | join Dispatch→Vendor |
| `assigneeName` | — | client-side | evitar join por nome |
---
## Query plan esperado (Fase 1)
### Caminho feliz (semana típica, ≤ 300 rows)
1. **Index seek** em `IX_workOrders_ScheduledDate` (proposto) com range `[scheduledFrom, scheduledTo+1day)`
2. Filtro `istemplate != true`
3. Filtros exatos em `AssignTo`, `LocationId`, `Status` (AND)
4. **Sem** search textual no servidor se `search` vazio
5. Join LEFT `Locations`, `AssignToUser`, LEFT `Dispatches` + `Vendor` (Fase 1 board — vendor no DTO, não no search)
6. Projeção flat para `WorkOrderBoardRowDto` — **sem** subquery `lastUpdated`
### Caminho search servidor (count > 300 ou user digitou termo indexável)
1. Mesma janela + filtros exatos
2. AND (`InternalWONumber LIKE 'term%'` OR `SiteCode LIKE 'term%'`)
3. Se count ainda > 300 → HTTP 400 com mensagem "Refine search or narrow date window"
### Anti-padrões proibidos no board endpoint
- `pageSize=500` como gambiarra de fetch semanal
- `Contains('%x%')` em múltiplas tabelas sem janela temporal
- Filtro assignee por nome concatenado (`FirstName + LastName`) — usar `assigneeId`
- Subquery correlated Comments/AuditLog por row
---
## Índices recomendados
Migration separada **após aprovação** desta spike (não incluir na Fase 1 code sem review):
| Índice | Coluna(s) | Prioridade | Nota |
|--------|-----------|------------|------|
| `IX_workOrders_ScheduledDate` | `ScheduledDate` | **P1** | Filtro de janela do board |
| `IX_workOrders_InternalWONumber` | `InternalWONumber` | P2 | Requer alterar coluna de `nvarchar(max)` → `nvarchar(50)` |
| `IX_workOrders_SiteCode` | `SiteCode` | P2 | Idem — tamanho fixo ~50 |
| `IX_workOrders_AssignTo` | `AssignTo` | existente | Multi-select dispatcher |
| `IX_workOrders_LocationId` | `LocationId` | existente | Filtro location |
**Full-text index:** não recomendado Fase 1 — volume semanal baixo + fallback client-side suficiente.
---
## Referência de implementação
Copiar padrão de janela temporal de `DispatchController.List`:
```csharp
if (dateFrom.HasValue)
q = q.Where(d => d.DispatchedAt >= dateFrom.Value);
if (dateTo.HasValue)
{
var end = dateTo.Value.Date.AddDays(1);
q = q.Where(d => d.DispatchedAt < end);
}
```
Adaptar para `WorkOrder.ScheduledDate` no board endpoint.
---
## Parâmetros propostos — `GetWorkOrdersBoard`
| Parâmetro | Tipo | Obrigatório | Descrição |
|-----------|------|-------------|-----------|
| `scheduledFrom` | `DateOnly` | **Sim** | Início da semana/janela |
| `scheduledTo` | `DateOnly` | **Sim** | Fim da janela (inclusivo) |
| `search` | `string` | Não | WO# ou siteCode prefix |
| `assignee` | `string[]` | Não | IDs de dispatcher (multi) |
| `status` | `string[]` | Não | Status canônico API |
| `locationId` | `int?` | Não | Filtro location |
**Limite hard janela:** max **90 dias** (Fase 2 advanced search cross-week).
---
## Threshold e fallback
| Constante | Valor | Justificativa |
|-----------|-------|---------------|
| `BoardSearchClientSideThreshold` | **300** | Payload ~300 rows × ~500 bytes ≈ 150 KB — aceitável para 1 call/semana |
| Sem paginação Fase 1 | — | Janela semanal é o filtro natural |
| Paginação Fase 2 | cursor/offset | Só quando janela > 1 semana com search global |
---
## Conclusão
Fase 1 adota **window-first, search-second**:
1. Janela `ScheduledDate` obrigatória + índice dedicado
2. Search servidor mínimo (`StartsWith` em identificadores)
3. Fallback client-side para campos ricos quando count ≤ 300
4. Vendor/tech e full-text adiados para Fase 2
Esta estratégia desbloqueia `GetWorkOrdersBoard` sem replicar os anti-padrões de `GetWorkOrderListPagedAsync`.

View file

@ -1,32 +0,0 @@
# Fase 0 — Work Orders Foundation
Documentação e gates da Fase 0 (Domain Foundation + Governança).
## Governança
- [DAR — Domain Architecture Review](dar-domain-architecture-review.md)
- [Matriz Persistido vs Derivado](dar-persisted-vs-derived-matrix.md)
- [Data Ownership Model](data-ownership-model.md)
- [Field Ownership Matrix](field-ownership-matrix.md)
- [ManualEditFlag Design](manual-edit-flag-design.md)
- [Audit Event Contract](audit-event-contract.md)
## Discovery
- [Volume Discovery Report](volume-discovery-report.md)
- [Database Drift Report](database-drift-report.md)
- [Consumer Inventory](consumer-inventory.md)
- [ADR Vendor Source of Truth](adr-vendor-source-of-truth.md)
## Validação
- [RowVersion Design](rowversion-design.md)
- [Migration Dry-Run Report](migration-dry-run-report.md)
- [Smoke Checklist](smoke-checklist.md)
- [Gates Sign-Off](phase-0-gates-signoff.md)
## Código entregue
- Migration: `Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs`
- Serviços: `IWorkOrderAuditService`, `IWorkOrderFieldLockService`, `ISyncFieldMergePolicy`
- Testes: `SeaHavenIndustries.Tests` (12 testes)

View file

@ -1,67 +0,0 @@
# ADR — Vendor Source of Truth
**Status:** Accepted (Fase 0 spike)
**Decisores:** Backend + Arquiteto
---
## Contexto
O board SHOC exibe colunas VENDOR e APPT na listagem semanal. Hoje vendor existe apenas no agregado Dispatch; a entidade WorkOrder não possui referência ao dispatch primário.
---
## Decisão
1. **WorkOrder.PrimaryDispatchId** (nullable FK → Dispatches) identifica o dispatch canônico para projeção board.
2. Colunas VENDOR/APPT = **projeção read-only** via join no dispatch primário (Vendor.Name, Dispatch.ScheduledDate).
3. Edição inline vendor (Fase 2) muta **Dispatch**, não WorkOrder. Bump `Dispatch.RowVersion`.
4. WO sem dispatch: primeira edição vendor cria dispatch primário e seta `PrimaryDispatchId`.
5. **Proibido** adicionar VendorId, TechName, TechPhone na tabela workOrders.
---
## Query de projeção (spike)
```sql
SELECT wo.Id,
wo.InternalWONumber,
d.Id AS DispatchId,
v.Name AS VendorName,
d.ScheduledDate AS ApptDate,
d.Status AS DispatchStatus
FROM workOrders wo
LEFT JOIN Dispatches d ON d.Id = wo.PrimaryDispatchId
LEFT JOIN Vendors v ON v.Id = d.VendorId
WHERE wo.IsDeleted IS NULL OR wo.IsDeleted = 0;
```
Implementação C# em `DispatchDataService.GetPrimaryDispatchProjectionAsync(workOrderId)` (Fase 1).
---
## Seleção do dispatch primário
| Cenário | Regra |
|---------|-------|
| PrimaryDispatchId setado | Usar esse dispatch |
| Null + exatamente 1 dispatch | Auto-set PrimaryDispatchId na migration backfill |
| Null + N dispatches | Usar dispatch mais recente por DispatchedAt; log para revisão manual |
| Zero dispatches | Vendor columns vazias no board |
---
## Consequências
- **Positivo:** Zero drift vendor WO vs Dispatch; Portal regression isolada.
- **Negativo:** Join extra na listagem board — mitigado por índice em PrimaryDispatchId.
- **Fase 0:** Coluna + FK + backfill script no dry-run; endpoint board na Fase 1.
---
## Critérios de aceite spike
- [x] ADR documentado
- [x] Query prototipada
- [ ] PrimaryDispatchId na migration Phase0
- [ ] Backfill documentado no dry-run report

View file

@ -1,106 +0,0 @@
# Audit Event Contract — Work Orders
**Fase:** 0
**Status:** Aprovado para implementação baseline
**Schema EF:** `WorkOrderAuditLog` estendido
---
## 1. Princípios
- Toda mutação gera audit **síncrono** desde Fase 0.
- Edição inline (Fase 2) = **1 evento por campo**.
- Audit serve investigação operacional, não só compliance.
---
## 2. Schema por evento
| Campo | Tipo | Obrigatório |
|-------|------|-------------|
| Id | int | Sim (PK) |
| WorkOrderId | int | Sim |
| EventType | enum string | Sim: Manual, System, Sync, Vendor |
| Action | string | Sim: FieldChanged, StatusChanged, WeekRolled, SyncRejected, AssignmentChanged |
| FieldName | string | Sim para inline / field change |
| OldValue | string | Sim |
| NewValue | string | Sim |
| UserId / ActorId | string | Sim para Manual |
| ActorType | enum string | Sim: Dispatcher, Vendor, System, Sync |
| CreatedAt | DateTime UTC | Sim |
| CorrelationId | string | Recomendado |
| DispatchId | int? | Quando evento é no dispatch |
**Compatibilidade:** colunas `Action` legada mapeada para novo `Action`; `UserId` = ActorId para Manual.
---
## 3. Enums
```csharp
AuditEventType: Manual | System | Sync | Vendor
AuditActorType: Dispatcher | Vendor | System | Sync
AuditActionType: FieldChanged | StatusChanged | AssignmentChanged | WeekRolled | SyncRejected | Create | Delete
```
---
## 4. Mutação → eventos
| Mutação | Eventos |
|---------|---------|
| ChangeStatus | 1× StatusChanged (EventType=Manual) |
| ChangeAssignment | 1× AssignmentChanged (FieldName=AssignTo) |
| Inline cell (Fase 2) | 1× FieldChanged por campo |
| Auto-schedule (Fase 2) | StatusChanged + FieldChanged se aplicável |
| Reschedule (Fase 2) | FieldChanged + analytics |
| WeekRolled (Fase 5) | System WeekRolled + CarriedOver |
| Sync skip | Sync SyncRejected |
| Vendor checklist | Vendor FieldChanged + DispatchId |
---
## 5. Contrato FE (Audit tab)
```typescript
interface AuditEntry {
type: 'manual' | 'system' | 'sync' | 'vendor';
dispatcherId?: string;
action: string;
fieldName?: string;
oldValue: string;
newValue: string;
time: string; // ISO UTC
dispatchId?: number;
}
```
Mapeamento API → FE:
| API EventType | FE type |
|---------------|---------|
| Manual | manual |
| System | system |
| Sync | sync |
| Vendor | vendor |
---
## 6. Baseline Fase 0 (endpoints)
| Endpoint | Garantia |
|----------|----------|
| POST ChangeStatus | 1 audit StatusChanged; Old/New preenchidos |
| POST ChangeAssignment | 1 audit AssignmentChanged; nomes usuário em Old/New |
Implementação via `IWorkOrderAuditService`.
---
## 7. Critérios de aceite
- [ ] Migration estende WorkOrderAuditLog
- [ ] ChangeStatus/ChangeAssignment usam serviço central
- [ ] Testes assertam 1 evento por mutação
**Assinatura Backend Lead:** _________________ Data: _______

View file

@ -1,85 +0,0 @@
# Consumer Inventory — Work Orders
**Fase:** 0
**Referência:** roadmap §11
---
## 1. Inventário de consumidores
| Consumidor | Conexão | Código principal | Campos mutados | Audit hoje? |
|------------|---------|------------------|----------------|-------------|
| **SHOC React** | REST JWT | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` | Status, AssignTo, CRUD parcial via Service | Parcial (ChangeStatus/Assignment) |
| **Blazor** | EF direto | `SeaHavenIndustries/Data/Services/WorkorderService.cs` | CRUD completo, comments, status | **Não** |
| **Vendor Portal** | REST token | `Api.SeaHavenIndustries/Controllers/VendorPortalController.cs` | Dispatch checklist, signoff, status | Sim (ad hoc) |
| **Sync/Lambda** | DynamoDB → SQL | `Api.SeaHavenIndustries/Controllers/SyncController.cs` | Upsert WO por ExternalWorkOrderId | **Não** |
| **Dispatch API** | REST | `DispatchController.cs` | Dispatch create/update/verify | Sim |
| **E-mail SendGrid** | Deep links | `Helper/SendMessage` | Nenhum (read-only links) | N/A |
---
## 2. Diagrama
```mermaid
flowchart TB
SHOC[SHOC_React] -->|REST_JWT| API[Api_REST]
VPortal[Vendor_Portal] -->|X_Vendor_Token| API
Lambda[Lambda_ingest] --> DynamoDB[(DynamoDB)]
DynamoDB --> Sync[SyncController]
Sync --> DB[(SQL_Server)]
API --> DB
Blazor[Blazor_EF] -->|legado| DB
```
---
## 3. Trilhas de entrega (coexistência)
| Trilha | Escopo Fase 0+ |
|--------|----------------|
| **A — SHOC** | Fases 0–7; feature flags; piloto dispatchers |
| **B — Blazor** | Smoke EF pós-migration; bugfix crítico only |
| **C — Vendor Portal** | Regression checklist cada release WO/Dispatch |
| **D — Lambda/Sync** | Manter até SHOC produção; merge policy Fase 0 |
---
## 4. Endpoints legado vs board
| Tipo | Exemplos | Política |
|------|----------|----------|
| Legado | GetWorkOrderList, ChangeStatus | Congelar contrato |
| Board (Fase 1+) | GetWorkOrdersBoard | Novos contratos separados |
---
## 5. Gaps Fase 0 endereçados
| Gap | Mitigação |
|-----|-----------|
| Blazor sem audit | Congelar; não expandir |
| Sync overwrite | ISyncFieldMergePolicy + ManualEditFlag |
| Auth WO desabilitada | Reativar `[Authorize]` WorkOrderController |
| WO# inconsistente | Documentar normalização 11 dígitos (Fase 2 execução) |
---
## 6. Checklist pré-go-live PO
- [ ] App mobile externo confirmado?
- [ ] Data sunset Blazor WO definida?
- [ ] FrontendBaseUrl produção → SHOC?
- [ ] Owner Lambda cutover nomeado?
---
## 7. Auth — consumidores e tokens
| Consumidor | Auth |
|------------|------|
| SHOC | JWT Bearer |
| Sync | JWT Bearer (`[Authorize]` no SyncController) |
| Vendor Portal | X-Vendor-Token (rotas públicas dispatch) |
| Blazor | Cookie Identity (app separado) |
Reativar auth em WorkOrderController exige SHOC enviar JWT em todas as chamadas WO.

View file

@ -1,139 +0,0 @@
# DAR — Domain Architecture Review (Work Orders)
**Fase:** 0
**Status:** Draft para sign-off CTO
**Referência:** [roadmap-work-orders-board.md](../../roadmap-work-orders-board.md) §4–5
---
## 1. Objetivo
Formalizar o modelo de domínio operacional do Schedule Board antes de qualquer migration de schema board ou contrato de listagem semanal.
---
## 2. Agregados e limites (anti God Entity)
```mermaid
flowchart TB
subgraph root [AggregateRoot_WorkOrder]
CORE[Core_Lifecycle_Assignment]
SCH[Scheduling_slice]
TRK[Tracking_slice]
COMP[Completion_slice]
ANA[Analytics_slice]
end
subgraph separate [Agregado_separado]
DISP[Dispatch_fonte_vendor]
end
AUD[WorkOrderAuditLog_append_only]
CORE --> SCH
CORE --> TRK
CORE --> ANA
CORE --> COMP
CORE --> DISP
CORE --> AUD
```
| Agregado / Slice | Responsabilidade | Persistência Fase 0 |
|------------------|------------------|---------------------|
| **Core** | Id, WoNumber, Type, LifecycleStatus, SiteCode, LocationId, DueDate, AssignTo, Title, RowVersion, PrimaryDispatchId | Colunas em `workOrders` |
| **Scheduling** | ScheduledDate, ScheduledStart/End, TargetWeek, ScheduleWeekOnly | Colunas em `workOrders` |
| **Tracking** | OriginalWeek, OriginalDate (set-once) | Colunas em `workOrders` |
| **Analytics** | RescheduleCount, CarriedOver | Colunas em `workOrders` |
| **Completion** | DocStatus, refs attachments | Coluna DocStatus + attachments existentes |
| **Dispatch** | Vendor, tech, status portal | Tabela `Dispatches` + RowVersion |
| **Audit** | Rastreabilidade imutável | `WorkOrderAuditLogs` |
**Proibido:** `VendorId`, `TechName`, `TechPhone` canônicos na entidade WorkOrder. Vendor sempre via Dispatch primário.
---
## 3. Fonte da verdade por conceito
| Conceito | Fonte da verdade | Proibido |
|----------|------------------|----------|
| Status operacional | `LifecycleStatus` (10 valores FE) | String livre `Status` como canônico |
| Past Due | Derivado on-read (`ScheduledDate < hoje` AND NOT terminal) | Misturar no enum lifecycle |
| Vendor / Tech | Dispatch primário (`PrimaryDispatchId`) | Duplicar na WO |
| Schedule | Scheduling slice | Duplicar no core sem slice lógico |
| Completion | `DocStatus` | Inferir só de dispatch signoff |
| Reschedule / CarriedOver | Analytics + domain events | Job blind overwrite |
| WO# | `InternalWONumber` / `WorkerOrderNumber` com regra 11 dígitos (Fase 2+) | Dois números sem regra |
| POC | `WorkOrderContacts` + Notes | Só no detalhe sem contato |
---
## 4. Lifecycle Status vs Operational Flags
```text
LifecycleStatus → enum único (10 valores alinhados ao frontend)
OperationalFlags → PastDue (derivado on-read; cache opcional Fase 5)
LegacyStatus → string original read-only (migration Fase 0)
```
Filtro de status no board = `LifecycleStatus`. Overlay Past Due = flag derivada. Bloqueio de edição = validação sobre flag (Fase 2).
### Mapeamento legado → LifecycleStatus
| Legado (`Status`) | LifecycleStatus |
|-------------------|-----------------|
| Open | Incomplete |
| InProgress / In Progress | InProgress |
| Completed / Complete | Complete |
| Cancelled / Canceled | Canceled |
| OnHold / On Hold | OnHold |
| Desconhecido | Incomplete + NeedsReview |
---
## 5. CarriedOver — justificativa
| | isPastDue | carriedOver |
|--|-----------|-------------|
| Natureza | Estado pontual | Métrica histórica acumulada |
| Persistir | Não (derivado) | Sim (contador) |
| Incremento | N/A | Somente via domain event `WeekRolled` (Fase 5) |
---
## 6. Scheduling Aggregate Growth Watchlist
Campos que **não** entram em Scheduling sem ARB review:
- `MoveReason`, `MoveUser`, `MoveCategory`, `MoveSource`
Metadados de movimentação → Audit Event Contract.
**Gate:** slice Scheduling > 8 campos operacionais → ARB obrigatório.
Contagem Fase 0: ScheduledDate, ScheduledStart, ScheduledEnd, TargetWeek, ScheduleWeekOnly, OriginalWeek, OriginalDate (tracking separado) — dentro do limite.
---
## 7. Jobs — domínio primeiro
| Job | Papel |
|-----|-------|
| Past Due diário | Cache refresh opcional; on-read sempre correto |
| Carried Over semanal | Dispara `WeekRolled` → incrementa contador + audit system |
| Promoção Overdue type | Derivado on-read |
| Audit | Síncrono em toda mutação desde Fase 0 |
**Regra de ouro:** Jobs nunca são a única fonte da verdade.
---
## 8. Critérios de aceite (sign-off CTO)
- [ ] Zero campos vendor canônicos na WO
- [ ] Agregados documentados e refletidos no schema Fase 0
- [ ] Persistido vs derivado validado com PO (ver `dar-persisted-vs-derived-matrix.md`)
- [ ] Watchlist Scheduling registrada
- [ ] Blazor congelado — sem novos campos board via EF direto
**Assinaturas**
| Papel | Nome | Data |
|-------|------|------|
| CTO / Arquiteto | | |
| Product Owner | | |

View file

@ -1,54 +0,0 @@
# DAR — Matriz Persistido vs Derivado
**Fase:** 0
**Status:** Draft para validação PO + CTO
---
## Matriz de campos (board + domínio)
| Campo FE / conceito | Persistido | Derivado | Fonte / Regra |
|---------------------|------------|----------|---------------|
| lifecycleStatus | Sim | Não | Core — `LifecycleStatus` enum |
| isPastDue | Não | **Sim** | `ScheduledDate < UTC hoje` AND NOT status terminal |
| type (Overdue) | Parcial | **Sim** | Regra type + schedule + status |
| workOrderType | Sim | Não | Core — enum `WorkOrderType` |
| carriedOver | Sim | Não | Analytics — incremento via `WeekRolled` only |
| rescheduleCount | Sim | Parcial | Analytics — incremento em mudança ScheduledDate |
| docStatus | Sim | Não | Completion slice |
| dayGroup | Não | Sim | Calculado na leitura a partir de ScheduledDate |
| apptTime | Não | Sim | Projeção Dispatch primário ScheduledDate/Start |
| vendorName / tech | Não | Sim (projeção) | Join Dispatch primário |
| targetWeek | Sim | Não | Scheduling slice |
| scheduledEnd | Sim | Não | Scheduling slice |
| originalWeek / originalDate | Sim | Não | Tracking — set-once na primeira agenda |
| operationalFlags.pastDue | Não* | Sim | *Cache opcional Fase 5; on-read authoritative |
| legacyStatus | Sim (read-only) | Não | Valor string original pré-migration |
---
## Regras de derivação (on-read)
### isPastDue
```text
isPastDue = ScheduledDate.HasValue
AND ScheduledDate.Value.Date < DateTime.UtcNow.Date
AND LifecycleStatus NOT IN (Complete, Canceled)
```
### type Overdue (derivado)
WO com type != Overdue pode exibir badge Overdue quando isPastDue && regras de negócio PO confirmadas.
---
## Validação PO
| Pergunta | Resposta PO |
|----------|-------------|
| Past Due separado do lifecycle status? | Sim |
| carriedOver persiste mesmo após reschedule? | Sim |
| Vendor nunca duplicado na WO? | Sim |
**Assinatura PO:** _________________ Data: _______

View file

@ -1,75 +0,0 @@
# Data Ownership Model — Work Orders
**Fase:** 0
**Status:** Draft para sign-off CTO
---
## 1. Princípios
1. **SHOC (React)** é o único destino de novas features do board.
2. **SQL Server** é fonte operacional para board, Blazor e Portal.
3. **DynamoDB/Sync** é ponte temporária de ingestão externa.
4. Conflito entre atores resolve-se pela Field Ownership Matrix, não por last-write-wins global.
---
## 2. Ownership por ator
| Ator | Conexão | Pode alterar | Não pode alterar |
|------|---------|--------------|------------------|
| **SHOC** | REST JWT (`api/WorkOrder`) | Lifecycle, Schedule, Assignment, Completion, vendor via Dispatch, POC, WO# | Checklist/signoff vendor |
| **Vendor Portal** | REST `X-Vendor-Token` | Dispatch status, checklist, signoff, comments vendor | Schedule, lifecycle, assignment, DueDate |
| **Sync/Lambda** | `POST api/Sync/WorkOrders` | Campos ingest (Description, ExternalId, SiteCode na criação, etc.) | Campos SHOC-owned após ManualEditFlag |
| **Blazor** | EF direto (`WorkorderService`) | Campos legados existentes até sunset | Campos novos board; **congelado** |
| **Jobs** | Domain events (Fase 5+) | Incremento CarriedOver; audit System | Lifecycle, Schedule, Dispatch |
---
## 3. Política Blazor (sunset)
- Manutenção mínima: bugfix crítico only.
- Não recebe: board, inline edit, jobs, advanced search, completion doc WO-level.
- Schema: colunas novas Fase 0 são **nullable** — Blazor continua funcionando sem conhecer novos campos.
- Conflito Blazor vs SHOC: **SHOC vence**; Blazor não deve escrever campos board após go-live Fase 1.
- Smoke test EF obrigatório pós-migration (ver checklist smoke).
---
## 4. Política Vendor Portal
- Dispatch intacto; zero regressão checklist/signoff/verify.
- Vendor não muta WorkOrder core — apenas Dispatch e comments associados.
- Audit EventType = `Vendor` para mutações portal.
---
## 5. Política Sync/Lambda
- Upsert por `ExternalWorkOrderId` (idempotente).
- Field-level merge conforme Field Ownership Matrix.
- Campo com ManualEditFlag → skip + audit `SyncRejected`.
- Cutover Lambda → API direta: Fase 7.
---
## 6. Regras de conflito (resumo)
| Cenário | Vencedor |
|---------|----------|
| SHOC vs SHOC (mesmo campo) | 409 RowVersion; último commit válido |
| SHOC vs Sync (mesmo campo) | SHOC se ManualEditFlag |
| SHOC vs Sync (campos distintos) | Merge |
| Vendor vs Sync | Domínios separados |
| SHOC vs Vendor (mesmo conceito) | Impossível por design (vendor = Dispatch) |
| Blazor vs SHOC | SHOC vence; Blazor congelado |
---
## 7. Critérios de aceite
- [ ] Matriz por ator revisada por CTO
- [ ] PO confirma política Blazor sunset
- [ ] Sync merge referencia Field Ownership Matrix
**Assinatura CTO:** _________________ Data: _______

View file

@ -1,110 +0,0 @@
# Database Drift Report — SQL vs EF
**Fase:** 0
**Data:** 2026-06-24
**EF Snapshot:** `Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs`
**Script legado:** `Api.SeaHavenIndustries/db.txt`
---
## 1. Metodologia
1. Colunas EF extraídas do `ApplicationDbContextModelSnapshot` (entidade `WorkOrder`).
2. Colunas SQL legado extraídas de `db.txt` DDL `[WorkOrders]`.
3. Classificação: **mapear**, **deprecar**, **satélite**, **OK**.
---
## 2. Tabela workOrders — drift conhecido
| Coluna SQL (legado) | No EF? | Classificação | Ação Fase 0 |
|---------------------|--------|---------------|-------------|
| WorkOrderType | **Não** | mapear | Adicionar `WorkOrderType` int nullable → enum |
| AvettaTask | **Não** | inventariar | Adicionar coluna nullable; uso TBD com PO |
| AssignDate | **Não** | inventariar | Adicionar coluna nullable; possível alias AssignDate tracking |
| Nome tabela WorkOrders vs workOrders | EF usa `workOrders` | OK | Manter EF naming; SQL Server case-insensitive |
---
## 3. Colunas EF (workOrders) — baseline
Presentes no snapshot e mapeadas:
InternalWONumber, ExternalWorkOrderId, WorkerOrderNumber, WorkerOrderTitle, Description, Customer, SiteCode, Building, Severity, DateReported, ScheduledStart, ScheduledDate, CompletedDate, Source, SourceEmailS3Key, Problem, Trade, SubTrade, VendorNTE, AssignTo, DueDate, Priority, Status, LocationId, PO, TT, Attachments, BeforPhoto*, AfterPhoto*, SignOff*, istemplate, audit fields (CreatedDate, IsDeleted, etc.)
---
## 4. Novas colunas Fase 0 (migration)
| Coluna | Tipo | Slice |
|--------|------|-------|
| LifecycleStatus | int nullable | Core |
| LegacyStatus | nvarchar (cópia Status) | Core |
| WorkOrderType | int nullable | Core |
| PrimaryDispatchId | int nullable FK | Core |
| RowVersion | rowversion | Core |
| TargetWeek | date nullable | Scheduling |
| ScheduledEnd | datetime2 nullable | Scheduling |
| ScheduleWeekOnly | bit nullable | Scheduling |
| OriginalWeek | date nullable | Tracking |
| OriginalDate | date nullable | Tracking |
| RescheduleCount | int default 0 | Analytics |
| CarriedOver | int default 0 | Analytics |
| DocStatus | int nullable | Completion |
| AvettaTask | nvarchar max nullable | Legado SQL |
| AssignDate | date nullable | Legado SQL |
---
## 5. Dispatch
| Item | EF | Ação Fase 0 |
|------|-----|-------------|
| RowVersion | Ausente | Adicionar |
| Demais colunas | OK | Manter |
---
## 6. WorkOrderAuditLog
| Coluna | EF atual | Ação Fase 0 |
|--------|----------|-------------|
| EventType | Ausente | Adicionar |
| ActorType | Ausente | Adicionar |
| DispatchId | Ausente | Adicionar nullable |
| CorrelationId | Ausente | Adicionar nullable |
---
## 7. WorkOrderContacts
| Coluna | EF | Ação Fase 0 |
|--------|-----|-------------|
| Notes | Ausente | Adicionar nvarchar nullable (pocNotes) |
---
## 8. ApplicationUser (AspNetUsers)
| Coluna | Ação Fase 0 |
|--------|-------------|
| Initials | nvarchar(8) nullable |
| Color | nvarchar(16) nullable |
---
## 9. Tabelas revisadas — sem drift crítico adicional
- `Comments` — OK
- `Locations` — OK (SiteCode via WO.SiteCode)
- `DispatchWorkOrders` — OK
---
## 10. Recomendações
1. **Eliminar dual-path:** `db.txt` catch-up não deve ser usado após migration EF Phase0; documentar em runbook.
2. **WorkOrderType:** mapear valores SQL existentes → enum na migration data script (dry-run).
3. **AvettaTask / AssignDate:** manter nullable; PO valida uso antes de exposição board.
**Gate:** Diff assinado por Backend antes de merge migration.

View file

@ -1,86 +0,0 @@
# Field Ownership Matrix — Work Orders
**Fase:** 0
**Depende de:** [data-ownership-model.md](data-ownership-model.md)
**Status:** Draft para sign-off CTO + PO
---
## Legenda
- **Owner escritor:** ator autorizado a criar/alterar o valor canônico.
- **Sync sobrescreve?:** se ingest DynamoDB pode substituir após WO existir.
- **ManualEditFlag:** primeira edição SHOC bloqueia Sync no campo (ver [manual-edit-flag-design.md](manual-edit-flag-design.md)).
---
## Matriz completa
| Campo | Agregado | Owner escritor | Sync sobrescreve? | Conflito SHOC vs Sync |
|-------|----------|----------------|-------------------|----------------------|
| LifecycleStatus | Core | SHOC | Não após 1ª edição SHOC | SHOC vence |
| LegacyStatus | Core | — (read-only) | Não | N/A |
| AssignTo | Core | SHOC | Não após ManualEditFlag | SHOC vence |
| ScheduledDate | Scheduling | SHOC | Não | SHOC vence |
| ScheduledStart | Scheduling | SHOC | Parcial (ingest inicial) | SHOC após flag |
| ScheduledEnd | Scheduling | SHOC | Não | SHOC vence |
| TargetWeek | Scheduling | SHOC | Não | SHOC vence |
| ScheduleWeekOnly | Scheduling | SHOC | Não | SHOC vence |
| DueDate | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
| Description | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
| WorkerOrderTitle | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
| SiteCode | Core | SHOC + Sync | Sim na criação; não após SHOC | SHOC após flag |
| Building | Core | SHOC + Sync | Sim na criação | SHOC após flag |
| LocationId | Core | SHOC + Sync | Sim na criação | SHOC após flag |
| WorkOrderType | Core | SHOC + Sync | Sim na criação ingest | SHOC após flag |
| InternalWONumber / WoNumber | Core | SHOC (Sync só na criação) | Não | SHOC only |
| ExternalWorkOrderId | Core | Sync | Sim (idempotente key) | Sync only |
| VendorId / VendorName | Dispatch | SHOC + Vendor | Não | Domínios separados |
| Dispatch Status | Dispatch | Vendor + SHOC | Não | Por contexto |
| Checklist / Signoff | Dispatch | Vendor | Não | Vendor vence |
| DocStatus | Completion | SHOC | Não | SHOC only |
| RescheduleCount | Analytics | Domain events | Não | N/A |
| CarriedOver | Analytics | Jobs (WeekRolled) | Não | N/A |
| OriginalWeek / OriginalDate | Tracking | SHOC (set-once) | Não | SHOC vence |
| POC / WorkOrderContacts.Notes | Contacts | SHOC | Não | SHOC vence |
| Priority / Severity | Core | SHOC + Sync | Sim se nunca editado | SHOC após flag |
| Status (legado string) | Core | — (deprecated write) | Não | Migrar para LifecycleStatus |
| Trade / Problem / SubTrade | Core | SHOC | Parcial Sync ingest | SHOC após flag |
| Customer / Source | Core | Sync + SHOC | Sim na criação | SHOC após flag |
| Attachments / Photos | Core | SHOC + Vendor | Não overwrite mútuo | Por domínio |
---
## Campos board (14 colunas) — ownership resumido
| Coluna board | Fonte | Owner |
|--------------|-------|-------|
| WO# | Core.InternalWONumber | SHOC |
| Type | Core.WorkOrderType | SHOC |
| Site | Core.SiteCode | SHOC |
| Status | Core.LifecycleStatus | SHOC |
| Assignee | Core.AssignTo | SHOC |
| Due | Core.DueDate | SHOC + Sync |
| Scheduled | Scheduling.ScheduledDate | SHOC |
| Vendor | Dispatch (primário) | SHOC via Dispatch |
| Appt | Dispatch.ScheduledDate | Dispatch |
| Past Due | Derivado | — |
| Carried | Analytics.CarriedOver | Jobs |
| Reschedule | Analytics.RescheduleCount | Domain |
| Doc | Completion.DocStatus | SHOC |
| Actions | — | SHOC UI |
---
## Critérios de aceite
- [ ] PO valida campos board vs matriz
- [ ] Sync merge implementado referencia esta matriz
- [ ] ManualEditFlag design aprovado
**Assinaturas**
| Papel | Data |
|-------|------|
| CTO | |
| PO | |

View file

@ -1,87 +0,0 @@
# ManualEditFlag — Design
**Fase:** 0
**Implementação:** tabela satélite `WorkOrderFieldLocks`
---
## 1. Problema
Sync/Lambda faz upsert overwrite em campos que dispatchers editam no SHOC. Precisamos saber, por campo, se houve edição manual SHOC para rejeitar ingest conflitante.
---
## 2. Solução escolhida: tabela satélite
```text
WorkOrderFieldLocks
Id (PK)
WorkOrderId (FK)
FieldName (string, max 64)
LockedAt (UTC)
LockedByUserId (nullable — null = system/bootstrap)
UNIQUE (WorkOrderId, FieldName)
```
**Alternativa descartada:** bitmask — difícil de auditar e estender.
---
## 3. Comportamento
| Evento | Ação |
|--------|------|
| SHOC edita campo X pela 1ª vez | INSERT lock (WorkOrderId, FieldName) |
| Sync tenta atualizar campo X com lock | Skip update; audit `SyncRejected` |
| Sync atualiza campo Y sem lock | Apply merge normal |
| WO novo (Sync create) | Sem locks; full ingest |
| Blazor edita campo legado | **Não** cria lock Fase 0 (gap conhecido; Blazor congelado) |
---
## 4. Campos elegíveis a lock (SHOC writers)
Definidos em `SyncFieldMergePolicy.ShocOwnedFields`:
- LifecycleStatus, AssignTo, ScheduledDate, ScheduledEnd, TargetWeek, ScheduleWeekOnly
- DueDate, Description, WorkerOrderTitle, SiteCode, Building, LocationId
- WorkOrderType, DocStatus, Trade, Problem, Priority, InternalWONumber
---
## 5. Audit em rejeição
```json
{
"eventType": "Sync",
"action": "SyncRejected",
"fieldName": "DueDate",
"oldValue": "2026-06-01",
"newValue": "2026-06-15",
"actorType": "Sync"
}
```
---
## 6. API interna
```csharp
interface IWorkOrderFieldLockService
{
Task LockFieldAsync(int workOrderId, string fieldName, string? userId);
Task<bool> IsLockedAsync(int workOrderId, string fieldName);
}
```
Implementação: `WorkOrderFieldLockService` em `SeaHaven.Services`.
Lock criado automaticamente por `IWorkOrderAuditService` em eventos `Manual` + `FieldChanged` / `StatusChanged`.
---
## 7. Critérios de aceite
- [ ] Tabela criada na migration Phase0
- [ ] Sync consulta locks antes de overwrite
- [ ] SyncRejected aparece no audit log

View file

@ -1,58 +0,0 @@
# Migration Dry-Run Report — Phase 0
**Migration:** `20260624163145_Phase0_DomainFoundation`
**Status:** Pronto para execução em staging
---
## 1. Procedimento
1. Restore backup produção → staging espelho
2. Snapshot pré-migration:
```sql
SELECT Status, COUNT(*) FROM workOrders GROUP BY Status;
SELECT COUNT(*) AS Total FROM workOrders;
```
3. Executar:
```powershell
dotnet ef database update --project Data.SeaHavenIndustries --startup-project Api.SeaHavenIndustries
```
4. Validar pós-migration:
```sql
SELECT COUNT(*) FROM workOrders WHERE LegacyStatus IS NOT NULL;
SELECT LifecycleStatus, COUNT(*) FROM workOrders GROUP BY LifecycleStatus;
SELECT COUNT(*) FROM workOrders WHERE PrimaryDispatchId IS NOT NULL;
```
5. Smoke Blazor + API legado + Sync sample
6. Rollback (se falha):
```powershell
dotnet ef database update 20260417195316_AddDispatchVerification --project Data.SeaHavenIndustries --startup-project Api.SeaHavenIndustries
```
---
## 2. Data scripts incluídos na migration
- `LegacyStatus` ← cópia de `Status`
- `LifecycleStatus` ← mapeamento legado (Open→Incomplete, etc.)
- `PrimaryDispatchId` ← dispatch mais recente por WO
---
## 3. Resultados (preencher em staging)
| Métrica | Antes | Depois |
|---------|-------|--------|
| Total WOs | | |
| Com LegacyStatus | | |
| Com LifecycleStatus | | |
| Com PrimaryDispatchId | | |
| Rollback testado | | Sim/Não |
---
## 4. Notas
A migration inclui reconciliação de drift em `Locations`, `Regions`, `Departments` detectada pelo EF — revisar impacto em staging antes de produção.
**WO# normalização 11 dígitos:** documentada para Fase 2; não executada nesta migration.

View file

@ -1,46 +0,0 @@
# Phase 0 — Gates Sign-Off Checklist
**Programa:** Work Orders Board
**Fase:** 0 — Domain Foundation + Governança
---
## 9 Gates bloqueantes
| # | Gate | Artefato | Status |
|---|------|----------|--------|
| 1 | DAR aprovado | [dar-domain-architecture-review.md](dar-domain-architecture-review.md) | Documentado |
| 2 | Data Ownership Model | [data-ownership-model.md](data-ownership-model.md) | Documentado |
| 3 | Field Ownership Matrix | [field-ownership-matrix.md](field-ownership-matrix.md) | Documentado |
| 4 | Audit Event Contract | [audit-event-contract.md](audit-event-contract.md) | Implementado baseline |
| 5 | Volume Discovery | [volume-discovery-report.md](volume-discovery-report.md) | Queries prontas; tier S provisório |
| 6 | Database drift | [database-drift-report.md](database-drift-report.md) | Documentado |
| 7 | Spike vendor/dispatch | [adr-vendor-source-of-truth.md](adr-vendor-source-of-truth.md) | ADR + query |
| 8 | Migration dry-run | [migration-dry-run-report.md](migration-dry-run-report.md) | Procedimento pronto |
| 9 | RowVersion multi-agregado | WO + Dispatch + ConcurrencyExceptionFilter | Implementado |
---
## Implementação código (Fase 0)
- Migration `Phase0_DomainFoundation`
- Enums: LifecycleStatus, WorkOrderType, DocStatus, AuditEventType, etc.
- `IWorkOrderAuditService`, `IWorkOrderFieldLockService`, `ISyncFieldMergePolicy`
- `[Authorize]` reativado em WorkOrderController
- Projeto `SeaHavenIndustries.Tests`
---
## GO Fase 1 — pendente
- [ ] Sign-off CTO/PO nos artefatos
- [ ] Dry-run staging executado
- [ ] Volume tier confirmado em staging/prod
- [ ] Smoke checklist verde
**Assinatura GO Fase 1**
| Papel | Nome | Data |
|-------|------|------|
| CTO | | |
| PO | | |

View file

@ -1,36 +0,0 @@
# RowVersion Multi-Agregado — Design
**Fase:** 0 — Gate #9
---
## Agregados
| Agregado | Coluna | Comportamento |
|----------|--------|---------------|
| WorkOrder | `RowVersion` rowversion | Bump em mutação WO (schedule, status, assignment) |
| Dispatch | `RowVersion` rowversion | Bump em mutação vendor/checklist/signoff |
---
## HTTP 409
`ConcurrencyExceptionFilter` captura `DbUpdateConcurrencyException` e retorna:
```json
{
"status": "Conflict",
"message": "The record was modified by another user. Refresh and retry.",
"code": 409
}
```
---
## Regras (Fase 2 testes completos)
- SHOC edita ScheduledDate → bump WO.RowVersion
- SHOC edita vendor → bump Dispatch.RowVersion
- Vendor edita checklist → bump Dispatch.RowVersion only
Testes de concorrência paralela: Fase 2 (`Concurrency integration tests` backlog #27).

View file

@ -1,57 +0,0 @@
# Smoke Test Checklist — Multi-Consumidor (Fase 0)
**Ambiente:** Docker local (`docker compose up -d` + `scripts/setup-local-db.ps1`)
---
## Blazor EF
| # | Teste | Pass |
|---|-------|------|
| 1 | `/workorderlist` carrega sem exception | [ ] |
| 2 | Abrir detalhe WO existente | [ ] |
| 3 | Campos novos nullable não quebram binding | [ ] |
---
## API REST (legado)
| # | Teste | Pass |
|---|-------|------|
| 1 | POST login → JWT | [ ] |
| 2 | GET work order list com Bearer token | [ ] |
| 3 | POST ChangeStatus com audit no log | [ ] |
| 4 | POST ChangeAssignment.tex Assignment com audit | [ ] |
| 5 | 401 sem token (auth reativada) | [ ] |
---
## Sync
| # | Teste | Pass |
|---|-------|------|
| 1 | POST `api/Sync/WorkOrders` com JWT (mock/staging DynamoDB) | [ ] |
| 2 | Upsert idempotente por ExternalWorkOrderId | [ ] |
| 3 | Campo locked → SyncRejected no audit | [ ] |
---
## Vendor Portal
| # | Teste | Pass |
|---|-------|------|
| 1 | Checklist update flow | [ ] |
| 2 | Signoff flow | [ ] |
| 3 | Dispatch RowVersion presente (schema) | [ ] |
---
## Automação local
```powershell
dotnet test SeaHavenIndustries.Tests
dotnet build
./scripts/setup-local-db.ps1
```
Testes unitários cobrem: LifecycleStatusMapper, audit baseline, sync merge rejection.

View file

@ -1,96 +0,0 @@
# Volume Discovery Report — Work Orders
**Fase:** 0 — Gate Fase 1
**Data:** 2026-06-24
**Ambiente:** Local dev (Docker) + queries para staging/prod
---
## 1. Objetivo
Definir tier S/M/L/XL antes de índices e estratégia de search (Fase 4). **Sem premissa de volume.**
---
## 2. Queries obrigatórias
Executar em staging ou produção read-only:
```sql
-- Total WOs
SELECT COUNT(*) AS TotalWorkOrders
FROM workOrders
WHERE IsDeleted IS NULL OR IsDeleted = 0;
-- WOs por semana (ISO) — p95
WITH WeeklyCounts AS (
SELECT DATEPART(iso_week, ScheduledDate) AS IsoWeek,
YEAR(ScheduledDate) AS IsoYear,
COUNT(*) AS Cnt
FROM workOrders
WHERE ScheduledDate IS NOT NULL
AND (IsDeleted IS NULL OR IsDeleted = 0)
GROUP BY DATEPART(iso_week, ScheduledDate), YEAR(ScheduledDate)
)
SELECT MAX(Cnt) AS P95ProxyMaxPerWeek,
AVG(Cnt * 1.0) AS AvgPerWeek
FROM WeeklyCounts;
-- Unscheduled (não terminal)
SELECT COUNT(*) AS UnscheduledCount
FROM workOrders
WHERE ScheduledDate IS NULL
AND (IsDeleted IS NULL OR IsDeleted = 0)
AND Status NOT IN ('Completed', 'Complete', 'Cancelled', 'Canceled');
-- Crescimento mensal
SELECT YEAR(CreatedDate) AS Y, MONTH(CreatedDate) AS M, COUNT(*) AS Cnt
FROM workOrders
WHERE CreatedDate IS NOT NULL
GROUP BY YEAR(CreatedDate), MONTH(CreatedDate)
ORDER BY Y DESC, M DESC;
```
---
## 3. Resultados
| Métrica | Local Docker (dev) | Staging | Produção |
|---------|-------------------|---------|----------|
| Total WOs | *executar após seed* | _pendente acesso_ | _pendente acesso_ |
| WOs/semana p95 | — | _pendente_ | _pendente_ |
| Unscheduled | — | _pendente_ | _pendente_ |
| Crescimento mensal | — | _pendente_ | _pendente_ |
**Nota:** Ambiente local inicia vazio. Tier provisório para desenvolvimento: **S** (< 25k). Confirmar com query em staging antes do GO Fase 1.
---
## 4. Classificação tier (§10.2 roadmap)
| Tier | Total WOs | Listagem semanal | Advanced search |
|------|-----------|------------------|-----------------|
| **S** | < 25k | Índices simples | SQL filtros compostos |
| **M** | 25k–250k | Índices covering | Paginação obrigatória |
| **L** | 250k–1M | Read model candidato | Full-text |
| **XL** | > 1M | Materialized view | Search dedicado (ARB) |
**Tier definido para Fase 1:** **S (provisório dev)** — revisar ao obter métricas staging.
---
## 5. Impacto Fase 0–1
- Fases 1–3: índices conservadores compatíveis com qualquer tier.
- Fase 4: implementação search conforme tier confirmado + load test.
---
## 6. Script local
```powershell
./scripts/setup-local-db.ps1
# Executar queries acima via sqlcmd ou DBCode contra localhost:1433
```
**Gate:** Preencher coluna Staging/Produção antes do GO Fase 1.

View file

@ -1,159 +0,0 @@
# Fase 1 — Weekly Board (leitura)
**Programa:** Work Orders Board
**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais.
---
## Endpoints
### `GET /api/workorders/board`
Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada.
**Autenticação:** Bearer JWT (`[Authorize]`)
**Query params:**
| Param | Obrigatório | Descrição |
|-------|-------------|-----------|
| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. |
| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. |
| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. |
| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). |
| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) |
| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) |
**Exemplo:**
```bash
curl -H "Authorization: Bearer <token>" \
"https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true"
```
**Response:**
```json
{
"weekStart": "2026-06-22",
"weekEnd": "2026-06-26",
"counts": { "returned": 42, "total": 58 },
"unscheduled": [ /* WorkOrderBoardRowDto[] */ ],
"scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ]
}
```
- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search)
- `counts.returned` — WOs agendadas após aplicar search
- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo)
- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior)
### `GET /api/workorders/lookups/dispatchers`
Lista opções para o filtro/dropdown de assignee do board.
**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito.
```json
[
{ "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" }
]
```
---
## Matriz coluna → campo API
| Coluna board | Campos response |
|--------------|-----------------|
| WO# | `woNumber`, `rescheduleCount`, `carriedOver` |
| Type | `workOrderType`, `isPastDue` |
| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` |
| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` |
| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` |
| Due | `dueDate` |
| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` |
| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` |
| Appt | `apptDate`, `apptTime` |
| Past Due | `isPastDue` (derivado on-read) |
| Carried | `carriedOver` |
| Reschedule | `rescheduleCount` |
| Doc | `docStatus` |
| Actions | FE only |
---
## Regras de derivação
### isPastDue
```
ScheduledDate < UTC hoje
AND LifecycleStatus NOT IN (Complete, Canceled, Closed)
```
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
### Vendor / Appt
Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)).
### Janela semanal
WO entra em **scheduled** se:
- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou**
- `ScheduleWeekOnly == true` e `TargetWeek == weekStart`
**Unscheduled:** `ScheduledDate == null` e status não terminal.
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` |
| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` |
| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` |
| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) |
| Migration | `Data.SeaHavenIndustries/Migrations/20260624180343_Phase1_BoardIndexes.cs` |
| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` |
---
## Gates de aceite
### Desenvolvimento (concluído)
- [x] Endpoint `GET board` — semana + Unscheduled
- [x] Projeção 12/14 colunas de dados
- [x] `isPastDue` derivado on-read
- [x] Vendor via dispatch primário
- [x] Filtros dispatcher / My WOs / tipo / search
- [x] Contador X of Y
- [x] Índices Tier S
- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total)
### Staging / produção (pendente)
- [ ] Sign-off CTO/PO (herda gates Fase 0)
- [ ] Dry-run migration Phase0 + Phase1 em staging
- [ ] Tier volume confirmado em staging/prod
- [ ] Smoke checklist Blazor / Sync / Portal
- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados)
- [ ] Latência aceitável com volume real (Tier S: &lt; 25k WOs)
- [ ] Feature flag SHOC board em staging
---
## Coexistência
Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas.
---
## Próximo passo
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).

View file

@ -1,154 +0,0 @@
# Fase 2 — Inline Edit + Concorrência
**Programa:** Work Orders Board
**Objetivo:** Edição spreadsheet-style célula a célula no board SHOC, com dual RowVersion, audit granular e regras de domínio no backend.
**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md)
---
## Endpoints
### `PATCH /api/workorders/{id}/board`
Atualiza um único campo do board com validação otimista de concorrência.
**Autenticação:** Bearer JWT (`[Authorize]`)
**Request body:**
```json
{
"field": "scheduledDate",
"value": "2026-06-25",
"workOrderVersion": "<base64 RowVersion>",
"dispatchVersion": "<base64 RowVersion | null para campos WO-only>",
"primaryDispatchId": 123
}
```
| Campo | Obrigatório | Descrição |
|-------|-------------|-----------|
| `field` | Sim | Nome canônico do campo (case-insensitive) |
| `value` | Sim* | Valor serializado como string (*pode ser vazio para limpar datas) |
| `workOrderVersion` | Sim | `RowVersion` atual da WO (Base64) |
| `dispatchVersion` | Condicional | Obrigatório para campos Dispatch quando dispatch já existe |
| `primaryDispatchId` | Não | Valida que o dispatch pertence à WO |
**Responses:**
| HTTP | Body | Quando |
|------|------|--------|
| 200 | `WorkOrderBoardRowDto` | Sucesso — row completo com versões atualizadas |
| 409 | `WorkOrderBoardConflictDto` + `currentState` | Conflito de versão |
| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio violada |
| 400 | `Response` | Erro genérico / argumento inválido |
**Exemplo:**
```bash
curl -X PATCH -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"field":"siteCode","value":"BK5","workOrderVersion":"AQAAAAAAAAA="}' \
"https://localhost:5001/api/workorders/1/board"
```
---
## Campos editáveis
| field (API) | Agregado | Coluna board | Audit FieldName |
|-------------|----------|--------------|-----------------|
| `woNumber` | WorkOrder | WO# | InternalWONumber |
| `workOrderType` | WorkOrder | Type | WorkOrderType |
| `siteCode` | WorkOrder | Site | SiteCode |
| `lifecycleStatus` | WorkOrder | Status | LifecycleStatus |
| `assignTo` | WorkOrder | Assignee | AssignTo |
| `dueDate` | WorkOrder | Due | DueDate |
| `scheduledDate` | WorkOrder | Scheduled | ScheduledDate |
| `targetWeek` | WorkOrder | Scheduled (week-only) | TargetWeek |
| `scheduleWeekOnly` | WorkOrder | Scheduled | ScheduleWeekOnly |
| `vendorId` | Dispatch | Vendor | VendorId |
| `apptDate` | Dispatch | Appt | ApptDate |
| `apptTime` | Dispatch/WO | Appt | ApptTime |
| `docStatus` | WorkOrder | Doc | DocStatus |
**Read-only:** `isPastDue`, `carriedOver`, `rescheduleCount` (badge derivado; count incrementado por regra).
---
## Dual RowVersion
| Agregado | Quando exigir versão |
|----------|---------------------|
| WorkOrder | Sempre (`workOrderVersion`) |
| Dispatch | Campos vendor/appt quando dispatch primário já existe (`dispatchVersion`) |
O GET board retorna `rowVersion`, `dispatchRowVersion` e `primaryDispatchId` em cada row.
**409 currentState:** inclui row recarregado do DB para refresh imediato no FE.
---
## Regras de domínio
| Regra | Comportamento |
|-------|---------------|
| Auto-schedule | `Incomplete` + `scheduledDate` + `assignTo` → `Scheduled` |
| Reschedule | Mudança de `scheduledDate` com data anterior → `rescheduleCount++` |
| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` |
| Past Due block | Não permite mudar `lifecycleStatus` quando `isPastDue` |
| Cancel read-only | Status terminal (`Canceled`, `Closed`, `Complete`) bloqueia PATCH |
| WO# 11 dígitos | Normalização numérica + unicidade |
| Vendor ADR | Mutação em `Dispatch`; cria dispatch primário se ausente |
---
## Audit
- 1 evento por campo alterado (`FieldChanged`, `StatusChanged`, ou `AssignmentChanged`)
- Side effects (auto-schedule, rescheduleCount) geram eventos adicionais
- Field lock (`WorkOrderFieldLocks`) criado automaticamente em edições manuais
- Eventos Dispatch incluem `dispatchId`
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
| Helpers | `WorkOrderBoardMutationRules`, `WorkOrderNumberNormalizer`, `WorkOrderBoardFieldNames`, `WorkOrderBoardApptTimeParser` |
| Update service | `SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs` |
| Audit | `IWorkOrderAuditService.StageFieldChanged`, `LogFieldChangedAsync` |
| API | `WorkOrderController` — `PATCH {id}/board` |
| Testes | `WorkOrderBoardMutationRulesTests`, `WorkOrderNumberNormalizerTests`, `WorkOrderBoardUpdateServiceTests`, `WorkOrderBoardConcurrencyTests` |
---
## Gates de aceite
### Desenvolvimento
- [x] `PATCH /api/workorders/{id}/board` para todos os campos da matriz
- [x] Dual RowVersion validado; 409 com `currentState`
- [x] Auto-schedule Incomplete→Scheduled
- [x] RescheduleCount++ em reagendamento
- [x] Bloqueio status quando PastDue; reagendar limpa flag on-read
- [x] WO cancelado/closed rejeita edição (422)
- [x] WO# normalizado 11 dígitos + unicidade
- [x] Vendor/appt muta Dispatch; cria primário se ausente
- [x] 1 audit event por campo; field lock criado
- [x] 51 testes unitários total (25 Fase 2 + 26 Fases 0–1)
### Staging (pendente)
- [ ] Smoke Sync durante edição SHOC
- [ ] UAT dispatcher: editar células reais no board
- [ ] Latência PATCH aceitável (<200ms p95 Tier S)
---
## Próximo passo
**Fase 3** — Criação wizard/inline, soft cancel dedicado, ManualEditFlag na criação.

View file

@ -1,174 +0,0 @@
# Fase 3 — Criação e Cancelamento
**Programa:** Work Orders Board
**Objetivo:** Criação wizard/inline via contrato do board SHOC, cancelamento soft dedicado, ManualEditFlag na criação, e delete físico restrito a Admin.
**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md)
---
## Endpoints
### `POST /api/workorders/board`
Cria uma WO com contrato alinhado ao board. Suporta wizard (payload completo) e inline row (subset mínimo).
**Autenticação:** Bearer JWT (`[Authorize]`)
**Request body:**
```json
{
"woNumber": "12345",
"workOrderType": "PM",
"siteCode": "BK5",
"assignTo": "dispatcher-guid",
"dueDate": "2026-07-01",
"scheduledDate": "2026-06-25",
"targetWeek": "2026-06-22",
"scheduleWeekOnly": false,
"vendorId": 5,
"apptDate": "2026-06-26",
"apptTime": "09:00 – 11:00",
"docStatus": "No",
"description": "Leak in break room",
"trade": "HVAC PM",
"locationId": 12,
"pocContactId": 3,
"pocNotes": "Call before arrival"
}
```
| Campo | Obrigatório | Descrição |
|-------|-------------|-----------|
| `workOrderType` | Sim | Enum `WorkOrderType` |
| `siteCode` | Sim | Código do site |
| `woNumber` | Não | Se omitido, auto-gera sequencial normalizado 11 dígitos |
| `scheduleWeekOnly` | Não | Se `true`, `targetWeek` é obrigatório |
| `vendorId` | Condicional | Obrigatório quando `apptDate` ou `apptTime` informados |
**Responses:**
| HTTP | Body | Quando |
|------|------|--------|
| 200 | `WorkOrderBoardRowDto` | Sucesso — row pronto para inserir no board |
| 400 | `Response` | Validação FluentValidation |
| 409 | `WorkOrderBoardValidationErrorDto` | WO# duplicado |
| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio |
**Exemplo (inline mínimo):**
```bash
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"workOrderType":"PM","siteCode":"BK5"}' \
"https://localhost:5001/api/workorders/board"
```
---
### `POST /api/workorders/{id}/cancel`
Soft cancel — transição para `LifecycleStatus.Canceled` com WO read-only.
**Autenticação:** Bearer JWT
**Responses:**
| HTTP | Body | Quando |
|------|------|--------|
| 200 | `WorkOrderBoardRowDto` | Cancelado (ou já estava cancelado — idempotente) |
| 422 | `WorkOrderBoardValidationErrorDto` | WO `Complete` ou `Closed` |
**Exemplo:**
```bash
curl -X POST -H "Authorization: Bearer <token>" \
"https://localhost:5001/api/workorders/42/cancel"
```
---
## Regras de domínio na criação
| Regra | Comportamento |
|-------|---------------|
| Status inicial | `Incomplete` |
| Auto-schedule | `scheduledDate` + `assignTo` → `Scheduled` |
| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` |
| Week-only | `scheduleWeekOnly=true` + `targetWeek` → aparece na semana no GET board |
| WO# | Manual normalizado 11 dígitos; auto-gerado se omitido |
| Vendor | Cria dispatch primário quando `vendorId` informado |
| ManualEditFlag | Field locks criados para cada campo SHOC preenchido |
---
## Soft cancel vs hard delete
| Operação | Endpoint | Quem | Efeito |
|----------|----------|------|--------|
| Soft cancel | `POST /{id}/cancel` | Dispatcher | Status `Canceled`, WO permanece, PATCH bloqueado (422) |
| Hard delete | `DELETE DeleteWorkorder` | **Admin only** | Remove registro, anexos e contatos |
O legado `POST AddWorkorder` (form/Blazor) permanece inalterado.
---
## ManualEditFlag na criação
Campos preenchidos na criação SHOC recebem lock em `WorkOrderFieldLocks` via audit `FieldChanged`. Sync subsequente em campo lockado gera `SyncRejected` (ver [manual-edit-flag-design.md](../phase-0/manual-edit-flag-design.md)).
WO criada via Sync/Lambda continua sem locks até edição SHOC.
---
## Audit
| Evento | Action |
|--------|--------|
| Criação | 1× `Create` + `FieldChanged` por campo preenchido |
| Auto-schedule na criação | `StatusChanged` adicional |
| Cancel | `StatusChanged` → `Canceled` |
| Hard delete Admin | `Delete` |
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
| Validação | `SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs` |
| Scheduling compartilhado | `SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs` |
| Create service | `SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs` |
| Cancel service | `SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs` |
| Audit | `IWorkOrderAuditService.StageCreated`, `LogCreatedAsync` |
| API | `WorkOrderController` — `POST board`, `POST {id}/cancel`, `DELETE` Admin |
| Testes | `WorkOrderBoardCreateServiceTests`, `WorkOrderBoardCancelServiceTests`, `WorkOrderBoardCreateSyncLockTests` |
---
## Gates de aceite
### Desenvolvimento
- [x] `POST /api/workorders/board` — Incomplete / auto-schedule / week-only
- [x] WO# auto ou manual 11 dígitos + unicidade (409)
- [x] Vendor/dispatch primário na criação
- [x] Field locks + SyncRejected pós-create SHOC
- [x] `POST /{id}/cancel` idempotente; PATCH bloqueado após cancel
- [x] `DELETE DeleteWorkorder` restrito a Admin
- [x] Legado `AddWorkorder` inalterado
- [x] 68 testes unitários total (17 Fase 3 + 51 Fases 0–2)
### Staging (pendente)
- [ ] UAT dispatcher: wizard + inline row
- [ ] Smoke Sync durante criação SHOC
- [ ] Confirmar navegação FE para semana do WO criado
---
## Próximo passo
**Fase 4** — Busca contextual na semana + advanced search cross-week conforme tier volume.

View file

@ -1,162 +0,0 @@
# Fase 4 — Search contextual + Advanced Search
**Programa:** Work Orders Board
**Objetivo:** Hardening da busca contextual no board semanal, endpoint de advanced search cross-week (Tier S), índices de suporte e harness de load test.
**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md), [Fase 3](../phase-3/README.md)
**Tier vigente (dev):** **S (provisório)** — ver [search-tier-decision.md](./search-tier-decision.md)
---
## Endpoints
### `GET /api/workorders/board?search=` (hardening)
Busca contextual na janela semanal. Campos cobertos:
| Campo | Origem |
|-------|--------|
| Site | `SiteCode` |
| WO# | `InternalWONumber`, `WorkerOrderNumber` |
| Location | `Locations.Name` |
| Dispatcher | `AssignToUser` nome |
| PM | `Trade`, `Problem` |
| Vendor | `PrimaryDispatch.Vendor.CompanyName` |
| Tech | `PrimaryDispatch.Vendor.ContactName` |
| Status legado | `Status` |
| Lifecycle | label enum (`Scheduled`, `In Progress`, etc.) |
**Regras:**
- `search` com trim; ignorado se vazio ou &lt; 2 caracteres
- `counts.total` — agendadas na semana **sem** search
- `counts.returned` — agendadas **com** search
### `GET /api/workorders/board/search`
Advanced search cross-week paginado.
**Autenticação:** Bearer JWT
**Query params:**
| Param | Tipo | Descrição |
|-------|------|-----------|
| `search` | string | Texto livre (mesmos campos do contextual) |
| `datePreset` | enum | `thisWeek`, `lastWeek`, `thisMonth`, `last3Months`, `nextWeek`, `nextMonth`, `custom` |
| `dateFrom` / `dateTo` | date | Obrigatórios se `datePreset=custom` |
| `sites` | string[] | `SiteCode` |
| `types` | WorkOrderType[] | Tipo WO |
| `dispatchers` | string[] | GUIDs + `__unassigned__` |
| `statuses` | LifecycleStatus[] | Status operacional |
| `pmTypes` | string[] | Match em `Trade`/`Problem` (contains, case-insensitive) |
| `vendorIds` | int[] | Via `PrimaryDispatch.VendorId` |
| `docStatuses` | DocStatus[] | Enum `DocStatus` |
| `myWorkOrders` | bool | Filtro usuário logado |
| `page` | int | Default 1 |
| `pageSize` | int | Default 50; max 100 (Tier S/M) |
| `sortBy` | string | `scheduledDate` (default), `woNumber`, `dueDate` |
| `sortDir` | string | `asc` / `desc` |
**Response:** `PagedResult<WorkOrderBoardRowDto>`
```json
{
"items": [ /* WorkOrderBoardRowDto */ ],
"totalCount": 120,
"page": 1,
"pageSize": 50,
"totalPages": 3,
"hasNext": true,
"hasPrevious": false
}
```
**Exemplo:**
```bash
curl -H "Authorization: Bearer <token>" \
"https://localhost:5001/api/workorders/board/search?datePreset=thisMonth&sites=BK5&search=HVAC&page=1"
```
---
## Matriz preset → intervalo de datas
Base: **segunda-feira ISO** (`WorkOrderSearchDateRangeResolver`).
| Preset | Intervalo |
|--------|-----------|
| `thisWeek` | Seg–Dom da semana ISO corrente |
| `lastWeek` | Seg–Dom da semana ISO anterior |
| `nextWeek` | Seg–Dom da próxima semana ISO |
| `thisMonth` | 1º–último dia do mês corrente |
| `nextMonth` | 1º–último dia do mês seguinte |
| `last3Months` | Hoje − 3 meses → hoje |
| `custom` | `dateFrom` / `dateTo` (400 se ausentes) |
**Filtro de data:** `ScheduledDate` no intervalo **OU** `ScheduleWeekOnly && TargetWeek` intersectando o intervalo. Exclui templates e `IsDeleted`.
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| Search filter | `SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs` |
| Query filters | `SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs` |
| Projeção | `SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs` |
| Date presets | `SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs` |
| Advanced data | `SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs` |
| Advanced service | `SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs` |
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
| API | `WorkOrderController` — `GET board/search` |
| Migration | `20260624200000_Phase4_SearchIndexes.cs` |
| Testes | `SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs` |
| Load test | `scripts/load-test/work-order-search.k6.js` |
| Seed | `scripts/seed-work-orders-search.ps1` |
---
## Gates de aceite
### Dev (implementação)
- [x] Helper de search compartilhado + testes por campo
- [x] `GET /board/search` paginado com filtros FE
- [x] Date presets alinhados ao SHOC (ISO Monday)
- [x] Migration índices Phase 4
- [x] Harness k6 + seed sintético (Tier S local)
- [x] Endpoints legados inalterados
- [x] ~12 testes unitários novos (total ~80)
### Staging/prod (GO — pendente)
- [ ] Volume Discovery preenchido — [volume-discovery-report.md](../phase-0/volume-discovery-report.md)
- [ ] Tier assinado — [search-tier-decision.md](./search-tier-decision.md)
- [ ] Load test em staging com volume real
- [ ] Latência dentro do SLO do tier confirmado
- [ ] Smoke Portal/Blazor inalterados
---
## SLOs Tier S (load test local)
| Cenário | Endpoint | p95 |
|---------|----------|-----|
| Board sem search | `GET /board?weekStart=...` | &lt; 800ms |
| Board com search | `GET /board?search=BK5` | &lt; 1000ms |
| Advanced preset | `GET /board/search?datePreset=thisMonth` | &lt; 1200ms |
| Advanced multi-filter | sites + types + search | &lt; 1500ms |
Ver [scripts/load-test/README.md](../../scripts/load-test/README.md).
---
## Fora de escopo
- Full-text search (Tier L)
- Search externo dedicado (Tier XL)
- Lookups PM catalog (`PM_TYPES`) — backlog #17
- Alterações em endpoints legados

View file

@ -1,51 +0,0 @@
# Search Tier Decision — Fase 4
**Data:** 2026-06-24
**Status:** Provisório (dev) — aguardando Volume Discovery staging/prod
---
## Tier confirmado
| Ambiente | Tier | Total WOs | Decisão |
|----------|------|-----------|---------|
| Dev local | **S (provisório)** | &lt; 25k sintético | Implementar SQL filtros + paginação opcional |
| Staging | _pendente_ | _pendente acesso_ | Executar [volume-discovery-report.md](../phase-0/volume-discovery-report.md) |
| Produção | _pendente_ | _pendente acesso_ | Executar queries read-only |
---
## Estratégia por tier
| Tier | Volume | Estratégia advanced search |
|------|--------|----------------------------|
| **S** | &lt; 25k | SQL filtros compostos + paginação default 50, max 100 |
| **M** | 25k–250k | Paginação **obrigatória** + índices covering |
| **L** | 250k–1M | Spike full-text SQL Server — **não implementar sem spike** |
| **XL** | &gt; 1M | Documentar ARB; search externo — **não implementar** |
---
## Implementação atual (Tier S)
- `WorkOrderBoardSearchFilter` — filtro textual compartilhado (board + advanced)
- `WorkOrderAdvancedSearchDataService` — query composta com `CountAsync` + `Skip/Take`
- Índices Phase 4: `SiteCode`, `InternalWONumber`, `LifecycleStatus+ScheduledDate`
- Min 2 caracteres em `search` para evitar full-table scan
---
## Próximos passos (gate staging)
1. Executar `scripts/volume-discovery.ps1` contra staging read-only
2. Preencher tabela §3 do volume report
3. Atualizar este documento com tier real
4. Repetir load test k6 em staging; ajustar `pageSize` max se tier = M
---
## Referências
- [volume-discovery-report.md](../phase-0/volume-discovery-report.md)
- [phase-4/README.md](./README.md)
- Roadmap §10, §12 Fase 4

View file

@ -1,119 +0,0 @@
# Fase 5 — Scheduled Domain Events
**Programa:** Work Orders Board
**Objetivo:** Jobs agendados in-process (`IHostedService`) para `WeekRolled` → `CarriedOver++` com idempotência WO+semana, audit System, cache opcional de `PastDue`, e garantia de que leituras do board continuam on-read como fonte da verdade.
**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md), [Fase 3](../phase-3/README.md), [Fase 4](../phase-4/README.md)
---
## Regra de elegibilidade WeekRolled
```text
sourceWeek = semana operacional encerrada (segunda a sexta UTC)
Elegível se:
- ScheduledDate.Date ∈ [sourceWeekStart, sourceWeekEnd]
- LifecycleStatus ∉ {Complete, Canceled, Closed}
- istemplate != true (ou null)
- Ainda não processado em WorkOrderWeekRolledLedger (WorkOrderId + SourceWeekStart)
```
**Gate PO (pendente):** confirmar se WOs só com `TargetWeek` (sem `ScheduledDate`) entram no carry-over. A implementação atual considera **apenas** WOs com `ScheduledDate` na janela.
---
## Idempotência
Chave composta `(WorkOrderId, SourceWeekStart)` na tabela `WorkOrderWeekRolledLedger`. Re-execução do job ou endpoint admin na mesma semana não duplica `CarriedOver`.
---
## Contrato de audit
Um evento `WeekRolled` por WO processado:
| Campo | Valor |
|-------|-------|
| `EventType` | `System` |
| `ActorType` | `System` |
| `Action` | `WeekRolled` |
| `FieldName` | `CarriedOver` |
| `OldValue` / `NewValue` | numéricos (string) |
| `CorrelationId` | `week:{yyyy-MM-dd}` (segunda da semana fonte) |
Não aciona `ManualEditFlag` nem field locks.
---
## Regra de ouro — isPastDue
Jobs **nunca** são fonte da verdade para `isPastDue`. O board continua calculando via `WorkOrderDerivedFields.IsPastDue` on-read. O cache `OperationalFlags.PastDue` é otimização opcional (`WorkOrderJobs:PastDueCache:Enabled`, default **false**).
---
## Configuração
```json
"WorkOrderJobs": {
"WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" },
"PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" }
}
```
Variáveis de ambiente (`.env.example`):
```text
WorkOrderJobs__WeekRolled__Enabled=true
WorkOrderJobs__PastDueCache__Enabled=false
```
---
## Endpoints admin (ops-only)
| Endpoint | Auth | Descrição |
|----------|------|-----------|
| `POST /api/workorders/jobs/week-rolled?sourceWeekStart=2026-06-16` | Admin | Reprocessa semana (segunda-feira). Idempotente. |
| `POST /api/workorders/jobs/past-due-cache` | Admin | Atualiza cache `OperationalFlags.PastDue` |
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| Ledger | `Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs` |
| Migration | `20260624210000_Phase5_DomainEvents.cs` |
| Data | `SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs` |
| WeekRolled | `SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs` |
| PastDue cache | `SeaHaven.Services/Implementation/PastDueCacheService.cs` |
| Hosted | `Api.SeaHavenIndustries/HostedServices/*.cs` |
| API | `WorkOrderJobsController` |
| Testes | `SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs` |
---
## Monitoramento
Logs estruturados com `CorrelationId` da semana. Ao concluir: `processed`, `skipped`, `failed`, `durationMs`.
**Alerta ops:** se `failed > 0` ou job não executou em 8 dias → investigar + `POST /jobs/week-rolled` manual.
---
## Critérios de aceite
- [x] Job semanal incrementa `carriedOver` para WOs elegíveis da semana anterior
- [x] Re-execução na mesma semana não duplica (ledger WO+semana)
- [x] Audit `WeekRolled` System com old/new `CarriedOver` e `CorrelationId`
- [x] `GET /api/workorders/board` continua com `isPastDue` derivado on-read
- [x] Endpoint admin permite reprocessar semana específica
- [x] Testes cobrem idempotência, terminal skip, audit e on-read correctness
---
## Fora de escopo
- Message queue / worker externo
- Alteração do contrato REST do board para usar `OperationalFlags`
- WOs week-only (`TargetWeek` sem `ScheduledDate`) — gate PO

View file

@ -1,159 +0,0 @@
# Fase 6 — Completion Doc e Slide-over
**Programa:** Work Orders Board
**Objetivo:** Contrato SHOC para slide-over (`WOSlideOver`): detalhe unificado, `docStatus` WO-level com templates por serviço, e tabs Comments/Audit/Media alinhadas ao frontend.
**Depende de:** [Fase 0](../phase-0/README.md) … [Fase 5](../phase-5/README.md)
---
## Endpoints
### `GET /api/workorders/{id}/detail`
Payload único para abrir o slide-over.
**Autenticação:** Bearer JWT (`[Authorize]`)
**Response:**
```json
{
"info": { /* WorkOrderDetailInfoDto — board row + description/trade/original* */ },
"completion": {
"docStatus": "No",
"template": { "id": 1, "name": "HVAC PM Completion", "serviceKey": "HVAC PM", "templateUrl": "..." },
"signOffName": null,
"signOffAttachment": null,
"signOffSignature": null,
"dispatchSignoffs": []
},
"comments": [{ "id": 1, "authorId": "guid", "text": "...", "time": "2026-06-01T12:00:00.0000000Z" }],
"audit": [{ "type": "manual", "dispatcherId": "guid", "action": "FieldChanged", "fieldName": "DocStatus", "oldValue": "No", "newValue": "Yes", "time": "..." }],
"media": [{ "id": 5, "category": "Extra", "url": "...", "isLegacy": false }]
}
```
```bash
curl -H "Authorization: Bearer <token>" \
"https://localhost:5001/api/workorders/42/detail"
```
---
### `GET /api/workorders/{id}/audit?limit=50`
Audit tab lazy refresh. Schema FE: `{ type, dispatcherId?, action, fieldName?, oldValue, newValue, time, dispatchId? }`.
---
### `GET /api/workorders/{id}/comments`
### `POST /api/workorders/{id}/comments`
```json
{ "text": "Called vendor" }
```
Response: `{ id, authorId, text, time, documents? }`.
---
### Completion templates
| Método | Rota | Auth | Descrição |
|--------|------|------|-----------|
| GET | `/api/workorders/completion-templates` | JWT | Query `serviceKey`, `workOrderType` |
| GET | `/api/workorders/completion-templates/{id}` | JWT | Detalhe |
| POST | `/api/workorders/completion-templates` | Admin | Criar template |
| PUT | `/api/workorders/completion-templates/{id}` | Admin | Atualizar |
| DELETE | `/api/workorders/completion-templates/{id}` | Admin | Soft delete |
Lookup na WO: `Trade` → fallback `WorkOrderType`.
---
### `POST /api/workorders/{id}/completion-doc`
Upload PDF preenchido (multipart).
| Campo form | Obrigatório |
|------------|-------------|
| `file` | Sim |
| `signOffName` | Não |
| `signOffSignature` | Não |
| `workOrderVersion` | Recomendado (Base64 RowVersion) |
**Regras:**
- WO read-only (`Canceled`/`Complete`/`Closed`) → 422
- Sucesso → `SignOffAttachment` + `DocStatus=Yes` + audit `FieldChanged`
- Dispatch signoffs → somente leitura no slide-over
```bash
curl -X POST -H "Authorization: Bearer <token>" \
-F "file=@completion.pdf" \
-F "signOffName=Jane Doe" \
"https://localhost:5001/api/workorders/42/completion-doc"
```
**Inline toggle:** `PATCH /api/workorders/{id}/board` com `field=docStatus` (Fase 2).
---
### Media
| Método | Rota | Descrição |
|--------|------|-----------|
| GET | `/api/workorders/{id}/media` | Lista unificada com `category` |
| POST | `/api/workorders/{id}/media` | multipart: `category` (Before/After/Extra/Completion) + `file` |
| DELETE | `/api/workorders/{id}/media/{mediaId}` | Soft delete (somente attachments com `id > 0`) |
Legacy columns (`BeforPhotoAttachment`, `AfterPhotoAttachment`, `SignOffAttachment`) aparecem na projeção com `isLegacy: true`.
---
## Schema
Migration `Phase6_CompletionSlideOver`:
- Tabela `CompletionDocTemplates`
- Coluna `Category` em `workOrderAttachments`
- Backfill SQL: `SignOffAttachment` preenchido → `DocStatus=Yes`
Script ops: [`scripts/backfill-docstatus.ps1`](../../scripts/backfill-docstatus.ps1)
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| Model | `CompletionDocTemplate.cs`, `WorkOrderMediaCategory` enum |
| Migration | `20260625120000_Phase6_CompletionSlideOver.cs` |
| Data | `WorkOrderDetailDataService`, `CompletionDocTemplateDataService` |
| Services | `WorkOrderDetailService`, `WorkOrderCommentService`, `WorkOrderCompletionService`, `WorkOrderMediaService` |
| DTOs | `WorkOrderDetailDTOs.cs` |
| Helpers | `WorkOrderAuditProjection`, `WorkOrderCommentProjection`, `WorkOrderMediaProjection` |
| API | `WorkOrderController` — rotas `/detail`, `/comments`, `/audit`, `/completion-doc`, `/media`, `/completion-templates` |
| Testes | `WorkOrderPhase6Tests.cs` |
---
## Critérios de aceite
- [x] `GET /detail` retorna Info + Completion + Comments + Audit + Media no contrato FE
- [x] Coluna COMP DOC do board via `docStatus` no `WorkOrderBoardRowDto` (Fase 1)
- [x] `CompletionDocTemplate` CRUD admin + lookup por `serviceKey`/`workOrderType`
- [x] `POST completion-doc` persiste attachment, define `DocStatus=Yes`, audit
- [x] Comments/Audit tabs sem adapter no FE
- [x] Media com `category`; legado mapeado
- [x] Endpoints legados (`GetWorkorderById`, `GetCommentsByWorkorderId`) inalterados
- [x] Testes: DocStatus PATCH, detail, completion upload, comments, media
---
## Fora de escopo
- Geração de PDF server-side
- Alteração Vendor Portal checklist/signoff
- Rollout / sunset Blazor (Fase 7)

View file

@ -1,245 +0,0 @@
# Fase 7 — Rollout e Produção
**Programa:** Work Orders Board
**Objetivo:** Piloto SHOC, rollout gradual, monitoramento de coexistência, sunset Blazor WO, cutover Lambda→API e deprecação legado.
**Depende de:** [Fase 0](../phase-0/README.md) … [Fase 6](../phase-6/README.md) concluídas e validadas em staging.
---
## Gates
- **Entrada:** [phase-7-gates-signoff.md](phase-7-gates-signoff.md)
- **Saída:** piloto UAT, 100% SHOC, zero P1 × 2 semanas, Blazor sunset, Lambda cutover, ops health ativo
---
## Runbook de rollout (ordem obrigatória)
1. Fechar gates de entrada Fase 7
2. Deploy API com ops health + ingest (ingest desabilitado em prod inicialmente)
3. SHOC: feature flag board em staging → piloto 1–2 dispatchers em prod
4. UAT dispatchers ([roteiros abaixo](#uat-dispatchers))
5. Rollout gradual SHOC até 100%
6. Habilitar dual-run Lambda (`ingest` + Dynamo)
7. Validar drift → cutover Lambda só API
8. `Sync:Enabled=false`
9. `BlazorWorkOrderSunset:Enabled=true`
10. `LegacyEndpoints:DeprecationEnabled=true` + data Sunset
11. Remover Sync/legado após período de aviso (30 dias)
---
## Endpoints Fase 7
### `GET /api/workorders/ops/health`
Saúde operacional para coexistência multi-consumidor.
**Auth:** Admin JWT
```json
{
"lastWeekRolledRunUtc": "2026-06-23T00:05:12Z",
"lastPastDueCacheRunUtc": null,
"lastWeekRolledError": null,
"lastPastDueCacheError": null,
"syncRejectedLast24h": 3,
"fieldLockCount": 142,
"syncEnabled": true,
"ingestEnabled": true,
"legacyDeprecationEnabled": false,
"legacySunsetDate": null,
"checkedAtUtc": "2026-06-25T12:00:00Z"
}
```
Ver [cloudwatch-alerts.md](cloudwatch-alerts.md).
---
### `POST /api/workorders/ingest`
Ingest direto idempotente (substitui ponte DynamoDB para WOs).
**Auth:** header `X-Ingest-Key` (config `WorkOrderIngest:ApiKey`)
**Body (single ou batch):**
```json
{
"externalWorkOrderId": "ext-wo-12345",
"description": "HVAC unit not cooling",
"woStatus": "new",
"severity": "2",
"customer": "Acme Corp",
"siteCode": "BK5",
"building": "Building A",
"address": "123 Main St",
"dueDate": "2026-07-01T00:00:00Z",
"dateReported": "2026-06-20T10:00:00Z",
"scheduledStart": null,
"sourceEmailS3Key": null,
"createdAt": "2026-06-20T10:00:00Z"
}
```
**Batch:**
```json
{
"items": [ { "externalWorkOrderId": "...", "description": "..." } ]
}
```
**Response:**
```json
{
"created": 1,
"updated": 0,
"results": [{ "externalWorkOrderId": "ext-wo-12345", "workOrderId": 42, "created": true }]
}
```
Mapeamento completo: [lambda-ingest-discovery.md](lambda-ingest-discovery.md)
---
## Matriz de endpoints
| Tipo | Exemplos | Política Fase 7 |
|------|----------|-----------------|
| Board (SHOC) | `GET board`, `PATCH {id}/board`, `POST board` | **Produção** |
| Slide-over | `GET {id}/detail`, completion-doc, media | **Produção** |
| Ingest | `POST ingest` | Dual-run → produção |
| Sync | `POST api/Sync/WorkOrders` | Desligar após cutover (`Sync:Enabled`) |
| Legado | `GetWorkOrderList`, `AddWorkorder`, `ChangeStatus` | Deprecation headers |
| Jobs | `POST jobs/week-rolled` | Ops Admin |
---
## Configuração
### API (`Api.SeaHavenIndustries`)
```json
{
"FrontendBaseUrl": "https://shoc.seahaven.com",
"WorkOrderIngest": {
"Enabled": true,
"ApiKey": "${WORKORDER_INGEST_API_KEY}"
},
"Sync": {
"Enabled": true
},
"LegacyEndpoints": {
"DeprecationEnabled": false,
"SunsetDate": "2026-12-31"
}
}
```
Env vars: ver [.env.example](../../../.env.example)
### Blazor (`SeaHavenIndustries`)
```json
{
"BlazorWorkOrderSunset": {
"Enabled": false,
"ShocBaseUrl": "https://shoc.seahaven.com"
}
}
```
Quando `Enabled=true`: nav Work Orders aponta para SHOC; mutações EF bloqueadas; banner nas páginas WO.
---
## UAT dispatchers
Roteiro manual — marcar cada item antes de expandir rollout.
| # | Cenário | Pass |
|---|---------|------|
| 1 | Login SHOC; carregar semana (Mon–Fri + Unscheduled) | [ ] |
| 2 | Filtros: dispatcher, My WOs, tipo, busca contextual | [ ] |
| 3 | Inline edit célula (status, assignee, due) | [ ] |
| 4 | Conflito 409 — outro usuário editou; mensagem clara | [ ] |
| 5 | Criar WO wizard (Incomplete → Scheduled auto) | [ ] |
| 6 | Cancel soft; campos read-only pós-cancel | [ ] |
| 7 | Advanced search cross-week | [ ] |
| 8 | Slide-over: detail, comments, audit, media | [ ] |
| 9 | Upload completion doc; docStatus no board | [ ] |
| 10 | Badge PastDue / CarriedOver após segunda-feira | [ ] |
**Sign-off UAT**
| Dispatcher | Data | PO |
|------------|------|-----|
| | | |
---
## Piloto e rollout SHOC
Controle **somente no frontend** (feature flags). Backend não filtra dispatchers.
Critérios para avançar:
- Zero P1 na semana do piloto
- `GET board` latência aceitável (Tier S)
- Vendor Portal regression verde
---
## Sunset Blazor
Ver [data-ownership-model.md](../phase-0/data-ownership-model.md) §3.
1. Congelar `WorkorderService` (bugfix only) até data PO
2. Habilitar `BlazorWorkOrderSunset:Enabled`
3. Smoke final [smoke-checklist.md](../phase-0/smoke-checklist.md) Blazor EF
---
## Cutover Lambda
Ver [lambda-ingest-discovery.md](lambda-ingest-discovery.md).
---
## Testes automatizados
```powershell
dotnet test SeaHavenIndustries.Tests --filter "FullyQualifiedName~WorkOrderPhase7"
```
Suite: `WorkOrderPhase7CoexistenceTests.cs`
---
## Critérios de aceite Fase 7
- [x] `GET /api/workorders/ops/health`
- [x] `POST /api/workorders/ingest` com API key
- [x] `Sync:Enabled` feature flag
- [x] Legacy deprecation middleware
- [x] Blazor sunset config + guard mutações
- [x] Documentação gates, UAT, Lambda mapping
- [ ] Piloto prod (ops/PO)
- [ ] 100% dispatchers SHOC (ops/PO)
- [ ] Zero P1 × 2 semanas (ops/PO)
- [ ] Lambda cutover executado (ops)
---
## Comunicação
| Audiência | Mensagem | Quando |
|-----------|----------|--------|
| Dispatchers piloto | Novo board SHOC; suporte dedicado | Início piloto |
| Todos dispatchers | Rollout gradual; treinamento | Durante rollout |
| Usuários Blazor | WO migrou para SHOC; link direto | Sunset Blazor |
| Engenharia | Sync desligado; usar ingest API | Pós-cutover |

View file

@ -1,40 +0,0 @@
# Alertas Operacionais — Fase 7
**Endpoint de saúde:** `GET /api/workorders/ops/health` (Admin JWT)
---
## Métricas expostas
| Campo | Fonte | Uso |
|-------|-------|-----|
| `lastWeekRolledRunUtc` | `WorkOrderJobRunState` + ledger | Job segunda-feira |
| `lastPastDueCacheRunUtc` | `WorkOrderJobRunState` | Cache opcional PastDue |
| `syncRejectedLast24h` | `WorkOrderAuditLogs` Action=SyncRejected | Conflito SHOC vs ingest |
| `fieldLockCount` | `WorkOrderFieldLocks` | Adoção edição manual |
| `syncEnabled` | `Sync:Enabled` | Estado ponte Dynamo |
| `ingestEnabled` | `WorkOrderIngest:Enabled` | Ingest direto ativo |
---
## Alertas recomendados (Elastic Beanstalk / CloudWatch)
| Alerta | Condição | Severidade |
|--------|----------|------------|
| WeekRolled stale | `lastWeekRolledRunUtc` > 8 dias | P1 |
| WeekRolled job error | `lastWeekRolledError` não nulo | P1 |
| SyncRejected spike | `syncRejectedLast24h` > 50 | P2 |
| API 5xx board | ALB/Beanstalk 5xx rate > 1% em `/api/workorders/board` | P1 |
| Ingest auth failures | 401 em `/api/workorders/ingest` > 10/h | P2 |
| Sync disabled em prod sem cutover | `syncEnabled=false` e ingest não validado | P2 |
---
## Verificação manual (ops)
```powershell
# Com token Admin
curl -H "Authorization: Bearer <token>" https://<api-host>/api/workorders/ops/health
```
Agendar checagem diária durante piloto e rollout.

View file

@ -1,93 +0,0 @@
# Lambda Ingest — Descoberta e Mapeamento de Campos
**Fase:** 7
**Status:** Documentação de cutover (código Lambda fora deste repositório)
---
## 1. Localização da Lambda
| Item | Valor |
|------|-------|
| Nome referenciado | `workorder-ingest` |
| Referência código | [TODO.md](../../../TODO.md) L50–51 |
| Fluxo atual | Lambda → DynamoDB (`WorkOrders`) → `POST api/Sync/WorkOrders` |
| Fluxo alvo | Lambda → `POST api/workorders/ingest` (API key) |
**Ação pendente ops:** localizar repositório/infra AWS (SAM, Terraform, console Lambda) e preencher owner na [phase-7-gates-signoff.md](phase-7-gates-signoff.md).
---
## 2. Tabelas DynamoDB (SyncController)
| Tabela | Endpoint sync | Uso |
|--------|---------------|-----|
| `WorkOrders` | `POST api/Sync/WorkOrders` | Upsert WO por `work_order_id` |
| `WorkOrderComments` | `POST api/Sync/Comments` | Comentários cliente |
| `VendorReplies` | `POST api/Sync/VendorReplies` | Respostas vendor |
Cutover Fase 7 foca em **WorkOrders**; comments/replies permanecem no Sync até migração separada.
---
## 3. Mapeamento DynamoDB → API ingest
Campos lidos em [SyncController.cs](../../../Api.SeaHavenIndustries/Controllers/SyncController.cs) e espelhados em `WorkOrderIngestPayload`:
| Campo Dynamo | Campo API ingest | Campo SQL | Merge policy (update) |
|--------------|------------------|-----------|------------------------|
| `work_order_id` | `externalWorkOrderId` | `ExternalWorkOrderId` | Chave idempotente |
| `description` | `description` | `Description`, `WorkerOrderTitle` | Sim se não locked |
| `wo_status` | `woStatus` | `Status` (mapeado) | Sim se não locked |
| `severity` | `severity` | `Priority`, `Severity` | Sim se não locked |
| `customer` | `customer` | `Customer` | Direto na criação |
| `site_code` | `siteCode` | `SiteCode` | Sim se não locked |
| `building` | `building` | `Building` | Sim se não locked |
| `address` | `address` | `Locations` (resolve/create) | LocationId |
| `due_date` | `dueDate` | `DueDate` | Sim se não locked |
| `date_reported` | `dateReported` | `DateReported` | Direto |
| `scheduled_start` | `scheduledStart` | `ScheduledStart` | Direto |
| `source_email_s3_key` | `sourceEmailS3Key` | `SourceEmailS3Key` | Direto |
| `created_at` | `createdAt` | `CreatedDate` | Criação only |
### Mapeamento `wo_status` → SQL `Status`
| Dynamo | SQL |
|--------|-----|
| `new`, `assigned`, `unknown` | `Open` |
| `in_progress` | `In Progress` |
| `on_hold` | `On Hold` |
| `completed` | `Done` |
| `cancelled` | `Cancelled` |
### Mapeamento `severity` → `Priority`
`Sev {severity}` (ex.: `3` → `Sev 3`)
---
## 4. Auth serviço-a-serviço
| Header | Config |
|--------|--------|
| `X-Ingest-Key` | `WorkOrderIngest:ApiKey` (env `WorkOrderIngest__ApiKey`) |
Lambda deve enviar o header em cada `POST /api/workorders/ingest`. Não usar JWT de usuário dispatcher.
---
## 5. Estratégia dual-run
1. **Semana 1–2:** Lambda grava DynamoDB **e** chama API ingest
2. **Validação:** `GET /api/workorders/ops/health` + script amostra `ExternalWorkOrderId`
3. **Cutover:** Lambda só API; `Sync:Enabled=false`
4. **Retire:** backup DynamoDB → desativar tabelas
---
## 6. Critérios de cutover
- [ ] `POST /api/workorders/board` estável (criação SHOC)
- [ ] Auth ingest testada em staging
- [ ] Zero drift em amostra de 100 WOs
- [ ] Owner Lambda assinou runbook

View file

@ -1,55 +0,0 @@
# Phase 7 — Gates Sign-Off Checklist
**Programa:** Work Orders Board
**Fase:** 7 — Rollout e Produção
---
## Gate de entrada (GO Fase 7)
Bloqueia piloto em produção até todos os itens estarem verdes.
| # | Gate | Artefato / evidência | Status |
|---|------|----------------------|--------|
| 1 | Fases 0–6 validadas em staging | `docs/work-orders/phase-0` … `phase-6` READMEs | [ ] |
| 2 | Sign-off CTO/PO Fase 0 | [phase-0-gates-signoff.md](../phase-0/phase-0-gates-signoff.md) | [ ] |
| 3 | Dry-run migrations Phase0–Phase6 | [migration-dry-run-report.md](../phase-0/migration-dry-run-report.md) | [ ] |
| 4 | Smoke multi-consumidor | [smoke-checklist.md](../phase-0/smoke-checklist.md) | [ ] |
| 5 | Vendor Portal regression | Checklist dispatch checklist/signoff | [ ] |
| 6 | SHOC E2E staging | Contratos phase-1 … phase-6 integrados | [ ] |
| 7 | Tier volume confirmado | [volume-discovery-report.md](../phase-0/volume-discovery-report.md) | [ ] |
### Checklist PO ([consumer-inventory.md](../phase-0/consumer-inventory.md))
- [ ] App mobile externo confirmado (sim/não/N/A)
- [ ] Data sunset Blazor WO definida: _______________
- [ ] `FrontendBaseUrl` produção → SHOC configurado
- [ ] Owner Lambda cutover nomeado: _______________
**Assinatura GO Fase 7**
| Papel | Nome | Data |
|-------|------|------|
| CTO | | |
| PO | | |
---
## Gates de saída (DONE Fase 7)
| Gate | Critério |
|------|----------|
| Piloto | 1–2 dispatchers SHOC em prod; UAT assinado |
| Rollout | 100% dispatchers no board SHOC |
| Estabilidade | Zero P1 por 2 semanas consecutivas |
| Blazor | Módulo WO sunset (`BlazorWorkOrderSunset:Enabled`) |
| Lambda | Ingest direto `POST /api/workorders/ingest`; Dynamo/Sync desligados |
| Monitoramento | `GET /api/workorders/ops/health` + alertas documentados |
| Legado | Headers `Sunset`/`Deprecation` nos endpoints legado |
**Assinatura DONE Fase 7**
| Papel | Nome | Data |
|-------|------|------|
| CTO | | |
| PO | | |

View file

@ -1,23 +0,0 @@
# Backfill DocStatus from legacy SignOffAttachment (idempotent)
# Run against target SQL Server after Phase6_CompletionSlideOver migration.
param(
[string]$ConnectionString = $env:ConnectionStrings__DefaultConnection
)
if ([string]::IsNullOrWhiteSpace($ConnectionString)) {
Write-Error "Set ConnectionStrings__DefaultConnection or pass -ConnectionString"
exit 1
}
$sql = @"
UPDATE workOrders
SET DocStatus = 1
WHERE DocStatus IS NULL
AND SignOffAttachment IS NOT NULL
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
"@
Write-Host "Backfilling DocStatus=Yes where SignOffAttachment exists..."
Invoke-Sqlcmd -ConnectionString $ConnectionString -Query $sql
Write-Host "Done."

View file

@ -1,56 +0,0 @@
# Seed sintético de Work Orders para load test Tier S
#
# Uso:
# $env:SQL_CONNECTION_STRING = "Server=localhost;Database=SeaHaven;..."
# .\scripts\seed-work-orders-search.ps1 -Count 15000
param(
[int]$Count = 5000,
[string]$ConnectionString = $env:SQL_CONNECTION_STRING
)
if ([string]::IsNullOrWhiteSpace($ConnectionString)) {
Write-Error "Defina SQL_CONNECTION_STRING ou passe -ConnectionString."
exit 1
}
$sites = @("BK5", "BK6", "BK7", "BK8", "BK9", "LA1", "LA2", "NY1")
$trades = @("HVAC PM", "Plumbing PM", "Electrical PM", "Roof Inspection", "Fire Safety")
$baseDate = Get-Date "2026-01-06"
Write-Host "Inserindo $Count WOs sintéticas..." -ForegroundColor Cyan
$batchSize = 500
$inserted = 0
while ($inserted -lt $Count) {
$batch = [Math]::Min($batchSize, $Count - $inserted)
$values = @()
for ($i = 0; $i -lt $batch; $i++) {
$id = $inserted + $i + 1
$site = $sites[$id % $sites.Length]
$trade = $trades[$id % $trades.Length]
$weekOffset = $id % 52
$scheduled = $baseDate.AddDays($weekOffset * 7 + ($id % 5))
$woNumber = ("1{0:D10}" -f $id)
$values += "('$woNumber', '$site', '$trade', '$($scheduled.ToString('yyyy-MM-dd'))', 2, 0)"
}
$sql = @"
INSERT INTO workOrders (InternalWONumber, SiteCode, Trade, ScheduledDate, LifecycleStatus, istemplate, RescheduleCount, CarriedOver, CreatedDate)
VALUES $($values -join ',');
"@
sqlcmd -Q $sql -b
if ($LASTEXITCODE -ne 0) {
Write-Error "Falha no batch $inserted"
exit 1
}
$inserted += $batch
Write-Host " $inserted / $Count"
}
Write-Host "Seed concluído." -ForegroundColor Green

View file

@ -1,90 +0,0 @@
-- Spike Status Mapping (G2): seed legado + enum compacto + queries de análise
-- Executar APÓS dotnet ef database update
-- Uso: sqlcmd -S localhost,1433 -U sa -P "SeaHaven_Dev_2026!" -d SeahavenIndustries -C -i scripts/spike-status-mapping.sql
SET NOCOUNT ON;
-- ---------------------------------------------------------------------------
-- 1. Seed status spike (prefixo SPIKE-STATUS-)
-- Cobre formatos legado (com espaço) e canônico API (enum ToString)
-- ---------------------------------------------------------------------------
IF NOT EXISTS (SELECT 1 FROM workOrders WHERE InternalWONumber LIKE 'SPIKE-STATUS-%')
BEGIN
DECLARE @weekStart date = CAST(GETDATE() AS date);
INSERT INTO workOrders (
InternalWONumber, WorkerOrderTitle, SiteCode, Status, Priority,
ScheduledDate, DueDate, Trade, Problem, AssignTo, istemplate, CreatedDate
)
VALUES
-- Legado Blazor / Sync (com espaço)
('SPIKE-STATUS-001', 'Status spike - Open legado', 'SS1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()),
('SPIKE-STATUS-002', 'Status spike - In Progress legado','SS2', 'In Progress', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', NULL, 0, GETDATE()),
('SPIKE-STATUS-003', 'Status spike - On Hold legado', 'SS3', 'On Hold', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 10, @weekStart), 'Electrical', 'Hold', NULL, 0, GETDATE()),
('SPIKE-STATUS-004', 'Status spike - Done legado', 'SS4', 'Done', 'Low', DATEADD(day, 3, @weekStart), DATEADD(day, 4, @weekStart), 'General', 'Complete', NULL, 0, GETDATE()),
('SPIKE-STATUS-005', 'Status spike - Cancelled legado', 'SS5', 'Cancelled', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'HVAC', 'Cancelled', NULL, 0, GETDATE()),
('SPIKE-STATUS-006', 'Status spike - UnAssigned legado','SS6', 'UnAssigned', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 7, @weekStart), 'Plumbing', 'Unassigned', NULL, 0, GETDATE()),
-- Canônico API (enum ToString)
('SPIKE-STATUS-007', 'Status spike - Open canônico', 'SS7', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()),
('SPIKE-STATUS-008', 'Status spike - InProgress canônico','SS8', 'InProgress', 'High', DATEADD(day, 1, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', NULL, 0, GETDATE()),
('SPIKE-STATUS-009', 'Status spike - OnHold canônico', 'SS9', 'OnHold', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 10, @weekStart), 'Electrical', 'Hold', NULL, 0, GETDATE()),
('SPIKE-STATUS-010', 'Status spike - Completed canônico','SS10','Completed', 'Low', DATEADD(day, 3, @weekStart), DATEADD(day, 4, @weekStart), 'General', 'Complete', NULL, 0, GETDATE()),
('SPIKE-STATUS-011', 'Status spike - Cancelled canônico','SS11','Cancelled', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'HVAC', 'Cancelled', NULL, 0, GETDATE()),
-- Open sem assignee (derivado Unassigned no board)
('SPIKE-STATUS-012', 'Status spike - Open unassigned', 'SS12','Open', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 3, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()),
-- Past due candidato (scheduled no passado, status não terminal)
('SPIKE-STATUS-013', 'Status spike - past due candidate','SS13','In Progress', 'High', DATEADD(day, -3, @weekStart), DATEADD(day, -1, @weekStart), 'HVAC', 'Overdue test', NULL, 0, GETDATE());
PRINT 'Seed status spike: 13 work orders inseridos (prefixo SPIKE-STATUS-).';
END
ELSE
PRINT 'Seed status spike: já existente — pulando INSERT.';
GO
-- ---------------------------------------------------------------------------
-- 2. Distribuição Status (todos os WOs não-template)
-- ---------------------------------------------------------------------------
PRINT '--- Distribuição Status ---';
SELECT Status, COUNT(*) AS Cnt
FROM workOrders
WHERE istemplate IS NULL OR istemplate = 0
GROUP BY Status
ORDER BY Cnt DESC;
-- ---------------------------------------------------------------------------
-- 3. Status x assignee (detectar Unassigned implícito)
-- ---------------------------------------------------------------------------
PRINT '--- Status x AssignTo (Unassigned implícito) ---';
SELECT
Status,
CASE WHEN AssignTo IS NULL OR LTRIM(RTRIM(AssignTo)) = '' THEN 1 ELSE 0 END AS IsUnassigned,
COUNT(*) AS Cnt
FROM workOrders
WHERE istemplate IS NULL OR istemplate = 0
GROUP BY Status,
CASE WHEN AssignTo IS NULL OR LTRIM(RTRIM(AssignTo)) = '' THEN 1 ELSE 0 END
ORDER BY Status, IsUnassigned;
-- ---------------------------------------------------------------------------
-- 4. Órfãos — valores não mapeáveis pelo mapper G2 (ajustar lista conforme spike)
-- ---------------------------------------------------------------------------
PRINT '--- Status órfãos (fora do vocabulário conhecido) ---';
SELECT Status, COUNT(*) AS Cnt
FROM workOrders
WHERE (istemplate IS NULL OR istemplate = 0)
AND Status IS NOT NULL
AND LTRIM(RTRIM(Status)) NOT IN (
'Open', 'In Progress', 'On Hold', 'Done', 'Cancelled', 'UnAssigned', 'Unassigned',
'InProgress', 'OnHold', 'Completed',
'new', 'assigned', 'in_progress', 'on_hold', 'completed', 'cancelled', 'unknown'
)
GROUP BY Status
ORDER BY Cnt DESC;
-- ---------------------------------------------------------------------------
-- 5. Template query PRODUÇÃO (rodar manualmente em prod/staging)
-- ---------------------------------------------------------------------------
PRINT '--- Template query PRODUÇÃO ---';
-- SELECT Status, COUNT(*) AS Cnt FROM workOrders WHERE istemplate IS NULL OR istemplate = 0 GROUP BY Status ORDER BY Cnt DESC;
GO

View file

@ -1,96 +0,0 @@
-- Spike WorkOrderType: coluna legada + seed local + queries de análise
-- Executar APÓS dotnet ef database update
-- Uso: sqlcmd -S localhost,1433 -U sa -P "SeaHaven_Dev_2026!" -d SeahavenIndustries -C -i scripts/spike-work-order-type.sql
SET NOCOUNT ON;
-- ---------------------------------------------------------------------------
-- 1. Colunas legadas (existem em prod/db.txt, ausentes nas migrations EF)
-- ---------------------------------------------------------------------------
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL
ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL;
IF COL_LENGTH('workOrders', 'AvettaTask') IS NULL
ALTER TABLE workOrders ADD AvettaTask varchar(max) NULL;
IF COL_LENGTH('workOrders', 'AssignDate') IS NULL
ALTER TABLE workOrders ADD AssignDate date NULL;
GO
-- ---------------------------------------------------------------------------
-- 2. Seed spike (somente se ainda não existir prefixo SPIKE-WO-)
-- ---------------------------------------------------------------------------
IF NOT EXISTS (SELECT 1 FROM workOrders WHERE InternalWONumber LIKE 'SPIKE-WO-%')
BEGIN
DECLARE @weekStart date = CAST(GETDATE() AS date);
DECLARE @weekEnd date = DATEADD(day, 6, @weekStart);
INSERT INTO workOrders (
InternalWONumber, WorkerOrderTitle, SiteCode, Status, Priority,
ScheduledDate, DueDate, Trade, Problem, WorkOrderType, istemplate, CreatedDate
)
VALUES
-- NULL WorkOrderType (5)
('SPIKE-WO-001', 'Spike seed - null type A', 'BK5', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 3, @weekStart), 'HVAC', 'Cooling', NULL, 0, GETDATE()),
('SPIKE-WO-002', 'Spike seed - null type B', 'BK6', 'Open', 'Low', DATEADD(day, 1, @weekStart), DATEADD(day, 5, @weekStart), 'Plumbing', 'Leak', NULL, 0, GETDATE()),
('SPIKE-WO-003', 'Spike seed - null type C', 'BK7', 'InProgress', 'Medium', DATEADD(day, 2, @weekStart), DATEADD(day, 4, @weekStart), 'Electrical', 'Panel', NULL, 0, GETDATE()),
('SPIKE-WO-004', 'Spike seed - null type D', 'BK8', 'Open', 'High', DATEADD(day, 3, @weekStart), DATEADD(day, 7, @weekStart), 'HVAC', 'Filter', NULL, 0, GETDATE()),
('SPIKE-WO-005', 'Spike seed - null type E', 'BK9', 'OnHold', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 10, @weekStart), 'General', 'Inspection', NULL, 0, GETDATE()),
-- PM (5)
('SPIKE-WO-006', 'Spike seed - PM A', 'PM1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 14, @weekStart), 'HVAC', 'Preventive Maintenance', 'PM', 0, GETDATE()),
('SPIKE-WO-007', 'Spike seed - PM B', 'PM2', 'Open', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 14, @weekStart), 'Plumbing', 'PM Schedule', 'PM', 0, GETDATE()),
('SPIKE-WO-008', 'Spike seed - PM C', 'PM3', 'InProgress', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 21, @weekStart), 'Electrical', 'Quarterly PM', 'PM', 0, GETDATE()),
('SPIKE-WO-009', 'Spike seed - PM D', 'PM4', 'Open', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 30, @weekStart), 'HVAC', 'Annual PM', 'PM', 0, GETDATE()),
('SPIKE-WO-010', 'Spike seed - PM E', 'PM5', 'Open', 'Low', DATEADD(day, 6, @weekStart), DATEADD(day, 7, @weekStart), 'General', 'PM', 'PM', 0, GETDATE()),
-- Standard (4)
('SPIKE-WO-011', 'Spike seed - Standard A', 'ST1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', 'Standard', 0, GETDATE()),
('SPIKE-WO-012', 'Spike seed - Standard B', 'ST2', 'InProgress', 'High', DATEADD(day, 2, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', 'Standard', 0, GETDATE()),
('SPIKE-WO-013', 'Spike seed - Standard C', 'ST3', 'Open', 'Medium', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'Electrical', 'Repair', 'Standard', 0, GETDATE()),
('SPIKE-WO-014', 'Spike seed - Standard D', 'ST4', 'Completed', 'Low', DATEADD(day, 1, @weekStart), DATEADD(day, 2, @weekStart), 'General', 'Repair', 'Standard', 0, GETDATE()),
-- Add-On (3)
('SPIKE-WO-015', 'Spike seed - Add-On A', 'AO1', 'Open', 'Medium', DATEADD(day, 3, @weekStart), DATEADD(day, 9, @weekStart), 'HVAC', 'Add work', 'Add-On', 0, GETDATE()),
('SPIKE-WO-016', 'Spike seed - Add-On B', 'AO2', 'Open', 'High', DATEADD(day, 4, @weekStart), DATEADD(day, 10, @weekStart), 'Plumbing', 'Add work', 'Add-On', 0, GETDATE()),
('SPIKE-WO-017', 'Spike seed - Add-On C', 'AO3', 'InProgress', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 11, @weekStart), 'Electrical', 'Add work', 'Add-On', 0, GETDATE()),
-- Emergency + Corrective (3)
('SPIKE-WO-018', 'Spike seed - Emergency', 'EM1', 'Open', 'Critical', DATEADD(day, 0, @weekStart), DATEADD(day, 1, @weekStart), 'HVAC', 'Emergency', 'Emergency', 0, GETDATE()),
('SPIKE-WO-019', 'Spike seed - Corrective A', 'CR1', 'Open', 'High', DATEADD(day, 2, @weekStart), DATEADD(day, 4, @weekStart), 'Plumbing', 'Corrective', 'Corrective', 0, GETDATE()),
('SPIKE-WO-020', 'Spike seed - Corrective B', 'CR2', 'Open', 'Medium', DATEADD(day, 6, @weekStart), DATEADD(day, 8, @weekStart), 'General', 'Corrective', 'Corrective', 0, GETDATE());
PRINT 'Seed spike: 20 work orders inseridos (prefixo SPIKE-WO-).';
END
ELSE
PRINT 'Seed spike: já existente — pulando INSERT.';
GO
-- ---------------------------------------------------------------------------
-- 3. Queries de análise (entregável spike)
-- ---------------------------------------------------------------------------
PRINT '--- Distribuição WorkOrderType (seed + existentes) ---';
SELECT WorkOrderType, COUNT(*) AS Cnt
FROM workOrders
WHERE istemplate IS NULL OR istemplate = 0
GROUP BY WorkOrderType
ORDER BY Cnt DESC;
PRINT '--- % populado ---';
SELECT
COUNT(*) AS TotalRows,
SUM(CASE WHEN WorkOrderType IS NOT NULL AND LTRIM(RTRIM(WorkOrderType)) <> '' THEN 1 ELSE 0 END) AS Populated,
CAST(100.0 * SUM(CASE WHEN WorkOrderType IS NOT NULL AND LTRIM(RTRIM(WorkOrderType)) <> '' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0) AS decimal(5,2)) AS PopulatedPct
FROM workOrders
WHERE istemplate IS NULL OR istemplate = 0;
PRINT '--- Cruzamento Problem/Trade (hipótese derivação) ---';
SELECT WorkOrderType, Problem, Trade, COUNT(*) AS Cnt
FROM workOrders
WHERE (istemplate IS NULL OR istemplate = 0)
AND (InternalWONumber LIKE 'SPIKE-WO-%' OR WorkOrderType IS NOT NULL)
GROUP BY WorkOrderType, Problem, Trade
ORDER BY WorkOrderType, Cnt DESC;
PRINT '--- Template query PRODUÇÃO (rodar manualmente em prod/staging) ---';
-- SELECT WorkOrderType, COUNT(*) FROM workOrders GROUP BY WorkOrderType;
-- SELECT COUNT(*) AS Total, SUM(CASE WHEN WorkOrderType IS NOT NULL THEN 1 ELSE 0 END) AS WithType FROM workOrders;
GO

View file

@ -1,61 +0,0 @@
# Volume Discovery — Work Orders
# Executa queries do volume-discovery-report.md contra staging/prod read-only.
#
# Uso:
# $env:SQL_CONNECTION_STRING = "Server=...;Database=...;User Id=...;Password=...;TrustServerCertificate=True"
# .\scripts\volume-discovery.ps1
param(
[string]$ConnectionString = $env:SQL_CONNECTION_STRING
)
function Get-SqlConnectionArgs([string]$cs) {
return @("-C", $cs)
}
if ([string]::IsNullOrWhiteSpace($ConnectionString)) {
Write-Error "Defina SQL_CONNECTION_STRING ou passe -ConnectionString."
exit 1
}
$queries = @{
TotalWorkOrders = @"
SELECT COUNT(*) AS TotalWorkOrders
FROM workOrders
WHERE IsDeleted IS NULL OR IsDeleted = 0;
"@
P95ProxyMaxPerWeek = @"
WITH WeeklyCounts AS (
SELECT DATEPART(iso_week, ScheduledDate) AS IsoWeek,
YEAR(ScheduledDate) AS IsoYear,
COUNT(*) AS Cnt
FROM workOrders
WHERE ScheduledDate IS NOT NULL
AND (IsDeleted IS NULL OR IsDeleted = 0)
GROUP BY DATEPART(iso_week, ScheduledDate), YEAR(ScheduledDate)
)
SELECT MAX(Cnt) AS P95ProxyMaxPerWeek,
AVG(Cnt * 1.0) AS AvgPerWeek
FROM WeeklyCounts;
"@
UnscheduledCount = @"
SELECT COUNT(*) AS UnscheduledCount
FROM workOrders
WHERE ScheduledDate IS NULL
AND (IsDeleted IS NULL OR IsDeleted = 0)
AND Status NOT IN ('Completed', 'Complete', 'Cancelled', 'Canceled');
"@
}
$connArgs = Get-SqlConnectionArgs $ConnectionString
Write-Host "=== Volume Discovery ===" -ForegroundColor Cyan
Write-Host ""
foreach ($name in $queries.Keys) {
Write-Host "--- $name ---" -ForegroundColor Yellow
sqlcmd @connArgs -Q $queries[$name] -W
Write-Host ""
}
Write-Host "Preencha docs/work-orders/phase-0/volume-discovery-report.md e docs/work-orders/phase-4/search-tier-decision.md com os resultados."