Compare commits

...

5 commits

Author SHA1 Message Date
Alexandre Brandizzi
cae22bce0d
Merge 5877f7817f into d204536215 2026-09-16 22:57:18 +00:00
Alexandre Brandizzi
5877f7817f fix(dashboard): bucket trend by calendar ScheduledDate (SH-350)
ScheduledDate is persisted as a midnight calendar value (board create writes
request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a
datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and
converted to America/New_York, so midnight became 19:00/20:00 the previous day
and every board-scheduled work order fell into the prior bucket: a job scheduled
today read as yesterday and overdue, and the Today bucket showed zero.

Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching
DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the
same rows. Rewrite the daily and yearly trend tests to assert calendar-date
classification (the removed conversion had encoded the shift into their
expectations) and add a regression test that a midnight-today work order stays
in the Today bucket and is not overdue.
2026-09-16 19:57:09 -03:00
Alexandre Brandizzi
d204536215
Merge pull request #121 from Sea-Haven-Industries/feat/ab/sh-278-vendor-area
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-278: add admin-assigned vendor company Area
2026-09-16 17:05:57 -03:00
Alexandre Brandizzi
3047c2ba59
Merge branch 'dev' into feat/ab/sh-278-vendor-area 2026-09-16 17:00:27 -03:00
Alexandre Brandizzi
8515676f4d feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
2026-09-16 11:34:45 -03:00
29 changed files with 5014 additions and 71 deletions

View file

@ -369,9 +369,14 @@ public class DashboardServiceTests
}
[Fact]
public async Task GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate()
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
{
using var ctx = NewContext();
// ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work
// order buckets on its own calendar day regardless of the time-of-day component. The
// UTC hours below were previously shifted a full day by an America/New_York conversion
// (03:00 UTC -> prior evening), so they double as a regression guard: each row must now
// stay on the calendar date it was scheduled for.
ctx.workOrders.AddRange(
new WorkOrder
{
@ -426,17 +431,21 @@ public class DashboardServiceTests
trend.Buckets.Select(bucket => bucket.Label).Should().Equal(
"2026-01-14", "2026-01-15", "2026-01-16");
trend.Buckets[0].Total.Should().Be(2);
trend.Buckets[0].Open.Should().Be(1);
// 01-14: only the canceled row scheduled that calendar day.
trend.Buckets[0].Total.Should().Be(1);
trend.Buckets[0].Open.Should().Be(0);
trend.Buckets[0].Canceled.Should().Be(1);
trend.Buckets[0].Completed.Should().Be(0);
trend.Buckets[0].Overdue.Should().Be(1);
trend.Buckets[0].Overdue.Should().Be(0);
// 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row.
trend.Buckets[1].Total.Should().Be(2);
trend.Buckets[1].Open.Should().Be(1);
trend.Buckets[1].Completed.Should().Be(1);
trend.Buckets[1].Canceled.Should().Be(0);
trend.Buckets[1].Overdue.Should().Be(1);
// 01-16: the 03:00 UTC scheduled row that stays on its own day.
trend.Buckets[2].Total.Should().Be(1);
trend.Buckets[2].Open.Should().Be(1);
trend.Buckets[2].Overdue.Should().Be(1);
@ -444,6 +453,38 @@ public class DashboardServiceTests
trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent);
}
[Fact]
public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue()
{
using var ctx = NewContext();
var today = DashboardBusinessTime.Today();
// A board-scheduled work order for today is stored as midnight (`.Date`). It must land
// in the Today bucket and count as open, not roll back to yesterday and read as overdue.
ctx.workOrders.Add(new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
});
ctx.SaveChanges();
var query = new DashboardTrendQueryDTO
{
DateFrom = today.AddDays(-1),
DateTo = today.AddDays(1)
};
var trend = await NewService(ctx).GetTrendAsync(
AccountUser(1), query, CancellationToken.None);
var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today);
todayBucket.Total.Should().Be(1);
todayBucket.Open.Should().Be(1);
todayBucket.Overdue.Should().Be(0);
var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1));
yesterdayBucket.Total.Should().Be(0);
}
[Fact]
public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets()
{
@ -515,6 +556,8 @@ public class DashboardServiceTests
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc)
},
// 2027-01-01 by calendar: outside the 2026 window. Previously an
// America/New_York conversion pulled it back to 2026-12-31 and into December.
new WorkOrder
{
AccountId = 1,
@ -535,10 +578,10 @@ public class DashboardServiceTests
trend.Buckets[2].Label.Should().Be("2026-03-01");
trend.Buckets[2].Total.Should().Be(1);
trend.Buckets[2].Completed.Should().Be(1);
// December stays empty: the 2027-01-01 row is no longer pulled inside the window.
trend.Buckets[11].Label.Should().Be("2026-12-01");
trend.Buckets[11].Total.Should().Be(1);
trend.Buckets[11].Open.Should().Be(1);
trend.Buckets[11].Overdue.Should().Be(0);
trend.Buckets[11].Total.Should().Be(0);
trend.Buckets[11].Open.Should().Be(0);
trend.Today.Should().Be(DashboardBusinessTime.Today());
}

