Эпик 7 «Гарантийный возврат имущества» MVP Стола заказов (598-10) (#397)

* [598-10][@ant] feat: backend Эпика 7 «Гарантийный возврат» (Stories 7.1-7.4) — завести MarketplaceReturnClaimService с 5 mutations (submretrn/aprretrem/rejretrem/accretrn/rejretrn) и хранилище фото в bucket stol-zakazov:images

— Domain: ReturnClaim entity + types (5 статусов pendrev/approvvisit/accepted/rejremote/rejatku с маппингом на ReturnStatus::* контракта), decision_log append-only журнал решений председателя, on_site_inspection-снапшот очного осмотра, ledger_snapshot снапшот compensating-forward пары после accretrn. Photo type хранит bucket_key + sha256 content_hash + mime_type — хеш используется как анкер on-chain (параметр photos[] submretrn).
— Repository: create/findById/findByRequestHash/findActiveByOrderId (idempotency check) /listByOrderer/listByDeliveryBraname/applyDecision (append к decision_log + опциональная фиксация on_site_inspection / ledger_snapshot). Partial-unique индекс (coopname, order_id) ограниченный активными статусами — пайщик не может одновременно открыть две заявки на один заказ.
— Canonical blockchain port + adapter: 5 новых методов submRetrn/aprRetRem/rejRetRem/accRetrn/rejRetrn — обёртки над marketplace::submretrn/aprretrem/rejretrem/accretrn/rejretrn (контракты уже реализованы в cpp/marketplace/src/p.mkt.return/). accretrn — композитная транзакция через транзит 91: o.mkt.return + o.mkt.return2 (compensating forward к o.mkt.consum, AR9/AR14 L3).
— MarketplaceReturnClaimService: submitReturnClaim (Story 7.1: валидация warranty_until>now, reason_text 1-500, photos 1-10, request_hash детерминирован от order_hash+orderer+actual_quantity, фото загружаются в bucket до on-chain submit, on-chain photos[] = sha256-хеши), approveReturnVisit/rejectReturnRemote (Story 7.2 FR31: PENDING_CHAIRMAN_REVIEW → APPROVED_FOR_VISIT/REJECTED_REMOTELY, comment обязателен), acceptReturnAtVisit/rejectReturnAtVisit (Story 7.3-7.4 FR32-FR33: APPROVED_FOR_VISIT → ACCEPTED_AT_VISIT/REJECTED_AT_VISIT, при accept атомарно compensating forward o.mkt.return+o.mkt.return2 восстанавливает w.mkt.member.available заказчика и возвращает имущество на склад КУ, barcode-сверка с Order). getReturnClaimSignablePayload — preview через factory registry_id=800 (ReturnByAssetStatement, переиспользован для членской модели по стандарту p.mkt.return.standard.yaml).
— MarketplaceReturnClaimImagesService: @UseBucket('stol-zakazov:images') — конвенция «один сервис на один bucket» из README file-storage. maxBytes 10MB, allowedMime jpeg/png/webp, metadataSchema требует ownerAccount+orderId+claimId. Ключ объекта детерминирован: returns/<claim_id>/<role>/<index>.<ext>; backend хеширует bytes (sha256-hex) — этот же хеш идёт on-chain в submretrn.photos[].
— Resolver + DTO: 5 mutations + 3 queries (marketplaceListMyReturnClaims, marketplaceListReturnClaimsByBraname, marketplaceReturnClaimSignablePayload, marketplaceReturnClaim) + Photo/Decision/Inspection/Ledger ObjectType. GraphQL enum через registerEnumType (MarketplaceReturnClaimStatus / MarketplaceReturnClaimDefectCategory / MarketplaceReturnClaimExpectedResolution). Подписанное заявление приходит как SignedDigitalDocumentInputDTO meta=MarketplaceReturnClaimSignedStatementInputDTO; toDocument() конвертит в Cooperative.Registry.ReturnByAssetStatement.Action для submretrn.statement.
— Signed-document DTO: переиспользуем шаблон 800 «Заявление на возврат паевого взноса имуществом» (по стандарту p.mkt.return — структурно совместим с членской моделью; поля request.hash/title/units/unit_cost/total_cost заполняются из Order). request.hash = backend-computed request_hash → двусторонняя сверка backend ↔ on-chain.
— Access-matrix: orderer ['create:own', 'read:own'] на ReturnClaim, operator ['read:own-KU', 'decide:remote', 'decide:on-site']. resolver-уровень проверка ownership (orderer_account == member.username, braname == председатель чьего КУ — отложено до core coop_ku.chairman_account, в MVP передаётся параметром).
— Регистрация в marketplace-infrastructure.module.ts (TypeORM entity + repository) и marketplace-application.module.ts (FileStorageInfrastructureModule.forFeature + service + resolver).

Why: реализация Эпика 7 «Гарантийный возврат имущества» MVP «Стол Заказов» — пайщик подаёт заявление с фото-доказательством, председатель КУ удалённо рассматривает и при необходимости приглашает на очный осмотр, при принятии возврата атомарно через транзит 91 восстанавливаются средства на программном кошельке заказчика и имущество возвращается на склад участка (compensating forward — отдельное событие в журнале, не revert исходного o.mkt.consum). Возврат поставщику и работа с поставщиком по претензиям — Phase 2 (out of MVP).

* [598-10][@ant] feat: notifications Эпика 7 — 3 Novu workflow и слушатели per-contract event-bus (submitted/decided/finalized) для гарантийного возврата

— @coopenomics/notifications: 3 workflow: marketplace-return-claim-submitted (председателю КУ на новое заявление; email + in-app + push с deep-link на operator-стол), marketplace-return-claim-decided (заказчику на промежуточные решения председателя — invite на очный визит), marketplace-return-claim-finalized (заказчику на финальный исход с восстановленной суммой если accept_at_visit).
— controller: 3 события в marketplace-notification.events.ts — MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT/DECIDED/FINALIZED + типы payload. Сервис эмитит их после save в PG (INV-12).
— marketplace-notification.service.ts: 3 listener (handleReturnClaimSubmitted/Decided/Finalized) тянут subscriber_id/email из AccountDataPort, формируют payload с display-name и deep-link через config.frontend_url, шлют через NovuWorkflowPort. Ошибки доставки logged как warn — основной flow возврата не блокируется.

Why: уведомления (push/email/in-app) — обязательный канал коммуникации по UX-DR38; заявление на гарантийный возврат должно дойти до председателя КУ без поляризации UI (председатель не всегда сидит в operator-столе), решение председателя — до заказчика без необходимости вручную проверять статус заявления.

* [598-10][@ant] feat: SDK Эпика 7 — Zeus selectors, 5 mutations и 3 queries для гарантийного возврата + перегенерированная schema.gql

— Selectors: marketplaceReturnClaimSelector (полный набор полей заявления вместе с photos / decision_log / on_site_inspection / ledger_snapshot), marketplaceReturnClaimResultSelector (claim + tx_hash) и поднаборы (photo / decision-entry / inspection / ledger). Валидаторы MakeAllFieldsRequired гарантируют полное покрытие type.
— Mutations: CreateReturnClaim / ApproveReturnVisit / RejectReturnRemote / AcceptReturnAtVisit / RejectReturnAtVisit. Каждая принимает соответствующий Input DTO и возвращает marketplaceReturnClaimResultSelector.
— Queries: ListMyReturnClaims (без args), ListReturnClaimsByBraname (data.delivery_braname), ReturnClaimSignablePayload (data.order_id + actual_quantity? — preview registry_id=800 для подписи пайщиком).
— schema.gql пересчитана; zeus/const.ts + zeus/index.ts перегенерированы. Все ID-поля заявления через @Field(() => String) (Zeus резолвит scalar ID как unknown без глобального scalar-resolver — поэтому Order и ReturnClaim единообразно используют String).

Why: типобезопасный SDK-слой между backend GraphQL и desktop UI; правило «никаких raw GraphQL-строк в desktop» (feedback_graphql_no_raw_strings_desktop) требует прохода через Zeus client.

* [598-10][@ant] feat: desktop UI Эпика 7 — orderer и operator страницы возврата на канон-widgets (TakeoverDialog + BarcodeScanner)

— OrdererReturnClaims (страница /:coopname/market/returns): список заявлений пайщика разделён на «активные» (PENDING_CHAIRMAN_REVIEW + APPROVED_FOR_VISIT) и «архив» (финальные статусы). Подача нового заявления через SubmitReturnClaimDialog — full-screen TakeoverDialog с q-stepper 3 шага: описание (reason_text + defect_category + actual_quantity?) → фото (q-file 1-10 шт jpeg/png/webp до 10МБ → base64) → подпись заявления (preview registry_id=800 + Classes.Document.signDocument приватным ключом из useGlobalStore). Просмотр через ReturnClaimDetailsDialog — фото-thumbnails со ссылками на signed-URL bucket'а, q-timeline decision_log председателя, снапшот compensating-forward при ACCEPTED_AT_VISIT.
— OperatorReturnClaims (страница /:coopname/market-pvz/returns): три ленты — pending (PENDING_CHAIRMAN_REVIEW) с inline-thumbnails фото и кнопкой «Принять решение» → RemoteDecisionDialog, approved (APPROVED_FOR_VISIT) с кнопкой «Очный осмотр» → OnSiteDecisionDialog, archive. RemoteDecisionDialog — q-option-group approve/reject + обязательный комментарий 1-500 симв. OnSiteDecisionDialog — q-stepper: 1) BarcodeScanner сканирует штрих-код имущества для сверки с Order; 2) inspection_result 1-2000 симв; 3) опциональные inspection_photos до 10 шт; 4) accept (compensating forward предупреждение) либо reject (имущество остаётся у заказчика).
— API: типобезопасные обёртки в pages/<page>/api/index.ts через destructuring результата `{ [Queries.X.name]: result } = await client.Query(...)`. Типы выведены из Queries/Mutations.X.IOutput без cast'ов. Фото-загрузка через FileReader→base64 в browser, backend кладёт в bucket stol-zakazov:images.
— DateTime utility: `formatDateTime(value: unknown)` для GraphQL DateTime scalar — Zeus резолвит как unknown без глобального scalar-resolver, простой helper парсит и форматирует через toLocaleString('ru-RU').
— install.ts: 2 новых маршрута зарегистрированы — workspace 'market' (orderer-стол) → '/:coopname/market/returns' (roles []); workspace 'market-pvz' (operator-стол) → '/:coopname/market-pvz/returns' (roles ['chairman']). Иконки fa-rotate-left / fa-clipboard-check; agreements: agreementsBase.

