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
Основное:
* 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>
478 lines
25 KiB
C#
478 lines
25 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, 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");
|
||
}
|
||
}
|