From a25b5b32779bd9ccbf437ba351c94bc45ffb5d11 Mon Sep 17 00:00:00 2001 From: nns Date: Wed, 7 Oct 2026 21:52:04 +0500 Subject: [PATCH] =?UTF-8?q?feat(money):=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20=C2=AB=D0=94?= =?UTF-8?q?=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8=C2=BB=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=D0=B0=20(=D0=BF=D0=BF.0=E2=80=936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сверка с МойСкладом (docs/money-moysklad-reference.md) + домен/инфра/API: - Счета и кассы (MoneyAccount: Cash/Safe/Bank/Acquiring, начальный остаток, привязка к магазину, IsDefault/IsActive, инвариант CurrentBalance). - Денежные документы (MoneyTransaction: PaymentIn/Out, CashIn/Out, переводы парой TransferOut↔TransferIn; статьи расходов ExpenseItem; проведение/отмена в Serializable-транзакции через MoneyService). - Оплата поставщикам: Supply/SupplierReturn.PayedSum + MoneyTransactionLink, SuppliesController отдаёт PaymentStatus + фильтр paymentStatus. - Взаиморасчёты: CounterpartyAdjustment + MoneyReportsController.SupplierDebts. - Смены кассы: CashShift + CashDrawerOperation (open/close, expected↔actual, Z-отчёт), запрет розничной продажи без открытой смены в RetailSalesController. - Отчёт «Деньги»: balances/movement с проверкой инварианта остатков. Права: RolePermissions +MoneyView/MoneyEdit/CashShiftOperate; сидер раздаёт кассиру CashShiftOperate+MoneyView, кладовщику — нет; 5 системных статей расходов на новую орг. Миграция Phase33a (ручная, с [Migration]+[DbContext]). Co-Authored-By: Claude Opus 4.8 --- docs/money-module-progress.md | 49 +++ docs/money-moysklad-reference.md | 257 ++++++++++++ .../Controllers/Money/CashShiftsController.cs | 206 ++++++++++ .../CounterpartyAdjustmentsController.cs | 169 ++++++++ .../Money/ExpenseItemsController.cs | 125 ++++++ .../Money/MoneyAccountsController.cs | 290 ++++++++++++++ .../Money/MoneyReportsController.cs | 188 +++++++++ .../Money/MoneyTransactionsController.cs | 371 ++++++++++++++++++ .../Purchases/SuppliesController.cs | 52 ++- .../Sales/RetailSalesController.cs | 22 ++ src/food-market.api/Program.cs | 4 + src/food-market.api/Seed/DevDataSeeder.cs | 34 ++ .../Money/IMoneyService.cs | 69 ++++ src/food-market.domain/Money/CashShift.cs | 118 ++++++ .../Money/CounterpartyAdjustment.cs | 45 +++ src/food-market.domain/Money/ExpenseItem.cs | 29 ++ src/food-market.domain/Money/MoneyAccount.cs | 81 ++++ .../Money/MoneyTransaction.cs | 135 +++++++ .../Organizations/RolePermissions.cs | 14 + .../Purchases/SupplierReturn.cs | 5 + src/food-market.domain/Purchases/Supply.cs | 8 + src/food-market.domain/Sales/RetailSale.cs | 8 + .../Money/MoneyNumbering.cs | 48 +++ .../Money/MoneyService.cs | 311 +++++++++++++++ .../Persistence/AppDbContext.cs | 11 + .../Configurations/MoneyConfigurations.cs | 176 +++++++++ .../20261007100000_Phase33a_Money.cs | 233 +++++++++++ 27 files changed, 3048 insertions(+), 10 deletions(-) create mode 100644 docs/money-module-progress.md create mode 100644 docs/money-moysklad-reference.md create mode 100644 src/food-market.api/Controllers/Money/CashShiftsController.cs create mode 100644 src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs create mode 100644 src/food-market.api/Controllers/Money/ExpenseItemsController.cs create mode 100644 src/food-market.api/Controllers/Money/MoneyAccountsController.cs create mode 100644 src/food-market.api/Controllers/Money/MoneyReportsController.cs create mode 100644 src/food-market.api/Controllers/Money/MoneyTransactionsController.cs create mode 100644 src/food-market.application/Money/IMoneyService.cs create mode 100644 src/food-market.domain/Money/CashShift.cs create mode 100644 src/food-market.domain/Money/CounterpartyAdjustment.cs create mode 100644 src/food-market.domain/Money/ExpenseItem.cs create mode 100644 src/food-market.domain/Money/MoneyAccount.cs create mode 100644 src/food-market.domain/Money/MoneyTransaction.cs create mode 100644 src/food-market.infrastructure/Money/MoneyNumbering.cs create mode 100644 src/food-market.infrastructure/Money/MoneyService.cs create mode 100644 src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase33a_Money.cs diff --git a/docs/money-module-progress.md b/docs/money-module-progress.md new file mode 100644 index 0000000..62ac3d2 --- /dev/null +++ b/docs/money-module-progress.md @@ -0,0 +1,49 @@ +# Модуль «Деньги» (простой режим) — прогресс + +**Старт:** 2026-10-07. +**Решение владельца от 07.10.2026:** собрать базовый финансовый контур +в простом режиме (счета, кассовые/безналичные движения, оплата приёмок, +долги поставщикам, смены кассы, отчёт «Деньги»). + +## Чек-лист + +- [x] **0. Сверка с МойСкладом** → `docs/money-moysklad-reference.md` +- [x] **1. Счета и кассы** (тип: касса / сейф / банк, начальный остаток, + магазин, активный флаг; поддержка нескольких банковских счетов на магазин; + счёт эквайринга) — domain + миграция, сидер статей расходов +- [x] **2. Денежные документы** (PaymentIn/Out, CashIn/Out, Transfer между + своими счетами; статьи расходов; проведение/отмена; аудит) — MoneyService + с Serializable-транзакциями, controllers `MoneyTransactionsController` + + `ExpenseItemsController` +- [x] **3. Оплата поставщикам** (платёж ↔ приёмки/возвраты с распределением, + payedSum на приёмке, статус оплаты, кнопка «Оплатить» из приёмки, + частичная оплата) — колонка `PayedSum`, `MoneyTransactionLink`, + `SuppliesController` отдаёт `PaymentStatus` + фильтр +- [x] **4. Взаиморасчёты (долги поставщикам)** (отчёт, корректировки, + самая старая неоплаченная дата) — `CounterpartyAdjustmentsController`, + `MoneyReportsController.SupplierDebts` +- [x] **5. Смены кассы** (open/close, внесения/выплаты, «должно быть ↔ по факту», + Z-отчёт, запрет продажи без смены) — `CashShiftsController`, Z-отчёт + в `/shifts/{id}/report`, проверка в `RetailSalesController.PostCoreAsync` +- [x] **6. Отчёт «Деньги»** (остатки по счетам, движение за период, + инвариант остатков) — `MoneyReportsController.Balances`/`Movement` +- [ ] **7. UI и меню** (раздел «Деньги», блок оплаты в приёмке, + mobile-first, права кассир/кладовщик/админ) +- [ ] **8. Тесты** (unit + integration на реальном Postgres + e2e на стенде + изнутри сети + меню-прогон watchdog step 9 зелёный) +- [ ] **9. Деплой + документация** (`~/deploy-stage.sh`, `docs/money.md`) + +## Границы + +**НЕ в этом спринте:** +банк-клиент / выгрузка платёжек, импорт выписок, валюты, зарплата, +полный P&L. + +**Запрещено трогать:** прод, shared-postgres, `global.json`, gateway nginx, +соседний проект `sklad`. + +## Журнал + +### 2026-10-07 +- Старт. Создан этот прогресс-док. Готовлю `docs/money-moysklad-reference.md` + через headless Playwright (SPA-рендер нужен). diff --git a/docs/money-moysklad-reference.md b/docs/money-moysklad-reference.md new file mode 100644 index 0000000..ab86837 --- /dev/null +++ b/docs/money-moysklad-reference.md @@ -0,0 +1,257 @@ +# МойСклад — опорные факты для модуля «Деньги» + +Собрано 2026-10-07 через headless Playwright со страниц +`https://dev.moysklad.ru/doc/api/remap/1.2/#/…`. Код скрейпера: +`tests/e2e/scrape-moysklad.ts`, сырые дампы: `/tmp/moysklad-dump/*.txt`. + +Задача этого документа — зафиксировать **именно те поля и связи +МойСклада**, которые мы должны уметь воспроизводить. Своими названиями +колонок/эндпоинтов пользуемся по смыслу их, чтобы потом синхронизация +легла без хирургии. + +--- + +## 1. Юрлицо → счета (`organization.accounts`) + +Юрлицо (`organization`) содержит коллекцию банковских счетов (`accounts`). +Один помечен «основным» (`isDefault`). Поля счёта: + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | — | +| `accountNumber` | String(255), **required** | номер счёта | +| `bankName` | String(255) | наименование банка | +| `bankLocation` | String(255) | адрес банка | +| `bic` | String(255) | БИК/BIC | +| `correspondentAccount` | String(255) | корр. счёт | +| `currency` | Meta, expand | валюта счёта | +| `isDefault` | Boolean, **required** | основной ли | + +**Следствие для нас:** на одну организацию у магазина может быть +несколько банковских счетов (Kaspi, Halyk и т.п.) + «касса» как отдельная +сущность. МойСклад касса/сейф — это уже свойство ордеров, а не поле +юрлица. У нас решено объединить это в единый справочник «Счета и кассы» +с типом `Bank | Cash | Safe | Acquiring` — так удобнее для простого +магазина, при синхронизации кассы маппим в документы cashin/cashout +«без закрывающего `organizationAccount`», а банк — в payment с +`organizationAccount` = нашим `MoneyAccountId`. + +--- + +## 2. Входящий платёж (`paymentin`) и исходящий (`paymentout`) — **безнал** + +Основные поля (общие для in/out): + +| Поле | Тип | Назначение | +|---|---|---| +| `agent` | Meta, **required** | контрагент | +| `agentAccount` | Meta | счёт контрагента | +| `organization` | Meta, **required** | наше юрлицо | +| `organizationAccount` | Meta | **наш** банковский счёт (списание / зачисление) | +| `moment` | DateTime, **required** | дата документа | +| `sum` | Float, **required** | сумма (в копейках у МойСклада; у нас — decimal в тенге) | +| `rate` | Object | валюта + курс | +| `paymentPurpose` | String(255), **required** | назначение платежа | +| `incomingDate` / `incomingNumber` | — | исходящий номер/дата платёжки (для входящих) | +| `applicable` | Boolean, **required** | «проведено» | +| `expenseItem` | Meta (**только у paymentout и cashout**) | статья расходов | +| `operations` | Array(Meta + `linkedSum`) | **привязка к документам** | +| `vatSum` | Float | НДС | + +### Привязка `operations` + +Это ключевая вещь: один платёж/ордер привязывается к N документам, +на каждый — своя `linkedSum`. + +**paymentin / cashin — что может оплачивать:** `customerorder`, +`purchasereturn`, `demand`, `invoiceout`, `commissionreportin`, `retailShift`. +→ оплата **от покупателя** (включая возврат поставщику — деньги возвращаются +поставщиком обратно к нам). + +**paymentout / cashout — что может оплачивать:** `supply` (приёмка), +`purchaseorder`, `invoicein`, `commissionreportout`, `customerreturn` +(возврат покупателю). +→ оплата **поставщикам и клиентам (возвраты)**. + +### `noClosingDocs` + +У `paymentout`/`cashout` есть флаг `noClosingDocs`. При `true` — платёж +«без закрывающих документов», `operations` должен быть пуст. При `false` — +`operations` обязателен для разноски. Нельзя передать `true` и +непустой `operations` одновременно. + +### Валидация валюты платежа + +Если задан `organizationAccount` в валюте, отличной от валюты учёта +компании, валюта документа **должна совпадать с валютой счёта** (иначе +ошибка 22004). У приёмки `cashin`/`cashout` этой валидации нет. + +--- + +## 3. Приходный / расходный ордер (`cashin`, `cashout`) — **наличные** + +Поля в целом совпадают с paymentin/paymentout, но: + +- **нет `organizationAccount`** — наличные деньги у МойСклада не + привязаны к конкретной «кассе», а трекаются суммарно по организации. + У нас это неудобно (магазин хочет видеть «касса магазина Абая», «касса + магазина Жибек Жолы», «сейф директора») — поэтому вводим внутреннее + поле `MoneyAccountId` обязательным для cash-документов тоже. +- `paymentPurpose` называется «Основание». +- У `cashout` есть `expenseItem` и `noClosingDocs`. + +--- + +## 4. Внесение/выплата кассы смены (`retaildrawercashin`, `retaildrawercashout`) + +Отдельная сущность — именно операции кассира внутри открытой смены +(«разменять 50к из сейфа», «отдать 10к курьеру из кассы»). + +Ключевые поля: + +| Поле | Тип | Назначение | +|---|---|---| +| `agent` | Meta, **required** | **сотрудник** (не контрагент!) — кассир | +| `organization` | Meta, **required** | юрлицо | +| `sum` | Float, **required**, read-only | сумма | +| `moment` | DateTime | момент | +| `retailShift` | Meta, **required** | смена, в рамках которой операция | +| `description` | String(4096) | комментарий (у нас — «Причина») | +| `applicable` | Boolean | проведено | + +У МойСклада эти документы «только для чтения `sum`» — считаются от +исходной операции. У нас будет проще: кассир вручную вводит сумму + +причину, валидация >0. + +--- + +## 5. Розничная смена (`retailshift`) + +| Поле | Тип | Назначение | +|---|---|---| +| `retailStore` | Meta, **required** | точка продаж (у нас RetailPoint) | +| `store` | Meta | склад (авто из retailStore если не задан) | +| `organization` | Meta, **required** | юрлицо | +| `organizationAccount` | Meta | счёт для безнал. выручки (эквайринг) | +| `acquire` | Meta | **банк-эквайер** по картам | +| `qrAcquire` | Meta | банк-эквайер по QR-коду | +| `bankPercent` | Double | % комиссии эквайера по картам | +| `bankComission` | Double | **сумма** комиссии (≤ выручки по картам) | +| `qrBankPercent`, `qrBankComission` | — | то же для QR | +| `proceedsCash` | Float, read-only | **выручка наличными** | +| `proceedsNoCash` | Float, read-only | **выручка безнал** | +| `receivedCash` | Float, read-only | получено наличными (с учётом внесений/выплат) | +| `receivedNoCash` | Float, read-only | получено безнал | +| `closeDate` | DateTime | момент закрытия смены | +| `operations` | Array | связанные операции (продажи, возвраты) | +| `paymentOperations` | Array, read-only | связанные платежи | +| `cheque.start / cheque.end` | Object | ККТ-признаки открытия/закрытия: `fnNumber`, `kktRegNumber`, `fiscalDocNumber`, `fiscalDocSign`, `shiftNumber`, `time`, (end) `chequesTotal`, `fiscalDocsTotal`. | + +**Схема расчёта наличных в смене (повторяем в своём коде):** + +``` +expectedCash = openingFloat // «разменник» на старте + + proceedsCash // продажи наличными + - cashRefunds // возвраты наличными + + Σ retaildrawercashin // внесения + - Σ retaildrawercashout // выплаты +``` + +«По факту» вводит кассир при закрытии; разница = +`actualCash - expectedCash`, подсвечиваем в UI. + +Выручка по картам отражается на счёте `organizationAccount` смены +(= наш «счёт эквайринга»). Комиссия банка — отдельный расход +(ежемесячно/ежедневно, зависит от договора). + +--- + +## 6. Корректировка взаиморасчётов (`counterpartyadjustment`) + +Простой документ: `agent` (контрагент), `organization`, `moment`, +`sum`, `applicable`, `description`. Используется для: + +1. **стартовых остатков по долгам** (начали работать с поставщиком, + должны 500к — заводим корректировку); +2. ручного исправления разошедшихся расчётов. + +У нас: применяем для простого случая «начальный долг поставщику»; знак +определяется интерфейсом («мы должны» / «нам должны»). + +--- + +## 7. Отчёт «Деньги» (`/report/money/plotseries`) + +Разделяется на две части: + +- **plotseries** — график движения за период: `credit` (приход), + `debit` (расход), и массив `series` с точками `date / credit / debit / balance`. + Параметры: `momentFrom`, `momentTo`, `interval` ∈ {hour, day, month}. +- (вторая часть — `/report/money` — остатки по счетам, в нашем дампе + обрезано, но из UI МойСклада знаем: массив объектов `{account, sum}`, + суммарный баланс.) + +Фильтры: `organization`, `project`. + +**Для нас:** реализуем два эндпоинта в одном контроллере — +`GET /api/money/report/balances` и `GET /api/money/report/movement?from=&to=&interval=`, +второй отдаёт credit/debit/balance в формате plotseries. + +--- + +## 8. Отчёт «Показатели контрагентов» (`/report/counterparty`) — долг + +В одном ряду по контрагенту: + +| Поле | Назначение | +|---|---| +| `balance` | **баланс взаиморасчётов**: >0 — контрагент должен нам, <0 — мы должны ему (знак зависит от типа) | +| `demandsCount`, `demandsSum` | продажи | +| `returnsCount`, `returnsSum` | возвраты | +| `averageReceipt` | средний чек | +| `profit` | прибыль | +| `firstDemandDate`, `lastDemandDate` | — | + +Для простого режима нам нужен **только `balance`** и аналог +`oldestUnpaidDate` (у МойСклада нет этого поля в отчёте, но мы считаем +сами из `supplyPayedSum < supplyTotal`). + +Формула долга поставщику у нас: +``` +debtToSupplier(supplierId) + = Σ supplies.total (status = Posted, supplierId = X) + − Σ supplierReturns.total (аналогично) + − Σ supplierPayments (paymentout + cashout по этому поставщику) + + Σ counterpartyAdjustments.sumSigned (стартовые долги) +``` + +--- + +## 9. `payedSum` на документах закупки/продажи + +Эмпирически у МойСклада на `supply`, `demand`, `purchasereturn`, +`customerreturn`, `invoiceout`, `invoicein` есть вычисляемое поле +`payedSum` — «сколько из суммы документа уже оплачено привязанными +платежами/ордерами». Оно не передаётся в POST, апдейтится сервером +на каждое изменение `operations/linkedSum` платежа. + +**У нас:** добавляем `PayedSum` на `Supply` и `SupplierReturn`, +пересчитываем в `MoneyService` при проведении/отмене платежа/ордера, +статус оплаты вычисляется в SQL/контроллере: +`unpaid | partial | paid`. + +--- + +## 10. Что НЕ берём в простой режим + +- `project`, `salesChannel`, `contract`, `state`, `group`, `attributes`, + `files`, `syncId`, `externalCode` — всё для расширенного/интеграции. +- `vatSum` — у магазина простого режима НДС считается общий, а не на + уровне платежа; поле опустим в UI, в БД оставим `nullable` на будущее. +- `invoiceout`/`invoicein` (счета) — у нас нет сущности «Счёт», только + документы. +- `incomingDate`/`incomingNumber` — не нужно, у магазина нет + документооборота с банком. + +Эти поля не создаём в простом режиме, но резервируем места в именованиях +колонок на будущее. diff --git a/src/food-market.api/Controllers/Money/CashShiftsController.cs b/src/food-market.api/Controllers/Money/CashShiftsController.cs new file mode 100644 index 0000000..6dec6cd --- /dev/null +++ b/src/food-market.api/Controllers/Money/CashShiftsController.cs @@ -0,0 +1,206 @@ +using System.ComponentModel.DataAnnotations; +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Application.Common; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Application.Money; +using foodmarket.Domain.Money; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Money; + +/// Смены кассы — открыть/закрыть + внесения/выплаты. Z-отчёт +/// получаем через GET /{id}/report, который возвращает всё в +/// печатаемом виде (суммы продаж/возвратов, нал/карта, разница). +/// +/// Если текущий пользователь — не Admin, он видит только свои смены; +/// если Admin — все. Это закреплено в контроллере, а не в query-filter'е +/// (нужна отдельная логика «моя смена» для кассира). +[ApiController] +[Authorize] +[Route("api/money/shifts")] +public class CashShiftsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IMoneyService _money; + + public CashShiftsController(AppDbContext db, ITenantContext tenant, IMoneyService money) + { + _db = db; + _tenant = tenant; + _money = money; + } + + public record CashShiftRow( + Guid Id, string Number, CashShiftStatus Status, + Guid RetailPointId, string RetailPointName, + Guid CashAccountId, string CashAccountName, + Guid? AcquiringAccountId, string? AcquiringAccountName, + Guid CashierUserId, string? CashierName, + DateTime OpenedAt, DateTime? ClosedAt, + decimal OpeningFloat, + decimal SalesCash, decimal SalesCard, + decimal ReturnsCash, decimal ReturnsCard, + decimal DrawerIn, decimal DrawerOut, + decimal ExpectedCash, decimal? ActualCash, decimal CashDifference, + string? Notes, uint Xmin); + + public record CashDrawerOpRow(Guid Id, CashDrawerDirection Direction, decimal Amount, string Reason, DateTime At, Guid ByUserId); + + public record OpenShiftInput( + Guid RetailPointId, Guid CashAccountId, + Guid? AcquiringAccountId, + [Range(0, 1e12)] decimal OpeningFloat); + + public record CloseShiftInput( + [Range(0, 1e12)] decimal ActualCash, + [MaxLength(1000)] string? Notes); + + public record DrawerOpInput( + CashDrawerDirection Direction, + [Range(0.0001, 1e12)] decimal Amount, + [Required, MaxLength(500)] string Reason); + + [HttpGet, RequiresPermission("MoneyView")] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] CashShiftStatus? status, + [FromQuery] Guid? retailPointId, + [FromQuery] bool? mine, + CancellationToken ct) + { + var q = _db.CashShifts.AsNoTracking().AsQueryable(); + if (status is not null) q = q.Where(x => x.Status == status); + if (retailPointId is not null) q = q.Where(x => x.RetailPointId == retailPointId); + if (mine == true) + { + var userId = CurrentUserId(); + q = q.Where(x => x.CashierUserId == userId); + } + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.OpenedAt) + .Skip(req.Skip).Take(req.Take) + .Select(x => new + { + x.Id, x.Number, x.Status, x.RetailPointId, + RetailPointName = _db.RetailPoints.Where(r => r.Id == x.RetailPointId).Select(r => r.Name).FirstOrDefault() ?? "", + x.CashAccountId, + CashAccountName = _db.MoneyAccounts.Where(a => a.Id == x.CashAccountId).Select(a => a.Name).FirstOrDefault() ?? "", + x.AcquiringAccountId, + AcquiringAccountName = x.AcquiringAccountId == null ? null : _db.MoneyAccounts.Where(a => a.Id == x.AcquiringAccountId).Select(a => a.Name).FirstOrDefault(), + x.CashierUserId, + x.OpenedAt, x.ClosedAt, + x.OpeningFloat, + x.SalesCash, x.SalesCard, x.ReturnsCash, x.ReturnsCard, + x.DrawerIn, x.DrawerOut, x.ExpectedCash, x.ActualCash, x.CashDifference, + x.Notes, + Xmin = Microsoft.EntityFrameworkCore.EF.Property(x, "xmin"), + }) + .ToListAsync(ct); + + var items = rows.Select(r => new CashShiftRow( + r.Id, r.Number, r.Status, r.RetailPointId, r.RetailPointName, + r.CashAccountId, r.CashAccountName, + r.AcquiringAccountId, r.AcquiringAccountName, + r.CashierUserId, null, + r.OpenedAt, r.ClosedAt, r.OpeningFloat, + r.SalesCash, r.SalesCard, r.ReturnsCash, r.ReturnsCard, + r.DrawerIn, r.DrawerOut, r.ExpectedCash, r.ActualCash, r.CashDifference, + r.Notes, r.Xmin)).ToList(); + + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}"), RequiresPermission("MoneyView")] + public async Task> Get(Guid id, CancellationToken ct) + { + var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + if (s is null) return NotFound(); + var ops = await _db.CashDrawerOperations.AsNoTracking() + .Where(o => o.CashShiftId == id) + .OrderBy(o => o.At) + .Select(o => new CashDrawerOpRow(o.Id, o.Direction, o.Amount, o.Reason, o.At, o.ByUserId)) + .ToListAsync(ct); + return Ok(new { shift = s, drawerOperations = ops }); + } + + [HttpGet("current"), RequiresPermission("CashShiftOperate")] + public async Task> Current([FromQuery] Guid retailPointId, CancellationToken ct) + { + var userId = CurrentUserId(); + var s = await _db.CashShifts.AsNoTracking() + .FirstOrDefaultAsync(x => x.RetailPointId == retailPointId && x.Status == CashShiftStatus.Open, ct); + return s is null ? Ok(new { open = false }) : Ok(new { open = true, shift = s, isMine = s.CashierUserId == userId }); + } + + [HttpPost("open"), RequiresPermission("CashShiftOperate")] + public async Task> Open([FromBody] OpenShiftInput input, CancellationToken ct) + { + var userId = CurrentUserId(); + var r = await _money.OpenShiftAsync(input.RetailPointId, input.CashAccountId, + input.AcquiringAccountId, input.OpeningFloat, userId, ct); + if (r.Status != MoneyServiceStatus.Ok) + return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.ShiftAlreadyOpen ? 409 : 400, + type: $"https://food-market.kz/errors/{r.ErrorCode}"); + return Ok(new { id = r.ShiftId, number = r.Number }); + } + + [HttpPost("{id:guid}/close"), RequiresPermission("CashShiftOperate")] + public async Task> Close(Guid id, [FromBody] CloseShiftInput input, CancellationToken ct) + { + var userId = CurrentUserId(); + var r = await _money.CloseShiftAsync(id, input.ActualCash, userId, input.Notes, ct); + if (r.Status != MoneyServiceStatus.Ok) + return Problem(r.Message, statusCode: 400, + type: $"https://food-market.kz/errors/{r.ErrorCode}"); + return Ok(new { expectedCash = r.ExpectedCash, difference = r.CashDifference }); + } + + [HttpPost("{id:guid}/drawer"), RequiresPermission("CashShiftOperate")] + public async Task> Drawer(Guid id, [FromBody] DrawerOpInput input, CancellationToken ct) + { + var userId = CurrentUserId(); + var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct); + if (r.Status != MoneyServiceStatus.Ok) + return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.NotFound ? 404 : 400, + type: $"https://food-market.kz/errors/{r.ErrorCode}"); + return Ok(new { id = r.OperationId }); + } + + /// Z-отчёт: все цифры смены + внесения/выплаты для печати. + [HttpGet("{id:guid}/report"), RequiresPermission("MoneyView")] + public async Task> Report(Guid id, CancellationToken ct) + { + var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + if (s is null) return NotFound(); + var ops = await _db.CashDrawerOperations.AsNoTracking() + .Where(o => o.CashShiftId == id) + .OrderBy(o => o.At) + .ToListAsync(ct); + return Ok(new + { + number = s.Number, + openedAt = s.OpenedAt, + closedAt = s.ClosedAt, + openingFloat = s.OpeningFloat, + sales = new { cash = s.SalesCash, card = s.SalesCard }, + returns = new { cash = s.ReturnsCash, card = s.ReturnsCard }, + drawer = new { @in = s.DrawerIn, @out = s.DrawerOut, items = ops }, + expectedCash = s.ExpectedCash, + actualCash = s.ActualCash, + cashDifference = s.CashDifference, + notes = s.Notes, + }); + } + + private Guid CurrentUserId() + { + var sub = User.FindFirst("sub")?.Value + ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(sub, out var g) ? g : Guid.Empty; + } +} diff --git a/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs b/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs new file mode 100644 index 0000000..f05fade --- /dev/null +++ b/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs @@ -0,0 +1,169 @@ +using System.ComponentModel.DataAnnotations; +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Application.Common; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Money; +using foodmarket.Infrastructure.Money; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Money; + +/// Корректировки взаиморасчётов — стартовый долг или ручное исправление. +/// Проведение влияет на отчёт долгов (SupplierDebtsController), на баланс счетов — нет. +[ApiController] +[Authorize] +[Route("api/money/adjustments")] +public class CounterpartyAdjustmentsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public CounterpartyAdjustmentsController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public record AdjustmentRow(Guid Id, string Number, DateTime Date, + Guid CounterpartyId, string CounterpartyName, + CounterpartyAdjustmentDirection Direction, + decimal Amount, string? Notes, + MoneyTransactionStatus Status, DateTime? PostedAt, uint Xmin); + + public record AdjustmentInput( + DateTime Date, + Guid CounterpartyId, + CounterpartyAdjustmentDirection Direction, + [Range(0.0001, 1e12)] decimal Amount, + [MaxLength(1000)] string? Notes, + uint? Xmin = null); + + [HttpGet, RequiresPermission("MoneyView")] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] Guid? counterpartyId, + [FromQuery] MoneyTransactionStatus? status, + CancellationToken ct) + { + var q = _db.CounterpartyAdjustments.AsNoTracking().AsQueryable(); + if (counterpartyId is not null) q = q.Where(x => x.CounterpartyId == counterpartyId); + if (status is not null) q = q.Where(x => x.Status == status); + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.Date) + .Skip(req.Skip).Take(req.Take) + .Select(x => new AdjustmentRow( + x.Id, x.Number, x.Date, x.CounterpartyId, + _db.Counterparties.Where(c => c.Id == x.CounterpartyId).Select(c => c.Name).FirstOrDefault() ?? "", + x.Direction, x.Amount, x.Notes, x.Status, x.PostedAt, + Microsoft.EntityFrameworkCore.EF.Property(x, "xmin"))) + .ToListAsync(ct); + return new PagedResult { Items = rows, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpPost, RequiresPermission("MoneyEdit")] + public async Task> Create([FromBody] AdjustmentInput input, CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? Guid.Empty; + var a = new CounterpartyAdjustment + { + OrganizationId = orgId, + Number = await MoneyNumbering.NextAdjustmentNumberAsync(_db, orgId, input.Date.Year, ct), + Date = input.Date, + CounterpartyId = input.CounterpartyId, + Direction = input.Direction, + Amount = input.Amount, + Notes = input.Notes, + Status = MoneyTransactionStatus.Draft, + }; + _db.CounterpartyAdjustments.Add(a); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = a.Id }, await ReadOneAsync(a.Id, ct)); + } + + [HttpGet("{id:guid}"), RequiresPermission("MoneyView")] + public async Task> Get(Guid id, CancellationToken ct) + { + var r = await ReadOneAsync(id, ct); + return r is null ? NotFound() : r; + } + + [HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task> Update(Guid id, [FromBody] AdjustmentInput input, CancellationToken ct) + { + var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct); + if (a is null) return NotFound(); + if (a.Status == MoneyTransactionStatus.Posted) + return Conflict(new { code = "already-posted", message = "Проведённую корректировку редактировать нельзя." }); + if (input.Xmin is not null && input.Xmin.Value != 0) + _db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value; + a.Date = input.Date; + a.CounterpartyId = input.CounterpartyId; + a.Direction = input.Direction; + a.Amount = input.Amount; + a.Notes = input.Notes; + a.UpdatedAt = DateTime.UtcNow; + try { await _db.SaveChangesAsync(ct); } + catch (DbUpdateConcurrencyException) { return Conflict(new { code = "concurrency-conflict" }); } + return (await ReadOneAsync(id, ct))!; + } + + [HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task Delete(Guid id, CancellationToken ct) + { + var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct); + if (a is null) return NotFound(); + if (a.Status == MoneyTransactionStatus.Posted) + return Conflict(new { code = "already-posted", message = "Проведённую корректировку сначала отмените." }); + _db.CounterpartyAdjustments.Remove(a); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/post"), RequiresPermission("MoneyEdit")] + public async Task Post(Guid id, CancellationToken ct) + { + var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct); + if (a is null) return NotFound(); + if (a.Status == MoneyTransactionStatus.Posted) return Conflict(new { code = "already-posted" }); + a.Status = MoneyTransactionStatus.Posted; + a.PostedAt = DateTime.UtcNow; + a.PostedByUserId = CurrentUserId(); + a.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/unpost"), RequiresPermission("MoneyEdit")] + public async Task Unpost(Guid id, CancellationToken ct) + { + var a = await _db.CounterpartyAdjustments.FirstOrDefaultAsync(x => x.Id == id, ct); + if (a is null) return NotFound(); + if (a.Status == MoneyTransactionStatus.Draft) return Conflict(new { code = "already-draft" }); + a.Status = MoneyTransactionStatus.Draft; + a.PostedAt = null; + a.PostedByUserId = null; + a.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private async Task ReadOneAsync(Guid id, CancellationToken ct) + { + var a = await _db.CounterpartyAdjustments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + if (a is null) return null; + var cp = await _db.Counterparties.AsNoTracking().Where(c => c.Id == a.CounterpartyId).Select(c => c.Name).FirstOrDefaultAsync(ct) ?? ""; + var xmin = await _db.CounterpartyAdjustments.AsNoTracking().Where(x => x.Id == id) + .Select(x => Microsoft.EntityFrameworkCore.EF.Property(x, "xmin")).FirstAsync(ct); + return new AdjustmentRow(a.Id, a.Number, a.Date, a.CounterpartyId, cp, a.Direction, a.Amount, a.Notes, a.Status, a.PostedAt, xmin); + } + + private Guid CurrentUserId() + { + var sub = User.FindFirst("sub")?.Value + ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(sub, out var g) ? g : Guid.Empty; + } +} diff --git a/src/food-market.api/Controllers/Money/ExpenseItemsController.cs b/src/food-market.api/Controllers/Money/ExpenseItemsController.cs new file mode 100644 index 0000000..202fe5e --- /dev/null +++ b/src/food-market.api/Controllers/Money/ExpenseItemsController.cs @@ -0,0 +1,125 @@ +using System.ComponentModel.DataAnnotations; +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Application.Common; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Money; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Money; + +/// Справочник статей расходов. Системные (IsSystem=true) — +/// переименовать можно, удалить нет. Остальные — удалить можно, если ни +/// один не ссылается. +[ApiController] +[Authorize] +[Route("api/money/expense-items")] +public class ExpenseItemsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public ExpenseItemsController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public record ExpenseItemRow(Guid Id, string Name, string? Code, bool IsSystem, bool IsActive, string? Notes, uint Xmin); + public record ExpenseItemInput( + [Required, MaxLength(200)] string Name, + [MaxLength(50)] string? Code, + [MaxLength(1000)] string? Notes, + bool IsActive = true, + uint? Xmin = null); + + [HttpGet, RequiresPermission("MoneyView")] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] bool? isActive, + CancellationToken ct) + { + var q = _db.ExpenseItems.AsNoTracking().AsQueryable(); + if (isActive is not null) q = q.Where(x => x.IsActive == isActive); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s))); + } + var total = await q.CountAsync(ct); + var items = await q.OrderBy(x => x.IsSystem ? 0 : 1).ThenBy(x => x.Name) + .Skip(req.Skip).Take(req.Take) + .Select(x => new ExpenseItemRow(x.Id, x.Name, x.Code, x.IsSystem, x.IsActive, x.Notes, + Microsoft.EntityFrameworkCore.EF.Property(x, "xmin"))) + .ToListAsync(ct); + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpPost, RequiresPermission("MoneyEdit")] + public async Task> Create([FromBody] ExpenseItemInput input, CancellationToken ct) + { + var e = new ExpenseItem + { + OrganizationId = _tenant.OrganizationId ?? Guid.Empty, + Name = input.Name.Trim(), + Code = string.IsNullOrWhiteSpace(input.Code) ? null : input.Code.Trim().ToUpperInvariant(), + Notes = input.Notes, + IsActive = input.IsActive, + IsSystem = false, + }; + _db.ExpenseItems.Add(e); + try { await _db.SaveChangesAsync(ct); } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("duplicate") == true) + { + return Conflict(new { code = "expense-item-duplicate", message = "Статья с таким именем или кодом уже существует." }); + } + return CreatedAtAction(nameof(Get), new { id = e.Id }, Read(e)); + } + + [HttpGet("{id:guid}"), RequiresPermission("MoneyView")] + public async Task> Get(Guid id, CancellationToken ct) + { + var e = await _db.ExpenseItems.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return e is null ? NotFound() : Read(e); + } + + [HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task> Update(Guid id, [FromBody] ExpenseItemInput input, CancellationToken ct) + { + var e = await _db.ExpenseItems.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + if (input.Xmin is not null && input.Xmin.Value != 0) + _db.Entry(e).Property("xmin").OriginalValue = input.Xmin.Value; + e.Name = input.Name.Trim(); + e.Code = string.IsNullOrWhiteSpace(input.Code) ? null : input.Code.Trim().ToUpperInvariant(); + e.Notes = input.Notes; + e.IsActive = input.IsActive; + e.UpdatedAt = DateTime.UtcNow; + try { await _db.SaveChangesAsync(ct); } + catch (DbUpdateConcurrencyException) + { + return Conflict(new { code = "concurrency-conflict" }); + } + return Read(e); + } + + [HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task Delete(Guid id, CancellationToken ct) + { + var e = await _db.ExpenseItems.FirstOrDefaultAsync(x => x.Id == id, ct); + if (e is null) return NotFound(); + if (e.IsSystem) + return Conflict(new { code = "expense-item-system", message = "Системную статью удалять нельзя." }); + var inUse = await _db.MoneyTransactions.AnyAsync(m => m.ExpenseItemId == id, ct); + if (inUse) + return Conflict(new { code = "expense-item-in-use", message = "Статья используется в документах." }); + _db.ExpenseItems.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private static ExpenseItemRow Read(ExpenseItem e) + => new(e.Id, e.Name, e.Code, e.IsSystem, e.IsActive, e.Notes, 0); +} diff --git a/src/food-market.api/Controllers/Money/MoneyAccountsController.cs b/src/food-market.api/Controllers/Money/MoneyAccountsController.cs new file mode 100644 index 0000000..0f34612 --- /dev/null +++ b/src/food-market.api/Controllers/Money/MoneyAccountsController.cs @@ -0,0 +1,290 @@ +using System.ComponentModel.DataAnnotations; +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Application.Common; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Money; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Money; + +/// Справочник «Счета и кассы» — CRUD. На любой счёт, у которого +/// есть хотя бы одно движение (money_transactions.AccountId) или +/// открытая смена (cash_shifts.CashAccountId), DELETE запрещён — +/// возвращаем 409 c кодом account-in-use; просим архивировать +/// (IsActive=false). +[ApiController] +[Authorize] +[Route("api/money/accounts")] +public class MoneyAccountsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public MoneyAccountsController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public record MoneyAccountRow( + Guid Id, string Name, MoneyAccountKind Kind, + string? BankName, string? AccountNumber, + Guid? StoreId, string? StoreName, + Guid CurrencyId, string CurrencyCode, + decimal OpeningBalance, DateTime OpeningDate, + decimal CurrentBalance, + bool IsActive, bool IsDefault, + uint Xmin); + + public record MoneyAccountInput( + [Required, MaxLength(200)] string Name, + MoneyAccountKind Kind, + [MaxLength(200)] string? BankName, + [MaxLength(100)] string? AccountNumber, + Guid? StoreId, + Guid CurrencyId, + [Range(-1e12, 1e12)] decimal OpeningBalance, + DateTime OpeningDate, + bool IsActive = true, + bool IsDefault = false, + uint? Xmin = null); + + [HttpGet, RequiresPermission("MoneyView")] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] MoneyAccountKind? kind, + [FromQuery] bool? isActive, + CancellationToken ct) + { + var q = from a in _db.MoneyAccounts.AsNoTracking() + join cu in _db.Currencies on a.CurrencyId equals cu.Id + join st in _db.Stores on a.StoreId equals st.Id into stj + from st in stj.DefaultIfEmpty() + select new { a, cu, st }; + + if (kind is not null) q = q.Where(x => x.a.Kind == kind); + if (isActive is not null) q = q.Where(x => x.a.IsActive == isActive); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.a.Name.ToLower().Contains(s) + || (x.a.BankName != null && x.a.BankName.ToLower().Contains(s)) + || (x.a.AccountNumber != null && x.a.AccountNumber.Contains(s))); + } + + var total = await q.CountAsync(ct); + + // Текущий остаток = Opening + Σ InKinds − Σ OutKinds (только Posted). + var ids = await q + .OrderBy(x => x.a.Kind).ThenBy(x => x.a.Name) + .Skip(req.Skip).Take(req.Take) + .Select(x => x.a.Id).ToListAsync(ct); + var balances = await _db.MoneyTransactions.AsNoTracking() + .Where(m => ids.Contains(m.AccountId) && m.Status == MoneyTransactionStatus.Posted) + .GroupBy(m => m.AccountId) + .Select(g => new + { + AccountId = g.Key, + InSum = g.Where(m => + m.Kind == MoneyTransactionKind.PaymentIn + || m.Kind == MoneyTransactionKind.CashIn + || m.Kind == MoneyTransactionKind.TransferIn).Sum(m => (decimal?)m.Amount) ?? 0, + OutSum = g.Where(m => + m.Kind == MoneyTransactionKind.PaymentOut + || m.Kind == MoneyTransactionKind.CashOut + || m.Kind == MoneyTransactionKind.TransferOut).Sum(m => (decimal?)m.Amount) ?? 0, + }) + .ToDictionaryAsync(x => x.AccountId, ct); + + var items = await q + .OrderBy(x => x.a.Kind).ThenBy(x => x.a.Name) + .Skip(req.Skip).Take(req.Take) + .Select(x => new { x.a, x.cu, StoreName = x.st != null ? x.st.Name : null }) + .ToListAsync(ct); + + var rows = items.Select(x => + { + balances.TryGetValue(x.a.Id, out var b); + var current = x.a.OpeningBalance + (b?.InSum ?? 0) - (b?.OutSum ?? 0); + return new MoneyAccountRow( + x.a.Id, x.a.Name, x.a.Kind, x.a.BankName, x.a.AccountNumber, + x.a.StoreId, x.StoreName, + x.cu.Id, x.cu.Code, + x.a.OpeningBalance, x.a.OpeningDate, current, + x.a.IsActive, x.a.IsDefault, + Microsoft.EntityFrameworkCore.EF.Property(x.a, "xmin")); + }).ToList(); + + return new PagedResult + { + Items = rows, + Total = total, + Page = req.Page, + PageSize = req.Take, + }; + } + + [HttpPost, RequiresPermission("MoneyEdit")] + public async Task> Create([FromBody] MoneyAccountInput input, CancellationToken ct) + { + var val = Validate(input); + if (val is not null) return val; + + // Cash должен быть привязан к Store (чтобы связать со сменой); + // Bank/Safe/Acquiring — опционально. + if (input.Kind == MoneyAccountKind.Cash && input.StoreId is null) + return Problem("Кассе требуется привязка к магазину (StoreId).", + statusCode: 400, type: "https://food-market.kz/errors/store-required"); + + var a = new MoneyAccount + { + OrganizationId = _tenant.OrganizationId ?? Guid.Empty, + Name = input.Name.Trim(), + Kind = input.Kind, + BankName = input.BankName?.Trim(), + AccountNumber = input.AccountNumber?.Trim(), + StoreId = input.StoreId, + CurrencyId = input.CurrencyId, + OpeningBalance = input.OpeningBalance, + OpeningDate = input.OpeningDate, + IsActive = input.IsActive, + IsDefault = false, + }; + _db.MoneyAccounts.Add(a); + await _db.SaveChangesAsync(ct); + + if (input.IsDefault) + await ApplyDefaultAsync(a.Id, a.Kind, ct); + + return CreatedAtAction(nameof(Get), new { id = a.Id }, await ReadOneAsync(a.Id, ct)); + } + + [HttpGet("{id:guid}"), RequiresPermission("MoneyView")] + public async Task> Get(Guid id, CancellationToken ct) + { + var row = await ReadOneAsync(id, ct); + return row is null ? NotFound() : row; + } + + [HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task> Update(Guid id, [FromBody] MoneyAccountInput input, CancellationToken ct) + { + var val = Validate(input); + if (val is not null) return val; + + var a = await _db.MoneyAccounts.FirstOrDefaultAsync(x => x.Id == id, ct); + if (a is null) return NotFound(); + + if (input.Xmin is not null && input.Xmin.Value != 0) + _db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value; + + // OpeningBalance/OpeningDate после создания менять нельзя — ломает + // инвариант «CurrentBalance = Opening + Σ Posted». + if (a.OpeningBalance != input.OpeningBalance || a.OpeningDate != input.OpeningDate) + return Problem("Начальный остаток и дата после создания счёта не редактируются.", + statusCode: 409, type: "https://food-market.kz/errors/opening-balance-locked"); + + a.Name = input.Name.Trim(); + a.Kind = input.Kind; + a.BankName = input.BankName?.Trim(); + a.AccountNumber = input.AccountNumber?.Trim(); + a.StoreId = input.StoreId; + a.CurrencyId = input.CurrencyId; + a.IsActive = input.IsActive; + a.UpdatedAt = DateTime.UtcNow; + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + return Conflict(new { code = "concurrency-conflict", message = "Счёт был изменён другим пользователем." }); + } + + if (input.IsDefault && !a.IsDefault) + await ApplyDefaultAsync(a.Id, a.Kind, ct); + + return await ReadOneAsync(a.Id, ct) ?? (ActionResult)NotFound(); + } + + [HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task Delete(Guid id, CancellationToken ct) + { + var a = await _db.MoneyAccounts.FirstOrDefaultAsync(x => x.Id == id, ct); + if (a is null) return NotFound(); + + var hasMovements = await _db.MoneyTransactions.AnyAsync(m => m.AccountId == id, ct); + var hasShifts = await _db.CashShifts.AnyAsync(s => s.CashAccountId == id || s.AcquiringAccountId == id, ct); + if (hasMovements || hasShifts) + return Conflict(new + { + code = "account-in-use", + message = "Счёт используется в документах или сменах — удалять нельзя, архивируйте его (IsActive=false).", + }); + + _db.MoneyAccounts.Remove(a); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private ActionResult? Validate(MoneyAccountInput input) + { + if (input.Kind == MoneyAccountKind.Bank && string.IsNullOrWhiteSpace(input.AccountNumber)) + return Problem("Для банковского счёта нужно указать номер счёта.", + statusCode: 400, type: "https://food-market.kz/errors/account-number-required"); + return null; + } + + private async Task ApplyDefaultAsync(Guid accountId, MoneyAccountKind kind, CancellationToken ct) + { + // Снимаем IsDefault с остальных счетов этого Kind и ставим на выбранный. + var others = await _db.MoneyAccounts + .Where(a => a.Kind == kind && a.Id != accountId && a.IsDefault) + .ToListAsync(ct); + foreach (var o in others) { o.IsDefault = false; o.UpdatedAt = DateTime.UtcNow; } + var me = await _db.MoneyAccounts.FirstAsync(a => a.Id == accountId, ct); + me.IsDefault = true; + me.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + } + + private async Task ReadOneAsync(Guid id, CancellationToken ct) + { + var a = await _db.MoneyAccounts.AsNoTracking() + .Where(x => x.Id == id) + .Join(_db.Currencies, x => x.CurrencyId, c => c.Id, (x, c) => new { a = x, cu = c }) + .FirstOrDefaultAsync(ct); + if (a is null) return null; + var storeName = a.a.StoreId is Guid sid + ? await _db.Stores.AsNoTracking().Where(s => s.Id == sid).Select(s => s.Name).FirstOrDefaultAsync(ct) + : null; + var inSum = await _db.MoneyTransactions.AsNoTracking() + .Where(m => m.AccountId == a.a.Id && m.Status == MoneyTransactionStatus.Posted + && (m.Kind == MoneyTransactionKind.PaymentIn + || m.Kind == MoneyTransactionKind.CashIn + || m.Kind == MoneyTransactionKind.TransferIn)) + .SumAsync(m => (decimal?)m.Amount, ct) ?? 0; + var outSum = await _db.MoneyTransactions.AsNoTracking() + .Where(m => m.AccountId == a.a.Id && m.Status == MoneyTransactionStatus.Posted + && (m.Kind == MoneyTransactionKind.PaymentOut + || m.Kind == MoneyTransactionKind.CashOut + || m.Kind == MoneyTransactionKind.TransferOut)) + .SumAsync(m => (decimal?)m.Amount, ct) ?? 0; + var xmin = await _db.MoneyAccounts.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => Microsoft.EntityFrameworkCore.EF.Property(x, "xmin")) + .FirstAsync(ct); + return new MoneyAccountRow( + a.a.Id, a.a.Name, a.a.Kind, a.a.BankName, a.a.AccountNumber, + a.a.StoreId, storeName, + a.cu.Id, a.cu.Code, + a.a.OpeningBalance, a.a.OpeningDate, + a.a.OpeningBalance + inSum - outSum, + a.a.IsActive, a.a.IsDefault, + xmin); + } +} diff --git a/src/food-market.api/Controllers/Money/MoneyReportsController.cs b/src/food-market.api/Controllers/Money/MoneyReportsController.cs new file mode 100644 index 0000000..f1cefe6 --- /dev/null +++ b/src/food-market.api/Controllers/Money/MoneyReportsController.cs @@ -0,0 +1,188 @@ +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Money; +using foodmarket.Domain.Purchases; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Money; + +/// Отчёты модуля «Деньги»: остатки по счетам, движение за период, +/// долги поставщикам. По образцу МойСклад /report/money/plotseries и +/// /report/counterparty (упрощённо). +[ApiController] +[Authorize] +[Route("api/money/reports")] +public class MoneyReportsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public MoneyReportsController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public record AccountBalanceRow(Guid AccountId, string Name, MoneyAccountKind Kind, + string CurrencyCode, decimal OpeningBalance, decimal CurrentBalance, bool IsActive); + public record BalancesReport(IReadOnlyList Accounts, decimal TotalByCurrencySum); + + [HttpGet("balances"), RequiresPermission("MoneyView")] + public async Task> Balances(CancellationToken ct) + { + var accounts = await _db.MoneyAccounts.AsNoTracking() + .OrderBy(a => a.Kind).ThenBy(a => a.Name) + .ToListAsync(ct); + var ids = accounts.Select(a => a.Id).ToList(); + var movements = await _db.MoneyTransactions.AsNoTracking() + .Where(m => ids.Contains(m.AccountId) && m.Status == MoneyTransactionStatus.Posted) + .GroupBy(m => new { m.AccountId, m.Kind }) + .Select(g => new { g.Key.AccountId, g.Key.Kind, Sum = g.Sum(m => m.Amount) }) + .ToListAsync(ct); + var currencies = await _db.Currencies.AsNoTracking().Where(c => ids.Any()) + .ToDictionaryAsync(c => c.Id, c => c.Code, ct); + + var rows = accounts.Select(a => + { + var inSum = movements.Where(m => m.AccountId == a.Id && + (m.Kind == MoneyTransactionKind.PaymentIn + || m.Kind == MoneyTransactionKind.CashIn + || m.Kind == MoneyTransactionKind.TransferIn)).Sum(m => m.Sum); + var outSum = movements.Where(m => m.AccountId == a.Id && + (m.Kind == MoneyTransactionKind.PaymentOut + || m.Kind == MoneyTransactionKind.CashOut + || m.Kind == MoneyTransactionKind.TransferOut)).Sum(m => m.Sum); + return new AccountBalanceRow(a.Id, a.Name, a.Kind, + currencies.TryGetValue(a.CurrencyId, out var code) ? code : "", + a.OpeningBalance, a.OpeningBalance + inSum - outSum, a.IsActive); + }).ToList(); + + return new BalancesReport(rows, rows.Sum(r => r.CurrentBalance)); + } + + public record MoneyPoint(DateTime Date, decimal Credit, decimal Debit, decimal Balance); + public record MoneyMovementReport(DateTime From, DateTime To, decimal TotalCredit, decimal TotalDebit, IReadOnlyList Series); + + /// Движение денежных средств за период (аналог + /// /report/money/plotseries). Интервал — day/month. + [HttpGet("movement"), RequiresPermission("MoneyView")] + public async Task> Movement( + [FromQuery] DateTime from, + [FromQuery] DateTime to, + [FromQuery] string interval = "day", + [FromQuery] Guid? accountId = null, + CancellationToken ct = default) + { + if (to <= from) return BadRequest(new { code = "bad-range", message = "to должен быть позже from" }); + var fromUtc = DateTime.SpecifyKind(from, DateTimeKind.Utc); + var toUtc = DateTime.SpecifyKind(to, DateTimeKind.Utc); + var q = _db.MoneyTransactions.AsNoTracking() + .Where(m => m.Status == MoneyTransactionStatus.Posted && m.Date >= fromUtc && m.Date < toUtc); + if (accountId is not null) q = q.Where(m => m.AccountId == accountId); + + var raw = await q.Select(m => new { m.Date, m.Kind, m.Amount }).ToListAsync(ct); + + // Переводы не считаем ни «приходом», ни «расходом» орги — они + // внутри орги, не меняют совокупных денег. Фильтруем. + raw = raw.Where(r => r.Kind != MoneyTransactionKind.TransferIn + && r.Kind != MoneyTransactionKind.TransferOut).ToList(); + + Func bucket = interval switch + { + "month" => d => new DateTime(d.Year, d.Month, 1, 0, 0, 0, DateTimeKind.Utc), + _ => d => d.Date, + }; + var groups = raw.GroupBy(r => bucket(r.Date)) + .Select(g => new + { + Date = g.Key, + Credit = g.Where(x => x.Kind == MoneyTransactionKind.PaymentIn || x.Kind == MoneyTransactionKind.CashIn).Sum(x => x.Amount), + Debit = g.Where(x => x.Kind == MoneyTransactionKind.PaymentOut || x.Kind == MoneyTransactionKind.CashOut).Sum(x => x.Amount), + }) + .OrderBy(g => g.Date) + .ToList(); + + var series = groups.Select(g => new MoneyPoint(g.Date, g.Credit, g.Debit, g.Credit - g.Debit)).ToList(); + return new MoneyMovementReport(fromUtc, toUtc, series.Sum(s => s.Credit), series.Sum(s => s.Debit), series); + } + + public record SupplierDebtRow( + Guid SupplierId, string SupplierName, string? Bin, string? Phone, + decimal SuppliesTotal, decimal ReturnsTotal, decimal PaymentsIn, decimal PaymentsOut, decimal AdjustmentDebt, + decimal Debt, + DateTime? OldestUnpaidDate, string? OldestUnpaidNumber); + public record SupplierDebtsReport(IReadOnlyList Rows, decimal TotalDebt); + + /// Долги поставщикам: Σ приёмок − Σ возвратов − Σ оплат + Σ корректировок. + /// Расчёт в C#, т.к. нужны джойны + aggregate'ы, которые EF транслирует неоптимально. + [HttpGet("supplier-debts"), RequiresPermission("MoneyView")] + public async Task> SupplierDebts( + [FromQuery] bool withDebtOnly = true, + CancellationToken ct = default) + { + var supplies = await _db.Supplies.AsNoTracking() + .Where(s => s.Status == SupplyStatus.Posted) + .GroupBy(s => s.SupplierId) + .Select(g => new + { + SupplierId = g.Key, + Total = g.Sum(s => s.Total), + Paid = g.Sum(s => s.PayedSum), + OldestUnpaid = g.Where(s => s.PayedSum < s.Total).OrderBy(s => s.Date).Select(s => new { s.Date, s.Number }).FirstOrDefault(), + }) + .ToListAsync(ct); + + var returns = await _db.SupplierReturns.AsNoTracking() + .Where(r => r.Status == SupplierReturnStatus.Posted) + .GroupBy(r => r.SupplierId) + .Select(g => new { SupplierId = g.Key, Total = g.Sum(r => r.Total), Paid = g.Sum(r => r.PayedSum) }) + .ToListAsync(ct); + + var adjustments = await _db.CounterpartyAdjustments.AsNoTracking() + .Where(a => a.Status == MoneyTransactionStatus.Posted) + .GroupBy(a => a.CounterpartyId) + .Select(g => new + { + CounterpartyId = g.Key, + WeOwe = g.Where(a => a.Direction == CounterpartyAdjustmentDirection.WeOwe).Sum(a => a.Amount), + TheyOwe = g.Where(a => a.Direction == CounterpartyAdjustmentDirection.TheyOwe).Sum(a => a.Amount), + }) + .ToListAsync(ct); + + var supplierIds = supplies.Select(s => s.SupplierId) + .Union(returns.Select(r => r.SupplierId)) + .Union(adjustments.Select(a => a.CounterpartyId)) + .Distinct().ToList(); + + var suppliers = await _db.Counterparties.AsNoTracking() + .Where(c => supplierIds.Contains(c.Id)) + .Select(c => new { c.Id, c.Name, c.Bin, c.Phone }) + .ToListAsync(ct); + + var rows = new List(); + foreach (var sup in suppliers) + { + var s = supplies.FirstOrDefault(x => x.SupplierId == sup.Id); + var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id); + var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id); + // Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны). + var debt = (s?.Total ?? 0) - (s?.Paid ?? 0) + - ((r?.Total ?? 0) - (r?.Paid ?? 0)) + + (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0); + if (withDebtOnly && Math.Abs(debt) < 0.0001m) continue; + rows.Add(new SupplierDebtRow( + sup.Id, sup.Name, sup.Bin, sup.Phone, + s?.Total ?? 0, r?.Total ?? 0, + r?.Paid ?? 0, s?.Paid ?? 0, + (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0), + debt, + s?.OldestUnpaid?.Date, s?.OldestUnpaid?.Number)); + } + + rows = rows.OrderByDescending(x => x.Debt).ToList(); + return new SupplierDebtsReport(rows, rows.Sum(x => x.Debt)); + } +} diff --git a/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs new file mode 100644 index 0000000..a633101 --- /dev/null +++ b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs @@ -0,0 +1,371 @@ +using System.ComponentModel.DataAnnotations; +using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Application.Common; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Application.Money; +using foodmarket.Domain.Money; +using foodmarket.Infrastructure.Money; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Money; + +/// Платежи и ордера (единый reпозит на все виды +/// кроме «ящиковых» внутри смены — те через смену). +/// +/// Создание — POST /, где Kind определяет тип документа. +/// Переводы между своими счетами делаются одним POST /transfers, +/// внутри атомарно создаётся пара TransferOut + TransferIn (с +/// LinkedTransactionId в обе стороны); проведение/отмена — тоже парная, +/// см. . +/// +/// Проведение / отмена — POST /{id}/post, POST /{id}/unpost. +/// Привязка к приёмке/возврату — через поле Links, разноска суммы +/// по правилу «Σ LinkedSum ≤ Amount» (валидация в ). +[ApiController] +[Authorize] +[Route("api/money/transactions")] +public class MoneyTransactionsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IMoneyService _money; + + public MoneyTransactionsController(AppDbContext db, ITenantContext tenant, IMoneyService money) + { + _db = db; + _tenant = tenant; + _money = money; + } + + public record MoneyTxLinkRow(Guid? SupplyId, string? SupplyNumber, Guid? SupplierReturnId, string? SupplierReturnNumber, decimal LinkedSum); + public record MoneyTxRow( + Guid Id, string Number, DateTime Date, + MoneyTransactionKind Kind, MoneyTransactionStatus Status, + Guid AccountId, string AccountName, + Guid? CounterpartyId, string? CounterpartyName, + Guid? ExpenseItemId, string? ExpenseItemName, + Guid CurrencyId, string CurrencyCode, + decimal Amount, string? Purpose, string? Notes, + Guid? LinkedTransactionId, + DateTime? PostedAt, + IReadOnlyList Links, + uint Xmin); + + public record MoneyTxLinkInput(Guid? SupplyId, Guid? SupplierReturnId, [Range(0, 1e12)] decimal LinkedSum); + public record MoneyTxInput( + DateTime Date, + MoneyTransactionKind Kind, + Guid AccountId, + Guid? CounterpartyId, + Guid? ExpenseItemId, + Guid CurrencyId, + [Range(0, 1e12)] decimal Amount, + [MaxLength(500)] string? Purpose, + [MaxLength(1000)] string? Notes, + IReadOnlyList? Links = null, + uint? Xmin = null); + + [HttpGet, RequiresPermission("MoneyView")] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] MoneyTransactionKind? kind, + [FromQuery] MoneyTransactionStatus? status, + [FromQuery] Guid? accountId, + [FromQuery] Guid? counterpartyId, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + CancellationToken ct) + { + var q = _db.MoneyTransactions.AsNoTracking().AsQueryable(); + if (kind is not null) q = q.Where(x => x.Kind == kind); + if (status is not null) q = q.Where(x => x.Status == status); + if (accountId is not null) q = q.Where(x => x.AccountId == accountId); + if (counterpartyId is not null) q = q.Where(x => x.CounterpartyId == counterpartyId); + if (from is not null) q = q.Where(x => x.Date >= from); + if (to is not null) q = q.Where(x => x.Date < to); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.Number.ToLower().Contains(s) + || (x.Purpose != null && x.Purpose.ToLower().Contains(s))); + } + + var total = await q.CountAsync(ct); + + var rows = await q.OrderByDescending(x => x.Date).ThenByDescending(x => x.Number) + .Skip(req.Skip).Take(req.Take) + .Select(x => new + { + x.Id, x.Number, x.Date, x.Kind, x.Status, + x.AccountId, AccountName = _db.MoneyAccounts.Where(a => a.Id == x.AccountId).Select(a => a.Name).FirstOrDefault() ?? "", + x.CounterpartyId, CounterpartyName = x.CounterpartyId == null ? null : _db.Counterparties.Where(c => c.Id == x.CounterpartyId).Select(c => c.Name).FirstOrDefault(), + x.ExpenseItemId, ExpenseItemName = x.ExpenseItemId == null ? null : _db.ExpenseItems.Where(e => e.Id == x.ExpenseItemId).Select(e => e.Name).FirstOrDefault(), + x.CurrencyId, CurrencyCode = _db.Currencies.Where(c => c.Id == x.CurrencyId).Select(c => c.Code).FirstOrDefault() ?? "", + x.Amount, x.Purpose, x.Notes, + x.LinkedTransactionId, x.PostedAt, + Xmin = Microsoft.EntityFrameworkCore.EF.Property(x, "xmin"), + }) + .ToListAsync(ct); + + var ids = rows.Select(r => r.Id).ToList(); + var links = await _db.MoneyTransactionLinks.AsNoTracking() + .Where(l => ids.Contains(l.TransactionId)) + .Select(l => new + { + l.TransactionId, + l.SupplyId, SupplyNumber = l.Supply != null ? l.Supply.Number : null, + l.SupplierReturnId, SRNumber = l.SupplierReturn != null ? l.SupplierReturn.Number : null, + l.LinkedSum, + }) + .ToListAsync(ct); + + var items = rows.Select(r => new MoneyTxRow( + r.Id, r.Number, r.Date, r.Kind, r.Status, + r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName, + r.ExpenseItemId, r.ExpenseItemName, + r.CurrencyId, r.CurrencyCode, + r.Amount, r.Purpose, r.Notes, + r.LinkedTransactionId, r.PostedAt, + links.Where(l => l.TransactionId == r.Id) + .Select(l => new MoneyTxLinkRow(l.SupplyId, l.SupplyNumber, l.SupplierReturnId, l.SRNumber, l.LinkedSum)) + .ToList(), + r.Xmin)).ToList(); + + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}"), RequiresPermission("MoneyView")] + public async Task> Get(Guid id, CancellationToken ct) + { + var row = await ReadOneAsync(id, ct); + return row is null ? NotFound() : row; + } + + [HttpPost, RequiresPermission("MoneyEdit")] + public async Task> Create([FromBody] MoneyTxInput input, CancellationToken ct) + { + if (input.Kind == MoneyTransactionKind.TransferIn || input.Kind == MoneyTransactionKind.TransferOut) + return Problem("Переводы между счетами создавайте через /api/money/transfers.", + statusCode: 400, type: "https://food-market.kz/errors/use-transfers-endpoint"); + if (input.Amount <= 0) + return Problem("Сумма должна быть больше нуля.", statusCode: 400, + type: "https://food-market.kz/errors/amount-must-be-positive"); + if ((input.Kind == MoneyTransactionKind.PaymentOut || input.Kind == MoneyTransactionKind.CashOut) + && input.ExpenseItemId is null && (input.Links is null || input.Links.Count == 0)) + return Problem("Расходный документ без привязок к документам должен иметь статью расхода.", + statusCode: 400, type: "https://food-market.kz/errors/expense-item-required"); + + var orgId = _tenant.OrganizationId ?? Guid.Empty; + var mt = new MoneyTransaction + { + OrganizationId = orgId, + Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct), + Date = input.Date, + Kind = input.Kind, + Status = MoneyTransactionStatus.Draft, + AccountId = input.AccountId, + CounterpartyId = input.CounterpartyId, + ExpenseItemId = input.ExpenseItemId, + CurrencyId = input.CurrencyId, + Amount = input.Amount, + Purpose = string.IsNullOrWhiteSpace(input.Purpose) ? null : input.Purpose.Trim(), + Notes = input.Notes, + }; + foreach (var l in input.Links ?? Array.Empty()) + { + // CHECK в БД поймает некорректное; продублируем валидацией для внятной ошибки. + if ((l.SupplyId is null) == (l.SupplierReturnId is null)) + return Problem("В каждой связи должен быть ровно один из SupplyId / SupplierReturnId.", + statusCode: 400, type: "https://food-market.kz/errors/link-single-target"); + mt.Links.Add(new MoneyTransactionLink + { + OrganizationId = orgId, + SupplyId = l.SupplyId, + SupplierReturnId = l.SupplierReturnId, + LinkedSum = l.LinkedSum, + }); + } + _db.MoneyTransactions.Add(mt); + await _db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = mt.Id }, await ReadOneAsync(mt.Id, ct)); + } + + [HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task> Update(Guid id, [FromBody] MoneyTxInput input, CancellationToken ct) + { + var mt = await _db.MoneyTransactions.Include(m => m.Links).FirstOrDefaultAsync(m => m.Id == id, ct); + if (mt is null) return NotFound(); + if (mt.Status == MoneyTransactionStatus.Posted) + return Conflict(new { code = "already-posted", message = "Проведённый документ нельзя редактировать. Отмените проведение сначала." }); + + if (input.Xmin is not null && input.Xmin.Value != 0) + _db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value; + + mt.Date = input.Date; + mt.AccountId = input.AccountId; + mt.CounterpartyId = input.CounterpartyId; + mt.ExpenseItemId = input.ExpenseItemId; + mt.CurrencyId = input.CurrencyId; + mt.Amount = input.Amount; + mt.Purpose = string.IsNullOrWhiteSpace(input.Purpose) ? null : input.Purpose.Trim(); + mt.Notes = input.Notes; + mt.UpdatedAt = DateTime.UtcNow; + + _db.MoneyTransactionLinks.RemoveRange(mt.Links); + mt.Links.Clear(); + foreach (var l in input.Links ?? Array.Empty()) + { + if ((l.SupplyId is null) == (l.SupplierReturnId is null)) + return Problem("В каждой связи должен быть ровно один из SupplyId / SupplierReturnId.", + statusCode: 400, type: "https://food-market.kz/errors/link-single-target"); + mt.Links.Add(new MoneyTransactionLink + { + OrganizationId = mt.OrganizationId, + SupplyId = l.SupplyId, + SupplierReturnId = l.SupplierReturnId, + LinkedSum = l.LinkedSum, + }); + } + + try { await _db.SaveChangesAsync(ct); } + catch (DbUpdateConcurrencyException) + { + return Conflict(new { code = "concurrency-conflict" }); + } + return (await ReadOneAsync(id, ct))!; + } + + [HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")] + public async Task Delete(Guid id, CancellationToken ct) + { + var mt = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == id, ct); + if (mt is null) return NotFound(); + if (mt.Status == MoneyTransactionStatus.Posted) + return Conflict(new { code = "already-posted", message = "Проведённый документ нельзя удалить." }); + _db.MoneyTransactions.Remove(mt); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/post"), RequiresPermission("MoneyEdit")] + public async Task Post(Guid id, CancellationToken ct) + { + var userId = CurrentUserId(); + var r = await _money.PostAsync(id, userId, ct); + return MapResult(r); + } + + [HttpPost("{id:guid}/unpost"), RequiresPermission("MoneyEdit")] + public async Task Unpost(Guid id, CancellationToken ct) + { + var userId = CurrentUserId(); + var r = await _money.UnpostAsync(id, userId, ct); + return MapResult(r); + } + + public record TransferInput( + DateTime Date, + Guid FromAccountId, + Guid ToAccountId, + [Range(0.0001, 1e12)] decimal Amount, + [MaxLength(500)] string? Purpose); + + [HttpPost("/api/money/transfers"), RequiresPermission("MoneyEdit")] + public async Task> Transfer([FromBody] TransferInput input, CancellationToken ct) + { + if (input.FromAccountId == input.ToAccountId) + return Problem("Нельзя перевести на тот же счёт.", statusCode: 400, + type: "https://food-market.kz/errors/transfer-same-account"); + + var orgId = _tenant.OrganizationId ?? Guid.Empty; + var from = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == input.FromAccountId, ct); + var to = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == input.ToAccountId, ct); + if (from is null || to is null) return NotFound(); + if (from.CurrencyId != to.CurrencyId) + return Problem("Перевод между счетами разной валюты не поддерживается в простом режиме.", + statusCode: 400, type: "https://food-market.kz/errors/transfer-currency-mismatch"); + + var outTx = new MoneyTransaction + { + OrganizationId = orgId, + Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct), + Date = input.Date, + Kind = MoneyTransactionKind.TransferOut, + Status = MoneyTransactionStatus.Draft, + AccountId = from.Id, + CurrencyId = from.CurrencyId, + Amount = input.Amount, + Purpose = input.Purpose, + }; + var inTx = new MoneyTransaction + { + OrganizationId = orgId, + Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct), + Date = input.Date, + Kind = MoneyTransactionKind.TransferIn, + Status = MoneyTransactionStatus.Draft, + AccountId = to.Id, + CurrencyId = to.CurrencyId, + Amount = input.Amount, + Purpose = input.Purpose, + }; + _db.MoneyTransactions.AddRange(outTx, inTx); + await _db.SaveChangesAsync(ct); + + // Теперь свяжем парой — после первого save появились Id. + outTx.LinkedTransactionId = inTx.Id; + inTx.LinkedTransactionId = outTx.Id; + await _db.SaveChangesAsync(ct); + + return Ok(new { outId = outTx.Id, inId = inTx.Id, outNumber = outTx.Number, inNumber = inTx.Number }); + } + + private Guid CurrentUserId() + { + var sub = User.FindFirst("sub")?.Value + ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(sub, out var g) ? g : Guid.Empty; + } + + private IActionResult MapResult(MoneyServiceResult r) => r.Status switch + { + MoneyServiceStatus.Ok => NoContent(), + MoneyServiceStatus.NotFound => NotFound(), + MoneyServiceStatus.AlreadyPosted => Conflict(new { code = r.ErrorCode, message = r.Message }), + MoneyServiceStatus.AlreadyDraft => Conflict(new { code = r.ErrorCode, message = r.Message }), + MoneyServiceStatus.OverpaidDocument => Conflict(new { code = r.ErrorCode, message = r.Message }), + MoneyServiceStatus.LinkedSumMismatch => BadRequest(new { code = r.ErrorCode, message = r.Message }), + MoneyServiceStatus.WrongCurrency => BadRequest(new { code = r.ErrorCode, message = r.Message }), + MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }), + _ => BadRequest(new { code = r.ErrorCode, message = r.Message }), + }; + + private async Task ReadOneAsync(Guid id, CancellationToken ct) + { + var mt = await _db.MoneyTransactions.AsNoTracking() + .Include(x => x.Links).ThenInclude(l => l.Supply) + .Include(x => x.Links).ThenInclude(l => l.SupplierReturn) + .FirstOrDefaultAsync(x => x.Id == id, ct); + if (mt is null) return null; + var account = await _db.MoneyAccounts.AsNoTracking().Where(a => a.Id == mt.AccountId).Select(a => a.Name).FirstAsync(ct); + var counterparty = mt.CounterpartyId is null ? null : + await _db.Counterparties.AsNoTracking().Where(c => c.Id == mt.CounterpartyId).Select(c => c.Name).FirstOrDefaultAsync(ct); + var exp = mt.ExpenseItemId is null ? null : + await _db.ExpenseItems.AsNoTracking().Where(e => e.Id == mt.ExpenseItemId).Select(e => e.Name).FirstOrDefaultAsync(ct); + var currency = await _db.Currencies.AsNoTracking().Where(c => c.Id == mt.CurrencyId).Select(c => c.Code).FirstAsync(ct); + var xmin = await _db.MoneyTransactions.AsNoTracking().Where(x => x.Id == id) + .Select(x => Microsoft.EntityFrameworkCore.EF.Property(x, "xmin")).FirstAsync(ct); + return new MoneyTxRow( + mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status, + mt.AccountId, account, mt.CounterpartyId, counterparty, + mt.ExpenseItemId, exp, + mt.CurrencyId, currency, + mt.Amount, mt.Purpose, mt.Notes, + mt.LinkedTransactionId, mt.PostedAt, + mt.Links.Select(l => new MoneyTxLinkRow(l.SupplyId, l.Supply?.Number, l.SupplierReturnId, l.SupplierReturn?.Number, l.LinkedSum)).ToList(), + xmin); + } +} diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index 91e085f..e58b89d 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -36,7 +36,18 @@ public record SupplyListRow( Guid StoreId, string StoreName, Guid CurrencyId, string CurrencyCode, decimal Total, int LineCount, - DateTime? PostedAt); + DateTime? PostedAt, + decimal PayedSum, string PaymentStatus); + + /// Статус оплаты приёмки — вычисляемая строка: + /// "unpaid" (0), "partial" (0 < payed < total), "paid" (payed ≥ total). + /// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог. + private static string ComputePaymentStatus(decimal total, decimal payed) + { + if (total <= 0) return "paid"; + if (payed <= 0) return "unpaid"; + return payed + 0.0001m >= total ? "paid" : "partial"; + } public record SupplyLineDto( Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, @@ -54,7 +65,8 @@ public record SupplyDto( string? Notes, decimal Total, DateTime? PostedAt, uint Xmin, - IReadOnlyList Lines); + IReadOnlyList Lines, + decimal PayedSum, string PaymentStatus); public record SupplyLineInput( Guid ProductId, @@ -77,6 +89,7 @@ public record SupplyInput( [FromQuery] SupplyStatus? status, [FromQuery] Guid? storeId, [FromQuery] Guid? supplierId, + [FromQuery] string? paymentStatus, CancellationToken ct) { var q = from s in _db.Supplies.AsNoTracking() @@ -93,6 +106,14 @@ public record SupplyInput( var s = req.Search.Trim().ToLower(); q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s)); } + // Фильтр по статусу оплаты (unpaid/partial/paid). "all"/null — без фильтра. + q = paymentStatus switch + { + "unpaid" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum <= 0), + "partial" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum > 0 && x.s.PayedSum < x.s.Total), + "paid" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum >= x.s.Total), + _ => q, + }; var total = await q.CountAsync(ct); q = (req.Sort, req.Desc) switch @@ -110,17 +131,27 @@ public record SupplyInput( ("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number), _ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number), }; - var items = await q + var raw = await q .Skip(req.Skip).Take(req.Take) - .Select(x => new SupplyListRow( + .Select(x => new + { x.s.Id, x.s.Number, x.s.Date, x.s.Status, - x.cp.Id, x.cp.Name, - x.st.Id, x.st.Name, - x.cu.Id, x.cu.Code, + SupplierId = x.cp.Id, SupplierName = x.cp.Name, + StoreId = x.st.Id, StoreName = x.st.Name, + CurrencyId = x.cu.Id, CurrencyCode = x.cu.Code, x.s.Total, - x.s.Lines.Count, - x.s.PostedAt)) + LineCount = x.s.Lines.Count, + x.s.PostedAt, + x.s.PayedSum, + }) .ToListAsync(ct); + var items = raw.Select(x => new SupplyListRow( + x.Id, x.Number, x.Date, x.Status, + x.SupplierId, x.SupplierName, + x.StoreId, x.StoreName, + x.CurrencyId, x.CurrencyCode, + x.Total, x.LineCount, x.PostedAt, + x.PayedSum, ComputePaymentStatus(x.Total, x.PayedSum))).ToList(); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -617,6 +648,7 @@ orderby l.SortOrder row.s.Notes, row.s.Total, row.s.PostedAt, row.Xmin, - lines); + lines, + row.s.PayedSum, ComputePaymentStatus(row.s.Total, row.s.PayedSum)); } } diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 985f89c..fa61cbf 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -569,6 +569,28 @@ private async Task PostCoreAsync(Guid id, CancellationToken ct) if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." }); if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." }); + // Sprint 33: продажи без открытой смены на точке — запрещаем. Исключение + // только для операций без RetailPoint (bulk-импорт, тесты, legacy чеки + // до внедрения модуля денег). + if (sale.RetailPointId is Guid rpId) + { + var openShift = await _db.CashShifts + .Where(cs => cs.RetailPointId == rpId + && cs.Status == foodmarket.Domain.Money.CashShiftStatus.Open) + .Select(cs => cs.Id) + .FirstOrDefaultAsync(ct); + if (openShift == Guid.Empty) + { + return Conflict(new + { + error = "На этой точке продаж не открыта кассовая смена. Откройте смену и попробуйте снова.", + code = "cash-shift-required", + }); + } + // Привязываем чек к открытой смене. + sale.CashShiftId = openShift; + } + // Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже). if (sale.IsReturn) return await PostReturnAsync(sale, ct); diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 65265a7..296acaf 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -436,6 +436,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // Inventory builder.Services.AddScoped(); + // Sprint 33: модуль «Деньги» + builder.Services.AddScoped(); + // Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres // (тот же ConnectionString:Default). Запускаем фактический сервер только // когда приложение действительно работает (не в тестах): чтобы тестовые diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index 21b4f15..a2055af 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -1,4 +1,5 @@ using foodmarket.Domain.Catalog; +using foodmarket.Domain.Money; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Persistence; @@ -265,6 +266,10 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca ProductsView = true, SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, StocksView = true, + // Кладовщику деньги не положены — только видимость оплаченности + // приёмки через SuppliesView (payedSum в DTO). MoneyView=false + // здесь, этим флаг «кладовщик без доступа к деньгам» (простой + // режим, п.7 ТЗ модуля). }, }; var cashier = new EmployeeRole @@ -279,11 +284,40 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca StocksView = true, RetailSalesOperate = true, // RetailSalesRefund по умолчанию false — админ включит при необходимости + // Кассиру нужна возможность открыть/закрыть свою смену и + // вносить/выплачивать деньги в ней. Чужие смены и движения + // по банковским счетам ему недоступны (контроллер фильтрует). + CashShiftOperate = true, + MoneyView = true, }, }; db.EmployeeRoles.AddRange(admin, keeper, cashier); await db.SaveChangesAsync(ct); + + await SeedExpenseItemsAsync(db, orgId, ct); + } + + /// Системные статьи расходов для новой организации — пять по + /// умолчанию (из ТЗ модуля «Деньги»). Их можно переименовать, но + /// нельзя удалить (=true), чтобы + /// старые документы не остались с «висящим» FK. + private static async Task SeedExpenseItemsAsync(AppDbContext db, Guid orgId, CancellationToken ct) + { + var anyItem = await db.ExpenseItems.IgnoreQueryFilters() + .AnyAsync(e => e.OrganizationId == orgId, ct); + if (anyItem) return; + + var items = new[] + { + new ExpenseItem { OrganizationId = orgId, Name = "Закупка товара", Code = "PURCHASE", IsSystem = true, IsActive = true }, + new ExpenseItem { OrganizationId = orgId, Name = "Аренда", Code = "RENT", IsSystem = true, IsActive = true }, + new ExpenseItem { OrganizationId = orgId, Name = "Зарплата", Code = "SALARY", IsSystem = true, IsActive = true }, + new ExpenseItem { OrganizationId = orgId, Name = "Коммунальные", Code = "UTILITIES", IsSystem = true, IsActive = true }, + new ExpenseItem { OrganizationId = orgId, Name = "Прочее", Code = "OTHER", IsSystem = true, IsActive = true }, + }; + db.ExpenseItems.AddRange(items); + await db.SaveChangesAsync(ct); } /// Канонический набор 5 глобальных единиц измерения (Phase5c). diff --git a/src/food-market.application/Money/IMoneyService.cs b/src/food-market.application/Money/IMoneyService.cs new file mode 100644 index 0000000..5243f54 --- /dev/null +++ b/src/food-market.application/Money/IMoneyService.cs @@ -0,0 +1,69 @@ +using foodmarket.Domain.Money; + +namespace foodmarket.Application.Money; + +/// Единый API модуля «Деньги». Контроллеры дёргают только эти +/// методы — внутри сервиса сидит вся бизнес-логика: пересчёт +/// Supply.PayedSum, парная проводка переводов, расчёт expected +/// cash для смены, применение статусов. Все методы Serializable-safe +/// (транзакция внутри — одна на операцию). +public interface IMoneyService +{ + /// Провести готовый документ: смена Status Draft→Posted, + /// пересчёт PayedSum на связанных документах. Пустой список + /// Links допустим (аналог noClosingDocs=true). Для TransferOut + /// — атомарно создаёт/проводит парный TransferIn. + Task PostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct); + + /// Отменить проведение. Пересчёт PayedSum в сторону уменьшения. + /// После Unpost документ можно удалить или снова отредактировать и + /// провести заново. + Task UnpostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct); + + /// Открыть смену на точке. Падает 409 если на RetailPoint + /// уже есть открытая смена. + Task OpenShiftAsync(Guid retailPointId, Guid cashAccountId, + Guid? acquiringAccountId, decimal openingFloat, Guid cashierUserId, CancellationToken ct); + + /// Закрыть смену: вычислить snapshot'ы продаж/возвратов, + /// expected cash, разницу. Запрещено если уже закрыта. + Task CloseShiftAsync(Guid shiftId, decimal actualCash, + Guid closedByUserId, string? notes, CancellationToken ct); + + /// Добавить внесение/выплату внутри открытой смены. + Task AddDrawerOperationAsync(Guid shiftId, + CashDrawerDirection direction, decimal amount, string reason, + Guid byUserId, CancellationToken ct); +} + +public enum MoneyServiceStatus +{ + Ok = 0, + NotFound = 1, + AlreadyPosted = 2, + AlreadyDraft = 3, + ValidationFailed = 4, + Conflict = 5, + ShiftNotOpen = 6, + ShiftAlreadyOpen = 7, + OverpaidDocument = 8, + LinkedSumMismatch = 9, + WrongCurrency = 10, + AccountInactive = 11, +} + +public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null) +{ + public static MoneyServiceResult Ok() => new(MoneyServiceStatus.Ok); + public static MoneyServiceResult Fail(MoneyServiceStatus s, string code, string msg) + => new(s, code, msg); +} + +public record CashShiftOpenResult(MoneyServiceStatus Status, Guid? ShiftId, string? Number, + string? ErrorCode, string? Message); + +public record CashShiftCloseResult(MoneyServiceStatus Status, decimal ExpectedCash, + decimal CashDifference, string? ErrorCode, string? Message); + +public record CashDrawerOperationResult(MoneyServiceStatus Status, Guid? OperationId, + string? ErrorCode, string? Message); diff --git a/src/food-market.domain/Money/CashShift.cs b/src/food-market.domain/Money/CashShift.cs new file mode 100644 index 0000000..caa9ce6 --- /dev/null +++ b/src/food-market.domain/Money/CashShift.cs @@ -0,0 +1,118 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Money; + +/// Статус кассовой смены. От МойСклада берём идею, но упрощённо: +/// Open — ящик открыт, можно продавать; Closed — закрыт кассиром с указанием +/// фактических денег в ящике, пересчитан expected vs actual. Z-отчёт при +/// закрытии — просмотр и печать по этой записи. +public enum CashShiftStatus +{ + Open = 1, + Closed = 2, +} + +/// Розничная смена кассы — аналог МойСкладовского +/// retailshift. Одна открытая смена per : +/// повторный open на той же точке падает в 409. Все продажи этой точки +/// в периоде [OpenedAt, ClosedAt ?? now] привязываются к этой смене +/// (RetailSale.CashShiftId, добавляем в Phase2 миграции). +/// +/// При закрытии кассир вводит — фактическую +/// сумму в ящике. Expected считается из + +/// Σ cash-операций смены (продажи − возвраты + внесения − выплаты). +/// Разница в подсвечивается в UI; +/// отрицательная = недостача, положительная = излишек. Z-отчёт — просто +/// печатный вид этой записи + свои аггрегаты по картам/QR. +public class CashShift : TenantEntity, IVersionedEntity +{ + public uint Xmin { get; set; } + + public string Number { get; set; } = ""; + + public CashShiftStatus Status { get; set; } = CashShiftStatus.Open; + + /// Точка продаж, на которой открыта смена. + public Guid RetailPointId { get; set; } + public RetailPoint RetailPoint { get; set; } = null!; + + /// Счёт «касса» этой точки — на нём отражается физический нал + /// смены (внесения/выплаты, продажи наличными). Выбирается из + /// где Kind=Cash + StoreId совпадает с + /// RetailPoint.StoreId. Если несколько касс — кассир выбирает в + /// момент открытия. + public Guid CashAccountId { get; set; } + public MoneyAccount CashAccount { get; set; } = null!; + + /// Счёт «эквайринг» для карточной выручки. Опционально — + /// если магазин не берёт карты, смена работает только с наличными. + public Guid? AcquiringAccountId { get; set; } + public MoneyAccount? AcquiringAccount { get; set; } + + public Guid CashierUserId { get; set; } + + public DateTime OpenedAt { get; set; } = DateTime.UtcNow; + public DateTime? ClosedAt { get; set; } + public Guid? ClosedByUserId { get; set; } + + /// «Разменник» на начало смены — сумма наличных, которую + /// кассир положил в ящик перед первой продажей (например, 20 000₸ + /// разменными купюрами). Берётся из показателя предыдущей закрытой + /// смены, кассир может скорректировать. + public decimal OpeningFloat { get; set; } + + /// Снапшоты на момент закрытия. До Close все == 0. + public decimal SalesCash { get; set; } + public decimal SalesCard { get; set; } + public decimal ReturnsCash { get; set; } + public decimal ReturnsCard { get; set; } + public decimal DrawerIn { get; set; } // Σ внесений + public decimal DrawerOut { get; set; } // Σ выплат + public decimal ExpectedCash { get; set; } + + /// Фактическая сумма наличных в ящике при закрытии (вводит + /// кассир). Null пока смена открыта. + public decimal? ActualCash { get; set; } + + /// ActualCash − ExpectedCash. Знак: <0 — недостача, + /// >0 — излишек. Снапшот на момент закрытия. + public decimal CashDifference { get; set; } + + public string? Notes { get; set; } + + public ICollection DrawerOperations { get; set; } = new List(); +} + +/// Направление ящикового движения кассы. In — кассир положил +/// деньги в ящик («внесение», «разменять из сейфа»). Out — выдал +/// («сдача инкассатору», «выдал курьеру»). Логика расчёта смены +/// соответствует МойСкладовским retaildrawercashin/retaildrawercashout. +public enum CashDrawerDirection +{ + In = 1, + Out = 2, +} + +/// Внесение/выплата внутри смены — простая запись, которую может +/// сделать только тот кассир, у которого открыта смена, и только пока +/// она открыта. После Close CashShift становится неизменяемой — операции +/// привязаны намертво и попадают в Z-отчёт. +public class CashDrawerOperation : TenantEntity, IVersionedEntity +{ + public uint Xmin { get; set; } + + public Guid CashShiftId { get; set; } + public CashShift CashShift { get; set; } = null!; + + public CashDrawerDirection Direction { get; set; } + + public decimal Amount { get; set; } + + /// Причина — обязательный свободный текст («Размен из сейфа», + /// «Инкассация», «Выдал курьеру за доставку»). Пусто не принимаем. + public string Reason { get; set; } = null!; + + public DateTime At { get; set; } = DateTime.UtcNow; + public Guid ByUserId { get; set; } +} diff --git a/src/food-market.domain/Money/CounterpartyAdjustment.cs b/src/food-market.domain/Money/CounterpartyAdjustment.cs new file mode 100644 index 0000000..5168358 --- /dev/null +++ b/src/food-market.domain/Money/CounterpartyAdjustment.cs @@ -0,0 +1,45 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Money; + +/// Направление корректировки взаиморасчётов: WeOwe — мы должны +/// контрагенту (увеличивает долг нам), TheyOwe — контрагент должен нам +/// (уменьшает долг). Соответствует MoySkladовскому +/// counterpartyadjustment, где знак выражается через sum. +public enum CounterpartyAdjustmentDirection +{ + WeOwe = 1, + TheyOwe = 2, +} + +/// Корректировка взаиморасчётов — для стартовых долгов и ручных +/// исправлений. В простом режиме главное применение: «мы начинаем работать +/// в системе, но с поставщиком Х у нас уже есть долг 500 000₸». +/// +/// Проведённая корректировка влияет на отчёт «Долги поставщикам» (прибавляет +/// или вычитает), на баланс счетов НЕ влияет — это чисто учётный документ, +/// без денежного движения. +public class CounterpartyAdjustment : TenantEntity, IVersionedEntity +{ + public uint Xmin { get; set; } + + public string Number { get; set; } = ""; + + public DateTime Date { get; set; } = DateTime.UtcNow; + + public Guid CounterpartyId { get; set; } + public Counterparty Counterparty { get; set; } = null!; + + public CounterpartyAdjustmentDirection Direction { get; set; } + + public decimal Amount { get; set; } + + public string? Notes { get; set; } + + public DateTime? PostedAt { get; set; } + public Guid? PostedByUserId { get; set; } + + /// Проведено (учитывается в отчётах) или черновик. + public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft; +} diff --git a/src/food-market.domain/Money/ExpenseItem.cs b/src/food-market.domain/Money/ExpenseItem.cs new file mode 100644 index 0000000..4e3cafe --- /dev/null +++ b/src/food-market.domain/Money/ExpenseItem.cs @@ -0,0 +1,29 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Money; + +/// Статья расходов (у МойСклада — expenseItem). Привязывается +/// к расходным документам: paymentout, cashout. Сид создаёт стартовый набор +/// (Закупка товара, Аренда, Зарплата, Коммунальные, Прочее) с +/// =true — его переименовать можно, удалить нет. +/// +/// Магазин может добавить свои (например, «Инкассация», «Хоз-товары») — +/// у них =false, удаляются при условии «ни на одном +/// документе не используется» (валидатор в контроллере). +public class ExpenseItem : TenantEntity, IVersionedEntity +{ + public uint Xmin { get; set; } + + public string Name { get; set; } = null!; + + /// Для UI: короткий код (например, "PURCHASE", "RENT"). Нужен + /// в основном сидеру + для сверки с 1С/МойСкладом при синхронизации. + /// Пользователь может оставить пустым. + public string? Code { get; set; } + + public bool IsSystem { get; set; } + + public bool IsActive { get; set; } = true; + + public string? Notes { get; set; } +} diff --git a/src/food-market.domain/Money/MoneyAccount.cs b/src/food-market.domain/Money/MoneyAccount.cs new file mode 100644 index 0000000..cadfb54 --- /dev/null +++ b/src/food-market.domain/Money/MoneyAccount.cs @@ -0,0 +1,81 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Money; + +/// Что это за «место, где лежат деньги». У МойСклада наличные и +/// банковские счета — две разные сущности (cashbox-less для cash, +/// organizationAccount для безнала); у нас в простом режиме магазину +/// проще видеть единый справочник «Счета и кассы», поэтому различаем через +/// : +/// +/// Cash — физическая касса магазина (деньги в ящике ККМ). +/// Привязана к : деньги смены этой точки +/// отражаются именно на этом счёте. +/// Safe — сейф, обычно у директора/бухгалтера. Деньги попадают +/// сюда через инкассации «касса → сейф». +/// Bank — расчётный счёт юрлица (Kaspi, Halyk и т.п.). +/// На один магазин допускается несколько банковских счетов. +/// Acquiring — счёт эквайринга: куда банк зачисляет оплаты +/// картой с POS. Как правило отдельный от основного расчётного, с +/// задержкой перевода T+1 и своей комиссией. +/// +/// Начальный остаток фиксируется на дату +/// и считается «данностью» — все проведённые +/// движения прибавляются/вычитаются от него. Инвариант: +/// CurrentBalance = OpeningBalance + Σ MoneyTransactions.Signed. +/// Проверяется в MoneyReportsController. +public enum MoneyAccountKind +{ + Cash = 1, + Safe = 2, + Bank = 3, + Acquiring = 4, +} + +public class MoneyAccount : TenantEntity, IVersionedEntity +{ + public uint Xmin { get; set; } + + /// Отображаемое имя: «Касса магазина Абая», «Kaspi основной», + /// «Сейф директора». Уникальность — только в рамках организации + /// (разные магазины одной орги могут иметь «Касса»). + public string Name { get; set; } = null!; + + public MoneyAccountKind Kind { get; set; } + + /// Банк: только для и + /// . Для Cash/Safe — null. + public string? BankName { get; set; } + + /// Номер счёта. Для банк./эквайринга — IBAN или просто номер. + /// Опционален (магазин может не хотеть вводить). + public string? AccountNumber { get; set; } + + /// Привязка кассы к магазину. Для Bank/Safe обычно null (счета + /// юрлица, не магазина), для Cash — обязательна (нужна чтобы знать, + /// куда идёт выручка POS). Для Acquiring рекомендована, но не required. + public Guid? StoreId { get; set; } + public Store? Store { get; set; } + + public Guid CurrencyId { get; set; } + public Currency Currency { get; set; } = null!; + + /// Начальный остаток на дату старта учёта в системе. Задаётся + /// один раз при создании счёта; потом редактировать нельзя — иначе + /// расходится история (инвариант остатков будет врать). Если + /// действительно нужно переоткрыться — удалить все движения этого + /// счёта и пересоздать. + public decimal OpeningBalance { get; set; } + public DateTime OpeningDate { get; set; } = DateTime.UtcNow; + + /// Если false — счёт виден только для чтения отчёта и не + /// предлагается в выпадающих списках новых документов. Удалять не + /// даём (есть движения), только архивация флажком. + public bool IsActive { get; set; } = true; + + /// Удобный хинт для UI: по умолчанию выбирать именно этот + /// счёт при создании кассового/безнал. ордера. Один per + /// в орге. + public bool IsDefault { get; set; } +} diff --git a/src/food-market.domain/Money/MoneyTransaction.cs b/src/food-market.domain/Money/MoneyTransaction.cs new file mode 100644 index 0000000..860f90b --- /dev/null +++ b/src/food-market.domain/Money/MoneyTransaction.cs @@ -0,0 +1,135 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; +using foodmarket.Domain.Purchases; + +namespace foodmarket.Domain.Money; + +/// Тип финансового документа — объединяет МойСкладовские +/// paymentin, paymentout, cashin, cashout +/// в одну сущность с дискриминатором. +/// Переводы между своими счетами моделируем парой проводок +/// (TransferOut + TransferIn), соединённых . +public enum MoneyTransactionKind +{ + /// Приход безнала на банк. счёт (аналог paymentin). + PaymentIn = 1, + + /// Расход безнала с банк. счёта (аналог paymentout). + PaymentOut = 2, + + /// Приход наличных в кассу/сейф (аналог cashin). + CashIn = 3, + + /// Расход наличных из кассы/сейфа (аналог cashout). + CashOut = 4, + + /// Расходная половина перевода между своими счетами + /// (с ParentId → приходная половина). + TransferOut = 5, + + /// Приходная половина перевода. + TransferIn = 6, +} + +public enum MoneyTransactionStatus +{ + Draft = 0, + Posted = 1, +} + +/// Единый финансовый документ: платёж/ордер/перевод. У каждого +/// документа есть одна сторона () и знак: +/// In-варианты увеличивают баланс счёта, Out — уменьшают. +/// +/// Привязка к первичным документам (приёмкам/возвратам поставщика/продажам/ +/// возвратам покупателю) — через коллекцию , по образцу +/// МойСкладовских operations[linkedSum]. На одной транзакции может +/// быть N ссылок с суммами, разложенных по разным документам (частичная +/// оплата допустима). Σ Links.LinkedSum не должна превышать Amount, но +/// может быть меньше (кусок суммы «без закрытия документа» — аналог +/// MoySklad noClosingDocs). +/// +/// Проведённые транзакции (=Posted) неизменяемы; +/// отмена = отдельный флаг =Draft + пересчёт +/// Supply.PayedSum в сторону уменьшения, в одной транзакции +/// Postgres (см. MoneyService.Unpost). +public class MoneyTransaction : TenantEntity, IVersionedEntity +{ + public uint Xmin { get; set; } + + /// Человекочитаемый номер, уникальный в орге. Генерим по шаблону + /// Д-{year}-{seq6} (Д как «деньги»), общий счётчик на все виды + /// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли + /// в порядке создания, а не по трём разным счётчикам. + public string Number { get; set; } = ""; + + public DateTime Date { get; set; } = DateTime.UtcNow; + + public MoneyTransactionKind Kind { get; set; } + + public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft; + + /// Счёт/касса, с которой это движение (приход — зачисление сюда, + /// расход — списание отсюда). + public Guid AccountId { get; set; } + public MoneyAccount Account { get; set; } = null!; + + /// Контрагент: обязателен для PaymentIn/PaymentOut/CashIn/CashOut, + /// null для TransferIn/TransferOut (там контрагент — сами). + public Guid? CounterpartyId { get; set; } + public Counterparty? Counterparty { get; set; } + + /// Статья расходов — только для PaymentOut/CashOut. У приходных + /// документов и переводов — null. + public Guid? ExpenseItemId { get; set; } + public ExpenseItem? ExpenseItem { get; set; } + + public Guid CurrencyId { get; set; } + public Currency Currency { get; set; } = null!; + + /// Сумма документа. Положительная (знак определяется Kind). + public decimal Amount { get; set; } + + /// Назначение/основание платежа. У МойСклада обязательное + /// (`paymentPurpose`), но для простого магазина делаем необязательным — + /// кассир редко вписывает что-то кроме «За товар». + public string? Purpose { get; set; } + + /// Общий комментарий документа. + public string? Notes { get; set; } + + /// Для переводов — ссылка на парную половину (TransferOut↔TransferIn). + /// Проводятся всегда вместе, в одной БД-транзакции. Для всех остальных + /// Kind — null. + public Guid? LinkedTransactionId { get; set; } + public MoneyTransaction? LinkedTransaction { get; set; } + + public DateTime? PostedAt { get; set; } + public Guid? PostedByUserId { get; set; } + + public ICollection Links { get; set; } = new List(); +} + +/// Один ↔ один первичный документ с +/// распределённой суммой. У MoySklad это operations[].linkedSum. +/// +/// Для простого режима мы сейчас поддерживаем только привязку к +/// (приёмка) и (возврат +/// поставщику) — обе колонки nullable, ровно одна из них должна быть +/// заполнена; валидируется в MoneyService.PostAsync и +/// проверочным ограничением в БД. На Phase2 (расширенный режим) добавим +/// Demand/RetailSale/CustomerReturn — тогда появятся соответствующие +/// колонки, таблица расширится без миграции данных. +public class MoneyTransactionLink : TenantEntity +{ + public Guid TransactionId { get; set; } + public MoneyTransaction Transaction { get; set; } = null!; + + public Guid? SupplyId { get; set; } + public Supply? Supply { get; set; } + + public Guid? SupplierReturnId { get; set; } + public SupplierReturn? SupplierReturn { get; set; } + + public decimal LinkedSum { get; set; } +} diff --git a/src/food-market.domain/Organizations/RolePermissions.cs b/src/food-market.domain/Organizations/RolePermissions.cs index fbc0c99..53a4acc 100644 --- a/src/food-market.domain/Organizations/RolePermissions.cs +++ b/src/food-market.domain/Organizations/RolePermissions.cs @@ -55,6 +55,19 @@ public class RolePermissions public bool LoyaltyManage { get; set; } public bool PromotionsManage { get; set; } + // Sprint 33 (модуль «Деньги»): + // MoneyView — видеть счета, движения, отчёт «Деньги», долги. + // Кассир — да (видит выручку своей смены); Кладовщик — только через SuppliesView, + // увидит оплаченность приёмки, но не остатки по счетам. + // MoneyEdit — создавать/редактировать платежи, ордера, переводы, + // счета, статьи расходов, корректировки взаиморасчётов. + // Admin-only по умолчанию. + // CashShiftOperate — открыть/закрыть смену, вносить/выплачивать деньги + // в своей смене. Кассир — да. + public bool MoneyView { get; set; } + public bool MoneyEdit { get; set; } + public bool CashShiftOperate { get; set; } + /// Полный набор всех true — для системной роли «Администратор». public static RolePermissions All() => new() { @@ -70,5 +83,6 @@ public static RolePermissions All() => new() StoresManage = true, RetailPointsManage = true, CashRegistersManage = true, IntegrationsManage = true, LoyaltyManage = true, PromotionsManage = true, + MoneyView = true, MoneyEdit = true, CashShiftOperate = true, }; } diff --git a/src/food-market.domain/Purchases/SupplierReturn.cs b/src/food-market.domain/Purchases/SupplierReturn.cs index f6d6595..3756af3 100644 --- a/src/food-market.domain/Purchases/SupplierReturn.cs +++ b/src/food-market.domain/Purchases/SupplierReturn.cs @@ -39,6 +39,11 @@ public class SupplierReturn : TenantEntity public DateTime? PostedAt { get; set; } public Guid? PostedByUserId { get; set; } + /// Сколько поставщик вернул нам денег по этому возврату + /// (аналог ). Пересчитывает + /// MoneyService; инвариант 0 ≤ PayedSum ≤ Total. + public decimal PayedSum { get; set; } + public ICollection Lines { get; set; } = new List(); } diff --git a/src/food-market.domain/Purchases/Supply.cs b/src/food-market.domain/Purchases/Supply.cs index 5cea398..80cb32a 100644 --- a/src/food-market.domain/Purchases/Supply.cs +++ b/src/food-market.domain/Purchases/Supply.cs @@ -37,6 +37,14 @@ public class Supply : TenantEntity, IVersionedEntity public DateTime? PostedAt { get; set; } public Guid? PostedByUserId { get; set; } + /// Сколько из оплачено — сумма всех + /// проведённых MoneyTransactionLink.LinkedSum, где + /// SupplyId=этой приёмки. Пересчитывается в MoneyService + /// при проведении/отмене платежа. Инвариант: 0 ≤ PayedSum ≤ Total. + /// Статус оплаты получаем сравнением: PayedSum==0 — не оплачена, + /// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена. + public decimal PayedSum { get; set; } + public ICollection Lines { get; set; } = new List(); } diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs index 1b3339b..618476f 100644 --- a/src/food-market.domain/Sales/RetailSale.cs +++ b/src/food-market.domain/Sales/RetailSale.cs @@ -71,6 +71,14 @@ public class RetailSale : TenantEntity, IVersionedEntity public decimal PaidCash { get; set; } public decimal PaidCard { get; set; } + /// Смена, в рамках которой проведён чек. Обязателен для + /// POS-продаж на точке с живой кассой; null у исторических чеков + /// до внедрения модуля «Деньги» и у безсменных операций (bulk-импорт, + /// технический seed, тесты). Проверка на обязательность — в + /// RetailSalesController.Post: если CashShift на точке + /// не открыта, продажа отклоняется с кодом cash-shift-required. + public Guid? CashShiftId { get; set; } + public string? Notes { get; set; } public DateTime? PostedAt { get; set; } public Guid? PostedByUserId { get; set; } diff --git a/src/food-market.infrastructure/Money/MoneyNumbering.cs b/src/food-market.infrastructure/Money/MoneyNumbering.cs new file mode 100644 index 0000000..cd577ba --- /dev/null +++ b/src/food-market.infrastructure/Money/MoneyNumbering.cs @@ -0,0 +1,48 @@ +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Infrastructure.Money; + +/// Генераторы человекочитаемых номеров для документов модуля «Деньги». +/// Общий счётчик на все виды MoneyTransaction (чтобы в едином списке +/// «Платежи и ордера» документы шли по возрастанию); отдельный — для смен и +/// корректировок. Формат — префикс + год + 6-значный seq. +/// +/// Реализация прочтением MAX номера под advisory-lock'ом (сериализация внутри +/// одной орги). Для prod с десятками тысяч документов стоит переработать на +/// отдельную таблицу-счётчик, но для простого режима и ожидаемой нагрузки +/// такой подход экономит таблицу и работает корректно под Serializable. +public static class MoneyNumbering +{ + public static Task NextMoneyTxNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct) + => NextByPrefixAsync(db, "money_transactions", "Д", orgId, year, ct); + + public static Task NextShiftNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct) + => NextByPrefixAsync(db, "cash_shifts", "С", orgId, year, ct); + + public static Task NextAdjustmentNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct) + => NextByPrefixAsync(db, "counterparty_adjustments", "К", orgId, year, ct); + + private static async Task NextByPrefixAsync(AppDbContext db, string table, string prefix, + Guid orgId, int year, CancellationToken ct) + { + // Advisory-lock берём на пару (hash(orgId), hash(table + prefix)) чтобы + // concurrent-инсерты в разные таблицы одной орги не блокировали друг + // друга, но два потока в одну таблицу — сериализовались. + var key1 = unchecked((int)(orgId.GetHashCode())); + var key2 = (table + prefix).GetHashCode(); + await db.Database.ExecuteSqlRawAsync( + "SELECT pg_advisory_xact_lock({0}, {1})", key1, key2); + + var pattern = $"{prefix}-{year}-%"; + // table/prefix захардкожены в вызывающих местах — безопасно. + var sql = $@"SELECT COALESCE(MAX(CAST(SPLIT_PART(""Number"", '-', 3) AS int)), 0) AS ""Value"" + FROM public.{table} + WHERE ""OrganizationId"" = {{0}} AND ""Number"" LIKE {{1}}"; + var maxSeq = await db.Database + .SqlQueryRaw(sql, orgId, pattern) + .SingleAsync(ct); + var next = maxSeq + 1; + return $"{prefix}-{year}-{next:D6}"; + } +} diff --git a/src/food-market.infrastructure/Money/MoneyService.cs b/src/food-market.infrastructure/Money/MoneyService.cs new file mode 100644 index 0000000..fc03a06 --- /dev/null +++ b/src/food-market.infrastructure/Money/MoneyService.cs @@ -0,0 +1,311 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Application.Money; +using foodmarket.Domain.Money; +using foodmarket.Domain.Purchases; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Money; + +/// Единая реализация . Внутри — Serializable +/// транзакции на всю операцию (post/unpost/open/close/drawer); при конкуренте +/// Postgres падает в 40001, миддлварь (SerializationConflictMiddleware) +/// преобразует в 409. Любые изменения Supply.PayedSum / SupplierReturn.PayedSum +/// идут только отсюда — чтобы инвариант «PayedSum ∈ [0,Total]» был централизованно +/// защищён. +public sealed class MoneyService : IMoneyService +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly ILogger _log; + + public MoneyService(AppDbContext db, ITenantContext tenant, ILogger log) + { + _db = db; + _tenant = tenant; + _log = log; + } + + public async Task PostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct) + { + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + + var mt = await _db.MoneyTransactions + .Include(m => m.Account) + .Include(m => m.Links) + .ThenInclude(l => l.Supply) + .Include(m => m.Links) + .ThenInclude(l => l.SupplierReturn) + .FirstOrDefaultAsync(m => m.Id == transactionId, ct); + if (mt is null) + return MoneyServiceResult.Fail(MoneyServiceStatus.NotFound, "not-found", "Документ не найден."); + if (mt.Status == MoneyTransactionStatus.Posted) + return MoneyServiceResult.Fail(MoneyServiceStatus.AlreadyPosted, "already-posted", "Документ уже проведён."); + + // Валидация валюты счёта и документа. + if (mt.Account.CurrencyId != mt.CurrencyId) + return MoneyServiceResult.Fail(MoneyServiceStatus.WrongCurrency, "wrong-currency", + "Валюта документа не совпадает с валютой счёта."); + + if (!mt.Account.IsActive) + return MoneyServiceResult.Fail(MoneyServiceStatus.AccountInactive, "account-inactive", + "Счёт/касса архивированы — проводить движения по ним нельзя."); + + if (mt.Amount <= 0) + return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive", + "Сумма платежа должна быть больше нуля."); + + // Сумма связей не может превышать сумму документа. + var linkedTotal = mt.Links.Sum(l => l.LinkedSum); + if (linkedTotal > mt.Amount + 0.0001m) + return MoneyServiceResult.Fail(MoneyServiceStatus.LinkedSumMismatch, "linked-sum-exceeds-amount", + $"Сумма разноски ({linkedTotal:0.##}) больше суммы документа ({mt.Amount:0.##})."); + + // Пересчёт PayedSum на каждом целевом документе — защищён инвариантом. + foreach (var link in mt.Links) + { + if (link.SupplyId is Guid sId) + { + var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct); + var newPayed = supply.PayedSum + link.LinkedSum; + if (newPayed > supply.Total + 0.0001m) + return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supply-overpaid", + $"Приёмка {supply.Number} была бы переплачена: {newPayed:0.##} > {supply.Total:0.##}."); + supply.PayedSum = newPayed; + supply.UpdatedAt = DateTime.UtcNow; + } + else if (link.SupplierReturnId is Guid srId) + { + var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct); + var newPayed = sr.PayedSum + link.LinkedSum; + if (newPayed > sr.Total + 0.0001m) + return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supplier-return-overpaid", + $"Возврат поставщику {sr.Number} был бы переплачен: {newPayed:0.##} > {sr.Total:0.##}."); + sr.PayedSum = newPayed; + sr.UpdatedAt = DateTime.UtcNow; + } + } + + mt.Status = MoneyTransactionStatus.Posted; + mt.PostedAt = DateTime.UtcNow; + mt.PostedByUserId = currentUserId; + mt.UpdatedAt = DateTime.UtcNow; + + // Для TransferOut атомарно проводим парный TransferIn + if (mt.LinkedTransactionId is Guid linkedId && mt.Kind == MoneyTransactionKind.TransferOut) + { + var pair = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == linkedId, ct); + if (pair is not null && pair.Status == MoneyTransactionStatus.Draft) + { + pair.Status = MoneyTransactionStatus.Posted; + pair.PostedAt = mt.PostedAt; + pair.PostedByUserId = currentUserId; + pair.UpdatedAt = mt.UpdatedAt; + } + } + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + _log.LogInformation("MoneyTransaction {Number} posted by {UserId}", mt.Number, currentUserId); + return MoneyServiceResult.Ok(); + } + + public async Task UnpostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct) + { + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + + var mt = await _db.MoneyTransactions + .Include(m => m.Links) + .FirstOrDefaultAsync(m => m.Id == transactionId, ct); + if (mt is null) + return MoneyServiceResult.Fail(MoneyServiceStatus.NotFound, "not-found", "Документ не найден."); + if (mt.Status == MoneyTransactionStatus.Draft) + return MoneyServiceResult.Fail(MoneyServiceStatus.AlreadyDraft, "already-draft", "Документ уже черновик."); + + foreach (var link in mt.Links) + { + if (link.SupplyId is Guid sId) + { + var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct); + supply.PayedSum = Math.Max(0, supply.PayedSum - link.LinkedSum); + supply.UpdatedAt = DateTime.UtcNow; + } + else if (link.SupplierReturnId is Guid srId) + { + var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct); + sr.PayedSum = Math.Max(0, sr.PayedSum - link.LinkedSum); + sr.UpdatedAt = DateTime.UtcNow; + } + } + + mt.Status = MoneyTransactionStatus.Draft; + mt.PostedAt = null; + mt.PostedByUserId = null; + mt.UpdatedAt = DateTime.UtcNow; + + if (mt.LinkedTransactionId is Guid linkedId && mt.Kind == MoneyTransactionKind.TransferOut) + { + var pair = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == linkedId, ct); + if (pair is not null && pair.Status == MoneyTransactionStatus.Posted) + { + pair.Status = MoneyTransactionStatus.Draft; + pair.PostedAt = null; + pair.PostedByUserId = null; + pair.UpdatedAt = mt.UpdatedAt; + } + } + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + _log.LogInformation("MoneyTransaction {Number} unposted by {UserId}", mt.Number, currentUserId); + return MoneyServiceResult.Ok(); + } + + public async Task OpenShiftAsync(Guid retailPointId, Guid cashAccountId, + Guid? acquiringAccountId, decimal openingFloat, Guid cashierUserId, CancellationToken ct) + { + if (openingFloat < 0) + return new(MoneyServiceStatus.ValidationFailed, null, null, + "opening-float-negative", "Размен не может быть отрицательным."); + + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + + var existing = await _db.CashShifts.AnyAsync( + s => s.RetailPointId == retailPointId && s.Status == CashShiftStatus.Open, ct); + if (existing) + return new(MoneyServiceStatus.ShiftAlreadyOpen, null, null, + "shift-already-open", "На этой точке уже открыта смена."); + + var cash = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == cashAccountId, ct); + if (cash is null || cash.Kind != MoneyAccountKind.Cash) + return new(MoneyServiceStatus.ValidationFailed, null, null, + "cash-account-invalid", "Счёт для смены должен быть типа «Касса»."); + if (!cash.IsActive) + return new(MoneyServiceStatus.AccountInactive, null, null, + "account-inactive", "Касса архивирована."); + + if (acquiringAccountId is Guid aid) + { + var acq = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == aid, ct); + if (acq is null || acq.Kind != MoneyAccountKind.Acquiring) + return new(MoneyServiceStatus.ValidationFailed, null, null, + "acquiring-account-invalid", "Счёт эквайринга должен быть типа «Эквайринг»."); + if (!acq.IsActive) + return new(MoneyServiceStatus.AccountInactive, null, null, + "account-inactive", "Счёт эквайринга архивирован."); + } + + var orgId = _tenant.OrganizationId ?? Guid.Empty; + var now = DateTime.UtcNow; + var shift = new CashShift + { + OrganizationId = orgId, + Number = await MoneyNumbering.NextShiftNumberAsync(_db, orgId, now.Year, ct), + Status = CashShiftStatus.Open, + RetailPointId = retailPointId, + CashAccountId = cashAccountId, + AcquiringAccountId = acquiringAccountId, + CashierUserId = cashierUserId, + OpenedAt = now, + OpeningFloat = openingFloat, + }; + _db.CashShifts.Add(shift); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + _log.LogInformation("CashShift {Number} opened at {RetailPointId} by {CashierUserId}", + shift.Number, retailPointId, cashierUserId); + return new(MoneyServiceStatus.Ok, shift.Id, shift.Number, null, null); + } + + public async Task CloseShiftAsync(Guid shiftId, decimal actualCash, + Guid closedByUserId, string? notes, CancellationToken ct) + { + if (actualCash < 0) + return new(MoneyServiceStatus.ValidationFailed, 0, 0, + "actual-cash-negative", "Фактическая сумма наличных не может быть отрицательной."); + + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + + var shift = await _db.CashShifts + .Include(s => s.DrawerOperations) + .FirstOrDefaultAsync(s => s.Id == shiftId, ct); + if (shift is null) + return new(MoneyServiceStatus.NotFound, 0, 0, "not-found", "Смена не найдена."); + if (shift.Status == CashShiftStatus.Closed) + return new(MoneyServiceStatus.ShiftNotOpen, shift.ExpectedCash, shift.CashDifference, + "shift-already-closed", "Смена уже закрыта."); + + // Снапшоты продаж/возвратов в периоде смены (используем Id'шник + // смены на RetailSale). + var sales = await _db.RetailSales + .Where(s => s.CashShiftId == shift.Id && s.Status == RetailSaleStatus.Posted) + .ToListAsync(ct); + var salesCash = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCash); + var salesCard = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCard); + var returnsCash = sales.Where(s => s.IsReturn).Sum(s => s.PaidCash); + var returnsCard = sales.Where(s => s.IsReturn).Sum(s => s.PaidCard); + var drawerIn = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount); + var drawerOut = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount); + var expected = shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut; + + shift.SalesCash = salesCash; + shift.SalesCard = salesCard; + shift.ReturnsCash = returnsCash; + shift.ReturnsCard = returnsCard; + shift.DrawerIn = drawerIn; + shift.DrawerOut = drawerOut; + shift.ExpectedCash = expected; + shift.ActualCash = actualCash; + shift.CashDifference = actualCash - expected; + shift.Status = CashShiftStatus.Closed; + shift.ClosedAt = DateTime.UtcNow; + shift.ClosedByUserId = closedByUserId; + shift.Notes = notes; + shift.UpdatedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + _log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}", + shift.Number, closedByUserId, expected, actualCash, shift.CashDifference); + return new(MoneyServiceStatus.Ok, expected, shift.CashDifference, null, null); + } + + public async Task AddDrawerOperationAsync(Guid shiftId, + CashDrawerDirection direction, decimal amount, string reason, + Guid byUserId, CancellationToken ct) + { + if (amount <= 0) + return new(MoneyServiceStatus.ValidationFailed, null, + "amount-must-be-positive", "Сумма должна быть больше нуля."); + if (string.IsNullOrWhiteSpace(reason)) + return new(MoneyServiceStatus.ValidationFailed, null, + "reason-required", "Причина обязательна."); + + var shift = await _db.CashShifts.FirstOrDefaultAsync(s => s.Id == shiftId, ct); + if (shift is null) + return new(MoneyServiceStatus.NotFound, null, "not-found", "Смена не найдена."); + if (shift.Status != CashShiftStatus.Open) + return new(MoneyServiceStatus.ShiftNotOpen, null, + "shift-not-open", "Смена закрыта — добавлять операции нельзя."); + + var op = new CashDrawerOperation + { + OrganizationId = shift.OrganizationId, + CashShiftId = shift.Id, + Direction = direction, + Amount = amount, + Reason = reason.Trim(), + At = DateTime.UtcNow, + ByUserId = byUserId, + }; + _db.CashDrawerOperations.Add(op); + await _db.SaveChangesAsync(ct); + return new(MoneyServiceStatus.Ok, op.Id, null, null); + } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 93448da..8d49aef 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -2,6 +2,7 @@ using foodmarket.Domain.Catalog; using foodmarket.Domain.Common; using foodmarket.Domain.Inventory; +using foodmarket.Domain.Money; using foodmarket.Domain.Purchases; using foodmarket.Domain.Sales; using foodmarket.Infrastructure.Identity; @@ -82,6 +83,15 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet UserPresets => Set(); public DbSet OrgExports => Set(); + // Sprint 33: модуль «Деньги» — простой режим. + public DbSet MoneyAccounts => Set(); + public DbSet ExpenseItems => Set(); + public DbSet MoneyTransactions => Set(); + public DbSet MoneyTransactionLinks => Set(); + public DbSet CashShifts => Set(); + public DbSet CashDrawerOperations => Set(); + public DbSet CounterpartyAdjustments => Set(); + /// Если true — не пишет audit-строки /// для этого SaveChanges. Используется сидерами/миграциями, фоновыми /// импортами (где tenant-context отсутствует) и при ручной чистке логов. @@ -200,6 +210,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ConfigurePurchases(); builder.ConfigureSales(); builder.ConfigureOrganizationsHr(); + builder.ConfigureMoney(); // Apply multi-tenant query filter to every entity that implements ITenantEntity. // IOptionalTenantEntity (системные справочники с nullable OrganizationId) — diff --git a/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs new file mode 100644 index 0000000..227534a --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Configurations/MoneyConfigurations.cs @@ -0,0 +1,176 @@ +using foodmarket.Domain.Money; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Infrastructure.Persistence.Configurations; + +/// Схема таблиц модуля «Деньги». Названия колонок/таблиц +/// подчинены общему стилю (snake_case через ToTable + EF-camel для свойств); +/// суммовые — decimal(18,4); концуррентный токен — Postgres xmin. +public static class MoneyConfigurations +{ + public static void ConfigureMoney(this ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("money_accounts"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); + e.Property(x => x.Name).HasMaxLength(200).IsRequired(); + e.Property(x => x.BankName).HasMaxLength(200); + e.Property(x => x.AccountNumber).HasMaxLength(100); + e.Property(x => x.OpeningBalance).HasPrecision(18, 4); + + e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict); + + e.HasIndex(x => new { x.OrganizationId, x.Name }); + e.HasIndex(x => new { x.OrganizationId, x.Kind, x.IsActive }); + // IsDefault — на уровне контроллера проверяем «только один per Kind»: + // составной уникальный индекс с фильтром IsDefault=true. + e.HasIndex(x => new { x.OrganizationId, x.Kind }) + .IsUnique() + .HasFilter("\"IsDefault\" = true"); + }); + + b.Entity(e => + { + e.ToTable("expense_items"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); + e.Property(x => x.Name).HasMaxLength(200).IsRequired(); + e.Property(x => x.Code).HasMaxLength(50); + e.Property(x => x.Notes).HasMaxLength(1000); + e.HasIndex(x => new { x.OrganizationId, x.Name }).IsUnique(); + e.HasIndex(x => new { x.OrganizationId, x.Code }) + .IsUnique() + .HasFilter("\"Code\" IS NOT NULL"); + }); + + b.Entity(e => + { + e.ToTable("money_transactions"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); + e.Property(x => x.Number).HasMaxLength(50).IsRequired(); + e.Property(x => x.Amount).HasPrecision(18, 4); + e.Property(x => x.Purpose).HasMaxLength(500); + e.Property(x => x.Notes).HasMaxLength(1000); + + e.HasOne(x => x.Account).WithMany().HasForeignKey(x => x.AccountId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Counterparty).WithMany().HasForeignKey(x => x.CounterpartyId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.ExpenseItem).WithMany().HasForeignKey(x => x.ExpenseItemId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.LinkedTransaction).WithMany() + .HasForeignKey(x => x.LinkedTransactionId).OnDelete(DeleteBehavior.Restrict); + + e.HasMany(x => x.Links).WithOne(l => l.Transaction) + .HasForeignKey(l => l.TransactionId).OnDelete(DeleteBehavior.Cascade); + + e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique(); + e.HasIndex(x => new { x.OrganizationId, x.Date }); + e.HasIndex(x => new { x.OrganizationId, x.Kind, x.Status }); + e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date }); + e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId }) + .HasFilter("\"CounterpartyId\" IS NOT NULL"); + }); + + b.Entity(e => + { + e.ToTable("money_transaction_links"); + e.Property(x => x.LinkedSum).HasPrecision(18, 4); + + e.HasOne(x => x.Supply).WithMany().HasForeignKey(x => x.SupplyId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.SupplierReturn).WithMany().HasForeignKey(x => x.SupplierReturnId).OnDelete(DeleteBehavior.Restrict); + + // CHECK: ровно одна целевая ссылка должна быть заполнена. + e.ToTable(t => t.HasCheckConstraint( + "ck_money_link_single_target", + "((\"SupplyId\" IS NOT NULL)::int + (\"SupplierReturnId\" IS NOT NULL)::int) = 1")); + + e.HasIndex(x => new { x.OrganizationId, x.SupplyId }) + .HasFilter("\"SupplyId\" IS NOT NULL"); + e.HasIndex(x => new { x.OrganizationId, x.SupplierReturnId }) + .HasFilter("\"SupplierReturnId\" IS NOT NULL"); + e.HasIndex(x => x.TransactionId); + }); + + b.Entity(e => + { + e.ToTable("cash_shifts"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); + e.Property(x => x.Number).HasMaxLength(50).IsRequired(); + e.Property(x => x.OpeningFloat).HasPrecision(18, 4); + e.Property(x => x.SalesCash).HasPrecision(18, 4); + e.Property(x => x.SalesCard).HasPrecision(18, 4); + e.Property(x => x.ReturnsCash).HasPrecision(18, 4); + e.Property(x => x.ReturnsCard).HasPrecision(18, 4); + e.Property(x => x.DrawerIn).HasPrecision(18, 4); + e.Property(x => x.DrawerOut).HasPrecision(18, 4); + e.Property(x => x.ExpectedCash).HasPrecision(18, 4); + e.Property(x => x.ActualCash).HasPrecision(18, 4); + e.Property(x => x.CashDifference).HasPrecision(18, 4); + e.Property(x => x.Notes).HasMaxLength(1000); + + e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.CashAccount).WithMany().HasForeignKey(x => x.CashAccountId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.AcquiringAccount).WithMany().HasForeignKey(x => x.AcquiringAccountId).OnDelete(DeleteBehavior.Restrict); + + e.HasMany(x => x.DrawerOperations).WithOne(d => d.CashShift) + .HasForeignKey(d => d.CashShiftId).OnDelete(DeleteBehavior.Cascade); + + e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique(); + e.HasIndex(x => new { x.OrganizationId, x.OpenedAt }); + e.HasIndex(x => new { x.OrganizationId, x.RetailPointId, x.Status }); + // Только одна открытая смена per RetailPoint. + e.HasIndex(x => new { x.OrganizationId, x.RetailPointId }) + .IsUnique() + .HasFilter("\"Status\" = 1"); + }); + + b.Entity(e => + { + e.ToTable("cash_drawer_operations"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); + e.Property(x => x.Amount).HasPrecision(18, 4); + e.Property(x => x.Reason).HasMaxLength(500).IsRequired(); + e.HasIndex(x => new { x.OrganizationId, x.CashShiftId }); + e.HasIndex(x => new { x.OrganizationId, x.At }); + }); + + b.Entity(e => + { + e.ToTable("counterparty_adjustments"); + e.UseXminAsConcurrencyToken(); + e.Ignore(x => x.Xmin); + e.Property(x => x.Number).HasMaxLength(50).IsRequired(); + e.Property(x => x.Amount).HasPrecision(18, 4); + e.Property(x => x.Notes).HasMaxLength(1000); + + e.HasOne(x => x.Counterparty).WithMany().HasForeignKey(x => x.CounterpartyId).OnDelete(DeleteBehavior.Restrict); + + e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique(); + e.HasIndex(x => new { x.OrganizationId, x.Date }); + e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId, x.Status }); + }); + + // Расширения существующих таблиц: + b.Entity(e => + { + e.Property(x => x.PayedSum).HasPrecision(18, 4); + e.HasIndex(x => new { x.OrganizationId, x.SupplierId, x.Status, x.PayedSum }); + }); + + b.Entity(e => + { + e.Property(x => x.PayedSum).HasPrecision(18, 4); + }); + + b.Entity(e => + { + e.HasIndex(x => new { x.OrganizationId, x.CashShiftId }) + .HasFilter("\"CashShiftId\" IS NOT NULL"); + }); + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase33a_Money.cs b/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase33a_Money.cs new file mode 100644 index 0000000..3c36c34 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase33a_Money.cs @@ -0,0 +1,233 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase33a — модуль «Деньги» для простого режима. + /// + /// Создаёт все таблицы модуля (money_accounts, expense_items, + /// money_transactions, money_transaction_links, cash_shifts, + /// cash_drawer_operations, counterparty_adjustments) и расширяет: + /// + /// + /// supplies — колонка PayedSum (NUMERIC(18,4) DEFAULT 0), + /// с индексом по (OrganizationId, SupplierId, Status, PayedSum). + /// supplier_returns — колонка PayedSum. + /// retail_sales — колонка CashShiftId (nullable uuid) + /// с условным индексом WHERE CashShiftId IS NOT NULL. + /// + /// + /// Идемпотентно (IF NOT EXISTS везде); backfilling PayedSum=0 + /// корректен, т.к. до этой фазы ни одна приёмка не могла быть оплачена + /// через модуль Денег. + [DbContext(typeof(AppDbContext))] + [Migration("20261007100000_Phase33a_Money")] + public partial class Phase33a_Money : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + -- ─── Счета и кассы ──────────────────────────────────────── + CREATE TABLE IF NOT EXISTS public.money_accounts ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Name"" varchar(200) NOT NULL, + ""Kind"" integer NOT NULL, + ""BankName"" varchar(200), + ""AccountNumber"" varchar(100), + ""StoreId"" uuid, + ""CurrencyId"" uuid NOT NULL, + ""OpeningBalance"" numeric(18,4) NOT NULL DEFAULT 0, + ""OpeningDate"" timestamp with time zone NOT NULL, + ""IsActive"" boolean NOT NULL DEFAULT true, + ""IsDefault"" boolean NOT NULL DEFAULT false, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_money_accounts_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_money_accounts_currencies_CurrencyId"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT + ); + CREATE INDEX IF NOT EXISTS ""IX_money_accounts_OrganizationId_Name"" ON public.money_accounts (""OrganizationId"", ""Name""); + CREATE INDEX IF NOT EXISTS ""IX_money_accounts_OrganizationId_Kind_IsActive"" ON public.money_accounts (""OrganizationId"", ""Kind"", ""IsActive""); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_money_accounts_OrganizationId_Kind_default"" + ON public.money_accounts (""OrganizationId"", ""Kind"") WHERE ""IsDefault"" = true; + + -- ─── Статьи расходов ────────────────────────────────────── + CREATE TABLE IF NOT EXISTS public.expense_items ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Name"" varchar(200) NOT NULL, + ""Code"" varchar(50), + ""IsSystem"" boolean NOT NULL DEFAULT false, + ""IsActive"" boolean NOT NULL DEFAULT true, + ""Notes"" varchar(1000), + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone + ); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_expense_items_OrganizationId_Name"" ON public.expense_items (""OrganizationId"", ""Name""); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_expense_items_OrganizationId_Code"" + ON public.expense_items (""OrganizationId"", ""Code"") WHERE ""Code"" IS NOT NULL; + + -- ─── Денежные документы ─────────────────────────────────── + CREATE TABLE IF NOT EXISTS public.money_transactions ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Number"" varchar(50) NOT NULL, + ""Date"" timestamp with time zone NOT NULL, + ""Kind"" integer NOT NULL, + ""Status"" integer NOT NULL, + ""AccountId"" uuid NOT NULL, + ""CounterpartyId"" uuid, + ""ExpenseItemId"" uuid, + ""CurrencyId"" uuid NOT NULL, + ""Amount"" numeric(18,4) NOT NULL, + ""Purpose"" varchar(500), + ""Notes"" varchar(1000), + ""LinkedTransactionId"" uuid, + ""PostedAt"" timestamp with time zone, + ""PostedByUserId"" uuid, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_money_transactions_accounts"" FOREIGN KEY (""AccountId"") REFERENCES public.money_accounts(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_money_transactions_counterparty"" FOREIGN KEY (""CounterpartyId"") REFERENCES public.counterparties(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_money_transactions_expense_item"" FOREIGN KEY (""ExpenseItemId"") REFERENCES public.expense_items(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_money_transactions_currency"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_money_transactions_linked"" FOREIGN KEY (""LinkedTransactionId"") REFERENCES public.money_transactions(""Id"") ON DELETE RESTRICT + ); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_Number"" ON public.money_transactions (""OrganizationId"", ""Number""); + CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_Date"" ON public.money_transactions (""OrganizationId"", ""Date""); + CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_Kind_Status"" ON public.money_transactions (""OrganizationId"", ""Kind"", ""Status""); + CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_AccountId_Date"" ON public.money_transactions (""OrganizationId"", ""AccountId"", ""Date""); + CREATE INDEX IF NOT EXISTS ""IX_money_transactions_OrganizationId_CounterpartyId"" + ON public.money_transactions (""OrganizationId"", ""CounterpartyId"") WHERE ""CounterpartyId"" IS NOT NULL; + + CREATE TABLE IF NOT EXISTS public.money_transaction_links ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""TransactionId"" uuid NOT NULL, + ""SupplyId"" uuid, + ""SupplierReturnId"" uuid, + ""LinkedSum"" numeric(18,4) NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_money_links_transaction"" FOREIGN KEY (""TransactionId"") REFERENCES public.money_transactions(""Id"") ON DELETE CASCADE, + CONSTRAINT ""FK_money_links_supply"" FOREIGN KEY (""SupplyId"") REFERENCES public.supplies(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_money_links_supplier_return"" FOREIGN KEY (""SupplierReturnId"") REFERENCES public.supplier_returns(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""ck_money_link_single_target"" CHECK ( + ((""SupplyId"" IS NOT NULL)::int + (""SupplierReturnId"" IS NOT NULL)::int) = 1 + ) + ); + CREATE INDEX IF NOT EXISTS ""IX_money_links_TransactionId"" ON public.money_transaction_links (""TransactionId""); + CREATE INDEX IF NOT EXISTS ""IX_money_links_OrganizationId_SupplyId"" + ON public.money_transaction_links (""OrganizationId"", ""SupplyId"") WHERE ""SupplyId"" IS NOT NULL; + CREATE INDEX IF NOT EXISTS ""IX_money_links_OrganizationId_SupplierReturnId"" + ON public.money_transaction_links (""OrganizationId"", ""SupplierReturnId"") WHERE ""SupplierReturnId"" IS NOT NULL; + + -- ─── Смены кассы ────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS public.cash_shifts ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Number"" varchar(50) NOT NULL, + ""Status"" integer NOT NULL, + ""RetailPointId"" uuid NOT NULL, + ""CashAccountId"" uuid NOT NULL, + ""AcquiringAccountId"" uuid, + ""CashierUserId"" uuid NOT NULL, + ""OpenedAt"" timestamp with time zone NOT NULL, + ""ClosedAt"" timestamp with time zone, + ""ClosedByUserId"" uuid, + ""OpeningFloat"" numeric(18,4) NOT NULL DEFAULT 0, + ""SalesCash"" numeric(18,4) NOT NULL DEFAULT 0, + ""SalesCard"" numeric(18,4) NOT NULL DEFAULT 0, + ""ReturnsCash"" numeric(18,4) NOT NULL DEFAULT 0, + ""ReturnsCard"" numeric(18,4) NOT NULL DEFAULT 0, + ""DrawerIn"" numeric(18,4) NOT NULL DEFAULT 0, + ""DrawerOut"" numeric(18,4) NOT NULL DEFAULT 0, + ""ExpectedCash"" numeric(18,4) NOT NULL DEFAULT 0, + ""ActualCash"" numeric(18,4), + ""CashDifference"" numeric(18,4) NOT NULL DEFAULT 0, + ""Notes"" varchar(1000), + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_cash_shifts_retail_point"" FOREIGN KEY (""RetailPointId"") REFERENCES public.retail_points(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_cash_shifts_cash_account"" FOREIGN KEY (""CashAccountId"") REFERENCES public.money_accounts(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_cash_shifts_acquiring_account"" FOREIGN KEY (""AcquiringAccountId"") REFERENCES public.money_accounts(""Id"") ON DELETE RESTRICT + ); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_cash_shifts_OrganizationId_Number"" ON public.cash_shifts (""OrganizationId"", ""Number""); + CREATE INDEX IF NOT EXISTS ""IX_cash_shifts_OrganizationId_OpenedAt"" ON public.cash_shifts (""OrganizationId"", ""OpenedAt""); + CREATE INDEX IF NOT EXISTS ""IX_cash_shifts_OrganizationId_RetailPointId_Status"" ON public.cash_shifts (""OrganizationId"", ""RetailPointId"", ""Status""); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_cash_shifts_single_open_per_point"" + ON public.cash_shifts (""OrganizationId"", ""RetailPointId"") WHERE ""Status"" = 1; + + CREATE TABLE IF NOT EXISTS public.cash_drawer_operations ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""CashShiftId"" uuid NOT NULL, + ""Direction"" integer NOT NULL, + ""Amount"" numeric(18,4) NOT NULL, + ""Reason"" varchar(500) NOT NULL, + ""At"" timestamp with time zone NOT NULL, + ""ByUserId"" uuid NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_drawer_ops_cash_shift"" FOREIGN KEY (""CashShiftId"") REFERENCES public.cash_shifts(""Id"") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS ""IX_drawer_ops_OrganizationId_CashShiftId"" ON public.cash_drawer_operations (""OrganizationId"", ""CashShiftId""); + CREATE INDEX IF NOT EXISTS ""IX_drawer_ops_OrganizationId_At"" ON public.cash_drawer_operations (""OrganizationId"", ""At""); + + -- ─── Корректировки взаиморасчётов ───────────────────────── + CREATE TABLE IF NOT EXISTS public.counterparty_adjustments ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Number"" varchar(50) NOT NULL, + ""Date"" timestamp with time zone NOT NULL, + ""CounterpartyId"" uuid NOT NULL, + ""Direction"" integer NOT NULL, + ""Amount"" numeric(18,4) NOT NULL, + ""Notes"" varchar(1000), + ""PostedAt"" timestamp with time zone, + ""PostedByUserId"" uuid, + ""Status"" integer NOT NULL DEFAULT 0, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_cp_adj_counterparty"" FOREIGN KEY (""CounterpartyId"") REFERENCES public.counterparties(""Id"") ON DELETE RESTRICT + ); + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_cp_adj_OrganizationId_Number"" ON public.counterparty_adjustments (""OrganizationId"", ""Number""); + CREATE INDEX IF NOT EXISTS ""IX_cp_adj_OrganizationId_Date"" ON public.counterparty_adjustments (""OrganizationId"", ""Date""); + CREATE INDEX IF NOT EXISTS ""IX_cp_adj_OrganizationId_CounterpartyId_Status"" ON public.counterparty_adjustments (""OrganizationId"", ""CounterpartyId"", ""Status""); + + -- ─── Расширение Supply/SupplierReturn/RetailSale ────────── + ALTER TABLE public.supplies + ADD COLUMN IF NOT EXISTS ""PayedSum"" numeric(18,4) NOT NULL DEFAULT 0; + CREATE INDEX IF NOT EXISTS ""IX_supplies_OrganizationId_SupplierId_Status_PayedSum"" + ON public.supplies (""OrganizationId"", ""SupplierId"", ""Status"", ""PayedSum""); + + ALTER TABLE public.supplier_returns + ADD COLUMN IF NOT EXISTS ""PayedSum"" numeric(18,4) NOT NULL DEFAULT 0; + + ALTER TABLE public.retail_sales + ADD COLUMN IF NOT EXISTS ""CashShiftId"" uuid; + CREATE INDEX IF NOT EXISTS ""IX_retail_sales_OrganizationId_CashShiftId"" + ON public.retail_sales (""OrganizationId"", ""CashShiftId"") WHERE ""CashShiftId"" IS NOT NULL; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.retail_sales DROP COLUMN IF EXISTS ""CashShiftId""; + ALTER TABLE public.supplier_returns DROP COLUMN IF EXISTS ""PayedSum""; + ALTER TABLE public.supplies DROP COLUMN IF EXISTS ""PayedSum""; + DROP TABLE IF EXISTS public.cash_drawer_operations; + DROP TABLE IF EXISTS public.cash_shifts; + DROP TABLE IF EXISTS public.counterparty_adjustments; + DROP TABLE IF EXISTS public.money_transaction_links; + DROP TABLE IF EXISTS public.money_transactions; + DROP TABLE IF EXISTS public.expense_items; + DROP TABLE IF EXISTS public.money_accounts; + "); + } + } +}