View file

@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests
public async Task Create_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests
public async Task Create_DuplicateName_ReturnsStable409()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorRosterDuplicateNameException(
"database detail",
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests
public async Task Reconcile_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
};
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Patch_AddsTechnicians_Returns200WithRoster()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
}
[Fact]
public async Task AreaAssignmentForbidden_Returns403OnEveryMutation()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
var controller = NewController(service);
var results = new[]
{
await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None),
await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None),
await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None)
};
foreach (var result in results)
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
}
}

View file

@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
context.Areas.AddRange(
new Area { Id = 1, Name = "East", NormalizedName = "east" },
new Area { Id = 2, Name = "Central", NormalizedName = "central" });
await context.SaveChangesAsync();
var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech");
var service = new VendorCompanyRosterDataService(context);
VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new()
{
CompanyId = companyId,
Name = "Area Co",
Email = "area@example.com",
AreaIdProvided = provided,
AreaId = areaId,
Technicians = context.Vendors.Where(v => v.CompanyId == companyId)
.Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName })
.ToList()
};
var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None);
assigned.AreaId.Should().Be(2);
assigned.AreaName.Should().Be("Central");
var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None);
untouched.AreaId.Should().Be(2);
var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None);
cleared.AreaId.Should().BeNull();
cleared.AreaName.Should().BeNull();
(await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue();
(await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse();
}
}

View file

@ -3,6 +3,7 @@ using FluentAssertions;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterServiceTests
{
private static readonly ClaimsPrincipal Dispatcher = Principal("User");
private static readonly ClaimsPrincipal Admin = Principal("Admin");
private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) },
"Test"));
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>()
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.Technicians.Should().BeEmpty();
@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501)
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests
GoogleMapsUrl = "http://maps.google.com/acme"
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests
{
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
}
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
}
@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme Co",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501)
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests
Technicians = null!
};
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().BeEmpty();
}
@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests
{
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
}
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().ContainSingle();
captured.Technicians.Single().ContactName.Should().BeEmpty();
@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
};
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
}
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", cts.Token);
}, "42", Dispatcher, cts.Token);
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
}
@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests
}
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields!.Notes.Should().Be(notes);
}
@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
// non-null write model made the data layer bump RowVersion and rewrite every
@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(
@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.City.Should().Be("Norfolk");
@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests
.ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource();
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token);
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
}
private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId)
{
var model = SampleReadModel(7, 1);
model.AreaId = areaId;
return model;
}
[Fact]
public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync(false);
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(2));
var result = await NewService(data).CreateRosterAsync(
new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 },
"42",
Admin,
cts.Token);
captured!.AreaId.Should().Be(2);
result.AreaId.Should().Be(2);
data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once);
}
[Fact]
public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
foreach (int? requested in new int?[] { 3, null })
{
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = requested
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
}
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
var captured = new List<VendorCompanyRosterWriteModel>();
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured.Add(model))
.ReturnsAsync(ReadModelWithArea(1));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com",
AreaId = 1
}, "42", Dispatcher, CancellationToken.None);
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com"
}, "42", Dispatcher, CancellationToken.None);
captured.Should().HaveCount(2);
captured[0].AreaIdProvided.Should().BeTrue();
captured[0].AreaId.Should().Be(1);
captured[1].AreaIdProvided.Should().BeFalse();
}
[Fact]
public async Task Reconcile_AdminClearsAreaToUnassigned()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(null));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = null
}, "42", Admin, CancellationToken.None);
captured!.AreaIdProvided.Should().BeTrue();
captured.AreaId.Should().BeNull();
data.Verify(x => x.IsActiveAreaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(null));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
{
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
omitted.AreaIdProvided.Should().BeFalse();
cleared.AreaIdProvided.Should().BeTrue();
cleared.AreaId.Should().BeNull();
assigned.AreaIdProvided.Should().BeTrue();
assigned.AreaId.Should().Be(3);
}
}

