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>
99 lines
5 KiB
C#
99 lines
5 KiB
C#
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 });
|
||
});
|
||
}
|
||
}
|