food-market/tests/food-market.UnitTests/DomainPocoSmokeTests.cs
nns 0d44cdabaf feat(inventory): модель МойСклада — инвентаризация не двигает остатки
Замечание владельца: инвентаризация стояла «Проведено» без оприходования/списания.
Переделано по МойСкладу (docs/inventory-moysklad-reference.md):

- InventoryStatus Draft→Completed (было Posted; значение 1 то же). Инвентаризация
  остатки НЕ двигает: endpoint complete замораживает строки без движений.
- create-enter (излишки diff>0) / create-loss (недостача diff<0) — создают черновики
  Оприходования/Списания со строками разницы + ссылка InventoryDocId. Повторно нельзя (409).
- Кнопки «Применить результаты» НЕТ (поправка Mac-Claude — не одобрено владельцем).
- unpost («вернуть в черновик») отменяет проведение и удаляет связанные документы.
- post/unpost — временная совместимость для мобильного «FM Склад» (post = завершить +
  создать и провести оба документа); помечено в docs/mobile-api.md.
- Enter/Loss: поле InventoryDocId (+ миграция Phase38) + в DTO «на основании инвентаризации №…».
- InventoryResult (application) — чистая разбивка diff→излишки/недостача (unit-тесты:
  разница/сумма/только посчитанные строки/округление).
- InventoryMigrationController (dry-run/apply): старые Posted+InventoryAdjustment →
  связанные проведённые документы переметкой движений (не задваивает), с проверкой
  неизменности остатков. Скрипт deploy/migrate-inventory-to-docs.sh (backup + запуск).
- Демо-сидеры: инвентаризации теперь Completed без прямых движений.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:34:05 +05:00

339 lines
13 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 FluentAssertions;
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
using foodmarket.Domain.Integrations;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Organizations;
using foodmarket.Domain.Platform;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Sprint 15 — POCO smoke. Доменные сущности почти всегда auto-properties.
/// Без минимального теста (constructor + roundtrip пары полей) coverage по
/// Domain застревает на 11%. Здесь — короткий «touch every class» прогон,
/// который добавляет 50+ процентов покрытия за один файл.
///
/// Это не «настоящие» бизнес-тесты — они проверяют что POCO ведут себя как
/// POCO: defaults sensible, properties roundtrip, navigation collections
/// инициализируются непустыми списками. Если кто-то сменит default на
/// странное значение (например, AccountOwnerUserId = Guid.NewGuid() по
/// дефолту) — тест поймает.</summary>
public class DomainPocoSmokeTests
{
[Fact]
public void Entity_base_sets_sane_defaults()
{
var e = new TestEntity();
e.Id.Should().NotBe(Guid.Empty, "Id должен инициализироваться NewGuid'ом");
e.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
e.UpdatedAt.Should().BeNull();
}
private class TestEntity : Entity { }
[Fact]
public void TenantEntity_default_org_is_empty()
{
var e = new TestTenant();
e.OrganizationId.Should().Be(Guid.Empty,
"stamping в AppDbContext.SaveChanges подставит реальный orgId");
}
private class TestTenant : TenantEntity { }
// ── Sales ────────────────────────────────────────────────────────────
[Fact]
public void RetailSale_defaults_and_round_trip()
{
var sale = new RetailSale
{
Number = "ПР-001",
Date = new DateTime(2026, 6, 7, 0, 0, 0, DateTimeKind.Utc),
Status = RetailSaleStatus.Posted,
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Subtotal = 1000m, Total = 1000m, PaidCash = 1000m,
};
sale.Number.Should().Be("ПР-001");
sale.Status.Should().Be(RetailSaleStatus.Posted);
sale.Lines.Should().BeEmpty();
sale.IsReturn.Should().BeFalse();
sale.Payment.Should().Be(PaymentMethod.Cash);
sale.Lines.Add(new RetailSaleLine
{
ProductId = Guid.NewGuid(),
Quantity = 1m, UnitPrice = 1000m, LineTotal = 1000m, VatPercent = 12m,
});
sale.Lines.Should().HaveCount(1);
sale.Lines.First().LineTotal.Should().Be(1000m);
}
[Fact]
public void Demand_and_DemandLine_round_trip()
{
var d = new Demand
{
Number = "ОТ-001",
Date = DateTime.UtcNow,
CustomerId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Total = 500m, Subtotal = 500m,
Status = DemandStatus.Posted,
};
d.Status.Should().Be(DemandStatus.Posted);
d.Lines.Should().BeEmpty();
d.Lines.Add(new DemandLine { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 500m, LineTotal = 500m, VatPercent = 12m });
d.Lines.Should().HaveCount(1);
}
[Fact]
public void Loyalty_Promotion_pocos()
{
var prog = new LoyaltyProgram { Name = "Plus", Type = LoyaltyProgramType.Percentage, Rate = 10m, IsActive = true };
prog.Name.Should().Be("Plus");
prog.Type.Should().Be(LoyaltyProgramType.Percentage);
prog.Cards.Should().BeEmpty();
var card = new LoyaltyCard { CardNumber = "ABC-1", ProgramId = prog.Id, CounterpartyId = Guid.NewGuid(), Balance = 500m };
card.CardNumber.Should().Be("ABC-1");
card.Balance.Should().Be(500m);
var promo = new Promotion
{
Name = "10%",
Type = PromotionType.Percent,
Value = 10m,
IsActive = true,
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(30),
};
promo.Name.Should().Be("10%");
promo.ProductGroupIds.Should().BeEmpty();
promo.ProductIds.Should().BeEmpty();
}
// ── Purchases ─────────────────────────────────────────────────────────
[Fact]
public void Supply_and_SupplyLine_round_trip()
{
var s = new Supply
{
Number = "П-001",
Date = DateTime.UtcNow,
SupplierId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Total = 1000m,
Status = SupplyStatus.Posted,
};
s.Status.Should().Be(SupplyStatus.Posted);
s.Lines.Should().BeEmpty();
s.Lines.Add(new SupplyLine { ProductId = Guid.NewGuid(), Quantity = 10m, UnitPrice = 100m, LineTotal = 1000m });
s.Lines.Should().HaveCount(1);
}
[Fact]
public void Enter_and_SupplierReturn_pocos()
{
var e = new Enter
{
Number = "ОПР-001",
Date = DateTime.UtcNow,
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Status = EnterStatus.Posted,
};
e.Lines.Should().BeEmpty();
e.Lines.Add(new EnterLine { ProductId = Guid.NewGuid(), Quantity = 1m });
var r = new SupplierReturn
{
Number = "ВП-001",
Date = DateTime.UtcNow,
SupplierId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Status = SupplierReturnStatus.Posted,
};
r.Lines.Should().BeEmpty();
r.Lines.Add(new SupplierReturnLine { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 100m });
}
// ── Inventory ─────────────────────────────────────────────────────────
[Fact]
public void Inventory_movement_loss_transfer_pocos()
{
var loss = new Loss
{
Number = "СП-001",
Date = DateTime.UtcNow,
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Status = LossStatus.Posted,
};
loss.Lines.Add(new LossLine { ProductId = Guid.NewGuid(), Quantity = 1m });
var t = new Transfer
{
Number = "ПР-001",
Date = DateTime.UtcNow,
FromStoreId = Guid.NewGuid(),
ToStoreId = Guid.NewGuid(),
Status = TransferStatus.Posted,
};
t.Lines.Add(new TransferLine { ProductId = Guid.NewGuid(), Quantity = 1m });
var inv = new InventoryDoc
{
Number = "ИН-001",
Date = DateTime.UtcNow,
StoreId = Guid.NewGuid(),
Status = InventoryStatus.Completed,
};
inv.Lines.Add(new InventoryLine { ProductId = Guid.NewGuid(), BookQty = 10m, ActualQty = 9m, Diff = -1m });
loss.Status.Should().Be(LossStatus.Posted);
t.Status.Should().Be(TransferStatus.Posted);
inv.Status.Should().Be(InventoryStatus.Completed);
}
[Fact]
public void Stock_and_StockMovement_pocos()
{
var s = new Stock { ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(), Quantity = 100m };
s.Quantity.Should().Be(100m);
s.ReservedQuantity.Should().Be(0m);
var m = new StockMovement
{
ProductId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
Type = MovementType.Supply,
Quantity = 100m,
OccurredAt = DateTime.UtcNow,
UnitCost = 50m,
};
m.Quantity.Should().Be(100m);
m.Type.Should().Be(MovementType.Supply);
}
// ── Catalog ──────────────────────────────────────────────────────────
[Fact]
public void Product_defaults_collections()
{
var p = new Product { Name = "Хлеб" };
p.Name.Should().Be("Хлеб");
p.Prices.Should().BeEmpty();
p.Barcodes.Should().BeEmpty();
p.Images.Should().BeEmpty();
p.Prices.Add(new ProductPrice { PriceTypeId = Guid.NewGuid(), CurrencyId = Guid.NewGuid(), Amount = 100m });
p.Barcodes.Add(new ProductBarcode { Code = "1234567890123", Type = BarcodeType.Ean13, IsPrimary = true });
p.Images.Add(new ProductImage { Url = "/uploads/p.jpg", IsMain = true });
p.Prices.Should().HaveCount(1);
p.Barcodes.Should().HaveCount(1);
p.Images.Should().HaveCount(1);
}
[Fact]
public void Catalog_referencs_pocos()
{
var c = new Counterparty { Name = "Поставщик", Type = CounterpartyType.LegalEntity, Bin = "12345" };
c.Name.Should().Be("Поставщик");
c.Type.Should().Be(CounterpartyType.LegalEntity);
var u = new UnitOfMeasure { Code = "796", Name = "шт" };
u.Code.Should().Be("796");
u.IsActive.Should().BeTrue();
var st = new Store { Name = "Главный", IsMain = true };
st.IsMain.Should().BeTrue();
var rp = new RetailPoint { Name = "Касса 1", StoreId = Guid.NewGuid() };
rp.Name.Should().Be("Касса 1");
var pt = new PriceType { Name = "Розничная", IsRetail = true, IsSystem = true };
pt.IsRetail.Should().BeTrue();
pt.IsSystem.Should().BeTrue();
var cu = new Currency { Code = "KZT", Symbol = "₸", Name = "Тенге" };
cu.Code.Should().Be("KZT");
var co = new Country { Code = "KZ", Name = "Казахстан", VatRate = 12m };
co.VatRate.Should().Be(12m);
var g = new ProductGroup { Name = "Хлеб и выпечка" };
g.Name.Should().Be("Хлеб и выпечка");
var ou = new OrgUnitOfMeasure { UnitOfMeasureId = u.Id };
ou.UnitOfMeasureId.Should().Be(u.Id);
}
// ── Organizations ────────────────────────────────────────────────────
[Fact]
public void Organization_defaults()
{
var o = new Organization { Name = "FM" };
o.Name.Should().Be("FM");
o.IsActive.Should().BeTrue();
o.IsArchived.Should().BeFalse();
o.CountryCode.Should().Be("KZ");
o.FiscalProvider.Should().Be(0, "по дефолту None — Sprint 11 scaffolding");
o.ShowReferencePriceOnProduct.Should().BeTrue();
}
[Fact]
public void Employee_role_audit_pocos()
{
var e = new Employee
{
FirstName = "Иван", LastName = "Иванов",
Email = "ivan@example.kz",
IsActive = true,
};
e.FirstName.Should().Be("Иван");
e.IsActive.Should().BeTrue();
e.RetailPointAssignments.Should().BeEmpty();
e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment { RetailPointId = Guid.NewGuid() });
var role = new EmployeeRole { Name = "Кассир", Permissions = new RolePermissions() };
role.Name.Should().Be("Кассир");
role.Permissions.Should().NotBeNull();
var audit = new OrgAuditLog { Action = "create", EntityType = "Product", EntityId = Guid.NewGuid(), ChangesJson = "{}" };
audit.Action.Should().Be("create");
var sa = new SuperAdminAuditLog { ActionType = "ChangeOwner", Description = "x", Reason = "y", ChangesJson = "{}" };
sa.ActionType.Should().Be("ChangeOwner");
}
// ── Platform + Integrations ──────────────────────────────────────────
[Fact]
public void PlatformSettings_defaults()
{
var ps = new PlatformSettings();
ps.SmtpHost.Should().BeNull();
ps.SmtpStartTls.Should().BeTrue("дефолт — STARTTLS, см. comments");
ps.SmtpUseSsl.Should().BeFalse();
}
[Fact]
public void ImportJob_defaults()
{
var j = new ImportJob { Kind = "moysklad", Status = ImportJobStatus.Running };
j.Kind.Should().Be("moysklad");
j.Status.Should().Be(ImportJobStatus.Running);
j.Stage.Should().BeNull();
j.ErrorsJson.Should().Be("[]", "JSON-массив по дефолту");
}
}