Compare commits

...

92 commits

Author SHA1 Message Date
Alexandre Brandizzi
656d1ae233 test: use collection assertion idiom 2026-07-24 16:44:45 -03:00
Alexandre Brandizzi
5c6553f230 style: format board update service 2026-07-24 16:42:48 -03:00
Alexandre Brandizzi
df291ea9a6 fix(architecture): abstract job run state access 2026-07-24 16:40:46 -03:00
Alexandre Brandizzi
638eccd46c Merge dev and enforce backend layering 2026-07-24 16:36:48 -03:00
Alexandre Brandizzi
8be07a9c5f
Merge pull request #22 from Sea-Haven-Industries/feature/wo-flag-color
feat(work-orders): add board flagColor with fixed palette
2026-07-24 14:32:41 -03:00
Alexandre Brandizzi
78abf84b0d fix(work-orders): finalize PR 22 integration compile fixes 2026-07-24 14:27:52 -03:00
Alexandre Brandizzi
e5cf4cec09 merge: integrate origin/dev into PR #22 flag-color base
Brings in dev's Phase 5 (PR #17) + vendor PRs (#25/#28/#29) atop the
Phase 6/7 + flagColor base (PR #22). Preserves dev Phase 1-5 behavior and
PR #22 Phase 6/7 + flagColor behavior.

Conflict resolutions (16 files):
- Migrations Phase4_SearchIndexes/.Designer + Phase5_DomainEvents/.Designer:
  take dev (Phase4 incl. SQL Server SiteCode/InternalWONumber index-compat
  shrink fix; Phase5 identical). ModelSnapshot union: Vendor CompanyId index
  + Phase7 ServiceNotes/ExternalWorkOrderId index.
- ApplicationDbContext: keep dev SiteCode/InternalWONumber MaxLength (Phase1-5
  + unguarded model test) + HEAD CompletionDocTemplate/ExternalWorkOrderId.
- WorkOrderAuditService: unify on dev async staging API; convert Phase6
  CompletionService 2 call sites to await StageFieldChangedAsync (drops
  HEAD sync duplicate; only callers, no test refs).
- Hosted services: take HEAD (retry-on-failure, coherent with Phase7
  WorkOrderJobRunStateAccessor/OpsHealth). Program.cs keeps dev vendor DI
  (ClamAV/VendorDocumentScanWorker/ArgumentExceptionFilter) + HEAD Phase7.
- WorkOrderController: keep HEAD Phase6/7 service params + dev doc comment.
- VendorController/WorkOrderBoardCreateService/QueryFilters/appsettings:
  union / dev-correct.
- WorkOrderBoardUpdateServiceTests: union of HEAD (Phase6/7+flagColor) and
  dev (Phase1-5) test methods.

Verified WorkOrderType.Other (enum 99) is a legit category, not an overdue
sentinel; overdue uses dedicated OperationalFlags.PastDue + IsPastDue, and
'Overdue' is rejected as a WorkOrderType (no PR #23 import needed).

Removed dev duplicate Api.Options.WorkOrderJobRunState (HEAD defines it in
Services.Implementation alongside the Accessor; Services cannot reference Api).
2026-07-24 14:20:16 -03:00
Alexandre Brandizzi
0e87817416
Merge pull request #17 from Sea-Haven-Industries/feat/work-orders-phase-5
Feat/work orders Phase 5
2026-07-24 13:55:09 -03:00
Alexandre Brandizzi
5fa9b4e344 merge: integrate origin/dev into work-orders-phase-5
Brings in merged PR #16 and vendor/Phase 1-4 work from origin/dev into the
Phase 5 (WeekRolled) head. Only conflict was ApplicationDbContextModelSnapshot.cs
(EF model snapshot touched by both sides); resolved by taking dev's latest
snapshot (matches migration 20260723220614) and grafting the
WorkOrderWeekRolledLedger entity + relationship blocks, matching the Phase5
migration Designer exactly. Migration ordering unchanged: Phase5 (20260709)
runs before vendor migrations (20260720-20260723).
2026-07-24 13:51:41 -03:00
Alexandre Brandizzi
917bad3ee1
Merge pull request #16 from Sea-Haven-Industries/feat/work-orders-phase-4
Feat/work orders phase 4
2026-07-24 13:41:14 -03:00
Alexandre Brandizzi
a9dee0bb84 merge: integrate origin/dev into work-orders-phase-4
Merge origin/dev (vendor roadmap PRs #28/#29, phases 1-3 PRs #13-15)
into work-orders-phase-4. Conflict in WorkOrderController.cs resolved by
keeping both the Phase 4 board/search endpoint and the dev-side XML doc
comment on GetDispatcherLookups (both additions at the same site).

Migration timestamps remain monotonic and non-overlapping. Phase 4 global
advanced search, search index migrations, and the cross-platform LocalDB
test guard are preserved alongside the merged vendor roadmap and phases 1-3.

Validation (Docker .NET 8 SDK): build 0 errors; 191 tests pass across
all three test projects (64 + 18 + 109).
2026-07-24 13:38:20 -03:00
Alexandre Brandizzi
8192da7790
Merge pull request #15 from Sea-Haven-Industries/feat/work-orders-phase-3
Feat/work orders Phase 3 board create and soft cancel
2026-07-24 13:34:39 -03:00
Alexandre Brandizzi
df828cf48c
Merge pull request #14 from Sea-Haven-Industries/feat/work-orders-phase-2
Feat/work orders phase 2
2026-07-24 13:32:25 -03:00
Alexandre Brandizzi
3bd0268129
Merge pull request #13 from Sea-Haven-Industries/feat/work-orders-phase-1-board
Feat/work orders phase 1 board
2026-07-24 13:29:09 -03:00
Alexandre Brandizzi
782d0e8ae9
Merge pull request #29 from Sea-Haven-Industries/codex/vendor-roadmap-completion-backend
Complete vendor operations roadmap backend
2026-07-24 13:24:22 -03:00
Alexandre Brandizzi
1ede0a5ece test: sync cross-platform LocalDB guard 2026-07-24 13:12:38 -03:00
Alexandre Brandizzi
af724ff625 fix(test): guard LocalDB migration check by platform 2026-07-24 13:10:50 -03:00
Alexandre Brandizzi
b1421de0b5 fix(vendors): address roadmap review findings 2026-07-24 11:49:57 -03:00
Arthur Bassi
8134c0b77f fix(work-orders): add Phase5 DomainEvents Designer metadata
Ensure EF discovers the WeekRolled ledger migration and keeps Phase 4 column bounds in the designer snapshot.
2026-07-24 10:14:47 -03:00
Arthur Bassi
93ef4f578b fix(work-orders): process all WeekRolled candidates across batches
Offset paging skipped remaining WOs after ledger writes; always refetch the first page of unprocessed candidates and cover with a >BatchSize test.
2026-07-24 10:13:17 -03:00
Arthur Bassi
7459dca3c6 feat(work-orders): add Phase 5 scheduled domain events (WeekRolled)
Introduce in-process WeekRolled job with idempotent ledger to increment carriedOver for the SHOC board, plus optional PastDue cache and admin reprocess endpoints while keeping isPastDue derived on-read.
2026-07-24 10:13:17 -03:00
Arthur Bassi
bdd566de83 fix(work-orders): bound SiteCode/InternalWONumber so Phase4 indexes apply on SQL Server 2026-07-24 10:02:57 -03:00
Arthur Bassi
31675e49dd fix(work-orders): make Phase4 search index migration discoverable by EF 2026-07-24 10:01:11 -03:00
Arthur Bassi
edcdc10395 fix(work-orders): return 400 for invalid advanced-search custom date range
Map ArgumentException from datePreset=custom without dates to BadRequest so the endpoint matches the Phase 4 contract.
2026-07-24 10:01:11 -03:00
Arthur Bassi
54cd24acf4 fix(migrations): shrink SiteCode and InternalWONumber before Phase4 search indexes
SQL Server cannot index nvarchar(max). Alter columns to nvarchar(450)
before creating Phase 4 search indexes.
2026-07-24 09:59:24 -03:00
Arthur Bassi
11fed06f03 feat(work-orders): add Phase 4 board search and advanced search API
Harden contextual search on the weekly board and expose paginated cross-week GET /board/search with date presets, FE filter params, SQL indexes, and unit tests.
2026-07-24 09:58:39 -03:00
Alexandre Brandizzi
39829f7274 Merge branch 'codex/vendor-core-completion-backend' into codex/vendor-roadmap-completion-backend 2026-07-23 19:18:19 -03:00
Alexandre Brandizzi
4863d1fdaf feat(vendors): complete operations roadmap backend 2026-07-23 19:18:06 -03:00
Arthur Bassi
385812c5c3 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.
2026-07-23 13:28:54 -03:00
Arthur Bassi
033db6f203 merge: sync flagColor with slideover pre-features base
Resolve stack conflicts while keeping FlagColor field, migration, and tests. Also add test project DataServices/AspNetCore refs for clean builds.
2026-07-23 13:21:16 -03:00
Arthur Bassi
f4985378fc
feat(work-orders): add structured service notes POC and vendor fields (#21)
* fix(migrations): make Phase6 completion slide-over migration idempotent

Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata
so partial environments can apply Phase 6 safely.

* feat(locations): add sites options endpoint and vendor contactName

Expose GET /api/locations/sites for the WO create wizard and include
vendor contactName on dropdown responses.

* feat(work-orders): add structured service notes POC and vendor fields

Persist primary/extra services, service notes, free-text POC, and
tech/vendor notes on create/patch/board/detail instead of embedding
them in description.

* fix(work-orders): make stacked tip build for CI quality gates

* fix(work-orders): align stacked services with CI build

* fix(work-orders): keep FlagColor out of structured-notes slice

Remove FlagColor model/API/tests from this PR so schema and EF model stay aligned with the structured-fields migration. FlagColor remains in the follow-on PR that adds its migration.

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
2026-07-22 17:36:46 -03:00
Arthur Bassi
8c4102b1cf
feat(locations): add sites options endpoint and vendor contactName (#20)
* fix(migrations): make Phase6 completion slide-over migration idempotent

Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata
so partial environments can apply Phase 6 safely.

* feat(locations): add sites options endpoint and vendor contactName

Expose GET /api/locations/sites for the WO create wizard and include
vendor contactName on dropdown responses.

* fix(work-orders): make stacked tip build for CI quality gates

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
2026-07-22 17:36:11 -03:00
Arthur Bassi
af1c0d19b4
fix(migrations): make Phase6 completion slide-over migration idempotent (#19)
* fix(migrations): make Phase6 completion slide-over migration idempotent

Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata
so partial environments can apply Phase 6 safely.

* fix(work-orders): make stacked tip build for CI quality gates

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
2026-07-22 17:35:55 -03:00
Alexandre Brandizzi
1ff29c234e
Merge branch 'dev' into feat/work-orders-phase-1-board 2026-07-21 14:01:48 -03:00
Arthur Bassi
cc37a0283b fix(work-orders): keep FlagColor feature on this slice with its migration
Restore FlagColor model/API/tests here after the parent structured-notes PR dropped them, so this PR remains the coherent home for the FlagColor column and palette.
2026-07-21 09:42:10 -03:00
Arthur Bassi
40d86c85a1 merge: bring FlagColor branch onto structured-notes without FlagColor model 2026-07-21 09:40:55 -03:00
Arthur Bassi
33574c233b fix(work-orders): keep FlagColor out of structured-notes slice
Remove FlagColor model/API/tests from this PR so schema and EF model stay aligned with the structured-fields migration. FlagColor remains in the follow-on PR that adds its migration.
2026-07-21 09:38:34 -03:00
Arthur Bassi
75969a1e3d docs(work-orders): clarify board filter precedence and dispatcher lookup
Document myWorkOrders overriding dispatchers, weekend dayGroup null contract, and that dispatcher lookups currently return all users. Map service ArgumentException to 400 for consistency.
2026-07-21 09:33:24 -03:00
Arthur Bassi
61b4c84337 fix(work-orders): lock ScheduledStart/End when patching apptTime
Align board apptTime field locks with SyncFieldMergePolicy field names so sync cannot overwrite a manual appointment edit. Also add shared LifecycleStatusSets so the Phase 2 branch compiles.
2026-07-21 09:31:27 -03:00
Arthur Bassi
f3d2a5cfe7 fix(work-orders): address PR13 phase-1 review feedback 2026-07-17 14:51:56 -03:00
Arthur Bassi
76db2b5a37 fix(work-orders): address PR14 phase-2 review feedback 2026-07-17 14:48:48 -03:00
Arthur Bassi
4bd11dcf6b fix(work-orders): address stacked PR review feedback 2026-07-17 14:31:08 -03:00
Arthur Bassi
d59f92910e fix(work-orders): align stacked services with CI build 2026-07-17 13:38:56 -03:00
Arthur Bassi
c722d37a8f fix(work-orders): align stacked services with CI build 2026-07-17 13:38:55 -03:00
Arthur Bassi
fa1eca8f89 fix(work-orders): make stacked tip build for CI quality gates 2026-07-17 13:18:46 -03:00
Arthur Bassi
a80a71b16a fix(work-orders): make stacked tip build for CI quality gates 2026-07-17 13:18:43 -03:00
Arthur Bassi
62572d8e03 feat(work-orders): add board flagColor with fixed palette
Support PATCH flagColor with FE-aligned #RRGGBB palette and expose the
value on board/detail rows.
2026-07-17 10:37:24 -03:00
Arthur Bassi
ecc1db3726 feat(work-orders): add structured service notes POC and vendor fields
Persist primary/extra services, service notes, free-text POC, and
tech/vendor notes on create/patch/board/detail instead of embedding
them in description.
2026-07-17 10:37:23 -03:00
Arthur Bassi
3983f7667d feat(locations): add sites options endpoint and vendor contactName
Expose GET /api/locations/sites for the WO create wizard and include
vendor contactName on dropdown responses.
2026-07-17 10:36:32 -03:00
Arthur Bassi
3e2952f8a0 fix(migrations): make Phase6 completion slide-over migration idempotent
Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata
so partial environments can apply Phase 6 safely.
2026-07-17 10:36:32 -03:00
Arthur Bassi
2c26ead193 chore(docs): remove Phase 7 README from PR scope 2026-07-13 13:55:05 -03:00
Arthur Bassi
4355c07e45 chore(docs): remove Phase 7 ops markdown from PR scope 2026-07-13 13:51:34 -03:00
Arthur Bassi
5d2a2d0ed0 chore(config): document Phase 7 feature flags in appsettings and .env.example 2026-07-13 13:23:54 -03:00
Arthur Bassi
ec6d69a933 docs(work-orders): add Phase 7 rollout gates Lambda mapping and CloudWatch alerts 2026-07-13 13:23:52 -03:00
Arthur Bassi
03dc04ad0b test(work-orders): add Phase 7 coexistence coverage 2026-07-13 13:23:07 -03:00
Arthur Bassi
64ca9e0bdf feat(work-orders): add legacy deprecation middleware and Blazor WO sunset guard 2026-07-13 13:22:53 -03:00
Arthur Bassi
89cb520db9 feat(work-orders): add Sync kill-switch and ops health coexistence endpoints 2026-07-13 13:22:12 -03:00
Arthur Bassi
1074ed860b feat(work-orders): add work order ingest API with X-Ingest-Key 2026-07-13 13:22:11 -03:00
Arthur Bassi
e67254ed7e feat(work-orders): add ExternalWorkOrderId unique index for ingest idempotency 2026-07-13 13:14:51 -03:00
Arthur Bassi
f8511ce732 test(work-orders): cover Phase 6 slide-over and completion doc flows 2026-07-13 13:13:09 -03:00
Arthur Bassi
af84001bf1 feat(work-orders): add Phase 6 slide-over detail comments audit and media API 2026-07-13 13:13:00 -03:00
Arthur Bassi
0ce008e5eb feat(work-orders): add Phase 6 completion templates schema and media category 2026-07-13 13:12:07 -03:00
Arthur Bassi
812a5e994c merge: integrate Phase 4 into Phase 5 and add DomainEvents Designer metadata
Resolve snapshot conflict by keeping Phase 4 search indexes and Phase 5 ledger model. Add missing Phase5_DomainEvents.Designer.cs so EF discovers the WorkOrderWeekRolledLedgers migration.
2026-07-10 11:53:29 -03:00
Arthur Bassi
8a5cb0cc90 fix(work-orders): make Phase4 search index migration discoverable by EF 2026-07-10 11:45:24 -03:00
Arthur Bassi
ceeb0c8fa3 fix(work-orders): wrap board create in a transaction for atomic persistence 2026-07-10 11:38:58 -03:00
Arthur Bassi
3122cbc8f7 fix(migrations): remove undiscoverable WorkOrderType backfill migration
Superseded by Phase0 (WorkOrderType as int) and Phase1 board indexes; keeping the bare Migration would either be skipped by EF or conflict if metadata were added.
2026-07-10 11:28:55 -03:00
Arthur Bassi
bdfaf1ff61 fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery 2026-07-10 11:27:43 -03:00
Arthur Bassi
cc8825ace0 fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery 2026-07-10 11:20:14 -03:00
Arthur Bassi
f977483c17 fix(work-orders): process all WeekRolled candidates across batches
Offset paging skipped remaining WOs after ledger writes; always refetch the first page of unprocessed candidates and cover with a >BatchSize test.
2026-07-10 11:01:52 -03:00
Arthur Bassi
dd2a805c24 fix(work-orders): return 400 for invalid advanced-search custom date range
Map ArgumentException from datePreset=custom without dates to BadRequest so the endpoint matches the Phase 4 contract.
2026-07-10 10:56:18 -03:00
Arthur Bassi
0ddc43210a Merge remote-tracking branch 'origin/feat/work-orders-phase-3' into feat/work-orders-phase-4 2026-07-10 10:54:15 -03:00
Arthur Bassi
9dc17d4255 fix(work-orders): persist WO identity before staging create audit and locks 2026-07-10 10:49:57 -03:00
Arthur Bassi
88f9245fbf merge: sync Phase 1 board review fixes into Phase 2
Resolve conflicts in WorkOrderController and WorkOrderBoardService so Phase 2 merges cleanly into the Phase 1 base.
2026-07-10 10:41:36 -03:00
Arthur Bassi
8c33eb7f9a feat(work-orders): add Phase 5 scheduled domain events (WeekRolled)
Introduce in-process WeekRolled job with idempotent ledger to increment carriedOver for the SHOC board, plus optional PastDue cache and admin reprocess endpoints while keeping isPastDue derived on-read.
2026-07-09 14:08:48 -03:00
Arthur Bassi
25eb6293e0 chore: remove docs and scripts from phase 2 PR scope 2026-07-09 13:24:47 -03:00
Arthur Bassi
c2d0bd45e3 fix(work-orders): address PR #13 review feedback
Return BadRequest for invalid board week window, remove duplicate unused board types, fix POC null guard, and document appt precedence and UTC Phase 1 assumptions.
2026-07-09 10:06:36 -03:00
Arthur Bassi
83272e4957 Revert "fix(work-orders): address PR #14 board PATCH review feedback"
This reverts commit eb64c2f55c.
2026-07-09 09:59:41 -03:00
Arthur Bassi
88caede86a fix(work-orders): address PR #14 board PATCH review feedback
Reuse field-lock DB checks during staged audits, defer dispatch creation to the final save, and return 404 for missing work orders.
2026-07-09 09:58:41 -03:00
Arthur Bassi
eb64c2f55c fix(work-orders): address PR #14 board PATCH review feedback
Reuse field-lock DB checks during staged audits, defer dispatch creation to the final save, and return 404 for missing work orders.
2026-07-09 09:54:53 -03:00
Arthur Bassi
4617e8ba83 feat(work-orders): add Phase 4 board search and advanced search API
Harden contextual search on the weekly board and expose paginated cross-week GET /board/search with date presets, FE filter params, SQL indexes, and unit tests.
2026-07-08 14:58:59 -03:00
Arthur Bassi
e5bda0714a feat(work-orders): add Phase 3 board create and soft cancel API
Expose POST /api/workorders/board and POST /api/workorders/{id}/cancel for SHOC wizard/inline creation and soft cancel, with field locks, WO# normalization, and Admin-only hard delete.
2026-07-08 10:17:31 -03:00
Arthur Bassi
91122d753d merge: integrate Phase 1 board API into Phase 2 branch
Combine the reviewed Phase 1 read-only board endpoints with Phase 2 inline edit and optimistic concurrency, resolving shared foundation conflicts while preserving both feature sets.
2026-07-07 13:59:22 -03:00
Arthur Bassi
673bc3b5a9 fix(work-orders): align phase 2 status/type contract to SHOC frontend
Expand LifecycleStatus (EnRoute, OnSite, Rescheduled, Pending, PendingQuote) and WorkOrderType (Reactive, AddOn) to match the frontend prototype. Add FE label round-trip via LifecycleStatusMapper/WorkOrderTypeMapper, update derived fields, mutation rules and board unscheduled filter, and reactivate [Authorize] on WorkOrderController. Fix pre-existing dayGroup test expectation.
2026-07-07 13:11:22 -03:00
Arthur Bassi
d040832b87 feat(work-orders): isolate phase 2 inline edit with optimistic concurrency
Deliver PATCH board field updates and drop phases 3-7 code from the branch while keeping phase 0/1 dependencies required to build and test.
2026-07-07 11:26:13 -03:00
Arthur Bassi
8efe4460bb fix(work-orders): complete Phase 1 board indexes, Service DTO and helper wiring
Add AssignTo+ScheduledDate and WorkOrderType indexes, expose service on board rows, and route data access through shared projection/filter helpers.
2026-07-07 10:11:04 -03:00
Arthur Bassi
ff53cb9fa6 feat(work-orders): add weekly board read API (Phase 1)
- GET /api/workorders/board with week window, Unscheduled section, and X of Y counts
- GET /api/workorders/lookups/dispatchers for SHOC filter avatars
- Board projection via WorkOrderBoardDataService with vendor/dispatch join and derived isPastDue
- Phase1_BoardIndexes migration (IX_workOrders_ScheduledDate)
- 5 board unit tests
2026-07-07 10:10:06 -03:00
Arthur Bassi
4f697f257a wip: work orders phases 1-7 (isolated from phase 0 foundation) 2026-06-30 10:09:48 -03:00
Arthur Bassi
65c1cdc7e9 docs(work-orders): add Phase 0 governance documentation 2026-06-29 17:22:31 -03:00
Arthur Bassi
5d5b3234c4 test(work-orders): add Phase 0 domain unit tests 2026-06-29 17:22:31 -03:00
Arthur Bassi
f52c430c2c feat(work-orders): add audit, field lock and sync merge services 2026-06-29 17:22:31 -03:00
Arthur Bassi
35b3bba596 feat(work-orders): add EF config and Phase 0 migration 2026-06-29 17:22:31 -03:00
Arthur Bassi
456bee2226 feat(work-orders): add domain enums and aggregate root fields 2026-06-29 17:22:31 -03:00
187 changed files with 27047 additions and 459 deletions

View file

@ -1,11 +1,11 @@
# Sea Haven Industries — shoc-backend required configuration
# Sea Haven Industries ÔÇö shoc-backend required configuration
#
# This file documents the secrets that used to be hardcoded in appsettings*.json
# and source files. Copy the values into one of the supported configuration sources;
# do NOT commit real values.
#
# .NET resolves configuration in this order (later wins):
# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only)
# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only)
# 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value"
# 3. Environment variables (use "__" as the section separator)
#
@ -32,3 +32,16 @@ GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_REGION=us-east-2
# --- Work Order ingest (Lambda cutover) ---
# Leave Enabled=false until ApiKey is a real secret (min 32 chars). Do not use appsettings placeholders.
WorkOrderIngest__Enabled=false
WorkOrderIngest__ApiKey=<shared-secret-for-X-Ingest-Key-min-32-chars>
# --- Dynamo sync bridge (keep false after Lambda cutover; avoid dual-run with ingest) ---
Sync__Enabled=true
# --- Legacy endpoint deprecation headers ---
LegacyEndpoints__DeprecationEnabled=false
LegacyEndpoints__SunsetDate=2026-12-31

View file

@ -25,7 +25,7 @@ public class AuthenticationServiceTests
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s;
hasher = h;
return new AuthenticationService(manager, Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
}
private static JwtOptions JwtOptions => new()

View file

@ -42,7 +42,7 @@ public class DispatchBatchingBehaviorTests
Mock.Of<ICommentDataService>(),
emailPort.Object,
tokenPort.Object,
Options.Create(new FrontendOptions()));
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
}
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) =>

View file

@ -17,7 +17,7 @@ internal static class IdentityTestHelpers
var manager = new UserManager<ApplicationUser>(
store.Object,
Options.Create(new IdentityOptions()),
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
hasher.Object,
new List<IUserValidator<ApplicationUser>>(),
new List<IPasswordValidator<ApplicationUser>>(),

View file

@ -0,0 +1,110 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationControllerSitesTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationController NewController(ApplicationDbContext ctx)
{
var dataService = new LocationDataService(ctx);
var service = new LocationService(
dataService,
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
return controller;
}
private static async Task SeedSitesAsync(ApplicationDbContext ctx)
{
ctx.Locations.AddRange(
new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" },
new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" },
new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" },
new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" });
await ctx.SaveChangesAsync();
}
[Fact]
public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().HaveCount(4);
data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8");
data[0].Should().BeEquivalentTo(new SiteOptionDTO
{
Id = 2,
Code = "BK5",
City = "Dallas",
State = "TX"
});
}
[Fact]
public async Task GetSites_FiltersByCode()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites("bk5");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().ContainSingle();
data[0].Code.Should().Be("BK5");
}
[Fact]
public async Task GetSites_FiltersByCity()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites("Dallas");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().HaveCount(2);
data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8");
}
}

View file

@ -210,7 +210,7 @@ public class WorkOrderDispatchServiceBehaviorTests
Mock.Of<ICommentDataService>(),
Mock.Of<IDispatchEmailPort>(),
Mock.Of<IVendorTokenPort>(),
Options.Create(new FrontendOptions()));
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
}
}

View file

@ -0,0 +1,319 @@
using System.Collections;
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorOperationsControllerTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static VendorOperationsController NewController(ApplicationDbContext context)
{
var dataService = new VendorOperationsDataService(context, new DispatchDataService(context));
var service = new VendorOperationsService(dataService, TimeProvider.System);
var controller = new VendorOperationsController(service);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test"))
}
};
return controller;
}
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours()
{
using var context = NewContext();
context.workOrders.Add(new WorkOrder
{
InternalWONumber = "WO-48",
WorkerOrderTitle = "Needs vendor",
ScheduledDate = DateTime.UtcNow.AddHours(47)
});
await context.SaveChangesAsync();
var result = await NewController(context).GetNotifications(CancellationToken.None);
var value = ((OkObjectResult)result).Value!;
var items = ((IEnumerable)Prop(value, "Items")).Cast<object>().ToList();
items.Should().ContainSingle();
Prop(items[0], "Type").Should().Be("NoVendor");
Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service");
}
[Fact]
public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-ASSIGNED",
ScheduledDate = DateTime.UtcNow.AddHours(24)
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledDate
});
await context.SaveChangesAsync();
var result = await NewController(context).GetNotifications(CancellationToken.None);
var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast<object>();
items.Should().BeEmpty();
}
[Fact]
public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst()
{
using var context = NewContext();
var site = new Locations { Name = "St Louis" };
var preferred = new Vendor
{
CompanyName = "Preferred",
ContactName = "Alex",
IsActive = true,
TradeSpecialties = "HVAC",
AvailabilityStatus = "Available",
AvailabilityUpdatedAt = DateTime.UtcNow
};
var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-BUSY",
ScheduledStart = DateTime.UtcNow.AddHours(1),
ScheduledEnd = DateTime.UtcNow.AddHours(3)
};
context.AddRange(site, preferred, busy, workOrder);
await context.SaveChangesAsync();
context.SitePreferredVendors.Add(new SitePreferredVendor
{
LocationId = site.Id,
VendorId = preferred.Id,
Trade = "HVAC",
SortOrder = 0
});
context.Dispatches.Add(new Dispatch
{
VendorId = busy.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledStart
});
await context.SaveChangesAsync();
var result = await NewController(context).GetAvailability(
DateTime.UtcNow.AddHours(2),
DateTime.UtcNow.AddHours(4),
site.Id,
"HVAC",
CancellationToken.None);
var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast<object>().ToList();
Prop(rows[0], "CompanyName").Should().Be("Preferred");
Prop(rows[0], "IsPreferred").Should().Be(true);
var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy");
Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable");
}
[Fact]
public async Task ReplaceSitePreferences_RejectsInactiveVendor()
{
using var context = NewContext();
var site = new Locations { Name = "St Louis" };
var inactive = new Vendor { CompanyName = "Inactive", IsActive = false };
context.AddRange(site, inactive);
await context.SaveChangesAsync();
var result = await NewController(context).ReplaceSitePreferences(site.Id,
new List<SitePreferenceRequest>
{
new() { VendorId = inactive.Id }
},
CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>();
context.SitePreferredVendors.Should().BeEmpty();
}
[Fact]
public async Task InsightsExports_ReturnStableCsvAndPdfFormats()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false };
var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed",
DispatchedAt = DateTime.UtcNow.AddHours(-4),
ScheduledDate = DateTime.UtcNow.AddHours(-2),
AcknowledgedAt = DateTime.UtcNow.AddHours(-3),
CompletedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var controller = NewController(context);
var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null, CancellationToken.None);
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null, CancellationToken.None);
csv.ContentType.Should().Be("text/csv");
var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents);
csvText.Should().Contain("AverageCycleHours");
csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\"");
pdf.ContentType.Should().Be("application/pdf");
System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4");
}
[Fact]
public async Task AssignVendor_RejectsInactiveVendor()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Inactive", IsActive = false };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var result = await NewController(context).AssignVendor(
workOrder.Id,
new VendorAssignmentRequest { VendorId = vendor.Id },
CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>();
context.Dispatches.Should().BeEmpty();
}
[Fact]
public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts()
{
using var context = NewContext();
var start = DateTime.UtcNow.AddHours(2);
var vendor = new Vendor
{
CompanyName = "Gateway",
IsActive = true,
AvailabilityStatus = "Available",
AvailabilityUpdatedAt = DateTime.UtcNow
};
var existingWorkOrder = new WorkOrder
{
InternalWONumber = "WO-EXISTING",
ScheduledStart = start,
ScheduledEnd = start.AddHours(2)
};
var assignedWorkOrder = new WorkOrder
{
InternalWONumber = "WO-NEW",
ScheduledStart = start.AddMinutes(30),
ScheduledEnd = start.AddHours(1)
};
context.AddRange(vendor, existingWorkOrder, assignedWorkOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = existingWorkOrder.Id,
Status = "Sent",
ScheduledDate = existingWorkOrder.ScheduledStart
});
await context.SaveChangesAsync();
var eta = start.AddMinutes(45);
var result = await NewController(context).AssignVendor(
assignedWorkOrder.Id,
new VendorAssignmentRequest
{
VendorId = vendor.Id,
ScheduledStart = assignedWorkOrder.ScheduledStart,
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
EstimatedArrivalAt = eta,
EtaManualOverride = true
},
CancellationToken.None);
var value = ((OkObjectResult)result).Value!;
Prop(value, "AvailabilityStatus").Should().Be("Unavailable");
((IEnumerable)Prop(value, "Conflicts")).Cast<object>().Should().ContainSingle();
var dispatch = await context.Dispatches.SingleAsync(item =>
item.WorkOrderId == assignedWorkOrder.Id);
dispatch.EstimatedArrivalAt.Should().Be(eta);
dispatch.EtaManualOverride.Should().BeTrue();
dispatch.DispatchNumber.Should().StartWith("DISP-");
context.WorkOrderAuditLogs.Should().ContainSingle(log =>
log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned");
}
[Fact]
public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin()
{
using var context = NewContext();
var originalVendor = new Vendor { CompanyName = "Original", IsActive = true };
var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-LINKED",
ScheduledStart = DateTime.UtcNow.AddHours(2),
ScheduledEnd = DateTime.UtcNow.AddHours(4)
};
context.AddRange(originalVendor, replacementVendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = originalVendor.Id,
Status = "Sent",
DispatchNumber = "DISP-LINKED"
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id
});
await context.SaveChangesAsync();
var result = await NewController(context).AssignVendor(
workOrder.Id,
new VendorAssignmentRequest
{
VendorId = replacementVendor.Id,
ScheduledStart = workOrder.ScheduledStart,
ScheduledEnd = workOrder.ScheduledEnd
},
CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
context.Dispatches.Should().ContainSingle();
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
}
}

View file

@ -0,0 +1,179 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class VendorPortalDocumentTests : IDisposable
{
private const string Token = "vendor-document-test-token";
private readonly string _contentRoot = Path.Combine(
Path.GetTempPath(),
$"seahaven-vendor-documents-{Guid.NewGuid():N}");
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private VendorPortalController NewController(ApplicationDbContext context)
{
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(
vendorData,
Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
var documentData = new VendorDocumentDataService(context);
var storageEnvironment = new Mock<IWebHostEnvironment>();
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
var service = new VendorPortalService(
tokenService,
new DispatchDataService(context),
Mock.Of<IUpliftDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IEmailSender>(),
documentData,
storage,
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
var controller = new VendorPortalController(service, Mock.Of<ILogger<VendorPortalController>>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
controller.Request.Headers["X-Vendor-Token"] = Token;
return controller;
}
private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch(
ApplicationDbContext context)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(dispatch);
context.VendorAccessTokens.Add(new VendorAccessToken
{
VendorId = vendor.Id,
Token = Token,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(1)
});
await context.SaveChangesAsync();
return (vendor, dispatch);
}
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
{
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
[Fact]
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var upload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "application/pdf"));
upload.Should().BeOfType<OkObjectResult>();
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus.Should().Be("Pending");
document.ReviewStatus.Should().Be("Processing");
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
var locked = download.Should().BeOfType<ObjectResult>().Subject;
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "Application/PDF"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 };
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(png, "photo.png", "Image/PNG"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
public void Dispose()
{
if (Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}

View file

@ -21,7 +21,7 @@ public class VendorServiceTests
data.Object,
Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(),
Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
new CreateVendorValidation(),
new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation());

View file

@ -0,0 +1,63 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class WorkOrderControllerSearchTests
{
private static WorkOrderBoardController NewController(IWorkOrderAdvancedSearchService advancedSearch)
{
var controller = new WorkOrderBoardController(
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
new[]
{
new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier,
"test-user")
},
"Test"))
}
};
return controller;
}
[Fact]
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
{
var advancedSearch = new WorkOrderAdvancedSearchService(
Mock.Of<IWorkOrderAdvancedSearchDataService>());
var controller = NewController(advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom
});
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("dateFrom");
}
}

View file

@ -0,0 +1,206 @@
using Api.SeaHavenIndustries.Controllers;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Framework-backed route contract tests that prove the public endpoint set exposed by the
/// WorkOrder URL space (api/WorkOrder, api/workorders) is preserved exactly after splitting the
/// original godfile WorkOrderController into cohesive controllers, and that no verb+route
/// combination is registered more than once by different actions.
///
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder,
/// GetWorkorderById) and the two shared controller-level base routes.
/// </summary>
public class WorkOrderRouteContractTests
{
private readonly ITestOutputHelper _output;
private static readonly HashSet<Type> WorkOrderFamilyControllerTypes = new()
{
typeof(WorkOrderController),
typeof(WorkOrderBoardController),
typeof(WorkOrderDetailController),
typeof(WorkOrderCompletionController),
typeof(WorkOrderMediaController),
typeof(WorkOrderDispatchController),
};
/// <summary>
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed. Every action is reachable under both api/WorkOrder and
/// api/workorders; that base-route duplication is collapsed here, so this is the distinct
/// action-relative contract. 45 routes come from 43 actions (Editworkorder and
/// GetWorkorderById each bind two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
"DELETE DeleteWorkorder",
"DELETE completion-templates/{id:int}",
"DELETE {id:int}/media/{mediaId:int}",
"GET GetComments",
"GET GetCommentsByWorkorderId",
"GET GetDispatch/{id}",
"GET GetDispatches/{workOrderId}",
"GET GetFilteredWorkorder",
"GET GetFilteredWorkorder2",
"GET GetWorkOrderList",
"GET GetWorkorderById",
"GET Getworkorders",
"GET GetworkordersDD",
"GET board",
"GET board/search",
"GET completion-templates",
"GET completion-templates/{id:int}",
"GET lookups/dispatchers",
"GET {id:int}",
"GET {id:int}/audit",
"GET {id:int}/comments",
"GET {id:int}/detail",
"GET {id:int}/media",
"PATCH {id:int}/board",
"POST AddChecklistItem",
"POST AddComment",
"POST AddCommentJson",
"POST AddDispatchComment",
"POST AddDispatchSignoff",
"POST AddWorkorder",
"POST ChangeAssignment",
"POST ChangeStatus",
"POST DispatchToVendor",
"POST EditWorkorder",
"POST UpdateChecklistItem",
"POST UpdateDispatch",
"POST VerifyDispatch",
"POST board",
"POST completion-templates",
"POST {id:int}/cancel",
"POST {id:int}/comments",
"POST {id:int}/completion-doc",
"POST {id:int}/media",
"PUT completion-templates/{id:int}",
"PUT {id:int}",
};
public WorkOrderRouteContractTests(ITestOutputHelper output)
{
_output = output;
}
private static IReadOnlyList<ActionDescriptor> BuildActionDescriptors()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore()
.AddApplicationPart(typeof(WorkOrderController).Assembly);
using var provider = services.BuildServiceProvider();
var actionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
return actionProvider.ActionDescriptors.Items;
}
private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor(
Func<ActionDescriptor, bool> predicate)
{
var tuples = new List<(string Verb, string FullTemplate, string Controller, string Action)>();
foreach (var action in BuildActionDescriptors().Where(predicate))
{
var cad = action as ControllerActionDescriptor;
var template = cad?.AttributeRouteInfo?.Template?.Trim('/');
if (string.IsNullOrEmpty(template))
continue;
var methods = action.ActionConstraints
.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods)
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var method in methods)
tuples.Add((method.ToUpperInvariant(), template, cad!.ControllerName, cad.ActionName));
}
return tuples;
}
private static string Normalize(string fullTemplate)
{
foreach (var prefix in new[] { "api/WorkOrder/", "api/workorders/" })
{
if (fullTemplate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return fullTemplate[prefix.Length..];
}
foreach (var exact in new[] { "api/WorkOrder", "api/workorders" })
{
if (fullTemplate.Equals(exact, StringComparison.OrdinalIgnoreCase))
return string.Empty;
}
return fullTemplate;
}
[Fact]
public void WorkOrder_Family_Preserves_Expected_Public_Endpoint_Set()
{
var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad
&& WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo))
.Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}")
.ToHashSet(StringComparer.Ordinal);
Dump(actual);
var missing = ExpectedWorkOrderEndpoints.Except(actual).OrderBy(x => x).ToList();
var unexpected = actual.Except(ExpectedWorkOrderEndpoints).OrderBy(x => x).ToList();
missing.Should().BeEmpty("every pre-split WorkOrder endpoint must still be reachable");
unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints");
actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count,
"the distinct action-relative endpoint count must match the baseline");
}
[Fact]
public void WorkOrder_Family_Has_No_Duplicate_Verb_And_Route_From_Different_Actions()
{
// Duplicate detection is done at the FULL route-template level. Each action legitimately
// resolves under both base routes (api/WorkOrder and api/workorders); those are distinct
// full templates. A true collision is the SAME full template produced by two actions,
// which would cause an AmbiguousMatchException at request time.
var collisions = FullRoutesFor(a => a is ControllerActionDescriptor cad
&& WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo))
.GroupBy(t => (t.Verb, t.FullTemplate))
.Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1)
.Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}")
.ToList();
collisions.Should().BeEmpty(
"no two actions in the WorkOrder family may register the same verb+full-route");
}
[Fact]
public void WorkOrder_Url_Space_Has_No_Duplicate_Verb_And_Route_From_Different_Actions()
{
var collisions = FullRoutesFor(_ => true)
.Where(t => t.FullTemplate.StartsWith("api/WorkOrder", StringComparison.OrdinalIgnoreCase)
|| t.FullTemplate.StartsWith("api/workorders", StringComparison.OrdinalIgnoreCase))
.GroupBy(t => (t.Verb, t.FullTemplate))
.Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1)
.Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}")
.ToList();
collisions.Should().BeEmpty(
"no two controllers sharing api/WorkOrder or api/workorders may register the same verb+full-route");
}
private void Dump(IEnumerable<string> endpoints)
{
_output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):");
foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal))
_output.WriteLine(" " + endpoint);
}
}

View file

@ -25,6 +25,13 @@ namespace Api.SeaHavenIndustries.Controllers
_logger = logger;
}
[HttpGet("sites")]
public async Task<IActionResult> GetSites(string? search = null)
{
var data = await _locationService.GetSiteOptionsAsync(search);
return Ok(data);
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{

View file

@ -1,24 +1,42 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/[controller]")]
public class SyncController : Controller
{
private readonly ISyncService _syncService;
private readonly SyncOptions _syncOptions;
public SyncController(ISyncService syncService)
public SyncController(ISyncService syncService, IOptions<SyncOptions> syncOptions)
{
_syncService = syncService;
_syncOptions = syncOptions.Value;
}
private IActionResult? SyncDisabledResult()
{
if (_syncOptions.Enabled)
return null;
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
{
message = "DynamoDB sync bridge is disabled. Use POST /api/workorders/ingest."
});
}
[HttpPost("WorkOrders")]
public async Task<IActionResult> SyncWorkOrders(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncWorkOrdersAsync(cancellationToken);
return Ok(result);
}
@ -26,6 +44,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("Comments")]
public async Task<IActionResult> SyncComments(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncCommentsAsync(cancellationToken);
return Ok(result);
}
@ -33,6 +54,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillInternalWONumbers")]
public async Task<IActionResult> BackfillInternalWONumbers(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken);
return Ok(result);
}
@ -40,6 +64,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillDispatchNumbers")]
public async Task<IActionResult> BackfillDispatchNumbers(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken);
return Ok(result);
}
@ -47,6 +74,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("VendorReplies")]
public async Task<IActionResult> SyncVendorReplies(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncVendorRepliesAsync(cancellationToken);
return Ok(result);
}
@ -54,6 +84,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillCommentTypes")]
public async Task<IActionResult> BackfillCommentTypes(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillCommentTypesAsync(cancellationToken);
return Ok(result);
}
@ -61,6 +94,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("All")]
public async Task<IActionResult> SyncAll(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncAllAsync(cancellationToken);
return Ok(result);
}

View file

@ -0,0 +1,210 @@
using System.Text;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/vendor-operations")]
public class VendorOperationsController : ControllerBase
{
private readonly IVendorOperationsService _service;
public VendorOperationsController(IVendorOperationsService service)
{
_service = service;
}
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.Identity?.Name
?? "unknown";
private IActionResult Map<T>(VendorOperationOutcome<T> outcome) => outcome.Kind switch
{
VendorOperationOutcomeKind.Ok => Ok(outcome.Value),
VendorOperationOutcomeKind.NotFound => outcome.Message is null
? NotFound()
: NotFound(new Response { Status = "Error", Message = outcome.Message }),
VendorOperationOutcomeKind.BadRequest => BadRequest(new Response { Status = "Error", Message = outcome.Message }),
VendorOperationOutcomeKind.Conflict => Conflict(new Response { Status = "Conflict", Message = outcome.Message }),
_ => StatusCode(StatusCodes.Status500InternalServerError)
};
[HttpGet("notifications")]
public async Task<IActionResult> GetNotifications(CancellationToken cancellationToken)
{
var result = await _service.GetNotificationsAsync(cancellationToken);
return Ok(new { result.GeneratedAt, result.Items });
}
[HttpGet("availability")]
public async Task<IActionResult> GetAvailability(
[FromQuery] DateTime? start,
[FromQuery] DateTime? end,
[FromQuery] int? locationId,
[FromQuery] string? trade,
CancellationToken cancellationToken)
{
var outcome = await _service.GetAvailabilityAsync(start, end, locationId, trade, cancellationToken);
return Map(outcome);
}
[HttpPut("vendors/{vendorId:int}/availability")]
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] VendorAvailabilityRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.UpdateAvailabilityAsync(vendorId, body?.Status, Actor(), cancellationToken);
return Map(outcome);
}
[HttpGet("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> GetSitePreferences(int locationId, CancellationToken cancellationToken)
{
var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken);
return Ok(preferences);
}
[HttpPut("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body, CancellationToken cancellationToken)
{
var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List<SitePreferenceRequest>(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpPut("documents/{documentId:int}/review")]
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.ReviewDocumentAsync(documentId, body ?? new DocumentReviewRequest(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.UpdateCommercialStatusAsync(dispatchId, body ?? new CommercialStatusRequest(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpPut("work-orders/{workOrderId:int}/assignment")]
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.AssignVendorAsync(workOrderId, body ?? new VendorAssignmentRequest(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpGet("insights")]
public async Task<IActionResult> GetInsights(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId,
CancellationToken cancellationToken)
{
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
return Ok(new
{
GeneratedAt = _service.UtcNow,
Filters = new { from, to, locationId, trade, vendorId },
MetricDefinitions = MetricDefinitions(),
Data = rows
});
}
[HttpGet("insights.csv")]
public async Task<IActionResult> ExportInsightsCsv(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId,
CancellationToken cancellationToken)
{
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
foreach (var row in rows)
{
csv.AppendLine(string.Join(",",
row.VendorId,
Csv(row.Vendor),
row.CompletedJobs,
row.CancellationRefusalRate.ToString("0.####"),
row.OnTimeArrivalRate.ToString("0.####"),
row.OnTimeCompletionRate.ToString("0.####"),
row.AverageCycleHours?.ToString("0.##") ?? ""));
}
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.csv");
}
[HttpGet("insights.pdf")]
public async Task<IActionResult> ExportInsightsPdf(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId,
CancellationToken cancellationToken)
{
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
var lines = new List<string>
{
"Vendor Performance Insights",
$"Generated: {_service.UtcNow:u}",
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
};
lines.AddRange(rows.Take(35).Select(row =>
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.pdf");
}
private static string Csv(string value)
{
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
? $"'{value}"
: value;
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
}
private static object MetricDefinitions() => new
{
CompletedJobs = "Dispatches with Completed or Verified status.",
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
AverageCycleHours = "Average elapsed hours from dispatch to completion."
};
private static byte[] SimplePdf(IEnumerable<string> lines)
{
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
+ " ET";
var objects = new[]
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
var builder = new StringBuilder("%PDF-1.4\n");
var offsets = new List<int> { 0 };
for (var index = 0; index < objects.Length; index++)
{
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
}
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
return Encoding.ASCII.GetBytes(builder.ToString());
}
}
}

View file

@ -1,5 +1,6 @@
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
@ -226,6 +227,43 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPost("dispatches/{id:int}/completion-documents")]
[RequestSizeLimit(10_000_000)]
public async Task<IActionResult> UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
{
var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The completion document could not be uploaded") });
}
}
[HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")]
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var result = await _portalService.DownloadCompletionDocumentAsync(session, id, documentId, cancellationToken);
return result.Outcome switch
{
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
VendorDocumentDownloadOutcome.Locked => StatusCode(StatusCodes.Status423Locked, new Response { Status = "Locked", Message = "The completion document is not available for download yet." }),
_ => NotFound(new Response { Status = "Error", Message = "Completion document not found" })
};
}
private async Task<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken)
{
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;

View file

@ -0,0 +1,216 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderBoardController : Controller
{
private readonly IWorkOrderBoardService _workOrderBoardService;
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
private readonly IWorkOrderBoardCreateService _boardCreateService;
private readonly IWorkOrderBoardCancelService _boardCancelService;
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
public WorkOrderBoardController(
IWorkOrderBoardService workOrderBoardService,
IWorkOrderBoardUpdateService boardUpdateService,
IWorkOrderBoardCreateService boardCreateService,
IWorkOrderBoardCancelService boardCancelService,
IWorkOrderAdvancedSearchService advancedSearchService)
{
_workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
_boardCreateService = boardCreateService;
_boardCancelService = boardCancelService;
_advancedSearchService = advancedSearchService;
}
/// <summary>
/// Weekly board payload.
/// When <paramref name="myWorkOrders"/> is true, the authenticated user filter wins and
/// <paramref name="dispatchers"/> is ignored (not AND-combined).
/// Weekend WOs in a Mon–Sun window may have <c>dayGroup = null</c>; FE should handle that.
/// </summary>
[HttpGet("board")]
public async Task<IActionResult> GetBoard(
[FromQuery, BindRequired] DateOnly weekStart,
[FromQuery] DateOnly? weekEnd = null,
[FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false,
[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);
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var query = new WorkOrderBoardQueryDto
{
WeekStart = weekStart,
WeekEnd = weekEnd,
Dispatchers = dispatchers,
MyWorkOrders = myWorkOrders,
Types = types,
Search = search
};
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("board/search")]
public async Task<IActionResult> SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _advancedSearchService.SearchAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
/// <summary>
/// Dispatcher dropdown options. Currently returns all non-deleted users
/// (role-filtered assignee list deferred until roles settle).
/// </summary>
[HttpGet("lookups/dispatchers")]
public async Task<IActionResult> GetDispatcherLookups()
{
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
return Ok(result);
}
[HttpPatch("{id:int}/board")]
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId);
return Ok(row);
}
catch (WorkOrderBoardConcurrencyException ex)
{
return Conflict(new WorkOrderBoardConflictDto
{
CurrentState = ex.CurrentState
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("board")]
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCreateService.CreateAsync(request, actorId);
return Ok(row);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber")
{
return Conflict(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
[HttpPost("{id:int}/cancel")]
public async Task<IActionResult> CancelBoardWorkOrder(int id)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCancelService.CancelAsync(id, actorId);
return Ok(row);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
}
}

View file

@ -0,0 +1,139 @@
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderCompletionController : Controller
{
private readonly IWorkOrderCompletionService _workOrderCompletionService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderCompletionController(
IWorkOrderCompletionService workOrderCompletionService,
IFileStoragePort fileStorage)
{
_workOrderCompletionService = workOrderCompletionService;
_fileStorage = fileStorage;
}
[HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? serviceKey = null,
[FromQuery] WorkOrderType? workOrderType = null)
{
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
return Ok(templates);
}
[HttpGet("completion-templates/{id:int}")]
public async Task<IActionResult> GetCompletionTemplate(int id)
{
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found." });
return Ok(template);
}
[Authorize(Roles = "Admin")]
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
{
try
{
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
return Ok(created);
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[Authorize(Roles = "Admin")]
[HttpPut("completion-templates/{id:int}")]
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
{
try
{
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
return Ok(updated);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[Authorize(Roles = "Admin")]
[HttpDelete("completion-templates/{id:int}")]
public async Task<IActionResult> DeleteCompletionTemplate(int id)
{
try
{
await _workOrderCompletionService.DeleteTemplateAsync(id);
return NoContent();
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(30_000_000)]
public async Task<IActionResult> UploadCompletionDoc(
int id,
[FromForm] IFormFile file,
[FromForm] string? signOffName = null,
[FromForm] string? signOffSignature = null,
[FromForm] string? workOrderVersion = null)
{
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." });
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file);
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
id,
new WorkOrderCompletionDocUploadDto
{
SignOffName = signOffName,
SignOffSignature = signOffSignature,
WorkOrderVersion = workOrderVersion
},
fileUrl,
actorId);
return Ok(result);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
{
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}
}

View file

@ -5,9 +5,9 @@ using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using SeaHaven.DataServices.Models;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
@ -19,16 +19,13 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderController : Controller
{
private readonly IWorkOrderService _workOrderService;
private readonly IWorkOrderDispatchService _dispatchService;
private readonly ILogger<WorkOrderController> _logger;
public WorkOrderController(
IWorkOrderService workOrderService,
IWorkOrderDispatchService dispatchService,
ILogger<WorkOrderController> logger)
{
_workOrderService = workOrderService;
_dispatchService = dispatchService;
_logger = logger;
}
@ -134,6 +131,7 @@ namespace Api.SeaHavenIndustries.Controllers
return Ok(viewModel);
}
[Authorize(Roles = "Admin")]
[HttpDelete]
[Route("DeleteWorkorder")]
public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model)
@ -344,188 +342,5 @@ namespace Api.SeaHavenIndustries.Controllers
var data = await _workOrderService.GetWorkordersAsync();
return Ok(data);
}
[HttpPost]
[Route("DispatchToVendor")]
public async Task<IActionResult> DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new DispatchToVendorInput
{
WorkOrderIds = model.WorkOrderIds,
VendorIds = model.VendorIds,
NTEAmount = model.NTEAmount,
Description = model.Description,
ScheduledDate = model.ScheduledDate,
TaskListTemplateId = model.TaskListTemplateId,
CustomChecklistItems = model.CustomChecklistItems,
UserId = userId
};
var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken);
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("GetDispatches/{workOrderId}")]
public async Task<IActionResult> GetDispatches(int workOrderId)
{
var data = await _dispatchService.GetDispatchesAsync(workOrderId);
return Ok(data);
}
[HttpGet("GetDispatch/{id}")]
public async Task<IActionResult> GetDispatchById(int id)
{
var d = await _dispatchService.GetDispatchDetailAsync(id);
if (d == null)
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(d);
}
[HttpPost("UpdateDispatch")]
public async Task<IActionResult> UpdateDispatch([FromBody] UpdateDispatch_DTO model)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new UpdateDispatchInput
{
Id = model.Id,
Status = model.Status,
NTEAmount = model.NTEAmount,
ScheduledDate = model.ScheduledDate,
CompletedDate = model.CompletedDate,
Description = model.Description,
UserId = userId
};
var success = await _dispatchService.UpdateDispatchAsync(input);
if (!success)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(new { message = "Dispatch updated" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("AddDispatchComment")]
public async Task<IActionResult> AddDispatchComment([FromBody] DispatchComment_DTO model)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new AddDispatchCommentInput
{
DispatchId = model.DispatchId,
Text = model.Text,
SendEmail = model.SendEmail,
UserId = userId
};
var result = await _dispatchService.AddDispatchCommentAsync(input);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("VerifyDispatch")]
public async Task<IActionResult> VerifyDispatch(int dispatchId)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (DispatchVerificationException ex)
{
return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("AddDispatchSignoff")]
public async Task<IActionResult> AddDispatchSignoff([FromBody] DispatchSignoff_DTO model)
{
try
{
var input = new AddDispatchSignoffInput
{
DispatchId = model.DispatchId,
SignoffType = model.SignoffType,
Name = model.Name,
Signature = model.Signature,
SignatureMethod = model.SignatureMethod
};
var result = await _dispatchService.AddDispatchSignoffAsync(input);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("UpdateChecklistItem")]
public async Task<IActionResult> UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model)
{
var input = new ChecklistItemUpdateInput
{
Id = model.Id,
IsCompleted = model.IsCompleted,
CompletedBy = model.CompletedBy
};
var result = await _dispatchService.UpdateChecklistItemAsync(input);
if (result == null)
return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
return Ok(result);
}
[HttpPost("AddChecklistItem")]
public async Task<IActionResult> AddChecklistItem([FromBody] AddChecklistItem_DTO model)
{
var input = new AddChecklistItemInput
{
DispatchId = model.DispatchId,
WorkOrderId = model.WorkOrderId,
ItemText = model.ItemText
};
var result = await _dispatchService.AddChecklistItemAsync(input);
return Ok(result);
}
}
}

View file

@ -0,0 +1,75 @@
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderDetailController : Controller
{
private readonly IWorkOrderDetailService _workOrderDetailService;
private readonly IWorkOrderCommentService _workOrderCommentService;
public WorkOrderDetailController(
IWorkOrderDetailService workOrderDetailService,
IWorkOrderCommentService workOrderCommentService)
{
_workOrderDetailService = workOrderDetailService;
_workOrderCommentService = workOrderCommentService;
}
[HttpGet("{id:int}/detail")]
public async Task<IActionResult> GetDetail(int id)
{
var detail = await _workOrderDetailService.GetDetailAsync(id);
if (detail == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(detail);
}
[HttpGet("{id:int}/audit")]
public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50)
{
var audit = await _workOrderDetailService.GetAuditAsync(id, limit);
if (audit == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(audit);
}
[HttpGet("{id:int}/comments")]
public async Task<IActionResult> GetBoardComments(int id)
{
var comments = await _workOrderCommentService.GetCommentsAsync(id);
if (comments == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(comments);
}
[HttpPost("{id:int}/comments")]
public async Task<IActionResult> AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}
}

View file

@ -0,0 +1,214 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderDispatchController : Controller
{
private readonly IWorkOrderDispatchService _dispatchService;
private readonly ILogger<WorkOrderDispatchController> _logger;
public WorkOrderDispatchController(
IWorkOrderDispatchService dispatchService,
ILogger<WorkOrderDispatchController> logger)
{
_dispatchService = dispatchService;
_logger = logger;
}
[HttpPost]
[Route("DispatchToVendor")]
public async Task<IActionResult> DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new DispatchToVendorInput
{
WorkOrderIds = model.WorkOrderIds,
VendorIds = model.VendorIds,
NTEAmount = model.NTEAmount,
Description = model.Description,
ScheduledDate = model.ScheduledDate,
TaskListTemplateId = model.TaskListTemplateId,
CustomChecklistItems = model.CustomChecklistItems,
UserId = userId
};
var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken);
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("GetDispatches/{workOrderId}")]
public async Task<IActionResult> GetDispatches(int workOrderId)
{
var data = await _dispatchService.GetDispatchesAsync(workOrderId);
return Ok(data);
}
[HttpGet("GetDispatch/{id}")]
public async Task<IActionResult> GetDispatchById(int id)
{
var d = await _dispatchService.GetDispatchDetailAsync(id);
if (d == null)
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(d);
}
[HttpPost("UpdateDispatch")]
public async Task<IActionResult> UpdateDispatch([FromBody] UpdateDispatch_DTO model)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new UpdateDispatchInput
{
Id = model.Id,
Status = model.Status,
NTEAmount = model.NTEAmount,
ScheduledDate = model.ScheduledDate,
CompletedDate = model.CompletedDate,
Description = model.Description,
UserId = userId
};
var success = await _dispatchService.UpdateDispatchAsync(input);
if (!success)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(new { message = "Dispatch updated" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("AddDispatchComment")]
public async Task<IActionResult> AddDispatchComment([FromBody] DispatchComment_DTO model)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new AddDispatchCommentInput
{
DispatchId = model.DispatchId,
Text = model.Text,
SendEmail = model.SendEmail,
UserId = userId
};
var result = await _dispatchService.AddDispatchCommentAsync(input);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("VerifyDispatch")]
public async Task<IActionResult> VerifyDispatch(int dispatchId)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (DispatchVerificationException ex)
{
return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("AddDispatchSignoff")]
public async Task<IActionResult> AddDispatchSignoff([FromBody] DispatchSignoff_DTO model)
{
try
{
var input = new AddDispatchSignoffInput
{
DispatchId = model.DispatchId,
SignoffType = model.SignoffType,
Name = model.Name,
Signature = model.Signature,
SignatureMethod = model.SignatureMethod
};
var result = await _dispatchService.AddDispatchSignoffAsync(input);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("UpdateChecklistItem")]
public async Task<IActionResult> UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model)
{
var input = new ChecklistItemUpdateInput
{
Id = model.Id,
IsCompleted = model.IsCompleted,
CompletedBy = model.CompletedBy
};
var result = await _dispatchService.UpdateChecklistItemAsync(input);
if (result == null)
return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
return Ok(result);
}
[HttpPost("AddChecklistItem")]
public async Task<IActionResult> AddChecklistItem([FromBody] AddChecklistItem_DTO model)
{
var input = new AddChecklistItemInput
{
DispatchId = model.DispatchId,
WorkOrderId = model.WorkOrderId,
ItemText = model.ItemText
};
var result = await _dispatchService.AddChecklistItemAsync(input);
return Ok(result);
}
}
}

View file

@ -0,0 +1,50 @@
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[ApiController]
[Route("api/workorders")]
public class WorkOrderIngestController : ControllerBase
{
private const int MaxBatchSize = 100;
private readonly IWorkOrderIngestService _ingestService;
public WorkOrderIngestController(IWorkOrderIngestService ingestService)
{
_ingestService = ingestService;
}
/// <summary>
/// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target).
/// Auth: X-Ingest-Key header.
/// </summary>
[HttpPost("ingest")]
[IngestApiKey]
[ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)]
public async Task<ActionResult<WorkOrderIngestBatchResultDto>> Ingest(
[FromBody] WorkOrderIngestBatchRequestDto? batch,
CancellationToken cancellationToken)
{
var items = NormalizeItems(batch);
if (items.Count == 0)
return BadRequest("At least one item with externalWorkOrderId is required.");
if (items.Count > MaxBatchSize)
return BadRequest($"Max {MaxBatchSize} items per request.");
var result = await _ingestService.UpsertBatchAsync(items, cancellationToken);
return Ok(result);
}
private static List<WorkOrderIngestPayloadDto> NormalizeItems(WorkOrderIngestBatchRequestDto? batch)
{
if (batch?.Items is { Count: > 0 })
return batch.Items;
return new List<WorkOrderIngestPayloadDto>();
}
}
}

View file

@ -0,0 +1,58 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
/// <summary>Ops-only endpoints for manual domain job reprocessing.</summary>
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/workorders/jobs")]
public class WorkOrderJobsController : ControllerBase
{
private readonly IWorkOrderWeekRolledService _weekRolledService;
private readonly IPastDueCacheService _pastDueCacheService;
public WorkOrderJobsController(
IWorkOrderWeekRolledService weekRolledService,
IPastDueCacheService pastDueCacheService)
{
_weekRolledService = weekRolledService;
_pastDueCacheService = pastDueCacheService;
}
/// <summary>
/// Reprocess WeekRolled for a specific operational week (Monday start).
/// Idempotent via WorkOrderWeekRolledLedger.
/// </summary>
[HttpPost("week-rolled")]
[ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<WeekRolledJobResult>> RunWeekRolled(
[FromQuery] DateOnly? sourceWeekStart,
CancellationToken cancellationToken)
{
var weekStart = sourceWeekStart
?? WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
if (weekStart.DayOfWeek != DayOfWeek.Monday)
return BadRequest("sourceWeekStart must be a Monday (operational week start).");
var result = await _weekRolledService.ProcessWeekRolledAsync(weekStart, cancellationToken);
return Ok(result);
}
/// <summary>
/// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read).
/// </summary>
[HttpPost("past-due-cache")]
[ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<PastDueCacheJobResult>> RunPastDueCache(CancellationToken cancellationToken)
{
var result = await _pastDueCacheService.RefreshAsync(cancellationToken);
return Ok(result);
}
}
}

View file

@ -0,0 +1,85 @@
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderMediaController : Controller
{
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderMediaController(
IWorkOrderMediaService workOrderMediaService,
IFileStoragePort fileStorage)
{
_workOrderMediaService = workOrderMediaService;
_fileStorage = fileStorage;
}
[HttpGet("{id:int}/media")]
public async Task<IActionResult> GetMedia(int id)
{
var media = await _workOrderMediaService.GetMediaAsync(id);
if (media == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(media);
}
[HttpPost("{id:int}/media")]
[RequestSizeLimit(30_000_000)]
public async Task<IActionResult> AddMedia(
int id,
[FromForm] WorkOrderMediaCategory category,
[FromForm] IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." });
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file);
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
return Ok(media);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpDelete("{id:int}/media/{mediaId:int}")]
public async Task<IActionResult> DeleteMedia(int id, int mediaId)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId);
return NoContent();
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
/// <summary>Ops health and coexistence metrics (Fase 7).</summary>
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/workorders/ops")]
public class WorkOrderOpsController : ControllerBase
{
private readonly IWorkOrderOpsHealthService _healthService;
public WorkOrderOpsController(IWorkOrderOpsHealthService healthService)
{
_healthService = healthService;
}
[HttpGet("health")]
[ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)]
public async Task<ActionResult<WorkOrderOpsHealthDto>> GetHealth(CancellationToken cancellationToken)
{
var health = await _healthService.GetHealthAsync(cancellationToken);
return Ok(health);
}
}
}

View file

@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
public List<int> VendorIds { get; set; } = new();
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string? Description { get; set; }
public int? TaskListTemplateId { get; set; }
public List<string>? CustomChecklistItems { get; set; }

View file

@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Status { get; set; }
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public DateTime? CompletedDate { get; set; }
public string? Description { get; set; }
}

View file

@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Api.SeaHavenIndustries.Filters
{
public class ArgumentExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is not ArgumentException ex) return;
context.Result = new BadRequestObjectResult(new
{
status = "Error",
message = ex.Message,
code = 400
});
context.ExceptionHandled = true;
}
}
}

View file

@ -0,0 +1,67 @@
using System.Security.Cryptography;
using System.Text;
using Api.SeaHavenIndustries.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
namespace Api.SeaHavenIndustries.Filters
{
/// <summary>Validates X-Ingest-Key for POST /api/workorders/ingest.</summary>
public class IngestApiKeyFilter : IAsyncActionFilter
{
private readonly WorkOrderIngestOptions _options;
public IngestApiKeyFilter(IOptions<WorkOrderIngestOptions> options)
{
_options = options.Value;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!_options.Enabled)
{
context.Result = new ObjectResult(new { message = "Work order ingest is disabled." })
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
return;
}
if (string.IsNullOrWhiteSpace(_options.ApiKey)
|| _options.ApiKey.Contains("${", StringComparison.Ordinal))
{
context.Result = new ObjectResult(new { message = "Ingest API key is not configured." })
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
return;
}
if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided)
|| !FixedTimeEquals(provided.ToString(), _options.ApiKey))
{
context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." });
return;
}
await next();
}
private static bool FixedTimeEquals(string provided, string expected)
{
var providedBytes = Encoding.UTF8.GetBytes(provided);
var expectedBytes = Encoding.UTF8.GetBytes(expected);
return providedBytes.Length == expectedBytes.Length
&& CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes);
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class IngestApiKeyAttribute : ServiceFilterAttribute
{
public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter))
{
}
}
}

View file

@ -0,0 +1,70 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace Api.SeaHavenIndustries.Helper
{
public enum DocumentScanResult
{
Passed,
Infected,
Unavailable
}
public interface IDocumentScanner
{
Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
}
public class ClamAvDocumentScanner : IDocumentScanner
{
private readonly IConfiguration _configuration;
public ClamAvDocumentScanner(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
{
var host = _configuration["ClamAV:Host"];
var port = _configuration.GetValue<int?>("ClamAV:Port") ?? 3310;
if (string.IsNullOrWhiteSpace(host) || !File.Exists(path))
return DocumentScanResult.Unavailable;
try
{
using var client = new TcpClient();
await client.ConnectAsync(host, port, cancellationToken);
await using var network = client.GetStream();
await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken);
await using var file = File.OpenRead(path);
var buffer = new byte[64 * 1024];
while (true)
{
var count = await file.ReadAsync(buffer, cancellationToken);
if (count == 0) break;
var length = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, count);
await network.WriteAsync(length, cancellationToken);
await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
}
await network.WriteAsync(new byte[4], cancellationToken);
await network.FlushAsync(cancellationToken);
var responseBuffer = new byte[1024];
var responseLength = await network.ReadAsync(responseBuffer, cancellationToken);
var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength);
if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
return DocumentScanResult.Infected;
return response.Contains("OK", StringComparison.OrdinalIgnoreCase)
? DocumentScanResult.Passed
: DocumentScanResult.Unavailable;
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
return DocumentScanResult.Unavailable;
}
}
}
}

View file

@ -0,0 +1,78 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Helper
{
public class VendorDocumentScanWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<VendorDocumentScanWorker> _logger;
public VendorDocumentScanWorker(
IServiceScopeFactory scopeFactory,
IWebHostEnvironment environment,
ILogger<VendorDocumentScanWorker> logger)
{
_scopeFactory = scopeFactory;
_environment = environment;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ScanPendingAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
private async Task ScanPendingAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
var pending = await db.VendorCompletionDocuments
.Where(document => document.ScanStatus == "Pending")
.OrderBy(document => document.CreatedDate)
.Take(20)
.ToListAsync(cancellationToken);
foreach (var document in pending)
{
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
var result = await scanner.ScanAsync(path, cancellationToken);
if (result == DocumentScanResult.Unavailable)
{
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
continue;
}
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
document.RejectionReason = result == DocumentScanResult.Infected
? "The upload failed malware scanning."
: document.RejectionReason;
document.ScannedAt = DateTime.UtcNow;
document.LastModificationTime = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
if (result == DocumentScanResult.Infected && File.Exists(path))
File.Delete(path);
}
}
}
public static class VendorDocumentStorage
{
public static string Root(string contentRoot) =>
Path.Combine(contentRoot, "App_Data", "vendor-documents");
public static string ResolvePath(string contentRoot, int vendorId, int dispatchId, string storedFileName) =>
Path.Combine(Root(contentRoot), vendorId.ToString(), dispatchId.ToString(), storedFileName);
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
ResolvePath(contentRoot, document.VendorId, document.DispatchId, document.StoredFileName);
}
}

View file

@ -0,0 +1,76 @@
using Microsoft.Extensions.Options;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
public class PastDueCacheHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PastDueCacheHostedService> _logger;
private readonly PastDueCacheJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public PastDueCacheHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<PastDueCacheHostedService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value.PastDueCache;
_runState = runState;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_options.Enabled && ShouldRunNow())
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
if (await RunJobAsync(stoppingToken))
_lastRunDate = today;
}
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IPastDueCacheService>();
await service.RefreshAsync(stoppingToken);
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
_runState.LastPastDueCacheError = null;
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastPastDueCacheError = ex.Message;
_logger.LogError(ex, "PastDue cache hosted job failed.");
return false;
}
}
private bool ShouldRunNow()
{
var now = DateTime.UtcNow;
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
runAt = new TimeOnly(0, 10);
return TimeOnly.FromDateTime(now) >= runAt;
}
}
}

View file

@ -0,0 +1,84 @@
using Microsoft.Extensions.Options;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
public class WorkOrderWeekRolledHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<WorkOrderWeekRolledHostedService> _logger;
private readonly WeekRolledJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public WorkOrderWeekRolledHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<WorkOrderWeekRolledHostedService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value.WeekRolled;
_runState = runState;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_options.Enabled && ShouldRunNow())
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
if (await RunJobAsync(stoppingToken))
_lastRunDate = today;
}
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderWeekRolledService>();
await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken);
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
_runState.LastWeekRolledError = null;
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastWeekRolledError = ex.Message;
_logger.LogError(ex, "WeekRolled hosted job failed.");
return false;
}
}
private bool ShouldRunNow()
{
var now = DateTime.UtcNow;
if (!Enum.TryParse<DayOfWeek>(_options.DayOfWeek, ignoreCase: true, out var targetDay))
targetDay = DayOfWeek.Monday;
if (now.DayOfWeek != targetDay)
return false;
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
runAt = new TimeOnly(0, 5);
return TimeOnly.FromDateTime(now) >= runAt;
}
}
}

View file

@ -72,4 +72,37 @@ namespace Api.SeaHavenIndustries.Infrastructure
return token.Token;
}
}
public class VendorDocumentStorageAdapter : IVendorDocumentStoragePort
{
private readonly IWebHostEnvironment _environment;
public VendorDocumentStorageAdapter(IWebHostEnvironment environment)
{
_environment = environment;
}
public async Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
{
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var file = File.Create(path);
await content.CopyToAsync(file, cancellationToken);
}
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
{
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
return File.OpenRead(path);
}
public void Delete(int vendorId, int dispatchId, string storedFileName)
{
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}

View file

@ -0,0 +1,57 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
namespace Api.SeaHavenIndustries.Middleware
{
/// <summary>Adds Sunset/Deprecation headers to legacy WorkOrder REST routes.</summary>
public class LegacyDeprecationMiddleware
{
private static readonly HashSet<string> LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase)
{
"GetWorkOrderList",
"AddWorkorder",
"EditWorkorder",
"ChangeStatus",
"ChangeAssignment",
"DeleteWorkorder",
"GetWorkorderById"
};
private readonly RequestDelegate _next;
private readonly LegacyEndpointOptions _options;
public LegacyDeprecationMiddleware(RequestDelegate next, IOptions<LegacyEndpointOptions> options)
{
_next = next;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
if (_options.DeprecationEnabled)
{
var path = context.Request.Path.Value ?? "";
var isLegacyWo = (path.Contains("/WorkOrder", StringComparison.OrdinalIgnoreCase)
|| path.Contains("/workorders", StringComparison.OrdinalIgnoreCase))
&& LegacyPathSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase));
if (isLegacyWo)
{
context.Response.OnStarting(() =>
{
context.Response.Headers["Deprecation"] = "true";
if (!string.IsNullOrWhiteSpace(_options.SunsetDate)
&& DateTimeOffset.TryParse(_options.SunsetDate, out var sunset))
{
context.Response.Headers["Sunset"] = sunset.UtcDateTime.ToString("r");
}
context.Response.Headers["Link"] = "</api/workorders/board>; rel=\"successor-version\"";
return Task.CompletedTask;
});
}
}
await _next(context);
}
}
}

View file

@ -0,0 +1,12 @@
namespace Api.SeaHavenIndustries.Options
{
public class LegacyEndpointOptions
{
public const string SectionName = "LegacyEndpoints";
public bool DeprecationEnabled { get; set; }
/// <summary>ISO date returned in Sunset header (e.g. 2026-12-31).</summary>
public string? SunsetDate { get; set; }
}
}

View file

@ -0,0 +1,10 @@
namespace Api.SeaHavenIndustries.Options
{
public class SyncOptions
{
public const string SectionName = "Sync";
/// <summary>When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest.</summary>
public bool Enabled { get; set; }
}
}

View file

@ -0,0 +1,13 @@
namespace Api.SeaHavenIndustries.Options
{
public class WorkOrderIngestOptions
{
public const string SectionName = "WorkOrderIngest";
/// <summary>When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned.</summary>
public bool Enabled { get; set; }
/// <summary>Shared secret for X-Ingest-Key header (Lambda / service accounts).</summary>
public string? ApiKey { get; set; }
}
}

View file

@ -0,0 +1,23 @@
namespace Api.SeaHavenIndustries.Options
{
public class WorkOrderJobsOptions
{
public const string SectionName = "WorkOrderJobs";
public WeekRolledJobOptions WeekRolled { get; set; } = new();
public PastDueCacheJobOptions PastDueCache { get; set; } = new();
}
public class WeekRolledJobOptions
{
public bool Enabled { get; set; } = true;
public string RunAtUtc { get; set; } = "00:05";
public string DayOfWeek { get; set; } = "Monday";
}
public class PastDueCacheJobOptions
{
public bool Enabled { get; set; }
public string RunAtUtc { get; set; } = "00:10";
}
}

View file

@ -1,4 +1,7 @@
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
@ -9,6 +12,8 @@ using Microsoft.OpenApi.Models;
using System.Text;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration;
@ -25,6 +30,7 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
builder.Services.AddControllers(options =>
{
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
@ -40,6 +46,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
builder.Services.AddScoped<SendMessage>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
builder.Services.AddHostedService<VendorDocumentScanWorker>();
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
{
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
@ -57,6 +66,41 @@ builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorDocumentStoragePort, Api.SeaHavenIndustries.Infrastructure.VendorDocumentStorageAdapter>();
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
builder.Services.AddOptions<WorkOrderIngestOptions>()
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
.Validate(
o => !o.Enabled
|| (!string.IsNullOrWhiteSpace(o.ApiKey)
&& !o.ApiKey.Contains("${", StringComparison.Ordinal)
&& o.ApiKey.Length >= 32),
"WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.")
.ValidateOnStart();
builder.Services.Configure<SyncOptions>(
builder.Configuration.GetSection(SyncOptions.SectionName));
builder.Services.Configure<LegacyEndpointOptions>(
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
builder.Services.AddSingleton<IWorkOrderJobRunStateAccessor>(
sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>());
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
builder.Services.AddHostedService<PastDueCacheHostedService>();
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
Microsoft.Extensions.Options.IOptions<LegacyEndpointOptions>>(
(health, sync, ingest, legacy) =>
{
health.SyncEnabled = sync.Value.Enabled;
health.IngestEnabled = ingest.Value.Enabled;
health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled;
health.LegacySunsetDate = legacy.Value.SunsetDate;
});
builder.Services.AddAuthentication(options =>
{
@ -125,6 +169,7 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCors();
app.UseMiddleware<LegacyDeprecationMiddleware>();
app.UseAuthentication();
app.UseAuthorization();

View file

@ -14,6 +14,12 @@
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Production must supply a reachable ClamAV Host (environment variable ClamAV__Host).
// While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked).
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://console.seahavenind.com",

View file

@ -1,4 +1,4 @@
{
{
"ConnectionStrings": {
//"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
@ -17,11 +17,34 @@
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Malware scanner for vendor completion document uploads. While Host is empty the
// scanner is considered unavailable and uploaded files stay quarantined (download
// returns 423 Locked until a scan passes). Production must supply a real Host.
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://localhost:4200",
"ValidIssuer": "http://localhost:7195",
// Provide the real value via environment variable JWT__Secret or user-secrets.
"Secret": "${JWT_SECRET}"
}
}
},
"WorkOrderJobs": {
"WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" },
"PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" }
},
"WorkOrderIngest": {
"Enabled": false,
"ApiKey": "${WORKORDER_INGEST_API_KEY}"
},
"Sync": {
"Enabled": true
},
"LegacyEndpoints": {
"DeprecationEnabled": false,
"SunsetDate": "2026-12-31"
},
"FrontendBaseUrl": "https://shoc.seahaven.com"
}

View file

@ -38,10 +38,51 @@ namespace Data.SeaHavenIndustries
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
.IsUnique();
builder.Entity<WorkOrderWeekRolledLedger>()
.HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
.IsUnique();
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
.IsRowVersion();
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
builder.Entity<WorkOrder>()
.Property(w => w.SiteCode)
.HasMaxLength(32);
builder.Entity<WorkOrder>()
.Property(w => w.InternalWONumber)
.HasMaxLength(11);
builder.Entity<WorkOrder>()
.HasIndex(w => w.SiteCode)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasIndex(w => w.InternalWONumber)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.Property(w => w.ExternalWorkOrderId)
.HasMaxLength(450);
builder.Entity<WorkOrder>()
.HasIndex(w => w.ExternalWorkOrderId)
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
builder.Entity<Dispatch>()
.Property(d => d.RowVersion)
.IsRowVersion();
@ -61,6 +102,16 @@ namespace Data.SeaHavenIndustries
.WithMany(c => c.Vendors)
.HasForeignKey(v => v.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<SitePreferredVendor>()
.HasIndex(p => new { p.LocationId, p.VendorId, p.Trade })
.IsUnique()
.HasFilter("[Trade] IS NOT NULL");
builder.Entity<SitePreferredVendor>()
.HasIndex(p => new { p.LocationId, p.VendorId })
.IsUnique()
.HasFilter("[Trade] IS NULL");
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -74,13 +125,17 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderContacts> WorkOrderContacts { get; set; }
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
public DbSet<VendorCompletionDocument> VendorCompletionDocuments { get; set; }
public DbSet<SitePreferredVendor> SitePreferredVendors { get; set; }
public DbSet<Dispatch> Dispatches { get; set; }
public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; }
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
@ -106,6 +161,51 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; }
public override int SaveChanges()
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpRowVersionsForInMemoryProvider()
{
if (!Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? true)
return;
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
foreach (var entry in ChangeTracker.Entries<Dispatch>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
}
private static byte[] IncrementRowVersion(byte[]? current)
{
var bytes = current != null && current.Length > 0
? (byte[])current.Clone()
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
for (var i = 0; i < bytes.Length; i++)
{
if (++bytes[i] != 0)
break;
}
return bytes;
}
}
public class ApplicationUser : IdentityUser

View file

@ -0,0 +1,14 @@
namespace Data.SeaHavenIndustries.Enums
{
/// <summary>
/// Shared terminal lifecycle statuses for EF-translatable filters and board rules.
/// </summary>
public static class LifecycleStatusSets
{
public static readonly LifecycleStatus?[] Terminal =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase4_SearchIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(32)",
maxLength: 32,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(11)",
maxLength: 11,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.CreateIndex(
name: "IX_workOrders_SiteCode",
table: "workOrders",
column: "SiteCode",
filter: "[istemplate] = 0");
migrationBuilder.CreateIndex(
name: "IX_workOrders_InternalWONumber",
table: "workOrders",
column: "InternalWONumber",
filter: "[istemplate] = 0");
migrationBuilder.CreateIndex(
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
table: "workOrders",
columns: new[] { "LifecycleStatus", "ScheduledDate" },
filter: "[istemplate] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_workOrders_SiteCode",
table: "workOrders");
migrationBuilder.DropIndex(
name: "IX_workOrders_InternalWONumber",
table: "workOrders");
migrationBuilder.DropIndex(
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
table: "workOrders");
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(32)",
oldMaxLength: 32);
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(11)",
oldMaxLength: 11);
}
}
}

View file

