diff --git a/docs/money-moysklad-reference.md b/docs/money-moysklad-reference.md index ab86837..292b977 100644 --- a/docs/money-moysklad-reference.md +++ b/docs/money-moysklad-reference.md @@ -1,257 +1,39 @@ -# МойСклад — опорные факты для модуля «Деньги» +# МойСклад — розничные деньги (reference для модели смены) -Собрано 2026-10-07 через headless Playwright со страниц -`https://dev.moysklad.ru/doc/api/remap/1.2/#/…`. Код скрейпера: -`tests/e2e/scrape-moysklad.ts`, сырые дампы: `/tmp/moysklad-dump/*.txt`. +Как в МойСклад розничные операции отражаются в деньгах (по +`https://dev.moysklad.ru/doc/api/remap/1.2/documents/` и известной семантике; +страница docs отдаётся JS-ом, поэтому ниже — сводка модели, а не дословная цитата). -Задача этого документа — зафиксировать **именно те поля и связи -МойСклада**, которые мы должны уметь воспроизводить. Своими названиями -колонок/эндпоинтов пользуемся по смыслу их, чтобы потом синхронизация -легла без хирургии. +## Сущности +- **Розничная смена** `retailshift` — контейнер смены на точке (ККМ). Хранит + начальный остаток (размен), суммы продаж нал/безнал, возвраты, внесения/изъятия, + итоговые суммы на закрытие. +- **Розничная продажа** `retaildemand` — чек. Внутри — оплата: `cashSum` (нал) и + `noCashSum`/`bankPercent` (безнал, эквайринг). Привязана к смене. +- **Розничный возврат** `retailsalesreturn` — возврат покупателю (нал/безнал). +- **Внесение/Изъятие** `retaildrawercashin` / `retaildrawercashout` — движение + наличных в кассе ККМ внутри смены (внесение разменом, изъятие инкассацией). +- **Приходный/расходный ордер** `cashin` / `cashout`, платёж `paymentin`/`paymentout` + — общие денежные документы (не розничные), влияют на остаток счёта/кассы. ---- +## Как попадает в деньги +- Касса (денежный счёт типа «касса») = размен + Σ продаж нал − Σ возвратов нал + + Σ внесений − Σ изъятий. Безнал (карты) идёт на привязанный **расчётный счёт/ + эквайринг**, отдельно от наличной кассы. +- Каждый `retaildemand`/`retailsalesreturn`/`drawercashin|out` — это движение по + счёту; в отчёте «Деньги» (движение ДС) они видны по типам. +- **Закрытие смены**: фиксируется фактический остаток наличных; **расхождение** + (факт − расчёт) оформляется как излишек/недостача — отдельное движение кассы. +- Размен (начальный остаток смены) — НЕ новое поступление: это те же деньги, что + уже в кассе; он не задваивается как приход. -## 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` — не нужно, у магазина нет - документооборота с банком. - -Эти поля не создаём в простом режиме, но резервируем места в именованиях -колонок на будущее. +## Наша адаптация (food-market, простой режим) +- Материализуем движения смены в `money_transactions` ПРИ ЗАКРЫТИИ (а не по каждому + чеку) — проще для простого магазина, остаток кассы/эквайринга становится верным + сразу после закрытия. Типы — `MoneyTransactionCategory` (RetailSalesCash/…/ShiftSurplus). +- Карта → отдельный счёт «Эквайринг» смены (как расчётный счёт в МойСклад). +- Размен не материализуем (деньги уже в кассе). Итог кассы после закрытия = факту + наличных, если по кассе не было других операций. +- Отличие: МойСклад проводит `retaildemand` в деньги сразу (живой остаток в течение + смены); у нас остаток кассы меняется при закрытии, но «живой» нал смены для + контроля выплат считается на лету (размен + продажи − возвраты + внесения − выплаты). diff --git a/docs/money-shift-balances-progress.md b/docs/money-shift-balances-progress.md new file mode 100644 index 0000000..9466b3f --- /dev/null +++ b/docs/money-shift-balances-progress.md @@ -0,0 +1,79 @@ +# Деньги смены → остатки счетов (прогресс) + +Автономная задача владельца (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 подряд зелёные. diff --git a/docs/money.md b/docs/money.md index 925822f..982e9cb 100644 --- a/docs/money.md +++ b/docs/money.md @@ -151,3 +151,30 @@ **Ошибся в смене/платеже.** Платёж можно «Отменить проведение» и исправить. Закрытая смена неизменяема — расхождение фиксируется как разница (недостача/ излишек) и видно в Z-отчёте. + +## Как деньги смены попадают в остатки счетов (с 08.10.2026) + +Раньше продажи/возвраты/внесения/выплаты смены не влияли на остаток кассы — это +была ошибка. Теперь при **закрытии смены** эти движения автоматически попадают в +деньги по типам: + +- **Касса смены:** + Розничные продажи (нал) − Возвраты покупателям (нал) + + Внесения − Выплаты ± Излишек/Недостача. +- **Эквайринг смены:** + Продажи картой − Возвраты по карте. +- **Размен** (начальный остаток смены) движением не считается — это те же деньги, + что уже были в кассе. Поэтому остаток кассы после закрытия = фактической сумме + наличных, если по кассе не было других операций. + +Эти строки видны в отчёте «Деньги» (движение) и в списке «Платежи и ордера» +(с пометкой смены). Остаток каждого счёта = Начальный остаток + все проведённые +движения (платежи/ордера/переводы + движения смен). + +## Отрицательный остаток + +По умолчанию счёт/касса **не может уйти в минус**: если расход (оплата, перевод, +выплата из смены) больше остатка — операция отклоняется с сообщением +«На счёте «…» недостаточно денег: остаток X ₸, нужно Y ₸». Выплата из смены +сверяется с наличными, которые реально есть в кассе на этот момент. + +Включить минус может только администратор: Настройки организации → +«Разрешить отрицательные остатки».