View file

@ -72,6 +72,7 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()), Times.Never);
}
@ -155,6 +156,7 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
{
@ -203,6 +205,7 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
It.IsAny<int>(),

View file

@ -470,12 +470,14 @@ public class VendorDataServiceTests
search: "alice",
trades: new[] { "Plumbing" },
locations: new[] { "Chicago, IL" },
jobBuckets: new[] { "under-50" }).ToQueryString();
jobBuckets: new[] { "under-50" },
areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString();
sql.Should().Contain("GROUP BY");
sql.Should().Contain("LEFT JOIN");
sql.Should().Contain("TradeSpecialties");
sql.Should().Contain("TotalJobs");
sql.Should().Contain("AreaId");
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
}
@ -1044,4 +1046,69 @@ public class VendorDataServiceTests
sql.Should().Contain("SELECT");
sql.Should().Contain("VendorCompanies");
}
[Fact]
public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows()
{
await using var context = NewContext();
var east = new Area { Id = 1, Name = "East", NormalizedName = "east" };
var west = new Area { Id = 3, Name = "West", NormalizedName = "west" };
context.Areas.AddRange(east, west);
var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east };
var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west };
var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" };
var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east };
context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly);
context.Vendors.AddRange(
new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false },
new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false },
new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var eastResult = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
eastResult.Items.Select(item => item.CompanyName)
.Should().BeEquivalentTo("East Co", "East Only");
eastResult.TotalCount.Should().Be(2);
var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co");
eastRow.AreaId.Should().Be(east.Id);
eastRow.AreaName.Should().Be("East");
eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East");
var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true,
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true });
eastOrUnassigned.Items.Select(item => item.CompanyName)
.Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co");
eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull();
var eastAndBoston = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "Boston, MA" },
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co");
var matchesNobody = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, areaFilter: new VendorAreaFilter());
matchesNobody.TotalCount.Should().Be(0);
matchesNobody.Items.Should().BeEmpty();
var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
unfiltered.TotalCount.Should().Be(5);
}
[Fact]
public async Task GetActiveAreasAsync_ExcludesArchivedAreas()
{
await using var context = NewContext();
context.Areas.AddRange(
new Area { Id = 1, Name = "East", NormalizedName = "east" },
new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false });
await context.SaveChangesAsync();
var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None);
areas.Select(area => area.Name).Should().Equal("East");
}
}

View file

@ -166,6 +166,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token))
.ReturnsAsync((new[]
{
@ -212,6 +213,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token), Times.Once);
}
@ -228,6 +230,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
@ -756,6 +759,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
@ -794,6 +799,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -822,6 +829,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -849,6 +858,8 @@ public class VendorServiceTests
SortOrder = index + 1
})
.ToList());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
@ -898,4 +909,96 @@ public class VendorServiceTests
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
}
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 3, Name = "West", NormalizedName = "west" }
});
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
return data;
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().Equal(1);
captured.IncludeUnassigned.Should().BeTrue();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
// 4 is not in the active catalogue (unknown or archived); "West" is a label.
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { "4", "West", "-3" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().BeEmpty();
captured.IncludeUnassigned.Should().BeFalse();
result.Items.Should().BeEmpty();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter()
{
VendorAreaFilter? captured = new();
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" });
captured.Should().BeNull();
data.Verify(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>());
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
.ReturnsAsync(new List<Area>
{
new() { Id = 3, Name = "West", NormalizedName = "west" },
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 4, Name = "california", NormalizedName = "california" }
});
var facets = await NewService(data).GetFacetsAsync(null, cts.Token);
facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal(
(4, "california"), (1, "East"), (3, "West"));
data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once);
}
}

View file

@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken);
var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (VendorRosterDuplicateNameException dup)
{
return Conflict(new
@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken);
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken);
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });

View file

