food-market/src/food-market.api/Controllers/Purchases/InvoicesController.cs
nns e45600df4b
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(invoices+catalog): порт улучшений 06.10.2026 из sklad
Основное:

* Program.cs: SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=true
  + InvalidModelStateResponseFactory → ValidationProblemDetails + Serilog Warning.
  Пустые необязательные строковые поля в InvoiceLine (Note, BarcodeSource и т.п.)
  больше не роняют запрос в 400. humanizeError на фронте уже умеет этот формат.
* InvoiceModels.cs: Name/Source/FilePath → string?.
* Validators.cs: InvoiceSaveLinesValidator (Lines NotNull), InvoiceApplyValidator
  (Guid.Empty на Store/Currency = ошибка, SupplierId опционален).

Служебный «Поставщик не указан» (адаптация sklad §11 под multi-tenant):

* Phase32b миграция добавляет Counterparty.IsPlaceholder bool + partial index
  по IsPlaceholder=true.
* InvoicesController.Apply: supplierId опционален; если не указан —
  ResolvePlaceholderCounterpartyAsync ищет/создаёт одного служебного per org.
  Имя из накладной в комментарий документа НЕ пишется.
* CounterpartiesController: служебный всегда последним в List сортировках,
  Update и Delete при IsPlaceholder → 409 Conflict.
* CounterpartyDto.IsPlaceholder + Mapster-config обновлены.
* Автоподбора/автозаведения контрагентов нигде не было — специально проверили.

Applied-накладная readOnly (§10):

* InvoiceDetailDto: добавлены supplyNumber + processedAt.
* InvoiceReviewPage: applied-режим — плашка «Черновик приёмки № … создан …»
  со ссылкой на приёмку, шапка и строки readOnly, скан-блок и apply-кнопка
  скрыты. После успешного apply — invalidateQueries, экран сразу readOnly.

Текст ошибки apply (§5) и список накладных (§6):

* ApplyDialog: error → detail → title → (код), __silent на axios.
* Invoices list: parseServerDate подшивает «Z» если его нет, «Загружена DD.MM.YY HH:MM»
  в бейдже статуса (видимо в десктоп-таблице и в мобильной карточке — у нас общая).

Тесты + проверки:

* tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs — 5 кейсов:
  SaveLines со всеми null, apply без поставщика с idempotency по placeholder'у,
  Delete/Update placeholder → 409, applied detail содержит processedAt +
  hasFiles=false, apply с Guid.Empty storeId → ValidationProblemDetails.
  Все 5 ✓.
* stage retest на https://test.admin.food-market.kz: upload+SaveLines(null-fields)+
  apply(без поставщика)+apply(с ИП Бегимбаев)+DELETE placeholder → весь flow
  зелёный, служебный один на орг, hasFiles=false после apply.
* quality-watchdog step 9 (menu-walk): 3/3 passed (3.7m).
* Фото-накладная через Claude CLI не гонял — пайплайн apply→DeleteStored
  закрыт через hasFiles=false в тестах и реальный stage smoke.

docs/supply-inventory.md — шаг 7 расписан под необязательного поставщика,
новый шаг 8 (applied → readOnly), миграция Phase32b в таблице «Файлы».

docs/sklad-0610-progress.md — финал 9/9 с подробным журналом решений.

Пред-существующие (не трогал):

* OrgAuditLogTests.Counterparty_update_diff_contains_before_after → 403 от
  [RequiresAdvancedMode] на /api/admin/audit-log (commit 921bca0, s29);
* ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return —
  регекс-тюнинг доков.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-07 19:50:03 +05:00

