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
Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff).
Backend (src/food-market.api/Infrastructure/Invoices):
- Ean.cs — контрольная цифра EAN-8/13/UPC-A/GTIN-14 (ловит одну ошибку в цифре).
- TableReader.cs — своя читалка .xlsx/.csv без библиотек, UTF-8/1251,
.xls → понятная ошибка; TableInvoiceParser — шаблон накладной.
- InvoiceModels: +Photo/BarcodeSource/Note в InvoiceLine, PhotoHint,
IInvoiceExtractor.ExtractPhotosAsync.
- InvoiceMatcher.FindByBarcode — для photo-matching.
- InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto с EAN-проверкой
(barcode_source="read" → валидация), SweepAbandonedPhotosAsync раз в час
убирает папки needs_review старше 24ч; DeleteStored (owner-policy: НЕ копить).
- ClaudeCliInvoiceExtractor: ExtractPhotosAsync + BuildPhotosPrompt +
FromPhotosJson (barcode_source → BarcodeSource, comment → Note, unmatched_photos
→ Warnings); дефолт модели claude-sonnet-5-5, PhotoTimeoutSeconds=600.
Backend (Controllers):
- POST /purchases/invoices/upload-photos (≤80 файлов, 60 МБ) +
GET /purchases/invoices/{id}/photo/{n}; Apply/Delete убирают папку (не только файл).
- InvoiceDetailDto +Source/+HasFiles — клиент не ломится за 404 миниатюрами.
- POST /inventory/inventories/import — xlsx/csv → {found, unknown}, повторы ШК
суммируются, документ не создаётся.
- Program.cs: UseResponseCompression + GzipCompressionProvider для application/json.
- InventoriesController.mobile-catalog: weak ETag + If-None-Match → 304;
Cache-Control private max-age=60. Замер 31k: JSON 3.60 МБ → gzip 0.98 МБ.
Frontend:
- lib/shots.ts — ZXing TRY_HARDER на 1x+1600; compress JPEG 0.85 до 2400.
- components/PhotoSession.tsx — полноэкранная пачка (камера + готовые фото).
- components/CameraScanner.tsx: onCodeRef + эффект [], камера не закрывается
после каждого скана.
- pages/InvoicesPage.tsx — четыре способа: камера одиночными, файл (xlsx/csv/img/pdf),
камера многостраничная, фото-приёмка (камера или готовые фото) + «Отправить ещё раз».
- pages/InvoiceReviewPage.tsx — автосохранение 700 мс + индикатор; row-picker панель
«в какую строку?» если fuzzy-match неуверен; linesRef; глобальный keydown; миниатюра
снимка у строки; пометки Note.
- pages/SupplyScanPage.tsx + InventoryMobilePage.tsx — глобальный keydown.
- InventoryMobilePage: поле сканера на виду + импорт Excel/CSV.
- docs/supplier-invoice-template.xlsx — шаблон для поставщиков (9-я строка заголовки).
Хранение файлов (owner-policy 2026-10-05): фото НЕ копим.
- Обычная загрузка → удаление сразу после разбора.
- Фото-приёмка → удаление при Apply/Delete/24h abandoned.
- KeepFilesDays удалён из флоу (конфиг-ключ игнорируется).
Тесты (unit, зелёные):
- EanTests (6): валидные/невалидные/one-digit error.
- TableReaderTests (5): xlsx cells, шапка «Поставщик/Номер/Дата», CSV UTF-8+BOM,
CSV Windows-1251, .xls friendly error.
- ClaudeCliInvoicePhotosParseTests (4): decoded проходит, source=null чистит Barcode,
unmatched_photos → Warnings, comment → Note.
- InvoicePhotoRetentionTests (3): DeleteStored файл/папка/идемпотентность.
Preexisting ApiReferenceDocsJob (Sprint 28) failure не связан с портом.
Прогресс: docs/sklad-port-20261005-progress.md. Пользовательская инструкция:
docs/supply-inventory.md обновлена.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
425 lines
21 KiB
C#
425 lines
21 KiB
C#
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, 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))
|
||
.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.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? Error,
|
||
string? Source, bool HasFiles,
|
||
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));
|
||
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, up.Error,
|
||
up.Source, hasFiles, 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 ────────────────────────────────────────────
|
||
|
||
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." });
|
||
|
||
// Генерация номера приёмки — тот же шаблон что в 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 = dto.SupplierId,
|
||
StoreId = dto.StoreId,
|
||
CurrencyId = dto.CurrencyId,
|
||
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);
|
||
}
|
||
|
||
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");
|
||
}
|
||
}
|