Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны). EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD + Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в одной Serializable-транзакции; Unpost — обратная пара тоже атомарно. Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар мог быть уже расходован). Web: /inventory/transfers (list+edit) с двумя селекторами складов и визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission TransferEdit добавлен в RolePermissions. Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock на FromStore → 409 без побочных эффектов; tenant-изоляция). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
162 lines
8.1 KiB
C#
162 lines
8.1 KiB
C#
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
using FluentAssertions;
|
||
using foodmarket.IntegrationTests.Support;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.IntegrationTests;
|
||
|
||
[Collection(ApiCollection.Name)]
|
||
public class TransferPostUnpostTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public TransferPostUnpostTests(ApiFactory factory) => _factory = factory;
|
||
|
||
private static string RandomBarcode()
|
||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||
|
||
/// <summary>Создать второй склад через api (главный уже есть из bootstrap).</summary>
|
||
private async Task<string> CreateSecondStore(ApiActor api, string name)
|
||
{
|
||
var resp = await api.Http.PostAsJsonAsync("/api/catalog/stores", new
|
||
{
|
||
name, code = (string?)null, address = "Алматы 2",
|
||
phone = (string?)null, managerName = (string?)null,
|
||
isMain = false, isActive = true,
|
||
});
|
||
resp.EnsureSuccessStatusCode();
|
||
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Post_creates_paired_movements_unpost_reverts_no_orphans()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"xfer-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var to = await CreateSecondStore(api, $"Склад-2-{Guid.NewGuid():N}");
|
||
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
|
||
|
||
// Оприходовать 10 шт на первый склад.
|
||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
notes = "seed", lines = new[] { new { productId, quantity = 10m, unitCost = 50m } },
|
||
});
|
||
enter.EnsureSuccessStatusCode();
|
||
var enterId = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode();
|
||
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
|
||
|
||
// Создать перемещение: 4 шт с первого склада на второй.
|
||
var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
|
||
{
|
||
date = DateTime.UtcNow,
|
||
fromStoreId = refs.StoreId, toStoreId = to,
|
||
notes = "test",
|
||
lines = new[] { new { productId, quantity = 4m, unitCost = 50m } },
|
||
});
|
||
xfer.EnsureSuccessStatusCode();
|
||
var xferId = (await xfer.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
|
||
// Post → 6 на источнике, 4 на получателе.
|
||
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { });
|
||
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
|
||
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(6m);
|
||
(await api.StockOfAsync(to, productId)).Should().Be(4m);
|
||
|
||
// Проверяем что движений ровно 2 (TransferOut + TransferIn) — никаких orphan.
|
||
var movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100");
|
||
var byThisDoc = movs.Where(m =>
|
||
m.GetProperty("documentId").ValueKind == JsonValueKind.String &&
|
||
m.GetProperty("documentId").GetString() == xferId).ToList();
|
||
byThisDoc.Should().HaveCount(2, "перемещение порождает строго пару движений Out+In");
|
||
|
||
// Unpost → 10 и 0. Атомарность: ИЛИ оба отменены, ИЛИ ни одного.
|
||
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/unpost", new { });
|
||
unpost.IsSuccessStatusCode.Should().BeTrue();
|
||
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
|
||
(await api.StockOfAsync(to, productId)).Should().Be(0m);
|
||
|
||
// По документу теперь 4 движения (2 forward + 2 reversal), но stock-инвариант держится.
|
||
movs = await api.ListAsync($"/api/inventory/movements?productId={productId}&pageSize=100");
|
||
var byThisDocAfter = movs.Where(m =>
|
||
m.GetProperty("documentId").ValueKind == JsonValueKind.String &&
|
||
m.GetProperty("documentId").GetString() == xferId).ToList();
|
||
byThisDocAfter.Should().HaveCount(4);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Cannot_create_transfer_to_same_store()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"xfer-same-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
using var resp = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
|
||
{
|
||
date = DateTime.UtcNow,
|
||
fromStoreId = refs.StoreId, toStoreId = refs.StoreId,
|
||
notes = "same",
|
||
lines = new[] { new { productId, quantity = 1m, unitCost = 10m } },
|
||
});
|
||
((int)resp.StatusCode).Should().Be(400);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Post_blocked_when_from_store_short()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"xfer-short-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var to = await CreateSecondStore(api, $"Склад-S-{Guid.NewGuid():N}");
|
||
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
// На первом 0 шт; пытаемся перевести 5 — должно отбиться.
|
||
var xfer = await api.Http.PostAsJsonAsync("/api/inventory/transfers", new
|
||
{
|
||
date = DateTime.UtcNow,
|
||
fromStoreId = refs.StoreId, toStoreId = to,
|
||
notes = "short",
|
||
lines = new[] { new { productId, quantity = 5m, unitCost = 10m } },
|
||
});
|
||
xfer.EnsureSuccessStatusCode();
|
||
var xferId = (await xfer.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
|
||
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/transfers/{xferId}/post", new { });
|
||
((int)post.StatusCode).Should().Be(409);
|
||
// Никаких движений не должно появиться.
|
||
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
|
||
(await api.StockOfAsync(to, productId)).Should().Be(0m);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Tenant_isolation_transfer()
|
||
{
|
||
var a = new ApiActor(_factory.CreateClient());
|
||
var b = new ApiActor(_factory.CreateClient());
|
||
await a.SignupAndLoginAsync($"xfer-iso-a-{Guid.NewGuid():N}");
|
||
await b.SignupAndLoginAsync($"xfer-iso-b-{Guid.NewGuid():N}");
|
||
var refsA = await a.LoadRefsAsync();
|
||
var toA = await CreateSecondStore(a, $"Склад-A2-{Guid.NewGuid():N}");
|
||
var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
var resp = await a.Http.PostAsJsonAsync("/api/inventory/transfers", new
|
||
{
|
||
date = DateTime.UtcNow,
|
||
fromStoreId = refsA.StoreId, toStoreId = toA,
|
||
notes = "iso",
|
||
lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } },
|
||
});
|
||
resp.EnsureSuccessStatusCode();
|
||
var xferId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
|
||
var bList = await b.ListAsync("/api/inventory/transfers?pageSize=200");
|
||
bList.Should().NotContain(x => x.GetProperty("id").GetString() == xferId);
|
||
|
||
using var direct = await b.Http.GetAsync($"/api/inventory/transfers/{xferId}");
|
||
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
|
||
}
|
||
}
|