fix(migrations): чиним P0-блокеры разворачивания на чистой БД

Проблема: на свежей PostgreSQL `dotnet ef database update` падает на пяти
миграциях подряд + рантайм-несовместимость схемы с domain Product/Store/
Counterparty. Невозможно поднять стек ни на dev, ни на новом стейдже.

Найдено и починено:

1. Phase2c4_ReconcileStage пыталась AddColumn IsMarked, который Phase1Catalog
   (после рефакторинга) уже добавляет. Завернули в IF NOT EXISTS.

2. Phase5d_ProductVatDecimal ALTER COLUMN products.Vat падал — Vat теперь
   заменён на FK VatRateId, колонки нет. Завернули в IF EXISTS.

3. Phase5c_UnitsOfMeasureGlobal INSERT канонических ОКЕИ пропускал NOT NULL
   колонку Symbol (а также DecimalPlaces, IsBase, CreatedAt). Дополнили
   полным набором: шт/кг/л/м/уп.

4. Phase5d_DropUnitOfMeasureDescription дропала несуществующую колонку
   (Description в новой схеме отсутствует). Завернули в IF EXISTS.

5. Phase5a_EmployeeSoftDelete и Phase5b_PlatformSettings были написаны
   вручную без атрибутов [Migration] + [DbContext] — EF их игнорировал
   и пропускал применение (см. memory/feedback_ef_migrations.md).
   Добавили атрибуты + сделали идемпотентными.

6. Новая Phase5f_DropStoreKindRudiment: rudimentные колонки stores.Kind и
   counterparties.Kind (NOT NULL без default'а) роняли любой INSERT —
   ни одной организации/контрагента создать нельзя. Дропаем.

7. Новая Phase5g_ProductVatRealign: приводим products в соответствие с
   domain — дропаем FK→vat_rates + колонку VatRateId + IsAlcohol + пустую
   таблицу vat_rates; добавляем products.Vat numeric(5,2) DEFAULT 12 и
   VatEnabled bool DEFAULT true. Без этого ProductsController падает 42703
   при создании любого товара.

Все миграции идемпотентны (DO $$ ... IF EXISTS/NOT EXISTS ...) — повторное
применение на старой стейдж-БД безопасно.

Проверено: E2E full-cycle на свежей dev-БД проходит 12/12 шагов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-23 12:13:19 +05:00
parent 35d70c5d80
commit a06464baeb
8 changed files with 293 additions and 76 deletions

View file

@ -23,21 +23,22 @@ public partial class Phase2c4_ReconcileStage : Migration
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
// 1. Добавляем IsMarked с дефолтом false. // Полностью идемпотентно: на стейдже (старый Phase1Catalog без IsMarked,
migrationBuilder.AddColumn<bool>( // но с TrackingType) колонку добавим и бэкфилл'нем. На свежей БД, где
name: "IsMarked", // отрефакторённый Phase1Catalog уже создал IsMarked, — пропускаем.
schema: "public", // Без этого защитного блока миграция падает с
table: "products", // `column "IsMarked" of relation "products" already exists` при первом
type: "boolean", // dotnet ef database update на пустой БД.
nullable: false,
defaultValue: false);
// 2. Если TrackingType есть в БД (стейдж) — бэкфиллим и удаляем.
// На свежей БД (dev, где migrations 2c2/2c3 не применялись отдельно)
// колонки не будет — IF EXISTS защищает от ошибки.
migrationBuilder.Sql(""" migrationBuilder.Sql("""
DO $$ DO $$
BEGIN 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 IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'products' WHERE table_schema = 'public' AND table_name = 'products'
AND column_name = 'TrackingType') THEN AND column_name = 'TrackingType') THEN

View file

@ -11,17 +11,35 @@ public partial class Phase5d_ProductVatDecimal : Migration
{ {
protected override void Up(MigrationBuilder b) 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(""" b.Sql("""
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 TABLE public.products
ALTER COLUMN "Vat" TYPE numeric(5,2) USING "Vat"::numeric(5,2); ALTER COLUMN "Vat" TYPE numeric(5,2) USING "Vat"::numeric(5,2);
END IF;
END $$;
"""); """);
} }
protected override void Down(MigrationBuilder b) protected override void Down(MigrationBuilder b)
{ {
b.Sql(""" b.Sql("""
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 TABLE public.products
ALTER COLUMN "Vat" TYPE integer USING ROUND("Vat")::integer; ALTER COLUMN "Vat" TYPE integer USING ROUND("Vat")::integer;
END IF;
END $$;
"""); """);
} }
} }

View file

@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable #nullable disable
@ -10,37 +12,42 @@ namespace foodmarket.Infrastructure.Persistence.Migrations
/// IsActive=false + FiredAt — уволен /// IsActive=false + FiredAt — уволен
/// IsActive=false + IsDeleted=true + DeletedAt — soft-deleted /// IsActive=false + IsDeleted=true + DeletedAt — soft-deleted
/// Физически Employee никогда не удаляем (FK из retail_sales, supplies).</summary> /// Физически Employee никогда не удаляем (FK из retail_sales, supplies).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260506000000_Phase5a_EmployeeSoftDelete")]
public partial class Phase5a_EmployeeSoftDelete : Migration public partial class Phase5a_EmployeeSoftDelete : Migration
{ {
protected override void Up(MigrationBuilder b) protected override void Up(MigrationBuilder b)
{ {
b.AddColumn<bool>( // Идемпотентно (см. CLAUDE memory feedback_ef_migrations): на чистой
name: "IsDeleted", // dev-БД нужен AddColumn, на стейдже миграция могла быть применена
schema: "public", // вручную через SQL — повторный AddColumn упадёт.
table: "employees", b.Sql(@"
type: "boolean", DO $$
nullable: false, BEGIN
defaultValue: false); 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<System.DateTime>( b.Sql(@"
name: "DeletedAt", CREATE INDEX IF NOT EXISTS ""IX_employees_OrganizationId_IsDeleted""
schema: "public", ON public.employees (""OrganizationId"", ""IsDeleted"");");
table: "employees",
type: "timestamp with time zone",
nullable: true);
b.CreateIndex(
name: "IX_employees_OrganizationId_IsDeleted",
schema: "public",
table: "employees",
columns: new[] { "OrganizationId", "IsDeleted" });
} }
protected override void Down(MigrationBuilder b) protected override void Down(MigrationBuilder b)
{ {
b.DropIndex(name: "IX_employees_OrganizationId_IsDeleted", schema: "public", table: "employees"); b.Sql(@"DROP INDEX IF EXISTS public.""IX_employees_OrganizationId_IsDeleted"";");
b.DropColumn(name: "IsDeleted", schema: "public", table: "employees"); b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""IsDeleted"";");
b.DropColumn(name: "DeletedAt", schema: "public", table: "employees"); b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""DeletedAt"";");
} }
} }
} }

View file

@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable #nullable disable
@ -7,33 +9,33 @@ namespace foodmarket.Infrastructure.Persistence.Migrations
/// <summary>Платформенные настройки (singleton). SMTP-креды для отправки /// <summary>Платформенные настройки (singleton). SMTP-креды для отправки
/// писем (forgot-password, нотификации). Управляется SuperAdmin'ом через /// писем (forgot-password, нотификации). Управляется SuperAdmin'ом через
/// /super-admin/platform-settings. Видна только им.</summary> /// /super-admin/platform-settings. Видна только им.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260506100000_Phase5b_PlatformSettings")]
public partial class Phase5b_PlatformSettings : Migration public partial class Phase5b_PlatformSettings : Migration
{ {
protected override void Up(MigrationBuilder b) protected override void Up(MigrationBuilder b)
{ {
b.CreateTable( // Идемпотентно — миграция могла быть применена ранее SQL-ом, либо
name: "platform_settings", // соседняя миграция могла создать таблицу под тем же именем.
schema: "public", b.Sql(@"
columns: table => new CREATE TABLE IF NOT EXISTS public.platform_settings (
{ ""Id"" uuid PRIMARY KEY,
Id = table.Column<System.Guid>(type: "uuid", nullable: false), ""SmtpHost"" varchar(200) NULL,
SmtpHost = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true), ""SmtpPort"" integer NULL,
SmtpPort = table.Column<int>(type: "integer", nullable: true), ""SmtpUseSsl"" boolean NOT NULL DEFAULT false,
SmtpUseSsl = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false), ""SmtpStartTls"" boolean NOT NULL DEFAULT true,
SmtpStartTls = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true), ""SmtpUsername"" varchar(200) NULL,
SmtpUsername = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true), ""SmtpPasswordEncrypted"" text NULL,
SmtpPasswordEncrypted = table.Column<string>(type: "text", nullable: true), ""FromEmail"" varchar(200) NULL,
FromEmail = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true), ""FromName"" varchar(200) NULL,
FromName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true), ""CreatedAt"" timestamp with time zone NOT NULL,
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false), ""UpdatedAt"" timestamp with time zone NULL
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true), );");
},
constraints: table => table.PrimaryKey("PK_platform_settings", x => x.Id));
} }
protected override void Down(MigrationBuilder b) protected override void Down(MigrationBuilder b)
{ {
b.DropTable(name: "platform_settings", schema: "public"); b.Sql(@"DROP TABLE IF EXISTS public.platform_settings;");
} }
} }
} }

View file

@ -113,16 +113,23 @@ UPDATE public.units_of_measure
b.Sql(@"DELETE FROM public.units_of_measure WHERE ""OrganizationId"" IS NOT NULL;"); b.Sql(@"DELETE FROM public.units_of_measure WHERE ""OrganizationId"" IS NOT NULL;");
// 7. Доинсёртить канонические globals, если каких-то не было в БД. // 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(@" b.Sql(@"
INSERT INTO public.units_of_measure (""Id"", ""Code"", ""Name"", ""IsActive"") INSERT INTO public.units_of_measure
SELECT gen_random_uuid(), v.code, v.name, true (""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 FROM (VALUES
('796','штука'), ('796','штука', 'шт', 0),
('166','килограмм'), ('166','килограмм', 'кг', 3),
('112','литр'), ('112','литр', 'л', 3),
('006','метр'), ('006','метр', 'м', 2),
('625','упаковка') ('625','упаковка', 'уп', 0)
) AS v(code, name) ) AS v(code, name, symbol, decimals)
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM public.units_of_measure SELECT 1 FROM public.units_of_measure
WHERE ""OrganizationId"" IS NULL AND ""Code"" = v.code WHERE ""OrganizationId"" IS NULL AND ""Code"" = v.code

View file

@ -17,21 +17,32 @@ public partial class Phase5d_DropUnitOfMeasureDescription : Migration
{ {
protected override void Up(MigrationBuilder b) protected override void Up(MigrationBuilder b)
{ {
b.DropColumn( // Идемпотентно: на старой стейдж-БД колонка Description была, на свежей
name: "Description", // (после рефакторинга Phase1Catalog) её сразу нет — без IF EXISTS
schema: "public", // миграция падает с 42703 «column Description does not exist».
table: "units_of_measure"); 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) protected override void Down(MigrationBuilder b)
{ {
b.AddColumn<string>( b.Sql(@"
name: "Description", DO $$
schema: "public", BEGIN
table: "units_of_measure", IF NOT EXISTS (SELECT 1 FROM information_schema.columns
type: "character varying(500)", WHERE table_schema='public' AND table_name='units_of_measure'
maxLength: 500, AND column_name='Description') THEN
nullable: true); ALTER TABLE public.units_of_measure
ADD COLUMN ""Description"" varchar(500) NULL;
END IF;
END $$;");
} }
} }
} }

View file

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>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-БД невозможно зарегистрировать организацию
/// и создать ни одного контрагента.
///
/// Фикс: дропаем колонки идемпотентно (стейдж уже мог быть руками
/// почищен).</summary>
[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 $$;");
}
}
}

View file

@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase5g — приводим схему products в соответствие с текущим domain
/// и кодом контроллеров.
///
/// Расхождение: в БД остались рудименты от старой попытки вынести VAT в
/// отдельную таблицу <c>vat_rates</c> (FK products.VatRateId → vat_rates.Id),
/// а также legacy-флаг IsAlcohol. Domain Product (см. food-market.domain/
/// Catalog/Product.cs) теперь хранит ставку напрямую как <c>decimal Vat</c>
/// + <c>bool VatEnabled</c>, 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.
///
/// Все шаги идемпотентны (на стейдже могло быть применено вручную).</summary>
[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;");
}
}
}