Compare commits
No commits in common. "bf536290927651c4ec52331b623d0944fe813835" and "dd3ee58502fac4d96b853125f7c3e0da80e42d24" have entirely different histories.
bf53629092
...
dd3ee58502
|
|
@ -69,7 +69,7 @@ public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(u => new UnitOfMeasureDto(
|
.Select(u => new UnitOfMeasureDto(
|
||||||
u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, true))
|
u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
|
||||||
var enabled = !orgId.HasValue || await _db.OrgUnitsOfMeasure
|
var enabled = !orgId.HasValue || await _db.OrgUnitsOfMeasure
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
|
.AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
|
||||||
return new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, enabled);
|
return new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ public class SuperAdminUnitsOfMeasureController : ControllerBase
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(u => new UnitOfMeasureDto(
|
.Select(u => new UnitOfMeasureDto(
|
||||||
u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, true))
|
u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
|
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
|
||||||
return u is null
|
return u is null
|
||||||
? NotFound()
|
? NotFound()
|
||||||
: new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, true);
|
: new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
@ -81,12 +81,13 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
|
||||||
OrganizationId = null,
|
OrganizationId = null,
|
||||||
Code = input.Code.Trim(),
|
Code = input.Code.Trim(),
|
||||||
Name = input.Name.Trim(),
|
Name = input.Name.Trim(),
|
||||||
|
Description = input.Description,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
};
|
};
|
||||||
_db.UnitsOfMeasure.Add(e);
|
_db.UnitsOfMeasure.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.OrganizationId, e.IsActive, true));
|
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId, e.IsActive, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
|
|
@ -109,6 +110,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
|
||||||
|
|
||||||
e.Code = input.Code.Trim();
|
e.Code = input.Code.Trim();
|
||||||
e.Name = input.Name.Trim();
|
e.Name = input.Name.Trim();
|
||||||
|
e.Description = input.Description;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ public record CountryDto(
|
||||||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
||||||
|
|
||||||
public record UnitOfMeasureDto(
|
public record UnitOfMeasureDto(
|
||||||
Guid Id, string Code, string Name, Guid? OrganizationId,
|
Guid Id, string Code, string Name, string? Description, Guid? OrganizationId,
|
||||||
bool IsActive = true, bool IsEnabledForOrg = true);
|
bool IsActive = true, bool IsEnabledForOrg = true);
|
||||||
|
|
||||||
public record PriceTypeDto(
|
public record PriceTypeDto(
|
||||||
|
|
@ -62,7 +62,7 @@ public record CountryInput(
|
||||||
string Code, string Name,
|
string Code, string Name,
|
||||||
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
|
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
|
||||||
public record CurrencyInput(string Code, string Name, string Symbol);
|
public record CurrencyInput(string Code, string Name, string Symbol);
|
||||||
public record UnitOfMeasureInput(string Code, string Name);
|
public record UnitOfMeasureInput(string Code, string Name, string? Description = null);
|
||||||
public record PriceTypeInput(
|
public record PriceTypeInput(
|
||||||
string Name, bool IsRequired = false,
|
string Name, bool IsRequired = false,
|
||||||
bool IsRetail = false, int SortOrder = 0);
|
bool IsRetail = false, int SortOrder = 0);
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ public class UnitOfMeasure : Entity, IOptionalTenantEntity
|
||||||
public Guid? OrganizationId { get; set; }
|
public Guid? OrganizationId { get; set; }
|
||||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||||
|
public string? Description { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
|
||||||
b.ToTable("units_of_measure");
|
b.ToTable("units_of_measure");
|
||||||
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
|
b.Property(x => x.Code).HasMaxLength(10).IsRequired();
|
||||||
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
|
||||||
|
b.Property(x => x.Description).HasMaxLength(500);
|
||||||
b.Property(x => x.IsActive).HasDefaultValue(true);
|
b.Property(x => x.IsActive).HasDefaultValue(true);
|
||||||
// Phase5c: после миграции OrganizationId всегда NULL — глобальный справочник.
|
// Phase5c: после миграции OrganizationId всегда NULL — глобальный справочник.
|
||||||
// Уникальность по Code только среди активных, чтобы можно было
|
// Уникальность по Code только среди активных, чтобы можно было
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using foodmarket.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
|
||||||
{
|
|
||||||
/// <summary>Phase5d — выкидываем UnitOfMeasure.Description: для пяти
|
|
||||||
/// канонических ОКЕИ-единиц («штука», «кг», ...) нечего описывать,
|
|
||||||
/// поле никогда не заполнялось ни UI, ни импортом, ни сидером.
|
|
||||||
/// Code остаётся (нужен для интеграций МойСклад/1С), но скрыт в UI
|
|
||||||
/// от org Admin'а.</summary>
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260508100000_Phase5d_DropUnitOfMeasureDescription")]
|
|
||||||
public partial class Phase5d_DropUnitOfMeasureDescription : Migration
|
|
||||||
{
|
|
||||||
protected override void Up(MigrationBuilder b)
|
|
||||||
{
|
|
||||||
b.DropColumn(
|
|
||||||
name: "Description",
|
|
||||||
schema: "public",
|
|
||||||
table: "units_of_measure");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder b)
|
|
||||||
{
|
|
||||||
b.AddColumn<string>(
|
|
||||||
name: "Description",
|
|
||||||
schema: "public",
|
|
||||||
table: "units_of_measure",
|
|
||||||
type: "character varying(500)",
|
|
||||||
maxLength: 500,
|
|
||||||
nullable: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -29,7 +29,7 @@ export interface Country {
|
||||||
}
|
}
|
||||||
export interface Currency { id: string; code: string; name: string; symbol: string }
|
export interface Currency { id: string; code: string; name: string; symbol: string }
|
||||||
export interface UnitOfMeasure {
|
export interface UnitOfMeasure {
|
||||||
id: string; code: string; name: string; organizationId: string | null;
|
id: string; code: string; name: string; description: string | null; organizationId: string | null;
|
||||||
isActive: boolean; isEnabledForOrg: boolean
|
isActive: boolean; isEnabledForOrg: boolean
|
||||||
}
|
}
|
||||||
export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
|
export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ interface Form {
|
||||||
id?: string
|
id?: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const blank: Form = { code: '', name: '' }
|
const blank: Form = { code: '', name: '', description: '' }
|
||||||
|
|
||||||
export function SuperAdminUnitsOfMeasurePage() {
|
export function SuperAdminUnitsOfMeasurePage() {
|
||||||
const list = useCatalogList<UnitOfMeasure>(URL)
|
const list = useCatalogList<UnitOfMeasure>(URL)
|
||||||
|
|
@ -79,11 +80,12 @@ export function SuperAdminUnitsOfMeasurePage() {
|
||||||
onSortChange={list.setSort}
|
onSortChange={list.setSort}
|
||||||
onRowClick={(r) => {
|
onRowClick={(r) => {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setForm({ id: r.id, code: r.code, name: r.name })
|
setForm({ id: r.id, code: r.code, name: r.name, description: r.description ?? '' })
|
||||||
}}
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||||
|
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||||
{
|
{
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
width: '110px',
|
width: '110px',
|
||||||
|
|
@ -119,6 +121,9 @@ export function SuperAdminUnitsOfMeasurePage() {
|
||||||
<Field label="Название">
|
<Field label="Название">
|
||||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Описание">
|
||||||
|
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
</Field>
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{submitError}</p>
|
<p className="text-sm text-red-600 dark:text-red-400">{submitError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,9 @@ export function UnitsOfMeasurePage() {
|
||||||
sortOrder={list.sortOrder}
|
sortOrder={list.sortOrder}
|
||||||
onSortChange={list.setSort}
|
onSortChange={list.setSort}
|
||||||
columns={[
|
columns={[
|
||||||
// Колонку «Код» (ОКЕИ 796/166/...) не показываем org-юзеру: код нужен
|
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||||
// только для интеграций (МойСклад/1С) и виден SuperAdmin'у на
|
|
||||||
// /super-admin/units. Здесь админ орги выбирает по понятному имени.
|
|
||||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||||
|
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||||
{
|
{
|
||||||
header: 'Для орги',
|
header: 'Для орги',
|
||||||
width: '120px',
|
width: '120px',
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
/**
|
|
||||||
* EAN-13 generator для e2e: префикс "20" (внутренний магазинный, как и
|
|
||||||
* в src/food-market.web/src/lib/barcode.ts), 10 цифр payload + checksum.
|
|
||||||
* Использует индекс шага вместо рандома, чтобы прогон был детерминированным
|
|
||||||
* и легче отлавливать дубликаты в отчёте.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function ean13Checksum(twelve: string): number {
|
|
||||||
let sum = 0
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const d = twelve.charCodeAt(i) - 48
|
|
||||||
sum += i % 2 === 0 ? d : d * 3
|
|
||||||
}
|
|
||||||
return (10 - (sum % 10)) % 10
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сгенерировать валидный EAN-13. Префикс "20" — резерв для внутренних
|
|
||||||
* штрихкодов магазина (in-store). Тело: timestamp.slice(-7) + index в
|
|
||||||
* 3-значной форме, итого 10 цифр payload. Получаем 13-значный код с
|
|
||||||
* контрольной суммой.
|
|
||||||
*/
|
|
||||||
export function generateEan13(index: number): string {
|
|
||||||
const ts = Date.now().toString().slice(-7)
|
|
||||||
const idx = String(index).padStart(3, '0')
|
|
||||||
const body = '20' + ts + idx
|
|
||||||
return body + ean13Checksum(body).toString()
|
|
||||||
}
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
# E2E report: full-cycle
|
|
||||||
|
|
||||||
Запущен: 2026-05-08T05:33:45.050Z
|
|
||||||
Длительность: 9.7с
|
|
||||||
|
|
||||||
**Итог:** 10 ✓ / 2 ✗ / 0 ⚠ / 0 ◯ (всего 12)
|
|
||||||
|
|
||||||
## ✓ Step step01_create_organization: SuperAdmin создаёт «Test Shop {timestamp}» (KZ, KZT, ФЛК телефона)
|
|
||||||
|
|
||||||
Длительность: 1316мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | POST /api/super-admin/organizations → 200 | ✓ org=Test Shop 1778218425049 |
|
|
||||||
| api | GET /api/super-admin/organizations включает созданную org | ✓ |
|
|
||||||
| api | Невалидный phone отвергается | ✓ 400 |
|
|
||||||
|
|
||||||
## ✓ Step step02_create_first_admin: SuperAdmin создаёт первого Admin сотрудника организации (Employee + AppUser)
|
|
||||||
|
|
||||||
Длительность: 619мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | Temp password возвращён CreateOrgResult | ✓ len=12 |
|
|
||||||
| db | employees содержит ровно 1 запись для новой org | ✓ count=1 |
|
|
||||||
| db | AspNetUserRoles содержит role=Admin для нового user | ✓ Admin |
|
|
||||||
|
|
||||||
## ✓ Step step03_login_as_admin: Логин под admin (не SuperAdmin override) — JWT с org_id и role=Admin
|
|
||||||
|
|
||||||
Длительность: 540мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | /connect/token password-grant выдал токен | ✓ |
|
|
||||||
| api | /api/me содержит role=Admin | ✓ Admin |
|
|
||||||
| api | /api/me содержит правильный orgId | ✓ 13ed4954-afeb-47fe-8d4a-7070758c7158 |
|
|
||||||
|
|
||||||
## ✓ Step step04_create_storekeeper_and_cashier: Admin создаёт Storekeeper и Cashier через /settings/employees
|
|
||||||
|
|
||||||
Длительность: 1318мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | employee-roles list | ✓ 200, total=3 |
|
|
||||||
| api | Системная роль «Кладовщик» существует | ✓ |
|
|
||||||
| api | Системная роль «Кассир» существует | ✓ |
|
|
||||||
| api | POST /api/organization/employees (Кладовщик) | ✓ 200 |
|
|
||||||
| api | POST /api/organization/employees (Кассир) | ✓ 200 |
|
|
||||||
| db | employees total = 3 (admin + keeper + cashier) | ✓ count=3 |
|
|
||||||
| api | Невалидный email отвергается при createAccount | ✓ 400 |
|
|
||||||
|
|
||||||
## ✓ Step step05_login_as_cashier: Логин под Cashier — role-guard проверяется (sidebar/role guard)
|
|
||||||
|
|
||||||
Длительность: 671мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | /api/me содержит роль соответствующую системной Cashier | ✓ Cashier |
|
|
||||||
| api | Cashier → GET /api/organization/employees → 403 | ✓ 403 |
|
|
||||||
| api | Cashier → GET /api/sales/retail — доступен | ✓ 200 |
|
|
||||||
|
|
||||||
## ✓ Step step06_create_counterparty: Admin создаёт «ТОО Тест Поставщик» (БИН + телефон)
|
|
||||||
|
|
||||||
Длительность: 182мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | POST /api/catalog/counterparties | ✓ 201 |
|
|
||||||
|
|
||||||
## ✓ Step step07_ensure_main_store: Проверить что есть main store (из bootstrap), иначе создать
|
|
||||||
|
|
||||||
Длительность: 84мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | GET /api/catalog/stores | ✓ 200 |
|
|
||||||
| db | Main store существует (от bootstrap) | ✓ Основной склад |
|
|
||||||
|
|
||||||
## ✗ Step step08_create_supply: Admin создаёт Supply Draft (3-5 товаров) и проводит (Posted)
|
|
||||||
|
|
||||||
Длительность: 2005мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | Создано 3 product (валидный barcode + price + group) | ✓ e2e Product 1 1778218425049, e2e Product 2 1778218425049, e2e Product 3 1778218425049 |
|
|
||||||
| api | Supply без supplierId → 400/409 | ✗ 500 |
|
|
||||||
| api | Supply с пустым lines[] → 400 | ✓ 400 |
|
|
||||||
| api | POST /api/purchases/supplies (Draft) | ✓ 201 |
|
|
||||||
| api | POST /api/purchases/supplies/{id}/post (Draft → Posted) | ✓ 204 |
|
|
||||||
| api | Повторный post Supply → 409 (idempotency) | ✓ 409 {"error":"Документ уже проведён."} |
|
|
||||||
| db | stock_movements содержат запись на каждую строку Supply | ✓ count=3, expected=3 |
|
|
||||||
|
|
||||||
## ✓ Step step09_check_stock_after_supply: GET /api/inventory/stock — quantity увеличился на supplied amount
|
|
||||||
|
|
||||||
Длительность: 878мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | stock(e2e Product 1 1778218425049) +10 (было 0, стало 10) | ✓ delta=10, expected=10 |
|
|
||||||
| api | stock(e2e Product 2 1778218425049) +15 (было 0, стало 15) | ✓ delta=15, expected=15 |
|
|
||||||
| api | stock(e2e Product 3 1778218425049) +20 (было 0, стало 20) | ✓ delta=20, expected=20 |
|
|
||||||
| api | GET /api/inventory/stock без storeId возвращает строки на каждый склад | ✓ rows=1, stores=07d23bb1 |
|
|
||||||
| db | stocks.Quantity == SUM(stock_movements.Quantity) для e2e Product 1 1778218425049… | ✓ sum_movements=10 stocks.Quantity=10 |
|
|
||||||
| db | stocks.Quantity == SUM(stock_movements.Quantity) для e2e Product 2 1778218425049… | ✓ sum_movements=15 stocks.Quantity=15 |
|
|
||||||
| db | stocks.Quantity == SUM(stock_movements.Quantity) для e2e Product 3 1778218425049… | ✓ sum_movements=20 stocks.Quantity=20 |
|
|
||||||
|
|
||||||
## ✓ Step step10_ensure_retail_point: Проверить или создать розничную точку (кассу)
|
|
||||||
|
|
||||||
Длительность: 168мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | RetailPoint существует | ✓ Касса 1 |
|
|
||||||
| api | RetailPoint с несуществующим storeId → 400/404 | ✓ 400 |
|
|
||||||
|
|
||||||
## ✗ Step step11_create_retail_sale: Admin создаёт RetailSale, 2 позиции из приёмки, cash, Post
|
|
||||||
|
|
||||||
Длительность: 1035мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | Продажа qty>остатка → /post должен 4xx | ✗ 204 |
|
|
||||||
| api | Продажа с отрицательным qty/price → 400 | ✓ 400 |
|
|
||||||
| api | discount=10 на line(price=100,qty=1) → lineTotal=90 | ✓ lineTotal=90 |
|
|
||||||
| api | POST /api/sales/retail (Draft) | ✓ 201 |
|
|
||||||
| api | POST /retail/{id}/post | ✓ 204 |
|
|
||||||
| api | Повторный post RetailSale → 409 | ✓ 409 |
|
|
||||||
|
|
||||||
## ✓ Step step12_check_stock_after_sale: GET /api/inventory/stock — quantity уменьшился на sold amount
|
|
||||||
|
|
||||||
Длительность: 889мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | stock product=f94d281a… −2 (было 10, стало 8) | ✓ delta=2, expected=2 |
|
|
||||||
| api | stock product=6e22649a… −2 (было 15, стало 13) | ✓ delta=2, expected=2 |
|
|
||||||
| db | stock_movements запись на sale-line f94d281a… | ✓ count=1, sum=-2 (expected sum=-2) |
|
|
||||||
| db | stock_movements запись на sale-line 6e22649a… | ✓ count=1, sum=-2 (expected sum=-2) |
|
|
||||||
| db | stock_movements.Type = RetailSale (2) для sale документа | ✓ types=2 |
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Passed: 10
|
|
||||||
- Failed: 2
|
|
||||||
- Warnings: 0
|
|
||||||
- Skipped: 0
|
|
||||||
|
|
||||||
## Critical bugs
|
|
||||||
|
|
||||||
### HIGH
|
|
||||||
|
|
||||||
- **[11] Розничная продажа провёл количество больше остатка**
|
|
||||||
- qty=99999 при остатке 10-15 — /post вернул 204 вместо 409/400.
|
|
||||||
- Fix: RetailSalesController.Post должен валидировать sum(line.qty) ≤ stocks.Available.
|
|
||||||
|
|
||||||
### MEDIUM
|
|
||||||
|
|
||||||
- **[08] Supply без supplierId → 500 вместо 400**
|
|
||||||
- Сервер бросил исключение (500) на отсутствующее required-поле supplierId. UI получит generic error вместо понятного field-level сообщения.
|
|
||||||
- Fix: SupplyInput.SupplierId — [Required] / model validation; ловить FK-violation перед SaveChanges и возвращать 400 с описанием.
|
|
||||||
|
|
||||||
## Logic gaps
|
|
||||||
|
|
||||||
- Bootstrap новой org не создаёт дефолтную ProductGroup, поэтому ProductsController.Create падает с 400 «ProductGroupId required» пока юзер вручную не заведёт группу.
|
|
||||||
- RetailSale Draft создаётся даже с пустым lines[]; ошибка появится только на /post — UX мог бы блокировать раньше.
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
|
||||||
import type { CheckResult, Step, Report } from '../lib/report.js'
|
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||||
import { Report as _R } from '../lib/report.js' // type-only ниже
|
import { Report as _R } from '../lib/report.js' // type-only ниже
|
||||||
import { countRows, psql } from '../lib/db.js'
|
import { countRows, psql } from '../lib/db.js'
|
||||||
import { generateEan13 } from '../lib/barcode.js'
|
|
||||||
|
|
||||||
type Ctx = {
|
type Ctx = {
|
||||||
apiOnly: boolean
|
apiOnly: boolean
|
||||||
|
|
@ -27,13 +26,10 @@ type Ctx = {
|
||||||
counterpartyId?: string
|
counterpartyId?: string
|
||||||
storeId?: string
|
storeId?: string
|
||||||
retailPointId?: string
|
retailPointId?: string
|
||||||
productGroupId?: string
|
|
||||||
retailPriceTypeId?: string
|
|
||||||
currencyId?: string
|
|
||||||
supplyId?: string
|
supplyId?: string
|
||||||
supplyLines?: { productId: string; productName: string; quantity: number; price: number }[]
|
supplyLines?: { productId: string; productName: string; quantity: number; price: number }[]
|
||||||
retailSaleId?: string
|
retailSaleId?: string
|
||||||
saleLines?: { productId: string; quantity: number; unitPrice: number }[]
|
saleLines?: { productId: string; quantity: number }[]
|
||||||
stockBefore?: Record<string, number>
|
stockBefore?: Record<string, number>
|
||||||
stockAfterSupply?: Record<string, number>
|
stockAfterSupply?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
@ -358,9 +354,9 @@ export async function step05_login_as_cashier({ ctx, step, report }: StepCtx) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const salesRes = await apiCashier.get('/api/sales/retail?pageSize=10')
|
const salesRes = await apiCashier.get('/api/sales/retail-sales?pageSize=10')
|
||||||
check(step, {
|
check(step, {
|
||||||
kind: 'api', description: 'Cashier → GET /api/sales/retail — доступен',
|
kind: 'api', description: 'Cashier → GET /api/sales/retail-sales — доступен',
|
||||||
ok: salesRes.status === 200 || salesRes.status === 404,
|
ok: salesRes.status === 200 || salesRes.status === 404,
|
||||||
detail: `${salesRes.status}`,
|
detail: `${salesRes.status}`,
|
||||||
})
|
})
|
||||||
|
|
@ -453,87 +449,37 @@ export async function step08_create_supply({ ctx, step, report }: StepCtx) {
|
||||||
if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return }
|
if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return }
|
||||||
const api = makeClient(ctx.adminToken)
|
const api = makeClient(ctx.adminToken)
|
||||||
|
|
||||||
// 1) Подготовим справочники: unit, product group, retail price type, currency.
|
// Берём 3 произвольных products. Реестр products в БД tenant-scoped
|
||||||
|
// (ITenantEntity), поэтому после создания новой org `GET /api/catalog/
|
||||||
|
// products` возвращает 0 — старые products принадлежат другому tenant'у.
|
||||||
|
// Это logic-gap описанный в отчёте; для прогона сценария создаём 3
|
||||||
|
// products в новой org прямо сейчас.
|
||||||
|
const products = await api.get('/api/catalog/products?pageSize=5')
|
||||||
|
let items = (products.data?.items ?? []) as { id: string; name: string; unitId?: string }[]
|
||||||
|
if (items.length < 3) {
|
||||||
|
report.gap('Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API.')
|
||||||
|
// Получим первую unit-of-measure (системную или из org).
|
||||||
const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=10')
|
const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=10')
|
||||||
const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined
|
const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
report.bug({
|
|
||||||
step: '08', severity: 'critical',
|
|
||||||
title: 'Для нового tenant нет ни одной единицы измерения',
|
|
||||||
detail: 'GET /api/catalog/units-of-measure?pageSize=10 → пустой items',
|
|
||||||
fix: 'Phase5c должен auto-enable все active globals в org_units_of_measure при создании org.',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupsRes = await api.get('/api/catalog/product-groups?pageSize=200')
|
|
||||||
let groupId = (groupsRes.data?.items ?? [])[0]?.id as string | undefined
|
|
||||||
if (!groupId) {
|
|
||||||
const cg = await api.post('/api/catalog/product-groups', {
|
|
||||||
name: 'e2e Группа', parentId: null, sortOrder: 0,
|
|
||||||
})
|
|
||||||
if (cg.status >= 400) {
|
|
||||||
report.bug({
|
report.bug({
|
||||||
step: '08', severity: 'high',
|
step: '08', severity: 'high',
|
||||||
title: 'Не удалось создать ProductGroup для нового tenant',
|
title: 'Нет ни одной единицы измерения для нового tenant',
|
||||||
detail: `${cg.status} ${asString(cg.data).slice(0, 200)}`,
|
detail: 'Bootstrap должен сидить системные units (шт, кг, л) при создании org.',
|
||||||
fix: 'Bootstrap должен сидить дефолтную группу «Продукты питания» при создании org.',
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupId = cg.data?.id
|
const created: { id: string; name: string }[] = []
|
||||||
report.gap('Bootstrap новой org не создаёт дефолтную ProductGroup, поэтому ProductsController.Create падает с 400 «ProductGroupId required» пока юзер вручную не заведёт группу.')
|
|
||||||
}
|
|
||||||
ctx.productGroupId = groupId!
|
|
||||||
|
|
||||||
const ptRes = await api.get('/api/catalog/price-types?pageSize=200')
|
|
||||||
const retailPt = ((ptRes.data?.items ?? []) as { id: string; isRetail?: boolean; name: string }[])
|
|
||||||
.find((p) => p.isRetail) ?? (ptRes.data?.items ?? [])[0]
|
|
||||||
if (!retailPt) {
|
|
||||||
report.bug({
|
|
||||||
step: '08', severity: 'critical',
|
|
||||||
title: 'Нет ни одного PriceType для нового tenant',
|
|
||||||
detail: '',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.retailPriceTypeId = retailPt.id
|
|
||||||
|
|
||||||
const curRes = await api.get('/api/catalog/currencies?pageSize=50')
|
|
||||||
const kzt = ((curRes.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZT')
|
|
||||||
if (!kzt) {
|
|
||||||
report.bug({ step: '08', severity: 'high', title: 'Нет валюты KZT в справочнике', detail: '' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.currencyId = kzt.id
|
|
||||||
|
|
||||||
// 2) Создаём 3 product'а с валидным ProductInput.
|
|
||||||
const created: { id: string; name: string; price: number }[] = []
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const retailPrice = 100 + i * 50
|
|
||||||
const cr = await api.post('/api/catalog/products', {
|
const cr = await api.post('/api/catalog/products', {
|
||||||
name: `e2e Product ${i + 1} ${TIMESTAMP}`,
|
name: `e2e Product ${i + 1} ${TIMESTAMP}`,
|
||||||
article: `E2E-${TIMESTAMP}-${i + 1}`,
|
article: `E2E-${TIMESTAMP}-${i + 1}`,
|
||||||
description: null,
|
barcode: null,
|
||||||
unitOfMeasureId: unit.id,
|
unitId: unit.id,
|
||||||
vat: 12,
|
groupId: null,
|
||||||
vatEnabled: true,
|
retailPrice: 100 + i * 50,
|
||||||
productGroupId: ctx.productGroupId,
|
purchasePrice: 70 + i * 30,
|
||||||
defaultSupplierId: null,
|
isActive: true,
|
||||||
countryOfOriginId: null,
|
|
||||||
isService: false,
|
|
||||||
packaging: 0,
|
|
||||||
isMarked: false,
|
|
||||||
minStock: null, maxStock: null,
|
|
||||||
referencePrice: retailPrice * 0.7,
|
|
||||||
purchaseCurrencyId: ctx.currencyId,
|
|
||||||
imageUrl: null,
|
|
||||||
prices: [
|
|
||||||
{ priceTypeId: ctx.retailPriceTypeId, amount: retailPrice, currencyId: ctx.currencyId },
|
|
||||||
],
|
|
||||||
barcodes: [
|
|
||||||
{ code: generateEan13(i + 1), type: 0, isPrimary: true },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
if (cr.status >= 400) {
|
if (cr.status >= 400) {
|
||||||
report.bug({
|
report.bug({
|
||||||
|
|
@ -543,71 +489,35 @@ export async function step08_create_supply({ ctx, step, report }: StepCtx) {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name, price: retailPrice })
|
created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name })
|
||||||
}
|
}
|
||||||
|
items = created.map((p) => ({ id: p.id, name: p.name }))
|
||||||
check(step, {
|
check(step, {
|
||||||
kind: 'api', description: 'Создано 3 product (валидный barcode + price + group)',
|
kind: 'api', description: 'Auto-created 3 products для нового tenant',
|
||||||
ok: created.length === 3, detail: created.map((p) => p.name).join(', '),
|
ok: true, detail: items.map((i) => i.name).join(', '),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
const lines = created.map((p, i) => ({
|
const lines = items.slice(0, 3).map((p, i) => ({
|
||||||
productId: p.id, productName: p.name, quantity: 10 + i * 5, price: 70 + i * 30,
|
productId: p.id,
|
||||||
|
productName: p.name,
|
||||||
|
quantity: 10 + i * 5,
|
||||||
|
price: 100 + i * 50,
|
||||||
}))
|
}))
|
||||||
ctx.supplyLines = lines
|
ctx.supplyLines = lines
|
||||||
|
|
||||||
// Stock snapshot до приёмки (должен быть 0 на новой орге).
|
// Сохраняем stock-snapshot ДО приёмки.
|
||||||
const stockBefore: Record<string, number> = {}
|
const stockBefore: Record<string, number> = {}
|
||||||
for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
|
for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
|
||||||
ctx.stockBefore = stockBefore
|
ctx.stockBefore = stockBefore
|
||||||
|
|
||||||
// 3) Bug-hunt: попытаться создать Supply без supplierId / без lines.
|
|
||||||
const noSupplier = await api.post('/api/purchases/supplies', {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
storeId: ctx.storeId,
|
|
||||||
currencyId: ctx.currencyId,
|
|
||||||
notes: 'no supplier',
|
|
||||||
lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })),
|
|
||||||
})
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'Supply без supplierId → 400/409',
|
|
||||||
ok: noSupplier.status >= 400 && noSupplier.status < 500,
|
|
||||||
detail: `${noSupplier.status} ${asString(noSupplier.data).slice(0, 100)}`,
|
|
||||||
})
|
|
||||||
if (noSupplier.status === 200 || noSupplier.status === 201) {
|
|
||||||
report.bug({
|
|
||||||
step: '08', severity: 'high',
|
|
||||||
title: 'Supply создаётся без supplierId',
|
|
||||||
detail: 'POST /api/purchases/supplies без поля supplierId прошёл валидацию и вернул 200',
|
|
||||||
fix: 'SupplyInput.SupplierId должен быть [Required] и проверяться в Create.',
|
|
||||||
})
|
|
||||||
} else if (noSupplier.status >= 500) {
|
|
||||||
report.bug({
|
|
||||||
step: '08', severity: 'medium',
|
|
||||||
title: 'Supply без supplierId → 500 вместо 400',
|
|
||||||
detail: `Сервер бросил исключение (${noSupplier.status}) на отсутствующее required-поле supplierId. UI получит generic error вместо понятного field-level сообщения.`,
|
|
||||||
fix: 'SupplyInput.SupplierId — [Required] / model validation; ловить FK-violation перед SaveChanges и возвращать 400 с описанием.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const noLines = await api.post('/api/purchases/supplies', {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
supplierId: ctx.counterpartyId, storeId: ctx.storeId, currencyId: ctx.currencyId,
|
|
||||||
notes: 'no lines', lines: [],
|
|
||||||
})
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'Supply с пустым lines[] → 400',
|
|
||||||
ok: noLines.status >= 400 && noLines.status < 500,
|
|
||||||
detail: `${noLines.status}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4) Создаём настоящий draft Supply.
|
|
||||||
const draft = await api.post('/api/purchases/supplies', {
|
const draft = await api.post('/api/purchases/supplies', {
|
||||||
date: new Date().toISOString(),
|
counterpartyId: ctx.counterpartyId,
|
||||||
supplierId: ctx.counterpartyId,
|
|
||||||
storeId: ctx.storeId,
|
storeId: ctx.storeId,
|
||||||
currencyId: ctx.currencyId,
|
docDate: new Date().toISOString(),
|
||||||
notes: 'e2e draft',
|
description: 'e2e draft',
|
||||||
lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })),
|
lines: lines.map((l) => ({
|
||||||
|
productId: l.productId, quantity: l.quantity, price: l.price,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
check(step, {
|
check(step, {
|
||||||
kind: 'api', description: 'POST /api/purchases/supplies (Draft)',
|
kind: 'api', description: 'POST /api/purchases/supplies (Draft)',
|
||||||
|
|
@ -638,48 +548,6 @@ export async function step08_create_supply({ ctx, step, report }: StepCtx) {
|
||||||
ok: post.status === 200 || post.status === 204,
|
ok: post.status === 200 || post.status === 204,
|
||||||
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
|
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
|
||||||
})
|
})
|
||||||
if (post.status >= 400) {
|
|
||||||
report.bug({
|
|
||||||
step: '08', severity: 'critical',
|
|
||||||
title: 'Posted-переход Supply падает',
|
|
||||||
detail: asString(post.data),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Bug-hunt: повторный post → 409 (idempotency contract).
|
|
||||||
const dblPost = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {})
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'Повторный post Supply → 409 (idempotency)',
|
|
||||||
ok: dblPost.status === 409,
|
|
||||||
detail: `${dblPost.status} ${asString(dblPost.data).slice(0, 100)}`,
|
|
||||||
})
|
|
||||||
if (dblPost.status !== 409) {
|
|
||||||
report.bug({
|
|
||||||
step: '08', severity: 'medium',
|
|
||||||
title: 'Повторный post Supply возвращает не 409',
|
|
||||||
detail: `Получили ${dblPost.status}; ожидаем 409 чтобы UI мог показать «Уже проведено».`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Bug-hunt: stock_movements должны содержать одну запись на каждую строку.
|
|
||||||
const orgId = ctx.organization!.id
|
|
||||||
const movsRows = psql(`SELECT count(*) FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.supplyId}'`)
|
|
||||||
.trim()
|
|
||||||
const movs = parseInt(movsRows, 10) || 0
|
|
||||||
check(step, {
|
|
||||||
kind: 'db', description: 'stock_movements содержат запись на каждую строку Supply',
|
|
||||||
ok: movs === lines.length,
|
|
||||||
detail: `count=${movs}, expected=${lines.length}`,
|
|
||||||
})
|
|
||||||
if (movs !== lines.length) {
|
|
||||||
report.bug({
|
|
||||||
step: '08', severity: 'high',
|
|
||||||
title: 'stock_movements не записаны после Posted Supply',
|
|
||||||
detail: `expected ${lines.length}, got ${movs}`,
|
|
||||||
fix: 'Проверить SupplyController.Post: ApplyMovementAsync вызывается для каждой строки.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -712,41 +580,6 @@ export async function step09_check_stock_after_supply({ ctx, step, report }: Ste
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /stock без storeId — должен агрегировать или возвращать строку на склад.
|
|
||||||
const aggRes = await api.get(`/api/inventory/stock?productId=${ctx.supplyLines[0].productId}&pageSize=200`)
|
|
||||||
const aggItems = (aggRes.data?.items ?? []) as { storeId: string; quantity: number }[]
|
|
||||||
check(step, {
|
|
||||||
kind: 'api',
|
|
||||||
description: 'GET /api/inventory/stock без storeId возвращает строки на каждый склад',
|
|
||||||
ok: aggItems.length >= 1,
|
|
||||||
detail: `rows=${aggItems.length}, stores=${aggItems.map((i) => i.storeId.slice(0, 8)).join(',')}`,
|
|
||||||
})
|
|
||||||
const hasTotal = 'totalQuantity' in (aggRes.data ?? {})
|
|
||||||
if (!hasTotal && aggItems.length > 1) {
|
|
||||||
report.gap('Stock endpoint не агрегирует totalQuantity по нескольким складам — UI должен суммировать сам. Если в орге будет 5+ складов, это будет неудобно для дашборда «общие остатки товара».')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Согласованность Stocks.Quantity с суммой stock_movements.Quantity.
|
|
||||||
const orgId = ctx.organization!.id
|
|
||||||
for (const ln of ctx.supplyLines) {
|
|
||||||
const sumStr = psql(`SELECT COALESCE(SUM("Quantity"), 0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "ProductId"='${ln.productId}' AND "StoreId"='${ctx.storeId}'`).trim()
|
|
||||||
const sum = parseFloat(sumStr) || 0
|
|
||||||
const stockNow = after[ln.productId] ?? 0
|
|
||||||
check(step, {
|
|
||||||
kind: 'db',
|
|
||||||
description: `stocks.Quantity == SUM(stock_movements.Quantity) для ${ln.productName.slice(0, 30)}…`,
|
|
||||||
ok: Math.abs(sum - stockNow) < 0.0001,
|
|
||||||
detail: `sum_movements=${sum} stocks.Quantity=${stockNow}`,
|
|
||||||
})
|
|
||||||
if (Math.abs(sum - stockNow) >= 0.0001) {
|
|
||||||
report.bug({
|
|
||||||
step: '09', severity: 'high',
|
|
||||||
title: 'Stocks.Quantity рассинхронизированы с SUM(stock_movements.Quantity)',
|
|
||||||
detail: `product=${ln.productName} sum=${sum} stocks=${stockNow}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -755,7 +588,7 @@ export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx)
|
||||||
if (!ctx.adminToken) { step.status = 'skip'; return }
|
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||||||
const api = makeClient(ctx.adminToken)
|
const api = makeClient(ctx.adminToken)
|
||||||
const list = await api.get('/api/catalog/retail-points?pageSize=200')
|
const list = await api.get('/api/catalog/retail-points?pageSize=200')
|
||||||
const items = (list.data?.items ?? []) as { id: string; name: string; storeId?: string }[]
|
const items = (list.data?.items ?? []) as { id: string; name: string }[]
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
const create = await api.post('/api/catalog/retail-points', {
|
const create = await api.post('/api/catalog/retail-points', {
|
||||||
name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true,
|
name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true,
|
||||||
|
|
@ -775,168 +608,27 @@ export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx)
|
||||||
title: 'Не получилось гарантировать наличие розничной точки',
|
title: 'Не получилось гарантировать наличие розничной точки',
|
||||||
detail: '',
|
detail: '',
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bug-hunt: касса должна быть привязана к существующему складу.
|
|
||||||
const fakeStore = '00000000-0000-0000-0000-000000000001'
|
|
||||||
const badStore = await api.post('/api/catalog/retail-points', {
|
|
||||||
name: `e2e-bad-${TIMESTAMP}`, code: `BS-${TIMESTAMP}`, storeId: fakeStore, isActive: true,
|
|
||||||
})
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'RetailPoint с несуществующим storeId → 400/404',
|
|
||||||
ok: badStore.status >= 400 && badStore.status < 500,
|
|
||||||
detail: `${badStore.status}`,
|
|
||||||
})
|
|
||||||
if (badStore.status === 200 || badStore.status === 201) {
|
|
||||||
report.bug({
|
|
||||||
step: '10', severity: 'medium',
|
|
||||||
title: 'RetailPoint создаётся с несуществующим storeId',
|
|
||||||
detail: 'POST /api/catalog/retail-points c storeId=00000000-0000-0000-0000-000000000001 прошёл валидацию.',
|
|
||||||
fix: 'Добавить проверку EXISTS Store в RetailPointsController.Create.',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) {
|
export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) {
|
||||||
if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines || !ctx.storeId || !ctx.currencyId) {
|
if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines) { step.status = 'skip'; return }
|
||||||
step.status = 'skip'; return
|
|
||||||
}
|
|
||||||
const api = makeClient(ctx.adminToken)
|
const api = makeClient(ctx.adminToken)
|
||||||
// Берём 2 первых product, продаём по 2 шт каждого. Цена = supply.price*2 (наценка).
|
|
||||||
const lines = ctx.supplyLines.slice(0, 2).map((l) => ({
|
const lines = ctx.supplyLines.slice(0, 2).map((l) => ({
|
||||||
productId: l.productId,
|
productId: l.productId, quantity: 2, price: l.price * 2, // продаём по 2 шт.
|
||||||
quantity: 2,
|
|
||||||
unitPrice: l.price * 2,
|
|
||||||
discount: 0,
|
|
||||||
vatPercent: 12,
|
|
||||||
}))
|
}))
|
||||||
ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice }))
|
ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity }))
|
||||||
|
|
||||||
// Bug-hunt: пустой чек → 400 на /post (после Draft); тут проверяем create с пустыми lines.
|
const draft = await api.post('/api/sales/retail-sales', {
|
||||||
const empty = await api.post('/api/sales/retail', {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
storeId: ctx.storeId,
|
|
||||||
retailPointId: ctx.retailPointId,
|
retailPointId: ctx.retailPointId,
|
||||||
customerId: null,
|
docDate: new Date().toISOString(),
|
||||||
currencyId: ctx.currencyId,
|
description: 'e2e sale',
|
||||||
payment: 0, paidCash: 0, paidCard: 0,
|
|
||||||
notes: 'empty', lines: [],
|
|
||||||
})
|
|
||||||
if (empty.status === 200 || empty.status === 201) {
|
|
||||||
report.gap('RetailSale Draft создаётся даже с пустым lines[]; ошибка появится только на /post — UX мог бы блокировать раньше.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bug-hunt: продажа qty больше остатка должна давать предупреждение или ошибку.
|
|
||||||
const overSell = await api.post('/api/sales/retail', {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
storeId: ctx.storeId,
|
|
||||||
retailPointId: ctx.retailPointId,
|
|
||||||
customerId: null,
|
|
||||||
currencyId: ctx.currencyId,
|
|
||||||
payment: 0, paidCash: 999999, paidCard: 0,
|
|
||||||
notes: 'over-sell',
|
|
||||||
lines: [{ productId: ctx.supplyLines[0].productId, quantity: 99999, unitPrice: 100, discount: 0, vatPercent: 0 }],
|
|
||||||
})
|
|
||||||
let overSellId: string | undefined
|
|
||||||
if (overSell.status === 200 || overSell.status === 201) {
|
|
||||||
overSellId = overSell.data?.id ?? overSell.data?.saleId
|
|
||||||
const overPost = await api.post(`/api/sales/retail/${overSellId}/post`, {})
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'Продажа qty>остатка → /post должен 4xx',
|
|
||||||
ok: overPost.status >= 400 && overPost.status < 500,
|
|
||||||
detail: `${overPost.status}`,
|
|
||||||
})
|
|
||||||
if (overPost.status === 200 || overPost.status === 204) {
|
|
||||||
report.bug({
|
|
||||||
step: '11', severity: 'high',
|
|
||||||
title: 'Розничная продажа провёл количество больше остатка',
|
|
||||||
detail: `qty=99999 при остатке 10-15 — /post вернул ${overPost.status} вместо 409/400.`,
|
|
||||||
fix: 'RetailSalesController.Post должен валидировать sum(line.qty) ≤ stocks.Available.',
|
|
||||||
})
|
|
||||||
// Откатим, чтобы не сбить остатки для step12.
|
|
||||||
await api.post(`/api/sales/retail/${overSellId}/unpost`, {})
|
|
||||||
}
|
|
||||||
// Удалим draft чтобы не мусорил.
|
|
||||||
await api.delete(`/api/sales/retail/${overSellId}`)
|
|
||||||
} else {
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'POST /api/sales/retail с qty>>остатка отклонено',
|
|
||||||
ok: overSell.status >= 400 && overSell.status < 500,
|
|
||||||
detail: `${overSell.status}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bug-hunt: отрицательная цена/количество должны быть отклонены.
|
|
||||||
const negative = await api.post('/api/sales/retail', {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
storeId: ctx.storeId,
|
|
||||||
retailPointId: ctx.retailPointId,
|
|
||||||
customerId: null,
|
|
||||||
currencyId: ctx.currencyId,
|
|
||||||
payment: 0, paidCash: 0, paidCard: 0,
|
|
||||||
notes: 'negative',
|
|
||||||
lines: [{ productId: ctx.supplyLines[0].productId, quantity: -1, unitPrice: -100, discount: 0, vatPercent: 0 }],
|
|
||||||
})
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'Продажа с отрицательным qty/price → 400',
|
|
||||||
ok: negative.status === 400,
|
|
||||||
detail: `${negative.status}`,
|
|
||||||
})
|
|
||||||
if (negative.status === 200 || negative.status === 201) {
|
|
||||||
report.bug({
|
|
||||||
step: '11', severity: 'high',
|
|
||||||
title: 'RetailSale принимает отрицательные quantity / unitPrice',
|
|
||||||
detail: 'POST /api/sales/retail с qty=-1, unitPrice=-100 → 200. [Range(0,..)] не валидирует отрицательное?',
|
|
||||||
fix: 'Проверить RetailSaleLineInput атрибуты Range и [ApiController] валидацию.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bug-hunt: продажа с discount на позиции (lineTotal должен учитывать).
|
|
||||||
const withDiscount = await api.post('/api/sales/retail', {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
storeId: ctx.storeId,
|
|
||||||
retailPointId: ctx.retailPointId,
|
|
||||||
customerId: null,
|
|
||||||
currencyId: ctx.currencyId,
|
|
||||||
payment: 0, paidCash: 90, paidCard: 0,
|
|
||||||
notes: 'discount-test',
|
|
||||||
lines: [{ productId: ctx.supplyLines[0].productId, quantity: 1, unitPrice: 100, discount: 10, vatPercent: 0 }],
|
|
||||||
})
|
|
||||||
if (withDiscount.status === 200 || withDiscount.status === 201) {
|
|
||||||
const lt = withDiscount.data?.lines?.[0]?.lineTotal as number | undefined
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'discount=10 на line(price=100,qty=1) → lineTotal=90',
|
|
||||||
ok: lt === 90,
|
|
||||||
detail: `lineTotal=${lt}`,
|
|
||||||
})
|
|
||||||
if (lt !== 90) {
|
|
||||||
report.bug({
|
|
||||||
step: '11', severity: 'medium',
|
|
||||||
title: 'Discount на позиции не применяется к lineTotal',
|
|
||||||
detail: `Передали unitPrice=100, qty=1, discount=10; ожидаем lineTotal=90 — получили ${lt}.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (withDiscount.data?.id) await api.delete(`/api/sales/retail/${withDiscount.data.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Реальная продажа (positive).
|
|
||||||
const draft = await api.post('/api/sales/retail', {
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
storeId: ctx.storeId,
|
|
||||||
retailPointId: ctx.retailPointId,
|
|
||||||
customerId: null,
|
|
||||||
currencyId: ctx.currencyId,
|
|
||||||
payment: 0,
|
|
||||||
paidCash: lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
|
|
||||||
paidCard: 0,
|
|
||||||
notes: 'e2e sale',
|
|
||||||
lines,
|
lines,
|
||||||
})
|
})
|
||||||
check(step, {
|
check(step, {
|
||||||
kind: 'api', description: 'POST /api/sales/retail (Draft)',
|
kind: 'api', description: 'POST /api/sales/retail-sales (Draft)',
|
||||||
ok: draft.status === 200 || draft.status === 201,
|
ok: draft.status === 200 || draft.status === 201,
|
||||||
detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
|
detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
|
||||||
})
|
})
|
||||||
|
|
@ -952,36 +644,25 @@ export async function step11_create_retail_sale({ ctx, step, report }: StepCtx)
|
||||||
if (!ctx.retailSaleId) {
|
if (!ctx.retailSaleId) {
|
||||||
report.bug({
|
report.bug({
|
||||||
step: '11', severity: 'high',
|
step: '11', severity: 'high',
|
||||||
title: 'POST /retail не возвращает id',
|
title: 'POST /retail-sales не возвращает id',
|
||||||
detail: asString(draft.data).slice(0, 200),
|
detail: asString(draft.data).slice(0, 200),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {})
|
const post = await api.post(`/api/sales/retail-sales/${ctx.retailSaleId}/post`, {})
|
||||||
check(step, {
|
check(step, {
|
||||||
kind: 'api', description: 'POST /retail/{id}/post',
|
kind: 'api', description: 'POST /retail-sales/{id}/post',
|
||||||
ok: post.status === 200 || post.status === 204,
|
ok: post.status === 200 || post.status === 204,
|
||||||
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
|
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bug-hunt: повторный post проведённого → 409.
|
|
||||||
const dblPost = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {})
|
|
||||||
check(step, {
|
|
||||||
kind: 'api', description: 'Повторный post RetailSale → 409',
|
|
||||||
ok: dblPost.status === 409,
|
|
||||||
detail: `${dblPost.status}`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) {
|
export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) {
|
||||||
if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply || !ctx.retailSaleId) {
|
if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply) { step.status = 'skip'; return }
|
||||||
step.status = 'skip'; return
|
|
||||||
}
|
|
||||||
const api = makeClient(ctx.adminToken)
|
const api = makeClient(ctx.adminToken)
|
||||||
const orgId = ctx.organization!.id
|
|
||||||
|
|
||||||
for (const ln of ctx.saleLines) {
|
for (const ln of ctx.saleLines) {
|
||||||
const before = ctx.stockAfterSupply[ln.productId] ?? 0
|
const before = ctx.stockAfterSupply[ln.productId] ?? 0
|
||||||
|
|
@ -1001,38 +682,6 @@ export async function step12_check_stock_after_sale({ ctx, step, report }: StepC
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stock_movements: записи на каждую sale-строку с типом RetailSale и qty<0.
|
|
||||||
for (const ln of ctx.saleLines) {
|
|
||||||
const row = psql(
|
|
||||||
`SELECT count(*), COALESCE(SUM("Quantity"),0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}' AND "ProductId"='${ln.productId}'`
|
|
||||||
).trim().split('|')
|
|
||||||
const cnt = parseInt(row[0], 10) || 0
|
|
||||||
const sumQty = parseFloat(row[1]) || 0
|
|
||||||
check(step, {
|
|
||||||
kind: 'db',
|
|
||||||
description: `stock_movements запись на sale-line ${ln.productId.slice(0, 8)}…`,
|
|
||||||
ok: cnt === 1 && Math.abs(sumQty + ln.quantity) < 0.0001,
|
|
||||||
detail: `count=${cnt}, sum=${sumQty} (expected sum=${-ln.quantity})`,
|
|
||||||
})
|
|
||||||
if (cnt !== 1 || Math.abs(sumQty + ln.quantity) >= 0.0001) {
|
|
||||||
report.bug({
|
|
||||||
step: '12', severity: 'high',
|
|
||||||
title: 'stock_movements не записаны после Posted RetailSale',
|
|
||||||
detail: `productId=${ln.productId} count=${cnt} sumQty=${sumQty} (ожидаем 1 запись с qty=${-ln.quantity})`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тип движения должен быть RetailSale (enum=2 в Domain.Inventory.MovementType).
|
|
||||||
const typesRaw = psql(
|
|
||||||
`SELECT DISTINCT "Type"::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}'`
|
|
||||||
).trim()
|
|
||||||
check(step, {
|
|
||||||
kind: 'db', description: 'stock_movements.Type = RetailSale (2) для sale документа',
|
|
||||||
ok: typesRaw === '2' || typesRaw.toLowerCase() === 'retailsale',
|
|
||||||
detail: `types=${typesRaw}`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue