diff --git a/docs/supply-inventory-mobile-progress.md b/docs/supply-inventory-mobile-progress.md
new file mode 100644
index 0000000..ec7ce96
--- /dev/null
+++ b/docs/supply-inventory-mobile-progress.md
@@ -0,0 +1,57 @@
+# Supply + Inventory mobile — прогресс
+
+Задача: добавить в food-market два мобильных варианта приёмки (по фото
+накладной + сканированием штрихкодов) и инвентаризацию с телефона с
+офлайн-режимом. Образец логики — `~/ref/sklad/sklad/` (read-only); но
+документы создаются в НАШЕЙ базе (Supply/Inventory), а не черновиками
+в МойСклад.
+
+Все три фичи — основные (видны в простом режиме), доступны Admin +
+Storekeeper.
+
+## Чек-лист
+
+### Приёмка
+
+- [x] 1. **Приёмка по фото/PDF накладной** — загрузка, фоновое
+ распознавание через Claude CLI (`claude -p --output-format json`,
+ промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально
+ или через ssh на RemoteHost. Статусы: `queued → extracting →
+ review | failed`. Платные API-ключи не заводим.
+- [ ] 2. **Экран проверки накладной.** Редактируемые строки, помощь в
+ сопоставлении с товаром (barcode → saved-mapping → ручной), создание
+ товара прямо из строки, проверка «итого», предложение создать
+ контрагента. Коммит → наш Supply (Draft).
+- [ ] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на
+ скан, последняя закупочная подставляется, неизвестный штрихкод →
+ предложить создать товар или отложить.
+
+### Инвентаризация
+
+- [ ] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы,
+ офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан /
+ ручной ввод; неизвестные штрихкоды в отдельный список.
+- [ ] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах,
+ фильтр «только расхождения». Частичная (по группе) НЕ обнуляет
+ непосчитанные; полная — явный выбор.
+- [ ] 6. **Учётный остаток подставляется автоматически** (без ручной
+ кнопки «Пересчитать»): при создании bookQty = текущий Stock; при
+ добавлении позиции — тоже; перед проведением пересчёт + warning о
+ расхождениях с момента создания (продажи/приёмки). Если где-то
+ экспорт в МойСклад — там тоже явный расчётный остаток.
+
+### Общее
+
+- [ ] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг,
+ запоминание маппингов, bookQty, расхождения, частичная инв.),
+ integration (накладная → Supply Draft; частичная инв. не трогает
+ другие товары; bookQty = текущий Stock), e2e на стенде.
+- [ ] 8. `~/deploy-stage.sh`, ретест, `docs/supply-inventory.md` —
+ пользовательская инструкция.
+
+## Правила
+
+НЕ трогать: `~/ref/sklad` (только читать), прод admin.food-market.kz,
+`global.json`, gateway nginx, POS WPF.
+
+После каждого пункта: build + тесты + коммит порцией + `[x]`.
diff --git a/src/food-market.api/Controllers/Purchases/InvoicesController.cs b/src/food-market.api/Controllers/Purchases/InvoicesController.cs
new file mode 100644
index 0000000..177918e
--- /dev/null
+++ b/src/food-market.api/Controllers/Purchases/InvoicesController.cs
@@ -0,0 +1,348 @@
+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;
+
+/// Приёмка по фото/PDF накладной.
+///
+/// Жизненный цикл загрузки:
+///
+/// - POST /upload — файл(ы) сохраняются, запись
+/// в статусе pending.
+/// - в фоне: pending → parsing →
+/// needs_review | failed.
+/// - GET /{id} возвращает распознанные строки + сопоставление.
+/// - PUT /{id}/lines — кладовщик правит строки + ручные
+/// сопоставления; каждое ручное сопоставление пишется в
+/// — на следующих накладных того же
+/// поставщика matcher уже знает ответ.
+/// - POST /{id}/apply — создаёт Supply (Draft) с указанными
+/// поставщиком/складом/валютой. Status → applied, SupplyId
+/// ставится.
+///
+///
+/// Доступ: Admin + Storekeeper (SuppliesEdit permission).
+[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 _log;
+
+ public InvoicesController(AppDbContext db, IWebHostEnvironment env,
+ ITenantContext tenant, ILogger 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 Ids);
+
+ [HttpPost("upload"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
+ public async Task> Upload([FromForm] IFormFileCollection files, CancellationToken ct)
+ {
+ if (files is null || files.Count == 0)
+ return BadRequest(new { error = "Файлы не выбраны." });
+
+ Directory.CreateDirectory(StorageDir);
+ var created = new List();
+ 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));
+ }
+
+ // ── 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 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,
+ List Lines);
+
+ [HttpGet("{id:guid}")]
+ public async Task> 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()
+ : JsonSerializer.Deserialize>(up.ParsedJson) ?? new List();
+ 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, lines));
+ }
+
+ [HttpGet("{id:guid}/file")]
+ public async Task 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 Delete(Guid id, CancellationToken ct)
+ {
+ var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (up is null) return NotFound();
+ try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
+ System.IO.File.Delete(up.StoredPath); } catch { }
+ _db.InvoiceUploads.Remove(up);
+ await _db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ // ── правки + ручные сопоставления ───────────────────────────────────
+
+ public record SaveLinesDto(List Lines, string? SupplierName,
+ string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum);
+
+ /// Сохраняет правки кладовщика: строки + «шапка». Любое ручное
+ /// сопоставление (MatchedBy=«manual» и ProductId != null) УЧИТСЯ: мы
+ /// пишем/обновляем для текущего поставщика
+ /// → этот товар. Следующая накладная того же поставщика с таким же
+ /// SourceCode/NameNorm сопоставится автоматически.
+ [HttpPut("{id:guid}/lines"), RequiresPermission("SuppliesEdit")]
+ public async Task 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> 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>(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;
+ // Файл накладной больше не нужен (строки сохранены). Удаление — тихое.
+ try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
+ System.IO.File.Delete(up.StoredPath); } catch { }
+ up.StoredPath = "";
+ await _db.SaveChangesAsync(ct);
+
+ return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count));
+ }
+
+ // ── помощники для UI ────────────────────────────────────────────────
+
+ /// Поиск товара по штрихкоду — для ручной привязки строки без
+ /// открытия отдельного экрана поиска. Возвращает 404 если нет.
+ [HttpGet("product-by-barcode")]
+ public async Task 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 });
+ return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article, barcode = digits });
+ }
+
+ /// Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем
+ /// на экране «применить накладную», чтобы кладовщик не искал в отдельном
+ /// списке. Если ничего не нашлось, UI предложит создать нового.
+ [HttpGet("counterparty-search")]
+ public async Task CounterpartySearch([FromQuery] string q, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty