diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260423161923_Phase2c4_ReconcileStage.cs b/src/food-market.infrastructure/Persistence/Migrations/20260423161923_Phase2c4_ReconcileStage.cs
index 10a79b7..42642fc 100644
--- a/src/food-market.infrastructure/Persistence/Migrations/20260423161923_Phase2c4_ReconcileStage.cs
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260423161923_Phase2c4_ReconcileStage.cs
@@ -23,21 +23,22 @@ public partial class Phase2c4_ReconcileStage : Migration
///
protected override void Up(MigrationBuilder migrationBuilder)
{
- // 1. Добавляем IsMarked с дефолтом false.
- migrationBuilder.AddColumn(
- name: "IsMarked",
- schema: "public",
- table: "products",
- type: "boolean",
- nullable: false,
- defaultValue: false);
-
- // 2. Если TrackingType есть в БД (стейдж) — бэкфиллим и удаляем.
- // На свежей БД (dev, где migrations 2c2/2c3 не применялись отдельно)
- // колонки не будет — IF EXISTS защищает от ошибки.
+ // Полностью идемпотентно: на стейдже (старый Phase1Catalog без IsMarked,
+ // но с TrackingType) колонку добавим и бэкфилл'нем. На свежей БД, где
+ // отрефакторённый Phase1Catalog уже создал IsMarked, — пропускаем.
+ // Без этого защитного блока миграция падает с
+ // `column "IsMarked" of relation "products" already exists` при первом
+ // dotnet ef database update на пустой БД.
migrationBuilder.Sql("""
DO $$
BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'products'
+ AND column_name = 'IsMarked') THEN
+ ALTER TABLE public.products
+ ADD COLUMN "IsMarked" boolean NOT NULL DEFAULT false;
+ END IF;
+
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'products'
AND column_name = 'TrackingType') THEN
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260424210000_Phase5d_ProductVatDecimal.cs b/src/food-market.infrastructure/Persistence/Migrations/20260424210000_Phase5d_ProductVatDecimal.cs
index 3729ebe..dbad0bc 100644
--- a/src/food-market.infrastructure/Persistence/Migrations/20260424210000_Phase5d_ProductVatDecimal.cs
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260424210000_Phase5d_ProductVatDecimal.cs
@@ -11,17 +11,35 @@ public partial class Phase5d_ProductVatDecimal : Migration
{
protected override void Up(MigrationBuilder b)
{
+ // Идемпотентно: на стейдже Phase1Catalog создавал products.Vat integer,
+ // а на свежей БД отрефакторённый Phase1Catalog уже создаёт products.VatRateId
+ // (FK на vat_rates) и колонки Vat нет. Без NOT EXISTS-гарда миграция падает
+ // с "column Vat does not exist" при первом dotnet ef database update.
b.Sql("""
- ALTER TABLE public.products
- ALTER COLUMN "Vat" TYPE numeric(5,2) USING "Vat"::numeric(5,2);
+ DO $$
+ BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='products'
+ AND column_name='Vat') THEN
+ ALTER TABLE public.products
+ ALTER COLUMN "Vat" TYPE numeric(5,2) USING "Vat"::numeric(5,2);
+ END IF;
+ END $$;
""");
}
protected override void Down(MigrationBuilder b)
{
b.Sql("""
- ALTER TABLE public.products
- ALTER COLUMN "Vat" TYPE integer USING ROUND("Vat")::integer;
+ DO $$
+ BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='products'
+ AND column_name='Vat') THEN
+ ALTER TABLE public.products
+ ALTER COLUMN "Vat" TYPE integer USING ROUND("Vat")::integer;
+ END IF;
+ END $$;
""");
}
}
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs b/src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs
index f68b550..930ea3d 100644
--- a/src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260506000000_Phase5a_EmployeeSoftDelete.cs
@@ -1,4 +1,6 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
+using foodmarket.Infrastructure.Persistence;
#nullable disable
@@ -10,37 +12,42 @@ namespace foodmarket.Infrastructure.Persistence.Migrations
/// IsActive=false + FiredAt — уволен
/// IsActive=false + IsDeleted=true + DeletedAt — soft-deleted
/// Физически Employee никогда не удаляем (FK из retail_sales, supplies).
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260506000000_Phase5a_EmployeeSoftDelete")]
public partial class Phase5a_EmployeeSoftDelete : Migration
{
protected override void Up(MigrationBuilder b)
{
- b.AddColumn(
- name: "IsDeleted",
- schema: "public",
- table: "employees",
- type: "boolean",
- nullable: false,
- defaultValue: false);
+ // Идемпотентно (см. CLAUDE memory feedback_ef_migrations): на чистой
+ // dev-БД нужен AddColumn, на стейдже миграция могла быть применена
+ // вручную через SQL — повторный AddColumn упадёт.
+ b.Sql(@"
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='employees'
+ AND column_name='IsDeleted') THEN
+ ALTER TABLE public.employees
+ ADD COLUMN ""IsDeleted"" boolean NOT NULL DEFAULT false;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='employees'
+ AND column_name='DeletedAt') THEN
+ ALTER TABLE public.employees
+ ADD COLUMN ""DeletedAt"" timestamp with time zone NULL;
+ END IF;
+ END $$;");
- b.AddColumn(
- name: "DeletedAt",
- schema: "public",
- table: "employees",
- type: "timestamp with time zone",
- nullable: true);
-
- b.CreateIndex(
- name: "IX_employees_OrganizationId_IsDeleted",
- schema: "public",
- table: "employees",
- columns: new[] { "OrganizationId", "IsDeleted" });
+ b.Sql(@"
+ CREATE INDEX IF NOT EXISTS ""IX_employees_OrganizationId_IsDeleted""
+ ON public.employees (""OrganizationId"", ""IsDeleted"");");
}
protected override void Down(MigrationBuilder b)
{
- b.DropIndex(name: "IX_employees_OrganizationId_IsDeleted", schema: "public", table: "employees");
- b.DropColumn(name: "IsDeleted", schema: "public", table: "employees");
- b.DropColumn(name: "DeletedAt", schema: "public", table: "employees");
+ b.Sql(@"DROP INDEX IF EXISTS public.""IX_employees_OrganizationId_IsDeleted"";");
+ b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""IsDeleted"";");
+ b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""DeletedAt"";");
}
}
}
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260506100000_Phase5b_PlatformSettings.cs b/src/food-market.infrastructure/Persistence/Migrations/20260506100000_Phase5b_PlatformSettings.cs
index a360594..d10d809 100644
--- a/src/food-market.infrastructure/Persistence/Migrations/20260506100000_Phase5b_PlatformSettings.cs
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260506100000_Phase5b_PlatformSettings.cs
@@ -1,4 +1,6 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
+using foodmarket.Infrastructure.Persistence;
#nullable disable
@@ -7,33 +9,33 @@ namespace foodmarket.Infrastructure.Persistence.Migrations
/// Платформенные настройки (singleton). SMTP-креды для отправки
/// писем (forgot-password, нотификации). Управляется SuperAdmin'ом через
/// /super-admin/platform-settings. Видна только им.
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260506100000_Phase5b_PlatformSettings")]
public partial class Phase5b_PlatformSettings : Migration
{
protected override void Up(MigrationBuilder b)
{
- b.CreateTable(
- name: "platform_settings",
- schema: "public",
- columns: table => new
- {
- Id = table.Column(type: "uuid", nullable: false),
- SmtpHost = table.Column(type: "character varying(200)", maxLength: 200, nullable: true),
- SmtpPort = table.Column(type: "integer", nullable: true),
- SmtpUseSsl = table.Column(type: "boolean", nullable: false, defaultValue: false),
- SmtpStartTls = table.Column(type: "boolean", nullable: false, defaultValue: true),
- SmtpUsername = table.Column(type: "character varying(200)", maxLength: 200, nullable: true),
- SmtpPasswordEncrypted = table.Column(type: "text", nullable: true),
- FromEmail = table.Column(type: "character varying(200)", maxLength: 200, nullable: true),
- FromName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true),
- CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
- UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true),
- },
- constraints: table => table.PrimaryKey("PK_platform_settings", x => x.Id));
+ // Идемпотентно — миграция могла быть применена ранее SQL-ом, либо
+ // соседняя миграция могла создать таблицу под тем же именем.
+ b.Sql(@"
+ CREATE TABLE IF NOT EXISTS public.platform_settings (
+ ""Id"" uuid PRIMARY KEY,
+ ""SmtpHost"" varchar(200) NULL,
+ ""SmtpPort"" integer NULL,
+ ""SmtpUseSsl"" boolean NOT NULL DEFAULT false,
+ ""SmtpStartTls"" boolean NOT NULL DEFAULT true,
+ ""SmtpUsername"" varchar(200) NULL,
+ ""SmtpPasswordEncrypted"" text NULL,
+ ""FromEmail"" varchar(200) NULL,
+ ""FromName"" varchar(200) NULL,
+ ""CreatedAt"" timestamp with time zone NOT NULL,
+ ""UpdatedAt"" timestamp with time zone NULL
+ );");
}
protected override void Down(MigrationBuilder b)
{
- b.DropTable(name: "platform_settings", schema: "public");
+ b.Sql(@"DROP TABLE IF EXISTS public.platform_settings;");
}
}
}
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260508000000_Phase5c_UnitsOfMeasureGlobal.cs b/src/food-market.infrastructure/Persistence/Migrations/20260508000000_Phase5c_UnitsOfMeasureGlobal.cs
index e866547..04dad9c 100644
--- a/src/food-market.infrastructure/Persistence/Migrations/20260508000000_Phase5c_UnitsOfMeasureGlobal.cs
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260508000000_Phase5c_UnitsOfMeasureGlobal.cs
@@ -113,16 +113,23 @@ UPDATE public.units_of_measure
b.Sql(@"DELETE FROM public.units_of_measure WHERE ""OrganizationId"" IS NOT NULL;");
// 7. Доинсёртить канонические globals, если каких-то не было в БД.
+ // Передаём ВСЕ NOT NULL поля схемы units_of_measure: Symbol, Name,
+ // DecimalPlaces, IsBase, CreatedAt — иначе INSERT падает 23502
+ // («null value in column "Symbol" violates not-null constraint»)
+ // на свежей БД, где units_of_measure был пустой и до Phase5c
+ // единицы не успели заinsert'иться.
b.Sql(@"
- INSERT INTO public.units_of_measure (""Id"", ""Code"", ""Name"", ""IsActive"")
- SELECT gen_random_uuid(), v.code, v.name, true
+ INSERT INTO public.units_of_measure
+ (""Id"", ""Code"", ""Name"", ""Symbol"", ""DecimalPlaces"", ""IsBase"", ""IsActive"", ""CreatedAt"")
+ SELECT gen_random_uuid(), v.code, v.name, v.symbol,
+ v.decimals, false, true, now() AT TIME ZONE 'UTC'
FROM (VALUES
- ('796','штука'),
- ('166','килограмм'),
- ('112','литр'),
- ('006','метр'),
- ('625','упаковка')
- ) AS v(code, name)
+ ('796','штука', 'шт', 0),
+ ('166','килограмм', 'кг', 3),
+ ('112','литр', 'л', 3),
+ ('006','метр', 'м', 2),
+ ('625','упаковка', 'уп', 0)
+ ) AS v(code, name, symbol, decimals)
WHERE NOT EXISTS (
SELECT 1 FROM public.units_of_measure
WHERE ""OrganizationId"" IS NULL AND ""Code"" = v.code
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260508100000_Phase5d_DropUnitOfMeasureDescription.cs b/src/food-market.infrastructure/Persistence/Migrations/20260508100000_Phase5d_DropUnitOfMeasureDescription.cs
index ce557ee..b76b049 100644
--- a/src/food-market.infrastructure/Persistence/Migrations/20260508100000_Phase5d_DropUnitOfMeasureDescription.cs
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260508100000_Phase5d_DropUnitOfMeasureDescription.cs
@@ -17,21 +17,32 @@ public partial class Phase5d_DropUnitOfMeasureDescription : Migration
{
protected override void Up(MigrationBuilder b)
{
- b.DropColumn(
- name: "Description",
- schema: "public",
- table: "units_of_measure");
+ // Идемпотентно: на старой стейдж-БД колонка Description была, на свежей
+ // (после рефакторинга Phase1Catalog) её сразу нет — без IF EXISTS
+ // миграция падает с 42703 «column Description does not exist».
+ b.Sql(@"
+ DO $$
+ BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='units_of_measure'
+ AND column_name='Description') THEN
+ ALTER TABLE public.units_of_measure DROP COLUMN ""Description"";
+ END IF;
+ END $$;");
}
protected override void Down(MigrationBuilder b)
{
- b.AddColumn(
- name: "Description",
- schema: "public",
- table: "units_of_measure",
- type: "character varying(500)",
- maxLength: 500,
- nullable: true);
+ b.Sql(@"
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='units_of_measure'
+ AND column_name='Description') THEN
+ ALTER TABLE public.units_of_measure
+ ADD COLUMN ""Description"" varchar(500) NULL;
+ END IF;
+ END $$;");
}
}
}
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260523120000_Phase5f_DropStoreKindRudiment.cs b/src/food-market.infrastructure/Persistence/Migrations/20260523120000_Phase5f_DropStoreKindRudiment.cs
new file mode 100644
index 0000000..31e6754
--- /dev/null
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260523120000_Phase5f_DropStoreKindRudiment.cs
@@ -0,0 +1,68 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using foodmarket.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace foodmarket.Infrastructure.Persistence.Migrations
+{
+ /// Phase5f — выкидываем рудиментные колонки Kind из stores и
+ /// counterparties.
+ ///
+ /// История: ранние черновики домена различали тип объекта через целочисленное
+ /// поле Kind. Позже модель упростили — для Store отдельной сущностью стал
+ /// RetailPoint; для Counterparty — отдельный enum CounterpartyType (поле
+ /// Type, а не Kind). Свойства Kind ушли из domain и configurations, но
+ /// миграции Phase1Catalog продолжали создавать колонки как NOT NULL integer
+ /// без default'а.
+ ///
+ /// Симптом: при любом INSERT в stores или counterparties (seeder,
+ /// signup-bootstrap, контроллер) Postgres падает 23502 «null value in
+ /// column "Kind"». На чистой dev-БД невозможно зарегистрировать организацию
+ /// и создать ни одного контрагента.
+ ///
+ /// Фикс: дропаем колонки идемпотентно (стейдж уже мог быть руками
+ /// почищен).
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260523120000_Phase5f_DropStoreKindRudiment")]
+ public partial class Phase5f_DropStoreKindRudiment : Migration
+ {
+ protected override void Up(MigrationBuilder b)
+ {
+ b.Sql(@"
+ DO $$
+ BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='stores'
+ AND column_name='Kind') THEN
+ ALTER TABLE public.stores DROP COLUMN ""Kind"";
+ END IF;
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='counterparties'
+ AND column_name='Kind') THEN
+ ALTER TABLE public.counterparties DROP COLUMN ""Kind"";
+ END IF;
+ END $$;");
+ }
+
+ protected override void Down(MigrationBuilder b)
+ {
+ b.Sql(@"
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='stores'
+ AND column_name='Kind') THEN
+ ALTER TABLE public.stores
+ ADD COLUMN ""Kind"" integer NOT NULL DEFAULT 0;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='counterparties'
+ AND column_name='Kind') THEN
+ ALTER TABLE public.counterparties
+ ADD COLUMN ""Kind"" integer NOT NULL DEFAULT 0;
+ END IF;
+ END $$;");
+ }
+ }
+}
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260523130000_Phase5g_ProductVatRealign.cs b/src/food-market.infrastructure/Persistence/Migrations/20260523130000_Phase5g_ProductVatRealign.cs
new file mode 100644
index 0000000..2a35534
--- /dev/null
+++ b/src/food-market.infrastructure/Persistence/Migrations/20260523130000_Phase5g_ProductVatRealign.cs
@@ -0,0 +1,103 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using foodmarket.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace foodmarket.Infrastructure.Persistence.Migrations
+{
+ /// Phase5g — приводим схему products в соответствие с текущим domain
+ /// и кодом контроллеров.
+ ///
+ /// Расхождение: в БД остались рудименты от старой попытки вынести VAT в
+ /// отдельную таблицу vat_rates (FK products.VatRateId → vat_rates.Id),
+ /// а также legacy-флаг IsAlcohol. Domain Product (см. food-market.domain/
+ /// Catalog/Product.cs) теперь хранит ставку напрямую как decimal Vat
+ /// + bool VatEnabled, vat_rates никем не используется. Любой INSERT
+ /// в products падает 42703 «column "Vat" of relation "products" does not
+ /// exist», что блокирует создание товаров и весь dataflow приёмка→продажа.
+ ///
+ /// Миграция:
+ /// 1. Дропаем FK products.VatRateId → vat_rates.
+ /// 2. Дропаем колонку products.VatRateId.
+ /// 3. Дропаем рудиментную колонку products.IsAlcohol.
+ /// 4. Дропаем пустую таблицу vat_rates.
+ /// 5. Добавляем products.Vat numeric(5,2) NOT NULL DEFAULT 12
+ /// (12% — ставка НДС по умолчанию для KZ; конкретный товар может быть 0%).
+ /// 6. Добавляем products.VatEnabled boolean NOT NULL DEFAULT true.
+ ///
+ /// Все шаги идемпотентны (на стейдже могло быть применено вручную).
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260523130000_Phase5g_ProductVatRealign")]
+ public partial class Phase5g_ProductVatRealign : Migration
+ {
+ protected override void Up(MigrationBuilder b)
+ {
+ b.Sql(@"
+ DO $$
+ BEGIN
+ -- 1. FK products.VatRateId -> vat_rates
+ IF EXISTS (SELECT 1 FROM information_schema.table_constraints
+ WHERE table_schema='public' AND table_name='products'
+ AND constraint_name='FK_products_vat_rates_VatRateId') THEN
+ ALTER TABLE public.products DROP CONSTRAINT ""FK_products_vat_rates_VatRateId"";
+ END IF;
+
+ -- 2. products.VatRateId column
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='products'
+ AND column_name='VatRateId') THEN
+ DROP INDEX IF EXISTS public.""IX_products_VatRateId"";
+ ALTER TABLE public.products DROP COLUMN ""VatRateId"";
+ END IF;
+
+ -- 3. products.IsAlcohol rudiment
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='products'
+ AND column_name='IsAlcohol') THEN
+ ALTER TABLE public.products DROP COLUMN ""IsAlcohol"";
+ END IF;
+
+ -- 4. vat_rates table
+ IF EXISTS (SELECT 1 FROM information_schema.tables
+ WHERE table_schema='public' AND table_name='vat_rates') THEN
+ DROP TABLE public.vat_rates CASCADE;
+ END IF;
+
+ -- 5. products.Vat numeric(5,2)
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='products'
+ AND column_name='Vat') THEN
+ ALTER TABLE public.products
+ ADD COLUMN ""Vat"" numeric(5,2) NOT NULL DEFAULT 12;
+ END IF;
+
+ -- 6. products.VatEnabled boolean
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='public' AND table_name='products'
+ AND column_name='VatEnabled') THEN
+ ALTER TABLE public.products
+ ADD COLUMN ""VatEnabled"" boolean NOT NULL DEFAULT true;
+ END IF;
+ END $$;");
+ }
+
+ protected override void Down(MigrationBuilder b)
+ {
+ // Без vat_rates восстановить семантически невозможно — это деструктивная
+ // миграция, откат вернёт только структуру.
+ b.Sql(@"
+ CREATE TABLE IF NOT EXISTS public.vat_rates (
+ ""Id"" uuid PRIMARY KEY,
+ ""Code"" varchar(10) NOT NULL,
+ ""Rate"" numeric(5,2) NOT NULL,
+ ""CreatedAt"" timestamp with time zone NOT NULL
+ );
+
+ ALTER TABLE public.products
+ DROP COLUMN IF EXISTS ""VatEnabled"",
+ DROP COLUMN IF EXISTS ""Vat"",
+ ADD COLUMN IF NOT EXISTS ""IsAlcohol"" boolean NOT NULL DEFAULT false;");
+ }
+ }
+}