Эпик 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:
@@ -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!]!
|
||||
|
||||
|
||||
+142
@@ -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;
|
||||
}
|
||||
+10
@@ -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'],
|
||||
|
||||
+479
@@ -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;
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
|
||||
+25
@@ -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 {}
|
||||
|
||||
+104
@@ -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,
|
||||
};
|
||||
}
|
||||
+243
@@ -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 };
|
||||
}
|
||||
}
|
||||
+155
@@ -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 {
|
||||
|
||||
+103
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
+834
@@ -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;
|
||||
+88
@@ -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;
|
||||
}
|
||||
}
|
||||
+165
@@ -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;
|
||||
}
|
||||
+59
@@ -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');
|
||||
|
||||
+82
@@ -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>;
|
||||
}
|
||||
+85
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -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);
|
||||
}
|
||||
}
|
||||
+104
@@ -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;
|
||||
}
|
||||
+32
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+14
@@ -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 {}
|
||||
|
||||
@@ -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
+132
@@ -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';
|
||||
+256
@@ -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>
|
||||
+190
@@ -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>
|
||||
+177
@@ -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';
|
||||
+194
@@ -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>
|
||||
+160
@@ -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>
|
||||
+345
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user