Compare commits
No commits in common. "0977c639cd98fc168bce89578bea40d1fc2135ce" and "b6c4d6056d73f3016b0c0b9292284aa8cf2052d6" have entirely different histories.
0977c639cd
...
b6c4d6056d
|
|
@ -1,39 +1,257 @@
|
||||||
# МойСклад — розничные деньги (reference для модели смены)
|
# МойСклад — опорные факты для модуля «Деньги»
|
||||||
|
|
||||||
Как в МойСклад розничные операции отражаются в деньгах (по
|
Собрано 2026-10-07 через headless Playwright со страниц
|
||||||
`https://dev.moysklad.ru/doc/api/remap/1.2/documents/` и известной семантике;
|
`https://dev.moysklad.ru/doc/api/remap/1.2/#/…`. Код скрейпера:
|
||||||
страница docs отдаётся JS-ом, поэтому ниже — сводка модели, а не дословная цитата).
|
`tests/e2e/scrape-moysklad.ts`, сырые дампы: `/tmp/moysklad-dump/*.txt`.
|
||||||
|
|
||||||
## Сущности
|
Задача этого документа — зафиксировать **именно те поля и связи
|
||||||
- **Розничная смена** `retailshift` — контейнер смены на точке (ККМ). Хранит
|
МойСклада**, которые мы должны уметь воспроизводить. Своими названиями
|
||||||
начальный остаток (размен), суммы продаж нал/безнал, возвраты, внесения/изъятия,
|
колонок/эндпоинтов пользуемся по смыслу их, чтобы потом синхронизация
|
||||||
итоговые суммы на закрытие.
|
легла без хирургии.
|
||||||
- **Розничная продажа** `retaildemand` — чек. Внутри — оплата: `cashSum` (нал) и
|
|
||||||
`noCashSum`/`bankPercent` (безнал, эквайринг). Привязана к смене.
|
|
||||||
- **Розничный возврат** `retailsalesreturn` — возврат покупателю (нал/безнал).
|
|
||||||
- **Внесение/Изъятие** `retaildrawercashin` / `retaildrawercashout` — движение
|
|
||||||
наличных в кассе ККМ внутри смены (внесение разменом, изъятие инкассацией).
|
|
||||||
- **Приходный/расходный ордер** `cashin` / `cashout`, платёж `paymentin`/`paymentout`
|
|
||||||
— общие денежные документы (не розничные), влияют на остаток счёта/кассы.
|
|
||||||
|
|
||||||
## Как попадает в деньги
|
---
|
||||||
- Касса (денежный счёт типа «касса») = размен + Σ продаж нал − Σ возвратов нал +
|
|
||||||
Σ внесений − Σ изъятий. Безнал (карты) идёт на привязанный **расчётный счёт/
|
|
||||||
эквайринг**, отдельно от наличной кассы.
|
|
||||||
- Каждый `retaildemand`/`retailsalesreturn`/`drawercashin|out` — это движение по
|
|
||||||
счёту; в отчёте «Деньги» (движение ДС) они видны по типам.
|
|
||||||
- **Закрытие смены**: фиксируется фактический остаток наличных; **расхождение**
|
|
||||||
(факт − расчёт) оформляется как излишек/недостача — отдельное движение кассы.
|
|
||||||
- Размен (начальный остаток смены) — НЕ новое поступление: это те же деньги, что
|
|
||||||
уже в кассе; он не задваивается как приход.
|
|
||||||
|
|
||||||
## Наша адаптация (food-market, простой режим)
|
## 1. Юрлицо → счета (`organization.accounts`)
|
||||||
- Материализуем движения смены в `money_transactions` ПРИ ЗАКРЫТИИ (а не по каждому
|
|
||||||
чеку) — проще для простого магазина, остаток кассы/эквайринга становится верным
|
Юрлицо (`organization`) содержит коллекцию банковских счетов (`accounts`).
|
||||||
сразу после закрытия. Типы — `MoneyTransactionCategory` (RetailSalesCash/…/ShiftSurplus).
|
Один помечен «основным» (`isDefault`). Поля счёта:
|
||||||
- Карта → отдельный счёт «Эквайринг» смены (как расчётный счёт в МойСклад).
|
|
||||||
- Размен не материализуем (деньги уже в кассе). Итог кассы после закрытия = факту
|
| Поле | Тип | Назначение |
|
||||||
наличных, если по кассе не было других операций.
|
|---|---|---|
|
||||||
- Отличие: МойСклад проводит `retaildemand` в деньги сразу (живой остаток в течение
|
| `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` — не нужно, у магазина нет
|
||||||
|
документооборота с банком.
|
||||||
|
|
||||||
|
Эти поля не создаём в простом режиме, но резервируем места в именованиях
|
||||||
|
колонок на будущее.
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
# Деньги смены → остатки счетов (прогресс)
|
|
||||||
|
|
||||||
Автономная задача владельца (08.10.2026, нашёл Mac-Claude на стенде). Деньги смены
|
|
||||||
(продажи нал/карта, возвраты, внесения/выплаты, расхождение) НЕ попадают в остаток
|
|
||||||
счёта. Делать ДО кассы — касса опирается на эти остатки.
|
|
||||||
|
|
||||||
## Факты со стенда (Демо после B)
|
|
||||||
- «Касса (e2e)»: нач. 50 000, текущий −98 812 (= 50 000 − 148 812 оплата приёмки нал).
|
|
||||||
Смена С-2026-000001: продажи нал 3 775, возвраты нал 1 500, внесение 10 000, выплата 3 000,
|
|
||||||
факт 14 775 — НИЧЕГО не попало в остаток счёта. Эквайринг: 0 при продажах картой 2 325.
|
|
||||||
- Оплата 148 812 нал при остатке 50 000 → касса в минус, никто не остановил.
|
|
||||||
- Инвариант теста «счёт ≡ начальный + Σ операций» прошёл только потому, что движения смены
|
|
||||||
не считаются операциями счёта — дыра в тесте.
|
|
||||||
|
|
||||||
## Текущая модель (как есть)
|
|
||||||
- Остаток счёта = `OpeningBalance + Σ(PaymentIn/CashIn/TransferIn) − Σ(PaymentOut/CashOut/TransferOut)`
|
|
||||||
по Posted `money_transactions` (MoneyAccountsController.List + MoneyReportsController.Balances).
|
|
||||||
- Смена (`CashShift`) хранит SalesCash/SalesCard/ReturnsCash/ReturnsCard/DrawerIn/DrawerOut/
|
|
||||||
ExpectedCash/ActualCash/CashDifference — считаются ПРИ ЗАКРЫТИИ из связанных retail_sales +
|
|
||||||
cash_drawer_operations. В `money_transactions` НЕ пишутся → в остаток счёта не входят.
|
|
||||||
|
|
||||||
## Выбранная модель (материализация движений смены при закрытии)
|
|
||||||
Единый реестр — `money_transactions`. При закрытии смены создаём Posted-транзакции на счета смены,
|
|
||||||
и существующая формула остатка автоматически их учитывает; отчёт «Движение» показывает их по типам.
|
|
||||||
- Касса смены (CashAccount): `+SalesCash` (Розничные продажи), `−ReturnsCash` (Возвраты),
|
|
||||||
`+DrawerIn` (Внесение), `−DrawerOut` (Выплата), `±CashDifference` (Излишек/Недостача).
|
|
||||||
- Эквайринг смены (AcquiringAccount): `+SalesCard` (Продажи картой), `−ReturnsCard` (Возвраты по карте).
|
|
||||||
- Размен (OpeningFloat) — НЕ движение (деньги уже в кассе). Итог: остаток кассы после закрытия =
|
|
||||||
старый остаток + (SalesCash−ReturnsCash+DrawerIn−DrawerOut+Diff) = ActualCash, если до смены на
|
|
||||||
кассе был только размен. Σ по типам = ActualCash − OpeningFloat.
|
|
||||||
Движения помечаем `Category` (enum) + `CashShiftId` (идемпотентность). Создаются только ненулевые.
|
|
||||||
|
|
||||||
## Отрицательный остаток
|
|
||||||
- Расход (CashOut/PaymentOut/TransferOut при post money-tx) и Выплата из смены (drawer Out) больше
|
|
||||||
доступных денег → 409 «На счёте «…» недостаточно денег: остаток X ₸, нужно Y ₸».
|
|
||||||
- Настройка орг `AllowNegativeBalance` (по умолч. выкл., менять только админ).
|
|
||||||
- Выплата из смены сверяется с ЖИВЫМ налом смены (OpeningFloat + SalesCash−ReturnsCash+DrawerIn−DrawerOut
|
|
||||||
на текущий момент), расход money-tx — с остатком счёта.
|
|
||||||
|
|
||||||
## План
|
|
||||||
1. [x] МойСклад reference → `docs/money-moysklad-reference.md`.
|
|
||||||
2. [x] Миграция `Phase34_ShiftMoneyMovements` (manual, idempotent): `money_transactions.Category`,
|
|
||||||
`.CashShiftId`, `organizations.AllowNegativeBalance`. Применилась на стенде (в history + колонки есть).
|
|
||||||
3. [x] `MoneyService.CloseShiftAsync` → `MaterializeShiftMovementsAsync` (Posted-движения по типам, link на смену, идемпотентно).
|
|
||||||
4. [x] Guard: `PostAsync` (расход > остатка счёта → InsufficientFunds 409) + `AddDrawerOperationAsync` (выплата > нала смены);
|
|
||||||
настройка `AllowNegativeBalance` в OrgSettings (PUT только Admin); InsufficientFunds→409 в MapResult/Drawer.
|
|
||||||
5. [x] Остатки/отчёты правильные автоматически (всё читает money_transactions); `Category` в DTO для типов.
|
|
||||||
6. [x] `docs/money.md` — раздел «Как деньги смены попадают в остатки» + «Отрицательный остаток».
|
|
||||||
7. [x] Остаток вычисляемый → пересчёт не нужен; новые смены материализуются при закрытии.
|
|
||||||
(Историч. закрытые смены прошлых тест-орг не бэкфиллим — чистятся prune'ом; демо пересоздан прогоном B.)
|
|
||||||
8. [x] full-chain: `indepBalance()` — независимый расчёт (нач + платежи + смены: касса += факт−размен,
|
|
||||||
эквайринг += картой−возвраты), сверяется с API /balances; шаг 8 — оплата ≤ остатка + отказ при превышении.
|
|
||||||
Unit (MoneyMathTests, +1 = 26 ✓) + integration (MoneyModuleTests, +2 = 10 ✓).
|
|
||||||
9. [x] deploy-stage; post-deploy C 16/16 зелёный; репро до деплоя — 4 шага красные (подтверждено).
|
|
||||||
10. [ ] Коммиты в main + push (после 2×C + B зелёных).
|
|
||||||
|
|
||||||
## Найденное/исправленное
|
|
||||||
1. **Движения смены не попадали в остаток счёта** (касса/эквайринг игнорировали продажи/возвраты/
|
|
||||||
внесения/выплаты/расхождение). Фикс: материализация при закрытии смены в money_transactions по типам.
|
|
||||||
2. **Касса уходила в минус без предупреждения** (расход больше остатка). Фикс: guard InsufficientFunds (409)
|
|
||||||
на расход money-tx и выплату из смены; настройка орг «Разрешить отрицательные остатки» (по умолч. выкл., Admin).
|
|
||||||
3. **Дыра в тесте**: инвариант сверял остаток с суммой из того же API. Фикс: независимый расчёт в тесте.
|
|
||||||
|
|
||||||
## Демо после фикса (прогон B, проверено руками)
|
|
||||||
- Касса: нач. 50 000 → текущий **31 275** (= 50 000 − 30 000 оплата приёмки нал
|
|
||||||
+ 9 775 смена С-001 (факт14775−размен5000) + 1 500 смена С-003). Раньше было −98 812.
|
|
||||||
- Касса-2: 3 000 → **4 550** (+1 550 смена С-004). Банк: 200 000 → **181 610** (−18 390 оплата №2).
|
|
||||||
- Эквайринг: 0 → **2 325** (продажи картой смены С-001). Раньше 0. Эквайринг-2: 0.
|
|
||||||
- Отрицательных остатков нет. 8 движений смен материализованы по типам (продажи нал/карта,
|
|
||||||
возвраты, внесение, выплата, излишек). Отказ при оплате больше остатка кассы — 409.
|
|
||||||
|
|
||||||
## Статус: ГОТОВО ✅
|
|
||||||
Движения смены попадают в остатки; отрицательный остаток запрещён (с настройкой). B 16/16 (36 скринов),
|
|
||||||
2× C подряд зелёные (16/16). unit 26 ✓, integration money 10 ✓.
|
|
||||||
|
|
||||||
## Журнал
|
|
||||||
- 08.10 — старт; модель (материализация при закрытии); миграция+код+guard+настройка+отчёт; docs.
|
|
||||||
Репро до деплоя: 4 шага красные. После деплоя: C 16/16 зелёный; unit 26 ✓, integration money 10 ✓.
|
|
||||||
Демо B зелёный, остатки сверены (см. выше). 2× C подряд зелёные.
|
|
||||||
|
|
@ -151,30 +151,3 @@
|
||||||
**Ошибся в смене/платеже.** Платёж можно «Отменить проведение» и исправить.
|
**Ошибся в смене/платеже.** Платёж можно «Отменить проведение» и исправить.
|
||||||
Закрытая смена неизменяема — расхождение фиксируется как разница (недостача/
|
Закрытая смена неизменяема — расхождение фиксируется как разница (недостача/
|
||||||
излишек) и видно в Z-отчёте.
|
излишек) и видно в Z-отчёте.
|
||||||
|
|
||||||
## Как деньги смены попадают в остатки счетов (с 08.10.2026)
|
|
||||||
|
|
||||||
Раньше продажи/возвраты/внесения/выплаты смены не влияли на остаток кассы — это
|
|
||||||
была ошибка. Теперь при **закрытии смены** эти движения автоматически попадают в
|
|
||||||
деньги по типам:
|
|
||||||
|
|
||||||
- **Касса смены:** + Розничные продажи (нал) − Возвраты покупателям (нал)
|
|
||||||
+ Внесения − Выплаты ± Излишек/Недостача.
|
|
||||||
- **Эквайринг смены:** + Продажи картой − Возвраты по карте.
|
|
||||||
- **Размен** (начальный остаток смены) движением не считается — это те же деньги,
|
|
||||||
что уже были в кассе. Поэтому остаток кассы после закрытия = фактической сумме
|
|
||||||
наличных, если по кассе не было других операций.
|
|
||||||
|
|
||||||
Эти строки видны в отчёте «Деньги» (движение) и в списке «Платежи и ордера»
|
|
||||||
(с пометкой смены). Остаток каждого счёта = Начальный остаток + все проведённые
|
|
||||||
движения (платежи/ордера/переводы + движения смен).
|
|
||||||
|
|
||||||
## Отрицательный остаток
|
|
||||||
|
|
||||||
По умолчанию счёт/касса **не может уйти в минус**: если расход (оплата, перевод,
|
|
||||||
выплата из смены) больше остатка — операция отклоняется с сообщением
|
|
||||||
«На счёте «…» недостаточно денег: остаток X ₸, нужно Y ₸». Выплата из смены
|
|
||||||
сверяется с наличными, которые реально есть в кассе на этот момент.
|
|
||||||
|
|
||||||
Включить минус может только администратор: Настройки организации →
|
|
||||||
«Разрешить отрицательные остатки».
|
|
||||||
|
|
|
||||||
|
|
@ -166,13 +166,7 @@ public async Task<ActionResult<object>> Drawer(Guid id, [FromBody] DrawerOpInput
|
||||||
var userId = CurrentUserId();
|
var userId = CurrentUserId();
|
||||||
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
|
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
|
||||||
if (r.Status != MoneyServiceStatus.Ok)
|
if (r.Status != MoneyServiceStatus.Ok)
|
||||||
return Problem(r.Message,
|
return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.NotFound ? 404 : 400,
|
||||||
statusCode: r.Status switch
|
|
||||||
{
|
|
||||||
MoneyServiceStatus.NotFound => 404,
|
|
||||||
MoneyServiceStatus.InsufficientFunds => 409,
|
|
||||||
_ => 400,
|
|
||||||
},
|
|
||||||
type: $"https://food-market.kz/errors/{r.ErrorCode}");
|
type: $"https://food-market.kz/errors/{r.ErrorCode}");
|
||||||
return Ok(new { id = r.OperationId });
|
return Ok(new { id = r.OperationId });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ public record MoneyTxLinkRow(Guid? SupplyId, string? SupplyNumber, Guid? Supplie
|
||||||
public record MoneyTxRow(
|
public record MoneyTxRow(
|
||||||
Guid Id, string Number, DateTime Date,
|
Guid Id, string Number, DateTime Date,
|
||||||
MoneyTransactionKind Kind, MoneyTransactionStatus Status,
|
MoneyTransactionKind Kind, MoneyTransactionStatus Status,
|
||||||
MoneyTransactionCategory Category,
|
|
||||||
Guid AccountId, string AccountName,
|
Guid AccountId, string AccountName,
|
||||||
Guid? CounterpartyId, string? CounterpartyName,
|
Guid? CounterpartyId, string? CounterpartyName,
|
||||||
Guid? ExpenseItemId, string? ExpenseItemName,
|
Guid? ExpenseItemId, string? ExpenseItemName,
|
||||||
|
|
@ -101,7 +100,6 @@ public record MoneyTxInput(
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id, x.Number, x.Date, x.Kind, x.Status,
|
x.Id, x.Number, x.Date, x.Kind, x.Status,
|
||||||
x.Category,
|
|
||||||
x.AccountId, AccountName = _db.MoneyAccounts.Where(a => a.Id == x.AccountId).Select(a => a.Name).FirstOrDefault() ?? "",
|
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.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.ExpenseItemId, ExpenseItemName = x.ExpenseItemId == null ? null : _db.ExpenseItems.Where(e => e.Id == x.ExpenseItemId).Select(e => e.Name).FirstOrDefault(),
|
||||||
|
|
@ -126,7 +124,6 @@ public record MoneyTxInput(
|
||||||
|
|
||||||
var items = rows.Select(r => new MoneyTxRow(
|
var items = rows.Select(r => new MoneyTxRow(
|
||||||
r.Id, r.Number, r.Date, r.Kind, r.Status,
|
r.Id, r.Number, r.Date, r.Kind, r.Status,
|
||||||
r.Category,
|
|
||||||
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
|
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
|
||||||
r.ExpenseItemId, r.ExpenseItemName,
|
r.ExpenseItemId, r.ExpenseItemName,
|
||||||
r.CurrencyId, r.CurrencyCode,
|
r.CurrencyId, r.CurrencyCode,
|
||||||
|
|
@ -343,7 +340,6 @@ private Guid CurrentUserId()
|
||||||
MoneyServiceStatus.LinkedSumMismatch => BadRequest(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.WrongCurrency => BadRequest(new { code = r.ErrorCode, message = r.Message }),
|
||||||
MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }),
|
MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }),
|
||||||
MoneyServiceStatus.InsufficientFunds => Conflict(new { code = r.ErrorCode, message = r.Message }),
|
|
||||||
_ => BadRequest(new { code = r.ErrorCode, message = r.Message }),
|
_ => BadRequest(new { code = r.ErrorCode, message = r.Message }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -364,7 +360,6 @@ private Guid CurrentUserId()
|
||||||
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
|
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
|
||||||
return new MoneyTxRow(
|
return new MoneyTxRow(
|
||||||
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
|
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
|
||||||
mt.Category,
|
|
||||||
mt.AccountId, account, mt.CounterpartyId, counterparty,
|
mt.AccountId, account, mt.CounterpartyId, counterparty,
|
||||||
mt.ExpenseItemId, exp,
|
mt.ExpenseItemId, exp,
|
||||||
mt.CurrencyId, currency,
|
mt.CurrencyId, currency,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ public record OrgSettingsDto(
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices,
|
bool AllowFractionalPrices,
|
||||||
bool AllowNegativeBalance,
|
|
||||||
bool ShowReferencePriceOnProduct,
|
bool ShowReferencePriceOnProduct,
|
||||||
bool ShowCountryOfOriginOnProduct,
|
bool ShowCountryOfOriginOnProduct,
|
||||||
bool ShowDescriptionOnProduct);
|
bool ShowDescriptionOnProduct);
|
||||||
|
|
@ -50,7 +49,6 @@ public record OrgSettingsInput(
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices,
|
bool AllowFractionalPrices,
|
||||||
bool AllowNegativeBalance,
|
|
||||||
bool ShowReferencePriceOnProduct,
|
bool ShowReferencePriceOnProduct,
|
||||||
bool ShowCountryOfOriginOnProduct,
|
bool ShowCountryOfOriginOnProduct,
|
||||||
bool ShowDescriptionOnProduct);
|
bool ShowDescriptionOnProduct);
|
||||||
|
|
@ -89,7 +87,6 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
|
||||||
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
||||||
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
||||||
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
||||||
o.AllowNegativeBalance = input.AllowNegativeBalance;
|
|
||||||
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
||||||
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
||||||
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
|
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
|
||||||
|
|
@ -121,7 +118,6 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
||||||
o.ShowMarkedOnProduct,
|
o.ShowMarkedOnProduct,
|
||||||
o.ShowMinMaxStock,
|
o.ShowMinMaxStock,
|
||||||
o.AllowFractionalPrices,
|
o.AllowFractionalPrices,
|
||||||
o.AllowNegativeBalance,
|
|
||||||
o.ShowReferencePriceOnProduct,
|
o.ShowReferencePriceOnProduct,
|
||||||
o.ShowCountryOfOriginOnProduct,
|
o.ShowCountryOfOriginOnProduct,
|
||||||
o.ShowDescriptionOnProduct);
|
o.ShowDescriptionOnProduct);
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ public enum MoneyServiceStatus
|
||||||
LinkedSumMismatch = 9,
|
LinkedSumMismatch = 9,
|
||||||
WrongCurrency = 10,
|
WrongCurrency = 10,
|
||||||
AccountInactive = 11,
|
AccountInactive = 11,
|
||||||
InsufficientFunds = 12,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null)
|
public record MoneyServiceResult(MoneyServiceStatus Status, string? ErrorCode = null, string? Message = null)
|
||||||
|
|
|
||||||
|
|
@ -53,33 +53,10 @@ public enum MoneyTransactionStatus
|
||||||
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
|
/// отмена = отдельный флаг <see cref="Status"/>=Draft + пересчёт
|
||||||
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
|
/// <c>Supply.PayedSum</c> в сторону уменьшения, в одной транзакции
|
||||||
/// Postgres (см. <c>MoneyService.Unpost</c>).</summary>
|
/// Postgres (см. <c>MoneyService.Unpost</c>).</summary>
|
||||||
/// <summary>Происхождение/тип движения денег — для отчёта «Деньги» и для того,
|
|
||||||
/// чтобы отличать движения смены (создаются при закрытии кассовой смены) от
|
|
||||||
/// ручных платежей/ордеров. Manual — обычный платёж/ордер/перевод.</summary>
|
|
||||||
public enum MoneyTransactionCategory
|
|
||||||
{
|
|
||||||
Manual = 0,
|
|
||||||
RetailSalesCash = 1, // розничные продажи наличными (приход в кассу смены)
|
|
||||||
RetailReturnsCash = 2, // возвраты покупателям наличными (расход из кассы смены)
|
|
||||||
DrawerIn = 3, // внесение в кассу смены
|
|
||||||
DrawerOut = 4, // выплата из кассы смены
|
|
||||||
ShiftSurplus = 5, // излишек при закрытии смены (факт > должно быть)
|
|
||||||
ShiftShortage = 6, // недостача при закрытии смены (факт < должно быть)
|
|
||||||
RetailSalesCard = 7, // продажи картой (приход на эквайринг смены)
|
|
||||||
RetailReturnsCard = 8, // возвраты по карте (расход с эквайринга смены)
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MoneyTransaction : TenantEntity, IVersionedEntity
|
public class MoneyTransaction : TenantEntity, IVersionedEntity
|
||||||
{
|
{
|
||||||
public uint Xmin { get; set; }
|
public uint Xmin { get; set; }
|
||||||
|
|
||||||
/// <summary>Тип движения. Manual — ручной платёж/ордер/перевод; остальные —
|
|
||||||
/// движения кассовой смены (материализуются при закрытии).</summary>
|
|
||||||
public MoneyTransactionCategory Category { get; set; } = MoneyTransactionCategory.Manual;
|
|
||||||
|
|
||||||
/// <summary>Смена, породившая это движение (для движений смены). null — ручной документ.</summary>
|
|
||||||
public Guid? CashShiftId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
|
/// <summary>Человекочитаемый номер, уникальный в орге. Генерим по шаблону
|
||||||
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
|
/// <c>Д-{year}-{seq6}</c> (Д как «деньги»), общий счётчик на все виды
|
||||||
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли
|
/// MoneyTransaction — чтобы в списке «Платежи и ордера» документы шли
|
||||||
|
|
|
||||||
|
|
@ -73,11 +73,6 @@ public class Organization : Entity
|
||||||
/// дробное через API.</summary>
|
/// дробное через API.</summary>
|
||||||
public bool AllowFractionalPrices { get; set; }
|
public bool AllowFractionalPrices { get; set; }
|
||||||
|
|
||||||
/// <summary>Разрешить ли уходить счетам/кассам в минус. По умолчанию выкл.:
|
|
||||||
/// расход больше остатка (оплата, выплата из смены) отклоняется с 409.
|
|
||||||
/// Менять может только администратор организации.</summary>
|
|
||||||
public bool AllowNegativeBalance { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
|
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
|
||||||
/// Default: true.</summary>
|
/// Default: true.</summary>
|
||||||
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
||||||
|
|
|
||||||
|
|
@ -58,21 +58,6 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
|
||||||
return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive",
|
return MoneyServiceResult.Fail(MoneyServiceStatus.ValidationFailed, "amount-must-be-positive",
|
||||||
"Сумма платежа должна быть больше нуля.");
|
"Сумма платежа должна быть больше нуля.");
|
||||||
|
|
||||||
// Отрицательный остаток: расход больше остатка счёта → отказ,
|
|
||||||
// если орг не разрешила уходить в минус.
|
|
||||||
if (mt.Kind is MoneyTransactionKind.PaymentOut or MoneyTransactionKind.CashOut or MoneyTransactionKind.TransferOut)
|
|
||||||
{
|
|
||||||
var allowNeg = await _db.Organizations.IgnoreQueryFilters()
|
|
||||||
.Where(o => o.Id == mt.OrganizationId).Select(o => o.AllowNegativeBalance).FirstOrDefaultAsync(ct);
|
|
||||||
if (!allowNeg)
|
|
||||||
{
|
|
||||||
var balance = await AccountBalanceAsync(mt.AccountId, mt.Id, ct);
|
|
||||||
if (balance < mt.Amount - MoneyMath.Eps)
|
|
||||||
return MoneyServiceResult.Fail(MoneyServiceStatus.InsufficientFunds, "insufficient-funds",
|
|
||||||
$"На счёте «{mt.Account.Name}» недостаточно денег: остаток {balance:0.##} ₸, нужно {mt.Amount:0.##} ₸.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сумма связей не может превышать сумму документа.
|
// Сумма связей не может превышать сумму документа.
|
||||||
var linkedTotal = mt.Links.Sum(l => l.LinkedSum);
|
var linkedTotal = mt.Links.Sum(l => l.LinkedSum);
|
||||||
if (linkedTotal > mt.Amount + 0.0001m)
|
if (linkedTotal > mt.Amount + 0.0001m)
|
||||||
|
|
@ -311,11 +296,6 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
||||||
shift.Notes = notes;
|
shift.Notes = notes;
|
||||||
shift.UpdatedAt = DateTime.UtcNow;
|
shift.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
// Материализуем движения смены в money_transactions → остаток счетов и
|
|
||||||
// отчёт «Деньги» теперь учитывают продажи/возвраты/внесения/выплаты/расхождение.
|
|
||||||
await MaterializeShiftMovementsAsync(shift, salesCash, salesCard, returnsCash, returnsCard,
|
|
||||||
drawerIn, drawerOut, shift.CashDifference, ct);
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
await tx.CommitAsync(ct);
|
await tx.CommitAsync(ct);
|
||||||
_log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}",
|
_log.LogInformation("CashShift {Number} closed by {UserId}: expected={Expected} actual={Actual} diff={Diff}",
|
||||||
|
|
@ -341,20 +321,6 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
||||||
return new(MoneyServiceStatus.ShiftNotOpen, null,
|
return new(MoneyServiceStatus.ShiftNotOpen, null,
|
||||||
"shift-not-open", "Смена закрыта — добавлять операции нельзя.");
|
"shift-not-open", "Смена закрыта — добавлять операции нельзя.");
|
||||||
|
|
||||||
// Выплата больше наличных в кассе смены → отказ (если орг не разрешает минус).
|
|
||||||
if (direction == CashDrawerDirection.Out)
|
|
||||||
{
|
|
||||||
var allowNeg = await _db.Organizations.IgnoreQueryFilters()
|
|
||||||
.Where(o => o.Id == shift.OrganizationId).Select(o => o.AllowNegativeBalance).FirstOrDefaultAsync(ct);
|
|
||||||
if (!allowNeg)
|
|
||||||
{
|
|
||||||
var cashOnHand = await ShiftCashOnHandAsync(shift, ct);
|
|
||||||
if (cashOnHand < amount - MoneyMath.Eps)
|
|
||||||
return new(MoneyServiceStatus.InsufficientFunds, null, "insufficient-funds",
|
|
||||||
$"В кассе смены недостаточно наличных: есть {cashOnHand:0.##} ₸, нужно {amount:0.##} ₸.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var op = new CashDrawerOperation
|
var op = new CashDrawerOperation
|
||||||
{
|
{
|
||||||
OrganizationId = shift.OrganizationId,
|
OrganizationId = shift.OrganizationId,
|
||||||
|
|
@ -369,91 +335,4 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return new(MoneyServiceStatus.Ok, op.Id, null, null);
|
return new(MoneyServiceStatus.Ok, op.Id, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Остаток счёта = OpeningBalance + Σ приходов − Σ расходов по
|
|
||||||
/// проведённым movements (кроме исключённого документа).</summary>
|
|
||||||
private async Task<decimal> AccountBalanceAsync(Guid accountId, Guid? excludeTxId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var opening = await _db.MoneyAccounts.Where(a => a.Id == accountId)
|
|
||||||
.Select(a => a.OpeningBalance).FirstAsync(ct);
|
|
||||||
var q = _db.MoneyTransactions
|
|
||||||
.Where(m => m.AccountId == accountId && m.Status == MoneyTransactionStatus.Posted);
|
|
||||||
if (excludeTxId is Guid ex) q = q.Where(m => m.Id != ex);
|
|
||||||
var rows = await q.Select(m => new { m.Kind, m.Amount }).ToListAsync(ct);
|
|
||||||
var inSum = rows.Where(r => r.Kind is MoneyTransactionKind.PaymentIn or MoneyTransactionKind.CashIn or MoneyTransactionKind.TransferIn).Sum(r => r.Amount);
|
|
||||||
var outSum = rows.Where(r => r.Kind is MoneyTransactionKind.PaymentOut or MoneyTransactionKind.CashOut or MoneyTransactionKind.TransferOut).Sum(r => r.Amount);
|
|
||||||
return MoneyMath.AccountBalance(opening, inSum, outSum);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Наличные в кассе открытой смены: размен + продажи нал − возвраты
|
|
||||||
/// нал + внесения − выплаты (на текущий момент).</summary>
|
|
||||||
private async Task<decimal> ShiftCashOnHandAsync(CashShift shift, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var sales = await _db.RetailSales
|
|
||||||
.Where(s => s.CashShiftId == shift.Id && s.Status == RetailSaleStatus.Posted)
|
|
||||||
.Select(s => new { s.IsReturn, s.PaidCash }).ToListAsync(ct);
|
|
||||||
var salesCash = sales.Where(s => !s.IsReturn).Sum(s => s.PaidCash);
|
|
||||||
var returnsCash = sales.Where(s => s.IsReturn).Sum(s => s.PaidCash);
|
|
||||||
var ops = await _db.CashDrawerOperations.Where(o => o.CashShiftId == shift.Id)
|
|
||||||
.Select(o => new { o.Direction, o.Amount }).ToListAsync(ct);
|
|
||||||
var drawerIn = ops.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount);
|
|
||||||
var drawerOut = ops.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount);
|
|
||||||
return shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Материализует финансовые движения закрытой смены в money_transactions
|
|
||||||
/// (Posted) — продажи/возвраты нал (касса), внесения/выплаты (касса),
|
|
||||||
/// излишек/недостача (касса), продажи/возвраты по карте (эквайринг).
|
|
||||||
/// Идемпотентно по CashShiftId. Размен (OpeningFloat) не материализуется —
|
|
||||||
/// эти деньги уже были в кассе до смены.</summary>
|
|
||||||
private async Task MaterializeShiftMovementsAsync(CashShift shift,
|
|
||||||
decimal salesCash, decimal salesCard, decimal returnsCash, decimal returnsCard,
|
|
||||||
decimal drawerIn, decimal drawerOut, decimal difference, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var existing = await _db.MoneyTransactions.Where(m => m.CashShiftId == shift.Id).ToListAsync(ct);
|
|
||||||
if (existing.Count > 0) _db.MoneyTransactions.RemoveRange(existing);
|
|
||||||
|
|
||||||
var year = (shift.ClosedAt ?? DateTime.UtcNow).Year;
|
|
||||||
var baseNum = await MoneyNumbering.NextMoneyTxNumberAsync(_db, shift.OrganizationId, year, ct);
|
|
||||||
var seq = int.Parse(baseNum.Split('-')[2]);
|
|
||||||
var cashCur = await _db.MoneyAccounts.Where(a => a.Id == shift.CashAccountId).Select(a => a.CurrencyId).FirstAsync(ct);
|
|
||||||
Guid? acqId = shift.AcquiringAccountId;
|
|
||||||
var acqCur = acqId is Guid a ? await _db.MoneyAccounts.Where(x => x.Id == a).Select(x => x.CurrencyId).FirstAsync(ct) : Guid.Empty;
|
|
||||||
|
|
||||||
void Add(Guid accountId, Guid currencyId, MoneyTransactionKind kind, MoneyTransactionCategory cat, decimal amount, string purpose)
|
|
||||||
{
|
|
||||||
if (amount <= MoneyMath.Eps) return;
|
|
||||||
_db.MoneyTransactions.Add(new MoneyTransaction
|
|
||||||
{
|
|
||||||
OrganizationId = shift.OrganizationId,
|
|
||||||
Number = $"Д-{year}-{seq++:D6}",
|
|
||||||
Date = shift.ClosedAt ?? DateTime.UtcNow,
|
|
||||||
Kind = kind,
|
|
||||||
Category = cat,
|
|
||||||
Status = MoneyTransactionStatus.Posted,
|
|
||||||
AccountId = accountId,
|
|
||||||
CurrencyId = currencyId,
|
|
||||||
Amount = amount,
|
|
||||||
CashShiftId = shift.Id,
|
|
||||||
Purpose = $"{purpose} (смена {shift.Number})",
|
|
||||||
PostedAt = DateTime.UtcNow,
|
|
||||||
PostedByUserId = shift.ClosedByUserId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.RetailSalesCash, salesCash, "Розничные продажи (нал)");
|
|
||||||
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.RetailReturnsCash, returnsCash, "Возвраты покупателям (нал)");
|
|
||||||
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.DrawerIn, drawerIn, "Внесение в кассу");
|
|
||||||
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.DrawerOut, drawerOut, "Выплата из кассы");
|
|
||||||
if (difference > MoneyMath.Eps)
|
|
||||||
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashIn, MoneyTransactionCategory.ShiftSurplus, difference, "Излишек смены");
|
|
||||||
else if (difference < -MoneyMath.Eps)
|
|
||||||
Add(shift.CashAccountId, cashCur, MoneyTransactionKind.CashOut, MoneyTransactionCategory.ShiftShortage, -difference, "Недостача смены");
|
|
||||||
|
|
||||||
if (acqId is Guid aid)
|
|
||||||
{
|
|
||||||
Add(aid, acqCur, MoneyTransactionKind.PaymentIn, MoneyTransactionCategory.RetailSalesCard, salesCard, "Продажи картой");
|
|
||||||
Add(aid, acqCur, MoneyTransactionKind.PaymentOut, MoneyTransactionCategory.RetailReturnsCard, returnsCard, "Возвраты по карте");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,6 @@ public static void ConfigureMoney(this ModelBuilder b)
|
||||||
e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date });
|
e.HasIndex(x => new { x.OrganizationId, x.AccountId, x.Date });
|
||||||
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
|
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId })
|
||||||
.HasFilter("\"CounterpartyId\" IS NOT NULL");
|
.HasFilter("\"CounterpartyId\" IS NOT NULL");
|
||||||
// Движения смены (материализуются при закрытии) ищем по смене —
|
|
||||||
// идемпотентность и отчёт по смене.
|
|
||||||
e.HasIndex(x => x.CashShiftId).HasFilter("\"CashShiftId\" IS NOT NULL");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
b.Entity<MoneyTransactionLink>(e =>
|
b.Entity<MoneyTransactionLink>(e =>
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using foodmarket.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
|
||||||
{
|
|
||||||
/// <summary>Phase34 — движения кассовой смены попадают в деньги.
|
|
||||||
///
|
|
||||||
/// Добавляет:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>money_transactions.<c>Category</c> (integer, DEFAULT 0 = Manual) —
|
|
||||||
/// тип движения: розничные продажи/возвраты нал/карта, внесения/выплаты,
|
|
||||||
/// излишек/недостача смены.</item>
|
|
||||||
/// <item>money_transactions.<c>CashShiftId</c> (uuid, nullable) —
|
|
||||||
/// смена, породившая движение (материализуется при закрытии).</item>
|
|
||||||
/// <item>organizations.<c>AllowNegativeBalance</c> (boolean, DEFAULT false) —
|
|
||||||
/// разрешить ли уход счетов в минус.</item>
|
|
||||||
/// </list>
|
|
||||||
///
|
|
||||||
/// Идемпотентно (IF NOT EXISTS). Данные не пересчитываем: остаток счёта
|
|
||||||
/// вычисляемый (Opening + Σ Posted money_transactions); движения прошлых
|
|
||||||
/// закрытых смен материализуются ретроспективно отдельным backfill-методом
|
|
||||||
/// в DevDataSeeder (idempotent, по CashShiftId).</summary>
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20261008030000_Phase34_ShiftMoneyMovements")]
|
|
||||||
public partial class Phase34_ShiftMoneyMovements : Migration
|
|
||||||
{
|
|
||||||
protected override void Up(MigrationBuilder b)
|
|
||||||
{
|
|
||||||
b.Sql(@"
|
|
||||||
ALTER TABLE public.money_transactions
|
|
||||||
ADD COLUMN IF NOT EXISTS ""Category"" integer NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE public.money_transactions
|
|
||||||
ADD COLUMN IF NOT EXISTS ""CashShiftId"" uuid;
|
|
||||||
CREATE INDEX IF NOT EXISTS ""IX_money_transactions_CashShiftId""
|
|
||||||
ON public.money_transactions (""CashShiftId"")
|
|
||||||
WHERE ""CashShiftId"" IS NOT NULL;
|
|
||||||
ALTER TABLE public.organizations
|
|
||||||
ADD COLUMN IF NOT EXISTS ""AllowNegativeBalance"" boolean NOT NULL DEFAULT false;
|
|
||||||
");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Down(MigrationBuilder b)
|
|
||||||
{
|
|
||||||
b.Sql(@"
|
|
||||||
DROP INDEX IF EXISTS public.""IX_money_transactions_CashShiftId"";
|
|
||||||
ALTER TABLE public.money_transactions DROP COLUMN IF EXISTS ""CashShiftId"";
|
|
||||||
ALTER TABLE public.money_transactions DROP COLUMN IF EXISTS ""Category"";
|
|
||||||
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""AllowNegativeBalance"";
|
|
||||||
");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -41,17 +41,13 @@ interface Ctx {
|
||||||
supplies: SupplyRec[]
|
supplies: SupplyRec[]
|
||||||
pay: PayRec[]
|
pay: PayRec[]
|
||||||
shift?: ShiftRec
|
shift?: ShiftRec
|
||||||
closedShifts: ShiftContrib[]
|
|
||||||
reportFrom?: string
|
reportFrom?: string
|
||||||
reportTo?: string
|
reportTo?: string
|
||||||
}
|
}
|
||||||
/** Вклад закрытой смены в счета (для НЕЗАВИСИМого расчёта остатка в тесте):
|
|
||||||
* касса += actual − opening; эквайринг += salesCard − returnsCard. */
|
|
||||||
interface ShiftContrib { cashAcctId: string; actual: number; opening: number; acqAcctId?: string; salesCard: number; returnsCard: number }
|
|
||||||
interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean }
|
interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean }
|
||||||
interface EmpRec { id: string; email: string; password: string; role: string; token?: string }
|
interface EmpRec { id: string; email: string; password: string; role: string; token?: string }
|
||||||
interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number }
|
interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number }
|
||||||
interface PayRec { txId: string; supplyIdx: number; amount: number; account: string; accountId: string }
|
interface PayRec { txId: string; supplyIdx: number; amount: number; account: string }
|
||||||
interface ShiftRec {
|
interface ShiftRec {
|
||||||
id: string; pointId: string; cashId: string; openingFloat: number
|
id: string; pointId: string; cashId: string; openingFloat: number
|
||||||
salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number
|
salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number
|
||||||
|
|
@ -82,7 +78,6 @@ function initCtx(ctx: Ctx) {
|
||||||
ctx.emp ??= {}
|
ctx.emp ??= {}
|
||||||
ctx.supplies ??= []
|
ctx.supplies ??= []
|
||||||
ctx.pay ??= []
|
ctx.pay ??= []
|
||||||
ctx.closedShifts ??= []
|
|
||||||
ctx.cashOpening ??= 0
|
ctx.cashOpening ??= 0
|
||||||
ctx.bankOpening ??= 0
|
ctx.bankOpening ??= 0
|
||||||
ctx.acqOpening ??= 0
|
ctx.acqOpening ??= 0
|
||||||
|
|
@ -138,17 +133,6 @@ async function stockOf(api: AxiosInstance, storeId: string, productId: string):
|
||||||
const row = items.find((i) => i.storeId === storeId && i.productId === productId)
|
const row = items.find((i) => i.storeId === storeId && i.productId === productId)
|
||||||
return row ? Number(row.quantity) : 0
|
return row ? Number(row.quantity) : 0
|
||||||
}
|
}
|
||||||
// НЕЗАВИСИМЫЙ расчёт остатка счёта (считает сам тест из своих данных, не из
|
|
||||||
// money_transactions API): начальный − расходы теста + вклад закрытых смен
|
|
||||||
// (касса += факт − размен; эквайринг += продажи картой − возвраты картой).
|
|
||||||
function indepBalance(ctx: Ctx, accountId: string | undefined, opening: number): number {
|
|
||||||
if (!accountId) return opening
|
|
||||||
const payOut = ctx.pay.filter((p) => p.accountId === accountId).reduce((a, p) => a + p.amount, 0)
|
|
||||||
const shiftCash = ctx.closedShifts.filter((s) => s.cashAcctId === accountId).reduce((a, s) => a + (s.actual - s.opening), 0)
|
|
||||||
const shiftCard = ctx.closedShifts.filter((s) => s.acqAcctId === accountId).reduce((a, s) => a + (s.salesCard - s.returnsCard), 0)
|
|
||||||
return round2(opening - payOut + shiftCash + shiftCard)
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper: sum of movements for a product+store (инвариант)
|
// helper: sum of movements for a product+store (инвариант)
|
||||||
async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
|
async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
|
||||||
const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`)
|
const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`)
|
||||||
|
|
@ -816,32 +800,16 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) {
|
||||||
const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик)
|
const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик)
|
||||||
const s2 = ctx.supplies[1] // приёмка №2 (placeholder)
|
const s2 = ctx.supplies[1] // приёмка №2 (placeholder)
|
||||||
|
|
||||||
// №1 — частично наличными из кассы. Сумма НЕ должна превышать остаток кассы
|
// №1 — частично наличными из кассы
|
||||||
// (нач. 50000, пока без движений смены): берём ≤ 30000.
|
const partial = round2(s1.total * 0.4)
|
||||||
const partial = round2(Math.min(s1.total * 0.4, 30_000))
|
|
||||||
const p1 = await api.post('/api/money/transactions', {
|
const p1 = await api.post('/api/money/transactions', {
|
||||||
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
|
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
|
||||||
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial,
|
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial,
|
||||||
Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }],
|
Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }],
|
||||||
})
|
})
|
||||||
let ok1 = p1.status < 400 && !!p1.data?.id
|
let ok1 = p1.status < 400 && !!p1.data?.id
|
||||||
if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash', accountId: ctx.cash1Id! }); s1.paid = partial }
|
if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash' }); s1.paid = partial }
|
||||||
rec(step, 'api', 'Оплата №1 частично наличными из кассы (≤ остатка)', ok1, '204', `${p1.status}`)
|
rec(step, 'api', 'Оплата №1 частично наличными из кассы', ok1, '204', `${p1.status}`)
|
||||||
|
|
||||||
// Отказ при оплате больше остатка кассы (нет настройки «минус»).
|
|
||||||
const over = await api.post('/api/money/transactions', {
|
|
||||||
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
|
|
||||||
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: round2(ctx.cashOpening + 100_000),
|
|
||||||
Purpose: 'Переплата (тест отказа)', Links: [{ SupplyId: s1.id, LinkedSum: 0 }], ExpenseItemId: null,
|
|
||||||
})
|
|
||||||
if (over.data?.id) {
|
|
||||||
const overPost = await api.post(`/api/money/transactions/${over.data.id}/post`, {})
|
|
||||||
rec(step, 'api', 'Оплата больше остатка кассы → отказ (409 insufficient-funds)', overPost.status === 409, 409, overPost.status)
|
|
||||||
if (overPost.status !== 409) { await api.post(`/api/money/transactions/${over.data.id}/unpost`, {}); report.bug({ step: '08', severity: 'critical', title: 'Расход больше остатка кассы прошёл (касса в минус)', detail: `post=${overPost.status}` }) }
|
|
||||||
await api.delete(`/api/money/transactions/${over.data.id}`)
|
|
||||||
} else {
|
|
||||||
rec(step, 'api', 'Оплата больше остатка кассы → отказ', over.status === 409, 409, over.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// №2 — полностью с банковского счёта
|
// №2 — полностью с банковского счёта
|
||||||
const p2 = await api.post('/api/money/transactions', {
|
const p2 = await api.post('/api/money/transactions', {
|
||||||
|
|
@ -850,7 +818,7 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) {
|
||||||
Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }],
|
Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }],
|
||||||
})
|
})
|
||||||
let ok2 = p2.status < 400 && !!p2.data?.id
|
let ok2 = p2.status < 400 && !!p2.data?.id
|
||||||
if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank', accountId: ctx.bankId! }); s2.paid = s2.total }
|
if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank' }); s2.paid = s2.total }
|
||||||
rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`)
|
rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`)
|
||||||
|
|
||||||
// статусы оплаты (PayedSum)
|
// статусы оплаты (PayedSum)
|
||||||
|
|
@ -958,16 +926,6 @@ export async function step09_cashier_shift({ ctx, step, report }: StepCtx) {
|
||||||
rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`)
|
rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`)
|
||||||
|
|
||||||
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
|
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
|
||||||
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: actualCash, opening: openingFloat, acqAcctId: ctx.acq1Id, salesCard, returnsCard: 0 })
|
|
||||||
|
|
||||||
// Движения смены попали в остаток кассы: касса выросла на (факт − размен).
|
|
||||||
const balAfter = await api.get('/api/money/reports/balances')
|
|
||||||
const cashBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.cash1Id)?.currentBalance ?? 0
|
|
||||||
const expCashAfter = round2(ctx.cashOpening - sum(ctx.pay.filter((p) => p.accountId === ctx.cash1Id).map((p) => p.amount)) + (actualCash - openingFloat))
|
|
||||||
rec(step, 'api', 'Деньги смены в остатке кассы (касса += факт − размен)', approxEq(cashBal, expCashAfter), expCashAfter, cashBal)
|
|
||||||
if (!approxEq(cashBal, expCashAfter)) report.bug({ step: '09', severity: 'critical', title: 'Движения смены не попали в остаток кассы', detail: `ожид=${expCashAfter} факт=${cashBal}` })
|
|
||||||
const acqBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.acq1Id)?.currentBalance ?? 0
|
|
||||||
rec(step, 'api', 'Продажи картой в остатке эквайринга', approxEq(acqBal, salesCard), salesCard, acqBal)
|
|
||||||
|
|
||||||
// продажа без смены → отказ (смена уже закрыта)
|
// продажа без смены → отказ (смена уже закрыта)
|
||||||
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
|
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
|
||||||
|
|
@ -1032,13 +990,11 @@ export async function step09a_points_binding({ ctx, step, report }: StepCtx) {
|
||||||
const exp1 = round2(1000 + sp1.paidCash)
|
const exp1 = round2(1000 + sp1.paidCash)
|
||||||
const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' })
|
const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' })
|
||||||
rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash)
|
rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash)
|
||||||
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: exp1, opening: 1000, acqAcctId: ctx.acq1Id, salesCard: 0, returnsCard: 0 })
|
|
||||||
}
|
}
|
||||||
if (shift2) {
|
if (shift2) {
|
||||||
const exp2 = round2(2000 + sp2.paidCash)
|
const exp2 = round2(2000 + sp2.paidCash)
|
||||||
const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' })
|
const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' })
|
||||||
rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash)
|
rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash)
|
||||||
ctx.closedShifts.push({ cashAcctId: ctx.cash2Id!, actual: exp2, opening: 2000, acqAcctId: ctx.acq2Id, salesCard: 0, returnsCard: 0 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// продажа на НЕактивной точке → отказ
|
// продажа на НЕактивной точке → отказ
|
||||||
|
|
@ -1189,16 +1145,13 @@ export async function step11_reports({ ctx, step, report }: StepCtx) {
|
||||||
// отчёт «Деньги»: остаток каждого счёта = начальный + движения
|
// отчёт «Деньги»: остаток каждого счёта = начальный + движения
|
||||||
const bal = await api.get('/api/money/reports/balances')
|
const bal = await api.get('/api/money/reports/balances')
|
||||||
const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[]
|
const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[]
|
||||||
// остаток счёта = начальный + платежи + ДВИЖЕНИЯ СМЕН (независимый расчёт).
|
// касса: opening − оплата №1 наличными (partial); банк: opening − оплата №2
|
||||||
|
const paidCash = ctx.pay.filter((p) => p.account === 'cash').reduce((a, p) => a + p.amount, 0)
|
||||||
|
const paidBank = ctx.pay.filter((p) => p.account === 'bank').reduce((a, p) => a + p.amount, 0)
|
||||||
const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id)
|
const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id)
|
||||||
const expCash = indepBalance(ctx, ctx.cash1Id, ctx.cashOpening)
|
rec(step, 'api', 'Деньги: касса = начальный − оплата наличными', !!cashAcc && approxEq(cashAcc.currentBalance, round2(ctx.cashOpening - paidCash)), round2(ctx.cashOpening - paidCash), cashAcc?.currentBalance)
|
||||||
rec(step, 'api', 'Деньги: касса = начальный − оплаты + движения смен', !!cashAcc && approxEq(cashAcc.currentBalance, expCash), expCash, cashAcc?.currentBalance)
|
|
||||||
const bankAcc = accs.find((a) => a.accountId === ctx.bankId)
|
const bankAcc = accs.find((a) => a.accountId === ctx.bankId)
|
||||||
const expBank = indepBalance(ctx, ctx.bankId, ctx.bankOpening)
|
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, round2(ctx.bankOpening - paidBank)), round2(ctx.bankOpening - paidBank), bankAcc?.currentBalance)
|
||||||
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, expBank), expBank, bankAcc?.currentBalance)
|
|
||||||
const acqAcc = accs.find((a) => a.accountId === ctx.acq1Id)
|
|
||||||
const expAcq = indepBalance(ctx, ctx.acq1Id, ctx.acqOpening)
|
|
||||||
rec(step, 'api', 'Деньги: эквайринг = продажи картой − возвраты картой', !!acqAcc && approxEq(acqAcc.currentBalance, expAcq), expAcq, acqAcc?.currentBalance)
|
|
||||||
|
|
||||||
// долги поставщикам
|
// долги поставщикам
|
||||||
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true')
|
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true')
|
||||||
|
|
@ -1241,22 +1194,16 @@ export async function step12_invariants({ ctx, step, report }: StepCtx) {
|
||||||
}
|
}
|
||||||
if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' })
|
if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' })
|
||||||
|
|
||||||
// 2) по каждому счёту: остаток ≡ НЕЗАВИСИМО посчитанной в тесте сумме
|
// 2) по каждому счёту: баланс ≡ начальный + Σ проведённых операций
|
||||||
// (начальный + платежи + движения смен), а НЕ сумме из того же API.
|
|
||||||
const bal = await api.get('/api/money/reports/balances')
|
const bal = await api.get('/api/money/reports/balances')
|
||||||
const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[]
|
const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[]
|
||||||
const known: [string | undefined, number, string][] = [
|
for (const a of accs) {
|
||||||
[ctx.cash1Id, ctx.cashOpening, 'Касса'],
|
const txs = await api.get(`/api/money/transactions?accountId=${a.accountId}&status=1&pageSize=500`)
|
||||||
[ctx.bankId, ctx.bankOpening, 'Банк'],
|
const items = (txs.data?.items ?? []) as { kind: number; amount: number }[]
|
||||||
[ctx.acq1Id, ctx.acqOpening, 'Эквайринг'],
|
const inSum = sum(items.filter((t) => [1, 3, 6].includes(t.kind)).map((t) => Number(t.amount)))
|
||||||
[ctx.cash2Id, 3000, 'Касса-2'],
|
const outSum = sum(items.filter((t) => [2, 4, 5].includes(t.kind)).map((t) => Number(t.amount)))
|
||||||
]
|
const expected = round2(Number(a.openingBalance) + inSum - outSum)
|
||||||
for (const [acctId, opening, label] of known) {
|
rec(step, 'api', `Инвариант счёт≡начальный+Σопераций: ${a.name.slice(0, 16)}`, approxEq(Number(a.currentBalance), expected), expected, a.currentBalance)
|
||||||
if (!acctId) continue
|
|
||||||
const a = accs.find((x) => x.accountId === acctId)
|
|
||||||
const expected = indepBalance(ctx, acctId, opening)
|
|
||||||
rec(step, 'api', `Инвариант ${label} ≡ независимому расчёту (нач+платежи+смены)`, !!a && approxEq(Number(a.currentBalance), expected), expected, a?.currentBalance)
|
|
||||||
if (a && !approxEq(Number(a.currentBalance), expected)) report.bug({ step: '12', severity: 'critical', title: `Остаток счёта ${label} ≠ независимой формуле`, detail: `ожид=${expected} факт=${a.currentBalance}` })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) долг поставщика ≡ формуле (пересчёт независимо)
|
// 3) долг поставщика ≡ формуле (пересчёт независимо)
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ public async Task Partial_then_full_payment_moves_supply_status_unpaid_partial_p
|
||||||
var refs = await api.LoadRefsAsync();
|
var refs = await api.LoadRefsAsync();
|
||||||
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
||||||
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
|
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
|
||||||
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 1_000m);
|
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m);
|
||||||
|
|
||||||
var (supplyId, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, qty: 10m, unitPrice: 100m);
|
var (supplyId, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, qty: 10m, unitPrice: 100m);
|
||||||
total.Should().Be(1000m);
|
total.Should().Be(1000m);
|
||||||
|
|
@ -122,7 +122,7 @@ public async Task Overpaying_supply_is_rejected()
|
||||||
var refs = await api.LoadRefsAsync();
|
var refs = await api.LoadRefsAsync();
|
||||||
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
||||||
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
|
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
|
||||||
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 900m);
|
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m);
|
||||||
var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 5m, 100m); // total 500
|
var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 5m, 100m); // total 500
|
||||||
|
|
||||||
// Платёж на 800 с привязкой 800 → при проведении переплата → 409.
|
// Платёж на 800 с привязкой 800 → при проведении переплата → 409.
|
||||||
|
|
@ -146,7 +146,7 @@ public async Task Supplier_debt_report_reflects_supplies_minus_payments()
|
||||||
var refs = await api.LoadRefsAsync();
|
var refs = await api.LoadRefsAsync();
|
||||||
var supplier = await api.CreateCounterpartyAsync($"Долговой-{Guid.NewGuid():N}");
|
var supplier = await api.CreateCounterpartyAsync($"Долговой-{Guid.NewGuid():N}");
|
||||||
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
|
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode());
|
||||||
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 1_000m);
|
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m);
|
||||||
var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // 1000
|
var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // 1000
|
||||||
await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 300m);
|
await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 300m);
|
||||||
|
|
||||||
|
|
@ -211,51 +211,6 @@ public async Task Closing_shift_computes_expected_cash_and_difference()
|
||||||
body.GetProperty("difference").GetDecimal().Should().Be(0m);
|
body.GetProperty("difference").GetDecimal().Should().Be(0m);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Closing_shift_posts_cash_sales_into_cash_account_balance()
|
|
||||||
{
|
|
||||||
var api = new ApiActor(_factory.CreateClient());
|
|
||||||
await api.SignupAndLoginAsync($"shbal-{Guid.NewGuid():N}");
|
|
||||||
var refs = await api.LoadRefsAsync();
|
|
||||||
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
|
||||||
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode());
|
|
||||||
await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m);
|
|
||||||
var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Т1");
|
|
||||||
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m);
|
|
||||||
|
|
||||||
var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m);
|
|
||||||
var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, 2m, 500m); // 1000 нал
|
|
||||||
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode();
|
|
||||||
(await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null })).EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
// Остаток кассы = 0 (нач. счёта) + (6000 факт − 5000 размен) = 1000 (продажа нал).
|
|
||||||
var balances = await api.GetJsonAsync("/api/money/reports/balances");
|
|
||||||
var row = balances.GetProperty("accounts").EnumerateArray().First(x => x.GetProperty("accountId").GetString() == cash);
|
|
||||||
row.GetProperty("currentBalance").GetDecimal().Should().Be(1_000m);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Cash_expense_over_balance_is_rejected()
|
|
||||||
{
|
|
||||||
var api = new ApiActor(_factory.CreateClient());
|
|
||||||
await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}");
|
|
||||||
var refs = await api.LoadRefsAsync();
|
|
||||||
var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 5_000m);
|
|
||||||
|
|
||||||
var items = await api.ListAsync("/api/money/expense-items?pageSize=50");
|
|
||||||
var expenseItemId = items.First().GetProperty("id").GetString();
|
|
||||||
var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new
|
|
||||||
{
|
|
||||||
date = DateTime.UtcNow, kind = 4, accountId = cash, currencyId = refs.CurrencyId,
|
|
||||||
amount = 10_000m, expenseItemId, purpose = "big",
|
|
||||||
});
|
|
||||||
created.EnsureSuccessStatusCode();
|
|
||||||
var txId = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
||||||
// 10 000 > остаток 5 000 → 409 (орг не разрешает минус).
|
|
||||||
var post = await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { });
|
|
||||||
((int)post.StatusCode).Should().Be(409);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────
|
||||||
private static async Task PostTxAsync(ApiActor api, int kind, string accountId, decimal amount, string currencyId,
|
private static async Task PostTxAsync(ApiActor api, int kind, string accountId, decimal amount, string currencyId,
|
||||||
bool needExpenseItem = false, ApiActor? api2 = null)
|
bool needExpenseItem = false, ApiActor? api2 = null)
|
||||||
|
|
|
||||||
|
|
@ -117,18 +117,4 @@ public void RolePermissions_default_denies_money()
|
||||||
p.MoneyEdit.Should().BeFalse();
|
p.MoneyEdit.Should().BeFalse();
|
||||||
p.CashShiftOperate.Should().BeFalse();
|
p.CashShiftOperate.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Shift_net_cash_movement_equals_actual_minus_opening()
|
|
||||||
{
|
|
||||||
// Вклад смены в кассу = продажи нал − возвраты нал + внесения − выплаты +
|
|
||||||
// расхождение = (expected − размен) + (факт − expected) = факт − размен.
|
|
||||||
// Числа — из факта стенда (смена С-2026-000001).
|
|
||||||
decimal opening = 5_000m, salesCash = 3_775m, returnsCash = 1_500m, drawerIn = 10_000m, drawerOut = 3_000m;
|
|
||||||
var expected = MoneyMath.ExpectedCash(opening, salesCash, returnsCash, drawerIn, drawerOut);
|
|
||||||
decimal actual = 14_775m;
|
|
||||||
var diff = MoneyMath.CashDifference(actual, expected);
|
|
||||||
var net = salesCash - returnsCash + drawerIn - drawerOut + diff;
|
|
||||||
net.Should().Be(actual - opening);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue