food-market/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs
nns 47a019dc6d feat(demands): оптовая отгрузка контрагенту-юрлицу (P1-5)
Domain Demand+DemandLine - зеркалит RetailSale, но всегда с CustomerId
(обязателен, не nullable), способ оплаты DemandPayment с Credit
(постоплата = дебиторка), без RetailPoint/Cashier.

EF + миграция Phase8a_Demands (idempotent CREATE TABLE).
Контроллер api/sales/demands - CRUD + Post/Unpost. Post создаёт
StockMovement тип WholesaleSale с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар.

ApplyLines пишет в DbSet напрямую (не через nav-collection) и Update
использует ExecuteDelete для старых строк - тот же fix-паттерн что в
RetailSalesController (избегает DbUpdateConcurrency на client-side Id).

Permissions переиспользуют DemandsEdit/DemandsPost (уже в RolePermissions).
Метрики observability: food_market_documents_posted_total{type="demand"}
и documents_error_total{type="demand", reason="serialization"}.

Web: /sales/demands (list+edit) с AsyncSelect контрагентов, способом
оплаты включая Credit, PaidAmount-полем для дебиторки. Сайдбар:
"Оптовые отгрузки" в группе Продажи для Admin.

Тесты: 3 интеграционных (post снижает stock + unpost восстанавливает,
over-stock posting -> 409 без побочных эффектов, tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:18:49 +05:00

99 lines
5 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.Sales;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
public static class SalesConfigurations
{
public static void ConfigureSales(this ModelBuilder b)
{
b.Entity<RetailSale>(e =>
{
e.ToTable("retail_sales");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Subtotal).HasPrecision(18, 4);
e.Property(x => x.DiscountTotal).HasPrecision(18, 4);
e.Property(x => x.Total).HasPrecision(18, 4);
e.Property(x => x.PaidCash).HasPrecision(18, 4);
e.Property(x => x.PaidCard).HasPrecision(18, 4);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ReferenceSale).WithMany().HasForeignKey(x => x.ReferenceSaleId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.RetailSale).HasForeignKey(l => l.RetailSaleId).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.Status });
e.HasIndex(x => new { x.OrganizationId, x.CashierUserId });
e.HasIndex(x => new { x.OrganizationId, x.IsReturn });
e.HasIndex(x => x.ReferenceSaleId);
});
b.Entity<RetailSaleLine>(e =>
{
e.ToTable("retail_sale_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
e.Property(x => x.Discount).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.Property(x => x.VatPercent).HasPrecision(5, 2);
e.Property(x => x.QtyReturned).HasPrecision(18, 4);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
b.Entity<PosBatchAck>(e =>
{
e.ToTable("pos_batch_acks");
e.Property(x => x.IdempotencyKey).IsRequired();
e.Property(x => x.ResponseJson).HasColumnType("jsonb").IsRequired();
// Уникальный индекс — гарантия идемпотентности на уровне БД:
// вторая параллельная попытка вставить тот же ключ упадёт 23505,
// контроллер прочитает уже сохранённый ResponseJson и вернёт его.
e.HasIndex(x => new { x.OrganizationId, x.IdempotencyKey }).IsUnique();
e.HasIndex(x => x.CreatedAt); // для фонового cleanup'а старых acks
});
b.Entity<Demand>(e =>
{
e.ToTable("demands");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Subtotal).HasPrecision(18, 4);
e.Property(x => x.DiscountTotal).HasPrecision(18, 4);
e.Property(x => x.Total).HasPrecision(18, 4);
e.Property(x => x.PaidAmount).HasPrecision(18, 4);
e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
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.HasMany(x => x.Lines).WithOne(l => l.Demand).HasForeignKey(l => l.DemandId).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.Status });
e.HasIndex(x => new { x.OrganizationId, x.CustomerId });
});
b.Entity<DemandLine>(e =>
{
e.ToTable("demand_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
e.Property(x => x.Discount).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.Property(x => x.VatPercent).HasPrecision(5, 2);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
}
}