Why: продолжение поведенческой части Эпика 7 — пайщик и председатель кооперативного участка получают функциональные UI для подачи и рассмотрения заявлений на гарантийный возврат, со всеми атрибутами (фото, подпись заявления, барскод-сверка, compensating forward) согласно FR29-FR33. Соблюдён канон дизайн-системы Эпика 10: используются только widgets/Marketplace/* (TakeoverDialog + BarcodeScanner), inline-вёрстка кастомных диалогов запрещена; токены через marketplace-tokens.scss (var(--mp-space-md), mp-role-* классы на корнях страниц).

* [598-10][@ant] feat: добавить registry_id=1104 MarketplaceReturnStatement в cooptypes — отделить документ Эпика 7 «Стол заказов» от системы клиринга

Marketplace по системе членских взносов ведёт свой контур документов рядом
с 1102 «Акт приёма-передачи» и 1103 «ТТН». До этого Эпик 7 ошибочно
переиспользовал registry_id=800 ReturnByAssetStatement из старой системы
клиринга «по структурному совпадению» — это смешало два процесса в одном
signed-document пайплайне. Завожу полноценный собственный документ:
заявление пайщика о гарантийном возврате имущества с marketplace-полями
(order_id / order_hash / fact_cost / actual_quantity / reason_text /
defect_category / braname). Шаблон 800 остаётся как есть на случай
будущего клиринга.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* [598-10][@ant] feat: проложить factory-цепочку для registry_id=1104 — Action + Template + регистрация в Registry/index

Action 1104.MarketplaceReturnStatement.Factory повторяет паттерн 1102
(getCooperative/getVars/getUser/getRequest/getProgram/getOrganization),
но кладёт в Model marketplace-поля (fact_cost / actual_quantity /
reason_text / defect_category) и не требует transmitter/decision —
заявление подписывает только пайщик.

Также: убираю tsconfig.tsbuildinfo из индекса (build-артефакт, не должен
жить в git).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* [598-10][@ant] feat: переключить controller + SDK на registry_id=1104 для Эпика 7 — заявление о гарантийном возврате имущества

- marketplace-return-statement-document.dto.ts — новый Signed-DTO под
  Cooperative.Registry.MarketplaceReturnStatement.Action; старый
  marketplace-return-claim-document.dto.ts (ссылавшийся на 800
  ReturnByAssetStatement) удалён.
- MarketplaceReturnClaimService.generateStatementDocument теперь строит
  Action.MarketplaceReturnStatement с marketplace-полями (order_id /
  order_hash / fact_cost / reason_text / defect_category / braname),
  а не reuse'ит request{hash,units,unit_cost,total_cost} из старого 800.
- getReturnClaimSignablePayload принимает reason_text — превью теперь
  показывает реальный текст обращения пайщика.
- Регенерированы schema.gql + zeus client + SDK zeus (вход
  MarketplaceReturnStatementSignedInput с marketplace-меткой meta).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* [598-10][@ant] feat: desktop SubmitReturnClaimDialog — превью под registry_id=1104 с пробросом reason_text

- API getReturnClaimSignablePayload теперь принимает reason_text и
  defect_category — превью показывает реальный текст обращения пайщика.
- Убрал `result as MarketplaceGeneratedDocumentView` cast (Zeus уже
  выдаёт нужный тип после регенерации schema/SDK).
- Комментарии и подсказки UI обновлены: 1104 MarketplaceReturnStatement
  вместо устаревшего 800 ReturnByAssetStatement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* [598-10][@ant] refactor: десктоп-API Эпика 7 на строгие типы из SDK — variables передаём целиком data

Канон @coopenomics/sdk: каждый api-метод принимает `data: Mutations.<X>.<Y>.IInput['data']`
(или `Queries.<X>.<Y>.IInput['data']`) и передаёт его в variables объектом
целиком — `{ variables: { data } }`. Без раскладки полей вручную и без
самодельных интерфейсов аргументов.

Затронуто:
- OrdererReturnClaims/api: getReturnClaimSignablePayload + createReturnClaim
- OperatorReturnClaims/api: listReturnClaimsByBraname, approveReturnVisit,
  rejectReturnRemote, acceptReturnAtVisit, rejectReturnAtVisit
- SubmitReturnClaimDialog: defectCategory типизирован как enum
  Zeus.MarketplaceReturnClaimDefectCategory (раньше падал на string→enum,
  но был замаскирован ручной раскладкой полей в variables)
- OnSiteDecisionDialog: ReturnClaimPhotoUploadInput выведен из IAcceptReturnAtVisitInput
- OperatorReturnClaimsPage: listReturnClaimsByBraname({ delivery_braname })

Зачем — ревью PR #397: при ручной раскладке полей в variables.data
изменение схемы в SDK не подсвечивает call-site как ошибку TS;
регрессии маскируются. Канон даёт строгую сверку при изменении бэкенда.

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Alex Ant
2026-05-18 15:32:47 +05:00
committed by GitHub
parent 6250abd56b
commit f0311afaa7
61 changed files with 7842 additions and 22 deletions
+402 -5
View File
@@ -6917,6 +6917,28 @@ input MarketplaceAcceptIndividualOrderInput {
order_id: String!
}
input MarketplaceAcceptReturnAtVisitInput {
"""Кооперативный участок, где идёт очный осмотр."""
braname: String!
"""Идентификатор заявления."""
claim_id: String!
"""Фото очного осмотра (опционально, до 10 файлов)."""
inspection_photos: [MarketplaceReturnClaimPhotoUploadInput!]
"""Результат очного осмотра (обязательно, до 2000 символов)."""
inspection_result: String!
"""
Сканированный штрих-код имущества для сверки с заказом (если применимо).
"""
scanned_barcode: String
"""Подписанное решение председателя (опционально)."""
signed_decision: MarketplaceReturnStatementSignedInput
}
input MarketplaceAddToWhitelistInput {
"""
eosio::name пайщика-поставщика (3-12 chars, [.12345abcdefghijklmnopqrstuvwxyz])
@@ -7110,6 +7132,20 @@ input MarketplaceApproveOfferInput {
offer_id: String!
}
input MarketplaceApproveReturnVisitInput {
"""Кооперативный участок, на который приглашаем пайщика."""
braname: String!
"""Идентификатор заявления."""
claim_id: String!
"""Комментарий председателя (обязательно, 1-500 символов)."""
comment: String!
"""Подписанное решение председателя (опционально, in-system запись)."""
signed_decision: MarketplaceReturnStatementSignedInput
}
type MarketplaceAttribute {
"""ID комплексного атрибута"""
attributeComplexId: Int!
@@ -7517,6 +7553,28 @@ type MarketplaceCreateOrderResult {
tx_snapshot: MarketplaceOrderCreateTxSnapshot!
}
input MarketplaceCreateReturnClaimInput {
"""Возвращаемое количество единиц (по умолчанию — выданное количество)."""
actual_quantity: Int
"""Категория дефекта (опционально)."""
defect_category: MarketplaceReturnClaimDefectCategory
"""Заказ, по которому подаётся заявление."""
order_id: String!
"""Фотографии товара — обязательно от 1 до 10 файлов."""
photos: [MarketplaceReturnClaimPhotoUploadInput!]!
"""Текст обращения пайщика (1-500 символов)."""
reason_text: String!
"""
Подписанное пайщиком заявление о гарантийном возврате имущества (реестр документов 1104).
"""
signed_statement: MarketplaceReturnStatementSignedInput!
}
input MarketplaceCreateShipmentInput {
"""Идентификатор консолидированной заявки в статусе ACCEPTED."""
cycle_id: ID!
@@ -7793,11 +7851,6 @@ input MarketplaceIssueActSignedMetaDocumentInput {
"""Идентификатор заказа пайщика, по которому формируется акт выдачи."""
order_id: String!
"""
Идентификатор записи акта в реестре marketplace (для выдачи — id заказа).
"""
reception_id: String!
"""ID документа в реестре"""
registry_id: Int!
@@ -7963,6 +8016,11 @@ input MarketplaceListPendingOffersInput {
sortOrder: String! = "ASC"
}
input MarketplaceListReturnClaimsByBranameInput {
"""Кооперативный участок (delivery_braname исходного заказа)."""
delivery_braname: String!
}
input MarketplaceListShipmentsInput {
"""Фильтр по КУ-получателю."""
braname: String
@@ -8376,6 +8434,37 @@ input MarketplaceRejectOfferInput {
reason: String!
}
input MarketplaceRejectReturnAtVisitInput {
"""Кооперативный участок, где идёт очный осмотр."""
braname: String!
"""Идентификатор заявления."""
claim_id: String!
"""Фото очного осмотра (опционально)."""
inspection_photos: [MarketplaceReturnClaimPhotoUploadInput!]
"""Результат и причина отказа (обязательно, до 2000 символов)."""
inspection_result: String!
"""Подписанное решение председателя (опционально)."""
signed_decision: MarketplaceReturnStatementSignedInput
}
input MarketplaceRejectReturnRemoteInput {
"""Кооперативный участок, под чьей юрисдикцией решение."""
braname: String!
"""Идентификатор заявления."""
claim_id: String!
"""Причина отказа (обязательно, 1-500 символов)."""
comment: String!
"""Подписанное решение председателя (опционально)."""
signed_decision: MarketplaceReturnStatementSignedInput
}
input MarketplaceRemoveFromWhitelistInput {
"""eosio::name пайщика-поставщика"""
member_account: String!
@@ -8611,6 +8700,271 @@ type MarketplaceRequestStatistics {
totalRequests: Int!
}
"""Заявление пайщика на гарантийный возврат имущества (Эпик 7)."""
type MarketplaceReturnClaim {
actual_quantity: Int!
coopname: String!
created_at: DateTime!
decision_log: [MarketplaceReturnClaimDecisionEntry!]!
"""Категория дефекта (если указана)."""
defect_category: MarketplaceReturnClaimDefectCategory
"""КУ доставки исходного заказа (куда подаётся заявление)."""
delivery_braname: String!
expected_resolution: MarketplaceReturnClaimExpectedResolution!
fact_cost: String!
id: String!
"""Снапшот compensating-forward (только при ACCEPTED_AT_VISIT)."""
ledger_snapshot: MarketplaceReturnClaimLedgerSnapshot
"""
Параметры очного осмотра (заполняется на стадии accept/reject_at_visit).
"""
on_site_inspection: MarketplaceReturnClaimOnSiteInspection
order_hash: String!
order_id: String!
"""Аккаунт пайщика-заявителя."""
orderer_account: String!
"""Фотографии товара, приложенные пайщиком."""
photos: [MarketplaceReturnClaimPhoto!]!
"""Текст обращения пайщика."""
reason_text: String!
"""Якорный hash on-chain return_request."""
request_hash: String!
"""Текущий статус заявления."""
status: MarketplaceReturnClaimStatus!
"""Хэш транзакции submretrn в блокчейне."""
submretrn_tx_hash: String!
"""Поставщик исходного заказа (для будущего возврата поставщику)."""
supplier_account: String!
updated_at: DateTime!
}
"""Запись о решении председателя по заявлению на возврат."""
type MarketplaceReturnClaimDecisionEntry {
"""Время фиксации решения."""
at: DateTime!
"""Кооперативный участок, под чьей юрисдикцией решение."""
braname: String!
"""Аккаунт председателя, принявшего решение."""
by_chairman_account: String!
"""Комментарий / причина / результат осмотра."""
comment: String!
"""
Тип решения: approve_visit / reject_remote / accept_at_visit / reject_at_visit.
"""
decision: String!
"""Стадия решения: «remote» — удалённое, «on_site» — очный осмотр."""
stage: String!
"""Хэш транзакции в блокчейне для аудита решения."""
tx_hash: String!
}
"""Категория дефекта, на который ссылается заявление на возврат."""
enum MarketplaceReturnClaimDefectCategory {
BROKEN
EXPIRED
NOT_AS_DESCRIBED
OTHER
WRONG_ITEM
}
"""
Желаемый исход возврата (в MVP — только восстановление средств на программном кошельке).
"""
enum MarketplaceReturnClaimExpectedResolution {
FUNDS_RETURN
}
"""Снапшот compensating-forward пары после успешного приёма возврата."""
type MarketplaceReturnClaimLedgerSnapshot {
"""Сумма compensating-forward (восстановленная на программный кошелёк)."""
amount: String!
"""Время фиксации возврата."""
at: DateTime!
"""Возвращённое количество единиц имущества."""
returned_quantity: Int!
"""Хэш транзакции accretrn в блокчейне."""
tx_hash: String!
}
"""Параметры очного осмотра имущества председателем КУ."""
type MarketplaceReturnClaimOnSiteInspection {
"""Время очного осмотра."""
at: DateTime!
"""Председатель, проводивший очный осмотр."""
by_chairman_account: String!
"""Фото очного осмотра."""
photos: [MarketplaceReturnClaimPhoto!]!
"""Текстовое описание результатов осмотра."""
result_text: String!
"""Сканированный штрих-код имущества (если применимо)."""
scanned_barcode: String
}
"""Снапшот фото-доказательства, приложенного к заявлению на возврат."""
type MarketplaceReturnClaimPhoto {
"""Sha256-хеш содержимого фото (используется как анкер on-chain)."""
content_hash: String!
"""MIME-тип файла."""
mime_type: String!
"""Время загрузки фото в bucket."""
uploaded_at: DateTime!
"""Подписанный URL для чтения фото из bucket'а stol-zakazov:images."""
url: String!
}
input MarketplaceReturnClaimPhotoUploadInput {
"""Содержимое файла, закодированное в base64."""
base64: String!
"""MIME-тип фото (image/jpeg, image/png либо image/webp)."""
mime_type: String!
}
"""Результат любого изменения статуса заявления на гарантийный возврат."""
type MarketplaceReturnClaimResult {
"""Актуальное состояние заявления."""
claim: MarketplaceReturnClaim!
"""Хэш транзакции в блокчейне последнего действия."""
tx_hash: String!
}
input MarketplaceReturnClaimSignablePayloadInput {
"""Возвращаемое количество (если не указано — выданное количество)."""
actual_quantity: Int
"""Категория дефекта (опционально, дублируется в meta документа)."""
defect_category: MarketplaceReturnClaimDefectCategory
"""Идентификатор заказа, по которому готовится заявление."""
order_id: String!
"""
Причина обращения, как её сформулировал пайщик (попадает в текст заявления).
"""
reason_text: String
}
"""Состояние заявления на гарантийный возврат имущества пайщика."""
enum MarketplaceReturnClaimStatus {
ACCEPTED_AT_VISIT
APPROVED_FOR_VISIT
PENDING_CHAIRMAN_REVIEW
REJECTED_AT_VISIT
REJECTED_REMOTELY
}
input MarketplaceReturnStatementSignedInput {
"""Хэш содержимого документа"""
doc_hash: String!
"""Общий хэш (doc_hash + meta_hash)"""
hash: String!
"""Метаданные подписанного заявления о гарантийном возврате имущества."""
meta: MarketplaceReturnStatementSignedMetaDocumentInput!
"""Хэш мета-данных"""
meta_hash: String!
"""Вектор подписей"""
signatures: [SignatureInfoInput!]!
"""Версия стандарта документа"""
version: String!
}
input MarketplaceReturnStatementSignedMetaDocumentInput {
"""Фактическое количество единиц к возврату."""
actual_quantity: Int!
"""Номер блока, на котором был создан документ"""
block_num: Int!
"""Имя кооперативного участка доставки (braname) исходного заказа."""
braname: String
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String!
"""Необязательная категория дефекта (пересортица, истёкший срок и т.п.)."""
defect_category: String
"""
Хэш приватного payload документа (если приватные данные хранятся отдельно).
"""
doc_data_hash: String
"""Стоимость возвращаемой части (4 знака после запятой)."""
fact_cost: String!
"""Имя генератора, использованного для создания документа"""
generator: String!
"""Язык документа"""
lang: String!
"""Ссылки, связанные с документом"""
links: [String!]!
"""Канонический order_hash on-chain."""
order_hash: String!
"""Идентификатор заказа, по которому подаётся возврат."""
order_id: String!
"""Причина обращения, как её сформулировал пайщик."""
reason_text: String!
"""ID документа в реестре"""
registry_id: Int!
"""Сформировать документ без сохранения (preview-режим)."""
skip_save: Boolean!
"""Часовой пояс, в котором был создан документ"""
timezone: String!
"""Название документа"""
title: String!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String!
}
input MarketplaceSetKUStatusInput {
"""Имя аккаунта кооператива"""
coopname: String!
@@ -10055,6 +10409,11 @@ type Mutation {
"""Поставщик принимает один индивидуальный заказ."""
marketplaceAcceptIndividualOrder(input: MarketplaceAcceptIndividualOrderInput!): MarketplaceSupplierOrderActionResult!
"""
Председатель по результатам очного осмотра принимает гарантийный возврат — атомарно восстанавливает средства на программный кошелёк пайщика и возвращает имущество на склад участка.
"""
marketplaceAcceptReturnAtVisit(data: MarketplaceAcceptReturnAtVisitInput!): MarketplaceReturnClaimResult!
"""
Добавить категории в доступные для кооператива (целые категории)
@@ -10075,6 +10434,11 @@ type Mutation {
"""Одобрить Offer (status → ACTIVE) (admin)"""
marketplaceApproveOffer(input: MarketplaceApproveOfferInput!): MarketplaceOffer!
"""
Председатель кооперативного участка по результатам удалённого рассмотрения приглашает пайщика на очный осмотр имущества.
"""
marketplaceApproveReturnVisit(data: MarketplaceApproveReturnVisitInput!): MarketplaceReturnClaimResult!
"""
Отменить свой заказ до его приёма поставщиком; средства разблокируются.
"""
@@ -10105,6 +10469,11 @@ type Mutation {
"""
marketplaceCreateRequest(data: CreateRequestInput!): MarketplaceRequest!
"""
Пайщик подаёт заявление на гарантийный возврат имущества — backend кладёт фото в защищённое хранилище и фиксирует заявление в блокчейне.
"""
marketplaceCreateReturnClaim(data: MarketplaceCreateReturnClaimInput!): MarketplaceReturnClaimResult!
"""
Сформировать партии поставки из акцептованной заявки: одна группа на каждый КУ-получатель.
"""
@@ -10150,6 +10519,16 @@ type Mutation {
"""Отклонить Offer с причиной (status → REJECTED) (admin)"""
marketplaceRejectOffer(input: MarketplaceRejectOfferInput!): MarketplaceOffer!
"""
Председатель по результатам очного осмотра отказывает в гарантийном возврате — заказчик забирает имущество обратно, движений по средствам нет.
"""
marketplaceRejectReturnAtVisit(data: MarketplaceRejectReturnAtVisitInput!): MarketplaceReturnClaimResult!
"""
Председатель отказывает в гарантийном возврате удалённо с указанием причины — финальное решение, движений по средствам нет.
"""
marketplaceRejectReturnRemote(data: MarketplaceRejectReturnRemoteInput!): MarketplaceReturnClaimResult!
"""
Удалить категории из доступных для кооператива (включая все их типы)
@@ -12623,6 +13002,11 @@ type Query {
"""Список заказов текущего пайщика, готовых к получению на пункте выдачи."""
marketplaceListMyReadyToReceive: [MarketplaceOrder!]!
"""
Все заявления текущего пайщика на гарантийный возврат — активные и архивные.
"""
marketplaceListMyReturnClaims: [MarketplaceReturnClaim!]!
"""
История выплат поставщику в столе поставщика — статусы по каждому заказу.
"""
@@ -12631,6 +13015,11 @@ type Query {
"""Список Offer'ов на модерации (admin)"""
marketplaceListPendingOffers(input: MarketplaceListPendingOffersInput): MarketplaceOfferPaginationResult!
"""
Список заявлений на гарантийный возврат, привязанных к кооперативному участку доставки — для председателя своего КУ.
"""
marketplaceListReturnClaimsByBraname(data: MarketplaceListReturnClaimsByBranameInput!): [MarketplaceReturnClaim!]!
"""
Список партий поставки текущего поставщика — для стола подготовки поставки и истории.
"""
@@ -12662,6 +13051,14 @@ type Query {
"""Получить обязательные атрибуты для категории и типа товара marketplace"""
marketplaceRequiredAttributes(data: GetRequiredAttributesInput!): [MarketplaceAttribute!]!
"""Получить одно заявление на гарантийный возврат по идентификатору."""
marketplaceReturnClaim(claim_id: String!): MarketplaceReturnClaim!
"""
Превью заявления на гарантийный возврат имущества для подписания пайщиком-заказчиком.
"""
marketplaceReturnClaimSignablePayload(data: MarketplaceReturnClaimSignablePayloadInput!): GeneratedDocument!
"""Поиск атрибутов marketplace по названию"""
marketplaceSearchAttributes(input: SearchAttributesInput!): [MarketplaceAttribute!]!
@@ -0,0 +1,142 @@
import { Field, InputType, Int, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsBoolean, IsInt, IsOptional, IsString, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import type { ExcludeCommonProps } from '~/application/document/types';
/**
* Подписываемая форма заявления пайщика о гарантийном возврате имущества
* по ЦПП «Стол заказов» (registry_id=1104, `MarketplaceReturnStatement`).
*
* Лежит рядом с 1102 «Акт приёма-передачи» и 1103 «ТТН» в Marketplace по
* системе членских взносов. Не путать с registry_id=800 — тот документ
* принадлежит старой системе клиринга и сохранён отдельно как есть.
*
* Поле `request.hash` дублирует якорный хеш on-chain `return_request.hash`
* (он же `request_hash` параметра `marketplace::submretrn`). Backend
* хеширует детерминированный seed `return:<order_hash>:<orderer>:<actual_
* quantity>` и кладёт результат в оба места.
*/
type action = Cooperative.Registry.MarketplaceReturnStatement.Action;
@InputType('MarketplaceReturnStatementRequestPayloadInput')
export class MarketplaceReturnStatementRequestPayloadInputDTO {
@Field({ description: 'Якорный hash заявления на возврат (request_hash).' })
@IsString()
hash!: string;
@Field({ description: 'Заголовок имущества, по которому подаётся возврат.' })
@IsString()
title!: string;
@Field({ description: 'Единица измерения (например «ед.»).' })
@IsString()
unit_of_measurement!: string;
@Field(() => Int, { description: 'Возвращаемое количество единиц.' })
@IsInt()
@Min(1)
units!: number;
@Field({ description: 'Стоимость единицы (десятичное число строкой).' })
@IsString()
unit_cost!: string;
@Field({ description: 'Итоговая возвращаемая сумма (десятичное число строкой).' })
@IsString()
total_cost!: string;
@Field({ description: 'Валюта расчёта (например «RUB»).' })
@IsString()
currency!: string;
@Field({ description: 'Тип операции в каноне ICommonRequest (для возврата — «RETURN»).' })
@IsString()
type!: string;
@Field(() => Int, { description: 'Идентификатор программы Стола Заказов в кооперативе.' })
@IsInt()
@Min(0)
program_id!: number;
}
@InputType('BaseMarketplaceReturnStatementMetaDocumentInput')
class BaseMarketplaceReturnStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Идентификатор заказа, по которому подаётся возврат.' })
@IsString()
order_id!: string;
@Field({ description: 'Канонический order_hash on-chain.' })
@IsString()
order_hash!: string;
@Field({ nullable: true, description: 'Имя кооперативного участка доставки (braname) исходного заказа.' })
@IsOptional()
@IsString()
braname?: string;
@Field({ description: 'Причина обращения, как её сформулировал пайщик.' })
@IsString()
reason_text!: string;
@Field({ nullable: true, description: 'Необязательная категория дефекта (пересортица, истёкший срок и т.п.).' })
@IsOptional()
@IsString()
defect_category?: string;
@Field(() => Int, { description: 'Фактическое количество единиц к возврату.' })
@IsInt()
@Min(1)
actual_quantity!: number;
@Field({ description: 'Стоимость возвращаемой части (4 знака после запятой).' })
@IsString()
fact_cost!: string;
@Field({
nullable: true,
description: 'Хэш приватного payload документа (если приватные данные хранятся отдельно).',
})
@IsOptional()
@IsString()
doc_data_hash?: string;
@Field({
description: 'Сформировать документ без сохранения (preview-режим).',
})
@IsBoolean()
skip_save!: boolean;
}
@InputType('MarketplaceReturnStatementGenerateDocumentInput')
export class MarketplaceReturnStatementGenerateDocumentInputDTO
extends IntersectionType(
BaseMarketplaceReturnStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
)
implements action
{
registry_id!: number;
constructor() {
super();
}
}
@InputType('MarketplaceReturnStatementSignedMetaDocumentInput')
export class MarketplaceReturnStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseMarketplaceReturnStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {}
@InputType('MarketplaceReturnStatementSignedInput')
export class MarketplaceReturnStatementSignedInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => MarketplaceReturnStatementSignedMetaDocumentInputDTO, {
description: 'Метаданные подписанного заявления о гарантийном возврате имущества.',
})
@ValidateNested()
@Type(() => MarketplaceReturnStatementSignedMetaDocumentInputDTO)
public readonly meta!: MarketplaceReturnStatementSignedMetaDocumentInputDTO;
}
@@ -38,6 +38,10 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
// Story 6.3 / FR24: заказчик закрывает АПП-выдачу финальной подписью
// на ПВЗ и видит свои заказы, готовые к получению.
Issuance: ['sign:final', 'read:own'],
// Story 7.1 / FR29: заказчик подаёт заявление на гарантийный возврат
// имущества по своему Order'у в пределах гарантийного срока и видит
// только свои заявления.
ReturnClaim: ['create:own', 'read:own'],
},
offerer: {
Offer: ['create:own', 'update:own', 'delete:own', 'read'],
@@ -56,6 +60,12 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
Warehouse: ['read:own-KU'],
KU: ['read:own-KU'],
Vitrine: ['read'],
// Story 7.2 / 7.3 / FR30-FR32: председатель КУ доставки видит заявления
// на возврат своего КУ, принимает удалённые и очные решения. Действия:
// - read:own-KU — лента и detail заявлений;
// - decide:remote — одобрить визит / отказать удалённо (Story 7.2);
// - decide:on-site — принять / отказать на месте (Story 7.3 + 7.4).
ReturnClaim: ['read:own-KU', 'decide:remote', 'decide:on-site'],
},
admin: {
Offer: ['moderate', 'read'],
@@ -0,0 +1,479 @@
import {
Field,
ID,
InputType,
Int,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import GraphQLJSON from 'graphql-type-json';
import {
MarketplaceReturnClaimDefectCategories,
MarketplaceReturnClaimExpectedResolutions,
MarketplaceReturnClaimStatuses,
type MarketplaceReturnClaimDefectCategory,
type MarketplaceReturnClaimExpectedResolution,
type MarketplaceReturnClaimStatus,
} from '../../domain/entities/marketplace-return-claim.types';
import { MarketplaceReturnStatementSignedInputDTO } from '~/application/document/documents-dto/marketplace-return-statement-document.dto';
/**
* Эпик 7: GraphQL enum'ы статуса заявления и категории дефекта. Регистрируются
* через `registerEnumType` чтобы strict-TypeScript на клиенте получал
* именованные значения, а не строки (правило `feedback_graphql_enum_not_strings`).
*/
export enum MarketplaceReturnClaimStatusEnum {
PENDING_CHAIRMAN_REVIEW = 'PENDING_CHAIRMAN_REVIEW',
APPROVED_FOR_VISIT = 'APPROVED_FOR_VISIT',
REJECTED_REMOTELY = 'REJECTED_REMOTELY',
ACCEPTED_AT_VISIT = 'ACCEPTED_AT_VISIT',
REJECTED_AT_VISIT = 'REJECTED_AT_VISIT',
}
registerEnumType(MarketplaceReturnClaimStatusEnum, {
name: 'MarketplaceReturnClaimStatus',
description: 'Состояние заявления на гарантийный возврат имущества пайщика.',
});
export enum MarketplaceReturnClaimDefectCategoryEnum {
BROKEN = 'BROKEN',
EXPIRED = 'EXPIRED',
NOT_AS_DESCRIBED = 'NOT_AS_DESCRIBED',
WRONG_ITEM = 'WRONG_ITEM',
OTHER = 'OTHER',
}
registerEnumType(MarketplaceReturnClaimDefectCategoryEnum, {
name: 'MarketplaceReturnClaimDefectCategory',
description: 'Категория дефекта, на который ссылается заявление на возврат.',
});
export enum MarketplaceReturnClaimExpectedResolutionEnum {
FUNDS_RETURN = 'FUNDS_RETURN',
}
registerEnumType(MarketplaceReturnClaimExpectedResolutionEnum, {
name: 'MarketplaceReturnClaimExpectedResolution',
description: 'Желаемый исход возврата (в MVP — только восстановление средств на программном кошельке).',
});
export const MARKETPLACE_RETURN_CLAIM_STATUS_VALUES = MarketplaceReturnClaimStatuses;
export const MARKETPLACE_RETURN_CLAIM_DEFECT_CATEGORY_VALUES = MarketplaceReturnClaimDefectCategories;
export const MARKETPLACE_RETURN_CLAIM_EXPECTED_RESOLUTION_VALUES =
MarketplaceReturnClaimExpectedResolutions;
@InputType('MarketplaceReturnClaimPhotoUploadInput')
export class MarketplaceReturnClaimPhotoUploadInputDTO {
@Field({ description: 'Содержимое файла, закодированное в base64.' })
@IsString()
@IsNotEmpty()
public readonly base64!: string;
@Field({ description: 'MIME-тип фото (image/jpeg, image/png либо image/webp).' })
@IsString()
@IsNotEmpty()
public readonly mime_type!: string;
}
@InputType('MarketplaceCreateReturnClaimInput')
export class MarketplaceCreateReturnClaimInputDTO {
@Field(() => String, { description: 'Заказ, по которому подаётся заявление.' })
@IsString()
@IsNotEmpty()
public readonly order_id!: string;
@Field({ description: 'Текст обращения пайщика (1-500 символов).' })
@IsString()
@IsNotEmpty()
public readonly reason_text!: string;
@Field(() => MarketplaceReturnClaimDefectCategoryEnum, {
nullable: true,
description: 'Категория дефекта (опционально).',
})
@IsOptional()
@IsEnum(MarketplaceReturnClaimDefectCategoryEnum)
public readonly defect_category?: MarketplaceReturnClaimDefectCategory;
@Field(() => Int, {
nullable: true,
description: 'Возвращаемое количество единиц (по умолчанию — выданное количество).',
})
@IsOptional()
@IsInt()
@Min(1)
public readonly actual_quantity?: number;
@Field(() => MarketplaceReturnStatementSignedInputDTO, {
description: 'Подписанное пайщиком заявление о гарантийном возврате имущества (реестр документов 1104).',
})
@ValidateNested()
@Type(() => MarketplaceReturnStatementSignedInputDTO)
public readonly signed_statement!: MarketplaceReturnStatementSignedInputDTO;
@Field(() => [MarketplaceReturnClaimPhotoUploadInputDTO], {
description: 'Фотографии товара — обязательно от 1 до 10 файлов.',
})
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(10)
@ValidateNested({ each: true })
@Type(() => MarketplaceReturnClaimPhotoUploadInputDTO)
public readonly photos!: MarketplaceReturnClaimPhotoUploadInputDTO[];
}
@InputType('MarketplaceReturnClaimSignablePayloadInput')
export class MarketplaceReturnClaimSignablePayloadInputDTO {
@Field(() => String, { description: 'Идентификатор заказа, по которому готовится заявление.' })
@IsString()
@IsNotEmpty()
public readonly order_id!: string;
@Field(() => Int, {
nullable: true,
description: 'Возвращаемое количество (если не указано — выданное количество).',
})
@IsOptional()
@IsInt()
@Min(1)
public readonly actual_quantity?: number;
@Field({
nullable: true,
description: 'Причина обращения, как её сформулировал пайщик (попадает в текст заявления).',
})
@IsOptional()
@IsString()
public readonly reason_text?: string;
@Field(() => MarketplaceReturnClaimDefectCategoryEnum, {
nullable: true,
description: 'Категория дефекта (опционально, дублируется в meta документа).',
})
@IsOptional()
@IsEnum(MarketplaceReturnClaimDefectCategoryEnum)
public readonly defect_category?: MarketplaceReturnClaimDefectCategory;
}
@InputType('MarketplaceApproveReturnVisitInput')
export class MarketplaceApproveReturnVisitInputDTO {
@Field(() => String, { description: 'Идентификатор заявления.' })
@IsString()
public readonly claim_id!: string;
@Field({ description: 'Кооперативный участок, на который приглашаем пайщика.' })
@IsString()
public readonly braname!: string;
@Field({ description: 'Комментарий председателя (обязательно, 1-500 символов).' })
@IsString()
@IsNotEmpty()
public readonly comment!: string;
@Field(() => MarketplaceReturnStatementSignedInputDTO, {
nullable: true,
description: 'Подписанное решение председателя (опционально, in-system запись).',
})
@IsOptional()
@ValidateNested()
@Type(() => MarketplaceReturnStatementSignedInputDTO)
public readonly signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
@InputType('MarketplaceRejectReturnRemoteInput')
export class MarketplaceRejectReturnRemoteInputDTO {
@Field(() => String, { description: 'Идентификатор заявления.' })
@IsString()
public readonly claim_id!: string;
@Field({ description: 'Кооперативный участок, под чьей юрисдикцией решение.' })
@IsString()
public readonly braname!: string;
@Field({ description: 'Причина отказа (обязательно, 1-500 символов).' })
@IsString()
@IsNotEmpty()
public readonly comment!: string;
@Field(() => MarketplaceReturnStatementSignedInputDTO, {
nullable: true,
description: 'Подписанное решение председателя (опционально).',
})
@IsOptional()
@ValidateNested()
@Type(() => MarketplaceReturnStatementSignedInputDTO)
public readonly signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
@InputType('MarketplaceAcceptReturnAtVisitInput')
export class MarketplaceAcceptReturnAtVisitInputDTO {
@Field(() => String, { description: 'Идентификатор заявления.' })
@IsString()
public readonly claim_id!: string;
@Field({ description: 'Кооперативный участок, где идёт очный осмотр.' })
@IsString()
public readonly braname!: string;
@Field({
description: 'Результат очного осмотра (обязательно, до 2000 символов).',
})
@IsString()
@IsNotEmpty()
public readonly inspection_result!: string;
@Field({
nullable: true,
description: 'Сканированный штрих-код имущества для сверки с заказом (если применимо).',
})
@IsOptional()
@IsString()
public readonly scanned_barcode?: string;
@Field(() => [MarketplaceReturnClaimPhotoUploadInputDTO], {
nullable: true,
description: 'Фото очного осмотра (опционально, до 10 файлов).',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(10)
@ValidateNested({ each: true })
@Type(() => MarketplaceReturnClaimPhotoUploadInputDTO)
public readonly inspection_photos?: MarketplaceReturnClaimPhotoUploadInputDTO[];
@Field(() => MarketplaceReturnStatementSignedInputDTO, {
nullable: true,
description: 'Подписанное решение председателя (опционально).',
})
@IsOptional()
@ValidateNested()
@Type(() => MarketplaceReturnStatementSignedInputDTO)
public readonly signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
@InputType('MarketplaceRejectReturnAtVisitInput')
export class MarketplaceRejectReturnAtVisitInputDTO {
@Field(() => String, { description: 'Идентификатор заявления.' })
@IsString()
public readonly claim_id!: string;
@Field({ description: 'Кооперативный участок, где идёт очный осмотр.' })
@IsString()
public readonly braname!: string;
@Field({ description: 'Результат и причина отказа (обязательно, до 2000 символов).' })
@IsString()
@IsNotEmpty()
public readonly inspection_result!: string;
@Field(() => [MarketplaceReturnClaimPhotoUploadInputDTO], {
nullable: true,
description: 'Фото очного осмотра (опционально).',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(10)
@ValidateNested({ each: true })
@Type(() => MarketplaceReturnClaimPhotoUploadInputDTO)
public readonly inspection_photos?: MarketplaceReturnClaimPhotoUploadInputDTO[];
@Field(() => MarketplaceReturnStatementSignedInputDTO, {
nullable: true,
description: 'Подписанное решение председателя (опционально).',
})
@IsOptional()
@ValidateNested()
@Type(() => MarketplaceReturnStatementSignedInputDTO)
public readonly signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
@InputType('MarketplaceListReturnClaimsByBranameInput')
export class MarketplaceListReturnClaimsByBranameInputDTO {
@Field({ description: 'Кооперативный участок (delivery_braname исходного заказа).' })
@IsString()
public readonly delivery_braname!: string;
}
/* ── Output types ─────────────────────────────────────────────────────── */
@ObjectType('MarketplaceReturnClaimPhoto', {
description: 'Снапшот фото-доказательства, приложенного к заявлению на возврат.',
})
export class MarketplaceReturnClaimPhotoDTO {
@Field({
description: 'Подписанный URL для чтения фото из bucket\'а stol-zakazov:images.',
})
public readonly url!: string;
@Field({ description: 'Sha256-хеш содержимого фото (используется как анкер on-chain).' })
public readonly content_hash!: string;
@Field({ description: 'MIME-тип файла.' })
public readonly mime_type!: string;
@Field({ description: 'Время загрузки фото в bucket.' })
public readonly uploaded_at!: Date;
}
@ObjectType('MarketplaceReturnClaimDecisionEntry', {
description: 'Запись о решении председателя по заявлению на возврат.',
})
export class MarketplaceReturnClaimDecisionEntryDTO {
@Field({ description: 'Стадия решения: «remote» — удалённое, «on_site» — очный осмотр.' })
public readonly stage!: string;
@Field({ description: 'Тип решения: approve_visit / reject_remote / accept_at_visit / reject_at_visit.' })
public readonly decision!: string;
@Field({ description: 'Аккаунт председателя, принявшего решение.' })
public readonly by_chairman_account!: string;
@Field({ description: 'Кооперативный участок, под чьей юрисдикцией решение.' })
public readonly braname!: string;
@Field({ description: 'Комментарий / причина / результат осмотра.' })
public readonly comment!: string;
@Field({ description: 'Время фиксации решения.' })
public readonly at!: Date;
@Field({ description: 'Хэш транзакции в блокчейне для аудита решения.' })
public readonly tx_hash!: string;
}
@ObjectType('MarketplaceReturnClaimOnSiteInspection', {
description: 'Параметры очного осмотра имущества председателем КУ.',
})
export class MarketplaceReturnClaimOnSiteInspectionDTO {
@Field({ description: 'Текстовое описание результатов осмотра.' })
public readonly result_text!: string;
@Field(() => [MarketplaceReturnClaimPhotoDTO], { description: 'Фото очного осмотра.' })
public readonly photos!: MarketplaceReturnClaimPhotoDTO[];
@Field({ nullable: true, description: 'Сканированный штрих-код имущества (если применимо).' })
public readonly scanned_barcode?: string;
@Field({ description: 'Председатель, проводивший очный осмотр.' })
public readonly by_chairman_account!: string;
@Field({ description: 'Время очного осмотра.' })
public readonly at!: Date;
}
@ObjectType('MarketplaceReturnClaimLedgerSnapshot', {
description: 'Снапшот compensating-forward пары после успешного приёма возврата.',
})
export class MarketplaceReturnClaimLedgerSnapshotDTO {
@Field({ description: 'Сумма compensating-forward (восстановленная на программный кошелёк).' })
public readonly amount!: string;
@Field(() => Int, { description: 'Возвращённое количество единиц имущества.' })
public readonly returned_quantity!: number;
@Field({ description: 'Хэш транзакции accretrn в блокчейне.' })
public readonly tx_hash!: string;
@Field({ description: 'Время фиксации возврата.' })
public readonly at!: Date;
}
@ObjectType('MarketplaceReturnClaim', {
description: 'Заявление пайщика на гарантийный возврат имущества (Эпик 7).',
})
export class MarketplaceReturnClaimDTO {
@Field(() => String) public readonly id!: string;
@Field() public readonly coopname!: string;
@Field({ description: 'Якорный hash on-chain return_request.' })
public readonly request_hash!: string;
@Field() public readonly order_id!: string;
@Field() public readonly order_hash!: string;
@Field({ description: 'Аккаунт пайщика-заявителя.' })
public readonly orderer_account!: string;
@Field({ description: 'КУ доставки исходного заказа (куда подаётся заявление).' })
public readonly delivery_braname!: string;
@Field({ description: 'Поставщик исходного заказа (для будущего возврата поставщику).' })
public readonly supplier_account!: string;
@Field(() => MarketplaceReturnClaimStatusEnum, { description: 'Текущий статус заявления.' })
public readonly status!: MarketplaceReturnClaimStatus;
@Field({ description: 'Текст обращения пайщика.' })
public readonly reason_text!: string;
@Field(() => MarketplaceReturnClaimDefectCategoryEnum, {
nullable: true,
description: 'Категория дефекта (если указана).',
})
public readonly defect_category!: MarketplaceReturnClaimDefectCategory | null;
@Field(() => MarketplaceReturnClaimExpectedResolutionEnum)
public readonly expected_resolution!: MarketplaceReturnClaimExpectedResolution;
@Field(() => Int) public readonly actual_quantity!: number;
@Field() public readonly fact_cost!: string;
@Field(() => [MarketplaceReturnClaimPhotoDTO], { description: 'Фотографии товара, приложенные пайщиком.' })
public readonly photos!: MarketplaceReturnClaimPhotoDTO[];
@Field({ description: 'Хэш транзакции submretrn в блокчейне.' })
public readonly submretrn_tx_hash!: string;
@Field(() => [MarketplaceReturnClaimDecisionEntryDTO])
public readonly decision_log!: MarketplaceReturnClaimDecisionEntryDTO[];
@Field(() => MarketplaceReturnClaimOnSiteInspectionDTO, {
nullable: true,
description: 'Параметры очного осмотра (заполняется на стадии accept/reject_at_visit).',
})
public readonly on_site_inspection!: MarketplaceReturnClaimOnSiteInspectionDTO | null;
@Field(() => MarketplaceReturnClaimLedgerSnapshotDTO, {
nullable: true,
description: 'Снапшот compensating-forward (только при ACCEPTED_AT_VISIT).',
})
public readonly ledger_snapshot!: MarketplaceReturnClaimLedgerSnapshotDTO | null;
@Field() public readonly created_at!: Date;
@Field() public readonly updated_at!: Date;
}
@ObjectType('MarketplaceReturnClaimResult', {
description: 'Результат любого изменения статуса заявления на гарантийный возврат.',
})
export class MarketplaceReturnClaimResultDTO {
@Field(() => MarketplaceReturnClaimDTO, { description: 'Актуальное состояние заявления.' })
public readonly claim!: MarketplaceReturnClaimDTO;
@Field({ description: 'Хэш транзакции в блокчейне последнего действия.' })
public readonly tx_hash!: string;
}
@ObjectType('MarketplaceReturnClaimSignablePayload', {
description:
'Preview подписываемого заявления на возврат: HTML + meta + hash для подписи на клиенте.',
})
export class MarketplaceReturnClaimSignablePayloadDTO {
@Field() public readonly full_title!: string;
@Field() public readonly html!: string;
@Field() public readonly hash!: string;
@Field(() => GraphQLJSON, { description: 'Метаинформация документа.' })
public readonly meta!: unknown;
}
@@ -66,3 +66,62 @@ export interface MarketplaceOrderReadyToReceiveEvent {
/** Кооперативный участок, где имущество готово к выдаче. */
braname: string;
}
/**
* Story 7.1 (Эпик 7): пайщик подал заявление на гарантийный возврат —
* председатель КУ доставки заказа получает multi-channel уведомление на
* рассмотрение.
*/
export const MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT =
'marketplace.returnClaim.chairman.submitted';
/**
* Story 7.2 / 7.3 (Эпик 7): председатель принял промежуточное решение
* (одобрить очный визит / отказать удалённо / принять / отказать на
* месте) — заказчик получает уведомление о текущем состоянии заявления.
*/
export const MARKETPLACE_RETURN_CLAIM_DECIDED_EVENT =
'marketplace.returnClaim.orderer.decided';
/**
* Story 7.4 (Эпик 7): заявление достигло финального статуса
* (ACCEPTED_AT_VISIT / REJECTED_REMOTELY / REJECTED_AT_VISIT) — отдельное
* событие для финализации orderer-стола и обновления карточки Order'а.
*/
export const MARKETPLACE_RETURN_CLAIM_FINALIZED_EVENT =
'marketplace.returnClaim.orderer.finalized';
export interface MarketplaceReturnClaimSubmittedEvent {
coopname: string;
claim_id: string;
order_id: string;
orderer_account: string;
delivery_braname: string;
reason_text: string;
}
export interface MarketplaceReturnClaimDecidedEvent {
coopname: string;
claim_id: string;
orderer_account: string;
stage: 'remote' | 'on_site';
decision: 'approve_visit' | 'reject_remote' | 'accept_at_visit' | 'reject_at_visit';
comment: string;
braname: string;
}
export interface MarketplaceReturnClaimFinalizedEvent {
coopname: string;
claim_id: string;
orderer_account: string;
/** Финальный статус заявления (значение из `MarketplaceReturnClaimStatuses`). */
final_status: string;
/** Действие, приведшее к финальному состоянию (approve_visit здесь не появляется). */
decision: 'approve_visit' | 'reject_remote' | 'accept_at_visit' | 'reject_at_visit';
comment: string;
ledger_snapshot: {
amount: string;
returned_quantity: number;
tx_hash: string;
} | null;
}
@@ -98,6 +98,14 @@ import {
MARKETPLACE_ISSUANCE_SERVICE,
} from './services/marketplace-issuance.service';
import { MarketplaceIssuanceResolver } from './resolvers/marketplace-issuance.resolver';
// Эпик 7 — гарантийный возврат (compensating forward)
import {
MarketplaceReturnClaimService,
MARKETPLACE_RETURN_CLAIM_SERVICE,
} from './services/marketplace-return-claim.service';
import { MarketplaceReturnClaimImagesService } from './services/marketplace-return-claim-images.service';
import { MarketplaceReturnClaimResolver } from './resolvers/marketplace-return-claim.resolver';
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
/**
* Модуль приложения marketplace
@@ -118,6 +126,11 @@ import { MarketplaceIssuanceResolver } from './resolvers/marketplace-issuance.re
// DocumentDomainService для генерации preview-документов АПП приёмки
// через GENERATOR_PORT (registry_id=1102).
DocumentDomainModule,
// Эпик 7 / Story 7.1: bucket для фотографий гарантийного возврата
// (`stol-zakazov:images`). Имя bucket'а декларируется через @UseBucket
// на MarketplaceReturnClaimImagesService — модуль `forFeature` читает
// метадату и провайдит ему `InterFileStorageBucket`.
FileStorageInfrastructureModule.forFeature([MarketplaceReturnClaimImagesService]),
],
providers: [
// GraphQL резолверы
@@ -143,6 +156,7 @@ import { MarketplaceIssuanceResolver } from './resolvers/marketplace-issuance.re
MarketplaceAplReceptionResolver,
MarketplaceOutgoingPaymentResolver,
MarketplaceIssuanceResolver,
MarketplaceReturnClaimResolver,
// Guards (Story 1.3 / Story 1.6)
MarketplaceMembershipGuard,
@@ -261,6 +275,13 @@ import { MarketplaceIssuanceResolver } from './resolvers/marketplace-issuance.re
useClass: MarketplaceIssuanceService,
},
MarketplaceIssuanceService,
// Эпик 7 — гарантийный возврат (compensating forward через транзит 91).
{
provide: MARKETPLACE_RETURN_CLAIM_SERVICE,
useClass: MarketplaceReturnClaimService,
},
MarketplaceReturnClaimService,
MarketplaceReturnClaimImagesService,
],
exports: [
// Экспортируем сервисы для использования в других модулях
@@ -306,6 +327,7 @@ import { MarketplaceIssuanceResolver } from './resolvers/marketplace-issuance.re
MarketplaceAplReceptionResolver,
MarketplaceOutgoingPaymentResolver,
MarketplaceIssuanceResolver,
MarketplaceReturnClaimResolver,
// Экспортируем сервисы Story 4.1 для использования в follow-up Stories Эпика 4
MARKETPLACE_ORDER_CREATE_SERVICE,
@@ -337,6 +359,9 @@ import { MarketplaceIssuanceResolver } from './resolvers/marketplace-issuance.re
// Story 6.1 / 6.3 / 6.4
MARKETPLACE_ISSUANCE_SERVICE,
MarketplaceIssuanceService,
// Эпик 7
MARKETPLACE_RETURN_CLAIM_SERVICE,
MarketplaceReturnClaimService,
],
})
export class MarketplaceExtensionApplicationModule {}
@@ -0,0 +1,104 @@
import type { MarketplaceReturnClaimDomainEntity } from '../../domain/entities/marketplace-return-claim.entity';
import type {
MarketplaceReturnClaimDecisionLogEntry,
MarketplaceReturnClaimOnSiteInspection,
MarketplaceReturnClaimPhoto,
} from '../../domain/entities/marketplace-return-claim.types';
import type {
MarketplaceReturnClaimDTO,
MarketplaceReturnClaimDecisionEntryDTO,
MarketplaceReturnClaimLedgerSnapshotDTO,
MarketplaceReturnClaimOnSiteInspectionDTO,
MarketplaceReturnClaimPhotoDTO,
} from '../dto/marketplace-return-claim.dto';
/**
* Преобразование domain → GraphQL DTO. Подписанные URL фотографий запрашиваются
* у переданного `urlResolver` (имплементация знает про bucket file-storage —
* resolver просто проксирует на `MarketplaceReturnClaimService.getPhotoReadUrl`).
*/
export async function toMarketplaceReturnClaimDTO(
claim: MarketplaceReturnClaimDomainEntity,
urlResolver: (bucket_key: string) => Promise<string>
): Promise<MarketplaceReturnClaimDTO> {
const photos = await Promise.all(claim.photos.map((p) => toPhotoDTO(p, urlResolver)));
const inspection = claim.on_site_inspection
? await toInspectionDTO(claim.on_site_inspection, urlResolver)
: null;
return {
id: claim.id,
coopname: claim.coopname,
request_hash: claim.request_hash,
order_id: claim.order_id,
order_hash: claim.order_hash,
orderer_account: claim.orderer_account,
delivery_braname: claim.delivery_braname,
supplier_account: claim.supplier_account,
status: claim.status,
reason_text: claim.reason_text,
defect_category: claim.defect_category,
expected_resolution: claim.expected_resolution,
actual_quantity: claim.actual_quantity,
fact_cost: claim.fact_cost,
photos,
submretrn_tx_hash: claim.submretrn_tx_hash,
decision_log: claim.decision_log.map(toDecisionEntryDTO),
on_site_inspection: inspection,
ledger_snapshot: claim.ledger_snapshot ? toLedgerSnapshotDTO(claim.ledger_snapshot) : null,
created_at: claim.created_at,
updated_at: claim.updated_at,
};
}
async function toPhotoDTO(
photo: MarketplaceReturnClaimPhoto,
urlResolver: (bucket_key: string) => Promise<string>
): Promise<MarketplaceReturnClaimPhotoDTO> {
const url = await urlResolver(photo.bucket_key);
return {
url,
content_hash: photo.content_hash,
mime_type: photo.mime_type,
uploaded_at: photo.uploaded_at,
};
}
function toDecisionEntryDTO(
entry: MarketplaceReturnClaimDecisionLogEntry
): MarketplaceReturnClaimDecisionEntryDTO {
return {
stage: entry.stage,
decision: entry.decision,
by_chairman_account: entry.by_chairman_account,
braname: entry.braname,
comment: entry.comment,
at: entry.at,
tx_hash: entry.tx_hash,
};
}
async function toInspectionDTO(
inspection: MarketplaceReturnClaimOnSiteInspection,
urlResolver: (bucket_key: string) => Promise<string>
): Promise<MarketplaceReturnClaimOnSiteInspectionDTO> {
const photos = await Promise.all(inspection.photos.map((p) => toPhotoDTO(p, urlResolver)));
return {
result_text: inspection.result_text,
photos,
scanned_barcode: inspection.scanned_barcode ?? undefined,
by_chairman_account: inspection.by_chairman_account,
at: inspection.at,
};
}
function toLedgerSnapshotDTO(
s: NonNullable<MarketplaceReturnClaimDomainEntity['ledger_snapshot']>
): MarketplaceReturnClaimLedgerSnapshotDTO {
return {
amount: s.amount,
returned_quantity: s.returned_quantity,
tx_hash: s.tx_hash,
at: s.at,
};
}
@@ -0,0 +1,243 @@
import { Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
import {
MarketplaceAcceptReturnAtVisitInputDTO,
MarketplaceApproveReturnVisitInputDTO,
MarketplaceCreateReturnClaimInputDTO,
MarketplaceListReturnClaimsByBranameInputDTO,
MarketplaceRejectReturnAtVisitInputDTO,
MarketplaceRejectReturnRemoteInputDTO,
MarketplaceReturnClaimDTO,
MarketplaceReturnClaimResultDTO,
MarketplaceReturnClaimSignablePayloadInputDTO,
} from '../dto/marketplace-return-claim.dto';
import { MarketplaceReturnClaimService } from '../services/marketplace-return-claim.service';
import { toMarketplaceReturnClaimDTO } from './marketplace-return-claim.mapper';
function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
const dto = new GeneratedDocumentDTO();
dto.full_title = e.full_title;
dto.html = e.html;
dto.hash = e.hash;
dto.meta = e.meta;
dto.binary = e.binary;
return dto;
}
/**
* Эпик 7: GraphQL-фасад для процесса гарантийного возврата. Каждый из 5
* mutation'ов соответствует одному C++ action'у контракта marketplace
* (submretrn / aprretrem / rejretrem / accretrn / rejretrn).
*
* Access-matrix:
* - orderer: создаёт заявление, читает свои.
* - operator (председатель КУ): читает заявления своего КУ,
* принимает remote и on-site решения.
*/
@Resolver()
@Injectable()
export class MarketplaceReturnClaimResolver {
constructor(private readonly service: MarketplaceReturnClaimService) {}
@Query(() => GeneratedDocumentDTO, {
name: 'marketplaceReturnClaimSignablePayload',
description:
'Превью заявления на гарантийный возврат имущества для подписания пайщиком-заказчиком.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'create:own')
async marketplaceReturnClaimSignablePayload(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceReturnClaimSignablePayloadInputDTO
): Promise<GeneratedDocumentDTO> {
const doc = await this.service.getReturnClaimSignablePayload({
coopname: config.coopname,
orderer_account: member.username,
order_id: data.order_id,
actual_quantity: data.actual_quantity,
reason_text: data.reason_text,
defect_category: data.defect_category,
});
return toGeneratedDocumentDTO(doc);
}
@Mutation(() => MarketplaceReturnClaimResultDTO, {
name: 'marketplaceCreateReturnClaim',
description:
'Пайщик подаёт заявление на гарантийный возврат имущества — backend кладёт фото в защищённое хранилище и фиксирует заявление в блокчейне.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'create:own')
async marketplaceCreateReturnClaim(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceCreateReturnClaimInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
const result = await this.service.submitReturnClaim({
coopname: config.coopname,
orderer_account: member.username,
order_id: data.order_id,
reason_text: data.reason_text,
defect_category: data.defect_category ?? null,
actual_quantity: data.actual_quantity,
signed_statement: data.signed_statement,
photos: data.photos.map((p) => ({ base64: p.base64, mime_type: p.mime_type })),
});
return this.toResultDTO(result);
}
@Mutation(() => MarketplaceReturnClaimResultDTO, {
name: 'marketplaceApproveReturnVisit',
description:
'Председатель кооперативного участка по результатам удалённого рассмотрения приглашает пайщика на очный осмотр имущества.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'decide:remote')
async marketplaceApproveReturnVisit(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceApproveReturnVisitInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
const result = await this.service.approveReturnVisit({
coopname: config.coopname,
chairman_account: member.username,
braname: data.braname,
claim_id: data.claim_id,
comment: data.comment,
signed_decision: data.signed_decision,
});
return this.toResultDTO(result);
}
@Mutation(() => MarketplaceReturnClaimResultDTO, {
name: 'marketplaceRejectReturnRemote',
description:
'Председатель отказывает в гарантийном возврате удалённо с указанием причины — финальное решение, движений по средствам нет.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'decide:remote')
async marketplaceRejectReturnRemote(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceRejectReturnRemoteInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
const result = await this.service.rejectReturnRemote({
coopname: config.coopname,
chairman_account: member.username,
braname: data.braname,
claim_id: data.claim_id,
comment: data.comment,
signed_decision: data.signed_decision,
});
return this.toResultDTO(result);
}
@Mutation(() => MarketplaceReturnClaimResultDTO, {
name: 'marketplaceAcceptReturnAtVisit',
description:
'Председатель по результатам очного осмотра принимает гарантийный возврат — атомарно восстанавливает средства на программный кошелёк пайщика и возвращает имущество на склад участка.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'decide:on-site')
async marketplaceAcceptReturnAtVisit(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceAcceptReturnAtVisitInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
const result = await this.service.acceptReturnAtVisit({
coopname: config.coopname,
chairman_account: member.username,
braname: data.braname,
claim_id: data.claim_id,
inspection_result: data.inspection_result,
scanned_barcode: data.scanned_barcode ?? null,
inspection_photos: data.inspection_photos?.map((p) => ({ base64: p.base64, mime_type: p.mime_type })),
signed_decision: data.signed_decision,
});
return this.toResultDTO(result);
}
@Mutation(() => MarketplaceReturnClaimResultDTO, {
name: 'marketplaceRejectReturnAtVisit',
description:
'Председатель по результатам очного осмотра отказывает в гарантийном возврате — заказчик забирает имущество обратно, движений по средствам нет.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'decide:on-site')
async marketplaceRejectReturnAtVisit(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceRejectReturnAtVisitInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
const result = await this.service.rejectReturnAtVisit({
coopname: config.coopname,
chairman_account: member.username,
braname: data.braname,
claim_id: data.claim_id,
inspection_result: data.inspection_result,
inspection_photos: data.inspection_photos?.map((p) => ({ base64: p.base64, mime_type: p.mime_type })),
signed_decision: data.signed_decision,
});
return this.toResultDTO(result);
}
@Query(() => [MarketplaceReturnClaimDTO], {
name: 'marketplaceListMyReturnClaims',
description: 'Все заявления текущего пайщика на гарантийный возврат — активные и архивные.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'read:own')
async marketplaceListMyReturnClaims(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceReturnClaimDTO[]> {
const claims = await this.service.listByOrderer(config.coopname, member.username);
return Promise.all(claims.map((c) => this.toClaimDTO(c)));
}
@Query(() => [MarketplaceReturnClaimDTO], {
name: 'marketplaceListReturnClaimsByBraname',
description:
'Список заявлений на гарантийный возврат, привязанных к кооперативному участку доставки — для председателя своего КУ.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'read:own-KU')
async marketplaceListReturnClaimsByBraname(
@Args('data') data: MarketplaceListReturnClaimsByBranameInputDTO
): Promise<MarketplaceReturnClaimDTO[]> {
const claims = await this.service.listByDeliveryBraname(
config.coopname,
data.delivery_braname
);
return Promise.all(claims.map((c) => this.toClaimDTO(c)));
}
@Query(() => MarketplaceReturnClaimDTO, {
name: 'marketplaceReturnClaim',
description: 'Получить одно заявление на гарантийный возврат по идентификатору.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'read:own')
async marketplaceReturnClaim(@Args('claim_id') claim_id: string): Promise<MarketplaceReturnClaimDTO> {
const claim = await this.service.findById(config.coopname, claim_id);
return this.toClaimDTO(claim);
}
// ── helpers ──────────────────────────────────────────────────────────
private async toClaimDTO(
claim: Awaited<ReturnType<MarketplaceReturnClaimService['findById']>>
): Promise<MarketplaceReturnClaimDTO> {
return toMarketplaceReturnClaimDTO(claim, (key) => this.service.getPhotoReadUrl(key));
}
private async toResultDTO(
result: Awaited<ReturnType<MarketplaceReturnClaimService['submitReturnClaim']>>
): Promise<MarketplaceReturnClaimResultDTO> {
const dto = await this.toClaimDTO(result.claim);
return { claim: dto, tx_hash: result.tx_hash };
}
}
@@ -10,11 +10,17 @@ import {
MARKETPLACE_APL_SUPPLIER_SIGN_REQUEST_EVENT,
MARKETPLACE_CASHIER_NEW_PAYMENT_EVENT,
MARKETPLACE_ORDER_READY_TO_RECEIVE_EVENT,
MARKETPLACE_RETURN_CLAIM_DECIDED_EVENT,
MARKETPLACE_RETURN_CLAIM_FINALIZED_EVENT,
MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT,
MARKETPLACE_SUPPLIER_PAYMENT_CONFIRMED_EVENT,
MARKETPLACE_SUPPLIER_PAYMENT_DECLINED_EVENT,
type MarketplaceAplSupplierSignRequestEvent,
type MarketplaceCashierNewPaymentEvent,
type MarketplaceOrderReadyToReceiveEvent,
type MarketplaceReturnClaimDecidedEvent,
type MarketplaceReturnClaimFinalizedEvent,
type MarketplaceReturnClaimSubmittedEvent,
type MarketplaceSupplierPaymentConfirmedEvent,
type MarketplaceSupplierPaymentDeclinedEvent,
} from '../events/marketplace-notification.events';
@@ -217,6 +223,155 @@ export class MarketplaceNotificationService implements OnModuleInit {
}
}
@OnEvent(MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT)
async handleReturnClaimSubmitted(event: MarketplaceReturnClaimSubmittedEvent): Promise<void> {
try {
// В MVP кооперативный участок — это account-like префикс; председатель
// КУ соответствует account == delivery_braname или назначен через
// coop_ku.chairman_account. Для подачи в Novu используем `delivery_braname`
// как fallback adressee; полная интеграция с `coop_ku.chairman_account`
// — Phase 2 (после расширения cooperative-coop_ku модели в core).
const chairmanAccount = event.delivery_braname;
const chairman = await this.accountPort.getAccount(chairmanAccount);
const subscriberId = chairman.provider_account?.subscriber_id?.trim();
const email = chairman.provider_account?.email;
if (!subscriberId || !email) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: subscriber_id/email председателя ${chairmanAccount} не найден — push пропущен.`
);
return;
}
const chairmanName = await this.accountPort.getDisplayName(chairmanAccount);
const ordererName = await this.accountPort.getDisplayName(event.orderer_account);
const reasonExcerpt =
event.reason_text.length > 240 ? event.reason_text.slice(0, 240) + '…' : event.reason_text;
const payload: Workflows.MarketplaceReturnClaimSubmitted.IPayload = {
chairmanName,
ordererName,
brananame: event.delivery_braname,
coopname: event.coopname,
claim_id: event.claim_id,
order_id: event.order_id,
reasonExcerpt,
deepLinkUrl: `${config.frontend_url}/${event.coopname}/market-pvz/returns/${event.claim_id}`,
};
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.MarketplaceReturnClaimSubmitted.id,
to: { subscriberId, email },
payload,
};
await this.novuWorkflowPort.triggerWorkflow(triggerData);
this.logger.log(
`Заявление на возврат ${event.claim_id}: push председателю ${chairmanAccount} отправлен.`
);
} catch (err: any) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: ошибка push председателю (${err.message}) — flow не блокируется.`
);
}
}
@OnEvent(MARKETPLACE_RETURN_CLAIM_DECIDED_EVENT)
async handleReturnClaimDecided(event: MarketplaceReturnClaimDecidedEvent): Promise<void> {
// approve_visit, reject_remote, accept_at_visit, reject_at_visit — для
// approve_visit отправляем приглашение на очный осмотр; финальные исходы
// ещё раз продублируются finalized-событием с восстановленной суммой.
if (event.decision !== 'approve_visit') {
// Финал — пользуем finalized-листенер.
return;
}
try {
const orderer = await this.accountPort.getAccount(event.orderer_account);
const subscriberId = orderer.provider_account?.subscriber_id?.trim();
const email = orderer.provider_account?.email;
if (!subscriberId || !email) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: subscriber_id/email заказчика ${event.orderer_account} не найден — push пропущен.`
);
return;
}
const ordererName = await this.accountPort.getDisplayName(event.orderer_account);
const decisionHuman = `Председатель пригласил вас на очный осмотр на КУ ${event.braname}`;
const payload: Workflows.MarketplaceReturnClaimDecided.IPayload = {
ordererName,
decisionHuman,
brananame: event.braname,
coopname: event.coopname,
claim_id: event.claim_id,
order_id: '',
comment: event.comment,
deepLinkUrl: `${config.frontend_url}/${event.coopname}/market/returns/${event.claim_id}`,
};
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.MarketplaceReturnClaimDecided.id,
to: { subscriberId, email },
payload,
};
await this.novuWorkflowPort.triggerWorkflow(triggerData);
this.logger.log(
`Заявление на возврат ${event.claim_id}: push заказчику ${event.orderer_account} (одобрен очный визит) отправлен.`
);
} catch (err: any) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: ошибка push заказчику (${err.message}) — flow не блокируется.`
);
}
}
@OnEvent(MARKETPLACE_RETURN_CLAIM_FINALIZED_EVENT)
async handleReturnClaimFinalized(event: MarketplaceReturnClaimFinalizedEvent): Promise<void> {
try {
const orderer = await this.accountPort.getAccount(event.orderer_account);
const subscriberId = orderer.provider_account?.subscriber_id?.trim();
const email = orderer.provider_account?.email;
if (!subscriberId || !email) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: subscriber_id/email заказчика ${event.orderer_account} не найден — push пропущен.`
);
return;
}
const ordererName = await this.accountPort.getDisplayName(event.orderer_account);
let outcomeHuman: string;
let returnedAmount: string | undefined;
switch (event.decision) {
case 'accept_at_visit':
outcomeHuman = 'Возврат принят — средства восстановлены на программе Стола Заказов';
returnedAmount = event.ledger_snapshot?.amount;
break;
case 'reject_remote':
outcomeHuman = 'Возврат отклонён удалённо председателем';
break;
case 'reject_at_visit':
outcomeHuman = 'Возврат отклонён по результатам очного осмотра';
break;
default:
outcomeHuman = 'Возврат завершён';
}
const payload: Workflows.MarketplaceReturnClaimFinalized.IPayload = {
ordererName,
outcomeHuman,
coopname: event.coopname,
claim_id: event.claim_id,
order_id: '',
returnedAmount,
deepLinkUrl: `${config.frontend_url}/${event.coopname}/market/returns/${event.claim_id}`,
};
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.MarketplaceReturnClaimFinalized.id,
to: { subscriberId, email },
payload,
};
await this.novuWorkflowPort.triggerWorkflow(triggerData);
this.logger.log(
`Заявление на возврат ${event.claim_id}: финальный push заказчику ${event.orderer_account} отправлен (${event.decision}).`
);
} catch (err: any) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: ошибка финального push заказчику (${err.message}) — flow не блокируется.`
);
}
}
@OnEvent(MARKETPLACE_SUPPLIER_PAYMENT_DECLINED_EVENT)
async handleSupplierPaymentDeclined(event: MarketplaceSupplierPaymentDeclinedEvent): Promise<void> {
try {
@@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { InjectBucket, UseBucket } from '~/infrastructure/file-storage';
import type { InterFileStorageBucket } from '@coopenomics/inter';
import type { MarketplaceReturnClaimPhoto } from '../../domain/entities/marketplace-return-claim.types';
const MB = 1024 * 1024;
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'] as const;
export interface MarketplaceReturnClaimImageUploadInput {
/** Сырое содержимое файла. */
bytes: Uint8Array;
/** MIME-тип; должен быть в `ALLOWED_MIME`, иначе bucket бросит `MimeRejected`. */
contentType: string;
/** Идентификатор заявления (используется как часть ключа bucket'а). */
claimId: string;
/** Источник фото: 'orderer' (приложение пайщика) или 'on_site' (очный осмотр). */
role: 'orderer' | 'on_site';
/** Аккаунт пайщика/председателя, загружающего фото (метадата для аудита). */
ownerAccount: string;
/** Связанный Order (метадата для аудита). */
orderId: string;
/** Порядковый номер фото в наборе (нужен для уникального ключа). */
index: number;
}
/**
* Story 7.1 / 7.3 (AR31, AR32): загрузка фотографий, прилагаемых к
* заявлению на гарантийный возврат и к очному осмотру. Один сервис на
* bucket `stol-zakazov:images` — convention из README модуля file-storage
* (один бакет на один сервис).
*
* Backend хеширует содержимое (sha256) и кладёт в bucket по детерминированному
* ключу `returns/<claim_id>/<role>/<index>.<ext>`. SHA256-хеш файла
* используется как `photos[i]` в параметре `submretrn` on-chain (фото
* становятся проверяемыми артефактами — UI может сопоставить bucket-объект
* и on-chain checksum256).
*/
@UseBucket({
name: 'stol-zakazov:images',
maxBytes: 10 * MB,
allowedMime: ['image/jpeg', 'image/png', 'image/webp'],
metadataSchema: { ownerAccount: 'required', orderId: 'required', claimId: 'required' },
defaultUrlTtlSeconds: 600,
})
@Injectable()
export class MarketplaceReturnClaimImagesService {
constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
/**
* Сохраняет один файл в bucket и возвращает доменный снапшот фото
* (ключ, sha256-хеш контента, MIME, время загрузки).
*/
async putPhoto(input: MarketplaceReturnClaimImageUploadInput): Promise<MarketplaceReturnClaimPhoto> {
if (!ALLOWED_MIME.includes(input.contentType as (typeof ALLOWED_MIME)[number])) {
throw new Error(
`Поддерживаются только изображения JPEG/PNG/WEBP; получен ${input.contentType}.`
);
}
const contentHashHex = createHash('sha256').update(input.bytes).digest('hex');
const ext = this.extFromMime(input.contentType);
const key = `returns/${input.claimId}/${input.role}/${input.index}.${ext}`;
await this.bucket.put(key, input.bytes, {
contentType: input.contentType,
metadata: {
ownerAccount: input.ownerAccount,
orderId: input.orderId,
claimId: input.claimId,
},
});
return {
bucket_key: key,
content_hash: contentHashHex,
mime_type: input.contentType,
uploaded_at: new Date(),
};
}
/**
* Возвращает HMAC-подписанный URL для чтения фото (TTL по умолчанию из
* `defaultUrlTtlSeconds` спеки бакета). UI отображает фото через этот URL.
*/
async getReadUrl(bucketKey: string): Promise<string> {
return this.bucket.getReadUrl(bucketKey);
}
private extFromMime(mime: string): string {
switch (mime) {
case 'image/jpeg':
return 'jpg';
case 'image/png':
return 'png';
case 'image/webp':
return 'webp';
default:
return 'bin';
}
}
}
@@ -0,0 +1,834 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { createHash, randomUUID } from 'crypto';
import { Cooperative, type MarketContract } from 'cooptypes';
import { PublicKey, Signature } from '@wharfkit/antelope';
import http from 'http-status';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { HttpApiError } from '~/utils/httpApiError';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import {
MARKETPLACE_ORDER_REPOSITORY,
type MarketplaceOrderDomainRepository,
} from '../../domain/repositories/marketplace-order.repository';
import {
MARKETPLACE_RETURN_CLAIM_REPOSITORY,
type MarketplaceReturnClaimDomainRepository,
} from '../../domain/repositories/marketplace-return-claim.repository';
import {
MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT,
type MarketplaceCanonicalBlockchainPort,
} from '../../domain/ports/marketplace-canonical-blockchain.port';
import { MarketplaceReturnClaimImagesService } from './marketplace-return-claim-images.service';
import type { MarketplaceReturnClaimDomainEntity } from '../../domain/entities/marketplace-return-claim.entity';
import {
MarketplaceReturnClaimDefectCategories,
MarketplaceReturnClaimExpectedResolutions,
MarketplaceReturnClaimStatuses,
type MarketplaceReturnClaimDecisionLogEntry,
type MarketplaceReturnClaimDefectCategory,
type MarketplaceReturnClaimLedgerSnapshot,
type MarketplaceReturnClaimOnSiteInspection,
type MarketplaceReturnClaimPhoto,
} from '../../domain/entities/marketplace-return-claim.types';
import type { MarketplaceOrderDomainEntity } from '../../domain/entities/marketplace-order.entity';
import type { MarketplaceReturnStatementSignedInputDTO } from '~/application/document/documents-dto/marketplace-return-statement-document.dto';
import {
MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT,
MARKETPLACE_RETURN_CLAIM_DECIDED_EVENT,
MARKETPLACE_RETURN_CLAIM_FINALIZED_EVENT,
type MarketplaceReturnClaimSubmittedEvent,
type MarketplaceReturnClaimDecidedEvent,
type MarketplaceReturnClaimFinalizedEvent,
} from '../events/marketplace-notification.events';
/**
* Сырой файл фото, поступающий из mutation. UI кодирует содержимое в base64
* (поскольку graphql-upload потоковая загрузка в desktop UI не используется,
* фронт уже работает через base64-payload в input'е). Backend сам хеширует и
* кладёт в bucket `stol-zakazov:images`.
*/
export interface MarketplaceReturnClaimImageUploadDTO {
/** Содержимое файла в base64. */
base64: string;
/** MIME-тип — допускаются image/jpeg | image/png | image/webp. */
mime_type: string;
}
export interface MarketplaceCreateReturnClaimInput {
coopname: string;
orderer_account: string;
order_id: string;
reason_text: string;
defect_category: MarketplaceReturnClaimDefectCategory | null;
/** Возвращаемое количество — по умолчанию = order.actual_quantity. */
actual_quantity?: number;
/** Подписанное заказчиком on-chain заявление (registry_id=1104). */
signed_statement: MarketplaceReturnStatementSignedInputDTO;
/** Фотографии товара — обязательно мин. 1, макс. 10. */
photos: MarketplaceReturnClaimImageUploadDTO[];
}
export interface MarketplaceApproveReturnVisitInput {
coopname: string;
chairman_account: string;
braname: string;
claim_id: string;
comment: string;
/** Опциональный документ-решение (registry_id=0 по стандарту, либо in-system запись). */
signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
export interface MarketplaceRejectReturnRemoteInput {
coopname: string;
chairman_account: string;
braname: string;
claim_id: string;
comment: string;
signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
export interface MarketplaceAcceptReturnAtVisitInput {
coopname: string;
chairman_account: string;
braname: string;
claim_id: string;
inspection_result: string;
scanned_barcode: string | null;
inspection_photos?: MarketplaceReturnClaimImageUploadDTO[];
signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
export interface MarketplaceRejectReturnAtVisitInput {
coopname: string;
chairman_account: string;
braname: string;
claim_id: string;
inspection_result: string;
inspection_photos?: MarketplaceReturnClaimImageUploadDTO[];
signed_decision?: MarketplaceReturnStatementSignedInputDTO;
}
export interface MarketplaceReturnClaimResult {
claim: MarketplaceReturnClaimDomainEntity;
tx_hash: string;
}
/**
* Эпик 7 (Story 7.1-7.4 / FR29-FR33): state machine гарантийного возврата
* имущества пайщиком. Backend оркеструет процесс p.mkt.return (стандарт
* `p.mkt.return.standard.yaml`); все 5 переходов проходят через C++
* actions контракта `marketplace`:
*
* - submretrn (Story 7.1) — пайщик подаёт заявление; PENDING_CHAIRMAN_REVIEW
* - aprretrem (Story 7.2) — председатель одобряет очный визит; APPROVED_FOR_VISIT
* - rejretrem (Story 7.2) — отказ удалённо; REJECTED_REMOTELY (final)
* - accretrn (Story 7.4) — приём возврата на очном осмотре; ACCEPTED_AT_VISIT
* (final, композитная пара o.mkt.return + o.mkt.return2
* через транзит 91, восстановление w.mkt.member.available)
* - rejretrn (Story 7.3) — отказ на очном осмотре; REJECTED_AT_VISIT (final)
*
* Фотографии товара и очного осмотра лежат в bucket'е `stol-zakazov:images`
* (через `MarketplaceReturnClaimImagesService`), on-chain публикуются их
* sha256-хеши (`photos[]` параметра submretrn).
*
* Order остаётся в статусе RECEIVED — возврат фиксируется отдельной
* сущностью; в UI orderer'а в карточке заказа появляется overlay по claim'у
* (статус возврата, decision_log, ledger_snapshot).
*/
@Injectable()
export class MarketplaceReturnClaimService {
constructor(
@Inject(MARKETPLACE_RETURN_CLAIM_REPOSITORY)
private readonly claimRepo: MarketplaceReturnClaimDomainRepository,
@Inject(MARKETPLACE_ORDER_REPOSITORY)
private readonly orderRepo: MarketplaceOrderDomainRepository,
@Inject(MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT)
private readonly chainPort: MarketplaceCanonicalBlockchainPort,
private readonly documentDomainService: DocumentDomainService,
private readonly imagesService: MarketplaceReturnClaimImagesService,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceReturnClaimService.name);
}
// ── Read-side queries ─────────────────────────────────────────────────
async findById(coopname: string, id: string): Promise<MarketplaceReturnClaimDomainEntity> {
const claim = await this.claimRepo.findById(id);
if (!claim || claim.coopname !== coopname) {
throw new NotFoundException(`Заявление на возврат ${id} не найдено.`);
}
return claim;
}
async listByOrderer(coopname: string, orderer_account: string) {
return this.claimRepo.listByOrderer(coopname, orderer_account);
}
async listByDeliveryBraname(coopname: string, delivery_braname: string) {
return this.claimRepo.listByDeliveryBraname(coopname, delivery_braname);
}
/**
* Story 7.1: backend-генерируемый payload заявления (registry_id=1104,
* `MarketplaceReturnStatement`). UI получает HTML preview + canonical hash
* и подписывает приватным ключом пайщика, после чего отправляет в
* `submitReturnClaim` вместе с фото.
*
* `request_hash` детерминирован: sha256('return:' + order_hash + ':' +
* orderer + ':' + actual_quantity). Тот же hash используется как `hash`
* генерируемого заявления и как `request_hash` параметра submretrn —
* двусторонняя сверка backend ↔ on-chain.
*/
async getReturnClaimSignablePayload(input: {
coopname: string;
orderer_account: string;
order_id: string;
actual_quantity?: number;
reason_text?: string;
defect_category?: MarketplaceReturnClaimDefectCategory | null;
}): Promise<DocumentDomainEntity> {
const order = await this.loadOrderForReturn(input.coopname, input.order_id, input.orderer_account);
const quantity = this.resolveActualQuantity(order, input.actual_quantity);
return this.generateStatementDocument({
order,
orderer: input.orderer_account,
actual_quantity: quantity,
reason_text: input.reason_text,
defect_category: input.defect_category ?? undefined,
});
}
// ── Story 7.1: пайщик подаёт заявление ───────────────────────────────
async submitReturnClaim(
input: MarketplaceCreateReturnClaimInput
): Promise<MarketplaceReturnClaimResult> {
if (!input.reason_text || input.reason_text.trim().length === 0) {
throw new BadRequestException('Опишите причину возврата.');
}
if (input.reason_text.length > 500) {
throw new BadRequestException('Причина возврата не должна превышать 500 символов.');
}
if (!Array.isArray(input.photos) || input.photos.length === 0) {
throw new BadRequestException('Приложите хотя бы одну фотографию товара.');
}
if (input.photos.length > 10) {
throw new BadRequestException('Можно приложить не более 10 фотографий.');
}
const order = await this.loadOrderForReturn(input.coopname, input.order_id, input.orderer_account);
const actual_quantity = this.resolveActualQuantity(order, input.actual_quantity);
const existingActive = await this.claimRepo.findActiveByOrderId(input.coopname, order.id);
if (existingActive) {
throw new ConflictException(
'По этому заказу уже открыто заявление на возврат.'
);
}
this.verifySignatures(input.signed_statement);
const claimId = randomUUID();
const request_hash = this.computeRequestHash({
order_hash: order.order_hash,
orderer: input.orderer_account,
actual_quantity,
});
const fact_cost = this.computeFactCost(order, actual_quantity);
// Сначала кладём фото в bucket, чтобы on-chain submit мог опереться
// на их sha256-хеши.
const photos = await this.uploadPhotos({
files: input.photos,
claimId,
role: 'orderer',
ownerAccount: input.orderer_account,
orderId: order.id,
});
const statementAct = input.signed_statement.toDocument() as MarketContract.Actions.SubmRetrn.ISubmRetrn['statement'];
let tx;
try {
tx = await this.chainPort.submRetrn({
coopname: order.coopname,
orderer: input.orderer_account,
request_hash,
original_order_hash: order.order_hash,
actual_quantity,
reason_text: input.reason_text,
photos: photos.map((p) => p.content_hash),
statement: statementAct,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Подача заявления на возврат order ${order.id}: on-chain submretrn упал (${message}); заявление не зарегистрировано.`
);
throw new ConflictException(
`Подача заявления на возврат не выполнена: ${message}. Повторите попытку.`
);
}
const txHash = this.extractTxHash(tx) || `submretrn-${claimId}`;
const claim = await this.claimRepo.create({
id: claimId,
coopname: order.coopname,
request_hash,
order_id: order.id,
order_hash: order.order_hash,
orderer_account: input.orderer_account,
delivery_braname: order.delivery_braname,
supplier_account: order.supplier_account,
reason_text: input.reason_text,
defect_category: input.defect_category ?? null,
expected_resolution: MarketplaceReturnClaimExpectedResolutions.FUNDS_RETURN,
actual_quantity,
fact_cost,
photos,
submretrn_tx_hash: txHash,
status: MarketplaceReturnClaimStatuses.PENDING_CHAIRMAN_REVIEW,
});
this.logger.log(
`Заявление на возврат ${claim.id} зарегистрировано: order=${order.id}, photos=${photos.length}, fact_cost=${fact_cost} (tx=${txHash}).`
);
const event: MarketplaceReturnClaimSubmittedEvent = {
coopname: claim.coopname,
claim_id: claim.id,
order_id: claim.order_id,
orderer_account: claim.orderer_account,
delivery_braname: claim.delivery_braname,
reason_text: claim.reason_text,
};
this.eventBus.emit(MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT, event);
return { claim, tx_hash: txHash };
}
// ── Story 7.2: председатель удалённо ─────────────────────────────────
async approveReturnVisit(
input: MarketplaceApproveReturnVisitInput
): Promise<MarketplaceReturnClaimResult> {
if (!input.comment || input.comment.trim().length === 0) {
throw new BadRequestException('Укажите комментарий к одобрению.');
}
const claim = await this.findById(input.coopname, input.claim_id);
if (claim.status !== MarketplaceReturnClaimStatuses.PENDING_CHAIRMAN_REVIEW) {
throw new ConflictException(
`Заявление в статусе «${claim.status}», удалённое одобрение недопустимо.`
);
}
if (input.signed_decision) this.verifySignatures(input.signed_decision);
const decisionDoc = input.signed_decision
? (input.signed_decision.toDocument() as MarketContract.Actions.AprRetRem.IAprRetRem['decision'])
: this.emptyDocument();
let tx;
try {
tx = await this.chainPort.aprRetRem({
coopname: claim.coopname,
signer: input.chairman_account,
braname: input.braname,
request_hash: claim.request_hash,
decision: decisionDoc,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Одобрение возврата claim ${claim.id}: on-chain aprretrem упал (${message}); статус не меняется.`
);
throw new ConflictException(
`Одобрение очного визита на цепи не выполнено: ${message}.`
);
}
const txHash = this.extractTxHash(tx) || `aprretrem-${claim.id}`;
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'remote',
decision: 'approve_visit',
by_chairman_account: input.chairman_account,
braname: input.braname,
comment: input.comment,
at: new Date(),
tx_hash: txHash,
};
const updated = await this.claimRepo.applyDecision(claim.id, {
status: MarketplaceReturnClaimStatuses.APPROVED_FOR_VISIT,
decision_entry: entry,
});
this.logger.log(
`Заявление на возврат ${claim.id}: председатель ${input.chairman_account} одобрил очный визит на КУ ${input.braname} (tx=${txHash}).`
);
this.emitDecided(updated, entry);
return { claim: updated, tx_hash: txHash };
}
async rejectReturnRemote(
input: MarketplaceRejectReturnRemoteInput
): Promise<MarketplaceReturnClaimResult> {
this.requireComment(input.comment);
const claim = await this.findById(input.coopname, input.claim_id);
if (claim.status !== MarketplaceReturnClaimStatuses.PENDING_CHAIRMAN_REVIEW) {
throw new ConflictException(
`Заявление в статусе «${claim.status}», удалённый отказ недопустим.`
);
}
if (input.signed_decision) this.verifySignatures(input.signed_decision);
const decisionDoc = input.signed_decision
? (input.signed_decision.toDocument() as MarketContract.Actions.RejRetRem.IRejRetRem['decision'])
: this.emptyDocument();
let tx;
try {
tx = await this.chainPort.rejRetRem({
coopname: claim.coopname,
signer: input.chairman_account,
braname: input.braname,
request_hash: claim.request_hash,
reason: input.comment,
decision: decisionDoc,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Отказ удалённо claim ${claim.id}: on-chain rejretrem упал (${message}); статус не меняется.`
);
throw new ConflictException(
`Отказ удалённо на цепи не выполнен: ${message}.`
);
}
const txHash = this.extractTxHash(tx) || `rejretrem-${claim.id}`;
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'remote',
decision: 'reject_remote',
by_chairman_account: input.chairman_account,
braname: input.braname,
comment: input.comment,
at: new Date(),
tx_hash: txHash,
};
const updated = await this.claimRepo.applyDecision(claim.id, {
status: MarketplaceReturnClaimStatuses.REJECTED_REMOTELY,
decision_entry: entry,
});
this.emitDecided(updated, entry);
this.emitFinalized(updated, entry);
return { claim: updated, tx_hash: txHash };
}
// ── Story 7.3 / 7.4: очный осмотр + compensating forward ─────────────
async acceptReturnAtVisit(
input: MarketplaceAcceptReturnAtVisitInput
): Promise<MarketplaceReturnClaimResult> {
this.requireInspectionResult(input.inspection_result);
const claim = await this.findById(input.coopname, input.claim_id);
if (claim.status !== MarketplaceReturnClaimStatuses.APPROVED_FOR_VISIT) {
throw new ConflictException(
`Заявление в статусе «${claim.status}», приём возврата на месте недопустим.`
);
}
if (input.scanned_barcode) {
// FR32: барскод сверяется с Order. В MVP минимальная сверка — что
// штрих-код принадлежит заказу. Глубокая логика inventory будет
// реализована вместе с canonical Inventory (Эпик 5/9).
if (!input.scanned_barcode.includes(claim.order_id.slice(0, 8))) {
throw new BadRequestException(
'Штрих-код не соответствует заказу. Проверьте имущество.'
);
}
}
if (input.signed_decision) this.verifySignatures(input.signed_decision);
const decisionDoc = input.signed_decision
? (input.signed_decision.toDocument() as MarketContract.Actions.AccRetrn.IAccRetrn['decision'])
: this.emptyDocument();
const inspectionPhotos = await this.uploadOptionalPhotos({
files: input.inspection_photos,
claimId: claim.id,
role: 'on_site',
ownerAccount: input.chairman_account,
orderId: claim.order_id,
});
let tx;
try {
tx = await this.chainPort.accRetrn({
coopname: claim.coopname,
signer: input.chairman_account,
braname: input.braname,
request_hash: claim.request_hash,
decision: decisionDoc,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Приём возврата claim ${claim.id}: on-chain accretrn упал (${message}); compensating forward не выполнен.`
);
throw new ConflictException(
`Приём возврата на цепи не выполнен: ${message}. Compensating forward не применён.`
);
}
const txHash = this.extractTxHash(tx) || `accretrn-${claim.id}`;
const at = new Date();
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'on_site',
decision: 'accept_at_visit',
by_chairman_account: input.chairman_account,
braname: input.braname,
comment: input.inspection_result,
at,
tx_hash: txHash,
};
const inspection: MarketplaceReturnClaimOnSiteInspection = {
result_text: input.inspection_result,
photos: inspectionPhotos,
scanned_barcode: input.scanned_barcode ?? null,
by_chairman_account: input.chairman_account,
at,
};
const ledger: MarketplaceReturnClaimLedgerSnapshot = {
amount: claim.fact_cost,
returned_quantity: claim.actual_quantity,
tx_hash: txHash,
at,
};
const updated = await this.claimRepo.applyDecision(claim.id, {
status: MarketplaceReturnClaimStatuses.ACCEPTED_AT_VISIT,
decision_entry: entry,
on_site_inspection: inspection,
ledger_snapshot: ledger,
});
this.logger.log(
`Заявление на возврат ${claim.id} принято: compensating forward выполнен на ${claim.fact_cost} (tx=${txHash}).`
);
this.emitDecided(updated, entry);
this.emitFinalized(updated, entry);
return { claim: updated, tx_hash: txHash };
}
async rejectReturnAtVisit(
input: MarketplaceRejectReturnAtVisitInput
): Promise<MarketplaceReturnClaimResult> {
this.requireInspectionResult(input.inspection_result);
const claim = await this.findById(input.coopname, input.claim_id);
if (claim.status !== MarketplaceReturnClaimStatuses.APPROVED_FOR_VISIT) {
throw new ConflictException(
`Заявление в статусе «${claim.status}», отказ на месте недопустим.`
);
}
if (input.signed_decision) this.verifySignatures(input.signed_decision);
const decisionDoc = input.signed_decision
? (input.signed_decision.toDocument() as MarketContract.Actions.RejRetrn.IRejRetrn['decision'])
: this.emptyDocument();
const inspectionPhotos = await this.uploadOptionalPhotos({
files: input.inspection_photos,
claimId: claim.id,
role: 'on_site',
ownerAccount: input.chairman_account,
orderId: claim.order_id,
});
let tx;
try {
tx = await this.chainPort.rejRetrn({
coopname: claim.coopname,
signer: input.chairman_account,
braname: input.braname,
request_hash: claim.request_hash,
reason: input.inspection_result,
decision: decisionDoc,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Отказ на месте claim ${claim.id}: on-chain rejretrn упал (${message}); статус не меняется.`
);
throw new ConflictException(
`Отказ на цепи не выполнен: ${message}.`
);
}
const txHash = this.extractTxHash(tx) || `rejretrn-${claim.id}`;
const at = new Date();
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'on_site',
decision: 'reject_at_visit',
by_chairman_account: input.chairman_account,
braname: input.braname,
comment: input.inspection_result,
at,
tx_hash: txHash,
};
const inspection: MarketplaceReturnClaimOnSiteInspection = {
result_text: input.inspection_result,
photos: inspectionPhotos,
scanned_barcode: null,
by_chairman_account: input.chairman_account,
at,
};
const updated = await this.claimRepo.applyDecision(claim.id, {
status: MarketplaceReturnClaimStatuses.REJECTED_AT_VISIT,
decision_entry: entry,
on_site_inspection: inspection,
});
this.emitDecided(updated, entry);
this.emitFinalized(updated, entry);
return { claim: updated, tx_hash: txHash };
}
// ── Image upload helper for resolver ─────────────────────────────────
async getPhotoReadUrl(bucketKey: string): Promise<string> {
return this.imagesService.getReadUrl(bucketKey);
}
// ── private helpers ──────────────────────────────────────────────────
private async loadOrderForReturn(
coopname: string,
order_id: string,
orderer_account: string
): Promise<MarketplaceOrderDomainEntity> {
const order = await this.orderRepo.findById(order_id);
if (!order || order.coopname !== coopname) {
throw new NotFoundException(`Заказ ${order_id} не найден.`);
}
if (order.orderer_account !== orderer_account) {
throw new ForbiddenException('Подавать заявление на возврат может только заказчик-владелец заказа.');
}
if (order.status !== 'RECEIVED') {
throw new ConflictException(
`Возврат возможен только по выданному заказу (текущий статус «${order.status}»).`
);
}
if (order.warranty_until !== null && order.warranty_until.getTime() <= Date.now()) {
throw new ConflictException(
`Гарантийный срок истёк ${order.warranty_until.toISOString().slice(0, 10)}.`
);
}
return order;
}
private resolveActualQuantity(order: MarketplaceOrderDomainEntity, requested?: number): number {
const factQty = order.issuance_fact?.actual_quantity ?? order.quantity;
if (requested === undefined || requested === null) return factQty;
if (requested <= 0) {
throw new BadRequestException('Возвращаемое количество должно быть больше нуля.');
}
if (requested > factQty) {
throw new BadRequestException(
`Нельзя вернуть больше единиц, чем было выдано (выдано ${factQty}).`
);
}
return requested;
}
private computeFactCost(order: MarketplaceOrderDomainEntity, actual_quantity: number): string {
const unitPrice = Number.parseFloat(order.price_per_unit);
return (actual_quantity * unitPrice).toFixed(4);
}
private computeRequestHash(input: {
order_hash: string;
orderer: string;
actual_quantity: number;
}): string {
const seed = `return:${input.order_hash}:${input.orderer}:${input.actual_quantity}`;
return createHash('sha256').update(seed).digest('hex');
}
private async generateStatementDocument(input: {
order: MarketplaceOrderDomainEntity;
orderer: string;
actual_quantity: number;
reason_text?: string;
defect_category?: string | null;
}): Promise<DocumentDomainEntity> {
const fact_cost = this.computeFactCost(input.order, input.actual_quantity);
const action: Cooperative.Registry.MarketplaceReturnStatement.Action = {
registry_id: Cooperative.Registry.MarketplaceReturnStatement.registry_id,
coopname: input.order.coopname,
username: input.orderer,
order_id: input.order.id,
order_hash: input.order.order_hash,
braname: input.order.delivery_braname,
reason_text: input.reason_text ?? '',
defect_category: input.defect_category ?? undefined,
actual_quantity: input.actual_quantity,
fact_cost,
skip_save: true,
};
return this.documentDomainService.generateDocument({ data: action });
}
private async uploadPhotos(input: {
files: MarketplaceReturnClaimImageUploadDTO[];
claimId: string;
role: 'orderer' | 'on_site';
ownerAccount: string;
orderId: string;
}): Promise<MarketplaceReturnClaimPhoto[]> {
const out: MarketplaceReturnClaimPhoto[] = [];
for (let i = 0; i < input.files.length; i++) {
const f = input.files[i];
const bytes = Buffer.from(f.base64, 'base64');
const photo = await this.imagesService.putPhoto({
bytes,
contentType: f.mime_type,
claimId: input.claimId,
role: input.role,
ownerAccount: input.ownerAccount,
orderId: input.orderId,
index: i,
});
out.push(photo);
}
return out;
}
private async uploadOptionalPhotos(input: {
files?: MarketplaceReturnClaimImageUploadDTO[];
claimId: string;
role: 'orderer' | 'on_site';
ownerAccount: string;
orderId: string;
}): Promise<MarketplaceReturnClaimPhoto[]> {
if (!input.files || input.files.length === 0) return [];
if (input.files.length > 10) {
throw new BadRequestException('Можно приложить не более 10 фотографий очного осмотра.');
}
return this.uploadPhotos({
files: input.files,
claimId: input.claimId,
role: input.role,
ownerAccount: input.ownerAccount,
orderId: input.orderId,
});
}
private verifySignatures(document: ISignedDocumentDomainInterface): void {
if (!document.signatures || document.signatures.length === 0) {
throw new HttpApiError(http.BAD_REQUEST, 'Документ не подписан: signatures пуст.');
}
for (const sig of document.signatures) {
const publicKey = PublicKey.from(sig.public_key);
const signature = Signature.from(sig.signature);
const verified = signature.verifyDigest(sig.signed_hash, publicKey);
if (!verified) {
throw new HttpApiError(http.BAD_REQUEST, 'Недействительная подпись документа возврата.');
}
}
}
private emptyDocument(): MarketContract.Actions.AprRetRem.IAprRetRem['decision'] {
return {
version: '',
hash: '',
doc_hash: '',
meta_hash: '',
meta: '',
signatures: [],
};
}
private extractTxHash(tx: unknown): string {
const candidate = tx as
| {
response?: { transaction_id?: string };
resolved?: { transaction?: { id?: string } };
transaction?: { id?: string };
}
| undefined;
return (
candidate?.response?.transaction_id ??
candidate?.resolved?.transaction?.id ??
candidate?.transaction?.id ??
''
);
}
private requireComment(comment: string): void {
if (!comment || comment.trim().length === 0) {
throw new BadRequestException('Укажите комментарий к решению.');
}
if (comment.length > 500) {
throw new BadRequestException('Комментарий не должен превышать 500 символов.');
}
}
private requireInspectionResult(result: string): void {
if (!result || result.trim().length === 0) {
throw new BadRequestException('Укажите результат очного осмотра.');
}
if (result.length > 2000) {
throw new BadRequestException('Результат осмотра не должен превышать 2000 символов.');
}
}
private emitDecided(
claim: MarketplaceReturnClaimDomainEntity,
entry: MarketplaceReturnClaimDecisionLogEntry
): void {
const event: MarketplaceReturnClaimDecidedEvent = {
coopname: claim.coopname,
claim_id: claim.id,
orderer_account: claim.orderer_account,
stage: entry.stage,
decision: entry.decision,
comment: entry.comment,
braname: entry.braname,
};
this.eventBus.emit(MARKETPLACE_RETURN_CLAIM_DECIDED_EVENT, event);
}
private emitFinalized(
claim: MarketplaceReturnClaimDomainEntity,
entry: MarketplaceReturnClaimDecisionLogEntry
): void {
const event: MarketplaceReturnClaimFinalizedEvent = {
coopname: claim.coopname,
claim_id: claim.id,
orderer_account: claim.orderer_account,
final_status: claim.status,
decision: entry.decision,
comment: entry.comment,
ledger_snapshot: claim.ledger_snapshot,
};
this.eventBus.emit(MARKETPLACE_RETURN_CLAIM_FINALIZED_EVENT, event);
}
}
export const MARKETPLACE_RETURN_CLAIM_SERVICE = Symbol('MARKETPLACE_RETURN_CLAIM_SERVICE');
// Ссылка на канонический справочник категорий — для GraphQL enum.
export const MARKETPLACE_RETURN_CLAIM_DEFECT_CATEGORIES = MarketplaceReturnClaimDefectCategories;
@@ -0,0 +1,88 @@
import {
MarketplaceReturnClaimStatuses,
type MarketplaceReturnClaimDecisionLogEntry,
type MarketplaceReturnClaimDefectCategory,
type MarketplaceReturnClaimExpectedResolution,
type MarketplaceReturnClaimLedgerSnapshot,
type MarketplaceReturnClaimOnSiteInspection,
type MarketplaceReturnClaimPhoto,
type MarketplaceReturnClaimProps,
type MarketplaceReturnClaimStatus,
} from './marketplace-return-claim.types';
/**
* Эпик 7: домен заявления на гарантийный возврат. Backend ведёт state
* machine процесса p.mkt.return, on-chain действия (submretrn, aprretrem,
* rejretrem, accretrn, rejretrn) фиксируют переходы и якорятся `request_hash`
* в таблице `marketplace::return_request`.
*/
export class MarketplaceReturnClaimDomainEntity {
public readonly id: string;
public readonly coopname: string;
public readonly request_hash: string;
public readonly order_id: string;
public readonly order_hash: string;
public readonly orderer_account: string;
public readonly delivery_braname: string;
public readonly supplier_account: string;
public status: MarketplaceReturnClaimStatus;
public readonly reason_text: string;
public readonly defect_category: MarketplaceReturnClaimDefectCategory | null;
public readonly expected_resolution: MarketplaceReturnClaimExpectedResolution;
public readonly actual_quantity: number;
public readonly fact_cost: string;
public readonly photos: MarketplaceReturnClaimPhoto[];
public readonly submretrn_tx_hash: string;
public decision_log: MarketplaceReturnClaimDecisionLogEntry[];
public on_site_inspection: MarketplaceReturnClaimOnSiteInspection | null;
public ledger_snapshot: MarketplaceReturnClaimLedgerSnapshot | null;
public readonly created_at: Date;
public updated_at: Date;
constructor(props: MarketplaceReturnClaimProps) {
if (!props.id || !props.coopname || !props.request_hash || !props.order_id) {
throw new Error('MarketplaceReturnClaimDomainEntity: обязательные поля отсутствуют.');
}
this.id = props.id;
this.coopname = props.coopname;
this.request_hash = props.request_hash;
this.order_id = props.order_id;
this.order_hash = props.order_hash;
this.orderer_account = props.orderer_account;
this.delivery_braname = props.delivery_braname;
this.supplier_account = props.supplier_account;
this.status = props.status;
this.reason_text = props.reason_text;
this.defect_category = props.defect_category;
this.expected_resolution = props.expected_resolution;
this.actual_quantity = props.actual_quantity;
this.fact_cost = props.fact_cost;
this.photos = props.photos;
this.submretrn_tx_hash = props.submretrn_tx_hash;
this.decision_log = props.decision_log;
this.on_site_inspection = props.on_site_inspection;
this.ledger_snapshot = props.ledger_snapshot;
this.created_at = props.created_at;
this.updated_at = props.updated_at;
}
public get awaits_chairman_remote_review(): boolean {
return this.status === MarketplaceReturnClaimStatuses.PENDING_CHAIRMAN_REVIEW;
}
public get awaits_visit(): boolean {
return this.status === MarketplaceReturnClaimStatuses.APPROVED_FOR_VISIT;
}
public get is_finalized(): boolean {
return (
this.status === MarketplaceReturnClaimStatuses.ACCEPTED_AT_VISIT ||
this.status === MarketplaceReturnClaimStatuses.REJECTED_AT_VISIT ||
this.status === MarketplaceReturnClaimStatuses.REJECTED_REMOTELY
);
}
public get is_funds_returned(): boolean {
return this.status === MarketplaceReturnClaimStatuses.ACCEPTED_AT_VISIT;
}
}
@@ -0,0 +1,165 @@
/**
* Эпик 7 (Story 7.1-7.4): типы заявления на гарантийный возврат имущества.
* Backend-only state machine, on-chain якорь — `marketplace::return_request`
* (анкер процесса p.mkt.return). Compensating-forward пара
* `o.mkt.return + o.mkt.return2` через транзит 91 выполняется в `accretrn`.
*
* Источник правды графа состояний — `p.mkt.return.standard.yaml` секция
* `states:`; контрактные имена статусов в `ReturnStatus::*` ядра
* (`pendrev / approvvisit / accepted / rejremote / rejatku`).
*/
/**
* Категория дефекта — опциональный признак для фасеточной аналитики
* (по согласованию с продакт-менеджером может расширяться без миграции).
*/
export type MarketplaceReturnClaimDefectCategory =
| 'BROKEN'
| 'EXPIRED'
| 'NOT_AS_DESCRIBED'
| 'WRONG_ITEM'
| 'OTHER';
export const MarketplaceReturnClaimDefectCategories = {
BROKEN: 'BROKEN',
EXPIRED: 'EXPIRED',
NOT_AS_DESCRIBED: 'NOT_AS_DESCRIBED',
WRONG_ITEM: 'WRONG_ITEM',
OTHER: 'OTHER',
} as const satisfies Record<string, MarketplaceReturnClaimDefectCategory>;
/**
* Какое разрешение ожидает пайщик. В MVP — только возврат средств на
* программный кошелёк (`w.mkt.member.available`). Расширения (`REPLACEMENT`,
* `REPAIR`) — Phase 2 (out of MVP).
*/
export type MarketplaceReturnClaimExpectedResolution = 'FUNDS_RETURN';
export const MarketplaceReturnClaimExpectedResolutions = {
FUNDS_RETURN: 'FUNDS_RETURN',
} as const satisfies Record<string, MarketplaceReturnClaimExpectedResolution>;
/**
* Статусы заявления. Маппинг на контрактные имена `ReturnStatus::*` ниже:
*
* PENDING_CHAIRMAN_REVIEW ↔ pendrev (Story 7.1 — после submretrn)
* APPROVED_FOR_VISIT ↔ approvvisit (Story 7.2 — после aprretrem)
* REJECTED_REMOTELY ↔ rejremote (Story 7.2 — после rejretrem)
* ACCEPTED_AT_VISIT ↔ accepted (Story 7.4 — после accretrn,
* composite forward выполнен)
* REJECTED_AT_VISIT ↔ rejatku (Story 7.3 — после rejretrn)
*/
export type MarketplaceReturnClaimStatus =
| 'PENDING_CHAIRMAN_REVIEW'
| 'APPROVED_FOR_VISIT'
| 'REJECTED_REMOTELY'
| 'ACCEPTED_AT_VISIT'
| 'REJECTED_AT_VISIT';
export const MarketplaceReturnClaimStatuses = {
PENDING_CHAIRMAN_REVIEW: 'PENDING_CHAIRMAN_REVIEW',
APPROVED_FOR_VISIT: 'APPROVED_FOR_VISIT',
REJECTED_REMOTELY: 'REJECTED_REMOTELY',
ACCEPTED_AT_VISIT: 'ACCEPTED_AT_VISIT',
REJECTED_AT_VISIT: 'REJECTED_AT_VISIT',
} as const satisfies Record<string, MarketplaceReturnClaimStatus>;
/**
* Снапшот фото-доказательства, приложенного пайщиком к заявлению
* (Story 7.1) либо председателем при очном осмотре (Story 7.3).
*
* `bucket_key` — ключ объекта в bucket'е `stol-zakazov:images` (формат
* `returns/<claim_id>/<role>/<index>.<ext>`). `content_hash` — sha256
* содержимого, ровно тот же checksum используется в `photos[]` параметра
* `submretrn` на цепи (двусторонняя сверка вычислений: backend bucket +
* on-chain hash).
*/
export interface MarketplaceReturnClaimPhoto {
bucket_key: string;
content_hash: string;
mime_type: string;
uploaded_at: Date;
}
/**
* Запись о решении председателя — append-only, журналирует «кто, когда,
* каким действием, с какой репликой». Используется UI заказчика и
* audit-trail.
*/
export interface MarketplaceReturnClaimDecisionLogEntry {
/** Логическая стадия процесса: 'remote' (Story 7.2) либо 'on_site' (Story 7.3). */
stage: 'remote' | 'on_site';
/** Решение председателя на стадии. */
decision: 'approve_visit' | 'reject_remote' | 'accept_at_visit' | 'reject_at_visit';
by_chairman_account: string;
/** КУ, под которым председатель принимал решение. */
braname: string;
comment: string;
at: Date;
/** tx_hash on-chain action, выполнившего решение (aprretrem / rejretrem / accretrn / rejretrn). */
tx_hash: string;
}
/**
* Снапшот compensating-forward пары `o.mkt.return + o.mkt.return2`,
* выполненной в композитной транзакции `accretrn` (Story 7.4). Снимок —
* для архива и UI; источник правды — ledger2 journal.
*/
export interface MarketplaceReturnClaimLedgerSnapshot {
/** Сумма compensating forward (равна order.fact_cost для returned_quantity = actual_quantity Order'а). */
amount: string;
/** Возвращённое количество — в MVP всегда равно actual_quantity исходного Order'а. */
returned_quantity: number;
/** Композитная транзакция accretrn. */
tx_hash: string;
/** Время фиксации возврата. */
at: Date;
}
/**
* Параметры очного осмотра — заполняются на стадии Story 7.3.
*/
export interface MarketplaceReturnClaimOnSiteInspection {
/** Текстовое описание результата осмотра (≤ 2000 символов). */
result_text: string;
/** Фото очного осмотра (опционально, ≤ 10 шт). */
photos: MarketplaceReturnClaimPhoto[];
/** Считанный штрих-код имущества (для сверки с Order). */
scanned_barcode: string | null;
/** Председатель, проводивший осмотр. */
by_chairman_account: string;
/** Время очного осмотра. */
at: Date;
}
export interface MarketplaceReturnClaimProps {
id: string;
coopname: string;
/** Якорный hash on-chain return_request (`request_hash` параметр submretrn). */
request_hash: string;
order_id: string;
order_hash: string;
orderer_account: string;
/** КУ, на котором имущество было выдано (delivery_braname Order'а в момент submretrn). */
delivery_braname: string;
/** Поставщик исходного заказа — фиксируется для будущего возврата поставщику (Phase 2). */
supplier_account: string;
status: MarketplaceReturnClaimStatus;
reason_text: string;
defect_category: MarketplaceReturnClaimDefectCategory | null;
expected_resolution: MarketplaceReturnClaimExpectedResolution;
/** Возвращаемое количество — по умолчанию = order.actual_quantity, может быть меньше (но > 0). */
actual_quantity: number;
/** Возвращаемая сумма = actual_quantity × unit_price (рассчитывается на submit). */
fact_cost: string;
photos: MarketplaceReturnClaimPhoto[];
/** tx_hash on-chain submretrn (хранится для трассировки). */
submretrn_tx_hash: string;
decision_log: MarketplaceReturnClaimDecisionLogEntry[];
/** Заполняется в Story 7.3 (accretrn / rejretrn). */
on_site_inspection: MarketplaceReturnClaimOnSiteInspection | null;
/** Снапшот compensating forward (только при status = ACCEPTED_AT_VISIT). */
ledger_snapshot: MarketplaceReturnClaimLedgerSnapshot | null;
created_at: Date;
updated_at: Date;
}
@@ -153,6 +153,65 @@ export interface MarketplaceCanonicalBlockchainPort {
* `delivery_braname`. Подписи в `act.signatures` — `{delivery_signer, orderer}`.
*/
signIss2(data: MarketContract.Actions.SignIss2.ISignIss2): Promise<TransactResult>;
/**
* Story 7.1 / FR29: пайщик подаёт заявление на гарантийный возврат.
* Без ledger2-операций — создаётся on-chain `return_request` в статусе
* `pendrev`, ставится двусторонняя связь `order.return_request_id`.
*
* Авторизация — кооператив (`require_auth(coopname)`); C++ проверяет:
* actor=order.orderer, order.status=received, warranty_until>now(),
* photos.size()>0, actual_quantity ∈ (0, order.actual_quantity].
*/
submRetrn(data: MarketContract.Actions.SubmRetrn.ISubmRetrn): Promise<TransactResult>;
/**
* Story 7.2 / FR31: председатель КУ удалённо одобряет очный визит.
* Без ledger2-операций. Статус return_request: `pendrev → approvvisit`.
*
* Авторизация — кооператив (`require_auth(coopname)`); C++ проверяет
* `Branch::is_user_authorized(coopname, braname, signer)` (председатель /
* trustee / trusted указанного КУ).
*/
aprRetRem(data: MarketContract.Actions.AprRetRem.IAprRetRem): Promise<TransactResult>;
/**
* Story 7.2 / FR31: председатель КУ удалённо отказывает в возврате.
* Без ledger2-операций. Статус: `pendrev → rejremote`; `reason_remote`
* сохраняется для UI заказчика.
*
* Авторизация — кооператив (`require_auth(coopname)`); C++ проверяет
* авторизацию `signer` для `braname` + `reason.size() ∈ (0, 500]`.
*/
rejRetRem(data: MarketContract.Actions.RejRetRem.IRejRetRem): Promise<TransactResult>;
/**
* Story 7.3 / 7.4 — FR32, FR33: председатель по результатам очного
* осмотра принимает гарантийный возврат. Композитная транзакция через
* транзит 91 (compensating forward, AR9/AR14):
*
* 1. `o.mkt.return(fact_cost, orderer)` (ISSUE, Дт 91 / Кт 86) —
* восстанавливает `.available` на `w.mkt.member` заказчика.
* 2. `o.mkt.return2(fact_cost, orderer)` (NONE, Дт 10 / Кт 91) —
* имущество возвращается на склад КУ через закрытие транзита.
*
* Статус return_request: `approvvisit → accepted` (final). Order.status
* остаётся `received` — возврат фиксируется отдельной сущностью.
*
* Авторизация — кооператив (`require_auth(coopname)`); C++ проверяет
* авторизацию `signer` для `braname` + статус заявления.
*/
accRetrn(data: MarketContract.Actions.AccRetrn.IAccRetrn): Promise<TransactResult>;
/**
* Story 7.3 / FR32: председатель отказывает в возврате на очном
* осмотре. Без ledger2-операций. Статус: `approvvisit → rejatku`;
* `reason_visit` сохраняется для UI заказчика.
*
* Авторизация — кооператив (`require_auth(coopname)`); C++ проверяет
* авторизацию `signer` для `braname` + `reason.size() ∈ (0, 500]`.
*/
rejRetrn(data: MarketContract.Actions.RejRetrn.IRejRetrn): Promise<TransactResult>;
}
export const MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT = Symbol('MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT');
@@ -0,0 +1,82 @@
import type { MarketplaceReturnClaimDomainEntity } from '../entities/marketplace-return-claim.entity';
import type {
MarketplaceReturnClaimDecisionLogEntry,
MarketplaceReturnClaimDefectCategory,
MarketplaceReturnClaimExpectedResolution,
MarketplaceReturnClaimLedgerSnapshot,
MarketplaceReturnClaimOnSiteInspection,
MarketplaceReturnClaimPhoto,
MarketplaceReturnClaimStatus,
} from '../entities/marketplace-return-claim.types';
export const MARKETPLACE_RETURN_CLAIM_REPOSITORY = Symbol('MARKETPLACE_RETURN_CLAIM_REPOSITORY');
export interface MarketplaceReturnClaimCreateInput {
id: string;
coopname: string;
request_hash: string;
order_id: string;
order_hash: string;
orderer_account: string;
delivery_braname: string;
supplier_account: string;
reason_text: string;
defect_category: MarketplaceReturnClaimDefectCategory | null;
expected_resolution: MarketplaceReturnClaimExpectedResolution;
actual_quantity: number;
fact_cost: string;
photos: MarketplaceReturnClaimPhoto[];
submretrn_tx_hash: string;
status: MarketplaceReturnClaimStatus;
}
export interface MarketplaceReturnClaimApplyDecisionInput {
status: MarketplaceReturnClaimStatus;
decision_entry: MarketplaceReturnClaimDecisionLogEntry;
on_site_inspection?: MarketplaceReturnClaimOnSiteInspection;
ledger_snapshot?: MarketplaceReturnClaimLedgerSnapshot;
}
export interface MarketplaceReturnClaimDomainRepository {
create(input: MarketplaceReturnClaimCreateInput): Promise<MarketplaceReturnClaimDomainEntity>;
findById(id: string): Promise<MarketplaceReturnClaimDomainEntity | null>;
findByRequestHash(
coopname: string,
request_hash: string
): Promise<MarketplaceReturnClaimDomainEntity | null>;
findActiveByOrderId(
coopname: string,
order_id: string
): Promise<MarketplaceReturnClaimDomainEntity | null>;
/**
* Заявления конкретного пайщика-заказчика, сортировка по created_at desc.
*/
listByOrderer(
coopname: string,
orderer_account: string,
status?: MarketplaceReturnClaimStatus | MarketplaceReturnClaimStatus[]
): Promise<MarketplaceReturnClaimDomainEntity[]>;
/**
* Заявления, отображаемые в operator-столе на КУ доставки исходного
* заказа. По умолчанию — нефинализированные (PENDING_CHAIRMAN_REVIEW +
* APPROVED_FOR_VISIT); при `status` запрашивается явная подвыборка.
*/
listByDeliveryBraname(
coopname: string,
delivery_braname: string,
status?: MarketplaceReturnClaimStatus | MarketplaceReturnClaimStatus[]
): Promise<MarketplaceReturnClaimDomainEntity[]>;
/**
* Применяет переход состояния заявления — журналирует решение в
* `decision_log` (append-only) и опционально записывает результаты
* очного осмотра / снапшот compensating-forward.
*/
applyDecision(
id: string,
input: MarketplaceReturnClaimApplyDecisionInput
): Promise<MarketplaceReturnClaimDomainEntity>;
}
@@ -244,4 +244,89 @@ export class MarketplaceCanonicalBlockchainAdapter implements MarketplaceCanonic
data,
});
}
async submRetrn(data: MarketContract.Actions.SubmRetrn.ISubmRetrn): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit submretrn'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.SubmRetrn.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async aprRetRem(data: MarketContract.Actions.AprRetRem.IAprRetRem): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit aprretrem'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.AprRetRem.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async rejRetRem(data: MarketContract.Actions.RejRetRem.IRejRetRem): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit rejretrem'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.RejRetRem.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async accRetrn(data: MarketContract.Actions.AccRetrn.IAccRetrn): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit accretrn'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.AccRetrn.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async rejRetrn(data: MarketContract.Actions.RejRetrn.IRejRetrn): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit rejretrn'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.RejRetrn.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
}
@@ -0,0 +1,125 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { MarketplaceReturnClaimDomainEntity } from '../../domain/entities/marketplace-return-claim.entity';
import {
MarketplaceReturnClaimStatuses,
type MarketplaceReturnClaimStatus,
} from '../../domain/entities/marketplace-return-claim.types';
import type {
MarketplaceReturnClaimApplyDecisionInput,
MarketplaceReturnClaimCreateInput,
MarketplaceReturnClaimDomainRepository,
} from '../../domain/repositories/marketplace-return-claim.repository';
import { MarketplaceReturnClaimEntity } from '../entities/marketplace-return-claim.entity';
import { MarketplaceReturnClaimMapper } from '../mappers/marketplace-return-claim.mapper';
const ACTIVE_STATUSES: MarketplaceReturnClaimStatus[] = [
MarketplaceReturnClaimStatuses.PENDING_CHAIRMAN_REVIEW,
MarketplaceReturnClaimStatuses.APPROVED_FOR_VISIT,
];
@Injectable()
export class MarketplaceReturnClaimRepositoryAdapter
implements MarketplaceReturnClaimDomainRepository
{
constructor(
@InjectRepository(MarketplaceReturnClaimEntity, 'marketplace')
private readonly repo: Repository<MarketplaceReturnClaimEntity>,
private readonly mapper: MarketplaceReturnClaimMapper
) {}
async create(
input: MarketplaceReturnClaimCreateInput
): Promise<MarketplaceReturnClaimDomainEntity> {
const row = this.repo.create({
id: input.id,
coopname: input.coopname,
request_hash: input.request_hash,
order_id: input.order_id,
order_hash: input.order_hash,
orderer_account: input.orderer_account,
delivery_braname: input.delivery_braname,
supplier_account: input.supplier_account,
status: input.status,
reason_text: input.reason_text,
defect_category: input.defect_category,
expected_resolution: input.expected_resolution,
actual_quantity: input.actual_quantity,
fact_cost: input.fact_cost,
photos: input.photos,
submretrn_tx_hash: input.submretrn_tx_hash,
decision_log: [],
on_site_inspection: null,
ledger_snapshot: null,
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async findById(id: string): Promise<MarketplaceReturnClaimDomainEntity | null> {
const row = await this.repo.findOne({ where: { id } });
return row ? this.mapper.toDomain(row) : null;
}
async findByRequestHash(
coopname: string,
request_hash: string
): Promise<MarketplaceReturnClaimDomainEntity | null> {
const row = await this.repo.findOne({ where: { coopname, request_hash } });
return row ? this.mapper.toDomain(row) : null;
}
async findActiveByOrderId(
coopname: string,
order_id: string
): Promise<MarketplaceReturnClaimDomainEntity | null> {
const row = await this.repo.findOne({
where: { coopname, order_id, status: In(ACTIVE_STATUSES) },
});
return row ? this.mapper.toDomain(row) : null;
}
async listByOrderer(
coopname: string,
orderer_account: string,
status?: MarketplaceReturnClaimStatus | MarketplaceReturnClaimStatus[]
): Promise<MarketplaceReturnClaimDomainEntity[]> {
const where: Record<string, unknown> = { coopname, orderer_account };
if (status) where.status = Array.isArray(status) ? In(status) : status;
const rows = await this.repo.find({ where, order: { created_at: 'DESC' } });
return rows.map((r) => this.mapper.toDomain(r));
}
async listByDeliveryBraname(
coopname: string,
delivery_braname: string,
status?: MarketplaceReturnClaimStatus | MarketplaceReturnClaimStatus[]
): Promise<MarketplaceReturnClaimDomainEntity[]> {
const where: Record<string, unknown> = { coopname, delivery_braname };
where.status = Array.isArray(status)
? In(status)
: status
? status
: In(ACTIVE_STATUSES);
const rows = await this.repo.find({ where, order: { created_at: 'DESC' } });
return rows.map((r) => this.mapper.toDomain(r));
}
async applyDecision(
id: string,
input: MarketplaceReturnClaimApplyDecisionInput
): Promise<MarketplaceReturnClaimDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
const nextLog = [...(row.decision_log ?? []), input.decision_entry];
const patch: Partial<MarketplaceReturnClaimEntity> = {
status: input.status,
decision_log: nextLog,
};
if (input.on_site_inspection !== undefined) patch.on_site_inspection = input.on_site_inspection;
if (input.ledger_snapshot !== undefined) patch.ledger_snapshot = input.ledger_snapshot;
await this.repo.update({ id }, patch);
const fresh = await this.repo.findOneOrFail({ where: { id } });
return this.mapper.toDomain(fresh);
}
}
@@ -0,0 +1,104 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type {
MarketplaceReturnClaimDecisionLogEntry,
MarketplaceReturnClaimDefectCategory,
MarketplaceReturnClaimExpectedResolution,
MarketplaceReturnClaimLedgerSnapshot,
MarketplaceReturnClaimOnSiteInspection,
MarketplaceReturnClaimPhoto,
MarketplaceReturnClaimStatus,
} from '../../domain/entities/marketplace-return-claim.types';
/**
* Эпик 7: TypeORM-сущность заявления на гарантийный возврат. Один Order
* имеет максимум одно активное заявление (PENDING_CHAIRMAN_REVIEW либо
* APPROVED_FOR_VISIT); финализированные заявления (ACCEPTED_AT_VISIT /
* REJECTED_REMOTELY / REJECTED_AT_VISIT) хранятся в архиве.
*
* Hot-path индексы:
* - `(coopname, request_hash)` unique — двусторонняя сверка с on-chain
* `marketplace::return_request.hash`;
* - `(coopname, delivery_braname, status)` — operator-стол на КУ выдачи;
* - `(coopname, orderer_account, status)` — orderer-стол «мои возвраты»;
* - `(coopname, order_id)` partial-unique для активного заявления
* (чтобы пайщик не создал второе на один Order одновременно).
*/
@Entity({ name: 'marketplace_return_claim' })
@Index('IDX_marketplace_return_claim_request_hash_unique', ['coopname', 'request_hash'], { unique: true })
@Index('IDX_marketplace_return_claim_order_active', ['coopname', 'order_id'], {
unique: true,
where: "status IN ('PENDING_CHAIRMAN_REVIEW', 'APPROVED_FOR_VISIT')",
})
@Index(['coopname', 'delivery_braname', 'status'])
@Index(['coopname', 'orderer_account', 'status'])
export class MarketplaceReturnClaimEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'varchar', length: 64 })
public request_hash!: string;
@Column({ type: 'uuid' })
public order_id!: string;
@Column({ type: 'varchar', length: 64 })
public order_hash!: string;
@Column({ type: 'varchar', length: 13 })
public orderer_account!: string;
@Column({ type: 'varchar', length: 13 })
public delivery_braname!: string;
@Column({ type: 'varchar', length: 13 })
public supplier_account!: string;
@Column({ type: 'varchar', length: 48 })
public status!: MarketplaceReturnClaimStatus;
@Column({ type: 'text' })
public reason_text!: string;
@Column({ type: 'varchar', length: 24, nullable: true })
public defect_category!: MarketplaceReturnClaimDefectCategory | null;
@Column({ type: 'varchar', length: 24 })
public expected_resolution!: MarketplaceReturnClaimExpectedResolution;
@Column({ type: 'integer' })
public actual_quantity!: number;
@Column({ type: 'numeric', precision: 24, scale: 4 })
public fact_cost!: string;
@Column({ type: 'jsonb' })
public photos!: MarketplaceReturnClaimPhoto[];
@Column({ type: 'varchar', length: 128 })
public submretrn_tx_hash!: string;
@Column({ type: 'jsonb', default: () => "'[]'::jsonb" })
public decision_log!: MarketplaceReturnClaimDecisionLogEntry[];
@Column({ type: 'jsonb', nullable: true })
public on_site_inspection!: MarketplaceReturnClaimOnSiteInspection | null;
@Column({ type: 'jsonb', nullable: true })
public ledger_snapshot!: MarketplaceReturnClaimLedgerSnapshot | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
public updated_at!: Date;
}
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceReturnClaimDomainEntity } from '../../domain/entities/marketplace-return-claim.entity';
import { MarketplaceReturnClaimEntity } from '../entities/marketplace-return-claim.entity';
@Injectable()
export class MarketplaceReturnClaimMapper {
toDomain(row: MarketplaceReturnClaimEntity): MarketplaceReturnClaimDomainEntity {
return new MarketplaceReturnClaimDomainEntity({
id: row.id,
coopname: row.coopname,
request_hash: row.request_hash,
order_id: row.order_id,
order_hash: row.order_hash,
orderer_account: row.orderer_account,
delivery_braname: row.delivery_braname,
supplier_account: row.supplier_account,
status: row.status,
reason_text: row.reason_text,
defect_category: row.defect_category,
expected_resolution: row.expected_resolution,
actual_quantity: row.actual_quantity,
fact_cost: row.fact_cost,
photos: row.photos,
submretrn_tx_hash: row.submretrn_tx_hash,
decision_log: row.decision_log,
on_site_inspection: row.on_site_inspection,
ledger_snapshot: row.ledger_snapshot,
created_at: row.created_at,
updated_at: row.updated_at,
});
}
}
@@ -27,6 +27,7 @@ import { MarketplaceInventoryEntity } from './entities/marketplace-inventory.ent
import { MarketplaceAplReceptionEntity } from './entities/marketplace-apl-reception.entity';
import { MarketplaceOutgoingPaymentRequestEntity } from './entities/marketplace-outgoing-payment-request.entity';
import { MarketplaceTtnDocumentEntity } from './entities/marketplace-ttn-document.entity';
import { MarketplaceReturnClaimEntity } from './entities/marketplace-return-claim.entity';
// Repository adapters
import { CategoryRepositoryAdapter } from './adapters/category-repository.adapter';
@@ -52,6 +53,7 @@ import { MarketplaceInventoryRepositoryAdapter } from './adapters/marketplace-in
import { MarketplaceAplReceptionRepositoryAdapter } from './adapters/marketplace-apl-reception-repository.adapter';
import { MarketplaceOutgoingPaymentRequestRepositoryAdapter } from './adapters/marketplace-outgoing-payment-request-repository.adapter';
import { MarketplaceTtnDocumentRepositoryAdapter } from './adapters/marketplace-ttn-document-repository.adapter';
import { MarketplaceReturnClaimRepositoryAdapter } from './adapters/marketplace-return-claim-repository.adapter';
// Mappers
import { MarketplaceVitrineMapper } from './mappers/marketplace-vitrine.mapper';
@@ -68,6 +70,7 @@ import { MarketplaceInventoryMapper } from './mappers/marketplace-inventory.mapp
import { MarketplaceAplReceptionMapper } from './mappers/marketplace-apl-reception.mapper';
import { MarketplaceOutgoingPaymentRequestMapper } from './mappers/marketplace-outgoing-payment-request.mapper';
import { MarketplaceTtnDocumentMapper } from './mappers/marketplace-ttn-document.mapper';
import { MarketplaceReturnClaimMapper } from './mappers/marketplace-return-claim.mapper';
// Repository tokens
import { CATEGORY_DOMAIN_REPOSITORY } from '../domain/repositories/category-domain.repository';
@@ -93,6 +96,7 @@ import { MARKETPLACE_INVENTORY_REPOSITORY } from '../domain/repositories/marketp
import { MARKETPLACE_APL_RECEPTION_REPOSITORY } from '../domain/repositories/marketplace-apl-reception.repository';
import { MARKETPLACE_OUTGOING_PAYMENT_REQUEST_REPOSITORY } from '../domain/repositories/marketplace-outgoing-payment-request.repository';
import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/marketplace-ttn-document.repository';
import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/marketplace-return-claim.repository';
@Module({
imports: [
@@ -130,6 +134,7 @@ import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/mark
MarketplaceAplReceptionEntity,
MarketplaceOutgoingPaymentRequestEntity,
MarketplaceTtnDocumentEntity,
MarketplaceReturnClaimEntity,
],
synchronize: true,
logging: false,
@@ -161,6 +166,7 @@ import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/mark
MarketplaceAplReceptionEntity,
MarketplaceOutgoingPaymentRequestEntity,
MarketplaceTtnDocumentEntity,
MarketplaceReturnClaimEntity,
],
'marketplace'
), // Указываем имя подключения
@@ -282,6 +288,12 @@ import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/mark
provide: MARKETPLACE_TTN_DOCUMENT_REPOSITORY,
useClass: MarketplaceTtnDocumentRepositoryAdapter,
},
// Эпик 7 — заявления на гарантийный возврат имущества
MarketplaceReturnClaimMapper,
{
provide: MARKETPLACE_RETURN_CLAIM_REPOSITORY,
useClass: MarketplaceReturnClaimRepositoryAdapter,
},
],
exports: [
CATEGORY_DOMAIN_REPOSITORY,
@@ -313,6 +325,8 @@ import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/mark
MARKETPLACE_OUTGOING_PAYMENT_REQUEST_REPOSITORY,
// Story 5.4 — локальный реестр ТТН
MARKETPLACE_TTN_DOCUMENT_REPOSITORY,
// Эпик 7 — гарантийный возврат
MARKETPLACE_RETURN_CLAIM_REPOSITORY,
],
})
export class MarketplaceInfrastructureModule {}
+126
View File
@@ -633,6 +633,10 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceAcceptIndividualOrderInput:{
},
MarketplaceAcceptReturnAtVisitInput:{
inspection_photos:"MarketplaceReturnClaimPhotoUploadInput",
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceAddToWhitelistInput:{
@@ -654,6 +658,9 @@ export const AllTypesProps: Record<string,any> = {
MarketplaceAplReceptionVariant: "enum" as const,
MarketplaceApproveOfferInput:{
},
MarketplaceApproveReturnVisitInput:{
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceAttributeType: "enum" as const,
MarketplaceBarcodeFormat: "enum" as const,
@@ -670,6 +677,11 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceCreateOrderInput:{
},
MarketplaceCreateReturnClaimInput:{
defect_category:"MarketplaceReturnClaimDefectCategory",
photos:"MarketplaceReturnClaimPhotoUploadInput",
signed_statement:"MarketplaceReturnStatementSignedInput"
},
MarketplaceCreateShipmentInput:{
groups:"MarketplaceShipmentGroupInput"
@@ -733,6 +745,9 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceListPendingOffersInput:{
},
MarketplaceListReturnClaimsByBranameInput:{
},
MarketplaceListShipmentsInput:{
statuses:"MarketplaceShipmentStatus"
@@ -746,9 +761,32 @@ export const AllTypesProps: Record<string,any> = {
MarketplaceOutgoingPaymentRequestStatus: "enum" as const,
MarketplaceRejectOfferInput:{
},
MarketplaceRejectReturnAtVisitInput:{
inspection_photos:"MarketplaceReturnClaimPhotoUploadInput",
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceRejectReturnRemoteInput:{
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceRemoveFromWhitelistInput:{
},
MarketplaceReturnClaimDefectCategory: "enum" as const,
MarketplaceReturnClaimExpectedResolution: "enum" as const,
MarketplaceReturnClaimPhotoUploadInput:{
},
MarketplaceReturnClaimSignablePayloadInput:{
defect_category:"MarketplaceReturnClaimDefectCategory"
},
MarketplaceReturnClaimStatus: "enum" as const,
MarketplaceReturnStatementSignedInput:{
meta:"MarketplaceReturnStatementSignedMetaDocumentInput",
signatures:"SignatureInfoInput"
},
MarketplaceReturnStatementSignedMetaDocumentInput:{
},
MarketplaceSetKUStatusInput:{
@@ -1236,6 +1274,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceAcceptIndividualOrder:{
input:"MarketplaceAcceptIndividualOrderInput"
},
marketplaceAcceptReturnAtVisit:{
data:"MarketplaceAcceptReturnAtVisitInput"
},
marketplaceAddAvailableCategories:{
input:"AddAvailableCategoriesInput"
},
@@ -1248,6 +1289,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceApproveOffer:{
input:"MarketplaceApproveOfferInput"
},
marketplaceApproveReturnVisit:{
data:"MarketplaceApproveReturnVisitInput"
},
marketplaceCancelOrder:{
input:"MarketplaceCancelOrderInput"
},
@@ -1263,6 +1307,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceCreateRequest:{
data:"CreateRequestInput"
},
marketplaceCreateReturnClaim:{
data:"MarketplaceCreateReturnClaimInput"
},
marketplaceCreateShipment:{
data:"MarketplaceCreateShipmentInput"
},
@@ -1290,6 +1337,12 @@ export const AllTypesProps: Record<string,any> = {
marketplaceRejectOffer:{
input:"MarketplaceRejectOfferInput"
},
marketplaceRejectReturnAtVisit:{
data:"MarketplaceRejectReturnAtVisitInput"
},
marketplaceRejectReturnRemote:{
data:"MarketplaceRejectReturnRemoteInput"
},
marketplaceRemoveAvailableCategories:{
input:"RemoveAvailableCategoriesInput"
},
@@ -1888,6 +1941,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceListPendingOffers:{
input:"MarketplaceListPendingOffersInput"
},
marketplaceListReturnClaimsByBraname:{
data:"MarketplaceListReturnClaimsByBranameInput"
},
marketplaceListShipments:{
data:"MarketplaceListShipmentsInput"
},
@@ -1898,6 +1954,12 @@ export const AllTypesProps: Record<string,any> = {
marketplaceRequiredAttributes:{
data:"GetRequiredAttributesInput"
},
marketplaceReturnClaim:{
},
marketplaceReturnClaimSignablePayload:{
data:"MarketplaceReturnClaimSignablePayloadInput"
},
marketplaceSearchAttributes:{
input:"SearchAttributesInput"
},
@@ -4167,6 +4229,61 @@ export const ReturnTypes: Record<string,any> = {
requestsByCategory:"MarketplaceCategoryRequestCount",
totalRequests:"Int"
},
MarketplaceReturnClaim:{
actual_quantity:"Int",
coopname:"String",
created_at:"DateTime",
decision_log:"MarketplaceReturnClaimDecisionEntry",
defect_category:"MarketplaceReturnClaimDefectCategory",
delivery_braname:"String",
expected_resolution:"MarketplaceReturnClaimExpectedResolution",
fact_cost:"String",
id:"String",
ledger_snapshot:"MarketplaceReturnClaimLedgerSnapshot",
on_site_inspection:"MarketplaceReturnClaimOnSiteInspection",
order_hash:"String",
order_id:"String",
orderer_account:"String",
photos:"MarketplaceReturnClaimPhoto",
reason_text:"String",
request_hash:"String",
status:"MarketplaceReturnClaimStatus",
submretrn_tx_hash:"String",
supplier_account:"String",
updated_at:"DateTime"
},
MarketplaceReturnClaimDecisionEntry:{
at:"DateTime",
braname:"String",
by_chairman_account:"String",
comment:"String",
decision:"String",
stage:"String",
tx_hash:"String"
},
MarketplaceReturnClaimLedgerSnapshot:{
amount:"String",
at:"DateTime",
returned_quantity:"Int",
tx_hash:"String"
},
MarketplaceReturnClaimOnSiteInspection:{
at:"DateTime",
by_chairman_account:"String",
photos:"MarketplaceReturnClaimPhoto",
result_text:"String",
scanned_barcode:"String"
},
MarketplaceReturnClaimPhoto:{
content_hash:"String",
mime_type:"String",
uploaded_at:"DateTime",
url:"String"
},
MarketplaceReturnClaimResult:{
claim:"MarketplaceReturnClaim",
tx_hash:"String"
},
MarketplaceShipment:{
braname:"String",
coopname:"String",
@@ -4468,16 +4585,19 @@ export const ReturnTypes: Record<string,any> = {
marketplaceAcceptConsolidatedRequest:"MarketplaceConsolidatedRequestActionResult",
marketplaceAcceptCpp:"MarketplaceCppStatus",
marketplaceAcceptIndividualOrder:"MarketplaceSupplierOrderActionResult",
marketplaceAcceptReturnAtVisit:"MarketplaceReturnClaimResult",
marketplaceAddAvailableCategories:"MarketplaceAvailableCategory",
marketplaceAddAvailableCategoryTypes:"MarketplaceAvailableCategory",
marketplaceAddToWhitelist:"MarketplaceWhitelistEntry",
marketplaceApproveOffer:"MarketplaceOffer",
marketplaceApproveReturnVisit:"MarketplaceReturnClaimResult",
marketplaceCancelOrder:"MarketplaceCancelOrderResult",
marketplaceClearAvailableCategories:"Boolean",
marketplaceCreateAplReception:"MarketplaceAplReceptionResult",
marketplaceCreateOffer:"MarketplaceOffer",
marketplaceCreateOrder:"MarketplaceCreateOrderResult",
marketplaceCreateRequest:"MarketplaceRequest",
marketplaceCreateReturnClaim:"MarketplaceReturnClaimResult",
marketplaceCreateShipment:"MarketplaceCreateShipmentResult",
marketplaceDeclineConsolidatedRequest:"MarketplaceConsolidatedRequestActionResult",
marketplaceDeclineIndividualOrder:"MarketplaceSupplierOrderActionResult",
@@ -4487,6 +4607,8 @@ export const ReturnTypes: Record<string,any> = {
marketplaceLabelInventory:"MarketplaceLabelInventoryResult",
marketplaceOpenIssuance:"MarketplaceIssuanceResult",
marketplaceRejectOffer:"MarketplaceOffer",
marketplaceRejectReturnAtVisit:"MarketplaceReturnClaimResult",
marketplaceRejectReturnRemote:"MarketplaceReturnClaimResult",
marketplaceRemoveAvailableCategories:"Boolean",
marketplaceRemoveAvailableCategoryTypes:"Boolean",
marketplaceRemoveFromWhitelist:"Boolean",
@@ -5117,8 +5239,10 @@ export const ReturnTypes: Record<string,any> = {
marketplaceListMyOffers:"MarketplaceOfferPaginationResult",
marketplaceListMyOrders:"MarketplaceOrderPaginationResult",
marketplaceListMyReadyToReceive:"MarketplaceOrder",
marketplaceListMyReturnClaims:"MarketplaceReturnClaim",
marketplaceListOutgoingPaymentsAsSupplier:"MarketplaceOutgoingPaymentRequest",
marketplaceListPendingOffers:"MarketplaceOfferPaginationResult",
marketplaceListReturnClaimsByBraname:"MarketplaceReturnClaim",
marketplaceListShipments:"MarketplaceShipment",
marketplaceListSupplierOrders:"MarketplaceOrderPaginationResult",
marketplaceListWhitelist:"MarketplaceWhitelistEntry",
@@ -5126,6 +5250,8 @@ export const ReturnTypes: Record<string,any> = {
marketplaceOnboardingState:"MarketplaceOnboardingState",
marketplaceRegistrationOfferStatus:"MarketplaceRegistrationOfferStatus",
marketplaceRequiredAttributes:"MarketplaceAttribute",
marketplaceReturnClaim:"MarketplaceReturnClaim",
marketplaceReturnClaimSignablePayload:"GeneratedDocument",
marketplaceSearchAttributes:"MarketplaceAttribute",
marketplaceSearchDictionaryValues:"MarketplaceDictionaryValue",
marketplaceSearchRequests:"MarketplaceRequest",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
import type { IDocDataRef, IGenerate, IMetaDocument } from '../../document'
import type { ICommonProgram, ICommonRequest, ICommonUser, ICooperativeData, IVars } from '../../model'
import type { IOrganizationData } from '../../users'
export const registry_id = 1104
/**
* Заявление пайщика о гарантийном возврате имущества — Эпик 7 ЦПП
* «Стол заказов» (членские взносы).
*
* Подписывается пайщиком-заказчиком и инициирует процесс возврата
* полученного по АПП (1102.MarketplaceAplReception) имущества обратно
* на кооператив в пределах гарантийного срока, заданного поставщиком.
*
* При окончательном принятии заявления на очном осмотре C++ контракт
* `p.mkt.return` выполняет атомарную compensating-forward-пару
* `o.mkt.return + o.mkt.return2` через транзит 91 — на программный
* кошелёк пайщика `w.mkt.member.available` возвращается стоимость, а
* имущество — на склад кооператива.
*
* Не путать с registry_id=800 `ReturnByAssetStatement` — тот документ
* принадлежит старой системе клиринга и сохранён отдельно как есть.
*/
export interface Action extends IGenerate, IDocDataRef {
registry_id: number
/** id заказа пайщика, по которому подаётся заявление о возврате. */
order_id: string
/** Канонический order_hash on-chain (якорь к o.mkt.order). */
order_hash: string
/** Имя кооперативного участка доставки (braname) исходного заказа. */
braname?: string
/** Краткое описание причины возврата (1-2000 симв.). */
reason_text: string
/**
* Необязательная категория дефекта (механическое повреждение,
* пересортица, несоответствие сроку годности и т.п.).
*/
defect_category?: string
/** Фактически возвращаемое количество единиц. */
actual_quantity: number
/** Стоимость возвращаемой части (4 знака после запятой). */
fact_cost: string
}
export type Meta = IMetaDocument & Action
/**
* Модель данных для PDF-рендера. Структурно повторяет 800.ReturnByAsset
* Statement (юр.выверенный текст), но дополнительно несёт ссылку на
* программу ЦПП «Стол заказов» и факт-параметры возврата (количество и
* сумма), а также кооперативный участок доставки.
*/
export interface Model {
meta: IMetaDocument
coop: ICooperativeData
vars: IVars
user: ICommonUser
request: ICommonRequest
program: ICommonProgram
/** Стоимость возвращаемой части. */
fact_cost: string
/** Фактически возвращаемое количество. */
actual_quantity: string
/** Краткое описание причины возврата. */
reason_text: string
defect_category?: string
branch?: IOrganizationData
}
export const title = 'Заявление о гарантийном возврате имущества'
export const description = 'Форма заявления пайщика о гарантийном возврате имущества по ЦПП «Стол заказов»'
export const context = '<style>\nh1 {\n margin: 0px;\n text-align: center;\n}\nh3 {\n margin: 0px;\n padding-top: 15px;\n}\n.about {\n padding: 20px;\n}\n.about p {\n margin: 0px;\n}\n.digital-document {\n padding: 20px;\n white-space: pre-wrap;\n}\n.subheader {\n padding-bottom: 20px;\n}\ntable {\n width: 100%;\n border-collapse: collapse;\n}\nth, td {\n border: 1px solid #ccc;\n padding: 8px;\n text-align: left;\n word-wrap: break-word;\n overflow-wrap: break-word;\n}\nth {\n background-color: #f4f4f4;\n width: 30%;\n}\n</style>\n\n<div class="digital-document">\n <div style="text-align: right; margin:">\n <p style="margin: 0px !important">{% trans \'v_soviet\' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p>\n <p style="margin: 0px !important">{% trans \'from\' %} {{ user.full_name_or_short_name }}</p>\n </div>\n\n <div style="text-align: center">\n <h1 class="header">{% trans \'statement\' %}</h1>\n <p class="subheader">{% trans \'statement_subheader\', program.name %}</p>\n </div>\n\n {% if coop.is_branched %}\n <p>{% trans \'branched_return\', branch.short_name, vars.full_abbr_genitive, vars.name, program.name %}</p>\n {% else %}\n <p>{% trans \'unbranched_return\', vars.full_abbr_genitive, vars.name, program.name %}</p>\n {% endif %}\n\n <table>\n <tbody>\n <tr>\n <th>№</th>\n <td>1</td>\n </tr>\n <tr>\n <th>{% trans \'article\' %}</th>\n <td>{{ request.hash }}</td>\n </tr>\n <tr>\n <th>{% trans \'asset_title\' %}</th>\n <td>{{ request.title }}</td>\n </tr>\n <tr>\n <th>{% trans \'form_of_asset\' %}</th>\n <td>{% trans \'form_of_asset_type\' %}</td>\n </tr>\n <tr>\n <th>{% trans \'unit_of_measurement\' %}</th>\n <td>{{ request.unit_of_measurement }}</td>\n </tr>\n <tr>\n <th>{% trans \'units_returned\' %}</th>\n <td>{{ actual_quantity }}</td>\n </tr>\n <tr>\n <th>{% trans \'unit_cost\', request.currency %}</th>\n <td>{{ request.unit_cost }}</td>\n </tr>\n <tr>\n <th>{% trans \'fact_cost\', request.currency %}</th>\n <td>{{ fact_cost }}</td>\n </tr>\n </tbody>\n </table>\n\n <p>{% trans \'reason_label\' %}</p>\n <p>{{ reason_text }}</p>\n {% if defect_category %}\n <p>{% trans \'defect_category_label\' %}: {{ defect_category }}</p>\n {% endif %}\n\n <p>{% trans \'signature\' %}</p>\n <p>{{ user.full_name_or_short_name }}</p>\n <p>{{ meta.created_at }}</p>\n</div>\n'
export const translations = {
ru: {
from: 'от',
v_soviet: 'В Совет',
statement: 'ЗАЯВЛЕНИЕ',
statement_subheader: 'о гарантийном возврате имущества по Целевой Потребительской Программе «{0}»',
unbranched_return: 'Прошу принять заявление о гарантийном возврате имущества, полученного мной от {0} "{1}" в рамках Целевой Потребительской Программы "{2}", в пределах гарантийного срока, установленного поставщиком, в следующем составе:',
branched_return: 'Прошу принять заявление о гарантийном возврате имущества, полученного мной через кооперативный участок "{0}" {1} "{2}" в рамках Целевой Потребительской Программы "{3}", в пределах гарантийного срока, установленного поставщиком, в следующем составе:',
signature: 'Подписано электронной подписью.',
article: 'Артикул',
asset_title: 'Наименование/Реквизиты',
form_of_asset: 'Форма имущества',
form_of_asset_type: 'Материальная',
unit_of_measurement: 'Единицы измерения',
units_returned: 'Количество к возврату',
unit_cost: 'Стоимость Единицы, {0}',
fact_cost: 'Сумма возврата, {0}',
reason_label: 'Причина обращения:',
defect_category_label: 'Категория дефекта',
},
}
export const exampleData = {
meta: {
created_at: '18.05.2026 11:30',
},
coop: {
short_name: 'ПК ВОСХОД',
city: 'Москва',
is_branched: true,
},
vars: {
name: 'ВОСХОД',
full_abbr_genitive: 'Потребительского Кооператива',
full_abbr: 'Потребительский Кооператив',
},
user: {
full_name_or_short_name: 'Иванов Иван Иванович',
},
request: {
hash: '0000abcd...',
title: 'Сахар-песок «Сладкий», 1 кг',
unit_of_measurement: 'шт.',
units: '10',
unit_cost: '85',
total_cost: '850',
currency: 'RUB',
},
program: {
name: 'СТОЛ ЗАКАЗОВ',
},
branch: {
short_name: 'КУ-МОСКВА-1',
},
fact_cost: '425.0000',
actual_quantity: '5',
reason_text: 'При вскрытии упаковки обнаружена пересортица: вместо сахарного песка молоко с истёкшим сроком годности.',
defect_category: 'Пересортица',
}
@@ -72,6 +72,7 @@ export * as MarketplaceOfferTemplate from './1100.MarketplaceOfferTemplate'
export * as MarketplaceOffer from './1101.MarketplaceOffer'
export * as MarketplaceAplReception from './1102.MarketplaceAplReception'
export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
export * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
export * as SosediAgreement from './699.SosediAgreement'
@@ -6,6 +6,8 @@ import { PvzListPage } from 'src/pages/Marketplace/PvzList'
import { DesignSystemPage } from 'src/pages/Marketplace/DesignSystem'
import { OperatorIssuancePage } from 'src/pages/Marketplace/OperatorIssuance'
import { OrdererReadyToReceivePage } from 'src/pages/Marketplace/OrdererReadyToReceive'
import { OrdererReturnClaimsPage } from 'src/pages/Marketplace/OrdererReturnClaims'
import { OperatorReturnClaimsPage } from 'src/pages/Marketplace/OperatorReturnClaims'
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace'
import { agreementsBase } from 'src/shared/lib/consts/workspaces'
@@ -77,6 +79,23 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 7 / Story 7.1: orderer-стол гарантийных возвратов. Здесь
// пайщик подаёт заявление по выданному заказу в гарантийный срок,
// прилагает фото товара и подписывает заявление (registry_id=1104).
// Доступ: только сам заказчик (`ReturnClaim: ['create:own', 'read:own']`
// в marketplace access-matrix), фильтрация по orderer_account.
path: 'returns',
name: 'marketplace-returns',
component: markRaw(OrdererReturnClaimsPage),
meta: {
title: 'Гарантийные возвраты',
icon: 'fa-solid fa-rotate-left',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Витрина дизайн-системы (Эпик 10): открыта председателю и членам совета
// для утверждения 13 custom-компонентов до их применения в эпиках 1-9.
@@ -141,6 +160,25 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 7 / Story 7.2-7.4: operator-стол гарантийных возвратов.
// Председатель КУ рассматривает поступившие заявления удалённо,
// приглашает на очный осмотр, принимает или отказывает.
// При приёме composite-транзакция accretrn выполняет
// compensating forward `o.mkt.return + o.mkt.return2` через
// транзит 91 — атомарное восстановление средств заказчику и
// возврат имущества на склад участка.
path: 'returns',
name: 'marketplace-pvz-returns',
component: markRaw(OperatorReturnClaimsPage),
meta: {
title: 'Гарантийные возвраты',
icon: 'fa-solid fa-clipboard-check',
roles: ['chairman'],
requiresAuth: true,
agreements: agreementsBase,
},
},
],
},
],
@@ -0,0 +1,73 @@
import { Mutations, Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
export type MarketplaceReturnClaimView =
Queries.Marketplace.ListReturnClaimsByBraname.IOutput['marketplaceListReturnClaimsByBraname'][number];
export type MarketplaceReturnClaimResultView =
Mutations.Marketplace.ApproveReturnVisit.IOutput['marketplaceApproveReturnVisit'];
export type IListReturnClaimsByBranameInput =
Queries.Marketplace.ListReturnClaimsByBraname.IInput['data'];
export type IApproveReturnVisitInput =
Mutations.Marketplace.ApproveReturnVisit.IInput['data'];
export type IRejectReturnRemoteInput =
Mutations.Marketplace.RejectReturnRemote.IInput['data'];
export type IAcceptReturnAtVisitInput =
Mutations.Marketplace.AcceptReturnAtVisit.IInput['data'];
export type IRejectReturnAtVisitInput =
Mutations.Marketplace.RejectReturnAtVisit.IInput['data'];
export async function listReturnClaimsByBraname(
data: IListReturnClaimsByBranameInput,
): Promise<MarketplaceReturnClaimView[]> {
const { [Queries.Marketplace.ListReturnClaimsByBraname.name]: result } = await client.Query(
Queries.Marketplace.ListReturnClaimsByBraname.query,
{ variables: { data } },
);
return result;
}
export async function approveReturnVisit(
data: IApproveReturnVisitInput,
): Promise<MarketplaceReturnClaimResultView> {
const { [Mutations.Marketplace.ApproveReturnVisit.name]: result } = await client.Mutation(
Mutations.Marketplace.ApproveReturnVisit.mutation,
{ variables: { data } },
);
return result;
}
export async function rejectReturnRemote(
data: IRejectReturnRemoteInput,
): Promise<MarketplaceReturnClaimResultView> {
const { [Mutations.Marketplace.RejectReturnRemote.name]: result } = await client.Mutation(
Mutations.Marketplace.RejectReturnRemote.mutation,
{ variables: { data } },
);
return result;
}
export async function acceptReturnAtVisit(
data: IAcceptReturnAtVisitInput,
): Promise<MarketplaceReturnClaimResultView> {
const { [Mutations.Marketplace.AcceptReturnAtVisit.name]: result } = await client.Mutation(
Mutations.Marketplace.AcceptReturnAtVisit.mutation,
{ variables: { data } },
);
return result;
}
export async function rejectReturnAtVisit(
data: IRejectReturnAtVisitInput,
): Promise<MarketplaceReturnClaimResultView> {
const { [Mutations.Marketplace.RejectReturnAtVisit.name]: result } = await client.Mutation(
Mutations.Marketplace.RejectReturnAtVisit.mutation,
{ variables: { data } },
);
return result;
}
@@ -0,0 +1,2 @@
export { OperatorReturnClaimsPage } from './ui';
export * from './api';
@@ -0,0 +1,256 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import { BarcodeScanner } from 'src/widgets/Marketplace/BarcodeScanner';
import {
acceptReturnAtVisit,
rejectReturnAtVisit,
type IAcceptReturnAtVisitInput,
type MarketplaceReturnClaimView,
} from '../api';
type ReturnClaimPhotoUploadInput = NonNullable<IAcceptReturnAtVisitInput['inspection_photos']>[number];
/**
* Story 7.3 / 7.4: full-screen takeover для очного осмотра. Председатель:
* 1. Сканирует штрих-код имущества (BarcodeScanner) для сверки с заказом.
* 2. Записывает результат осмотра (`inspection_result`, до 2000 симв.).
* 3. Опционально прилагает фото осмотра (до 10 файлов, до 10 МБ каждое).
* 4. Выбирает действие: «Принять возврат» → accretrn (compensating
* forward `o.mkt.return + o.mkt.return2` атомарно через транзит 91);
* «Отказать на месте» → rejretrn.
*
* При приёме backend атомарно восстанавливает `w.mkt.member.available`
* пайщика на `fact_cost` и возвращает имущество на склад участка
* (журнал содержит обе ledger2-операции с трассировкой на claim_id).
*/
const DECISION_ACCEPT = 'accept' as const;
const DECISION_REJECT = 'reject' as const;
type Decision = typeof DECISION_ACCEPT | typeof DECISION_REJECT;
const props = defineProps<{
modelValue: boolean;
claim: MarketplaceReturnClaimView | null;
braname: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'decided'): void;
}>();
const decision = ref<Decision>(DECISION_ACCEPT);
const scannedCode = ref<string>('');
const inspectionResult = ref<string>('');
const photos = ref<ReturnClaimPhotoUploadInput[]>([]);
const submitting = ref(false);
watch(
() => [props.modelValue, props.claim?.id],
([visible]) => {
if (visible) {
decision.value = DECISION_ACCEPT;
scannedCode.value = '';
inspectionResult.value = '';
photos.value = [];
}
},
{ immediate: false },
);
function onScanned(code: string): void {
scannedCode.value = code;
}
async function onFilesPicked(files: readonly File[] | File[]): Promise<void> {
const list = Array.from(files);
if (photos.value.length + list.length > 10) {
FailAlert(new Error('Не более 10 фотографий очного осмотра.'));
return;
}
for (const file of list) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
FailAlert(new Error(`Файл «${file.name}»: поддерживаются JPEG, PNG, WEBP.`));
continue;
}
if (file.size > 10 * 1024 * 1024) {
FailAlert(new Error(`Файл «${file.name}»: размер превышает 10 МБ.`));
continue;
}
const base64 = await fileToBase64(file);
photos.value.push({ base64, mime_type: file.type });
}
}
function removePhoto(index: number): void {
photos.value.splice(index, 1);
}
async function confirm(): Promise<void> {
if (!props.claim) return;
if (!inspectionResult.value.trim()) {
FailAlert(new Error('Опишите результат очного осмотра.'));
return;
}
if (!props.braname.trim()) {
FailAlert(new Error('Не выбран кооперативный участок.'));
return;
}
if (decision.value === DECISION_ACCEPT && !scannedCode.value.trim()) {
FailAlert(new Error('Для приёма возврата сначала отсканируйте штрих-код имущества.'));
return;
}
submitting.value = true;
try {
if (decision.value === DECISION_ACCEPT) {
await acceptReturnAtVisit({
claim_id: props.claim.id,
braname: props.braname.trim(),
inspection_result: inspectionResult.value.trim(),
scanned_barcode: scannedCode.value.trim() || undefined,
inspection_photos: photos.value.length > 0 ? photos.value : undefined,
});
SuccessAlert(
`Возврат принят. На программный кошелёк заказчика восстановлено ${props.claim.fact_cost} ₽.`,
);
} else {
await rejectReturnAtVisit({
claim_id: props.claim.id,
braname: props.braname.trim(),
inspection_result: inspectionResult.value.trim(),
inspection_photos: photos.value.length > 0 ? photos.value : undefined,
});
SuccessAlert('Возврат отклонён на месте. Имущество остаётся у заказчика.');
}
emit('decided');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось зафиксировать решение в блокчейне');
} finally {
submitting.value = false;
}
}
function cancel(): void {
emit('update:modelValue', false);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const value = reader.result;
if (typeof value !== 'string') {
reject(new Error('Не удалось прочитать файл'));
return;
}
const commaAt = value.indexOf(',');
resolve(commaAt === -1 ? value : value.slice(commaAt + 1));
};
reader.onerror = () => reject(reader.error ?? new Error('Ошибка чтения файла'));
reader.readAsDataURL(file);
});
}
const kind = computed<'success' | 'danger'>(() =>
decision.value === DECISION_ACCEPT ? 'success' : 'danger',
);
const confirmLabel = computed(() =>
decision.value === DECISION_ACCEPT
? 'Принять возврат и восстановить средства'
: 'Отказать на месте',
);
const confirmDisabled = computed(() => {
if (submitting.value) return true;
if (!inspectionResult.value.trim()) return true;
if (decision.value === DECISION_ACCEPT && !scannedCode.value.trim()) return true;
return false;
});
</script>
<template lang="pug">
TakeoverDialog(
:model-value="modelValue"
:title="claim ? `Очный осмотр по заявлению ${claim.id.slice(0, 8)}` : 'Очный осмотр'"
:lead-text="claim ? `Заказ ${claim.order_id.slice(0, 8)} · заказчик ${claim.orderer_account} · возврат на ${claim.fact_cost} ₽` : ''"
:kind="kind"
:confirm-label="confirmLabel"
cancel-label="Закрыть"
:loading="submitting"
:disable-confirm="confirmDisabled"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@confirm="confirm"
@cancel="cancel"
)
template(#default v-if="claim")
.mp-return-onsite
q-card(flat bordered).q-mb-md
q-card-section
.text-subtitle1 1. Сверка штрих-кода имущества
BarcodeScanner.q-mt-sm(start-label="Сканировать штрих-код имущества" @scanned="onScanned")
.text-caption.text-grey.q-mt-sm
| Mock-режим сканера для каркасной фазы следующий этап заменит на реальную камеру / USB-сканер по UX-DR26.
.text-caption.q-mt-sm(v-if="scannedCode")
| Считано: <strong>{{ scannedCode }}</strong>
q-card(flat bordered).q-mb-md
q-card-section
.text-subtitle1 2. Результат осмотра
q-input.q-mt-sm(
v-model="inspectionResult"
outlined
type="textarea"
autogrow
counter
maxlength="2000"
label="Что обнаружено при очном осмотре"
)
q-card(flat bordered).q-mb-md
q-card-section
.text-subtitle1 3. Фото очного осмотра (опционально)
q-file.q-mt-sm(
label="Выбрать фотографии"
outlined
dense
multiple
accept="image/jpeg,image/png,image/webp"
:model-value="null"
@update:model-value="onFilesPicked"
)
.row.q-gutter-sm.q-mt-md(v-if="photos.length > 0")
q-chip(
v-for="(p, i) in photos" :key="i"
:label="`Фото ${i + 1} · ${p.mime_type.replace('image/', '').toUpperCase()}`"
removable
color="accent"
text-color="white"
@remove="removePhoto(i)"
)
q-card(flat bordered).q-pa-md
.text-subtitle1.q-mb-sm 4. Решение
q-option-group(
v-model="decision"
:options="[
{ label: 'Принять возврат (compensating forward выполнится атомарно)', value: DECISION_ACCEPT, color: 'positive' },
{ label: 'Отказать на месте (имущество остаётся у заказчика)', value: DECISION_REJECT, color: 'negative' },
]"
type="radio"
)
q-banner.q-mt-md(v-if="decision === DECISION_ACCEPT" rounded class="bg-positive text-white")
| Восстановим {{ claim.fact_cost }} на программный кошелёк заказчика. Имущество вернётся на склад участка.
q-banner.q-mt-md(v-else rounded class="bg-warning text-dark")
| Имущество остаётся у заказчика. Движений по средствам нет.
</template>
<style scoped lang="scss">
.mp-return-onsite {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
}
</style>
@@ -0,0 +1,190 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import {
listReturnClaimsByBraname,
type MarketplaceReturnClaimView,
} from '../api';
import RemoteDecisionDialog from './RemoteDecisionDialog.vue';
import OnSiteDecisionDialog from './OnSiteDecisionDialog.vue';
/**
* Story 7.2-7.4 — operator-стол председателя КУ: лента заявлений на
* гарантийный возврат, привязанных к delivery_braname исходного заказа.
*
* - PENDING_CHAIRMAN_REVIEW → RemoteDecisionDialog (одобрить очный визит /
* отказать удалённо).
* - APPROVED_FOR_VISIT → OnSiteDecisionDialog (BarcodeScanner +
* inspection_result + accept / reject; accept атомарно выполняет
* compensating forward `o.mkt.return + o.mkt.return2`).
*/
const braname = ref<string>('');
const items = ref<MarketplaceReturnClaimView[]>([]);
const loading = ref(false);
const remoteDialog = ref(false);
const onSiteDialog = ref(false);
const selectedClaim = ref<MarketplaceReturnClaimView | null>(null);
const pendingClaims = computed(() =>
items.value.filter((c) => c.status === 'PENDING_CHAIRMAN_REVIEW'),
);
const approvedClaims = computed(() =>
items.value.filter((c) => c.status === 'APPROVED_FOR_VISIT'),
);
const archiveClaims = computed(() =>
items.value.filter(
(c) =>
c.status === 'ACCEPTED_AT_VISIT' ||
c.status === 'REJECTED_REMOTELY' ||
c.status === 'REJECTED_AT_VISIT',
),
);
async function load(): Promise<void> {
if (!braname.value.trim()) return;
loading.value = true;
try {
items.value = await listReturnClaimsByBraname({ delivery_braname: braname.value.trim() });
} catch (e) {
FailAlert(e, 'Не удалось загрузить заявления на возврат');
} finally {
loading.value = false;
}
}
function startRemote(claim: MarketplaceReturnClaimView): void {
selectedClaim.value = claim;
remoteDialog.value = true;
}
function startOnSite(claim: MarketplaceReturnClaimView): void {
selectedClaim.value = claim;
onSiteDialog.value = true;
}
function onDecided(): void {
void load();
}
/**
* Безопасное форматирование даты, приходящей из Zeus как `unknown`
* (GraphQL DateTime скаляр не зарегистрирован в Zeus-резолвере). Принимаем
* `unknown`, конвертируем в строку через `String()` и парсим — формат
* сервера ISO 8601, поэтому `new Date(<iso>)` валиден.
*/
function formatDateTime(value: unknown): string {
if (value === null || value === undefined) return '—';
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
function humanStatus(status: MarketplaceReturnClaimView['status']): string {
switch (status) {
case 'PENDING_CHAIRMAN_REVIEW':
return 'Ждёт удалённого рассмотрения';
case 'APPROVED_FOR_VISIT':
return 'Очный визит одобрен';
case 'ACCEPTED_AT_VISIT':
return 'Возврат принят';
case 'REJECTED_REMOTELY':
return 'Отказано удалённо';
case 'REJECTED_AT_VISIT':
return 'Отказано на месте';
default:
return status;
}
}
onMounted(() => {
// braname придёт из current member operator-роли при подключении к
// роутингу (на текущей фазе оператор задаёт КУ вручную в поле ввода).
});
</script>
<template lang="pug">
q-page.mp-role-operator.mp-return-operator.q-pa-md
.text-h6.q-mb-sm Гарантийный возврат рассмотрение заявлений
.row.q-mb-md.q-gutter-md
q-input.col-3(v-model="braname" dense outlined label="ID кооперативного участка")
q-btn(no-caps color="primary" :loading="loading" label="Загрузить заявления" @click="load")
.text-subtitle1.q-mb-sm.text-primary Ждут удалённого рассмотрения ({{ pendingClaims.length }})
q-list(v-if="pendingClaims.length > 0" bordered separator).q-mb-md
q-item(v-for="c in pendingClaims" :key="c.id")
q-item-section
q-item-label.text-weight-medium Заказ {{ c.order_id.slice(0, 8) }} · заказчик {{ c.orderer_account }}
q-item-label(caption) {{ c.actual_quantity }} ед. · {{ c.fact_cost }}
q-item-label(caption) {{ c.reason_text.slice(0, 240) }}{{ c.reason_text.length > 240 ? '…' : '' }}
q-item-label(caption v-if="c.defect_category")
| Категория: {{ c.defect_category }}
.row.q-mt-xs.q-gutter-sm
a.mp-return-operator__thumb(
v-for="(p, i) in c.photos" :key="p.content_hash"
:href="p.url" target="_blank" rel="noopener"
)
img(:src="p.url" :alt="`Фото ${i + 1}`")
q-item-section(side)
q-btn(unelevated no-caps color="primary" icon="fa-solid fa-gavel" label="Принять решение" @click="startRemote(c)")
q-card(v-else flat bordered).q-pa-md.q-mb-md
.text-grey Нет заявлений, ожидающих удалённого рассмотрения.
.text-subtitle1.q-mb-sm.text-warning Ожидают очного визита ({{ approvedClaims.length }})
q-list(v-if="approvedClaims.length > 0" bordered separator).q-mb-md
q-item(v-for="c in approvedClaims" :key="c.id")
q-item-section
q-item-label.text-weight-medium Заказ {{ c.order_id.slice(0, 8) }} · заказчик {{ c.orderer_account }}
q-item-label(caption) Возврат на сумму {{ c.fact_cost }}
q-item-label(caption) Дата одобрения: {{ c.decision_log.length > 0 ? formatDateTime(c.decision_log[c.decision_log.length - 1].at) : '—' }}
q-item-section(side)
q-btn(unelevated no-caps color="accent" icon="fa-solid fa-clipboard-check" label="Очный осмотр" @click="startOnSite(c)")
q-card(v-else flat bordered).q-pa-md.q-mb-md
.text-grey Нет заявлений, по которым ожидается очный визит.
.text-subtitle1.q-mb-sm.text-grey Архив ({{ archiveClaims.length }})
q-list(v-if="archiveClaims.length > 0" bordered separator)
q-item(v-for="c in archiveClaims" :key="c.id")
q-item-section
q-item-label Заказ {{ c.order_id.slice(0, 8) }} · {{ c.orderer_account }}
q-item-label(caption) {{ humanStatus(c.status) }}{{ c.ledger_snapshot ? ` · ${c.ledger_snapshot.amount} ₽ восстановлено` : '' }}
q-card(v-else flat bordered).q-pa-md
.text-grey Архив пуст.
RemoteDecisionDialog(
v-model="remoteDialog"
:claim="selectedClaim"
:braname="braname"
@decided="onDecided"
)
OnSiteDecisionDialog(
v-model="onSiteDialog"
:claim="selectedClaim"
:braname="braname"
@decided="onDecided"
)
</template>
<style scoped lang="scss">
.mp-return-operator {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__thumb {
display: inline-block;
width: 56px;
height: 56px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
</style>
@@ -0,0 +1,177 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import {
approveReturnVisit,
rejectReturnRemote,
type MarketplaceReturnClaimView,
} from '../api';
/**
* Story 7.2 / FR31: full-screen takeover для удалённого решения председателя.
* Председатель видит причину обращения и фото пайщика и принимает одно из
* двух решений:
* - «Пригласить на очный осмотр» → aprretrem (`APPROVED_FOR_VISIT`);
* - «Отказать удалённо» → rejretrem (`REJECTED_REMOTELY`, final).
*
* Comment обязателен; пустой comment — ранний фейл на клиенте до запроса
* (UX-DR36 decision pattern).
*/
const props = defineProps<{
modelValue: boolean;
claim: MarketplaceReturnClaimView | null;
braname: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'decided'): void;
}>();
const DECISION_APPROVE = 'approve' as const;
const DECISION_REJECT = 'reject' as const;
type Decision = typeof DECISION_APPROVE | typeof DECISION_REJECT;
const decision = ref<Decision>(DECISION_APPROVE);
const comment = ref<string>('');
const submitting = ref(false);
watch(
() => [props.modelValue, props.claim?.id],
([visible]) => {
if (visible) {
decision.value = DECISION_APPROVE;
comment.value = '';
}
},
{ immediate: false },
);
async function confirm(): Promise<void> {
if (!props.claim) return;
if (!comment.value.trim()) {
FailAlert(new Error('Укажите комментарий: пайщик его увидит в уведомлении.'));
return;
}
if (!props.braname.trim()) {
FailAlert(new Error('Не выбран кооперативный участок.'));
return;
}
submitting.value = true;
try {
if (decision.value === DECISION_APPROVE) {
await approveReturnVisit({
claim_id: props.claim.id,
braname: props.braname.trim(),
comment: comment.value.trim(),
});
SuccessAlert('Заказчик приглашён на очный осмотр. Уведомление отправлено.');
} else {
await rejectReturnRemote({
claim_id: props.claim.id,
braname: props.braname.trim(),
comment: comment.value.trim(),
});
SuccessAlert('Заявление отклонено удалённо. Уведомление отправлено.');
}
emit('decided');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось зафиксировать решение в блокчейне');
} finally {
submitting.value = false;
}
}
function cancel(): void {
emit('update:modelValue', false);
}
const kind = computed<'info' | 'warning'>(() =>
decision.value === DECISION_APPROVE ? 'info' : 'warning',
);
const confirmLabel = computed(() =>
decision.value === DECISION_APPROVE ? 'Пригласить на очный осмотр' : 'Отказать удалённо',
);
</script>
<template lang="pug">
TakeoverDialog(
:model-value="modelValue"
:title="claim ? `Удалённое решение по заявлению ${claim.id.slice(0, 8)}` : 'Заявление'"
:lead-text="claim ? `Заказ ${claim.order_id.slice(0, 8)} · заказчик ${claim.orderer_account} · ${claim.fact_cost} ₽` : ''"
:kind="kind"
:confirm-label="confirmLabel"
cancel-label="Закрыть"
:loading="submitting"
:disable-confirm="!comment.trim() || submitting"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@confirm="confirm"
@cancel="cancel"
)
template(#default v-if="claim")
.mp-return-remote
q-card(flat bordered).q-mb-md
q-card-section
.text-subtitle1 Обращение пайщика
.q-mt-sm {{ claim.reason_text }}
.q-mt-sm.text-caption.text-grey(v-if="claim.defect_category")
| Категория дефекта: {{ claim.defect_category }}
q-card(flat bordered).q-mb-md
q-card-section
.text-subtitle1 Фотографии товара
.row.q-mt-sm.q-gutter-sm
a.mp-return-remote__thumb(
v-for="(p, i) in claim.photos" :key="p.content_hash"
:href="p.url" target="_blank" rel="noopener"
)
img(:src="p.url" :alt="`Фото ${i + 1}`")
q-card(flat bordered).q-pa-md
.text-subtitle1.q-mb-sm Решение
q-option-group(
v-model="decision"
:options="[
{ label: 'Пригласить заказчика на очный осмотр на КУ', value: DECISION_APPROVE, color: 'primary' },
{ label: 'Отказать удалённо (без очного визита)', value: DECISION_REJECT, color: 'warning' },
]"
type="radio"
inline
)
q-input.q-mt-md(
v-model="comment"
outlined
type="textarea"
autogrow
counter
maxlength="500"
label="Комментарий для пайщика (обязательно)"
:placeholder="decision === DECISION_APPROVE ? 'Например: приходите с продукцией в часы работы участка.' : 'Например: гарантия не покрывает данный тип повреждений.'"
)
</template>
<style scoped lang="scss">
.mp-return-remote {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__thumb {
display: inline-block;
width: 96px;
height: 96px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
</style>
@@ -0,0 +1 @@
export { default as OperatorReturnClaimsPage } from './OperatorReturnClaimsPage.vue';
@@ -0,0 +1,44 @@
import { Mutations, Queries, type Types } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
export type MarketplaceReturnClaimView =
Queries.Marketplace.ListMyReturnClaims.IOutput['marketplaceListMyReturnClaims'][number];
export type MarketplaceReturnClaimResultView =
Mutations.Marketplace.CreateReturnClaim.IOutput['marketplaceCreateReturnClaim'];
export type MarketplaceGeneratedDocumentView = Types.Document.IGeneratedDocument;
export type IReturnClaimSignablePayloadInput =
Queries.Marketplace.ReturnClaimSignablePayload.IInput['data'];
export type ICreateReturnClaimInput =
Mutations.Marketplace.CreateReturnClaim.IInput['data'];
export async function listMyReturnClaims(): Promise<MarketplaceReturnClaimView[]> {
const { [Queries.Marketplace.ListMyReturnClaims.name]: result } = await client.Query(
Queries.Marketplace.ListMyReturnClaims.query,
{},
);
return result;
}
export async function getReturnClaimSignablePayload(
data: IReturnClaimSignablePayloadInput,
): Promise<MarketplaceGeneratedDocumentView> {
const { [Queries.Marketplace.ReturnClaimSignablePayload.name]: result } = await client.Query(
Queries.Marketplace.ReturnClaimSignablePayload.query,
{ variables: { data } },
);
return result;
}
export async function createReturnClaim(
data: ICreateReturnClaimInput,
): Promise<MarketplaceReturnClaimResultView> {
const { [Mutations.Marketplace.CreateReturnClaim.name]: result } = await client.Mutation(
Mutations.Marketplace.CreateReturnClaim.mutation,
{ variables: { data } },
);
return result;
}
@@ -0,0 +1,2 @@
export { OrdererReturnClaimsPage } from './ui';
export * from './api';
@@ -0,0 +1,194 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import { listMyReturnClaims, type MarketplaceReturnClaimView } from '../api';
import SubmitReturnClaimDialog from './SubmitReturnClaimDialog.vue';
import ReturnClaimDetailsDialog from './ReturnClaimDetailsDialog.vue';
/**
* Story 7.1 — orderer-стол: список заявлений пайщика на гарантийный возврат
* имущества. Карточки делятся на «активные» (PENDING_CHAIRMAN_REVIEW,
* APPROVED_FOR_VISIT) и «архив» (REJECTED_REMOTELY, REJECTED_AT_VISIT,
* ACCEPTED_AT_VISIT). На активной карточке заказчик может просмотреть
* текущий decision_log; на ACCEPTED_AT_VISIT — увидеть восстановленную
* сумму на программный кошелёк.
*
* Подача нового заявления (с фото-доказательством) идёт через
* SubmitReturnClaimDialog (full-screen TakeoverDialog с пошаговым
* wizard'ом, использует канонические виджеты).
*/
const items = ref<MarketplaceReturnClaimView[]>([]);
const loading = ref(false);
const orderIdInput = ref('');
const submitDialog = ref(false);
const detailsDialog = ref(false);
const selectedClaim = ref<MarketplaceReturnClaimView | null>(null);
const activeClaims = computed(() =>
items.value.filter(
(c) => c.status === 'PENDING_CHAIRMAN_REVIEW' || c.status === 'APPROVED_FOR_VISIT',
),
);
const archiveClaims = computed(() =>
items.value.filter(
(c) =>
c.status === 'ACCEPTED_AT_VISIT' ||
c.status === 'REJECTED_REMOTELY' ||
c.status === 'REJECTED_AT_VISIT',
),
);
async function load(): Promise<void> {
loading.value = true;
try {
items.value = await listMyReturnClaims();
} catch (e) {
FailAlert(e, 'Не удалось загрузить заявления на возврат');
} finally {
loading.value = false;
}
}
function openSubmit(): void {
if (!orderIdInput.value.trim()) {
FailAlert(new Error('Укажите идентификатор полученного заказа.'));
return;
}
submitDialog.value = true;
}
function openDetails(claim: MarketplaceReturnClaimView): void {
selectedClaim.value = claim;
detailsDialog.value = true;
}
function onSubmitted(): void {
orderIdInput.value = '';
void load();
}
function humanStatus(status: MarketplaceReturnClaimView['status']): string {
switch (status) {
case 'PENDING_CHAIRMAN_REVIEW':
return 'На рассмотрении председателя';
case 'APPROVED_FOR_VISIT':
return 'Очный визит одобрен';
case 'ACCEPTED_AT_VISIT':
return 'Возврат принят';
case 'REJECTED_REMOTELY':
return 'Отказано удалённо';
case 'REJECTED_AT_VISIT':
return 'Отказано на месте';
default:
return status;
}
}
function formatDate(value: unknown): string {
if (value === null || value === undefined) return '—';
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleDateString('ru-RU');
}
function statusColor(status: MarketplaceReturnClaimView['status']): string {
switch (status) {
case 'PENDING_CHAIRMAN_REVIEW':
return 'primary';
case 'APPROVED_FOR_VISIT':
return 'info';
case 'ACCEPTED_AT_VISIT':
return 'positive';
case 'REJECTED_REMOTELY':
case 'REJECTED_AT_VISIT':
return 'negative';
default:
return 'grey';
}
}
onMounted(() => {
void load();
});
</script>
<template lang="pug">
q-page.mp-role-orderer.mp-return-claims.q-pa-md
.text-h6.q-mb-sm Гарантийный возврат имущества
.text-body2.text-grey.q-mb-md
| Подача заявления возможна только по выданному заказу в пределах гарантийного срока, заданного поставщиком.
q-card.q-mb-md(flat bordered).mp-return-claims__submit
q-card-section
.row.q-gutter-md.items-center
q-input.col(
v-model="orderIdInput"
outlined
dense
label="Идентификатор полученного заказа"
)
q-btn(
unelevated no-caps color="primary"
icon="fa-solid fa-clipboard-list"
label="Подать заявление"
:disable="!orderIdInput.trim()"
@click="openSubmit"
)
.text-subtitle1.q-mb-sm Активные заявления
q-card(v-if="activeClaims.length === 0" flat bordered).q-pa-md.q-mb-md
.text-grey Нет активных заявлений на возврат.
q-list(v-else bordered separator).q-mb-md
q-item(
v-for="c in activeClaims" :key="c.id" clickable @click="openDetails(c)"
)
q-item-section
q-item-label.text-weight-medium {{ c.actual_quantity }} ед. · {{ c.fact_cost }}
q-item-label(caption) Заказ {{ c.order_id.slice(0, 8) }} · подано {{ formatDate(c.created_at) }}
q-item-label(caption) {{ c.reason_text.slice(0, 120) }}{{ c.reason_text.length > 120 ? '…' : '' }}
q-item-section(side)
q-chip(:color="statusColor(c.status)" text-color="white" dense) {{ humanStatus(c.status) }}
.text-subtitle1.q-mb-sm Архив заявлений
q-card(v-if="archiveClaims.length === 0" flat bordered).q-pa-md
.text-grey Архив пуст.
q-list(v-else bordered separator)
q-item(
v-for="c in archiveClaims" :key="c.id" clickable @click="openDetails(c)"
)
q-item-section
q-item-label {{ c.actual_quantity }} ед. · {{ c.fact_cost }}
q-item-label(caption) Заказ {{ c.order_id.slice(0, 8) }}
q-item-label(
v-if="c.ledger_snapshot"
caption
).text-positive
| Восстановлено на программный кошелёк: {{ c.ledger_snapshot.amount }}
q-item-section(side)
q-chip(:color="statusColor(c.status)" text-color="white" dense) {{ humanStatus(c.status) }}
SubmitReturnClaimDialog(
v-model="submitDialog"
:order-id="orderIdInput"
@submitted="onSubmitted"
)
ReturnClaimDetailsDialog(
v-model="detailsDialog"
:claim="selectedClaim"
)
</template>
<style scoped lang="scss">
.mp-return-claims {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__submit {
border-color: var(--mp-color-border, rgba(0, 0, 0, 0.12));
}
}
</style>
@@ -0,0 +1,160 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import type { MarketplaceReturnClaimView } from '../api';
/**
* Story 7.1-7.4 — детали заявления для пайщика: текущий статус, причина,
* фото (только просмотр), журнал решений председателя, и при принятии
* возврата — снапшот compensating-forward (восстановленная сумма + tx_hash
* композитной транзакции `accretrn`).
*/
const props = defineProps<{
modelValue: boolean;
claim: MarketplaceReturnClaimView | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
}>();
const decisionLabelMap: Record<string, string> = {
approve_visit: 'Председатель пригласил на очный осмотр',
reject_remote: 'Отказано удалённо',
accept_at_visit: 'Возврат принят на очном осмотре',
reject_at_visit: 'Отказано на очном осмотре',
};
const statusLabelMap: Record<MarketplaceReturnClaimView['status'], string> = {
PENDING_CHAIRMAN_REVIEW: 'На рассмотрении председателя',
APPROVED_FOR_VISIT: 'Очный визит одобрен',
ACCEPTED_AT_VISIT: 'Возврат принят',
REJECTED_REMOTELY: 'Отказано удалённо',
REJECTED_AT_VISIT: 'Отказано на месте',
};
const statusKind = computed<'info' | 'success' | 'warning' | 'danger'>(() => {
if (!props.claim) return 'info';
switch (props.claim.status) {
case 'ACCEPTED_AT_VISIT':
return 'success';
case 'REJECTED_REMOTELY':
case 'REJECTED_AT_VISIT':
return 'danger';
case 'APPROVED_FOR_VISIT':
return 'warning';
default:
return 'info';
}
});
function humanDecision(decision: string): string {
return decisionLabelMap[decision] ?? decision;
}
function close(): void {
emit('update:modelValue', false);
}
function formatDateTime(value: unknown): string {
if (value === null || value === undefined) return '—';
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
</script>
<template lang="pug">
TakeoverDialog(
:model-value="modelValue"
:title="claim ? `Заявление ${claim.id.slice(0, 8)} — ${statusLabelMap[claim.status]}` : 'Заявление'"
:lead-text="claim ? `Заказ ${claim.order_id.slice(0, 8)} · ${claim.actual_quantity} ед. · ${claim.fact_cost} ₽` : ''"
:kind="statusKind"
cancel-label="Закрыть"
confirm-label="Готово"
:disable-confirm="false"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@confirm="close"
@cancel="close"
)
template(#default v-if="claim")
.mp-return-details
q-card(flat bordered).q-mb-md
q-card-section
.text-subtitle1 Причина обращения
.q-mt-sm {{ claim.reason_text }}
.q-mt-sm.text-caption.text-grey(v-if="claim.defect_category")
| Категория дефекта: {{ claim.defect_category }}
.q-mt-sm.text-caption.text-grey
| Якорный hash on-chain: {{ claim.request_hash.slice(0, 16) }}
q-card(flat bordered).q-mb-md
q-card-section
.text-subtitle1 Приложенные фотографии
.row.q-mt-sm.q-gutter-sm
a.mp-return-details__thumb(
v-for="(p, i) in claim.photos" :key="p.content_hash"
:href="p.url" target="_blank" rel="noopener"
)
img(:src="p.url" :alt="`Фото ${i + 1}`")
q-card(flat bordered).q-mb-md(v-if="claim.decision_log.length > 0")
q-card-section
.text-subtitle1 История решений председателя
q-timeline(layout="dense" color="primary").q-mt-sm
q-timeline-entry(
v-for="entry in claim.decision_log" :key="entry.tx_hash"
:title="humanDecision(entry.decision)"
:subtitle="`${entry.by_chairman_account} · КУ ${entry.braname} · ${formatDateTime(entry.at)}`"
:color="entry.decision === 'accept_at_visit' ? 'positive' : entry.decision === 'reject_remote' || entry.decision === 'reject_at_visit' ? 'negative' : 'primary'"
)
| {{ entry.comment }}
q-card(flat bordered).q-mb-md(v-if="claim.on_site_inspection")
q-card-section
.text-subtitle1 Результат очного осмотра
.q-mt-sm {{ claim.on_site_inspection.result_text }}
.q-mt-sm.text-caption.text-grey(v-if="claim.on_site_inspection.scanned_barcode")
| Сканированный штрих-код: {{ claim.on_site_inspection.scanned_barcode }}
.row.q-mt-sm.q-gutter-sm(v-if="claim.on_site_inspection.photos.length > 0")
a.mp-return-details__thumb(
v-for="(p, i) in claim.on_site_inspection.photos" :key="p.content_hash"
:href="p.url" target="_blank" rel="noopener"
)
img(:src="p.url" :alt="`Фото осмотра ${i + 1}`")
q-card(flat bordered).bg-positive.text-white(v-if="claim.ledger_snapshot")
q-card-section
.text-subtitle1 Возврат принят
.q-mt-sm
| Восстановлено на программный кошелёк Стола Заказов:
strong.q-ml-xs {{ claim.ledger_snapshot.amount }}
.text-caption.q-mt-sm
| Композитная транзакция accretrn: {{ claim.ledger_snapshot.tx_hash }}
.text-caption
| Вы можете направить средства на следующий заказ либо вывести в общий членский кошелёк отдельным действием.
</template>
<style scoped lang="scss">
.mp-return-details {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__thumb {
display: inline-block;
width: 96px;
height: 96px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
</style>
@@ -0,0 +1,345 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Classes, Zeus } from '@coopenomics/sdk';
import { useGlobalStore } from 'src/shared/store';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import {
createReturnClaim,
getReturnClaimSignablePayload,
type ICreateReturnClaimInput,
} from '../api';
type ReturnClaimPhotoUploadInput = ICreateReturnClaimInput['photos'][number];
type DefectCategory = Zeus.MarketplaceReturnClaimDefectCategory;
/**
* Story 7.1 / FR29: full-screen takeover для подачи заявления на гарантийный
* возврат имущества. Шаги:
*
* 1. Описание дефекта (reason_text, defect_category, actual_quantity).
* 2. Загрузка фото (1-10 файлов, image/jpeg|png|webp до 10 МБ каждое) —
* UI кодирует содержимое в base64 и отправляет вместе с mutation,
* backend кладёт в bucket `stol-zakazov:images` и публикует sha256
* хеши on-chain параметром `photos[]` submretrn.
* 3. Подпись заявления (registry_id=1104, MarketplaceReturnStatement):
* backend возвращает preview HTML + hash; пайщик подписывает
* приватным ключом из useGlobalStore и шлёт результат вместе с
* reason_text + photos.
*
* `request_hash` детерминирован: backend хеширует
* `return:<order_hash>:<orderer>:<actual_quantity>`; одно и то же значение
* становится якорным `hash` подписываемого документа и `request_hash` в
* параметре submretrn → двусторонняя сверка backend ↔ on-chain.
*/
const DEFECT_CATEGORIES: Array<{ value: DefectCategory; label: string }> = [
{ value: Zeus.MarketplaceReturnClaimDefectCategory.BROKEN, label: 'Повреждено / сломано' },
{ value: Zeus.MarketplaceReturnClaimDefectCategory.EXPIRED, label: 'Истёк срок годности' },
{ value: Zeus.MarketplaceReturnClaimDefectCategory.NOT_AS_DESCRIBED, label: 'Не соответствует описанию' },
{ value: Zeus.MarketplaceReturnClaimDefectCategory.WRONG_ITEM, label: 'Не тот товар' },
{ value: Zeus.MarketplaceReturnClaimDefectCategory.OTHER, label: 'Другое' },
];
const props = defineProps<{
modelValue: boolean;
orderId: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'submitted'): void;
}>();
const globalStore = useGlobalStore();
const STEP_DESCRIBE = 'describe' as const;
const STEP_PHOTOS = 'photos' as const;
const STEP_SIGN = 'sign' as const;
type Step = typeof STEP_DESCRIBE | typeof STEP_PHOTOS | typeof STEP_SIGN;
const step = ref<Step>(STEP_DESCRIBE);
const reasonText = ref<string>('');
const defectCategory = ref<DefectCategory | ''>('');
const actualQuantity = ref<number | null>(null);
const photos = ref<ReturnClaimPhotoUploadInput[]>([]);
const previewHtml = ref<string>('');
const previewLoading = ref(false);
const submitting = ref(false);
watch(
() => [props.modelValue, props.orderId],
([visible]) => {
if (visible) {
step.value = STEP_DESCRIBE;
reasonText.value = '';
defectCategory.value = '';
actualQuantity.value = null;
photos.value = [];
previewHtml.value = '';
}
},
{ immediate: false },
);
async function onFilesPicked(files: readonly File[] | File[]): Promise<void> {
const list = Array.from(files);
if (photos.value.length + list.length > 10) {
FailAlert(new Error('Можно приложить не более 10 фотографий.'));
return;
}
for (const file of list) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
FailAlert(new Error(`Файл «${file.name}»: поддерживаются JPEG, PNG, WEBP.`));
continue;
}
if (file.size > 10 * 1024 * 1024) {
FailAlert(new Error(`Файл «${file.name}»: размер превышает 10 МБ.`));
continue;
}
const base64 = await fileToBase64(file);
photos.value.push({ base64, mime_type: file.type });
}
}
function removePhoto(index: number): void {
photos.value.splice(index, 1);
}
async function loadPreview(): Promise<void> {
if (!props.orderId) return;
previewLoading.value = true;
try {
const doc = await getReturnClaimSignablePayload({
order_id: props.orderId,
actual_quantity: actualQuantity.value ?? undefined,
});
previewHtml.value = doc.html;
} catch (e) {
FailAlert(e, 'Не удалось сформировать предварительное заявление');
step.value = STEP_PHOTOS;
} finally {
previewLoading.value = false;
}
}
function goToPhotos(): void {
if (!reasonText.value.trim()) {
FailAlert(new Error('Опишите причину возврата.'));
return;
}
if (reasonText.value.length > 500) {
FailAlert(new Error('Причина возврата не должна превышать 500 символов.'));
return;
}
step.value = STEP_PHOTOS;
}
async function goToSign(): Promise<void> {
if (photos.value.length === 0) {
FailAlert(new Error('Приложите хотя бы одну фотографию товара.'));
return;
}
step.value = STEP_SIGN;
await loadPreview();
}
async function confirm(): Promise<void> {
if (step.value === STEP_DESCRIBE) {
goToPhotos();
return;
}
if (step.value === STEP_PHOTOS) {
await goToSign();
return;
}
if (!previewHtml.value) {
FailAlert(new Error('Заявление ещё формируется, подождите.'));
return;
}
const wifKey = globalStore.wif?.toString();
if (!wifKey) {
FailAlert(new Error('Приватный ключ не найден. Войдите в кооператив заново.'));
return;
}
submitting.value = true;
try {
const generated = await getReturnClaimSignablePayload({
order_id: props.orderId,
actual_quantity: actualQuantity.value ?? undefined,
reason_text: reasonText.value,
defect_category: defectCategory.value || undefined,
});
const signer = new Classes.Document(wifKey);
const signed = await signer.signDocument(generated, globalStore.username, 1);
await createReturnClaim({
order_id: props.orderId,
reason_text: reasonText.value,
defect_category: defectCategory.value || undefined,
actual_quantity: actualQuantity.value ?? undefined,
signed_statement: signed,
photos: photos.value,
});
SuccessAlert('Заявление подано. Председатель кооперативного участка рассмотрит обращение.');
emit('submitted');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось подать заявление на возврат');
} finally {
submitting.value = false;
}
}
function cancel(): void {
emit('update:modelValue', false);
}
function backStep(): void {
if (step.value === STEP_PHOTOS) step.value = STEP_DESCRIBE;
else if (step.value === STEP_SIGN) step.value = STEP_PHOTOS;
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const value = reader.result;
if (typeof value !== 'string') {
reject(new Error('Не удалось прочитать файл'));
return;
}
const commaAt = value.indexOf(',');
resolve(commaAt === -1 ? value : value.slice(commaAt + 1));
};
reader.onerror = () => reject(reader.error ?? new Error('Ошибка чтения файла'));
reader.readAsDataURL(file);
});
}
const confirmLabel = computed(() => {
if (step.value === STEP_DESCRIBE) return 'К загрузке фото';
if (step.value === STEP_PHOTOS) return 'К подписи заявления';
return 'Подписать и подать';
});
const confirmDisabled = computed(() => {
if (submitting.value) return true;
if (step.value === STEP_DESCRIBE) return !reasonText.value.trim();
if (step.value === STEP_PHOTOS) return photos.value.length === 0;
return !previewHtml.value;
});
</script>
<template lang="pug">
TakeoverDialog(
:model-value="modelValue"
title="Заявление на гарантийный возврат"
:lead-text="`Заказ ${orderId.slice(0, 8)}`"
kind="info"
:confirm-label="confirmLabel"
cancel-label="Закрыть"
:loading="submitting"
:disable-confirm="confirmDisabled"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@confirm="confirm"
@cancel="cancel"
)
template(#default)
.mp-return-submit
q-stepper(
:model-value="step"
flat bordered animated
active-color="primary" done-color="positive"
)
q-step(:name="STEP_DESCRIBE" title="Описание" icon="fa-solid fa-pen" :done="step !== STEP_DESCRIBE")
q-banner.q-mb-md(rounded class="bg-primary text-white")
| Опишите, что не так с полученным товаром. Это сообщение увидит председатель кооперативного участка при удалённом рассмотрении.
q-input(
v-model="reasonText"
outlined
type="textarea"
label="Причина возврата"
counter
maxlength="500"
autogrow
)
q-select(
v-model="defectCategory"
:options="DEFECT_CATEGORIES"
label="Категория дефекта (опционально)"
map-options
emit-value
option-label="label"
option-value="value"
outlined
dense
clearable
).q-mt-md
q-input(
v-model.number="actualQuantity"
outlined
dense
type="number"
min="1"
label="Возвращаемое количество единиц (опционально)"
hint="Если пусто — возвращается всё фактически выданное количество."
).q-mt-md
q-step(:name="STEP_PHOTOS" title="Фото" icon="fa-solid fa-image" :done="step === STEP_SIGN")
q-banner.q-mb-md(rounded class="bg-info text-white")
| Приложите от 1 до 10 фотографий товара (JPEG, PNG, WEBP, до 10 МБ каждое). Хеши файлов будут записаны в блокчейн как доказательная база.
q-file(
label="Выбрать фотографии"
outlined
dense
multiple
accept="image/jpeg,image/png,image/webp"
:model-value="null"
@update:model-value="onFilesPicked"
)
.row.q-gutter-sm.q-mt-md(v-if="photos.length > 0")
q-chip(
v-for="(p, i) in photos" :key="i"
:label="`Фото ${i + 1} · ${p.mime_type.replace('image/', '').toUpperCase()}`"
removable
color="primary"
text-color="white"
@remove="removePhoto(i)"
)
.text-grey.q-mt-md(v-else)
| Фотографий пока нет.
.row.q-mt-md
q-btn(flat no-caps label="Назад к описанию" @click="backStep")
q-step(:name="STEP_SIGN" title="Подпись" icon="fa-solid fa-pen-nib")
q-card(v-if="previewLoading" flat bordered).q-pa-md
q-spinner(color="primary" size="32px")
.q-ml-md Формирую предварительное заявление
q-card(v-else-if="previewHtml" flat bordered).mp-return-submit__preview
q-card-section.q-pa-md
.text-caption.text-grey Превью заявления (registry_id=1104)
q-separator
q-card-section.q-pa-md
div(v-html="previewHtml")
q-card(v-else flat bordered).q-pa-md
.text-negative Не удалось сформировать предварительный документ.
.row.q-mt-md
q-btn(flat no-caps label="Назад к фото" @click="backStep")
</template>
<style scoped lang="scss">
.mp-return-submit {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__preview {
max-height: 50vh;
overflow: auto;
}
}
</style>
@@ -0,0 +1 @@
export { default as OrdererReturnClaimsPage } from './OrdererReturnClaimsPage.vue';
@@ -0,0 +1,63 @@
import { DraftContract } from 'cooptypes'
import { MarketplaceReturnStatement } from '../Templates'
import { DocFactory } from '../Factory'
import type { IGeneratedDocument, IGenerationOptions, IMetaDocument, ITemplate } from '../Interfaces'
import type { MongoDBConnector } from '../Services/Databazor'
import type { ExternalOrganizationData } from '../Models'
export { MarketplaceReturnStatement as Template } from '../Templates'
export class Factory extends DocFactory<MarketplaceReturnStatement.Action> {
constructor(storage: MongoDBConnector) {
super(storage)
}
async generateDocument(data: MarketplaceReturnStatement.Action, options?: IGenerationOptions): Promise<IGeneratedDocument> {
let template: ITemplate<MarketplaceReturnStatement.Model>
if (process.env.SOURCE === 'local') {
template = MarketplaceReturnStatement.Template
}
else {
template = await this.getTemplate(DraftContract.contractName.production, MarketplaceReturnStatement.registry_id, data.block_num)
}
const meta: IMetaDocument = await this.getMeta({ title: template.title, ...data })
const coop = await this.getCooperative(data.coopname, data.block_num)
const vars = await this.getVars(data.coopname, data.block_num)
const user = await this.getUser(data.username, data.block_num)
const commonUser = this.getCommonUser(user)
const request = await this.getRequest(Number(data.order_id), data.block_num)
if (coop.is_branched && !data.braname)
throw new Error('Branch name is required')
let branch: ExternalOrganizationData | undefined
if (data.braname)
branch = await this.getOrganization(data.braname, data.block_num)
const program = await this.getProgram(request.program_id)
const combinedData: MarketplaceReturnStatement.Model = {
meta,
coop,
vars,
user: commonUser,
request,
program,
fact_cost: data.fact_cost,
actual_quantity: String(data.actual_quantity),
reason_text: data.reason_text,
defect_category: data.defect_category,
branch,
}
await this.validate(combinedData, template.model)
const translation = template.translations[meta.lang]
const document: IGeneratedDocument = await this.generatePDF(commonUser.full_name_or_short_name, template.context, combinedData, translation, meta, options?.skip_save)
return document
}
}
+3
View File
@@ -78,3 +78,6 @@ export * as BlagorostOfferTemplate from './999.BlagorostOfferTemplate'
// Marketplace (Стол заказов) — Эпик 5
export * as MarketplaceAplReception from './1102.MarketplaceAplReception'
export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
// Marketplace (Стол заказов) — Эпик 7: гарантийный возврат
export * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
@@ -0,0 +1,41 @@
import type { JSONSchemaType } from 'ajv'
import { Cooperative } from 'cooptypes'
import type { ITemplate } from '../Interfaces'
import { IMetaJSONSchema } from '../Schema/MetaSchema'
import { CooperativeSchema, VarsSchema, organizationSchema } from '../Schema'
import { CommonUserSchema } from '../Schema/CommonUserSchema'
import { CommonRequestSchema } from '../Schema/CommonRequestSchema'
import { CommonProgramSchema } from '../Schema/CommonProgramSchema'
export const registry_id = Cooperative.Registry.MarketplaceReturnStatement.registry_id
export type Action = Cooperative.Registry.MarketplaceReturnStatement.Action
export type Model = Cooperative.Registry.MarketplaceReturnStatement.Model
export const Schema: JSONSchemaType<Model> = {
type: 'object',
properties: {
meta: IMetaJSONSchema,
coop: CooperativeSchema,
vars: VarsSchema,
request: CommonRequestSchema,
user: CommonUserSchema,
program: CommonProgramSchema,
fact_cost: { type: 'string' },
actual_quantity: { type: 'string' },
reason_text: { type: 'string' },
defect_category: { type: 'string', nullable: true },
branch: { ...organizationSchema, nullable: true },
},
required: ['meta', 'coop', 'vars', 'request', 'user', 'program', 'fact_cost', 'actual_quantity', 'reason_text'],
additionalProperties: true,
}
export const Template: ITemplate<Model> = {
title: Cooperative.Registry.MarketplaceReturnStatement.title,
description: Cooperative.Registry.MarketplaceReturnStatement.description,
model: Schema,
context: Cooperative.Registry.MarketplaceReturnStatement.context,
translations: Cooperative.Registry.MarketplaceReturnStatement.translations,
}
@@ -72,6 +72,9 @@ export * as SosediAgreement from './699.SosediAgreement'
export * as MarketplaceAplReception from './1102.MarketplaceAplReception'
export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
// Marketplace (Стол заказов) — Эпик 7: гарантийный возврат
export * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
export * as AnnualGeneralMeetingAgenda from './300.AnnualGeneralMeetingAgenda'
export * as AnnualGeneralMeetingSovietDecision from './301.AnnualGeneralMeetingSovietDecision'
export * as AnnualGeneralMeetingNotification from './302.AnnualGeneralMeetingNotification'
@@ -72,6 +72,7 @@ import * as GeneratorProgramTemplate from './994.GeneratorProgramTemplate'
import * as BlagorostOffer from './1000.BlagorostOffer'
import * as MarketplaceAplReception from './1102.MarketplaceAplReception'
import * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
import * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
export const Registry = {
1: WalletAgreement,
@@ -135,4 +136,5 @@ export const Registry = {
1090: CapitalizationToMainWalletConvertStatement,
1102: MarketplaceAplReception,
1103: MarketplaceTransportNote,
1104: MarketplaceReturnStatement,
}
+3
View File
@@ -171,6 +171,9 @@ export class Generator implements IGenerator {
// Marketplace (Стол заказов) — Эпик 5
[Actions.MarketplaceAplReception.Template.registry_id]: new Actions.MarketplaceAplReception.Factory(this.storage), // 1102
[Actions.MarketplaceTransportNote.Template.registry_id]: new Actions.MarketplaceTransportNote.Factory(this.storage), // 1103
// Marketplace (Стол заказов) — Эпик 7: гарантийный возврат
[Actions.MarketplaceReturnStatement.Template.registry_id]: new Actions.MarketplaceReturnStatement.Factory(this.storage), // 1104
}
await this.storage.connect()
}
File diff suppressed because one or more lines are too long
@@ -28,6 +28,9 @@ import { workflow as marketplaceCashierNewPaymentWorkflow } from './marketplace-
import { workflow as marketplaceSupplierPaymentConfirmedWorkflow } from './marketplace-supplier-payment-confirmed';
import { workflow as marketplaceSupplierPaymentDeclinedWorkflow } from './marketplace-supplier-payment-declined';
import { workflow as marketplaceOrderReadyWorkflow } from './marketplace-order-ready';
import { workflow as marketplaceReturnClaimSubmittedWorkflow } from './marketplace-return-claim-submitted';
import { workflow as marketplaceReturnClaimDecidedWorkflow } from './marketplace-return-claim-decided';
import { workflow as marketplaceReturnClaimFinalizedWorkflow } from './marketplace-return-claim-finalized';
// Импортируем все воркфлоу
export * as Welcome from './welcome';
@@ -58,6 +61,9 @@ export * as MarketplaceCashierNewPayment from './marketplace-cashier-new-payment
export * as MarketplaceSupplierPaymentConfirmed from './marketplace-supplier-payment-confirmed';
export * as MarketplaceSupplierPaymentDeclined from './marketplace-supplier-payment-declined';
export * as MarketplaceOrderReady from './marketplace-order-ready';
export * as MarketplaceReturnClaimSubmitted from './marketplace-return-claim-submitted';
export * as MarketplaceReturnClaimDecided from './marketplace-return-claim-decided';
export * as MarketplaceReturnClaimFinalized from './marketplace-return-claim-finalized';
// Массив всех воркфлоу для автоматической регистрации
export const allWorkflows: WorkflowDefinition[] = [
@@ -89,6 +95,9 @@ export const allWorkflows: WorkflowDefinition[] = [
marketplaceSupplierPaymentConfirmedWorkflow,
marketplaceSupplierPaymentDeclinedWorkflow,
marketplaceOrderReadyWorkflow,
marketplaceReturnClaimSubmittedWorkflow,
marketplaceReturnClaimDecidedWorkflow,
marketplaceReturnClaimFinalizedWorkflow,
];
// Экспортируем воркфлоу по ID для удобного доступа
@@ -0,0 +1,50 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceReturnClaimDecidedPayloadSchema = z.object({
ordererName: z.string(),
decisionHuman: z.string(),
brananame: z.string(),
coopname: z.string(),
claim_id: z.string(),
order_id: z.string(),
comment: z.string(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceReturnClaimDecidedPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Решение по заявлению на гарантийный возврат';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder
.create<IWorkflow>()
.name(name)
.workflowId(id)
.description('Уведомление пайщику-заказчику о решении председателя по заявлению на гарантийный возврат (одобрение очного визита / отказ удалённо).')
.payloadSchema(marketplaceReturnClaimDecidedPayloadSchema)
.tags(['marketplace', 'orderer', 'return'])
.addSteps([
createEmailStep(
'marketplace-return-claim-decided-email',
'Решение по вашему заявлению на возврат: {{payload.decisionHuman}}',
'Уважаемый {{payload.ordererName}}!<br><br>Председатель кооперативного участка <strong>{{payload.brananame}}</strong> вынес решение по вашему заявлению на гарантийный возврат по заказу <strong>{{payload.order_id}}</strong>: <strong>{{payload.decisionHuman}}</strong>.<br><br>Комментарий: {{payload.comment}}<br><br>Подробности заявления: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-return-claim-decided-notification',
'Решение по заявлению на возврат',
'По вашему заявлению: {{payload.decisionHuman}} — {{payload.comment}}'
),
createPushStep(
'marketplace-return-claim-decided-push',
'Решение по заявлению на возврат',
'{{payload.decisionHuman}}: {{payload.comment}}'
),
])
.build();
@@ -0,0 +1,49 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceReturnClaimFinalizedPayloadSchema = z.object({
ordererName: z.string(),
outcomeHuman: z.string(),
coopname: z.string(),
claim_id: z.string(),
order_id: z.string(),
returnedAmount: z.string().optional(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceReturnClaimFinalizedPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Гарантийный возврат завершён';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder
.create<IWorkflow>()
.name(name)
.workflowId(id)
.description('Финальное уведомление заказчику о результате гарантийного возврата (принят с восстановлением средств / отказ).')
.payloadSchema(marketplaceReturnClaimFinalizedPayloadSchema)
.tags(['marketplace', 'orderer', 'return'])
.addSteps([
createEmailStep(
'marketplace-return-claim-finalized-email',
'Гарантийный возврат завершён: {{payload.outcomeHuman}}',
'Уважаемый {{payload.ordererName}}!<br><br>Ваш гарантийный возврат по заказу <strong>{{payload.order_id}}</strong> завершён: <strong>{{payload.outcomeHuman}}</strong>.{% if payload.returnedAmount %}<br><br>На программный кошелёк восстановлено: <strong>{{payload.returnedAmount}} ₽</strong> — вы можете направить их на следующий заказ либо вывести в общий членский кошелёк.{% endif %}<br><br>Детали возврата: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-return-claim-finalized-notification',
'Гарантийный возврат завершён',
'{{payload.outcomeHuman}}{% if payload.returnedAmount %} — {{payload.returnedAmount}} ₽ восстановлены{% endif %}.'
),
createPushStep(
'marketplace-return-claim-finalized-push',
'Гарантийный возврат завершён',
'{{payload.outcomeHuman}}{% if payload.returnedAmount %} — {{payload.returnedAmount}} ₽ восстановлены{% endif %}.'
),
])
.build();
@@ -0,0 +1,50 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceReturnClaimSubmittedPayloadSchema = z.object({
chairmanName: z.string(),
ordererName: z.string(),
brananame: z.string(),
coopname: z.string(),
claim_id: z.string(),
order_id: z.string(),
reasonExcerpt: z.string(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceReturnClaimSubmittedPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Новое заявление на гарантийный возврат';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder
.create<IWorkflow>()
.name(name)
.workflowId(id)
.description('Уведомление председателю кооперативного участка о поступившем заявлении на гарантийный возврат имущества пайщиком.')
.payloadSchema(marketplaceReturnClaimSubmittedPayloadSchema)
.tags(['marketplace', 'operator', 'return'])
.addSteps([
createEmailStep(
'marketplace-return-claim-submitted-email',
'Новое заявление на гарантийный возврат от {{payload.ordererName}}',
'Уважаемый {{payload.chairmanName}}!<br><br>Пайщик <strong>{{payload.ordererName}}</strong> подал заявление на гарантийный возврат имущества по заказу <strong>{{payload.order_id}}</strong> на вашем кооперативном участке <strong>{{payload.brananame}}</strong>.<br><br>Причина: {{payload.reasonExcerpt}}<br><br>Рассмотреть заявление: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-return-claim-submitted-notification',
'Новое заявление на гарантийный возврат',
'Пайщик {{payload.ordererName}} подал заявление по заказу {{payload.order_id}} — требуется удалённое рассмотрение.'
),
createPushStep(
'marketplace-return-claim-submitted-push',
'Новое заявление на гарантийный возврат',
'Заявление на гарантийный возврат на КУ {{payload.brananame}} ждёт рассмотрения.'
),
])
.build();
@@ -0,0 +1,19 @@
import { marketplaceReturnClaimResultSelector } from '../../selectors/marketplace/returnClaimSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceAcceptReturnAtVisit'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceAcceptReturnAtVisitInput!') },
marketplaceReturnClaimResultSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceAcceptReturnAtVisitInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,19 @@
import { marketplaceReturnClaimResultSelector } from '../../selectors/marketplace/returnClaimSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceApproveReturnVisit'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceApproveReturnVisitInput!') },
marketplaceReturnClaimResultSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceApproveReturnVisitInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,19 @@
import { marketplaceReturnClaimResultSelector } from '../../selectors/marketplace/returnClaimSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceCreateReturnClaim'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceCreateReturnClaimInput!') },
marketplaceReturnClaimResultSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceCreateReturnClaimInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -24,3 +24,13 @@ export * as SignAplReceptionAsChairman from './signAplReceptionAsChairman'
export * as OpenIssuance from './openIssuance'
/** Эпик 6: заказчик закрывает выдачу финальной подписью с указанием фактического количества */
export * as FinalizeIssuance from './finalizeIssuance'
/** Эпик 7: заказчик подаёт заявление на гарантийный возврат имущества */
export * as CreateReturnClaim from './createReturnClaim'
/** Эпик 7: председатель КУ удалённо приглашает заказчика на очный осмотр */
export * as ApproveReturnVisit from './approveReturnVisit'
/** Эпик 7: председатель КУ удалённо отказывает в гарантийном возврате */
export * as RejectReturnRemote from './rejectReturnRemote'
/** Эпик 7: председатель КУ принимает гарантийный возврат на очном осмотре (compensating forward) */
export * as AcceptReturnAtVisit from './acceptReturnAtVisit'
/** Эпик 7: председатель КУ отказывает в гарантийном возврате на очном осмотре */
export * as RejectReturnAtVisit from './rejectReturnAtVisit'
@@ -0,0 +1,19 @@
import { marketplaceReturnClaimResultSelector } from '../../selectors/marketplace/returnClaimSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceRejectReturnAtVisit'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceRejectReturnAtVisitInput!') },
marketplaceReturnClaimResultSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceRejectReturnAtVisitInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,19 @@
import { marketplaceReturnClaimResultSelector } from '../../selectors/marketplace/returnClaimSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceRejectReturnRemote'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceRejectReturnRemoteInput!') },
marketplaceReturnClaimResultSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceRejectReturnRemoteInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -30,3 +30,9 @@ export * as ListMyReadyToReceive from './listMyReadyToReceive'
export * as IssueActChairmanSignablePayload from './issueActChairmanSignablePayload'
/** Эпик 6: превью акта выдачи для финальной подписи заказчика */
export * as IssueActOrdererSignablePayload from './issueActOrdererSignablePayload'
/** Эпик 7: все заявления текущего пайщика на гарантийный возврат */
export * as ListMyReturnClaims from './listMyReturnClaims'
/** Эпик 7: заявления на возврат текущего КУ для operator-стола */
export * as ListReturnClaimsByBraname from './listReturnClaimsByBraname'
/** Эпик 7: превью заявления на гарантийный возврат для подписи заказчиком */
export * as ReturnClaimSignablePayload from './returnClaimSignablePayload'
@@ -0,0 +1,15 @@
import { marketplaceReturnClaimSelector } from '../../selectors/marketplace/returnClaimSelector'
import { type GraphQLTypes, type InputType, Selector } from '../../zeus/index'
export const name = 'marketplaceListMyReturnClaims'
export const query = Selector('Query')({
[name]: marketplaceReturnClaimSelector,
})
export interface IInput {
/** @private */
[key: string]: unknown
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -0,0 +1,19 @@
import { marketplaceReturnClaimSelector } from '../../selectors/marketplace/returnClaimSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceListReturnClaimsByBraname'
export const query = Selector('Query')({
[name]: [
{ data: $('data', 'MarketplaceListReturnClaimsByBranameInput!') },
marketplaceReturnClaimSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceListReturnClaimsByBranameInput']
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -0,0 +1,19 @@
import { documentSelector } from '../../selectors/common/documentSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceReturnClaimSignablePayload'
export const query = Selector('Query')({
[name]: [
{ data: $('data', 'MarketplaceReturnClaimSignablePayloadInput!') },
documentSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceReturnClaimSignablePayloadInput']
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -0,0 +1,107 @@
import { Selector, type ValueTypes } from '../../zeus/index'
import type { MakeAllFieldsRequired } from '../../utils/MakeAllFieldsRequired'
const rawPhotoSelector = {
url: true,
content_hash: true,
mime_type: true,
uploaded_at: true,
}
const _validatePhoto: MakeAllFieldsRequired<ValueTypes['MarketplaceReturnClaimPhoto']> =
rawPhotoSelector
export const marketplaceReturnClaimPhotoSelector = Selector('MarketplaceReturnClaimPhoto')(
rawPhotoSelector,
)
const rawDecisionEntrySelector = {
stage: true,
decision: true,
by_chairman_account: true,
braname: true,
comment: true,
at: true,
tx_hash: true,
}
const _validateDecision: MakeAllFieldsRequired<
ValueTypes['MarketplaceReturnClaimDecisionEntry']
> = rawDecisionEntrySelector
export const marketplaceReturnClaimDecisionEntrySelector = Selector(
'MarketplaceReturnClaimDecisionEntry',
)(rawDecisionEntrySelector)
const rawOnSiteInspectionSelector = {
result_text: true,
photos: rawPhotoSelector,
scanned_barcode: true,
by_chairman_account: true,
at: true,
}
const _validateInspection: MakeAllFieldsRequired<
ValueTypes['MarketplaceReturnClaimOnSiteInspection']
> = rawOnSiteInspectionSelector
export const marketplaceReturnClaimOnSiteInspectionSelector = Selector(
'MarketplaceReturnClaimOnSiteInspection',
)(rawOnSiteInspectionSelector)
const rawLedgerSnapshotSelector = {
amount: true,
returned_quantity: true,
tx_hash: true,
at: true,
}
const _validateLedger: MakeAllFieldsRequired<
ValueTypes['MarketplaceReturnClaimLedgerSnapshot']
> = rawLedgerSnapshotSelector
export const marketplaceReturnClaimLedgerSnapshotSelector = Selector(
'MarketplaceReturnClaimLedgerSnapshot',
)(rawLedgerSnapshotSelector)
const rawClaimSelector = {
id: true,
coopname: true,
request_hash: true,
order_id: true,
order_hash: true,
orderer_account: true,
delivery_braname: true,
supplier_account: true,
status: true,
reason_text: true,
defect_category: true,
expected_resolution: true,
actual_quantity: true,
fact_cost: true,
photos: rawPhotoSelector,
submretrn_tx_hash: true,
decision_log: rawDecisionEntrySelector,
on_site_inspection: rawOnSiteInspectionSelector,
ledger_snapshot: rawLedgerSnapshotSelector,
created_at: true,
updated_at: true,
}
const _validateClaim: MakeAllFieldsRequired<ValueTypes['MarketplaceReturnClaim']> =
rawClaimSelector
export const marketplaceReturnClaimSelector = Selector('MarketplaceReturnClaim')(rawClaimSelector)
const rawClaimResultSelector = {
claim: rawClaimSelector,
tx_hash: true,
}
const _validateResult: MakeAllFieldsRequired<
ValueTypes['MarketplaceReturnClaimResult']
> = rawClaimResultSelector
export const marketplaceReturnClaimResultSelector = Selector('MarketplaceReturnClaimResult')(
rawClaimResultSelector,
)
+126
View File
@@ -633,6 +633,10 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceAcceptIndividualOrderInput:{
},
MarketplaceAcceptReturnAtVisitInput:{
inspection_photos:"MarketplaceReturnClaimPhotoUploadInput",
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceAddToWhitelistInput:{
@@ -654,6 +658,9 @@ export const AllTypesProps: Record<string,any> = {
MarketplaceAplReceptionVariant: "enum" as const,
MarketplaceApproveOfferInput:{
},
MarketplaceApproveReturnVisitInput:{
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceAttributeType: "enum" as const,
MarketplaceBarcodeFormat: "enum" as const,
@@ -670,6 +677,11 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceCreateOrderInput:{
},
MarketplaceCreateReturnClaimInput:{
defect_category:"MarketplaceReturnClaimDefectCategory",
photos:"MarketplaceReturnClaimPhotoUploadInput",
signed_statement:"MarketplaceReturnStatementSignedInput"
},
MarketplaceCreateShipmentInput:{
groups:"MarketplaceShipmentGroupInput"
@@ -733,6 +745,9 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceListPendingOffersInput:{
},
MarketplaceListReturnClaimsByBranameInput:{
},
MarketplaceListShipmentsInput:{
statuses:"MarketplaceShipmentStatus"
@@ -746,9 +761,32 @@ export const AllTypesProps: Record<string,any> = {
MarketplaceOutgoingPaymentRequestStatus: "enum" as const,
MarketplaceRejectOfferInput:{
},
MarketplaceRejectReturnAtVisitInput:{
inspection_photos:"MarketplaceReturnClaimPhotoUploadInput",
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceRejectReturnRemoteInput:{
signed_decision:"MarketplaceReturnStatementSignedInput"
},
MarketplaceRemoveFromWhitelistInput:{
},
MarketplaceReturnClaimDefectCategory: "enum" as const,
MarketplaceReturnClaimExpectedResolution: "enum" as const,
MarketplaceReturnClaimPhotoUploadInput:{
},
MarketplaceReturnClaimSignablePayloadInput:{
defect_category:"MarketplaceReturnClaimDefectCategory"
},
MarketplaceReturnClaimStatus: "enum" as const,
MarketplaceReturnStatementSignedInput:{
meta:"MarketplaceReturnStatementSignedMetaDocumentInput",
signatures:"SignatureInfoInput"
},
MarketplaceReturnStatementSignedMetaDocumentInput:{
},
MarketplaceSetKUStatusInput:{
@@ -1236,6 +1274,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceAcceptIndividualOrder:{
input:"MarketplaceAcceptIndividualOrderInput"
},
marketplaceAcceptReturnAtVisit:{
data:"MarketplaceAcceptReturnAtVisitInput"
},
marketplaceAddAvailableCategories:{
input:"AddAvailableCategoriesInput"
},
@@ -1248,6 +1289,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceApproveOffer:{
input:"MarketplaceApproveOfferInput"
},
marketplaceApproveReturnVisit:{
data:"MarketplaceApproveReturnVisitInput"
},
marketplaceCancelOrder:{
input:"MarketplaceCancelOrderInput"
},
@@ -1263,6 +1307,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceCreateRequest:{
data:"CreateRequestInput"
},
marketplaceCreateReturnClaim:{
data:"MarketplaceCreateReturnClaimInput"
},
marketplaceCreateShipment:{
data:"MarketplaceCreateShipmentInput"
},
@@ -1290,6 +1337,12 @@ export const AllTypesProps: Record<string,any> = {
marketplaceRejectOffer:{
input:"MarketplaceRejectOfferInput"
},
marketplaceRejectReturnAtVisit:{
data:"MarketplaceRejectReturnAtVisitInput"
},
marketplaceRejectReturnRemote:{
data:"MarketplaceRejectReturnRemoteInput"
},
marketplaceRemoveAvailableCategories:{
input:"RemoveAvailableCategoriesInput"
},
@@ -1888,6 +1941,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceListPendingOffers:{
input:"MarketplaceListPendingOffersInput"
},
marketplaceListReturnClaimsByBraname:{
data:"MarketplaceListReturnClaimsByBranameInput"
},
marketplaceListShipments:{
data:"MarketplaceListShipmentsInput"
},
@@ -1898,6 +1954,12 @@ export const AllTypesProps: Record<string,any> = {
marketplaceRequiredAttributes:{
data:"GetRequiredAttributesInput"
},
marketplaceReturnClaim:{
},
marketplaceReturnClaimSignablePayload:{
data:"MarketplaceReturnClaimSignablePayloadInput"
},
marketplaceSearchAttributes:{
input:"SearchAttributesInput"
},
@@ -4167,6 +4229,61 @@ export const ReturnTypes: Record<string,any> = {
requestsByCategory:"MarketplaceCategoryRequestCount",
totalRequests:"Int"
},
MarketplaceReturnClaim:{
actual_quantity:"Int",
coopname:"String",
created_at:"DateTime",
decision_log:"MarketplaceReturnClaimDecisionEntry",
defect_category:"MarketplaceReturnClaimDefectCategory",
delivery_braname:"String",
expected_resolution:"MarketplaceReturnClaimExpectedResolution",
fact_cost:"String",
id:"String",
ledger_snapshot:"MarketplaceReturnClaimLedgerSnapshot",
on_site_inspection:"MarketplaceReturnClaimOnSiteInspection",
order_hash:"String",
order_id:"String",
orderer_account:"String",
photos:"MarketplaceReturnClaimPhoto",
reason_text:"String",
request_hash:"String",
status:"MarketplaceReturnClaimStatus",
submretrn_tx_hash:"String",
supplier_account:"String",
updated_at:"DateTime"
},
MarketplaceReturnClaimDecisionEntry:{
at:"DateTime",
braname:"String",
by_chairman_account:"String",
comment:"String",
decision:"String",
stage:"String",
tx_hash:"String"
},
MarketplaceReturnClaimLedgerSnapshot:{
amount:"String",
at:"DateTime",
returned_quantity:"Int",
tx_hash:"String"
},
MarketplaceReturnClaimOnSiteInspection:{
at:"DateTime",
by_chairman_account:"String",
photos:"MarketplaceReturnClaimPhoto",
result_text:"String",
scanned_barcode:"String"
},
MarketplaceReturnClaimPhoto:{
content_hash:"String",
mime_type:"String",
uploaded_at:"DateTime",
url:"String"
},
MarketplaceReturnClaimResult:{
claim:"MarketplaceReturnClaim",
tx_hash:"String"
},
MarketplaceShipment:{
braname:"String",
coopname:"String",
@@ -4468,16 +4585,19 @@ export const ReturnTypes: Record<string,any> = {
marketplaceAcceptConsolidatedRequest:"MarketplaceConsolidatedRequestActionResult",
marketplaceAcceptCpp:"MarketplaceCppStatus",
marketplaceAcceptIndividualOrder:"MarketplaceSupplierOrderActionResult",
marketplaceAcceptReturnAtVisit:"MarketplaceReturnClaimResult",
marketplaceAddAvailableCategories:"MarketplaceAvailableCategory",
marketplaceAddAvailableCategoryTypes:"MarketplaceAvailableCategory",
marketplaceAddToWhitelist:"MarketplaceWhitelistEntry",
marketplaceApproveOffer:"MarketplaceOffer",
marketplaceApproveReturnVisit:"MarketplaceReturnClaimResult",
marketplaceCancelOrder:"MarketplaceCancelOrderResult",
marketplaceClearAvailableCategories:"Boolean",
marketplaceCreateAplReception:"MarketplaceAplReceptionResult",
marketplaceCreateOffer:"MarketplaceOffer",
marketplaceCreateOrder:"MarketplaceCreateOrderResult",
marketplaceCreateRequest:"MarketplaceRequest",
marketplaceCreateReturnClaim:"MarketplaceReturnClaimResult",
marketplaceCreateShipment:"MarketplaceCreateShipmentResult",
marketplaceDeclineConsolidatedRequest:"MarketplaceConsolidatedRequestActionResult",
marketplaceDeclineIndividualOrder:"MarketplaceSupplierOrderActionResult",
@@ -4487,6 +4607,8 @@ export const ReturnTypes: Record<string,any> = {
marketplaceLabelInventory:"MarketplaceLabelInventoryResult",
marketplaceOpenIssuance:"MarketplaceIssuanceResult",
marketplaceRejectOffer:"MarketplaceOffer",
marketplaceRejectReturnAtVisit:"MarketplaceReturnClaimResult",
marketplaceRejectReturnRemote:"MarketplaceReturnClaimResult",
marketplaceRemoveAvailableCategories:"Boolean",
marketplaceRemoveAvailableCategoryTypes:"Boolean",
marketplaceRemoveFromWhitelist:"Boolean",
@@ -5117,8 +5239,10 @@ export const ReturnTypes: Record<string,any> = {
marketplaceListMyOffers:"MarketplaceOfferPaginationResult",
marketplaceListMyOrders:"MarketplaceOrderPaginationResult",
marketplaceListMyReadyToReceive:"MarketplaceOrder",
marketplaceListMyReturnClaims:"MarketplaceReturnClaim",
marketplaceListOutgoingPaymentsAsSupplier:"MarketplaceOutgoingPaymentRequest",
marketplaceListPendingOffers:"MarketplaceOfferPaginationResult",
marketplaceListReturnClaimsByBraname:"MarketplaceReturnClaim",
marketplaceListShipments:"MarketplaceShipment",
marketplaceListSupplierOrders:"MarketplaceOrderPaginationResult",
marketplaceListWhitelist:"MarketplaceWhitelistEntry",
@@ -5126,6 +5250,8 @@ export const ReturnTypes: Record<string,any> = {
marketplaceOnboardingState:"MarketplaceOnboardingState",
marketplaceRegistrationOfferStatus:"MarketplaceRegistrationOfferStatus",
marketplaceRequiredAttributes:"MarketplaceAttribute",
marketplaceReturnClaim:"MarketplaceReturnClaim",
marketplaceReturnClaimSignablePayload:"GeneratedDocument",
marketplaceSearchAttributes:"MarketplaceAttribute",
marketplaceSearchDictionaryValues:"MarketplaceDictionaryValue",
marketplaceSearchRequests:"MarketplaceRequest",
File diff suppressed because it is too large Load Diff