feat(money): бэкенд модуля «Деньги» для простого режима (пп.0–6)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Сверка с МойСкладом (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 <noreply@anthropic.com>
This commit is contained in:
parent
e45600df4b
commit
a25b5b3277
49
docs/money-module-progress.md
Normal file
49
docs/money-module-progress.md
Normal file
|
|
@ -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-рендер нужен).
|
||||
257
docs/money-moysklad-reference.md
Normal file
257
docs/money-moysklad-reference.md
Normal file
|
|
@ -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` — не нужно, у магазина нет
|
||||
документооборота с банком.
|
||||
|
||||
Эти поля не создаём в простом режиме, но резервируем места в именованиях
|
||||
колонок на будущее.
|
||||
206
src/food-market.api/Controllers/Money/CashShiftsController.cs
Normal file
206
src/food-market.api/Controllers/Money/CashShiftsController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Смены кассы — открыть/закрыть + внесения/выплаты. Z-отчёт
|
||||
/// получаем через <c>GET /{id}/report</c>, который возвращает всё в
|
||||
/// печатаемом виде (суммы продаж/возвратов, нал/карта, разница).
|
||||
///
|
||||
/// Если текущий пользователь — не Admin, он видит только свои смены;
|
||||
/// если Admin — все. Это закреплено в контроллере, а не в query-filter'е
|
||||
/// (нужна отдельная логика «моя смена» для кассира).</summary>
|
||||
[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<ActionResult<PagedResult<CashShiftRow>>> 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<uint>(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<CashShiftRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
|
||||
public async Task<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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 });
|
||||
}
|
||||
|
||||
/// <summary>Z-отчёт: все цифры смены + внесения/выплаты для печати.</summary>
|
||||
[HttpGet("{id:guid}/report"), RequiresPermission("MoneyView")]
|
||||
public async Task<ActionResult<object>> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>Корректировки взаиморасчётов — стартовый долг или ручное исправление.
|
||||
/// Проведение влияет на отчёт долгов (SupplierDebtsController), на баланс счетов — нет.</summary>
|
||||
[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<ActionResult<PagedResult<AdjustmentRow>>> 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<uint>(x, "xmin")))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<AdjustmentRow> { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("MoneyEdit")]
|
||||
public async Task<ActionResult<AdjustmentRow>> 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<ActionResult<AdjustmentRow>> 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<ActionResult<AdjustmentRow>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<AdjustmentRow?> 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<uint>(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;
|
||||
}
|
||||
}
|
||||
125
src/food-market.api/Controllers/Money/ExpenseItemsController.cs
Normal file
125
src/food-market.api/Controllers/Money/ExpenseItemsController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Справочник статей расходов. Системные (<c>IsSystem=true</c>) —
|
||||
/// переименовать можно, удалить нет. Остальные — удалить можно, если ни
|
||||
/// один <see cref="MoneyTransaction"/> не ссылается.</summary>
|
||||
[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<ActionResult<PagedResult<ExpenseItemRow>>> 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<uint>(x, "xmin")))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<ExpenseItemRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("MoneyEdit")]
|
||||
public async Task<ActionResult<ExpenseItemRow>> 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<ActionResult<ExpenseItemRow>> 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<ActionResult<ExpenseItemRow>> 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<IActionResult> 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);
|
||||
}
|
||||
290
src/food-market.api/Controllers/Money/MoneyAccountsController.cs
Normal file
290
src/food-market.api/Controllers/Money/MoneyAccountsController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Справочник «Счета и кассы» — CRUD. На любой счёт, у которого
|
||||
/// есть хотя бы одно движение (<c>money_transactions.AccountId</c>) или
|
||||
/// открытая смена (<c>cash_shifts.CashAccountId</c>), DELETE запрещён —
|
||||
/// возвращаем 409 c кодом <c>account-in-use</c>; просим архивировать
|
||||
/// (<c>IsActive=false</c>).</summary>
|
||||
[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<ActionResult<PagedResult<MoneyAccountRow>>> 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<uint>(x.a, "xmin"));
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<MoneyAccountRow>
|
||||
{
|
||||
Items = rows,
|
||||
Total = total,
|
||||
Page = req.Page,
|
||||
PageSize = req.Take,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("MoneyEdit")]
|
||||
public async Task<ActionResult<MoneyAccountRow>> 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<ActionResult<MoneyAccountRow>> 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<ActionResult<MoneyAccountRow>> 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<MoneyAccountRow>)NotFound();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")]
|
||||
public async Task<IActionResult> 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<MoneyAccountRow?> 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<uint>(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);
|
||||
}
|
||||
}
|
||||
188
src/food-market.api/Controllers/Money/MoneyReportsController.cs
Normal file
188
src/food-market.api/Controllers/Money/MoneyReportsController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Отчёты модуля «Деньги»: остатки по счетам, движение за период,
|
||||
/// долги поставщикам. По образцу МойСклад <c>/report/money/plotseries</c> и
|
||||
/// <c>/report/counterparty</c> (упрощённо).</summary>
|
||||
[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<AccountBalanceRow> Accounts, decimal TotalByCurrencySum);
|
||||
|
||||
[HttpGet("balances"), RequiresPermission("MoneyView")]
|
||||
public async Task<ActionResult<BalancesReport>> 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<MoneyPoint> Series);
|
||||
|
||||
/// <summary>Движение денежных средств за период (аналог
|
||||
/// <c>/report/money/plotseries</c>). Интервал — day/month.</summary>
|
||||
[HttpGet("movement"), RequiresPermission("MoneyView")]
|
||||
public async Task<ActionResult<MoneyMovementReport>> 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<DateTime, DateTime> 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<SupplierDebtRow> Rows, decimal TotalDebt);
|
||||
|
||||
/// <summary>Долги поставщикам: Σ приёмок − Σ возвратов − Σ оплат + Σ корректировок.
|
||||
/// Расчёт в C#, т.к. нужны джойны + aggregate'ы, которые EF транслирует неоптимально.</summary>
|
||||
[HttpGet("supplier-debts"), RequiresPermission("MoneyView")]
|
||||
public async Task<ActionResult<SupplierDebtsReport>> 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<SupplierDebtRow>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>Платежи и ордера (единый reпозит на все виды <see cref="MoneyTransactionKind"/>
|
||||
/// кроме «ящиковых» внутри смены — те через смену).
|
||||
///
|
||||
/// Создание — <c>POST /</c>, где <c>Kind</c> определяет тип документа.
|
||||
/// Переводы между своими счетами делаются одним <c>POST /transfers</c>,
|
||||
/// внутри атомарно создаётся пара TransferOut + TransferIn (с
|
||||
/// LinkedTransactionId в обе стороны); проведение/отмена — тоже парная,
|
||||
/// см. <see cref="IMoneyService"/>.
|
||||
///
|
||||
/// Проведение / отмена — <c>POST /{id}/post</c>, <c>POST /{id}/unpost</c>.
|
||||
/// Привязка к приёмке/возврату — через поле <c>Links</c>, разноска суммы
|
||||
/// по правилу «Σ LinkedSum ≤ Amount» (валидация в <see cref="MoneyService"/>).</summary>
|
||||
[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<MoneyTxLinkRow> 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<MoneyTxLinkInput>? Links = null,
|
||||
uint? Xmin = null);
|
||||
|
||||
[HttpGet, RequiresPermission("MoneyView")]
|
||||
public async Task<ActionResult<PagedResult<MoneyTxRow>>> 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<uint>(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<MoneyTxRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
|
||||
public async Task<ActionResult<MoneyTxRow>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var row = await ReadOneAsync(id, ct);
|
||||
return row is null ? NotFound() : row;
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("MoneyEdit")]
|
||||
public async Task<ActionResult<MoneyTxRow>> 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<MoneyTxLinkInput>())
|
||||
{
|
||||
// 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<ActionResult<MoneyTxRow>> 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<MoneyTxLinkInput>())
|
||||
{
|
||||
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<object>> 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<MoneyTxRow?> 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<uint>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
/// <summary>Статус оплаты приёмки — вычисляемая строка:
|
||||
/// "unpaid" (0), "partial" (0 < payed < total), "paid" (payed ≥ total).
|
||||
/// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог.</summary>
|
||||
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<SupplyLineDto> Lines);
|
||||
IReadOnlyList<SupplyLineDto> 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<SupplyListRow> { 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -569,6 +569,28 @@ private async Task<IActionResult> 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -436,6 +436,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// Inventory
|
||||
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||||
|
||||
// Sprint 33: модуль «Деньги»
|
||||
builder.Services.AddScoped<foodmarket.Application.Money.IMoneyService,
|
||||
foodmarket.Infrastructure.Money.MoneyService>();
|
||||
|
||||
// Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
|
||||
// (тот же ConnectionString:Default). Запускаем фактический сервер только
|
||||
// когда приложение действительно работает (не в тестах): чтобы тестовые
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Системные статьи расходов для новой организации — пять по
|
||||
/// умолчанию (из ТЗ модуля «Деньги»). Их можно переименовать, но
|
||||
/// нельзя удалить (<see cref="ExpenseItem.IsSystem"/>=true), чтобы
|
||||
/// старые документы не остались с «висящим» FK.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Канонический набор 5 глобальных единиц измерения (Phase5c).
|
||||
|
|
|
|||
69
src/food-market.application/Money/IMoneyService.cs
Normal file
69
src/food-market.application/Money/IMoneyService.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using foodmarket.Domain.Money;
|
||||
|
||||
namespace foodmarket.Application.Money;
|
||||
|
||||
/// <summary>Единый API модуля «Деньги». Контроллеры дёргают только эти
|
||||
/// методы — внутри сервиса сидит вся бизнес-логика: пересчёт
|
||||
/// <c>Supply.PayedSum</c>, парная проводка переводов, расчёт expected
|
||||
/// cash для смены, применение статусов. Все методы Serializable-safe
|
||||
/// (транзакция внутри — одна на операцию).</summary>
|
||||
public interface IMoneyService
|
||||
{
|
||||
/// <summary>Провести готовый документ: смена Status Draft→Posted,
|
||||
/// пересчёт PayedSum на связанных документах. Пустой список
|
||||
/// <c>Links</c> допустим (аналог noClosingDocs=true). Для TransferOut
|
||||
/// — атомарно создаёт/проводит парный TransferIn.</summary>
|
||||
Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct);
|
||||
|
||||
/// <summary>Отменить проведение. Пересчёт PayedSum в сторону уменьшения.
|
||||
/// После Unpost документ можно удалить или снова отредактировать и
|
||||
/// провести заново.</summary>
|
||||
Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid currentUserId, CancellationToken ct);
|
||||
|
||||
/// <summary>Открыть смену на точке. Падает 409 если на RetailPoint
|
||||
/// уже есть открытая смена.</summary>
|
||||
Task<CashShiftOpenResult> OpenShiftAsync(Guid retailPointId, Guid cashAccountId,
|
||||
Guid? acquiringAccountId, decimal openingFloat, Guid cashierUserId, CancellationToken ct);
|
||||
|
||||
/// <summary>Закрыть смену: вычислить snapshot'ы продаж/возвратов,
|
||||
/// expected cash, разницу. Запрещено если уже закрыта.</summary>
|
||||
Task<CashShiftCloseResult> CloseShiftAsync(Guid shiftId, decimal actualCash,
|
||||
Guid closedByUserId, string? notes, CancellationToken ct);
|
||||
|
||||
/// <summary>Добавить внесение/выплату внутри открытой смены.</summary>
|
||||
Task<CashDrawerOperationResult> 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);
|
||||
118
src/food-market.domain/Money/CashShift.cs
Normal file
118
src/food-market.domain/Money/CashShift.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Money;
|
||||
|
||||
/// <summary>Статус кассовой смены. От МойСклада берём идею, но упрощённо:
|
||||
/// Open — ящик открыт, можно продавать; Closed — закрыт кассиром с указанием
|
||||
/// фактических денег в ящике, пересчитан expected vs actual. Z-отчёт при
|
||||
/// закрытии — просмотр и печать по этой записи.</summary>
|
||||
public enum CashShiftStatus
|
||||
{
|
||||
Open = 1,
|
||||
Closed = 2,
|
||||
}
|
||||
|
||||
/// <summary>Розничная смена кассы — аналог МойСкладовского
|
||||
/// <c>retailshift</c>. Одна открытая смена per <see cref="RetailPointId"/>:
|
||||
/// повторный open на той же точке падает в 409. Все продажи этой точки
|
||||
/// в периоде [OpenedAt, ClosedAt ?? now] привязываются к этой смене
|
||||
/// (<c>RetailSale.CashShiftId</c>, добавляем в Phase2 миграции).
|
||||
///
|
||||
/// При закрытии кассир вводит <see cref="ActualCash"/> — фактическую
|
||||
/// сумму в ящике. Expected считается из <see cref="OpeningFloat"/> +
|
||||
/// Σ cash-операций смены (продажи − возвраты + внесения − выплаты).
|
||||
/// Разница в <see cref="CashDifference"/> подсвечивается в UI;
|
||||
/// отрицательная = недостача, положительная = излишек. Z-отчёт — просто
|
||||
/// печатный вид этой записи + свои аггрегаты по картам/QR.</summary>
|
||||
public class CashShift : TenantEntity, IVersionedEntity
|
||||
{
|
||||
public uint Xmin { get; set; }
|
||||
|
||||
public string Number { get; set; } = "";
|
||||
|
||||
public CashShiftStatus Status { get; set; } = CashShiftStatus.Open;
|
||||
|
||||
/// <summary>Точка продаж, на которой открыта смена.</summary>
|
||||
public Guid RetailPointId { get; set; }
|
||||
public RetailPoint RetailPoint { get; set; } = null!;
|
||||
|
||||
/// <summary>Счёт «касса» этой точки — на нём отражается физический нал
|
||||
/// смены (внесения/выплаты, продажи наличными). Выбирается из
|
||||
/// <see cref="MoneyAccount"/> где Kind=Cash + StoreId совпадает с
|
||||
/// RetailPoint.StoreId. Если несколько касс — кассир выбирает в
|
||||
/// момент открытия.</summary>
|
||||
public Guid CashAccountId { get; set; }
|
||||
public MoneyAccount CashAccount { get; set; } = null!;
|
||||
|
||||
/// <summary>Счёт «эквайринг» для карточной выручки. Опционально —
|
||||
/// если магазин не берёт карты, смена работает только с наличными.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>«Разменник» на начало смены — сумма наличных, которую
|
||||
/// кассир положил в ящик перед первой продажей (например, 20 000₸
|
||||
/// разменными купюрами). Берётся из показателя предыдущей закрытой
|
||||
/// смены, кассир может скорректировать.</summary>
|
||||
public decimal OpeningFloat { get; set; }
|
||||
|
||||
/// <summary>Снапшоты на момент закрытия. До Close все == 0.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Фактическая сумма наличных в ящике при закрытии (вводит
|
||||
/// кассир). Null пока смена открыта.</summary>
|
||||
public decimal? ActualCash { get; set; }
|
||||
|
||||
/// <summary>ActualCash − ExpectedCash. Знак: <0 — недостача,
|
||||
/// >0 — излишек. Снапшот на момент закрытия.</summary>
|
||||
public decimal CashDifference { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ICollection<CashDrawerOperation> DrawerOperations { get; set; } = new List<CashDrawerOperation>();
|
||||
}
|
||||
|
||||
/// <summary>Направление ящикового движения кассы. In — кассир положил
|
||||
/// деньги в ящик («внесение», «разменять из сейфа»). Out — выдал
|
||||
/// («сдача инкассатору», «выдал курьеру»). Логика расчёта смены
|
||||
/// соответствует МойСкладовским <c>retaildrawercashin/retaildrawercashout</c>.</summary>
|
||||
public enum CashDrawerDirection
|
||||
{
|
||||
In = 1,
|
||||
Out = 2,
|
||||
}
|
||||
|
||||
/// <summary>Внесение/выплата внутри смены — простая запись, которую может
|
||||
/// сделать только тот кассир, у которого открыта смена, и только пока
|
||||
/// она открыта. После Close CashShift становится неизменяемой — операции
|
||||
/// привязаны намертво и попадают в Z-отчёт.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Причина — обязательный свободный текст («Размен из сейфа»,
|
||||
/// «Инкассация», «Выдал курьеру за доставку»). Пусто не принимаем.</summary>
|
||||
public string Reason { get; set; } = null!;
|
||||
|
||||
public DateTime At { get; set; } = DateTime.UtcNow;
|
||||
public Guid ByUserId { get; set; }
|
||||
}
|
||||
45
src/food-market.domain/Money/CounterpartyAdjustment.cs
Normal file
45
src/food-market.domain/Money/CounterpartyAdjustment.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Money;
|
||||
|
||||
/// <summary>Направление корректировки взаиморасчётов: WeOwe — мы должны
|
||||
/// контрагенту (увеличивает долг нам), TheyOwe — контрагент должен нам
|
||||
/// (уменьшает долг). Соответствует MoySkladовскому
|
||||
/// <c>counterpartyadjustment</c>, где знак выражается через <c>sum</c>.</summary>
|
||||
public enum CounterpartyAdjustmentDirection
|
||||
{
|
||||
WeOwe = 1,
|
||||
TheyOwe = 2,
|
||||
}
|
||||
|
||||
/// <summary>Корректировка взаиморасчётов — для стартовых долгов и ручных
|
||||
/// исправлений. В простом режиме главное применение: «мы начинаем работать
|
||||
/// в системе, но с поставщиком Х у нас уже есть долг 500 000₸».
|
||||
///
|
||||
/// Проведённая корректировка влияет на отчёт «Долги поставщикам» (прибавляет
|
||||
/// или вычитает), на баланс счетов НЕ влияет — это чисто учётный документ,
|
||||
/// без денежного движения.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Проведено (учитывается в отчётах) или черновик.</summary>
|
||||
public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft;
|
||||
}
|
||||
29
src/food-market.domain/Money/ExpenseItem.cs
Normal file
29
src/food-market.domain/Money/ExpenseItem.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Money;
|
||||
|
||||
/// <summary>Статья расходов (у МойСклада — <c>expenseItem</c>). Привязывается
|
||||
/// к расходным документам: paymentout, cashout. Сид создаёт стартовый набор
|
||||
/// (Закупка товара, Аренда, Зарплата, Коммунальные, Прочее) с
|
||||
/// <see cref="IsSystem"/>=true — его переименовать можно, удалить нет.
|
||||
///
|
||||
/// Магазин может добавить свои (например, «Инкассация», «Хоз-товары») —
|
||||
/// у них <see cref="IsSystem"/>=false, удаляются при условии «ни на одном
|
||||
/// документе не используется» (валидатор в контроллере).</summary>
|
||||
public class ExpenseItem : TenantEntity, IVersionedEntity
|
||||
{
|
||||
public uint Xmin { get; set; }
|
||||
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
/// <summary>Для UI: короткий код (например, "PURCHASE", "RENT"). Нужен
|
||||
/// в основном сидеру + для сверки с 1С/МойСкладом при синхронизации.
|
||||
/// Пользователь может оставить пустым.</summary>
|
||||
public string? Code { get; set; }
|
||||
|
||||
public bool IsSystem { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
81
src/food-market.domain/Money/MoneyAccount.cs
Normal file
81
src/food-market.domain/Money/MoneyAccount.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Money;
|
||||
|
||||
/// <summary>Что это за «место, где лежат деньги». У МойСклада наличные и
|
||||
/// банковские счета — две разные сущности (<c>cashbox</c>-less для cash,
|
||||
/// <c>organizationAccount</c> для безнала); у нас в простом режиме магазину
|
||||
/// проще видеть единый справочник «Счета и кассы», поэтому различаем через
|
||||
/// <see cref="MoneyAccountKind"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Cash</b> — физическая касса магазина (деньги в ящике ККМ).
|
||||
/// Привязана к <see cref="RetailPoint"/>: деньги смены этой точки
|
||||
/// отражаются именно на этом счёте.</item>
|
||||
/// <item><b>Safe</b> — сейф, обычно у директора/бухгалтера. Деньги попадают
|
||||
/// сюда через инкассации «касса → сейф».</item>
|
||||
/// <item><b>Bank</b> — расчётный счёт юрлица (Kaspi, Halyk и т.п.).
|
||||
/// На один магазин допускается несколько банковских счетов.</item>
|
||||
/// <item><b>Acquiring</b> — счёт эквайринга: куда банк зачисляет оплаты
|
||||
/// картой с POS. Как правило отдельный от основного расчётного, с
|
||||
/// задержкой перевода T+1 и своей комиссией.</item>
|
||||
/// </list>
|
||||
/// Начальный остаток <see cref="OpeningBalance"/> фиксируется на дату
|
||||
/// <see cref="OpeningDate"/> и считается «данностью» — все проведённые
|
||||
/// движения прибавляются/вычитаются от него. Инвариант:
|
||||
/// <c>CurrentBalance = OpeningBalance + Σ MoneyTransactions.Signed</c>.
|
||||
/// Проверяется в <c>MoneyReportsController</c>.</summary>
|
||||
public enum MoneyAccountKind
|
||||
{
|
||||
Cash = 1,
|
||||
Safe = 2,
|
||||
Bank = 3,
|
||||
Acquiring = 4,
|
||||
}
|
||||
|
||||
public class MoneyAccount : TenantEntity, IVersionedEntity
|
||||
{
|
||||
public uint Xmin { get; set; }
|
||||
|
||||
/// <summary>Отображаемое имя: «Касса магазина Абая», «Kaspi основной»,
|
||||
/// «Сейф директора». Уникальность — только в рамках организации
|
||||
/// (разные магазины одной орги могут иметь «Касса»).</summary>
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public MoneyAccountKind Kind { get; set; }
|
||||
|
||||
/// <summary>Банк: только для <see cref="MoneyAccountKind.Bank"/> и
|
||||
/// <see cref="MoneyAccountKind.Acquiring"/>. Для Cash/Safe — null.</summary>
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>Номер счёта. Для банк./эквайринга — IBAN или просто номер.
|
||||
/// Опционален (магазин может не хотеть вводить).</summary>
|
||||
public string? AccountNumber { get; set; }
|
||||
|
||||
/// <summary>Привязка кассы к магазину. Для Bank/Safe обычно null (счета
|
||||
/// юрлица, не магазина), для Cash — обязательна (нужна чтобы знать,
|
||||
/// куда идёт выручка POS). Для Acquiring рекомендована, но не required.</summary>
|
||||
public Guid? StoreId { get; set; }
|
||||
public Store? Store { get; set; }
|
||||
|
||||
public Guid CurrencyId { get; set; }
|
||||
public Currency Currency { get; set; } = null!;
|
||||
|
||||
/// <summary>Начальный остаток на дату старта учёта в системе. Задаётся
|
||||
/// один раз при создании счёта; потом редактировать нельзя — иначе
|
||||
/// расходится история (инвариант остатков будет врать). Если
|
||||
/// действительно нужно переоткрыться — удалить все движения этого
|
||||
/// счёта и пересоздать.</summary>
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public DateTime OpeningDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Если false — счёт виден только для чтения отчёта и не
|
||||
/// предлагается в выпадающих списках новых документов. Удалять не
|
||||
/// даём (есть движения), только архивация флажком.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Удобный хинт для UI: по умолчанию выбирать именно этот
|
||||
/// счёт при создании кассового/безнал. ордера. Один per
|
||||
/// <see cref="Kind"/> в орге.</summary>
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
135
src/food-market.domain/Money/MoneyTransaction.cs
Normal file
135
src/food-market.domain/Money/MoneyTransaction.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Domain.Common;
|
||||
using foodmarket.Domain.Purchases;
|
||||
|
||||
namespace foodmarket.Domain.Money;
|
||||
|
||||
/// <summary>Тип финансового документа — объединяет МойСкладовские
|
||||
/// <c>paymentin</c>, <c>paymentout</c>, <c>cashin</c>, <c>cashout</c>
|
||||
/// в одну сущность <see cref="MoneyTransaction"/> с дискриминатором.
|
||||
/// Переводы между своими счетами моделируем парой проводок
|
||||
/// (TransferOut + TransferIn), соединённых <see cref="MoneyTransaction.LinkedTransactionId"/>.</summary>
|
||||
public enum MoneyTransactionKind
|
||||
{
|
||||
/// <summary>Приход безнала на банк. счёт (аналог paymentin).</summary>
|
||||
PaymentIn = 1,
|
||||
|
||||
/// <summary>Расход безнала с банк. счёта (аналог paymentout).</summary>
|
||||
PaymentOut = 2,
|
||||
|
||||
/// <summary>Приход наличных в кассу/сейф (аналог cashin).</summary>
|
||||
CashIn = 3,
|
||||
|
||||
/// <summary>Расход наличных из кассы/сейфа (аналог cashout).</summary>
|
||||
CashOut = 4,
|
||||
|
||||
/// <summary>Расходная половина перевода между своими счетами
|
||||
/// (с ParentId → приходная половина).</summary>
|
||||
TransferOut = 5,
|
||||
|
||||
/// <summary>Приходная половина перевода.</summary>
|
||||
TransferIn = 6,
|
||||
}
|
||||
|
||||
public enum MoneyTransactionStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Posted = 1,
|
||||
}
|
||||
|
||||
/// <summary>Единый финансовый документ: платёж/ордер/перевод. У каждого
|
||||
/// документа есть одна сторона (<see cref="AccountId"/>) и знак:
|
||||
/// <c>In</c>-варианты увеличивают баланс счёта, <c>Out</c> — уменьшают.
|
||||
///
|
||||
/// Привязка к первичным документам (приёмкам/возвратам поставщика/продажам/
|
||||
/// возвратам покупателю) — через коллекцию <see cref="Links"/>, по образцу
|
||||
/// МойСкладовских <c>operations[linkedSum]</c>. На одной транзакции может
|
||||
/// быть N ссылок с суммами, разложенных по разным документам (частичная
|
||||
/// оплата допустима). Σ Links.LinkedSum не должна превышать Amount, но
|
||||
/// может быть меньше (кусок суммы «без закрытия документа» — аналог
|
||||
/// MoySklad <c>noClosingDocs</c>).
|
||||
///
|
||||
/// Проведённые транзакции (<see cref="Status"/>=Posted) неизменяемы;
|
||||
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
|
||||
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
|
||||
/// Postgres (см. <c>MoneyService.Unpost</c>).</summary>
|
||||
public class MoneyTransaction : TenantEntity, IVersionedEntity
|
||||
{
|
||||
public uint Xmin { get; set; }
|
||||
|
||||
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
|
||||
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
|
||||
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли
|
||||
/// в порядке создания, а не по трём разным счётчикам.</summary>
|
||||
public string Number { get; set; } = "";
|
||||
|
||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public MoneyTransactionKind Kind { get; set; }
|
||||
|
||||
public MoneyTransactionStatus Status { get; set; } = MoneyTransactionStatus.Draft;
|
||||
|
||||
/// <summary>Счёт/касса, с которой это движение (приход — зачисление сюда,
|
||||
/// расход — списание отсюда).</summary>
|
||||
public Guid AccountId { get; set; }
|
||||
public MoneyAccount Account { get; set; } = null!;
|
||||
|
||||
/// <summary>Контрагент: обязателен для PaymentIn/PaymentOut/CashIn/CashOut,
|
||||
/// null для TransferIn/TransferOut (там контрагент — сами).</summary>
|
||||
public Guid? CounterpartyId { get; set; }
|
||||
public Counterparty? Counterparty { get; set; }
|
||||
|
||||
/// <summary>Статья расходов — только для PaymentOut/CashOut. У приходных
|
||||
/// документов и переводов — null.</summary>
|
||||
public Guid? ExpenseItemId { get; set; }
|
||||
public ExpenseItem? ExpenseItem { get; set; }
|
||||
|
||||
public Guid CurrencyId { get; set; }
|
||||
public Currency Currency { get; set; } = null!;
|
||||
|
||||
/// <summary>Сумма документа. Положительная (знак определяется Kind).</summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>Назначение/основание платежа. У МойСклада обязательное
|
||||
/// (`paymentPurpose`), но для простого магазина делаем необязательным —
|
||||
/// кассир редко вписывает что-то кроме «За товар».</summary>
|
||||
public string? Purpose { get; set; }
|
||||
|
||||
/// <summary>Общий комментарий документа.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Для переводов — ссылка на парную половину (TransferOut↔TransferIn).
|
||||
/// Проводятся всегда вместе, в одной БД-транзакции. Для всех остальных
|
||||
/// Kind — null.</summary>
|
||||
public Guid? LinkedTransactionId { get; set; }
|
||||
public MoneyTransaction? LinkedTransaction { get; set; }
|
||||
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
public ICollection<MoneyTransactionLink> Links { get; set; } = new List<MoneyTransactionLink>();
|
||||
}
|
||||
|
||||
/// <summary>Один <see cref="MoneyTransaction"/> ↔ один первичный документ с
|
||||
/// распределённой суммой. У MoySklad это <c>operations[].linkedSum</c>.
|
||||
///
|
||||
/// Для простого режима мы сейчас поддерживаем только привязку к
|
||||
/// <see cref="Supply"/> (приёмка) и <see cref="SupplierReturn"/> (возврат
|
||||
/// поставщику) — обе колонки nullable, ровно одна из них должна быть
|
||||
/// заполнена; валидируется в <c>MoneyService.PostAsync</c> и
|
||||
/// проверочным ограничением в БД. На Phase2 (расширенный режим) добавим
|
||||
/// Demand/RetailSale/CustomerReturn — тогда появятся соответствующие
|
||||
/// колонки, таблица расширится без миграции данных.</summary>
|
||||
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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
||||
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ public class SupplierReturn : TenantEntity
|
|||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
/// <summary>Сколько поставщик вернул нам денег по этому возврату
|
||||
/// (аналог <see cref="Supply.PayedSum"/>). Пересчитывает
|
||||
/// <c>MoneyService</c>; инвариант <c>0 ≤ PayedSum ≤ Total</c>.</summary>
|
||||
public decimal PayedSum { get; set; }
|
||||
|
||||
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ public class Supply : TenantEntity, IVersionedEntity
|
|||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
/// <summary>Сколько из <see cref="Total"/> оплачено — сумма всех
|
||||
/// проведённых <c>MoneyTransactionLink.LinkedSum</c>, где
|
||||
/// <c>SupplyId</c>=этой приёмки. Пересчитывается в <c>MoneyService</c>
|
||||
/// при проведении/отмене платежа. Инвариант: <c>0 ≤ PayedSum ≤ Total</c>.
|
||||
/// Статус оплаты получаем сравнением: PayedSum==0 — не оплачена,
|
||||
/// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена.</summary>
|
||||
public decimal PayedSum { get; set; }
|
||||
|
||||
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ public class RetailSale : TenantEntity, IVersionedEntity
|
|||
public decimal PaidCash { get; set; }
|
||||
public decimal PaidCard { get; set; }
|
||||
|
||||
/// <summary>Смена, в рамках которой проведён чек. Обязателен для
|
||||
/// POS-продаж на точке с живой кассой; <c>null</c> у исторических чеков
|
||||
/// до внедрения модуля «Деньги» и у безсменных операций (bulk-импорт,
|
||||
/// технический seed, тесты). Проверка на обязательность — в
|
||||
/// <c>RetailSalesController.Post</c>: если <c>CashShift</c> на точке
|
||||
/// не открыта, продажа отклоняется с кодом <c>cash-shift-required</c>.</summary>
|
||||
public Guid? CashShiftId { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
|
|
|
|||
48
src/food-market.infrastructure/Money/MoneyNumbering.cs
Normal file
48
src/food-market.infrastructure/Money/MoneyNumbering.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Infrastructure.Money;
|
||||
|
||||
/// <summary>Генераторы человекочитаемых номеров для документов модуля «Деньги».
|
||||
/// Общий счётчик на все виды <c>MoneyTransaction</c> (чтобы в едином списке
|
||||
/// «Платежи и ордера» документы шли по возрастанию); отдельный — для смен и
|
||||
/// корректировок. Формат — префикс + год + 6-значный seq.
|
||||
///
|
||||
/// Реализация прочтением MAX номера под advisory-lock'ом (сериализация внутри
|
||||
/// одной орги). Для prod с десятками тысяч документов стоит переработать на
|
||||
/// отдельную таблицу-счётчик, но для простого режима и ожидаемой нагрузки
|
||||
/// такой подход экономит таблицу и работает корректно под Serializable.</summary>
|
||||
public static class MoneyNumbering
|
||||
{
|
||||
public static Task<string> NextMoneyTxNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
|
||||
=> NextByPrefixAsync(db, "money_transactions", "Д", orgId, year, ct);
|
||||
|
||||
public static Task<string> NextShiftNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
|
||||
=> NextByPrefixAsync(db, "cash_shifts", "С", orgId, year, ct);
|
||||
|
||||
public static Task<string> NextAdjustmentNumberAsync(AppDbContext db, Guid orgId, int year, CancellationToken ct)
|
||||
=> NextByPrefixAsync(db, "counterparty_adjustments", "К", orgId, year, ct);
|
||||
|
||||
private static async Task<string> 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<int>(sql, orgId, pattern)
|
||||
.SingleAsync(ct);
|
||||
var next = maxSeq + 1;
|
||||
return $"{prefix}-{year}-{next:D6}";
|
||||
}
|
||||
}
|
||||
311
src/food-market.infrastructure/Money/MoneyService.cs
Normal file
311
src/food-market.infrastructure/Money/MoneyService.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Единая реализация <see cref="IMoneyService"/>. Внутри — Serializable
|
||||
/// транзакции на всю операцию (post/unpost/open/close/drawer); при конкуренте
|
||||
/// Postgres падает в 40001, миддлварь (<c>SerializationConflictMiddleware</c>)
|
||||
/// преобразует в 409. Любые изменения <c>Supply.PayedSum</c> / <c>SupplierReturn.PayedSum</c>
|
||||
/// идут только отсюда — чтобы инвариант «PayedSum ∈ [0,Total]» был централизованно
|
||||
/// защищён.</summary>
|
||||
public sealed class MoneyService : IMoneyService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITenantContext _tenant;
|
||||
private readonly ILogger<MoneyService> _log;
|
||||
|
||||
public MoneyService(AppDbContext db, ITenantContext tenant, ILogger<MoneyService> log)
|
||||
{
|
||||
_db = db;
|
||||
_tenant = tenant;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<MoneyServiceResult> 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<MoneyServiceResult> 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<CashShiftOpenResult> 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<CashShiftCloseResult> 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<CashDrawerOperationResult> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AppDbContext> options, ITenantContext tenan
|
|||
public DbSet<UserPreset> UserPresets => Set<UserPreset>();
|
||||
public DbSet<OrgExport> OrgExports => Set<OrgExport>();
|
||||
|
||||
// Sprint 33: модуль «Деньги» — простой режим.
|
||||
public DbSet<MoneyAccount> MoneyAccounts => Set<MoneyAccount>();
|
||||
public DbSet<ExpenseItem> ExpenseItems => Set<ExpenseItem>();
|
||||
public DbSet<MoneyTransaction> MoneyTransactions => Set<MoneyTransaction>();
|
||||
public DbSet<MoneyTransactionLink> MoneyTransactionLinks => Set<MoneyTransactionLink>();
|
||||
public DbSet<CashShift> CashShifts => Set<CashShift>();
|
||||
public DbSet<CashDrawerOperation> CashDrawerOperations => Set<CashDrawerOperation>();
|
||||
public DbSet<CounterpartyAdjustment> CounterpartyAdjustments => Set<CounterpartyAdjustment>();
|
||||
|
||||
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
|
||||
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
|
||||
/// импортами (где tenant-context отсутствует) и при ручной чистке логов.</summary>
|
||||
|
|
@ -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) —
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
using foodmarket.Domain.Money;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>Схема таблиц модуля «Деньги». Названия колонок/таблиц
|
||||
/// подчинены общему стилю (snake_case через ToTable + EF-camel для свойств);
|
||||
/// суммовые — <c>decimal(18,4)</c>; концуррентный токен — Postgres <c>xmin</c>.</summary>
|
||||
public static class MoneyConfigurations
|
||||
{
|
||||
public static void ConfigureMoney(this ModelBuilder b)
|
||||
{
|
||||
b.Entity<MoneyAccount>(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<ExpenseItem>(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<MoneyTransaction>(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<MoneyTransactionLink>(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<CashShift>(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<CashDrawerOperation>(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<CounterpartyAdjustment>(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<foodmarket.Domain.Purchases.Supply>(e =>
|
||||
{
|
||||
e.Property(x => x.PayedSum).HasPrecision(18, 4);
|
||||
e.HasIndex(x => new { x.OrganizationId, x.SupplierId, x.Status, x.PayedSum });
|
||||
});
|
||||
|
||||
b.Entity<foodmarket.Domain.Purchases.SupplierReturn>(e =>
|
||||
{
|
||||
e.Property(x => x.PayedSum).HasPrecision(18, 4);
|
||||
});
|
||||
|
||||
b.Entity<foodmarket.Domain.Sales.RetailSale>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.OrganizationId, x.CashShiftId })
|
||||
.HasFilter("\"CashShiftId\" IS NOT NULL");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Phase33a — модуль «Деньги» для простого режима.
|
||||
///
|
||||
/// Создаёт все таблицы модуля (money_accounts, expense_items,
|
||||
/// money_transactions, money_transaction_links, cash_shifts,
|
||||
/// cash_drawer_operations, counterparty_adjustments) и расширяет:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>supplies — колонка <c>PayedSum</c> (NUMERIC(18,4) DEFAULT 0),
|
||||
/// с индексом по (OrganizationId, SupplierId, Status, PayedSum).</item>
|
||||
/// <item>supplier_returns — колонка <c>PayedSum</c>.</item>
|
||||
/// <item>retail_sales — колонка <c>CashShiftId</c> (nullable uuid)
|
||||
/// с условным индексом WHERE CashShiftId IS NOT NULL.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Идемпотентно (IF NOT EXISTS везде); backfilling <c>PayedSum=0</c>
|
||||
/// корректен, т.к. до этой фазы ни одна приёмка не могла быть оплачена
|
||||
/// через модуль Денег.</summary>
|
||||
[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;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue