food-market/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs
nns a25b5b3277
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
feat(money): бэкенд модуля «Деньги» для простого режима (пп.0–6)
Сверка с МойСкладом (docs/money-moysklad-reference.md) + домен/инфра/API:

- Счета и кассы (MoneyAccount: Cash/Safe/Bank/Acquiring, начальный остаток,
  привязка к магазину, IsDefault/IsActive, инвариант CurrentBalance).
- Денежные документы (MoneyTransaction: PaymentIn/Out, CashIn/Out, переводы
  парой TransferOut↔TransferIn; статьи расходов ExpenseItem; проведение/отмена
  в Serializable-транзакции через MoneyService).
- Оплата поставщикам: Supply/SupplierReturn.PayedSum + MoneyTransactionLink,
  SuppliesController отдаёт PaymentStatus + фильтр paymentStatus.
- Взаиморасчёты: CounterpartyAdjustment + MoneyReportsController.SupplierDebts.
- Смены кассы: CashShift + CashDrawerOperation (open/close, expected↔actual,
  Z-отчёт), запрет розничной продажи без открытой смены в RetailSalesController.
- Отчёт «Деньги»: balances/movement с проверкой инварианта остатков.

Права: RolePermissions +MoneyView/MoneyEdit/CashShiftOperate; сидер раздаёт
кассиру CashShiftOperate+MoneyView, кладовщику — нет; 5 системных статей
расходов на новую орг. Миграция Phase33a (ручная, с [Migration]+[DbContext]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 21:52:04 +05:00

177 lines
8.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using foodmarket.Domain.Money;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
/// <summary>Схема таблиц модуля «Деньги». Названия колонок/таблиц
/// подчинены общему стилю (snake_case через ToTable + EF-camel для свойств);
/// суммовые — <c>decimal(18,4)</c>; концуррентный токен — Postgres <c>xmin</c>.</summary>
public static class MoneyConfigurations
{
public static void ConfigureMoney(this ModelBuilder b)
{
b.Entity<MoneyAccount>(e =>
{
e.ToTable("money_accounts");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.BankName).HasMaxLength(200);
e.Property(x => x.AccountNumber).HasMaxLength(100);
e.Property(x => x.OpeningBalance).HasPrecision(18, 4);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.Name });
e.HasIndex(x => new { x.OrganizationId, x.Kind, x.IsActive });
// IsDefault — на уровне контроллера проверяем «только один per Kind»:
// составной уникальный индекс с фильтром IsDefault=true.
e.HasIndex(x => new { x.OrganizationId, x.Kind })
.IsUnique()
.HasFilter("\"IsDefault\" = true");
});
b.Entity<ExpenseItem>(e =>
{
e.ToTable("expense_items");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Code).HasMaxLength(50);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Code })
.IsUnique()
.HasFilter("\"Code\" IS NOT NULL");
});
b.Entity<MoneyTransaction>(e =>
{
e.ToTable("money_transactions");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Amount).HasPrecision(18, 4);
e.Property(x => x.Purpose).HasMaxLength(500);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasOne(x => x.Account).WithMany().HasForeignKey(x => x.AccountId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Counterparty).WithMany().HasForeignKey(x => x.CounterpartyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ExpenseItem).WithMany().HasForeignKey(x => x.ExpenseItemId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.LinkedTransaction).WithMany()
.HasForeignKey(x => x.LinkedTransactionId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Links).WithOne(l => l.Transaction)
.HasForeignKey(l => l.TransactionId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Kind, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
.HasFilter("\"CounterpartyId\" IS NOT NULL");
});
b.Entity<MoneyTransactionLink>(e =>
{
e.ToTable("money_transaction_links");
e.Property(x => x.LinkedSum).HasPrecision(18, 4);
e.HasOne(x => x.Supply).WithMany().HasForeignKey(x => x.SupplyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.SupplierReturn).WithMany().HasForeignKey(x => x.SupplierReturnId).OnDelete(DeleteBehavior.Restrict);
// CHECK: ровно одна целевая ссылка должна быть заполнена.
e.ToTable(t => t.HasCheckConstraint(
"ck_money_link_single_target",
"((\"SupplyId\" IS NOT NULL)::int + (\"SupplierReturnId\" IS NOT NULL)::int) = 1"));
e.HasIndex(x => new { x.OrganizationId, x.SupplyId })
.HasFilter("\"SupplyId\" IS NOT NULL");
e.HasIndex(x => new { x.OrganizationId, x.SupplierReturnId })
.HasFilter("\"SupplierReturnId\" IS NOT NULL");
e.HasIndex(x => x.TransactionId);
});
b.Entity<CashShift>(e =>
{
e.ToTable("cash_shifts");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.OpeningFloat).HasPrecision(18, 4);
e.Property(x => x.SalesCash).HasPrecision(18, 4);
e.Property(x => x.SalesCard).HasPrecision(18, 4);
e.Property(x => x.ReturnsCash).HasPrecision(18, 4);
e.Property(x => x.ReturnsCard).HasPrecision(18, 4);
e.Property(x => x.DrawerIn).HasPrecision(18, 4);
e.Property(x => x.DrawerOut).HasPrecision(18, 4);
e.Property(x => x.ExpectedCash).HasPrecision(18, 4);
e.Property(x => x.ActualCash).HasPrecision(18, 4);
e.Property(x => x.CashDifference).HasPrecision(18, 4);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.CashAccount).WithMany().HasForeignKey(x => x.CashAccountId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.AcquiringAccount).WithMany().HasForeignKey(x => x.AcquiringAccountId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.DrawerOperations).WithOne(d => d.CashShift)
.HasForeignKey(d => d.CashShiftId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.OpenedAt });
e.HasIndex(x => new { x.OrganizationId, x.RetailPointId, x.Status });
// Только одна открытая смена per RetailPoint.
e.HasIndex(x => new { x.OrganizationId, x.RetailPointId })
.IsUnique()
.HasFilter("\"Status\" = 1");
});
b.Entity<CashDrawerOperation>(e =>
{
e.ToTable("cash_drawer_operations");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Amount).HasPrecision(18, 4);
e.Property(x => x.Reason).HasMaxLength(500).IsRequired();
e.HasIndex(x => new { x.OrganizationId, x.CashShiftId });
e.HasIndex(x => new { x.OrganizationId, x.At });
});
b.Entity<CounterpartyAdjustment>(e =>
{
e.ToTable("counterparty_adjustments");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Amount).HasPrecision(18, 4);
e.Property(x => x.Notes).HasMaxLength(1000);
e.HasOne(x => x.Counterparty).WithMany().HasForeignKey(x => x.CounterpartyId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId, x.Status });
});
// Расширения существующих таблиц:
b.Entity<foodmarket.Domain.Purchases.Supply>(e =>
{
e.Property(x => x.PayedSum).HasPrecision(18, 4);
e.HasIndex(x => new { x.OrganizationId, x.SupplierId, x.Status, x.PayedSum });
});
b.Entity<foodmarket.Domain.Purchases.SupplierReturn>(e =>
{
e.Property(x => x.PayedSum).HasPrecision(18, 4);
});
b.Entity<foodmarket.Domain.Sales.RetailSale>(e =>
{
e.HasIndex(x => new { x.OrganizationId, x.CashShiftId })
.HasFilter("\"CashShiftId\" IS NOT NULL");
});
}
}