@ -0,0 +1,88 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260625120000_Phase6_CompletionSlideOver")]
public partial class Phase6_CompletionSlideOver : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL
BEGIN
ALTER TABLE workOrderAttachments ADD Category int NULL;
END
");
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NULL
BEGIN
CREATE TABLE CompletionDocTemplates (
Id int NOT NULL IDENTITY(1,1),
Name nvarchar(max) NOT NULL,
ServiceKey nvarchar(450) NOT NULL,
WorkOrderType int NULL,
TemplateUrl nvarchar(max) NOT NULL,
IsActive bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL,
CONSTRAINT PK_CompletionDocTemplates PRIMARY KEY (Id)
);
END
");
migrationBuilder.Sql(@"
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'IX_CompletionDocTemplates_ServiceKey_IsActive'
AND object_id = OBJECT_ID(N'dbo.CompletionDocTemplates'))
BEGIN
CREATE INDEX IX_CompletionDocTemplates_ServiceKey_IsActive
ON CompletionDocTemplates (ServiceKey, IsActive);
END
");
migrationBuilder.Sql(@"
UPDATE workOrders
SET DocStatus = 1
WHERE DocStatus IS NULL
AND SignOffAttachment IS NOT NULL
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
UPDATE workOrders
SET DocStatus = NULL
WHERE DocStatus = 1
AND SignOffAttachment IS NOT NULL
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
");
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL
DROP TABLE CompletionDocTemplates;
");
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL
ALTER TABLE workOrderAttachments DROP COLUMN Category;
");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase1_BoardIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
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
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
BEGIN
CREATE INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders (AssignTo, ScheduledDate);
END
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_WorkOrderType'
AND object_id = OBJECT_ID('workOrders'))
BEGIN
CREATE INDEX IX_workOrders_WorkOrderType ON workOrders (WorkOrderType);
END
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_WorkOrderType'
AND object_id = OBJECT_ID('workOrders'))
DROP INDEX IX_workOrders_WorkOrderType ON workOrders;
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
DROP INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders;
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;
""");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase5_DomainEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkOrderWeekRolledLedgers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
SourceWeekStart = table.Column<DateOnly>(type: "date", nullable: false),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CorrelationId = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkOrderWeekRolledLedgers", x => x.Id);
table.ForeignKey(
name: "FK_WorkOrderWeekRolledLedgers_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_WorkOrderWeekRolledLedgers_WorkOrderId_SourceWeekStart",
table: "WorkOrderWeekRolledLedgers",
columns: new[] { "WorkOrderId", "SourceWeekStart" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkOrderWeekRolledLedgers");
}
}
}

View file

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ExternalWorkOrderId",
table: "workOrders",
type: "nvarchar(450)",
maxLength: 450,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_workOrders_ExternalWorkOrderId",
table: "workOrders",
column: "ExternalWorkOrderId",
unique: true,
filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_workOrders_ExternalWorkOrderId",
table: "workOrders");
migrationBuilder.AlterColumn<string>(
name: "ExternalWorkOrderId",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
}
}
}

View file

@ -0,0 +1,121 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715160000_WoServiceNotesStructuredFields")]
public partial class WoServiceNotesStructuredFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PocName",
table: "workOrders",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExtraServices",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ServiceNotes",
table: "workOrders",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "Dispatches",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "Dispatches",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PocName",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PocPhone",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PocNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExtraServices",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ServiceNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TechPhone",
table: "workOrders");
migrationBuilder.DropColumn(
name: "VendorNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TechPhone",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "VendorNotes",
table: "Dispatches");
}
}
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715180000_WoFlagColor")]
public partial class WoFlagColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FlagColor",
table: "workOrders",
type: "nvarchar(7)",
maxLength: 7,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FlagColor",
table: "workOrders");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddVendorRoadmapOperations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AvailabilityStatus",
table: "Vendors",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unknown");
migrationBuilder.AddColumn<DateTime>(
name: "AvailabilityUpdatedAt",
table: "Vendors",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CommercialStatusUpdatedAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedArrivalAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "EtaManualOverride",
table: "Dispatches",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "InvoiceNumber",
table: "Dispatches",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Not Submitted");
migrationBuilder.AddColumn<string>(
name: "PaymentStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unavailable");
migrationBuilder.CreateTable(
name: "SitePreferredVendors",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
LocationId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false),
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SitePreferredVendors", x => x.Id);
table.ForeignKey(
name: "FK_SitePreferredVendors_Locations_LocationId",
column: x => x.LocationId,
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SitePreferredVendors_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "VendorCompletionDocuments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorId = table.Column<int>(type: "int", nullable: false),
DispatchId = table.Column<int>(type: "int", nullable: false),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
Version = table.Column<int>(type: "int", nullable: false),
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_Dispatches_DispatchId",
column: x => x.DispatchId,
principalTable: "Dispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_LocationId_VendorId",
table: "SitePreferredVendors",
columns: new[] { "LocationId", "VendorId" },
unique: true,
filter: "[Trade] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_LocationId_VendorId_Trade",
table: "SitePreferredVendors",
columns: new[] { "LocationId", "VendorId", "Trade" },
unique: true,
filter: "[Trade] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_VendorId",
table: "SitePreferredVendors",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_DispatchId",
table: "VendorCompletionDocuments",
column: "DispatchId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_VendorId",
table: "VendorCompletionDocuments",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_WorkOrderId",
table: "VendorCompletionDocuments",
column: "WorkOrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SitePreferredVendors");
migrationBuilder.DropTable(
name: "VendorCompletionDocuments");
migrationBuilder.DropColumn(
name: "AvailabilityStatus",
table: "Vendors");
migrationBuilder.DropColumn(
name: "AvailabilityUpdatedAt",
table: "Vendors");
migrationBuilder.DropColumn(
name: "CommercialStatusUpdatedAt",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "EstimatedArrivalAt",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "EtaManualOverride",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "InvoiceNumber",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "InvoiceStatus",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "PaymentStatus",
table: "Dispatches");
}
}
}

View file

@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Accounts");
b.ToTable("Accounts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("Addresses");
b.ToTable("Addresses", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Assets");
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
@ -371,7 +371,62 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Categories");
b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ServiceKey")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TemplateUrl")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ServiceKey", "IsActive")
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
b.ToTable("CompletionDocTemplates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
@ -438,7 +493,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkerOrderId");
b.ToTable("Comments");
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
@ -504,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("ContactDetails");
b.ToTable("ContactDetails", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
@ -596,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Contacts");
b.ToTable("Contacts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
@ -614,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Departments");
b.ToTable("Departments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
@ -631,6 +686,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CommercialStatusUpdatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -655,6 +713,19 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<bool>("EmailSent")
.HasColumnType("bit");
b.Property<DateTime?>("EstimatedArrivalAt")
.HasColumnType("datetime2");
b.Property<bool>("EtaManualOverride")
.HasColumnType("bit");
b.Property<string>("InvoiceNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("InvoiceStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -670,6 +741,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PONumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("PaymentStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ReplyToAddress")
.HasColumnType("nvarchar(max)");
@ -684,9 +759,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Status")
.HasColumnType("nvarchar(max)");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("datetime2");
@ -705,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("Dispatches");
b.ToTable("Dispatches", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
@ -764,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchChecklistItems");
b.ToTable("DispatchChecklistItems", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
@ -818,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("DispatchId");
b.ToTable("DispatchSignoffs");
b.ToTable("DispatchSignoffs", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
@ -888,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations
.IsUnique()
.HasFilter("[Status] = 'Pending'");
b.ToTable("DispatchUpliftRequests");
b.ToTable("DispatchUpliftRequests", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
@ -911,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders");
b.ToTable("DispatchWorkOrders", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
@ -941,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("DropdownOptions");
b.ToTable("DropdownOptions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
@ -1046,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("RegionId");
b.ToTable("Employees");
b.ToTable("Employees", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
@ -1086,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId")
.IsUnique();
b.ToTable("EmployeeAddresses");
b.ToTable("EmployeeAddresses", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
@ -1114,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeeEmails");
b.ToTable("EmployeeEmails", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
@ -1144,7 +1227,87 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeePhones");
b.ToTable("EmployeePhones", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AllDay")
.HasColumnType("bit");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<string>("EndTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("EventColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Location")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<int?>("RecordID")
.HasColumnType("int");
b.Property<string>("RecordName")
.HasColumnType("nvarchar(max)");
b.Property<string>("RecordTargetType")
.HasColumnType("nvarchar(max)");
b.Property<int>("ReminderMinutes")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<string>("StartTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Events");
});
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
@ -1212,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("FollowUps");
b.ToTable("FollowUps", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
@ -1237,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("ForgetPasswordCodes");
b.ToTable("ForgetPasswordCodes", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
@ -1255,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("JobTitles");
b.ToTable("JobTitles", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
@ -1325,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Locations");
b.ToTable("Locations", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
@ -1383,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("PMSchedules");
b.ToTable("PMSchedules", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
@ -1457,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("Quotes");
b.ToTable("Quotes", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
@ -1505,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("QuoteId");
b.ToTable("QuotesLines");
b.ToTable("QuotesLines", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
@ -1523,7 +1686,66 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Regions");
b.ToTable("Regions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<int>("LocationId")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("Trade")
.HasColumnType("nvarchar(450)");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("VendorId");
b.HasIndex("LocationId", "VendorId")
.IsUnique()
.HasFilter("[Trade] IS NULL");
b.HasIndex("LocationId", "VendorId", "Trade")
.IsUnique()
.HasFilter("[Trade] IS NOT NULL");
b.ToTable("SitePreferredVendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
@ -1566,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("TaskListTemplates");
b.ToTable("TaskListTemplates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
@ -1611,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("TaskListTemplateId");
b.ToTable("TaskListTemplateItems");
b.ToTable("TaskListTemplateItems", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
@ -1713,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Templates");
b.ToTable("Templates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
@ -1727,6 +1949,13 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("AvailabilityStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("AvailabilityUpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
@ -1794,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("CompanyId");
b.ToTable("Vendors");
b.ToTable("Vendors", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
@ -1853,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("VendorId");
b.ToTable("VendorAccessTokens");
b.ToTable("VendorAccessTokens", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
@ -1958,6 +2187,93 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("VendorCompanies");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<int>("DispatchId")
.HasColumnType("int");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RejectionReason")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ReplacesDocumentId")
.HasColumnType("int");
b.Property<string>("ReviewStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime2");
b.Property<string>("ScanStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ScannedAt")
.HasColumnType("datetime2");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("StoredFileName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<int>("Version")
.HasColumnType("int");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("DispatchId");
b.HasIndex("VendorId");
b.HasIndex("WorkOrderId");
b.ToTable("VendorCompletionDocuments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{
b.Property<int>("Id")
@ -2024,10 +2340,20 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("datetime2");
b.Property<string>("ExternalWorkOrderId")
.HasColumnType("nvarchar(max)");
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("ExtraServices")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("FlagColor")
.HasMaxLength(7)
.HasColumnType("nvarchar(7)");
b.Property<string>("InternalWONumber")
.HasColumnType("nvarchar(max)");
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -2059,6 +2385,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PO")
.HasColumnType("nvarchar(max)");
b.Property<string>("PocName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PocNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("PrimaryDispatchId")
.HasColumnType("int");
@ -2092,6 +2430,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ServiceNotes")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
@ -2105,7 +2447,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasColumnType("nvarchar(max)");
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
@ -2125,12 +2468,20 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateOnly?>("TargetWeek")
.HasColumnType("date");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Trade")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("VendorNTE")
.HasColumnType("decimal(18,2)");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
@ -2150,11 +2501,30 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo");
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("ExternalWorkOrderId")
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
b.HasIndex("InternalWONumber")
.HasFilter("[istemplate] = 0");
b.HasIndex("LifecycleStatus", "ScheduledDate")
.HasFilter("[istemplate] = 0");
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
b.ToTable("workOrders");
b.HasIndex("ScheduledDate");
b.HasIndex("SiteCode")
.HasFilter("[istemplate] = 0");
b.HasIndex("WorkOrderType");
b.ToTable("workOrders", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -2168,6 +2538,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Attachments")
.HasColumnType("nvarchar(max)");
b.Property<int?>("Category")
.HasColumnType("int");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
@ -2196,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderAttachments");
b.ToTable("workOrderAttachments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@ -2246,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("WorkOrderAuditLogs");
b.ToTable("WorkOrderAuditLogs", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@ -2290,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderCategories");
b.ToTable("workOrderCategories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@ -2337,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("WorkOrderContacts");
b.ToTable("WorkOrderContacts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@ -2366,7 +2739,65 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks");
b.ToTable("WorkOrderFieldLocks", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@ -2796,6 +3227,25 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
.WithMany()
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany("SitePreferences")
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Location");
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
{
b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template")
@ -2870,6 +3320,33 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
.WithMany("CompletionDocuments")
.HasForeignKey("DispatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Dispatch");
b.Navigation("Vendor");
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
@ -2956,6 +3433,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
@ -3060,6 +3548,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Comments");
b.Navigation("CompletionDocuments");
b.Navigation("DispatchWorkOrders");
b.Navigation("Signoffs");
@ -3106,6 +3596,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("AuditLogs");
b.Navigation("Dispatches");
b.Navigation("SitePreferences");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>

View file

@ -0,0 +1,13 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class CompletionDocTemplate : FullAuditEntity
{
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
}
}

View file

@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries
public string? Description { get; set; }
public string? Status { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string InvoiceStatus { get; set; } = "Not Submitted";
public string PaymentStatus { get; set; } = "Unavailable";
public string? InvoiceNumber { get; set; }
public DateTime? CommercialStatusUpdatedAt { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
@ -28,6 +34,13 @@ namespace Data.SeaHavenIndustries
public string? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
/// <summary>Tech/crew phone for this dispatch (overrides Vendor.Phone on board).</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Instructions for the vendor for this appointment/dispatch.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }
@ -35,5 +48,6 @@ namespace Data.SeaHavenIndustries
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
public ICollection<DispatchSignoff>? Signoffs { get; set; }
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
}
}

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class SitePreferredVendor : FullAuditEntity
{
public int LocationId { get; set; }
[ForeignKey(nameof(LocationId))]
public Locations? Location { get; set; }
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public Vendor? Vendor { get; set; }
public string? Trade { get; set; }
public int SortOrder { get; set; }
public string? UpdatedBy { get; set; }
}
}

View file

@ -18,6 +18,8 @@ namespace Data.SeaHavenIndustries
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public string? PreferredContact { get; set; }
public string AvailabilityStatus { get; set; } = "Unknown";
public DateTime? AvailabilityUpdatedAt { get; set; }
public int? CompanyId { get; set; }
[ForeignKey(nameof(CompanyId))]
@ -27,5 +29,6 @@ namespace Data.SeaHavenIndustries
public int TotalJobs { get; set; }
public ICollection<Dispatch>? Dispatches { get; set; }
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
public ICollection<SitePreferredVendor>? SitePreferences { get; set; }
}
}

View file

@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class VendorCompletionDocument : FullAuditEntity
{
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public Vendor? Vendor { get; set; }
public int DispatchId { get; set; }
[ForeignKey(nameof(DispatchId))]
public Dispatch? Dispatch { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public WorkOrder? WorkOrder { get; set; }
public string OriginalFileName { get; set; } = "";
public string StoredFileName { get; set; } = "";
public string ContentType { get; set; } = "";
public long SizeBytes { get; set; }
public string ScanStatus { get; set; } = "Pending";
public string ReviewStatus { get; set; } = "Processing";
public string? RejectionReason { get; set; }
public int Version { get; set; } = 1;
public int? ReplacesDocumentId { get; set; }
public DateTime? ScannedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
}
}

View file

@ -1,15 +1,14 @@
using System;
using System.ComponentModel.DataAnnotations.Schema;
using static System.Runtime.InteropServices.JavaScript.JSType;
using System.ComponentModel.DataAnnotations.Schema;
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class WorkOrderAttachments : FullAuditEntity
{
public string? Attachments { get; set; }
public WorkOrderMediaCategory? Category { get; set; }
public int? WorkorderId { get; set; }
[ForeignKey(nameof(WorkorderId))]
public virtual WorkOrder? WorkOrder { get; set; }
}
}

View file

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class WorkOrderWeekRolledLedger
{
public int Id { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public virtual WorkOrder? WorkOrder { get; set; }
public DateOnly SourceWeekStart { get; set; }
public DateTime ProcessedAt { get; set; }
public string CorrelationId { get; set; } = "";
}
}

View file

@ -27,11 +27,31 @@ namespace Data.SeaHavenIndustries
public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)]
public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; }
// --- Service & Notes / POC (structured create) ---
[MaxLength(200)]
public string? PocName { get; set; }
[MaxLength(50)]
public string? PocPhone { get; set; }
[MaxLength(2000)]
public string? PocNotes { get; set; }
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
[MaxLength(2000)]
public string? ExtraServices { get; set; }
[MaxLength(4000)]
public string? ServiceNotes { get; set; }
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
// --- Scheduling slice ---
public DateTime? ScheduledDate { get; set; }
public DateTime? ScheduledStart { get; set; }
@ -51,6 +71,10 @@ namespace Data.SeaHavenIndustries
// --- Completion slice ---
public DocStatus? DocStatus { get; set; }
/// <summary>Board flag color (#RRGGBB from fixed palette). Null = no flag.</summary>
[MaxLength(7)]
public string? FlagColor { get; set; }
// --- Legacy SQL columns (drift reconciliation) ---
public string? AvettaTask { get; set; }
public DateOnly? AssignDate { get; set; }

View file

@ -0,0 +1,127 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardProjection
{
public static async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
IQueryable<WorkOrder> query,
bool isUnscheduled)
{
var rows = await query
.Select(w => new
{
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
ContactPoc = w.WorkOrderContacts!
.OrderBy(c => c.Id)
.Select(c => new
{
Name = c.POC != null
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
: null,
PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null,
c.Notes
})
.FirstOrDefault(),
w.LifecycleStatus,
w.LegacyStatus,
w.Status,
w.AssignTo,
w.AssignToUser!.FirstName,
w.AssignToUser!.LastName,
w.AssignToUser!.Initials,
w.AssignToUser!.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.CompanyName
: null,
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.ContactName
: null,
DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null,
WoTechPhone = w.TechPhone,
VendorPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.Phone
: null,
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
w.Trade,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
})
.ToListAsync();
return rows.Select(w =>
{
var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name;
var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber;
var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes;
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
return new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
pocName,
pocPhone,
pocNotes,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.AssignTo,
w.FirstName,
w.LastName,
w.Initials,
w.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
w.VendorId,
w.VendorName,
w.TechName,
techPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
isUnscheduled);
}).ToList();
}
}
}

View file

@ -0,0 +1,148 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardQueryFilters
{
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
/// <summary>
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
/// <paramref name="currentUserId"/> is set, that takes precedence and
/// <paramref name="dispatchers"/> is ignored.
/// </summary>
public static IQueryable<WorkOrder> ApplyDispatcherFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? dispatchers,
bool myWorkOrders,
string? currentUserId)
{
if (myWorkOrders && !string.IsNullOrEmpty(currentUserId))
return query.Where(w => w.AssignTo == currentUserId);
if (dispatchers == null || dispatchers.Count == 0)
return query;
var ids = dispatchers
.Where(d => d != "__unassigned__")
.ToList();
var includeUnassigned = dispatchers.Contains("__unassigned__");
if (includeUnassigned && ids.Count == 0)
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
if (includeUnassigned && ids.Count > 0)
{
return query.Where(w =>
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
}
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
}
public static IQueryable<WorkOrder> ApplyTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types)
{
if (types == null || types.Count == 0)
return query;
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)
{
if (sites == null || sites.Count == 0)
return query;
var normalized = sites
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.ToList();
if (normalized.Count == 0)
return query;
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
}
public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)
{
if (statuses == null || statuses.Count == 0)
return query;
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
}
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<DocStatus>? docStatuses)
{
if (docStatuses == null || docStatuses.Count == 0)
return query;
return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value));
}
public static IQueryable<WorkOrder> ApplyVendorFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? vendorIds)
{
if (vendorIds == null || vendorIds.Count == 0)
return query;
return query.Where(w =>
w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId));
}
public static IQueryable<WorkOrder> ApplyPmTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? pmTypes)
{
if (pmTypes == null || pmTypes.Count == 0)
return query;
var normalized = pmTypes
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim().ToLower())
.ToList();
if (normalized.Count == 0)
return query;
return query.Where(w =>
normalized.Any(pm =>
(w.Trade ?? "").ToLower().Contains(pm)
|| (w.Problem ?? "").ToLower().Contains(pm)));
}
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> query,
DateOnly dateFrom,
DateOnly dateTo)
{
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
return query.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date >= fromDate
&& w.ScheduledDate.Value.Date <= toDate)
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo));
}
#endregion
}
}

View file

@ -0,0 +1,47 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardSearchFilter
{
public static string? NormalizeSearch(string? search)
{
if (string.IsNullOrWhiteSpace(search))
return null;
var trimmed = search.Trim();
return trimmed.Length < 2 ? null : trimmed;
}
public static IQueryable<WorkOrder> Apply(IQueryable<WorkOrder> query, string? search)
{
var normalized = NormalizeSearch(search);
if (normalized == null)
return query;
var s = normalized.ToLower();
return query.Where(w =>
(w.SiteCode ?? "").ToLower().Contains(s)
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|| (w.Trade ?? "").ToLower().Contains(s)
|| (w.Problem ?? "").ToLower().Contains(s)
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|| (w.Status ?? "").ToLower().Contains(s)
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))
|| (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s))
|| (w.LifecycleStatus == LifecycleStatus.EnRoute && ("en route".Contains(s) || "enroute".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.OnSite && ("on site".Contains(s) || "onsite".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.InProgress && ("in progress".Contains(s) || "inprogress".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s))
|| (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s))));
}
}
}

View file

@ -0,0 +1,103 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
{
private readonly ApplicationDbContext _context;
public CompletionDocTemplateDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
return await query
.OrderBy(t => t.Name)
.Select(MapRow)
.ToListAsync();
}
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
{
if (!string.IsNullOrWhiteSpace(trade))
{
var byTrade = await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade)
.OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1)
.ThenBy(t => t.Name)
.ThenBy(t => t.Id)
.Select(MapRow)
.FirstOrDefaultAsync();
if (byTrade != null)
return byTrade;
}
if (!workOrderType.HasValue)
return null;
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType)
.OrderBy(t => t.Name)
.ThenBy(t => t.Id)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
{
template.CreatedDate = DateTime.UtcNow;
await _context.CompletionDocTemplates.AddAsync(template);
await _context.SaveChangesAsync();
return template;
}
public async Task UpdateAsync(CompletionDocTemplate template)
{
template.LastModificationTime = DateTime.UtcNow;
_context.CompletionDocTemplates.Update(template);
await _context.SaveChangesAsync();
}
public async Task<bool> DeleteAsync(int id)
{
var entity = await _context.CompletionDocTemplates.FindAsync(id);
if (entity == null)
return false;
entity.IsDeleted = true;
entity.DeletionTime = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}
}

View file

@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
public async Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
{
var query = _context.Locations.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.ToLower();
query = query.Where(l =>
(l.Name ?? "").ToLower().Contains(s) ||
(l.City ?? "").ToLower().Contains(s));
}
return await query
.OrderBy(l => l.Name)
.Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State))
.ToListAsync();
}
public async Task<Locations> AddAsync(Locations location)
{
location.CreatedDate = DateTime.Now;

View file

@ -151,5 +151,27 @@ namespace SeaHaven.DataServices.Implementation
.Select(user => user.Email)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids)
{
var idList = ids
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
.ToList();
if (idList.Count == 0)
return new Dictionary<string, string>(StringComparer.Ordinal);
var users = await _context.Users
.AsNoTracking()
.Where(u => idList.Contains(u.Id))
.Select(u => new { u.Id, u.FirstName, u.LastName })
.ToListAsync();
return users.ToDictionary(
u => u.Id,
u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(),
StringComparer.Ordinal);
}
}
}

View file

@ -0,0 +1,43 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class VendorDocumentDataService : IVendorDocumentDataService
{
private readonly ApplicationDbContext _context;
public VendorDocumentDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return _context.VendorCompletionDocuments
.Where(document => document.DispatchId == dispatchId)
.OrderByDescending(document => document.Version)
.FirstOrDefaultAsync(cancellationToken);
}
public Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken)
{
return _context.VendorCompletionDocuments
.FirstOrDefaultAsync(document => document.Id == documentId
&& document.DispatchId == dispatchId
&& document.VendorId == vendorId
&& (document.IsDeleted == null || document.IsDeleted == false), cancellationToken);
}
public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken)
{
await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken);
}
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
return _context.SaveChangesAsync(cancellationToken);
}
}
}

View file

@ -0,0 +1,231 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Implementation
{
public class VendorOperationsDataService : IVendorOperationsDataService
{
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
private readonly ApplicationDbContext _context;
private readonly IDispatchDataService _dispatchDataService;
public VendorOperationsDataService(ApplicationDbContext context, IDispatchDataService dispatchDataService)
{
_context = context;
_dispatchDataService = dispatchDataService;
}
public async Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken)
{
var directlyAssignedIds = await _context.Dispatches
.AsNoTracking()
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
.Select(d => d.WorkOrderId!.Value)
.ToListAsync(cancellationToken);
var multiplyAssignedIds = await _context.DispatchWorkOrders
.AsNoTracking()
.Where(link => link.Dispatch != null
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
.Select(link => link.WorkOrderId)
.ToListAsync(cancellationToken);
return directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
}
public async Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(
DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken)
{
return await _context.workOrders
.AsNoTracking()
.Where(wo => wo.ScheduledDate.HasValue
&& wo.ScheduledDate.Value <= cutoff
&& wo.LifecycleStatus != LifecycleStatus.Completed
&& wo.LifecycleStatus != LifecycleStatus.Canceled
&& !excludeIds.Contains(wo.Id))
.OrderBy(wo => wo.ScheduledDate)
.Select(wo => new NoVendorCandidateReadModel
{
Id = wo.Id,
InternalWONumber = wo.InternalWONumber,
WorkerOrderNumber = wo.WorkerOrderNumber,
ScheduledDate = wo.ScheduledDate
})
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken)
{
var direct = _context.Dispatches.AsNoTracking()
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
&& dispatch.WorkOrderId.HasValue
&& (dispatch.ScheduledDate.HasValue
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
.Select(dispatch => new ScheduledDispatchReadModel
{
Id = dispatch.Id,
VendorId = dispatch.VendorId,
VendorName = dispatch.Vendor!.CompanyName,
WorkOrderId = dispatch.WorkOrderId!.Value,
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
End = dispatch.WorkOrder.ScheduledEnd
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
});
var linked = _context.DispatchWorkOrders.AsNoTracking()
.Where(link => link.Dispatch != null
&& link.WorkOrder != null
&& link.Dispatch.WorkOrderId != link.WorkOrderId
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
.Select(link => new ScheduledDispatchReadModel
{
Id = link.DispatchId,
VendorId = link.Dispatch!.VendorId,
VendorName = link.Dispatch.Vendor!.CompanyName,
WorkOrderId = link.WorkOrderId,
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
End = link.WorkOrder.ScheduledEnd
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)
});
return await direct.Concat(linked).ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken)
{
return await _context.Vendors.AsNoTracking()
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
.Select(v => new ActiveVendorReadModel
{
Id = v.Id,
CompanyName = v.CompanyName,
ContactName = v.ContactName,
TradeSpecialties = v.TradeSpecialties,
AvailabilityStatus = v.AvailabilityStatus,
AvailabilityUpdatedAt = v.AvailabilityUpdatedAt
})
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken)
{
return await _context.SitePreferredVendors
.AsNoTracking()
.Where(p => p.LocationId == locationId)
.Include(p => p.Vendor)
.OrderBy(p => p.SortOrder)
.ToListAsync(cancellationToken);
}
public async Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken)
{
return await _context.SitePreferredVendors
.Where(p => p.LocationId == locationId)
.ToListAsync(cancellationToken);
}
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))
.ToDictionaryAsync(vendor => vendor.Id, cancellationToken);
public async Task<IReadOnlyList<InsightDispatchData>> GetInsightDispatchDataAsync(
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken)
{
var query = _context.Dispatches.AsNoTracking()
.Include(dispatch => dispatch.Vendor)
.Include(dispatch => dispatch.WorkOrder)
.Where(dispatch => dispatch.Vendor != null);
if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from);
if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to);
if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId);
if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade);
if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId);
return await query.Select(dispatch => new InsightDispatchData
{
VendorId = dispatch.VendorId,
VendorName = dispatch.Vendor!.CompanyName,
Status = dispatch.Status,
DispatchedAt = dispatch.DispatchedAt,
CreatedDate = dispatch.CreatedDate,
ScheduledDate = dispatch.ScheduledDate,
AcknowledgedAt = dispatch.AcknowledgedAt,
CompletedDate = dispatch.CompletedDate,
DueDate = dispatch.WorkOrder != null ? dispatch.WorkOrder.DueDate : null
}).ToListAsync(cancellationToken);
}
public Task<Vendor?> GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken)
=> _context.Vendors.FirstOrDefaultAsync(v => v.Id == vendorId, cancellationToken);
public Task<VendorCompletionDocument?> GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken)
=> _context.VendorCompletionDocuments.FirstOrDefaultAsync(d => d.Id == documentId, cancellationToken);
public Task<Dispatch?> GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken)
=> _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken);
public Task<WorkOrder?> GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
public async Task<Dispatch?> GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
var linkedDispatchIds = _context.DispatchWorkOrders
.Where(link => link.WorkOrderId == workOrderId)
.Select(link => link.DispatchId);
return await _context.Dispatches
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
.OrderByDescending(item => item.CreatedDate)
.FirstOrDefaultAsync(cancellationToken);
}
public Task<string> GenerateNextDispatchNumberAsync(CancellationToken cancellationToken)
=> _dispatchDataService.GenerateNextDispatchNumberAsync();
public Task<string> GenerateNextPONumberAsync(CancellationToken cancellationToken)
=> _dispatchDataService.GenerateNextPONumberAsync();
public Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken)
{
_context.Dispatches.Add(dispatch);
return Task.CompletedTask;
}
public Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken)
{
_context.WorkOrderAuditLogs.Add(log);
return Task.CompletedTask;
}
public Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken)
{
_context.VendorAuditLogs.Add(log);
return Task.CompletedTask;
}
public Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken)
{
_context.SitePreferredVendors.Add(preference);
return Task.CompletedTask;
}
public Task RemoveSitePreferredVendorsAsync(IEnumerable<SitePreferredVendor> preferences, CancellationToken cancellationToken)
{
_context.SitePreferredVendors.RemoveRange(preferences);
return Task.CompletedTask;
}
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}
}

View file

@ -0,0 +1,64 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderAdvancedSearchDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query)
{
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses);
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
if (normalizedSearch != null)
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
var totalCount = await baseQuery.CountAsync();
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
}
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)
{
var descending = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase);
var field = (sortBy ?? "scheduledDate").Trim().ToLowerInvariant();
return field switch
{
"wonumber" or "woNumber" => descending
? query.OrderByDescending(w => w.InternalWONumber)
: query.OrderBy(w => w.InternalWONumber),
"duedate" or "dueDate" => descending
? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber)
: query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber),
_ => descending
? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber)
: query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber)
};
}
}
}

View file

@ -0,0 +1,95 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderBoardDataService : IWorkOrderBoardDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderBoardDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query)
{
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
var scheduledBase = baseQuery.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date >= weekStartDate.Date
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
|| (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart));
var unscheduledBase = baseQuery.Where(w =>
w.ScheduledDate == null
&& !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus));
var scheduledTotal = await scheduledBase.CountAsync();
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
if (normalizedSearch != null)
{
scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch);
unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch);
}
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber),
isUnscheduled: false);
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
unscheduledBase.OrderBy(w => w.InternalWONumber),
isUnscheduled: true);
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
}
public async Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync()
{
return await _context.Users
.AsNoTracking()
.Where(u => u.IsDeleted != true)
.OrderBy(u => u.FirstName)
.ThenBy(u => u.LastName)
.Select(u => new DispatcherLookupRow(
u.Id,
u.FirstName ?? "",
u.LastName,
u.Initials,
u.Color))
.ToListAsync();
}
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId)
{
var query = _context.workOrders
.AsNoTracking()
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
return rows.FirstOrDefault();
}
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
{
return await _context.workOrders
.AsNoTracking()
.AnyAsync(w =>
w.Id != excludeWorkOrderId
&& w.istemplate != true
&& (w.IsDeleted != true || w.IsDeleted == null)
&& w.InternalWONumber == normalizedWoNumber);
}
}
}

View file

@ -0,0 +1,92 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderBoardMutationDataService : IWorkOrderBoardMutationDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderBoardMutationDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<bool> VendorExistsAsync(int vendorId, CancellationToken cancellationToken)
=> _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true, cancellationToken);
public async Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken)
=> await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
cancellationToken);
public Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken)
=> _context.Dispatches.FirstOrDefaultAsync(
d => d.Id == dispatchId && d.WorkOrderId == workOrderId,
cancellationToken);
public Task<Dispatch?> GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken)
=> _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken);
public void TrackNewWorkOrder(WorkOrder workOrder)
=> _context.workOrders.Add(workOrder);
public void TrackNewDispatch(Dispatch dispatch)
=> _context.Dispatches.Add(dispatch);
public void TrackWorkOrderContact(int workOrderId, int contactId, string? notes)
=> _context.WorkOrderContacts.Add(new WorkOrderContacts
{
WorkorderId = workOrderId,
ContactId = contactId,
Notes = notes
});
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version)
=> _context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = version;
public bool HasPendingChanges()
=> _context.ChangeTracker.HasChanges();
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
{
await using var transaction = _context.Database.IsRelational()
? await _context.Database.BeginTransactionAsync(cancellationToken)
: null;
try
{
await work(cancellationToken);
if (transaction is not null)
await transaction.CommitAsync(cancellationToken);
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
public async Task<BoardSaveOutcome> SaveAsync(CancellationToken cancellationToken)
{
try
{
await _context.SaveChangesAsync(cancellationToken);
return BoardSaveOutcome.Saved;
}
catch (DbUpdateConcurrencyException)
{
_context.ChangeTracker.Clear();
return BoardSaveOutcome.ConcurrencyConflict;
}
}
}
}

View file

@ -0,0 +1,30 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderCompletionDataService : IWorkOrderCompletionDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderCompletionDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
cancellationToken);
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}
}

View file

@ -0,0 +1,95 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderDetailDataService : IWorkOrderDetailDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderDetailDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<bool> ExistsAsync(int workOrderId)
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.AnyAsync(w => w.Id == workOrderId);
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
{
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.Where(w => w.Id == workOrderId)
.Select(w => new WorkOrderDetailExtendedFields(
w.Description,
w.Trade,
w.Problem,
w.SubTrade,
w.OriginalWeek,
w.OriginalDate,
w.CompletedDate,
w.SignOffName,
w.SignOffAttachment,
w.SignOffSignature,
w.ExtraServices,
w.ServiceNotes,
w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null
? w.PrimaryDispatch.VendorNotes
: w.VendorNotes))
.FirstOrDefaultAsync();
}
public async Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId)
{
return await _context.Comments
.AsNoTracking()
.Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true)
.OrderByDescending(c => c.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null)
{
var query = _context.WorkOrderAuditLogs
.AsNoTracking()
.Where(a => a.WorkOrderId == workOrderId)
.OrderByDescending(a => a.CreatedAt);
if (limit.HasValue)
return await query.Take(limit.Value).ToListAsync();
return await query.ToListAsync();
}
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
.OrderByDescending(a => a.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
{
return await _context.DispatchSignoffs
.AsNoTracking()
.Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true)
.OrderByDescending(s => s.SignedAt)
.Select(s => new DispatchSignoffRow(
s.DispatchId,
s.SignoffType,
s.Name,
s.SignedAt))
.ToListAsync();
}
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
{
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.FirstOrDefaultAsync(w => w.Id == workOrderId);
}
}
}

View file

@ -0,0 +1,166 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService
{
private static readonly LifecycleStatus?[] TerminalStatuses =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
private readonly ApplicationDbContext _context;
public WorkOrderDomainJobDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
DateOnly sourceWeekStart,
DateOnly sourceWeekEnd,
int batchSize = 500,
CancellationToken cancellationToken = default)
{
var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue);
var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue);
// Always return the first page of remaining unprocessed candidates.
// Callers must not offset-page: ledger writes shrink this set after each batch.
return await _context.workOrders
.AsNoTracking()
.Where(w => w.IsDeleted != true)
.Where(w => w.istemplate != true)
.Where(w => w.ScheduledDate != null)
.Where(w =>
w.ScheduledDate!.Value.Date >= weekStartDate.Date
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
.Where(w => !TerminalStatuses.Contains(w.LifecycleStatus))
.Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l =>
l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart))
.OrderBy(w => w.Id)
.Take(batchSize)
.Select(w => w.Id)
.ToListAsync(cancellationToken);
}
public async Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
int workOrderId,
DateOnly sourceWeekStart,
string correlationId,
CancellationToken cancellationToken = default)
{
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
var alreadyProcessed = await _context.WorkOrderWeekRolledLedgers
.AnyAsync(l => l.WorkOrderId == workOrderId && l.SourceWeekStart == sourceWeekStart, cancellationToken);
if (alreadyProcessed)
{
await transaction.RollbackAsync(cancellationToken);
return new WeekRolledProcessOutcome { Skipped = true };
}
var workOrder = await _context.workOrders
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
if (workOrder == null)
{
await transaction.RollbackAsync(cancellationToken);
return new WeekRolledProcessOutcome { Skipped = true };
}
_context.WorkOrderWeekRolledLedgers.Add(new WorkOrderWeekRolledLedger
{
WorkOrderId = workOrderId,
SourceWeekStart = sourceWeekStart,
ProcessedAt = DateTime.UtcNow,
CorrelationId = correlationId
});
var oldCarriedOver = workOrder.CarriedOver;
workOrder.CarriedOver = oldCarriedOver + 1;
try
{
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return new WeekRolledProcessOutcome { Skipped = true };
}
return new WeekRolledProcessOutcome
{
Processed = true,
OldCarriedOver = oldCarriedOver,
NewCarriedOver = workOrder.CarriedOver
};
}
public async Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
DateTime utcNow,
int batchSize = 500,
CancellationToken cancellationToken = default)
{
var today = utcNow.Date;
var setCount = 0;
var clearedCount = 0;
var examinedCount = 0;
var skip = 0;
while (true)
{
var batch = await _context.workOrders
.Where(w => w.IsDeleted != true)
.Where(w => w.istemplate != true)
.Where(w => w.ScheduledDate != null)
.OrderBy(w => w.Id)
.Skip(skip)
.Take(batchSize)
.ToListAsync(cancellationToken);
if (batch.Count == 0)
break;
foreach (var workOrder in batch)
{
examinedCount++;
var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);
if (shouldBePastDue && !hasFlag)
{
workOrder.OperationalFlags |= OperationalFlags.PastDue;
setCount++;
}
else if (!shouldBePastDue && hasFlag)
{
workOrder.OperationalFlags &= ~OperationalFlags.PastDue;
clearedCount++;
}
}
await _context.SaveChangesAsync(cancellationToken);
skip += batchSize;
if (batch.Count < batchSize)
break;
}
return new PastDueRefreshResult
{
SetCount = setCount,
ClearedCount = clearedCount,
ExaminedCount = examinedCount
};
}
}
}

View file

@ -22,9 +22,13 @@ namespace SeaHaven.DataServices.Implementation
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName, cancellationToken);
}
public void EnqueueLock(int workOrderId, string fieldName, string? userId)
public async Task EnqueueLockAsync(
int workOrderId,
string fieldName,
string? userId,
CancellationToken cancellationToken)
{
if (IsTracked(workOrderId, fieldName))
if (await IsLockedAsync(workOrderId, fieldName, cancellationToken))
return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock

View file

@ -0,0 +1,57 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderIngestDataService : IWorkOrderIngestDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderIngestDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken)
=> await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
public Task<WorkOrder?> GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWorkOrderId, cancellationToken);
public void TrackWorkOrder(WorkOrder workOrder)
=> _context.workOrders.Add(workOrder);
public Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken)
=> _context.Locations.FirstOrDefaultAsync(l => l.Name == name || l.Title == name, cancellationToken);
public async Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken)
{
_context.Locations.Add(location);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
{
await using var transaction = _context.Database.IsRelational()
? await _context.Database.BeginTransactionAsync(cancellationToken)
: null;
try
{
await work(cancellationToken);
if (transaction is not null)
await transaction.CommitAsync(cancellationToken);
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}
}

View file

@ -0,0 +1,30 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderMediaDataService : IWorkOrderMediaDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderMediaDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
=> _context.workOrderAttachments.FirstOrDefaultAsync(
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,
cancellationToken);
public void TrackAttachment(WorkOrderAttachments attachment)
=> _context.workOrderAttachments.Add(attachment);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}
}

View file

@ -0,0 +1,32 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderOpsHealthDataService : IWorkOrderOpsHealthDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderOpsHealthDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<int> CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken)
=> _context.WorkOrderAuditLogs
.CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken);
public Task<int> CountFieldLocksAsync(CancellationToken cancellationToken)
=> _context.WorkOrderFieldLocks.CountAsync(cancellationToken);
public async Task<DateTime?> GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken)
{
if (!await _context.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken))
return null;
return await _context.WorkOrderWeekRolledLedgers
.MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken);
}
}
}

View file

@ -2,6 +2,8 @@ using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public record SiteOptionRow(int Id, string? Code, string? City, string? State);
public interface ILocationDataService
{
Task<Locations?> GetByIdAsync(int id);
@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces
// For addressbook endpoint - returns simplified DTO
Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null);
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
Task<Locations> AddAsync(Locations location);
Task UpdateAsync(Locations location);
Task DeleteAsync(int id);

View file

@ -18,5 +18,6 @@ namespace SeaHaven.DataServices.Interfaces
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}
}

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorDocumentDataService
{
Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorOperationsDataService
{
Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken);
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken);
Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken);
Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken);
Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken);
Task<IReadOnlyList<InsightDispatchData>> GetInsightDispatchDataAsync(DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken);
Task<Vendor?> GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken);
Task<Dispatch?> GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken);
Task<WorkOrder?> GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<string> GenerateNextDispatchNumberAsync(CancellationToken cancellationToken);
Task<string> GenerateNextPONumberAsync(CancellationToken cancellationToken);
Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken);
Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken);
Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken);
Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken);
Task RemoveSitePreferredVendorsAsync(IEnumerable<SitePreferredVendor> preferences, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderAdvancedSearchDataService
{
Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query);
}
}

View file

@ -0,0 +1,12 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderBoardDataService
{
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
}
}

View file

@ -0,0 +1,27 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public enum BoardSaveOutcome
{
Saved,
ConcurrencyConflict
}
public interface IWorkOrderBoardMutationDataService
{
Task<bool> VendorExistsAsync(int vendorId, CancellationToken cancellationToken);
Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken);
void TrackNewWorkOrder(WorkOrder workOrder);
void TrackNewDispatch(Dispatch dispatch);
void TrackWorkOrderContact(int workOrderId, int contactId, string? notes);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version);
bool HasPendingChanges();
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
Task<BoardSaveOutcome> SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderCompletionDataService
{
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,27 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderDetailDataService
{
Task<bool> ExistsAsync(int workOrderId);
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
}
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
}
}

View file

@ -0,0 +1,37 @@
namespace SeaHaven.DataServices.Interfaces
{
public sealed class WeekRolledProcessOutcome
{
public bool Processed { get; init; }
public bool Skipped { get; init; }
public int OldCarriedOver { get; init; }
public int NewCarriedOver { get; init; }
}
public sealed class PastDueRefreshResult
{
public int SetCount { get; init; }
public int ClearedCount { get; init; }
public int ExaminedCount { get; init; }
}
public interface IWorkOrderDomainJobDataService
{
Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
DateOnly sourceWeekStart,
DateOnly sourceWeekEnd,
int batchSize = 500,
CancellationToken cancellationToken = default);
Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
int workOrderId,
DateOnly sourceWeekStart,
string correlationId,
CancellationToken cancellationToken = default);
Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
DateTime utcNow,
int batchSize = 500,
CancellationToken cancellationToken = default);
}
}

View file

@ -6,6 +6,10 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<bool> IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken);
void EnqueueLock(int workOrderId, string fieldName, string? userId);
Task EnqueueLockAsync(
int workOrderId,
string fieldName,
string? userId,
CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,15 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderIngestDataService
{
Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken);
void TrackWorkOrder(WorkOrder workOrder);
Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken);
Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderMediaDataService
{
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,11 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderOpsHealthDataService
{
Task<int> CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken);
Task<int> CountFieldLocksAsync(CancellationToken cancellationToken);
Task<DateTime?> GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,27 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public record WorkOrderAdvancedSearchQuery(
string? Search,
DateOnly DateFrom,
DateOnly DateTo,
IReadOnlyList<string>? Sites,
IReadOnlyList<WorkOrderType>? Types,
IReadOnlyList<string>? Dispatchers,
IReadOnlyList<LifecycleStatus>? Statuses,
IReadOnlyList<string>? PmTypes,
IReadOnlyList<int>? VendorIds,
IReadOnlyList<DocStatus>? DocStatuses,
bool MyWorkOrders,
string? CurrentUserId,
int Page,
int PageSize,
string SortBy,
string SortDir);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,
int TotalCount);
}

View file

@ -0,0 +1,65 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record WorkOrderBoardQuery(
DateOnly WeekStart,
DateOnly WeekEnd,
IReadOnlyList<string>? Dispatchers,
bool MyWorkOrders,
IReadOnlyList<WorkOrderType>? Types,
string? Search,
string? CurrentUserId);
public record WorkOrderBoardRawRow(
int Id,
string? InternalWONumber,
int RescheduleCount,
int CarriedOver,
WorkOrderType? WorkOrderType,
string? SiteCode,
string? LocationName,
string? PocName,
string? PocPhone,
string? PocNotes,
LifecycleStatus? LifecycleStatus,
string? LegacyStatus,
string? AssignTo,
string? DispatcherFirstName,
string? DispatcherLastName,
string? Initials,
string? Color,
DateTime? DueDate,
DateTime? ScheduledDate,
DateTime? ScheduledStart,
DateTime? ScheduledEnd,
DateOnly? TargetWeek,
bool? ScheduleWeekOnly,
int? VendorId,
string? VendorName,
string? TechName,
string? TechPhone,
DateTime? DispatchApptDate,
string? Trade,
string? Problem,
string? ServiceNotes,
string? ExtraServices,
DocStatus? DocStatus,
string? FlagColor,
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
IReadOnlyList<WorkOrderBoardRawRow> UnscheduledRows,
int ScheduledTotalBeforeSearch);
public record DispatcherLookupRow(
string Id,
string FirstName,
string? LastName,
string? Initials,
string? Color);
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record WorkOrderDetailExtendedFields(
string? Description,
string? Trade,
string? Problem,
string? SubTrade,
DateOnly? OriginalWeek,
DateOnly? OriginalDate,
DateTime? CompletedDate,
string? SignOffName,
string? SignOffAttachment,
string? SignOffSignature,
string? ExtraServices,
string? ServiceNotes,
string? VendorNotes);
public record DispatchSignoffRow(
int DispatchId,
string? SignoffType,
string? Name,
DateTime? SignedAt);
public record CompletionDocTemplateRow(
int Id,
string Name,
string ServiceKey,
WorkOrderType? WorkOrderType,
string TemplateUrl,
bool IsActive);
}

View file

@ -0,0 +1,43 @@
namespace SeaHaven.DataServices.Models;
public sealed class ScheduledDispatchReadModel
{
public int Id { get; set; }
public int VendorId { get; set; }
public string? VendorName { get; set; }
public int WorkOrderId { get; set; }
public string? WorkOrderNumber { get; set; }
public DateTime Start { get; set; }
public DateTime End { get; set; }
}
public sealed class NoVendorCandidateReadModel
{
public int Id { get; set; }
public string? InternalWONumber { get; set; }
public string? WorkerOrderNumber { get; set; }
public DateTime? ScheduledDate { get; set; }
}
public sealed class ActiveVendorReadModel
{
public int Id { get; set; }
public string? CompanyName { get; set; }
public string? ContactName { get; set; }
public string? TradeSpecialties { get; set; }
public string? AvailabilityStatus { get; set; }
public DateTime? AvailabilityUpdatedAt { get; set; }
}
public sealed class InsightDispatchData
{
public int VendorId { get; set; }
public string? VendorName { get; set; }
public string? Status { get; set; }
public DateTime? DispatchedAt { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? AcknowledgedAt { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DueDate { get; set; }
}

Some files were not shown because too many files have changed in this diff Show more