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:
parent
35d70c5d80
commit
a06464baeb
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 $$;
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"";");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 $$;");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 $$;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue