backend phase 5: service catalog & nurse pricing variants
Two-tier service model the marketplace is priced and searched on. Admin catalog skeleton (categories + EAV option groups/values, addable as data not migrations; NULL category = cross-category) and the nurse pricing layer (nurse_service_variants — the atomic bookable unit: category + one value per required dimension at the nurse's own IRR price and price unit). - New `catalog` schema via one additive migration; Price BIGINT (no floats), on the wire as a string of digits; total = price + unit + session_count. - Duplicate-listing guard: deterministic option_set_hash + filtered UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL + friendly 409 pre-check. One value per dimension; required groups (incl. cross-category) enforced; deactivate, never delete. - Public catalog browse cached behind a CatalogCache generation token, invalidated on any admin write. IVariantSnapshotSerializer shipped for b8. - Contract (catalog.md) + handoff + report published; swagger refreshed. 122 tests green; zero new build warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
|
||||
|
||||
/// <summary>
|
||||
/// The five MVP service categories, seeded via <c>HasData</c> so they land with the migration on a fresh
|
||||
/// DB (the b1 seeding path) — a nurse can build a variant immediately. Ids are fixed and deterministic
|
||||
/// (1…5, sort_order = id) so re-running is idempotent and the model snapshot stays stable. Option
|
||||
/// groups/values are <b>not</b> seeded: those are admin-authored data per category (EAV is load-bearing).
|
||||
/// </summary>
|
||||
internal static class CatalogSeed
|
||||
{
|
||||
// (id, name_fa, name_en). Companionship ships only as a seeded category (data), not a pricing path.
|
||||
private static readonly (long Id, string NameFa, string NameEn)[] CategoryRows =
|
||||
[
|
||||
(1, "مراقبت از سالمند", "Elderly Care"),
|
||||
(2, "مراقبت پس از جراحی", "Post-Surgery Recovery"),
|
||||
(3, "مراقبت از نوزاد", "Infant Care"),
|
||||
(4, "مدیریت بیماری مزمن", "Chronic Illness Management"),
|
||||
(5, "همراهی و مراقبت روزمره", "Companionship"),
|
||||
];
|
||||
|
||||
public static object[] Categories()
|
||||
{
|
||||
var ts = SeedConstants.Timestamp;
|
||||
return CategoryRows
|
||||
.Select(c => (object)new
|
||||
{
|
||||
c.Id,
|
||||
c.NameFa,
|
||||
c.NameEn,
|
||||
SortOrder = (int)c.Id,
|
||||
IsActive = true,
|
||||
CreatedAt = ts
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
|
||||
|
||||
internal sealed class NurseServiceVariantConfig : IEntityTypeConfiguration<NurseServiceVariant>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NurseServiceVariant> builder)
|
||||
{
|
||||
builder.ToTable("NurseServiceVariants", "catalog");
|
||||
|
||||
// Price is IRR Rials as BIGINT (long → bigint). There is no float/decimal money path, ever.
|
||||
builder.Property(v => v.PriceUnit).HasMaxLength(20).IsRequired();
|
||||
builder.Property(v => v.DisplayName).HasMaxLength(300).IsRequired();
|
||||
builder.Property(v => v.OptionSetHash).HasMaxLength(64).IsRequired();
|
||||
builder.Property(v => v.IsActive).HasDefaultValue(true);
|
||||
|
||||
// The nurse's offerings list + the b7 index projection read on (nurse_id, is_active).
|
||||
builder.HasIndex(v => new { v.NurseId, v.IsActive });
|
||||
// Leading column is nurse_id on the unique index, so a standalone category index is still useful
|
||||
// for "all variants in a category" (b7 category browse).
|
||||
builder.HasIndex(v => v.ServiceCategoryId);
|
||||
|
||||
// Duplicate-listing DB backstop: a multi-row option-set can't be a plain composite unique, so it is
|
||||
// reduced to a deterministic option_set_hash and made race-safe here. Filtered to exclude
|
||||
// soft-deleted rows so a deactivated+deleted listing can be re-created.
|
||||
builder.HasIndex(v => new { v.NurseId, v.ServiceCategoryId, v.OptionSetHash })
|
||||
.IsUnique()
|
||||
.HasFilter("[DeletedAt] IS NULL")
|
||||
.HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet");
|
||||
|
||||
builder.HasOne(v => v.Nurse)
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.NurseId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(v => v.ServiceCategory)
|
||||
.WithMany(c => c.Variants)
|
||||
.HasForeignKey(v => v.ServiceCategoryId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasQueryFilter(v => v.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
|
||||
|
||||
internal sealed class NurseServiceVariantOptionConfig : IEntityTypeConfiguration<NurseServiceVariantOption>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NurseServiceVariantOption> builder)
|
||||
{
|
||||
builder.ToTable("NurseServiceVariantOptions", "catalog");
|
||||
|
||||
// One value per dimension per variant. The unique index is the authoritative backstop; the handler
|
||||
// validates the same rule for a clean message. Its leading column is variant_id, so it also serves
|
||||
// "load a variant's full option set" — no separate variant_id index needed.
|
||||
builder.HasIndex(o => new { o.VariantId, o.OptionGroupId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group");
|
||||
|
||||
builder.HasOne(o => o.Variant)
|
||||
.WithMany(v => v.Options)
|
||||
.HasForeignKey(o => o.VariantId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(o => o.OptionGroup)
|
||||
.WithMany()
|
||||
.HasForeignKey(o => o.OptionGroupId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(o => o.OptionValue)
|
||||
.WithMany()
|
||||
.HasForeignKey(o => o.OptionValueId)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
|
||||
|
||||
internal sealed class ServiceCategoryConfig : IEntityTypeConfiguration<ServiceCategory>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ServiceCategory> builder)
|
||||
{
|
||||
builder.ToTable("ServiceCategories", "catalog");
|
||||
|
||||
builder.Property(c => c.NameFa).HasMaxLength(150).IsRequired();
|
||||
builder.Property(c => c.NameEn).HasMaxLength(150).IsRequired();
|
||||
builder.Property(c => c.DescriptionFa).HasMaxLength(1000);
|
||||
builder.Property(c => c.DescriptionEn).HasMaxLength(1000);
|
||||
builder.Property(c => c.IconKey).HasMaxLength(100);
|
||||
builder.Property(c => c.SortOrder).HasDefaultValue(0);
|
||||
builder.Property(c => c.IsActive).HasDefaultValue(true);
|
||||
|
||||
// Public ordered browse: active categories in sort order.
|
||||
builder.HasIndex(c => new { c.IsActive, c.SortOrder });
|
||||
|
||||
builder.HasQueryFilter(c => c.DeletedAt == null);
|
||||
|
||||
builder.HasData(CatalogSeed.Categories());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
|
||||
|
||||
internal sealed class ServiceOptionGroupConfig : IEntityTypeConfiguration<ServiceOptionGroup>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ServiceOptionGroup> builder)
|
||||
{
|
||||
builder.ToTable("ServiceOptionGroups", "catalog");
|
||||
|
||||
builder.Property(g => g.NameFa).HasMaxLength(150).IsRequired();
|
||||
builder.Property(g => g.NameEn).HasMaxLength(150).IsRequired();
|
||||
builder.Property(g => g.IsRequired).HasDefaultValue(false);
|
||||
builder.Property(g => g.SortOrder).HasDefaultValue(0);
|
||||
builder.Property(g => g.IsActive).HasDefaultValue(true);
|
||||
|
||||
// (service_category_id, sort_order) for the applicable-groups read. The nullable FK is deliberate —
|
||||
// a NULL category is the cross-category case and must not be broken by a required relationship.
|
||||
builder.HasIndex(g => new { g.ServiceCategoryId, g.SortOrder });
|
||||
|
||||
builder.HasOne(g => g.ServiceCategory)
|
||||
.WithMany(c => c.OptionGroups)
|
||||
.HasForeignKey(g => g.ServiceCategoryId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasQueryFilter(g => g.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
|
||||
|
||||
internal sealed class ServiceOptionValueConfig : IEntityTypeConfiguration<ServiceOptionValue>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ServiceOptionValue> builder)
|
||||
{
|
||||
builder.ToTable("ServiceOptionValues", "catalog");
|
||||
|
||||
builder.Property(v => v.NameFa).HasMaxLength(150).IsRequired();
|
||||
builder.Property(v => v.NameEn).HasMaxLength(150).IsRequired();
|
||||
builder.Property(v => v.SortOrder).HasDefaultValue(0);
|
||||
builder.Property(v => v.IsActive).HasDefaultValue(true);
|
||||
|
||||
builder.HasIndex(v => new { v.OptionGroupId, v.SortOrder });
|
||||
|
||||
builder.HasOne(v => v.OptionGroup)
|
||||
.WithMany(g => g.Values)
|
||||
.HasForeignKey(v => v.OptionGroupId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasQueryFilter(v => v.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+2930
File diff suppressed because it is too large
Load Diff
+280
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ServiceCatalogAndNurseVariants : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "catalog");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServiceCategories",
|
||||
schema: "catalog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||
DescriptionFa = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
DescriptionEn = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
IconKey = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServiceCategories", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NurseServiceVariants",
|
||||
schema: "catalog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NurseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Price = table.Column<long>(type: "bigint", nullable: false),
|
||||
PriceUnit = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
SessionCount = table.Column<int>(type: "int", nullable: true),
|
||||
DisplayName = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||
OptionSetHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NurseServiceVariants", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseServiceVariants_NurseProfiles_NurseId",
|
||||
column: x => x.NurseId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseServiceVariants_ServiceCategories_ServiceCategoryId",
|
||||
column: x => x.ServiceCategoryId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "ServiceCategories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServiceOptionGroups",
|
||||
schema: "catalog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: true),
|
||||
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||
IsRequired = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServiceOptionGroups", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ServiceOptionGroups_ServiceCategories_ServiceCategoryId",
|
||||
column: x => x.ServiceCategoryId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "ServiceCategories",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServiceOptionValues",
|
||||
schema: "catalog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
OptionGroupId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServiceOptionValues", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ServiceOptionValues_ServiceOptionGroups_OptionGroupId",
|
||||
column: x => x.OptionGroupId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "ServiceOptionGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NurseServiceVariantOptions",
|
||||
schema: "catalog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VariantId = table.Column<long>(type: "bigint", nullable: false),
|
||||
OptionGroupId = table.Column<long>(type: "bigint", nullable: false),
|
||||
OptionValueId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NurseServiceVariantOptions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseServiceVariantOptions_NurseServiceVariants_VariantId",
|
||||
column: x => x.VariantId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "NurseServiceVariants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseServiceVariantOptions_ServiceOptionGroups_OptionGroupId",
|
||||
column: x => x.OptionGroupId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "ServiceOptionGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseServiceVariantOptions_ServiceOptionValues_OptionValueId",
|
||||
column: x => x.OptionValueId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "ServiceOptionValues",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "catalog",
|
||||
table: "ServiceCategories",
|
||||
columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "DescriptionEn", "DescriptionFa", "IconKey", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Elderly Care", "مراقبت از سالمند", 1 },
|
||||
{ 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Post-Surgery Recovery", "مراقبت پس از جراحی", 2 },
|
||||
{ 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Infant Care", "مراقبت از نوزاد", 3 },
|
||||
{ 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Chronic Illness Management", "مدیریت بیماری مزمن", 4 },
|
||||
{ 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Companionship", "همراهی و مراقبت روزمره", 5 }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseServiceVariantOptions_OptionGroupId",
|
||||
schema: "catalog",
|
||||
table: "NurseServiceVariantOptions",
|
||||
column: "OptionGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseServiceVariantOptions_OptionValueId",
|
||||
schema: "catalog",
|
||||
table: "NurseServiceVariantOptions",
|
||||
column: "OptionValueId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NurseServiceVariantOptions_Variant_Group",
|
||||
schema: "catalog",
|
||||
table: "NurseServiceVariantOptions",
|
||||
columns: new[] { "VariantId", "OptionGroupId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseServiceVariants_NurseId_IsActive",
|
||||
schema: "catalog",
|
||||
table: "NurseServiceVariants",
|
||||
columns: new[] { "NurseId", "IsActive" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseServiceVariants_ServiceCategoryId",
|
||||
schema: "catalog",
|
||||
table: "NurseServiceVariants",
|
||||
column: "ServiceCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NurseServiceVariants_Nurse_Category_OptionSet",
|
||||
schema: "catalog",
|
||||
table: "NurseServiceVariants",
|
||||
columns: new[] { "NurseId", "ServiceCategoryId", "OptionSetHash" },
|
||||
unique: true,
|
||||
filter: "[DeletedAt] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServiceCategories_IsActive_SortOrder",
|
||||
schema: "catalog",
|
||||
table: "ServiceCategories",
|
||||
columns: new[] { "IsActive", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServiceOptionGroups_ServiceCategoryId_SortOrder",
|
||||
schema: "catalog",
|
||||
table: "ServiceOptionGroups",
|
||||
columns: new[] { "ServiceCategoryId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServiceOptionValues_OptionGroupId_SortOrder",
|
||||
schema: "catalog",
|
||||
table: "ServiceOptionValues",
|
||||
columns: new[] { "OptionGroupId", "SortOrder" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "NurseServiceVariantOptions",
|
||||
schema: "catalog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NurseServiceVariants",
|
||||
schema: "catalog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServiceOptionValues",
|
||||
schema: "catalog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServiceOptionGroups",
|
||||
schema: "catalog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServiceCategories",
|
||||
schema: "catalog");
|
||||
}
|
||||
}
|
||||
}
|
||||
+414
@@ -98,6 +98,337 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("AuditLogs", "ops");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("OptionSetHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("PriceUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<long>("ServiceCategoryId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("SessionCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceCategoryId");
|
||||
|
||||
b.HasIndex("NurseId", "IsActive");
|
||||
|
||||
b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet")
|
||||
.HasFilter("[DeletedAt] IS NULL");
|
||||
|
||||
b.ToTable("NurseServiceVariants", "catalog");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("OptionGroupId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("OptionValueId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("VariantId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OptionGroupId");
|
||||
|
||||
b.HasIndex("OptionValueId");
|
||||
|
||||
b.HasIndex("VariantId", "OptionGroupId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group");
|
||||
|
||||
b.ToTable("NurseServiceVariantOptions", "catalog");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("DescriptionEn")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("DescriptionFa")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("IconKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("NameEn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<string>("NameFa")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsActive", "SortOrder");
|
||||
|
||||
b.ToTable("ServiceCategories", "catalog");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
NameEn = "Elderly Care",
|
||||
NameFa = "مراقبت از سالمند",
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
NameEn = "Post-Surgery Recovery",
|
||||
NameFa = "مراقبت پس از جراحی",
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
NameEn = "Infant Care",
|
||||
NameFa = "مراقبت از نوزاد",
|
||||
SortOrder = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
NameEn = "Chronic Illness Management",
|
||||
NameFa = "مدیریت بیماری مزمن",
|
||||
SortOrder = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
NameEn = "Companionship",
|
||||
NameFa = "همراهی و مراقبت روزمره",
|
||||
SortOrder = 5
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IsRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("NameEn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<string>("NameFa")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<long?>("ServiceCategoryId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceCategoryId", "SortOrder");
|
||||
|
||||
b.ToTable("ServiceOptionGroups", "catalog");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("NameEn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<string>("NameFa")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<long>("OptionGroupId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OptionGroupId", "SortOrder");
|
||||
|
||||
b.ToTable("ServiceOptionValues", "catalog");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -2243,6 +2574,72 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.HasForeignKey("ActorUserId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory")
|
||||
.WithMany("Variants")
|
||||
.HasForeignKey("ServiceCategoryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Nurse");
|
||||
|
||||
b.Navigation("ServiceCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup")
|
||||
.WithMany()
|
||||
.HasForeignKey("OptionGroupId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue")
|
||||
.WithMany()
|
||||
.HasForeignKey("OptionValueId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
|
||||
.WithMany("Options")
|
||||
.HasForeignKey("VariantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OptionGroup");
|
||||
|
||||
b.Navigation("OptionValue");
|
||||
|
||||
b.Navigation("Variant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory")
|
||||
.WithMany("OptionGroups")
|
||||
.HasForeignKey("ServiceCategoryId");
|
||||
|
||||
b.Navigation("ServiceCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup")
|
||||
.WithMany("Values")
|
||||
.HasForeignKey("OptionGroupId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OptionGroup");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Geography.Province", "Province")
|
||||
@@ -2466,6 +2863,23 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||
{
|
||||
b.Navigation("Options");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b =>
|
||||
{
|
||||
b.Navigation("OptionGroups");
|
||||
|
||||
b.Navigation("Variants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b =>
|
||||
{
|
||||
b.Navigation("Values");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b =>
|
||||
{
|
||||
b.Navigation("Districts");
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class CatalogRepository : ICatalogRepository
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
|
||||
public CatalogRepository(ApplicationDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<ServiceCategoryDto>> ListActiveCategoriesAsync(CancellationToken cancellationToken)
|
||||
=> await _db.Set<ServiceCategory>()
|
||||
.AsNoTracking()
|
||||
.Where(c => c.IsActive)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.Select(c => new ServiceCategoryDto(
|
||||
c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken)
|
||||
=> await _db.Set<ServiceOptionGroup>()
|
||||
.AsNoTracking()
|
||||
// The category's own active groups PLUS every cross-category (NULL) active group.
|
||||
.Where(g => g.IsActive && (g.ServiceCategoryId == categoryId || g.ServiceCategoryId == null))
|
||||
.OrderBy(g => g.SortOrder)
|
||||
.ThenBy(g => g.Id)
|
||||
.Select(g => new OptionGroupDto(
|
||||
g.Id,
|
||||
g.ServiceCategoryId,
|
||||
g.NameFa,
|
||||
g.NameEn,
|
||||
g.IsRequired,
|
||||
g.SortOrder,
|
||||
g.IsActive,
|
||||
g.Values
|
||||
.Where(v => v.IsActive)
|
||||
.OrderBy(v => v.SortOrder)
|
||||
.ThenBy(v => v.Id)
|
||||
.Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive))
|
||||
.ToList()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceCategory>()
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Id == id && c.IsActive)
|
||||
.Select(c => new ServiceCategoryDto(
|
||||
c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<OptionGroupDto?> GetGroupDtoAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceOptionGroup>()
|
||||
.AsNoTracking()
|
||||
.Where(g => g.Id == id)
|
||||
.Select(g => new OptionGroupDto(
|
||||
g.Id,
|
||||
g.ServiceCategoryId,
|
||||
g.NameFa,
|
||||
g.NameEn,
|
||||
g.IsRequired,
|
||||
g.SortOrder,
|
||||
g.IsActive,
|
||||
g.Values
|
||||
.Where(v => v.IsActive)
|
||||
.OrderBy(v => v.SortOrder)
|
||||
.ThenBy(v => v.Id)
|
||||
.Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive))
|
||||
.ToList()))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<ServiceCategory?> GetCategoryAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceCategory>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
|
||||
public Task<ServiceOptionGroup?> GetGroupAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceOptionGroup>().FirstOrDefaultAsync(g => g.Id == id, cancellationToken);
|
||||
|
||||
public Task<ServiceOptionValue?> GetValueAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceOptionValue>().FirstOrDefaultAsync(v => v.Id == id, cancellationToken);
|
||||
|
||||
public Task<bool> CategoryExistsAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceCategory>().AsNoTracking().AnyAsync(c => c.Id == id, cancellationToken);
|
||||
|
||||
public Task<bool> GroupExistsAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceOptionGroup>().AsNoTracking().AnyAsync(g => g.Id == id, cancellationToken);
|
||||
|
||||
public async Task AddCategoryAsync(ServiceCategory category, CancellationToken cancellationToken)
|
||||
=> await _db.Set<ServiceCategory>().AddAsync(category, cancellationToken);
|
||||
|
||||
public async Task AddGroupAsync(ServiceOptionGroup group, CancellationToken cancellationToken)
|
||||
=> await _db.Set<ServiceOptionGroup>().AddAsync(group, cancellationToken);
|
||||
|
||||
public async Task AddValueAsync(ServiceOptionValue value, CancellationToken cancellationToken)
|
||||
=> await _db.Set<ServiceOptionValue>().AddAsync(value, cancellationToken);
|
||||
}
|
||||
+4
@@ -16,6 +16,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IGeoRepository GeoRepository { get; }
|
||||
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
|
||||
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
||||
public ICatalogRepository CatalogRepository { get; }
|
||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||
|
||||
public UnitOfWork(ApplicationDbContext db)
|
||||
{
|
||||
@@ -30,6 +32,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
GeoRepository = new GeoRepository(_db);
|
||||
NurseServiceAreaRepository = new NurseServiceAreaRepository(_db);
|
||||
CustomerAddressRepository = new CustomerAddressRepository(_db);
|
||||
CatalogRepository = new CatalogRepository(_db);
|
||||
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
|
||||
}
|
||||
|
||||
public Task CommitAsync()
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.Linq.Expressions;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class NurseServiceVariantRepository : BaseAsyncRepository<NurseServiceVariant>, INurseServiceVariantRepository
|
||||
{
|
||||
public NurseServiceVariantRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task AddAsync(NurseServiceVariant variant, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(variant);
|
||||
|
||||
public Task<NurseServiceVariant?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(v => v.Id == id && v.NurseId == nurseId, cancellationToken);
|
||||
|
||||
public Task<bool> DuplicateHashExistsAsync(long nurseId, long serviceCategoryId, string optionSetHash, long? excludeVariantId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking.AnyAsync(
|
||||
v => v.NurseId == nurseId
|
||||
&& v.ServiceCategoryId == serviceCategoryId
|
||||
&& v.OptionSetHash == optionSetHash
|
||||
&& (excludeVariantId == null || v.Id != excludeVariantId),
|
||||
cancellationToken);
|
||||
|
||||
public async Task<PagedResult<VariantDto>> ListMineAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = TableNoTracking.Where(v => v.NurseId == nurseId);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await query
|
||||
// Active offerings first, then newest — the deactivated ones stay visibly distinct at the tail.
|
||||
.OrderByDescending(v => v.IsActive)
|
||||
.ThenByDescending(v => v.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(Projection)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<VariantDto>(rows.Select(Map).ToList(), total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<VariantDto?> GetOwnedProjectedAsync(long id, long nurseId, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await TableNoTracking
|
||||
.Where(v => v.Id == id && v.NurseId == nurseId)
|
||||
.Select(Projection)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return row is null ? null : Map(row);
|
||||
}
|
||||
|
||||
public async Task<VariantDto?> GetProjectedAsync(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await TableNoTracking
|
||||
.Where(v => v.Id == id)
|
||||
.Select(Projection)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return row is null ? null : Map(row);
|
||||
}
|
||||
|
||||
public async Task<VariantDto?> GetPublicProjectedAsync(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await TableNoTracking
|
||||
.Where(v => v.Id == id && v.IsActive)
|
||||
.Select(Projection)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return row is null ? null : Map(row);
|
||||
}
|
||||
|
||||
// Shared DB projection: keeps Price as the raw long (translatable) and resolves category/option labels.
|
||||
// Price is formatted to a digit string in memory (see Map) so no long.ToString() SQL translation is
|
||||
// required, and the option-set is a single-level collection projection (SQLite-safe).
|
||||
private static readonly Expression<Func<NurseServiceVariant, VariantRow>> Projection = v => new VariantRow(
|
||||
v.Id,
|
||||
v.ServiceCategoryId,
|
||||
v.ServiceCategory.NameFa,
|
||||
v.ServiceCategory.NameEn,
|
||||
v.Price,
|
||||
v.PriceUnit,
|
||||
v.SessionCount,
|
||||
v.DisplayName,
|
||||
v.IsActive,
|
||||
v.Options
|
||||
.OrderBy(o => o.OptionGroup.SortOrder)
|
||||
.ThenBy(o => o.OptionGroupId)
|
||||
.Select(o => new VariantOptionDto(
|
||||
o.OptionGroupId,
|
||||
o.OptionGroup.NameFa,
|
||||
o.OptionGroup.NameEn,
|
||||
o.OptionValueId,
|
||||
o.OptionValue.NameFa,
|
||||
o.OptionValue.NameEn))
|
||||
.ToList());
|
||||
|
||||
private static VariantDto Map(VariantRow r) => new(
|
||||
r.Id,
|
||||
r.ServiceCategoryId,
|
||||
r.CategoryNameFa,
|
||||
r.CategoryNameEn,
|
||||
r.Price.ToString(CultureInfo.InvariantCulture),
|
||||
r.PriceUnit,
|
||||
r.SessionCount,
|
||||
r.DisplayName,
|
||||
r.IsActive,
|
||||
r.Options);
|
||||
|
||||
private sealed record VariantRow(
|
||||
long Id,
|
||||
long ServiceCategoryId,
|
||||
string CategoryNameFa,
|
||||
string CategoryNameEn,
|
||||
long Price,
|
||||
string PriceUnit,
|
||||
int? SessionCount,
|
||||
string DisplayName,
|
||||
bool IsActive,
|
||||
List<VariantOptionDto> Options);
|
||||
}
|
||||
Reference in New Issue
Block a user