@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null,
[FromQuery] string[]? areas = null,
CancellationToken cancellationToken = default)
{
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
trades,
locations,
jobBuckets,
areas,
cancellationToken);
var data = pagedResult.Items.Select(v => new
@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers
v.Notes,
v.IsActive,
v.TotalJobs,
v.AreaId,
v.AreaName,
Technicians = v.Technicians.Select(t => new
{
t.Id,

View file

@ -206,6 +206,25 @@ namespace Data.SeaHavenIndustries
builder.Entity<Trade>()
.HasIndex(t => t.NormalizedName)
.IsUnique();
// SH-278 Area catalogue: bounded names so the unique key is indexable.
builder.Entity<Area>()
.Property(a => a.Name)
.HasMaxLength(128);
builder.Entity<Area>()
.Property(a => a.NormalizedName)
.HasMaxLength(128);
builder.Entity<Area>()
.HasIndex(a => a.NormalizedName)
.IsUnique();
builder.Entity<VendorCompany>()
.HasOne(c => c.Area)
.WithMany()
.HasForeignKey(c => c.AreaId)
.OnDelete(DeleteBehavior.Restrict);
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -228,6 +247,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Trade> Trades { get; set; }
public DbSet<Area> Areas { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH278_VendorCompanyArea : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AreaId",
table: "VendorCompanies",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "Areas",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Areas", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_VendorCompanies_AreaId",
table: "VendorCompanies",
column: "AreaId");
migrationBuilder.CreateIndex(
name: "IX_Areas_NormalizedName",
table: "Areas",
column: "NormalizedName",
unique: true);
// SH-278 v1 seed of the four dispatcher territories confirmed by Product.
// Names match the merged SH-273 Sites mapping (East / Central / West /
// California). Ids are stable; later renames change only the display name.
// Existing vendor companies stay Unassigned (no backfill).
migrationBuilder.InsertData(
table: "Areas",
columns: new[] { "Id", "Name", "NormalizedName", "IsActive" },
values: new object[,]
{
{ 1, "East", "east", true },
{ 2, "Central", "central", true },
{ 3, "West", "west", true },
{ 4, "California", "california", true }
});
migrationBuilder.AddForeignKey(
name: "FK_VendorCompanies_Areas_AreaId",
table: "VendorCompanies",
column: "AreaId",
principalTable: "Areas",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VendorCompanies_Areas_AreaId",
table: "VendorCompanies");
migrationBuilder.DropTable(
name: "Areas");
migrationBuilder.DropIndex(
name: "IX_VendorCompanies_AreaId",
table: "VendorCompanies");
migrationBuilder.DropColumn(
name: "AreaId",
table: "VendorCompanies");
}
}
}

View file

@ -286,6 +286,35 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Area", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Areas");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{
b.Property<int>("Id")
@ -2237,6 +2266,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<int?>("AreaId")
.HasColumnType("int");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
@ -2294,6 +2326,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("AreaId");
b.HasIndex("NormalizedName")
.IsUnique();
@ -3614,6 +3648,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
{
b.HasOne("Data.SeaHavenIndustries.Area", "Area")
.WithMany()
.HasForeignKey("AreaId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Area");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")

View file

@ -0,0 +1,15 @@
namespace Data.SeaHavenIndustries
{
// SH-278: dispatcher territory catalogue for this Sea Haven deployment. Vendor
// companies carry zero or one Area by stable id; the display name is separate and
// may be renamed without changing assignments. Area is selected independently and
// is never derived from a company's State. Rows are organization-wide: vendor
// companies are not scoped to a customer Account.
public class Area
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string NormalizedName { get; set; } = "";
public bool IsActive { get; set; } = true;
}
}

View file

@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned.
public int? AreaId { get; set; }
[ForeignKey(nameof(AreaId))]
public Area? Area { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }

View file

@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) =>
_context.Areas
.AsNoTracking()
.AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken);
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
int? vendorId,
int? companyId,
@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation
Zip = c.Zip,
GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes,
AreaId = c.AreaId,
AreaName = c.Area != null ? c.Area.Name : null,
RowVersion = c.RowVersion
})
.FirstOrDefaultAsync(cancellationToken);
@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation
company.Zip = roster.Zip;
company.GoogleMapsUrl = roster.GoogleMapsUrl;
company.Notes = roster.Notes;
if (roster.AreaIdProvided)
company.AreaId = roster.AreaId;
company.LastModificationTime = now;
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
if (actorId.HasValue)
@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation
Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes,
AreaId = roster.AreaId,
CreatedDate = now,
createdby = roster.ActorUserId
};
@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation
company.GoogleMapsUrl = fields.GoogleMapsUrl;
if (fields.Notes != null)
company.Notes = fields.Notes;
if (fields.AreaIdProvided)
company.AreaId = fields.AreaId;
// Keep the denormalized company columns on existing technicians aligned
// with the company row (same invariant SaveRosterAsync maintains). This

