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;"); + } + } +}