478 lines
25 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 System.Text.Json;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Infrastructure.Invoices;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
/// <summary>Приёмка по фото/PDF накладной.
///
/// Жизненный цикл загрузки:
/// <list type="number">
/// <item><c>POST /upload</c> — файл(ы) сохраняются, запись
/// <see cref="InvoiceUpload"/> в статусе <c>pending</c>.</item>
/// <item><see cref="InvoiceWorker"/> в фоне: <c>pending → parsing →
/// needs_review | failed</c>.</item>
/// <item><c>GET /{id}</c> возвращает распознанные строки + сопоставление.</item>
/// <item><c>PUT /{id}/lines</c> — кладовщик правит строки + ручные
/// сопоставления; каждое ручное сопоставление пишется в
/// <see cref="InvoiceAlias"/> — на следующих накладных того же
/// поставщика matcher уже знает ответ.</item>
/// <item><c>POST /{id}/apply</c> — создаёт Supply (Draft) с указанными
/// поставщиком/складом/валютой. Status → <c>applied</c>, <c>SupplyId</c>
/// ставится.</item>
/// </list>
///
/// Доступ: Admin + Storekeeper (SuppliesEdit permission).</summary>
[ApiController]
[Authorize]
[Route("api/purchases/invoices")]
public class InvoicesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IWebHostEnvironment _env;
private readonly ITenantContext _tenant;
private readonly ILogger<InvoicesController> _log;
public InvoicesController(AppDbContext db, IWebHostEnvironment env,
ITenantContext tenant, ILogger<InvoicesController> log)
{
_db = db; _env = env; _tenant = tenant; _log = log;
}
private string StorageDir => Path.Combine(_env.ContentRootPath, "uploads", "invoices",
DateTime.UtcNow.ToString("yyyy-MM"));
// ── upload ──────────────────────────────────────────────────────────
public record UploadResult(List<Guid> Ids);
[HttpPost("upload"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
public async Task<ActionResult<UploadResult>> Upload([FromForm] IFormFileCollection files, CancellationToken ct)
{
if (files is null || files.Count == 0)
return BadRequest(new { error = "Файлы не выбраны." });
Directory.CreateDirectory(StorageDir);
var created = new List<Guid>();
foreach (var f in files)
{
if (f.Length == 0) continue;
var ext = Path.GetExtension(f.FileName);
var path = Path.Combine(StorageDir, $"{Guid.NewGuid():N}{ext}");
await using (var fs = System.IO.File.Create(path)) await f.CopyToAsync(fs, ct);
var up = new InvoiceUpload
{
FileName = Path.GetFileName(f.FileName),
StoredPath = path,
ContentType = f.ContentType,
SizeBytes = f.Length,
UploadedByUserId = _tenant.UserId,
Status = "pending",
};
_db.InvoiceUploads.Add(up);
await _db.SaveChangesAsync(ct);
created.Add(up.Id);
}
return Ok(new UploadResult(created));
}
public record UploadPhotosResult(Guid Id);
/// <summary>Фото-приёмка: пачка снимков накладной + товаров одной загрузкой.
/// Штрихкоды с каждого снимка телефон уже прочитал ZXing-ом; <paramref name="codes"/>
/// — JSON-массив массивов (по порядку файлов), служит подсказкой модели.
/// Папка со снимками хранится до создания черновика приёмки / удаления
/// загрузки / 24 ч (брошенная на проверке) — см. <see cref="InvoiceWorker"/>.</summary>
[HttpPost("upload-photos"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
public async Task<ActionResult<UploadPhotosResult>> UploadPhotos(
[FromForm] IFormFileCollection files, [FromForm] string? codes, CancellationToken ct)
{
if (files is null || files.Count == 0)
return BadRequest(new { error = "Нет снимков." });
if (files.Count > 80)
return BadRequest(new { error = "Слишком много снимков за раз (больше 80)." });
var dir = Path.Combine(StorageDir, $"photos-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
long size = 0; int n = 0;
foreach (var f in files)
{
if (f.Length == 0) continue;
n++;
await using var fs = System.IO.File.Create(Path.Combine(dir, $"{n:D2}.jpg"));
await f.CopyToAsync(fs, ct);
size += f.Length;
}
if (!string.IsNullOrWhiteSpace(codes))
await System.IO.File.WriteAllTextAsync(Path.Combine(dir, "codes.json"), codes, ct);
var up = new InvoiceUpload
{
FileName = $"фото-приёмка: {n} снимков",
StoredPath = dir,
ContentType = "photos",
Source = "photos",
SizeBytes = size,
UploadedByUserId = _tenant.UserId,
Status = "pending",
};
_db.InvoiceUploads.Add(up);
await _db.SaveChangesAsync(ct);
return Ok(new UploadPhotosResult(up.Id));
}
/// <summary>Снимок товара для миниатюры рядом со строкой приёмки
/// (<c>NN.jpg</c> из папки фото-пачки). После <see cref="Apply"/> или
/// <see cref="Delete"/> папка удаляется — тогда вернёт 404.</summary>
[HttpGet("{id:guid}/photo/{n:int}")]
public async Task<IActionResult> Photo(Guid id, int n, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null || string.IsNullOrEmpty(up.StoredPath) || !Directory.Exists(up.StoredPath))
return NotFound();
var f = Path.Combine(up.StoredPath, $"{n:D2}.jpg");
return System.IO.File.Exists(f) ? PhysicalFile(f, "image/jpeg") : NotFound();
}
// ── list + detail ───────────────────────────────────────────────────
public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt,
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
int LinesTotal, int LinesMatched, Guid? SupplyId, DateTime? ProcessedAt, string? Error);
[HttpGet]
public async Task<IActionResult> List([FromQuery] int take = 50)
{
var rows = await _db.InvoiceUploads
.OrderByDescending(x => x.UploadedAt)
.Take(Math.Clamp(take, 1, 200))
// Port sklad 06.10 §6: UploadedAt — UTC, фронт умножает на локальную
// зону. Postgres timestamptz возвращается как Kind=Utc, но Dapper/
// JSON сериализация иногда теряет флаг — явно EnsureUtcKind перед
// отдачей не нужен для EF (он выставляет Kind=Utc для timestamptz),
// но клиент дополнительно подшивает «Z» если его нет (sklad §6).
.Select(x => new InvoiceListRow(x.Id, x.FileName, x.Status, x.UploadedAt,
x.SupplierName, x.InvoiceNumber, x.InvoiceDate, x.TotalSum,
x.LinesTotal, x.LinesMatched, x.SupplyId, x.ProcessedAt, x.Error))
.ToListAsync();
return Ok(rows);
}
public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt,
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
int LinesTotal, int LinesMatched, Guid? SupplyId, string? SupplyNumber, string? Error,
string? Source, bool HasFiles, DateTime? ProcessedAt,
List<InvoiceLine> Lines);
[HttpGet("{id:guid}")]
public async Task<ActionResult<InvoiceDetailDto>> Detail(Guid id, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
var lines = string.IsNullOrEmpty(up.ParsedJson)
? new List<InvoiceLine>()
: JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new List<InvoiceLine>();
// hasFiles — миниатюры для фото-приёмки доступны только пока папка жива
// (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404.
var hasFiles = !string.IsNullOrEmpty(up.StoredPath)
&& (Directory.Exists(up.StoredPath) || System.IO.File.Exists(up.StoredPath));
// supplyNumber — показывается плашкой на readOnly-экране готовой
// накладной (порт sklad 06.10 §10).
string? supplyNumber = null;
if (up.SupplyId is { } sid)
supplyNumber = await _db.Supplies.Where(s => s.Id == sid)
.Select(s => (string?)s.Number).FirstOrDefaultAsync(ct);
return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt,
up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum,
up.LinesTotal, up.LinesMatched, up.SupplyId, supplyNumber, up.Error,
up.Source, hasFiles, up.ProcessedAt, lines));
}
[HttpGet("{id:guid}/file")]
public async Task<IActionResult> File(Guid id, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath))
return NotFound(new { error = "Файл удалён." });
return PhysicalFile(up.StoredPath, up.ContentType ?? "application/octet-stream");
}
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
// Удаляем файл И папку фото-пачки (owner-policy: не копить фото).
InvoiceProcessor.DeleteStored(up);
_db.InvoiceUploads.Remove(up);
await _db.SaveChangesAsync(ct);
return NoContent();
}
// ── правки + ручные сопоставления ───────────────────────────────────
public record SaveLinesDto(List<InvoiceLine> Lines, string? SupplierName,
string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum);
/// <summary>Сохраняет правки кладовщика: строки + «шапка». Любое ручное
/// сопоставление (MatchedBy=«manual» и ProductId != null) УЧИТСЯ: мы
/// пишем/обновляем <see cref="InvoiceAlias"/> для текущего поставщика
/// → этот товар. Следующая накладная того же поставщика с таким же
/// SourceCode/NameNorm сопоставится автоматически.</summary>
[HttpPut("{id:guid}/lines"), RequiresPermission("SuppliesEdit")]
public async Task<IActionResult> SaveLines(Guid id, [FromBody] SaveLinesDto dto, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
if (up.Status == "applied")
return BadRequest(new { error = "Накладная уже перенесена в приёмку." });
up.SupplierName = dto.SupplierName ?? up.SupplierName;
up.InvoiceNumber = dto.InvoiceNumber ?? up.InvoiceNumber;
up.InvoiceDate = dto.InvoiceDate.HasValue
? DateTime.SpecifyKind(dto.InvoiceDate.Value, DateTimeKind.Utc)
: up.InvoiceDate;
up.TotalSum = dto.TotalSum ?? up.TotalSum;
up.ParsedJson = JsonSerializer.Serialize(dto.Lines);
up.LinesTotal = dto.Lines.Count;
up.LinesMatched = dto.Lines.Count(l => l.ProductId.HasValue);
// Запоминаем ручные сопоставления. Делаем до SaveChanges — если
// уникальный индекс выстрелит (concurrent write), ловим и
// игнорируем — приоритет на первой записи.
var supplierKey = (up.SupplierName ?? "").Trim().ToLowerInvariant();
if (!string.IsNullOrEmpty(supplierKey))
{
foreach (var l in dto.Lines)
{
if (!l.ProductId.HasValue) continue;
if (l.MatchedBy != "manual" && l.MatchedBy != "alias") continue;
await UpsertAliasAsync(supplierKey, l, ct);
}
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task UpsertAliasAsync(string supplierKey, InvoiceLine l, CancellationToken ct)
{
var nameNorm = InvoiceMatcher.NormalizeName(l.Name);
var code = string.IsNullOrWhiteSpace(l.SupplierCode) ? null : l.SupplierCode.Trim();
// Code и NameNorm — две отдельные уникальные записи (NULL в одной
// ветке допустим фильтр-индексом). Пишем обе параллельно.
foreach (var (byCode, byName) in new[] { (true, false), (false, true) })
{
var searchCode = byCode ? code : null;
var searchName = byName ? nameNorm : null;
if (byCode && string.IsNullOrEmpty(searchCode)) continue;
if (byName && string.IsNullOrEmpty(searchName)) continue;
var existing = await _db.InvoiceAliases
.Where(a => a.SupplierKey == supplierKey
&& ((byCode && a.SourceCode == searchCode) || (byName && a.SourceNameNorm == searchName)))
.FirstOrDefaultAsync(ct);
if (existing is null)
{
_db.InvoiceAliases.Add(new InvoiceAlias
{
SupplierKey = supplierKey,
SourceCode = searchCode,
SourceNameNorm = searchName,
ProductId = l.ProductId!.Value,
LastSeenAt = DateTime.UtcNow,
HitCount = 1,
});
}
else
{
existing.ProductId = l.ProductId.Value;
existing.LastSeenAt = DateTime.UtcNow;
existing.HitCount++;
}
}
}
// ── apply → Supply Draft ────────────────────────────────────────────
/// <summary><c>SupplierId</c> опционален (<c>null</c> или <see cref="Guid.Empty"/>):
/// в этом случае черновик ссылается на служебного «Поставщик не указан»
/// — порт sklad 06.10 §11, адаптировано под multi-tenant food-market.
/// Кладовщик может указать реального поставщика позже в карточке приёмки.</summary>
public record ApplyDto(Guid? SupplierId, Guid StoreId, Guid CurrencyId, string? Notes);
public record ApplyResult(Guid SupplyId, string Number, decimal Total, int Lines);
[HttpPost("{id:guid}/apply"), RequiresPermission("SuppliesEdit")]
public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto dto, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
if (up.Status == "applied" && up.SupplyId.HasValue)
return Conflict(new { error = "Накладная уже перенесена.", supplyId = up.SupplyId });
if (string.IsNullOrEmpty(up.ParsedJson))
return BadRequest(new { error = "Накладная ещё не разобрана." });
var lines = JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new();
var ready = lines.Where(l => l.ProductId.HasValue && l.Quantity > 0).ToList();
if (ready.Count == 0)
return BadRequest(new { error = "Нет строк с сопоставленным товаром и количеством > 0." });
// Поставщик: если явно не выбран — служебный «Поставщик не указан»
// (one per org, idempotent, помечен IsPlaceholder=true).
var supplierId = dto.SupplierId is { } sid && sid != Guid.Empty
? sid
: await ResolvePlaceholderCounterpartyAsync(ct);
// Генерация номера приёмки — тот же шаблон что в SuppliesController.
var number = await GenerateSupplyNumberAsync(up.InvoiceDate ?? DateTime.UtcNow, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var supply = new Supply
{
Number = number,
Date = up.InvoiceDate ?? DateTime.UtcNow,
Status = SupplyStatus.Draft,
SupplierId = supplierId,
StoreId = dto.StoreId,
CurrencyId = dto.CurrencyId,
// Port sklad 06.10 §11: имя поставщика из накладной в комментарий
// НЕ пишем — на карточке приёмки оно уже есть как подсказка, в
// комментарии только номер + дата исходного документа.
Notes = string.IsNullOrWhiteSpace(dto.Notes)
? $"Накладная {up.InvoiceNumber} от {up.InvoiceDate:dd.MM.yyyy}".Trim()
: dto.Notes,
};
int sort = 0;
foreach (var l in ready)
{
var price = allowFractional ? l.Price : Math.Round(l.Price, 0, MidpointRounding.AwayFromZero);
supply.Lines.Add(new SupplyLine
{
ProductId = l.ProductId!.Value,
Quantity = l.Quantity,
UnitPrice = price,
LineTotal = l.Quantity * price,
SortOrder = sort++,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
_db.Supplies.Add(supply);
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
return BadRequest(new { error = "Связанная запись не найдена (поставщик/склад/валюта/товар).", constraint = pg.ConstraintName });
}
up.SupplyId = supply.Id;
up.Status = "applied";
up.ProcessedAt = DateTime.UtcNow;
// Снимки/файл накладной больше не нужны (строки сохранены, черновик
// приёмки создан — миниатюры у строк кладовщик уже видел). Owner-policy
// 2026-10-05: фото накладных на сервере не копим.
InvoiceProcessor.DeleteStored(up);
await _db.SaveChangesAsync(ct);
return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count));
}
// ── помощники для UI ────────────────────────────────────────────────
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
/// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт
/// ещё и последнюю закупочную цену (чтобы scan-mode приёмки
/// подставлял цену автоматически).</summary>
[HttpGet("product-by-barcode")]
public async Task<IActionResult> ProductByBarcode([FromQuery] string barcode, CancellationToken ct)
{
var digits = InvoiceMatcher.OnlyDigits(barcode);
if (digits.Length < 6) return BadRequest(new { error = "Штрихкод короче 6 цифр." });
var hit = await _db.ProductBarcodes
.Where(b => b.Code == digits)
.Select(b => new { b.ProductId, Name = b.Product.Name, Article = b.Product.Article })
.FirstOrDefaultAsync(ct);
if (hit is null) return NotFound(new { barcode = digits });
// Последняя закупочная цена: берём из последней Posted supply-line
// где участвовал этот товар. Если никогда не поставляли — null.
var lastPrice = await _db.SupplyLines
.Where(l => l.ProductId == hit.ProductId && l.Supply.Status == SupplyStatus.Posted)
.OrderByDescending(l => l.Supply.Date)
.Select(l => (decimal?)l.UnitPrice)
.FirstOrDefaultAsync(ct);
return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article,
barcode = digits, lastPurchasePrice = lastPrice });
}
/// <summary>Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем
/// на экране «применить накладную», чтобы кладовщик не искал в отдельном
/// списке. Если ничего не нашлось, UI предложит создать нового.</summary>
[HttpGet("counterparty-search")]
public async Task<IActionResult> CounterpartySearch([FromQuery] string q, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty<object>());
var term = $"%{q.Trim()}%";
var rows = await _db.Counterparties
.Where(c => EF.Functions.ILike(c.Name, term) || (c.Bin != null && EF.Functions.ILike(c.Bin, term)))
.OrderBy(c => c.Name)
.Take(15)
.Select(c => new { c.Id, c.Name, c.Bin })
.ToListAsync(ct);
return Ok(rows);
}
/// <summary>Служебный «Поставщик не указан» — один на организацию.
/// Ищем по <c>IsPlaceholder=true</c> (partial-index, O(1)); если нет —
/// создаём и возвращаем id. Параллельный INSERT (две accept на пустую орг)
/// допустим: оба получат разные id, запись-«победитель» та, что в итоге
/// используется первой; дубль чистится ночным housekeeping'ом по
/// отсутствию ссылок. Для реального потока вероятность ~0.</summary>
private async Task<Guid> ResolvePlaceholderCounterpartyAsync(CancellationToken ct)
{
var existing = await _db.Counterparties
.Where(c => c.IsPlaceholder)
.Select(c => (Guid?)c.Id)
.FirstOrDefaultAsync(ct);
if (existing is { } id) return id;
var e = new Counterparty
{
Name = "Поставщик не указан",
LegalName = null,
Type = CounterpartyType.LegalEntity,
Notes = "Служебный: ставится в черновики приёмок, созданные через накладную без выбора поставщика. "
+ "Кладовщик меняет его на реального поставщика в карточке приёмки. Не удалять.",
IsPlaceholder = true,
};
_db.Counterparties.Add(e);
await _db.SaveChangesAsync(ct);
_log.LogInformation("[Накладные] Заведён служебный контрагент «Поставщик не указан» для орги {OrgId}", _tenant.OrganizationId);
return e.Id;
}
private async Task<string> GenerateSupplyNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"П-{year}-";
var max = await _db.Supplies
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (!string.IsNullOrEmpty(max) && int.TryParse(max.Split('-').LastOrDefault(), out var n)) seq = n + 1;
return prefix + seq.ToString("D6");
}
}