View file

@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation
return query.OrderBy(c => c.Id);
}
public async Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken)
{
return await _context.Areas
.AsNoTracking()
.Where(a => a.IsActive)
.OrderBy(a => a.Id)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default)
{
// Phase 1: select and paginate bounded company group keys in SQL.
@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation
companies,
trades,
locations,
jobBuckets);
jobBuckets,
areaFilter);
var totalCount = await keyQuery.CountAsync(cancellationToken);
@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation
? new Dictionary<int, VendorCompany>()
: await _context.VendorCompanies
.AsNoTracking()
.Include(c => c.Area)
.Where(c => missingCompanyIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, cancellationToken);
@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
GoogleMapsUrl = primary.GoogleMapsUrl,
Notes = linked ? primary.CompanyNotes : null,
AreaId = linked ? primary.CompanyAreaId : null,
AreaName = linked ? primary.CompanyAreaName : null,
IsActive = primary.IsActive,
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null,
GoogleMapsUrl = company.GoogleMapsUrl,
Notes = company.Notes,
AreaId = company.AreaId,
AreaName = company.Area?.Name,
IsActive = true,
TotalJobs = 0,
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null)
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null)
{
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
var grouped = sourceRows
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation
SearchMatch = group.Max(row => row.SearchFlag),
CompanyMatch = group.Max(row => row.CompanyFlag),
TradeMatch = group.Max(row => row.TradeFlag),
LocationMatch = group.Max(row => row.LocationFlag)
LocationMatch = group.Max(row => row.LocationFlag),
AreaMatch = group.Max(row => row.AreaFlag)
});
// Each facet selects the group when any status-eligible row matches it
@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation
if (selectedLocations is { Length: > 0 })
grouped = grouped.Where(key => key.LocationMatch == 1);
if (areaFilter != null)
grouped = grouped.Where(key => key.AreaMatch == 1);
var selectedJobBuckets = jobBuckets?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation
string? search,
IReadOnlyCollection<string>? companies,
IReadOnlyCollection<string>? trades,
IReadOnlyCollection<string>? locations)
IReadOnlyCollection<string>? locations,
VendorAreaFilter? areaFilter)
{
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation
var locationBody = selectedLocations is { Length: > 0 }
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
: null;
var areaBody = areaFilter == null
? null
: BuildAreaMatchBody(row, areaFilter);
var nameKey = Expression.Condition(
Expression.NotEqual(
@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))),
row);
}
@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation
CompanyCity = v.Company != null ? v.Company.City : null,
CompanyState = v.Company != null ? v.Company.State : null,
CompanyNotes = v.Company != null ? v.Company.Notes : null,
CompanyAreaId = v.Company != null ? v.Company.AreaId : null,
CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null,
ContactName = v.ContactName,
Email = v.Email,
Phone = v.Phone,
@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = v.TradeSpecialties,
GoogleMapsUrl = v.GoogleMapsUrl,
Notes = v.Company != null ? v.Company.Notes : null,
AreaId = v.Company != null ? v.Company.AreaId : null,
IsActive = v.IsActive,
TotalJobs = (int?)jobCount.TotalJobs ?? 0
};
@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null,
GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes,
AreaId = c.AreaId,
IsActive = true,
TotalJobs = 0
});
@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation
return body!;
}
// SH-278: OR within the Area facet. Selected ids match the company's stable
// AreaId; the Unassigned sentinel matches rows without an Area, which includes
// unlinked legacy vendors that have no VendorCompany at all. A filter with no
// resolvable ids and no Unassigned matches nobody.
private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter)
{
var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId));
Expression? body = null;
if (areaFilter.AreaIds.Count > 0)
{
var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray();
body = OrElse(body, Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Contains),
new[] { typeof(int?) },
Expression.Constant(ids),
areaId));
}
if (areaFilter.IncludeUnassigned)
body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?))));
return body ?? Expression.Constant(false);
}
private static Expression BuildCompanyNameMatchBody(
ParameterExpression row,
IReadOnlyCollection<string> selectedCompanies)

View file

@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorCompanyRosterDataService
{
// SH-278: true only for an existing, non-archived Area in the catalogue.
Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken);
// Loads the company roster from either a vendor row id and/or a company id,
// returning the company plus all non-deleted technician rows.
Task<VendorCompanyRosterReadModel?> GetRosterAsync(

View file

@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
// of technician Vendor.TradeSpecialties free text.
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
// SH-278: active (non-archived) Area catalogue rows for vendor facets and for
// resolving areas[n] filter ids. Archived Areas are excluded.
Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken);
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
// companies; active includes companies with zero non-deleted technicians or at
@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default);
Task<Vendor> AddAsync(Vendor vendor);
Task UpdateAsync(Vendor vendor);

View file

@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public byte[]? RowVersion { get; set; }
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
}
@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
public bool AreaIdProvided { get; set; }
public int? AreaId { get; set; }
public string? ActorUserId { get; set; }
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
}
@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
public bool AreaIdProvided { get; set; }
public int? AreaId { get; set; }
}
// Raised by the roster data service when a technician removed from the snapshot

View file

@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
}
// SH-278: Area facet filter for the company directory. AreaIds are already
// resolved against the active catalogue; IncludeUnassigned selects groups with no
// Area (including unlinked legacy vendors). An empty filter matches nobody.
public sealed class VendorAreaFilter
{
public IReadOnlyCollection<int> AreaIds { get; init; } = Array.Empty<int>();
public bool IncludeUnassigned { get; init; }
}
// SH-281 company directory: per-row source for the grouped key query. Carries the
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models
public int CompanyFlag { get; set; }
public int TradeFlag { get; set; }
public int LocationFlag { get; set; }
public int AreaFlag { get; set; }
}
// SH-281: one row per company group after SQL grouping — the bounded key page.
@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models
public int CompanyMatch { get; set; }
public int TradeMatch { get; set; }
public int LocationMatch { get; set; }
public int AreaMatch { get; set; }
}
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models
public string? CompanyCity { get; set; }
public string? CompanyState { get; set; }
public string? CompanyNotes { get; set; }
public int? CompanyAreaId { get; set; }
public string? CompanyAreaName { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();

View file

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace SeaHaven.Services.DTOs
{
public class VendorRosterDTO
@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public string? RowVersion { get; set; }
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
}
@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: optional Area id; null leaves the new company Unassigned. Assigning
// an Area requires the Admin role.
public int? AreaId { get; set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: an omitted areaId leaves the stored Area unchanged, so clients that
// predate Area never clear it. An explicit null clears it to Unassigned.
private int? _areaId;
public int? AreaId
{
get => _areaId;
set
{
_areaId = value;
AreaIdProvided = true;
}
}
[JsonIgnore]
public bool AreaIdProvided { get; private set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId.
private int? _areaId;
public int? AreaId
{
get => _areaId;
set
{
_areaId = value;
AreaIdProvided = true;
}
}
[JsonIgnore]
public bool AreaIdProvided { get; private set; }
}
// SH-278: raised when a caller without the Admin role tries to change a vendor
// company's Area. Mapped to a stable 403 by the API.
public sealed class VendorAreaAssignmentForbiddenException : Exception
{
public VendorAreaAssignmentForbiddenException()
: base("Only administrators can assign a vendor company Area.")
{
}
}
}

View file

@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public int TotalJobs { get; set; }
public bool IsActive { get; set; }
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();

View file

@ -59,7 +59,21 @@ namespace SeaHaven.Services.DTOs
{
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
public IEnumerable<VendorFacetAreaDTO> Areas { get; set; } = Enumerable.Empty<VendorFacetAreaDTO>();
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
}
// SH-278: one active Area option. Id is the stable areas[n] filter value.
public class VendorFacetAreaDTO
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
// SH-278: wire values accepted by the vendor directory areas[n] filter besides ids.
public static class VendorAreaFilterValues
{
public const string Unassigned = "__unassigned__";
}
}

View file

@ -196,7 +196,6 @@ namespace SeaHaven.Services.Implementation
var today = DashboardBusinessTime.Today();
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId);
var firstBucketStart = BucketStart(windowFrom, granularity);
var lastBucketStart = BucketStart(windowTo, granularity);
var lastBucketEnd = BucketEnd(lastBucketStart, granularity);
@ -217,9 +216,7 @@ namespace SeaHaven.Services.Implementation
{
if (workOrder.ScheduledDate is not DateTime scheduled)
continue;
var businessDate = DateOnly.FromDateTime(
TimeZoneInfo.ConvertTimeFromUtc(
DateTime.SpecifyKind(scheduled, DateTimeKind.Utc), timezone));
var businessDate = DateOnly.FromDateTime(scheduled.Date);
if (businessDate < firstBucketStart || businessDate > lastBucketEnd)
continue;
var bucket = buckets[LocateBucket(bucketStarts, businessDate)];

View file

@ -6,6 +6,7 @@ using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using System.Security.Claims;
namespace SeaHaven.Services.Implementation
{
@ -13,6 +14,8 @@ namespace SeaHaven.Services.Implementation
{
private const int MaxNotesLength = 500;
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
private const string AdminRole = "Admin";
private const string AreaNotFoundMessage = "Area was not found.";
private readonly IVendorCompanyRosterDataService _rosterDataService;
@ -39,6 +42,7 @@ namespace SeaHaven.Services.Implementation
public async Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -60,6 +64,13 @@ namespace SeaHaven.Services.Implementation
"Technician ids are not allowed when creating a vendor company.")
});
await EnsureAreaAssignmentAllowedAsync(
caller,
dto.AreaId,
currentAreaId: null,
nameof(CreateVendorRosterDTO.AreaId),
cancellationToken);
var writeModel = new VendorCompanyRosterWriteModel
{
Name = dto.Name,
@ -71,6 +82,8 @@ namespace SeaHaven.Services.Implementation
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
AreaIdProvided = true,
AreaId = dto.AreaId,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
@ -83,6 +96,7 @@ namespace SeaHaven.Services.Implementation
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -101,6 +115,14 @@ namespace SeaHaven.Services.Implementation
// Mismatched ids: every non-null technician id must belong to this company.
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
if (dto.AreaIdProvided)
await EnsureAreaChangeAllowedAsync(
companyId,
caller,
dto.AreaId,
nameof(ReconcileVendorRosterDTO.AreaId),
cancellationToken);
var writeModel = new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
@ -114,6 +136,8 @@ namespace SeaHaven.Services.Implementation
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
AreaIdProvided = dto.AreaIdProvided,
AreaId = dto.AreaId,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
@ -126,6 +150,7 @@ namespace SeaHaven.Services.Implementation
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -167,6 +192,14 @@ namespace SeaHaven.Services.Implementation
if (failures.Count > 0)
throw new ValidationException(failures);
if (companyFields is { AreaIdProvided: true })
await EnsureAreaChangeAllowedAsync(
companyId,
caller,
companyFields.AreaId,
$"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}",
cancellationToken);
// The contact-group invariant ("at least one company phone or email") is
// preserved by construction: blank company fields map to null ("unchanged"),
// so this endpoint can only set valid phone/email values, never clear them.
@ -184,6 +217,50 @@ namespace SeaHaven.Services.Implementation
return MapToDTO(saved);
}
// SH-278: Area assignment is Admin-only. A payload that leaves the stored Area
// unchanged is allowed for any authenticated caller, so non-admin users can
// still save other company fields while round-tripping the current Area.
private async Task EnsureAreaChangeAllowedAsync(
int companyId,
ClaimsPrincipal caller,
int? requestedAreaId,
string propertyName,
CancellationToken cancellationToken)
{
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken)
?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found.");
await EnsureAreaAssignmentAllowedAsync(
caller,
requestedAreaId,
roster.AreaId,
propertyName,
cancellationToken);
}
private async Task EnsureAreaAssignmentAllowedAsync(
ClaimsPrincipal caller,
int? requestedAreaId,
int? currentAreaId,
string propertyName,
CancellationToken cancellationToken)
{
if (requestedAreaId == currentAreaId)
return;
if (caller?.IsInRole(AdminRole) != true)
throw new VendorAreaAssignmentForbiddenException();
if (requestedAreaId is int areaId
&& (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken)))
{
throw new ValidationException(new[]
{
new ValidationFailure(propertyName, AreaNotFoundMessage)
});
}
}
private static void EnsureAuthenticated(string userId)
{
if (string.IsNullOrWhiteSpace(userId))
@ -290,7 +367,8 @@ namespace SeaHaven.Services.Implementation
|| !string.IsNullOrWhiteSpace(fields.State)
|| !string.IsNullOrWhiteSpace(fields.Zip)
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|| !string.IsNullOrWhiteSpace(fields.Notes);
|| !string.IsNullOrWhiteSpace(fields.Notes)
|| fields.AreaIdProvided;
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
model.Name == null
@ -301,7 +379,8 @@ namespace SeaHaven.Services.Implementation
&& model.State == null
&& model.Zip == null
&& model.GoogleMapsUrl == null
&& model.Notes == null;
&& model.Notes == null
&& !model.AreaIdProvided;
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
VendorRosterCompanyFieldsDTO fields,
@ -344,7 +423,9 @@ namespace SeaHaven.Services.Implementation
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes,
AreaIdProvided = fields.AreaIdProvided,
AreaId = fields.AreaId
};
}
@ -443,6 +524,8 @@ namespace SeaHaven.Services.Implementation
Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes,
AreaId = roster.AreaId,
AreaName = roster.AreaName,
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
{

View file

@ -4,6 +4,7 @@ using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Globalization;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
@ -160,10 +161,12 @@ namespace SeaHaven.Services.Implementation
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
IReadOnlyCollection<string>? areas = null,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken);
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
normalizedPage,
normalizedPageSize,
@ -173,6 +176,7 @@ namespace SeaHaven.Services.Implementation
trades,
locations,
jobBuckets,
areaFilter,
cancellationToken);
return new PagedResult<VendorDirectoryItemDTO>
@ -184,6 +188,47 @@ namespace SeaHaven.Services.Implementation
};
}
// SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects
// companies without an Area; other values must be stable positive integer ids
// of active catalogue rows. Display names, unknown ids and archived ids never
// error — they simply match nobody. Null means no Area filter was requested.
private async Task<VendorAreaFilter?> ResolveAreaFilterAsync(
IReadOnlyCollection<string>? areas,
CancellationToken cancellationToken)
{
var values = areas?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.ToList();
if (values is not { Count: > 0 })
return null;
var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal);
var requestedIds = new HashSet<int>();
foreach (var value in values)
{
if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0)
requestedIds.Add(id);
}
IReadOnlyCollection<int> activeIds = Array.Empty<int>();
if (requestedIds.Count > 0)
{
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
activeIds = activeAreas
.Select(area => area.Id)
.Where(requestedIds.Contains)
.ToArray();
}
return new VendorAreaFilter
{
AreaIds = activeIds,
IncludeUnassigned = includeUnassigned
};
}
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
{
Id = group.Id,
@ -201,6 +246,8 @@ namespace SeaHaven.Services.Implementation
TradeSpecialties = group.TradeSpecialties,
GoogleMapsUrl = group.GoogleMapsUrl,
Notes = group.Notes,
AreaId = group.AreaId,
AreaName = group.AreaName,
IsActive = group.IsActive,
TotalJobs = group.TotalJobs,
Technicians = group.Technicians.Select(MapToTechnician).ToList()
@ -731,6 +778,7 @@ namespace SeaHaven.Services.Implementation
// populated even on an empty database and independent of the status filter.
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
var companyFacets = companies
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
@ -772,10 +820,19 @@ namespace SeaHaven.Services.Implementation
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
// SH-278: the active Area catalogue, A–Z case-insensitive, independent of the
// status filter and of whether any company currently uses an Area. Clients
// prepend the "__unassigned__" option themselves.
var areas = activeAreas
.OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase)
.Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name })
.ToList();
return new VendorFacetsDTO
{
Companies = companyFacets,
Trades = trades,
Areas = areas,
Locations = locations,
JobBuckets = new[]
{

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
@ -13,18 +14,21 @@ namespace SeaHaven.Services.Interfaces
Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<VendorRosterDTO> ReconcileRosterAsync(
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<VendorRosterDTO> AddTechniciansAsync(
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
}
}

View file

@ -41,6 +41,7 @@ namespace SeaHaven.Services.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
IReadOnlyCollection<string>? areas = null,
CancellationToken cancellationToken = default);
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);