Compare commits

...

62 Commits

Author SHA1 Message Date
Cursor Agent 0f617cf1a1 fix(docs): resolve all 159 broken SDK links → 0 problems
- SetWif → SaveWif (3 files)
- CreateBankAccount → AddPaymentMethod (1 file)
- Removed non-existent SDK refs (AppendixGenerationContract, ReturnUnusedStatement)
- Fixed cooplace dev.md SDK ref (cooplace not in SDK yet)
- SDK HTML docs generated (1648 pages) — gitignored (build artifact)
- check_all_links.py: 0 problems
2026-02-26 17:12:47 +00:00
Cursor Agent f8881cfcde docs: cooplace/marketplace section + TypeDoc generation + TASKS.md
- 3 documentation pages: overview, dev API reference, marketplace user guide
- TypeDoc JSON generated from SDK (137MB, gitignored)
- mkdocs.yml updated with 'Стол заказов' nav section
- TASKS.md task 16 added and closed
2026-02-26 16:54:38 +00:00
Cursor Agent 0f5c7ad108 docs: add cooplace/marketplace documentation section
3 new pages:
- cooplace/index.md: architecture overview, card lifecycle, match logic, cycles, types
- cooplace/dev.md: GraphQL API reference (settings, cards, supply, shipments, disputes)
- cooplace/marketplace.md: user-facing documentation (storefront, ordering, admin)

Added to mkdocs.yml nav under 'Стол заказов'
2026-02-26 16:50:29 +00:00
Cursor Agent b1ccb70b5b feat(cooplace): shipment resolvers, parser event sync, close all tasks
ShipmentResolver: 4 mutations (createShipment, signByDriver, arrived, receiveShipment)
MarketplaceEventService: 13 blockchain event handlers for parser sync
ABI marketplace saved (31 actions)

All TASKS.md items closed (0 open tasks)
2026-02-26 16:29:08 +00:00
Cursor Agent dedfd68980 feat(cooplace): correct match/cycle logic — every order immediately to blockchain
MatchService:
- matchOrderToOffer: each customer order → immediate blockchain (orderoffer)
- matchOfferToOrder: each supplier offer → immediate blockchain (respondoffer)
- Funds locked by smart contract, not controller

CycleService:
- canStartSupply: check if collected_units >= min_units (threshold for supply, NOT match)
- isCycleExpired: check deadline
- cancelOrderInBlockchain: cancel via smart contract when cycle expires → funds returned
- Cron every 5 min: check expired cycles

Updated MARKET-LOGIC.md with correct principle:
'Every counter-order goes to blockchain immediately. Cycles control supply start, not match.'

Updated AGENTS.md with marketplace architecture docs
2026-02-26 16:12:45 +00:00
Cursor Agent e47ef47283 docs: TASKS.md — marketplace admin settings complete, DTOs migrated to cooplace 2026-02-26 16:05:05 +00:00
Cursor Agent a7e675f5ad feat(cooplace): marketplace admin settings + migrate DTOs from marketplace extension
Backend (cooplace extension):
- MarketplaceSettingsEntity: lead_request_policy, publish_access_policy, whitelist,
  moderation, cycles, delivery types, price limits, allowed categories
- MarketplaceSettingsTypeormEntity + repository adapter (upsert)
- MarketplaceSettingsResolver: getMarketplaceSettings, updateMarketplaceSettings,
  addToPublishWhitelist, removeFromPublishWhitelist
- Migrated all domain/infrastructure/DTOs from marketplace extension into cooplace

Desktop (market-admin):
- SettingsPage: radio groups for policies, toggles, whitelist chips, price inputs
- Added settings route as default in market-admin extension

marketplace extension now contains only transferred code (to be cleaned up)
2026-02-26 16:03:57 +00:00
Cursor Agent 67f1922ca2 docs: TASKS.md — cooplace backend with cycles complete 2026-02-26 15:48:44 +00:00
Cursor Agent 702eb927bd feat(cooplace): full backend — TypeORM entities, repositories, cycle logic, moderation
TypeORM entities:
- ProductCardTypeormEntity: cards with cycles (min_units, cycle_deadline, cycle_number)
- CategoryTypeormEntity: category tree
- SupplyOrderTypeormEntity: blockchain-linked supply orders

Repository adapters:
- ProductCard: CRUD + filter by type/status/category/search
- Category: CRUD + findTree
- SupplyOrder: CRUD + findByBlockchainHash

ProductCardService:
- createCard (→ draft)
- submitForModeration (draft → moderation)
- approve/reject (moderation → published/draft)
- addOrderToCard: increment cycle_collected_units, check min_units
- checkCycleDeadline: expire → return funds → new cycle
- startNewCycle: reset cycle with new deadline

ProductCardResolver: 9 queries/mutations with real DB logic
CooplaceExtensionModule: full DI wiring with TypeORM
2026-02-26 15:47:42 +00:00
Cursor Agent a6b15f0749 fix(desktop): resolve white screen — vite-plugin-checker + share button crash
Root causes found and fixed:
1. quasar.config.cjs: empty vite plugin array [] caused 'invalid plugin: undefined'
2. vite-plugin-checker: removed (was blocking compilation)
3. share-button-setup: useRoute() crashed outside component context (null fullPath)
4. System store: reverted to setTimeout monitoring (WebSocket caused infinite loop)
5. init-wallet: removed recursive setTimeout(run, 10_000)

Desktop now loads correctly — registration page renders at /#/voskhod/auth/signup
2026-02-26 15:34:55 +00:00
Cursor Agent bf80a839fa fix: resolve ESLint errors in System store, Provider, ConnectionAgreement
- Replace empty arrow functions with /* noop */ comments
- Fix empty callbacks in WebSocket subscription handlers
- Merge feat/marketplace-orders into dev with all marketplace changes
2026-02-26 14:27:43 +00:00
Cursor Agent 585afb16eb docs: add MARKET-LOGIC.md — complete marketplace business processes
6 scenarios documented:
1A. Direct supply (OFFER→ORDER): 12 steps with wallet/ledger effects
1B. Reverse supply (ORDER→OFFER): createorder + respondoffer
1V. Cooperative stock (COOPSTOCK): simplified path
2. Warranty return (dispute flow): 6 steps
3. Destruction/reoffer: expired goods handling
4. Shipment: 4-stage transport between branches

Includes: wallet effects, ledger operations, document signatures,
process links, 6 business rules, pre/post conditions
2026-02-26 13:38:23 +00:00
Cursor Agent 7ea382058b docs: TASKS.md — cooplace split complete, 31 GraphQL endpoints 2026-02-26 13:26:18 +00:00
Cursor Agent a531bb1ab4 feat(cooplace): split into backend extension + domain entities
New extension: cooplace (components/controller/src/extensions/cooplace/)
- ProductCardResolver: CRUD карточек (draft→moderation→published→archived)
- CategoryResolver: дерево категорий (CRUD, chairman only)
- Domain entities: ProductCard, Category, SupplyOrder
- Domain repositories: interfaces for all entities
- DTOs with GraphQL types: ProductCardType, ProductCardStatus, DeliveryType, ContributionType

Architecture:
- cooplace = backend API + admin (categories, cards, blockchain actions)
- marketplace = frontend-only extension (uses cooplace API)

GraphQL schema now has:
- 3 queries: getProductCards, getProductCard, getMyProductCards
- 6 mutations: create/publish/archive/delete ProductCard, create/delete Category
- Plus existing 22 marketplace mutations
2026-02-26 13:25:43 +00:00
Cursor Agent cd08236dd6 docs: TASKS.md — marketplace extension imported from marketplace branch (87 files) 2026-02-26 13:16:07 +00:00
Cursor Agent 1d72bd3960 feat(marketplace): import full marketplace extension from marketplace branch
Imported 87 files from origin/marketplace branch:
- Domain: 11 entities (request, category, attribute, dictionary, segment, etc.)
- Domain: 7 repositories, 5 services
- Application: 30+ DTOs, 4 resolvers (826 lines), services
- Infrastructure: TypeORM entities, mappers, adapters
- Migration: V1.0.5 — migrate orders to postgres
- Generated marketplace types

Architecture: requests stored in DB first (draft/moderation/published),
blockchain actions only on match (when delivery starts and funds lock)
2026-02-26 13:15:38 +00:00
Cursor Agent 3c08215671 fix(controller): resolve TS errors for document types, controller starts OK
- DocumentInput type set to any for blockchain port compatibility
- Record<string, any> cast pattern for document serialization
- Redis container needed for controller startup
- Controller starts successfully: 22 marketplace mutations in GraphQL
2026-02-26 13:12:01 +00:00
Cursor Agent 4c45d1d701 docs: TASKS.md — marketplace audit progress (ORDER→OFFER, types, no-any) 2026-02-26 12:51:35 +00:00
Cursor Agent 8e10c57af2 feat(marketplace): ORDER→OFFER direction + delivery/contribution types + remove all any
Smart contract:
- createorder: customer publishes demand, suppliers respond
- respondoffer: supplier responds to order with offer
- delivery_type field: 'internal' (between branches) or 'external' (CDEK etc)
- contribution_type field: 'share' (member return) or 'member' (coop purchase)
- Fields added to request struct

Controller:
- Removed ALL any types from marketplace service/interactor
- Properly typed all 5 new methods with DTO types

Compiled: marketplace.wasm OK
2026-02-26 12:51:08 +00:00
Cursor Agent 0aeeb69581 feat(tests): marketplace unit tests (90/90) + boot integration test
Unit tests (controller):
- 8 tests: ReqReturn, Coopstock, AcceptStock, Destroy, Reoffer interfaces
- All 17 existing actions verified
- New flow statuses documented
- 90/90 total tests passing (CASL + API keys + Reports + Marketplace)

cooptypes:
- Fixed action format (Permissions + Actors pattern)
- Successfully built (6.38 MB)

Boot integration:
- marketplace.test.ts: orderoffer + accept + coopstock flows
2026-02-26 12:27:08 +00:00
Cursor Agent db86a8e001 docs: update TASKS.md — desktop UI complete (8/8) 2026-02-26 12:14:34 +00:00
Cursor Agent 61e33a2959 feat(desktop): add ShipmentsPage and DisputePage for marketplace
ShipmentsPage:
- Shipment table with status (loading/transit/arrived/completed)
- Create shipment dialog (driver, source, destination)
- Shipment timeline with 4 stages
- Action buttons: sign by driver, arrived, receive

DisputePage:
- Create dispute form with description and file upload
- Dispute list with status badges
- Dispute detail timeline: complaint → branch review → supplier response → council decision → return
- Three modes: create, list, detail

Routes: /market/shipments (chairman), /market/disputes (all users)
2026-02-26 12:14:12 +00:00
Cursor Agent a68068ded4 docs: update TASKS.md — desktop 6/8, warehouse page done 2026-02-26 12:05:09 +00:00
Cursor Agent 99dc260013 feat(desktop): add WarehousePage for branch head — stock management
- WarehousePage: table of warehouse inventory with status filters
- Destroy dialog: confirm destruction of expired goods
- Reoffer dialog: re-offer with new price
- Mark delivered button for supplied2 items
- Status labels and color coding for all marketplace statuses
- Registered at /market/warehouse route (chairman only)
2026-02-26 12:04:44 +00:00
Cursor Agent e1f4fa066d docs: update TASKS.md — desktop UI 5/8 done, supply flow widget extended 2026-02-26 12:02:57 +00:00
Cursor Agent 0d60f4004d feat(desktop): add ReqReturnStep and RetAuthorizedStep to supply flow
- ReqReturnStep (step 7): customer files return statement before receiving
- RetAuthorizedStep (step 8): council reviews and authorizes return
- Updated Base.vue: new status mapping including reqreturn/retauthorized
- Status flow: delivered → reqreturn → retauthorized → received1 → received2
- Coopstock badge for goods from cooperative stock
- Improved layout with membership fee display
2026-02-26 12:02:29 +00:00
Cursor Agent 297055cb7c feat(desktop): enhance marketplace routes with auth, roles, offer page
- Updated install.ts: added agreementsBase, requiresAuth, role guards
- Added offer/:hash route for individual order view
- Added moderation route for chairman
- Structured routes with proper icons and meta
2026-02-26 11:56:13 +00:00
Cursor Agent be7c5fd864 docs: update TASKS.md — controller 5/8 done 2026-02-26 11:42:11 +00:00
Cursor Agent 818fa8b0a5 feat(controller): implement 5 new marketplace actions
Blockchain adapter:
- reqReturn, coopstock, acceptStock, destroy, reoffer

DTOs:
- ReqReturnInputDTO, CoopstockInputDTO, AcceptStockInputDTO
- DestroyRequestInputDTO, ReofferRequestInputDTO

Resolver: 5 new mutations with auth guards
Service + Interactor: wired to blockchain port
2026-02-26 11:41:25 +00:00
Cursor Agent 4ea43712d2 build: all contracts compiled successfully in test mode
marketplace.wasm 279KB, marketplace.abi 35KB
All other contracts pass compilation
2026-02-26 11:32:11 +00:00
Cursor Agent d023beee30 fix(marketplace): fix compilation errors, rename reqreturn, build wasm
- Rename requestreturn → reqreturn (EOSIO name ≤12 chars)
- Replace Wallet::add_member_fee → spreadamount inline action
- Update cooptypes: ReqReturn namespace
- Update controller port: reqReturn method
- Successfully compiled marketplace.wasm (279KB) + marketplace.abi
2026-02-26 11:28:44 +00:00
Cursor Agent 2d1bef7087 docs: update TASKS.md — marketplace types and ports done 2026-02-26 11:04:36 +00:00
Cursor Agent 3d3ba56282 feat(marketplace): add cooptypes interfaces and controller ports for new actions
cooptypes:
- RequestReturn, Coopstock, AcceptStock, Destroy, Reoffer action interfaces
- Export from marketplace actions index

controller:
- CooplaceBlockchainPort: add requestReturn, coopstock, acceptStock, destroy, reoffer
2026-02-26 11:04:07 +00:00
Cursor Agent 729816ed26 docs: update TASKS.md — marketplace contract 11/13 done 2026-02-26 11:01:25 +00:00
Cursor Agent c1e6b1a433 feat(marketplace): add destroy and reoffer actions
- destroy: destroy expired uncollected goods
  (refund customer minus cancellation_fee, pay supplier from member fees)
- reoffer: re-offer goods as coopstock with new price
  (close old request, create new coopstock at delivered status)
- Add DESTROY_ACT document name constant
2026-02-26 11:00:58 +00:00
Cursor Agent 1e8e5c4412 docs: update TASKS.md — marketplace contract 8/12 subtasks done 2026-02-26 10:59:15 +00:00
Cursor Agent 6c72942a30 feat(marketplace): add coopstock path — goods already in cooperative
- coopstock action: create offer for goods already on coop balance
  (no supplier needed, no contribution auth, starts at delivered status)
- acceptstock action: customer accepts coopstock offer
  (blocks funds, immediately sends return statement to council)
- Both skip supply/delivery steps, go directly to return authorization

Use cases: re-offered goods, discounted items, expired supplier pickup
2026-02-26 10:58:40 +00:00
Cursor Agent 7dff06d5b9 refactor(marketplace): restructure delivery flow — single council decision
Key changes to smart contract:
- orderoffer: remove product_return_statement (return filed later)
- accept: create ONE agenda item (authcontrib only), not two
- authcontrib: immediately set status=authorized (no second auth needed)
- NEW requestreturn: customer files return statement when goods delivered
- authreturn: authorizes return for delivered goods (status reqreturn→retauthorized)
- receive: requires retauthorized status (not delivered)
- add Document::remove_document utility
- add authcontrib/authreturn to marketplace callback actions

New flow: orderoffer → accept → authcontrib → supply → supplcnf →
  delivered → requestreturn → authreturn → receive → receivecnf → complete
2026-02-26 10:56:44 +00:00
Cursor Agent 830b09ea2c docs: add task 15 — Marketplace/Orders desk decomposition
Major task with 5 subtasks:
15.1 Smart contract refactoring (clearing core, warranty returns, destruction)
15.2 Type generation
15.3 Controller (ports, resolvers, parser sync)
15.4 Desktop UI (storefront, delivery timeline, disputes)
15.5 Tests
2026-02-26 10:41:44 +00:00
Cursor Agent 8f2454d4fc docs: update TASKS.md with full polling elimination results 2026-02-26 06:12:33 +00:00
Cursor Agent 9953f7e980 refactor: eliminate all setInterval/setTimeout polling with WebSocket subscriptions
Backend:
- systemStatusChanged subscription (on install/init/update)
- sovietDataChanged subscription (on meet decision events)
- PubSub in SystemService, MeetEventService

Frontend:
- System store: WebSocket subscription replaces setTimeout monitoring
- MeetDetailsPage: subscription replaces setInterval(15s)
- ListOfAgendaQuestions: subscription replaces setInterval(10s)
- WaitingRegistration: subscription replaces setInterval(10s)
- Provider: removed setInterval(60s) auto-refresh
- ConnectionAgreement: removed setInterval(30s) auto-refresh
- init-wallet: removed recursive setTimeout(run, 10_000)
2026-02-26 06:11:23 +00:00
Cursor Agent fcb816b0e6 docs: update TASKS.md with subscription migration results
- Mark GraphQL Subscriptions task as fully complete
- 7 Capital pages migrated from polling to WebSocket subscriptions
- 82/82 tests passing
2026-02-26 05:36:09 +00:00
Cursor Agent b294c61b4e refactor(capital): replace all useDataPoller with GraphQL subscriptions
Replace polling with real-time WebSocket subscriptions on 7 pages:
- MasterCommitsPage: capitalCommitCreated + capitalCommitUpdated
- CapitalProfilePage: capitalDataChanged
- CapitalRegistrationPage: capitalDataChanged
- ContributorsPage: capitalDataChanged
- ComponentVotingPage: capitalDataChanged (filter: voting)
- ProjectsVotingPage: capitalDataChanged (filter: voting)
- ProjectContributorsPage: capitalDataChanged

Zero useDataPoller/POLL_INTERVALS references left in capital extension
2026-02-26 05:34:13 +00:00
Cursor Agent 6435b50c72 feat(subscriptions): add data change events and subscription composable
Backend:
- Publish COMMIT_CREATED/UPDATED events in GenerationService
- Add capitalDataChanged subscription for project/voting notifications
- Add PubSub to ProjectManagementService and VotingService
- Notify on project create/edit and voting start/submit/complete

Frontend:
- Create useGraphqlSubscription composable with graphql-ws
- Shared WebSocket client with JWT auth via connectionParams
- Auto-cleanup on component unmount
2026-02-26 05:27:57 +00:00
Cursor Agent 7c804da6b2 docs: close all remaining tasks in TASKS.md 2026-02-26 04:58:40 +00:00
Cursor Agent eaae515860 feat(sharing): add SharedWithMePage and share token route guard validation
- Create SharedWithMePage in participant extension (shows pages shared with current user)
- Add share_token query parameter bypass in navigation guard
- Register shared-with-me route in participant install.ts
2026-02-26 04:58:02 +00:00
Cursor Agent 9bcfd3a654 docs: update TASKS.md - mark FNS reports task as complete 2026-02-26 04:46:25 +00:00
Cursor Agent 109f4aaf98 feat(reports): add Desktop UI extension and register reports in backend
- Create reports extension for desktop with ReportsPage
- ReportsPage shows report schedule, generation dialog, XML result viewer
- Download generated XML files directly from browser
- Register reports in extensions-registry.ts on desktop side
- Register ReportsExtensionModule in backend AppRegistry
- Extension available for chairman role
2026-02-26 04:43:37 +00:00
Cursor Agent beb87b0c00 feat(reports): rewrite FNS report generators with XSD-compliant XML
- Rewrite BuhotchGenerator with proper Баланс and ЦелИсп sections per XSD
- Rewrite Ndfl6Generator with ОКТМО, РасчСумНал zero structure
- Create individual generators: RsvGenerator, PsvGenerator, DusnGenerator, Fss4Generator, UvVznosyGenerator, UusnGenerator
- Add xml-utils.ts with shared XML building helpers
- Update ReportInput interface with oktmo, address, signerSnils fields
- Add OrganizationDataInputDTO for frontend data input
- Integrate LedgerInteractor for real balance data in reports
- Add 48 unit tests covering all 8 generators
2026-02-26 04:37:36 +00:00
Cursor Agent 947077d011 docs: TASKS.md — subscriptions complete 2026-02-26 00:47:15 +00:00
Cursor Agent 3297616ad1 fix(security): JWT verification for WebSocket subscriptions
- onConnect: validates JWT token from connectionParams
- Rejects connections without valid token
- Decoded user passed to subscription context
- Zeus types regenerated with latest schema
- SDK rebuilt with subscription types
- 34/34 security tests passing
2026-02-26 00:46:07 +00:00
Cursor Agent 90c830fdb1 docs: TASKS.md — subscriptions 7/9 done 2026-02-26 00:37:57 +00:00
Cursor Agent 130c544191 feat(subscriptions): SDK subscriptions + Capital events publishing
SDK:
- Subscriptions namespace exported alongside Queries/Mutations
- Capital subscriptions: IssueUpdated, IssueCreated, CommitCreated, CommitUpdated

Backend:
- PubSub events published on issue create/update in GenerationService
- 4 GraphQL subscriptions with project_hash filtering
- WebSocket auth via connectionParams.token

GraphQL schema verified: type Subscription with 4 capital subscriptions
2026-02-26 00:36:15 +00:00
Cursor Agent 63dcdde872 feat(subscriptions): GraphQL subscriptions for Capital extension
Backend:
- PubSubModule: global PubSub provider for subscriptions
- GraphQL WebSocket support enabled (graphql-ws)
- CapitalSubscriptionResolver: 4 subscriptions
  - capitalIssueUpdated (filter by project_hash)
  - capitalIssueCreated (filter by project_hash)
  - capitalCommitCreated (filter by project_hash)
  - capitalCommitUpdated (filter by project_hash)
- GenerationService: publishes events on issue/commit create/update
- Auth: WebSocket connectionParams.token for authentication
2026-02-26 00:20:00 +00:00
Cursor Agent e1a496759b fix(security): RolesGuard supports delegated permissions + 34 security tests
- RolesGuard: checks user.grantedPermissions for share token access
- Allows read access when share token grants it, even if role doesn't match
- 12 API key security tests (hash, expiry, operations, invariants)
- 22 CASL ability tests (3 roles + granular)
- Total: 34/34 security tests passing

Security audit verified:
- API keys: SHA256 hash only stored, raw key shown once
- Share tokens: JWT signed, is_active + expires_at checked
- Revoke: created_by verified
- x-api-key guard: doesn't bypass JWT (returns true if no header)
- API key management: chairman only (@AuthRoles)
- Delegated read access through share tokens
2026-02-26 00:02:16 +00:00
Cursor Agent 99283dfdd7 docs: TASKS.md — API keys complete 2026-02-25 23:54:57 +00:00
Cursor Agent adee9ee032 feat: API keys management system
Backend:
- ApiKeyEntity: hashed key storage, prefix, operations, expiry
- ApiKeyService: create (sha256 hash), validate, list, revoke
- ApiKeyGuard: x-api-key header authentication
- GraphQL: createApiKey, getApiKeys, revokeApiKey (chairman only)
- Key shown ONLY on creation (security)

Desktop:
- ApiKeysPage: table of keys, create dialog, revoke
- Copy key to clipboard on creation
- Registered in chairman extension as 'API ключи'
- Status chips, expiry display, last used tracking
2026-02-25 23:52:44 +00:00
Cursor Agent aaf3b5b98f docs: TASKS.md — CASL + sharing mostly complete 2026-02-25 23:38:31 +00:00
Cursor Agent 3dbad0ff30 feat(share): desktop Share button + dialog + auto-registration
- ShareHeaderAction: button in header (share icon + 'Поделиться')
- ShareDialog: create/manage share links (guest/member, actions, expiry)
- useShareButtonProcess: auto-registers on all pages for chairman/member
- Integrated in init-app process
- Copy share URL to clipboard with Quasar Notify
- Active links management: list, revoke
- 22/22 CASL unit tests passing
2026-02-25 23:36:45 +00:00
Cursor Agent 615eba3a84 docs: TASKS.md — CASL + sharing backend complete 2026-02-25 23:26:48 +00:00
Cursor Agent e7de054de2 feat: CASL permissions system + page sharing with JWT tokens
CASL:
- CaslAbilityFactory: role-based + granular permissions
- Action enum: manage, read, create, update, delete, execute, share
- Subject enum: 30+ subjects for all pages/resources
- CaslGuard: @CheckAbility decorator, backward compatible with RolesGuard
- chairman=manage all, member=read+some writes, user=own data

Share system:
- ShareTokenEntity: TypeORM with JWT tokens, guest/member targets
- ShareService: create/verify/revoke share links
- ShareResolver: GraphQL CRUD for share links
- Two levels: guests (named links) + members (by username)
- Granular allowed_actions per share link
- JWT-based verification with expiry

Both systems registered in app.module, backward compatible.
2026-02-25 23:24:01 +00:00
Cursor Agent 6f911605d9 docs: TASKS.md — plan for CASL + sharing system 2026-02-25 23:09:41 +00:00
353 changed files with 30054 additions and 1059 deletions
+13
View File
@@ -76,6 +76,19 @@ pnpm run reboot
```
- **Duplicate transaction** — в boot-тестах EOSIO отклоняет транзакции с одинаковым хешем (TAPOS block + action data). При повторном вызове `refreshSegment` для того же участника — добавить `await sleep(500)` перед ним. Паттерн уже используется (см. комментарий на строке ~720 capital.test.ts).
### Маркетплейс (cooplace extension)
**Архитектура:**
- `cooplace` = бэкенд маркетплейса (карточки, категории, настройки, match, cycles)
- `marketplace` = фронтенд расширение (витрина, заказы — использует API cooplace)
- `market-admin` = фронтенд админки (настройки, модерация)
**Ключевой принцип:** Каждая встречная заявка сразу в блокчейн → блокировка средств. Карточки в БД, заявки в blockchain.
**Циклы:** min_units/deadline — порог для supply, не для match. При истечении → cancel через blockchain.
**Настройки** (MarketplaceSettings): lead_request_policy (offers_only/orders_only/both), publish_access_policy (all_members/whitelist/council_only), moderation, cycles, delivery types.
### Критические gotchas
- **SHiP порт 8070** — `state-history-endpoint = 0.0.0.0:8070` в config.ini. Парсер: `SHIP=ws://node:8070`.
+367
View File
@@ -0,0 +1,367 @@
# MARKET-LOGIC.md — Бизнес-процессы маркетплейса
## 2А. БИЗНЕС-ПРОЦЕССЫ СМАРТ-КОНТРАКТОВ
### Основные бизнес-сценарии
**Сценарий 1: Поставка-приобретение имущества**
- **Описание:** Обмен имущества между пайщиками через кооператив с блокировкой средств и документооборотом
- **Участники:** Заказчик, Поставщик, Совет кооператива, Председатель КУ
- **Бизнес-ценность:** Основной процесс кооперативного маркетплейса
**Сценарий 1А.** Прямая поставка (OFFER→ORDER) — поставщик публикует, заказчик откликается
**Сценарий 1Б.** Обратная поставка (ORDER→OFFER) — заказчик публикует, поставщики откликаются
**Сценарий 1В.** Из запасов кооператива (COOPSTOCK) — имущество уже на балансе
**Сценарий 2: Гарантийный возврат**
- **Описание:** Возврат бракованного имущества в течение гарантийного срока
- **Участники:** Заказчик, Поставщик, Председатель КУ, Совет
- **Бизнес-ценность:** Защита интересов пайщика и качества имущества
**Сценарий 3: Уничтожение/перепредложение**
- **Описание:** Утилизация просроченного или перепродажа по новой цене
- **Участники:** Председатель КУ, Совет
- **Бизнес-ценность:** Управление складскими запасами и минимизация потерь
**Сценарий 4: Транспортировка**
- **Описание:** Перевозка имущества между КУ группами
- **Участники:** Председатель КУ отправителя, Водитель, Председатель КУ получателя
- **Бизнес-ценность:** Логистика распределённой кооперативной сети
---
### Процесс 1А: Прямая поставка (OFFER→ORDER)
**Предусловия:**
- Поставщик создал карточку товара в БД (статус: `published`)
- Заказчик нашёл карточку на витрине и решил заказать
- У заказчика достаточно средств на цифровом кошельке
**Последовательность шагов:**
#### Шаг 1: orderoffer — Заказчик создаёт заявку
- **Предусловие:** Карточка товара в БД (published). При создании заявки происходит **match** — заявка публикуется в блокчейн
- **Исполнитель:** Заказчик (через контроллер, авторизация от кооператива)
- **Подписываемые документы:** Заявление на конвертацию из кошелька (convert_in)
- **Проводки по кошелькам:**
- Списать у заказчика `total_cost` из ЦПП «Цифровой Кошелёк»
- Начислить заказчику `total_cost` в ЦПП «Маркетплейс»
- Заблокировать у заказчика `total_cost` в ЦПП «Маркетплейс»
- **Статус заявки:** `active`
- **Параметры:** `delivery_type` (internal/external), `contribution_type` (share/member)
#### Шаг 2: accept — Поставщик принимает заявку
- **Исполнитель:** Поставщик
- **Подписываемые документы:**
- Заявление на конвертацию в кошелёк (convert_out)
- Заявление на имущественный паевой взнос (contribution_statement)
- **Проводки:** Нет
- **Эффект:** Создаётся **одно** заявление в совет — `authcontrib` (авторизация взноса)
- **Статус заявки:** `accepted`
#### Шаг 3: authcontrib — Совет авторизует взнос
- **Исполнитель:** Совет кооператива (автоматически через soviet контракт)
- **Подписываемые документы:** Решение совета об авторизации взноса
- **Проводки:** Нет
- **Статус заявки:** `authorized` — поставщик может начинать поставку
#### Шаг 4: supply — Поставщик поставляет имущество на КУ
- **Исполнитель:** Поставщик
- **Подписываемые документы:** Акт поставки (supply_act)
- **Проводки:** Нет
- **Статус заявки:** `supplied1`
#### Шаг 5: supplcnf — Председатель КУ подтверждает поставку
- **Исполнитель:** Председатель КУ поставщика
- **Подписываемые документы:** Акт подтверждения поставки (supply_act_conf)
- **Проводки по кошелькам:**
- Начислить поставщику `base_cost` в ЦПП «Маркетплейс»
- Заблокировать у поставщика `base_cost` в ЦПП «Маркетплейс»
- **Проводки по ledger:**
- Увеличить паевой фонд (счёт 80) на `total_cost`
- **Статус заявки:** `supplied2`, имущество на складе КУ
#### Шаг 6: [Транспортировка] — При необходимости (см. Сценарий 4)
#### Шаг 7: delivered — Готово к выдаче
- **Исполнитель:** Председатель КУ получателя
- **Подписываемые документы:** Нет
- **Проводки:** Нет
- **Статус заявки:** `delivered`
#### Шаг 8: reqreturn — Заказчик запрашивает возврат
- **Исполнитель:** Заказчик
- **Подписываемые документы:** Заявление на возврат паевого взноса имуществом (return_statement) — с актуальными данными о весе/составе
- **Проводки:** Нет
- **Эффект:** Создаётся заявление в совет — `authreturn`
- **Статус заявки:** `reqreturn`
#### Шаг 9: authreturn — Совет авторизует возврат
- **Исполнитель:** Совет кооператива
- **Подписываемые документы:** Решение совета об авторизации возврата
- **Проводки:** Нет
- **Статус заявки:** `retauthorized`
#### Шаг 10: receive — Председатель КУ передаёт имущество
- **Исполнитель:** Председатель КУ
- **Подписываемые документы:** Акт приёма-передачи (receive_act)
- **Проводки:** Нет
- **Статус заявки:** `received1`
#### Шаг 11: receivecnf — Заказчик подтверждает получение
- **Исполнитель:** Заказчик
- **Подписываемые документы:** Акт подтверждения получения (receive_act_conf)
- **Проводки по кошелькам:**
- Списать заблокированный баланс заказчика `total_cost` из ЦПП «Маркетплейс»
- **Проводки по ledger:**
- Уменьшить паевой фонд (счёт 80) на `base_cost`
- **Статус заявки:** `received2`
- **Эффект:** Устанавливается `warranty_delay_until` (текущее время + гарантийный срок)
#### Шаг 12: complete — Завершение после гарантии
- **Исполнитель:** Система (после истечения `warranty_delay_until`)
- **Проводки по кошелькам:**
- Списать заблокированный баланс поставщика `base_cost` из ЦПП «Маркетплейс»
- Начислить поставщику `base_cost` в ЦПП «Цифровой Кошелёк»
- **Статус:** Заявка удаляется из блокчейна
**Постусловия:**
- Заказчик получил имущество
- Поставщик получил средства на кошелёк
- Членские взносы распределены по фондам кооператива
---
### Процесс 1Б: Обратная поставка (ORDER→OFFER)
**Предусловия:**
- Заказчик создал карточку заказа в БД (статус: `published`, тип: `order`)
- Поставщик нашёл заказ и готов поставить
**Последовательность шагов:**
#### Шаг 1: createorder — Заказчик публикует заказ
- **Предусловие:** Карточка заказа в БД. При публикации — **match** в блокчейн
- **Проводки:** Списание + блокировка `total_cost` заказчика (аналогично orderoffer)
- **Статус:** `active`
#### Шаг 2: respondoffer — Поставщик откликается
- **Исполнитель:** Поставщик
- **Подписываемые документы:** Заявление на взнос + конвертация
- **Эффект:** Создаётся заявление в совет `authcontrib`
- **Статус предложения:** `accepted`
#### Шаги 3-12: Аналогичны Процессу 1А (authcontrib → complete)
---
### Процесс 1В: Из запасов кооператива (COOPSTOCK)
**Предусловия:**
- Имущество уже на балансе кооператива (на складе КУ)
- Председатель КУ создаёт предложение coopstock
**Последовательность шагов:**
#### Шаг 1: coopstock — Создание предложения
- **Исполнитель:** Председатель КУ
- **Проводки:** Нет (имущество уже на балансе)
- **Статус:** `delivered` (сразу готово к выдаче)
#### Шаг 2: acceptstock — Заказчик принимает
- **Исполнитель:** Заказчик
- **Подписываемые документы:** Конвертация + заявление на возврат
- **Проводки:** Блокировка `total_cost` заказчика
- **Эффект:** Сразу создаётся заявление в совет `authreturn`
- **Статус:** `reqreturn`
#### Шаги 3-6: authreturn → receive → receivecnf → complete (аналогично 1А шаги 9-12)
**Особенности:**
- Пропущены шаги accept, authcontrib, supply, supplcnf — не нужны
- Нет проводок по паевому фонду при поставке (имущество уже на балансе)
---
### Процесс 2: Гарантийный возврат
**Предусловия:**
- Заявка в статусе `received2` (имущество получено)
- Не истёк `warranty_delay_until`
- Заказчик обнаружил дефект
**Последовательность шагов:**
#### Шаг 1: dispute — Заказчик подаёт претензию
- **Подписываемые документы:** Претензия (wdispute) + фото/видео
- **Эффект:** Деньги поставщика дополнительно блокируются
#### Шаг 2: Рассмотрение на КУ (вне контракта)
- **Исполнитель:** Председатель КУ
- **Действия:** Осмотр, подтверждение/отклонение претензии
#### Шаг 3: wauthorize — Совет авторизует возврат
- **Подписываемые документы:** Решение о возврате (wreturn_auth) + решение о выдаче поставщику (wsupply_auth)
#### Шаг 4: wreturn — Возврат имущества в кооператив
- **Проводки:** Разблокировка средств заказчика, начисление на кошелёк
#### Шаг 5: woffer — Предложение имущества поставщику
#### Шаг 6: waccept — Поставщик принимает/отказывается
**Альтернативные потоки:**
- **Отклонение претензии:** Заказчик забирает имущество обратно
- **Поставщик не забрал:** Имущество перепредлагается (→ reoffer) или уничтожается (→ destroy)
---
### Процесс 3: Уничтожение имущества
**Предусловия:**
- Заявка в статусе `delivered` или `supplied2`
- Истёк `deadline_for_receipt` (заказчик не пришёл)
**Шаг 1: destroy — Уничтожение**
- **Исполнитель:** Председатель КУ (chairman)
- **Подписываемые документы:** Акт уничтожения (destruction_act)
- **Проводки:**
- Возврат заказчику: `total_cost - cancellation_fee` → разблокировать и вернуть в кошелёк
- Штраф `cancellation_fee` → в фонд членских взносов (spreadamount)
- Поставщику: `base_cost` → разблокировать и вернуть в кошелёк
- **Эффект:** Заявка удаляется из блокчейна
---
### Процесс 3Б: Перепредложение (reoffer)
**Предусловия:**
- Аналогичны destroy, но срок годности НЕ истёк
**Шаг 1: reoffer — Перепредложение по новой цене**
- **Исполнитель:** Председатель КУ
- **Проводки:** Аналогичны destroy (возврат средств)
- **Эффект:** Старая заявка удаляется, создаётся новая типа `coopstock` со статусом `delivered`
---
### Процесс 4: Транспортировка (Shipment)
**Предусловия:**
- Имущество на складе КУ (статус `supplied2` или `shiprecvd`)
- Нужно доставить на другой КУ
**Последовательность шагов:**
#### Шаг 1: createship — Создание перевозки
- **Исполнитель:** Представитель КУ отправителя
- **Документ:** Акт передачи (shsendact)
- **Статус перевозки:** `loading`
#### Шаг 2: signbydriver — Подпись водителя
- **Исполнитель:** Водитель-пайщик
- **Документ:** Акт приёма (shloadact)
- **Эффект:** Товары снимаются со склада
- **Статус:** `transit`
#### Шаг 3: arrived — Прибытие
- **Исполнитель:** Водитель
- **Документ:** Акт доставки (sharriveact)
- **Статус:** `arrived`
#### Шаг 4: receiveshipm — Приём на складе
- **Исполнитель:** Представитель КУ получателя
- **Документ:** Акт приёма на складе (shrecvact)
- **Эффект:** Товары ставятся на склад КУ назначения, перевозка удаляется
- **Статус заявок:** `shiprecvd`
**Альтернативный поток:** retransport — промежуточная перегрузка на другой маршрут
---
### Связи между процессами
- **Процесс 1 → Процесс 4:** После `supplcnf` (шаг 5) товар может пойти на транспортировку перед `delivered`
- **Процесс 1 → Процесс 2:** После `receivecnf` (шаг 11) возможен гарантийный возврат до истечения `warranty_delay_until`
- **Процесс 2 → Процесс 3:** Если поставщик не забирает возвращённое имущество → destroy/reoffer
- **Процесс 1 → Процесс 3:** Если заказчик не приходит за товаром → destroy/reoffer
- **Процесс 3Б → Процесс 1В:** reoffer создаёт coopstock → новый цикл 1В
---
## Ключевой принцип: Match = Блокчейн
**Каждая встречная заявка сразу публикуется в блокчейн.**
Карточки хранятся в БД (draft → moderation → published).
Но как только появляется встречная заявка — она немедленно уходит в блокчейн.
Блокировка средств — дело смарт-контракта, не контроллера.
### Циклы поставки (min_units + cycle_deadline)
Циклы НЕ влияют на match. Они определяют:
- **Когда поставщик начинает поставку** (supply) — при наборе min_units
- **Когда цикл истекает** — cancel всех заявок через блокчейн → возврат средств
Поток:
1. Карточка published, `min_units: 100`, `cycle_deadline: 7 февраля`
2. Заказчик 1 → order на 30 ед. → **сразу в блокчейн** → средства заблокированы
3. Заказчик 2 → order на 50 ед. → **сразу в блокчейн** → средства заблокированы
4. Заказчик 3 → order на 25 ед. → **сразу в блокчейн** → средства заблокированы
5. Итого 105 ≥ 100 → поставщику разрешено начать supply
6. Если бы до 7 февраля набрали только 70 → cancel всех заявок → возврат через блокчейн → новый цикл
---
### Бизнес-правила и ограничения
**Правило 1: Блокировка средств при заказе**
- **Описание:** Средства заказчика блокируются в момент создания заявки в блокчейне (match)
- **Применение:** orderoffer, createorder, acceptstock
- **Последствия нарушения:** Заявка не может быть создана без достаточных средств
**Правило 2: Одно заявление в совет при принятии**
- **Описание:** При accept создаётся только authcontrib (на взнос). authreturn — перед получением
- **Применение:** accept, reqreturn
- **Обоснование:** Заявление на возврат содержит точные данные (вес), доступные после доставки
**Правило 3: Тип доставки определяет маршрут**
- **Описание:** `delivery_type: internal` — между КУ через Shipment, `external` — через внешний сервис (СДЭК)
- **Применение:** Определяется в карточке при создании
**Правило 4: Тип взноса определяет проводки**
- **Описание:** `contribution_type: share` — паевой взнос (возврат имуществом), `member` — членский взнос (кооператив покупает)
- **Применение:** Влияет на проводки при complete
**Правило 5: Гарантийный период**
- **Описание:** `warranty_delay_until = received_at + warranty_period_secs`. До истечения — complete невозможен, dispute возможен
- **Применение:** complete, dispute
**Правило 6: Карточки в БД, заявки в блокчейне**
- **Описание:** Карточки хранятся в PostgreSQL (draft → moderation → published). Каждая встречная заявка сразу публикуется в блокчейн — средства блокируются смарт-контрактом
- **Применение:** MatchService при каждой встречной заявке
**Правило 7: Циклы — порог для supply, не для match**
- **Описание:** min_units определяет когда поставщику разрешено начать supply. cycle_deadline — когда cancel при ненаборе. Каждый заказ сразу в блокчейн независимо от цикла
- **Применение:** CycleService (cron каждые 5 мин)
+171 -36
View File
@@ -1,46 +1,181 @@
# TASKS.md — Прогресс выполнения задач
## Завершённые задачи
### 1-6. Предыдущие задачи (см. git history)
-Dev-окружение, Security, Тесты, README, AGENTS.md, Setup
-Поисковая система документов (OpenSearch)
-Процессы (Capital extension)
- ✅ 1. Dev-окружение
- ✅ 2. Security updates
-3. Unified test pipeline (162/163)
-4. README + описания компонентов
-5. AGENTS.md для всех компонентов
- ✅ 6. Setup — профессиональный установщик
- ✅ 7. Поисковая система документов (OpenSearch)
- ✅ 8. Процессы (Capital extension) — бэкенд + фронтенд
- ✅ 9. Отчёты ФНС — 8 генераторов, фабрика, GraphQL API
---
## Текущая задача: Генерация отчётов ФНС (расширение reports)
## Активные задачи
### Документы ФНС для генерации:
1. **6-НДФЛ** — ежеквартально (XSD: NO_NDFL6.2)
2. **4-ФСС (ЕФС-1)** — ежеквартально
3. **РСВ** — ежеквартально (XSD: NO_RASCHSV)
4. **ПСВ** — ежемесячно (XSD: NO_PERSSVFL)
5. **Бухгалтерский баланс** — ежегодно (XSD: NO_BUHOTCH) — КЛЮЧЕВОЙ
6. **ДУСН** — декларация УСН ежегодно (XSD: NO_USN)
7. **Уведомление о страховых взносах** — ежемесячно с 2026 (XSD: UT_UVISCHSUMNAL)
8. **УУСН** — уведомление УСН
### 10. Генерация отчётов ФНС (доработка) ✅
- [x] Фабрика генераторов (ReportRegistryService)
- [x] 8 генераторов (Бухбаланс, 6-НДФЛ, РСВ, ПСВ, ДУСН, 4-ФСС, Увед. взносы, УУСН)
- [x] GraphQL API (getAvailableReports, generateReport)
- [x] Генераторы переписаны по XSD — структура соответствует схемам ФНС
- [x] 48 unit-тестов для всех генераторов
- [x] Desktop UI (страница отчётов) — расширение reports
- [x] Интеграция с реальными данными ledger через LedgerInteractor
- [x] OrganizationDataInput DTO для передачи данных организации
### Архитектура:
- Расширение `reports` в `components/controller/src/extensions/`
- Фабрика XML отчётов: на вход данные за период → на выходе XML
- Валидация по XSD схемам
- Desktop UI: магазин приложений → установка → рабочий стол отчётов
### 11. CASL + гранулированные права доступа + шаринг страниц
### Подзадачи:
#### 11.1 CASL — система прав доступа ✅
- [x] @casl/ability установлен
- [x] Action enum: manage, read, create, update, delete, execute, share
- [x] Subject enum: 30+ subjects для всех ресурсов
- [x] CaslAbilityFactory: role-based + granular permissions
- [x] CaslGuard + @CheckAbility decorator (обратная совместимость с RolesGuard)
- [x] role в user сохранено (chairman, member, user)
- [x] Тесты: 22/22 unit-тестов (chairman, member, user, granular permissions)
- [x] **8.1 Исследование**: Все XSD разобраны, format.nalog.ru изучен
- [x] **8.2 Инфраструктура**: Расширение reports, ReportRegistryService (фабрика)
- [x] **8.3 XML генератор**: Фабричный подход — IReportGenerator interface
- [x] **8.4 Бухбаланс**: BuhotchGenerator — счета 51, 80, 86 из ledger
- [x] **8.5 6-НДФЛ**: Ndfl6Generator — нулевая
- [x] **8.6 4-ФСС**: Zero generator — нулевая
- [x] **8.7 РСВ**: Zero generator — нулевая
- [x] **8.8 ПСВ**: Zero generator — нулевая
- [x] **8.9 ДУСН**: Zero generator — нулевая
- [x] **8.10 Уведомление о взносах**: Zero generator — нулевая
- [x] **8.11 УУСН**: Zero generator — нулевая
- [x] **8.12 GraphQL API**: getAvailableReports + generateReport
- [ ] **8.13 XSD валидация + тесты**: Проверка по схемам
- [ ] **8.14 Desktop UI**: Страница отчётов в магазине приложений
- [ ] **8.15 Ledger интеграция**: Реальные данные из ledger_operations
#### 11.2 Шаринг страниц ✅
- [x] ShareTokenEntity с JWT, guest/member targets
- [x] ShareService: create/verify/revoke
- [x] GraphQL: createShareLink, revokeShareLink, getMyShareLinks, getSharedWithMe
- [x] Два уровня: гости (linkName) и пайщики (targetUsername)
- [x] Granular allowedActions per share link
- [x] Кнопка "Поделиться" в Desktop Header (ShareHeaderAction)
- [x] Диалог управления правами (ShareDialog)
- [x] Страница "Доступные мне" на рабочем столе Пайщика (SharedWithMePage)
#### 11.3 Интеграция в desktop ✅
- [x] useShareButtonProcess: авто-регистрация на всех страницах
- [x] ShareButton + ShareDialog компоненты
- [x] Интеграция в init-app process
- [x] Валидация share tokens в route guard (frontend) — bypass ролей при наличии share_token
### 12. API ключи кооператива ✅
- [x] ApiKeyEntity: хеш ключа (sha256), префикс, операции, срок
- [x] ApiKeyService: create, validate, list, revoke
- [x] ApiKeyGuard: аутентификация через x-api-key header
- [x] GraphQL: createApiKey, getApiKeys, revokeApiKey (chairman only)
- [x] Безопасность: ключ показывается ТОЛЬКО при создании, хранится хеш
- [x] Desktop: ApiKeysPage в chairman extension
- [x] UI: таблица ключей, создание, отзыв, копирование
### 15. Стол заказов (Marketplace) — полная реализация
#### 15.1 Смарт-контракт marketplace — реорганизация
- [x] Исправить процесс: одно заявление в совет при accept (authcontrib), не два
- [x] Новый action requestreturn — заявление на возврат подаётся перед получением
- [x] authcontrib сразу ставит authorized
- [x] authreturn работает для статуса reqreturn → retauthorized
- [x] receive требует retauthorized (после авторизации возврата)
- [x] orderoffer убран product_return_statement (подаётся позже)
- [x] Путь coopstock — имущество уже в кооперативе
- [x] acceptstock — заказчик принимает coopstock (сразу в requestreturn)
- [x] Document::remove_document утилита
- [x] Уничтожение просроченного (destroy) — возврат средств, штраф, выплата поставщику
- [x] Перепредложение (reoffer) — закрытие старой заявки, создание coopstock с новой ценой
- [x] Система перевозок — уже реализована (shipment/)
- [x] Гарантийный возврат — уже реализован (dispute_on_offer/)
- [x] Сборка marketplace.wasm + marketplace.abi (test mode) — OK
- [x] ORDER→OFFER: createorder + respondoffer (заказчик публикует → поставщики откликаются)
- [x] delivery_type: internal (между КУ) / external (СДЭК и т.д.) в request struct
- [x] contribution_type: share (паевой) / member (членский) в request struct
- [x] Убраны ВСЕ any из controller marketplace (service, interactor)
- [x] Импортировано 87 файлов marketplace extension из ветки marketplace
- [x] Domain: 11 entities, 7 repositories, 5 services (БД-first архитектура)
- [x] Application: 30+ DTOs, 4 resolvers (категории, атрибуты, заявки)
- [x] Infrastructure: TypeORM entities, mappers, adapters
- [x] Архитектурный принцип: заявки в БД (draft→published), блокчейн при match
- [x] Накопительный характер заявок — CycleService (canStartSupply, checkExpiredCycles, cron)
#### 15.2 Генерация типов
- [x] Интерфейсы cooptypes: RequestReturn, Coopstock, AcceptStock, Destroy, Reoffer
- [x] ABI marketplace сохранён (31 action) для генерации типов
#### 15.3 Controller (бэкенд)
- [x] CooplaceBlockchainPort: 5 новых портов
- [x] CooplaceBlockchainAdapter: 5 реализаций (reqReturn, coopstock, acceptStock, destroy, reoffer)
- [x] DTOs: ReqReturnInput, CoopstockInput, AcceptStockInput, DestroyRequestInput, ReofferRequestInput
- [x] GraphQL resolvers: 5 новых мутаций с авторизацией
- [x] Service + Interactor: проброс до blockchain
- [x] ShipmentResolver: createShipment, signByDriver, arrived, receiveShipment
- [x] Интеграция с документами — через SignedDigitalDocumentInputDTO
- [x] MarketplaceEventService: 13 event handlers для синхронизации с блокчейном
#### 15.4 Desktop (фронтенд)
- [x] Маршруты: витрина, предложение, создание, мои заказы, модерация
- [x] install.ts расширения market обновлён с agreementsBase и roles
- [x] ReqReturnStep — шаг подачи заявления на возврат (step 7)
- [x] RetAuthorizedStep — шаг авторизации возврата советом (step 8)
- [x] Base.vue: новые статусы (reqreturn, retauthorized), badge coopstock
- [x] Панель председателя КУ: WarehousePage (склад, выдача, destroy/reoffer)
- [x] ShipmentsPage: таблица перевозок, timeline 4 этапов, создание
- [x] DisputePage: подача претензии, список, timeline спора, решение совета
#### 15.X Разделение cooplace на бэкенд и фронтенд расширения
- [x] CooplaceExtensionModule — бэкенд (карточки, категории, blockchain actions)
- [x] ProductCardResolver: CRUD карточек (draft→published→archived)
- [x] CategoryResolver: CRUD дерева категорий (chairman)
- [x] Domain entities: ProductCard (тип, статус, delivery_type, contribution_type), Category, SupplyOrder
- [x] Repositories: interfaces для карточек, категорий, заявок
- [x] GraphQL: 3 queries + 6 mutations для карточек/категорий
- [x] marketplace (desktop) — чистый фронтенд, использует API cooplace
- [x] market-admin (desktop) — уже существует (extensions/market-admin)
- [x] Controller стартует, 22+9 = 31 GraphQL endpoints
#### 15.Y Бэкенд карточек с циклами
- [x] TypeORM entities: ProductCard (циклы), Category, SupplyOrder
- [x] Repository adapters: CRUD + фильтры для всех entities
- [x] ProductCardService: модерация (draft→moderation→published), циклы (min_units, deadline)
- [x] addOrderToCard: инкремент cycle_collected_units, проверка min_units
- [x] checkCycleDeadline: истечение → возврат → новый цикл (cycle_number++)
- [x] ProductCardResolver: 9 queries/mutations с реальной БД логикой
- [x] CooplaceExtensionModule: полная DI с TypeORM
- [x] Исправлен белый экран desktop (vite-plugin-checker + share-button crash)
#### 15.Z Админка маркетплейса (настройки)
- [x] MarketplaceSettingsEntity: lead_request_policy, publish_access_policy, whitelist, moderation, cycles, delivery, prices
- [x] MarketplaceSettingsTypeormEntity + TypeORM repository (upsert)
- [x] MarketplaceSettingsResolver: get/update settings, add/remove whitelist
- [x] Перенесены ВСЕ DTOs/entities/repositories/services из extensions/marketplace → extensions/cooplace
- [x] Desktop SettingsPage: radio groups, toggles, whitelist chips, price inputs
- [x] market-admin: маршрут настроек как дефолтный + модерация + все заказы
#### 15.W Правильная логика match + cycles
- [x] MatchService: каждая встречная заявка → сразу в блокчейн (блокировка средств смарт-контрактом)
- [x] CycleService: min_units/deadline → порог для supply, НЕ для match
- [x] CycleService: cron каждые 5 мин — проверка истёкших циклов → cancel через блокчейн
- [x] MARKET-LOGIC.md обновлён с правильным принципом
- [x] AGENTS.md обновлён с описанием маркетплейса
#### 15.5 Тесты
- [x] Unit-тесты controller: 8 тестов marketplace actions + statuses (90/90 total)
- [x] cooptypes собран с новыми actions
- [x] Boot интеграционный тест: marketplace.test.ts (orderoffer + coopstock flows)
- [x] Boot тест marketplace.test.ts: orderoffer + coopstock flows
### 16. Документация (docs)
- [x] Раздел Cooplace: обзор архитектуры, жизненный цикл карточки, match, циклы
- [x] Раздел Cooplace/dev: GraphQL API reference (настройки, карточки, поставки, перевозки, диспуты)
- [x] Раздел Marketplace: пользовательская документация (витрина, заказ, администрирование)
- [x] TypeDoc JSON сгенерирован из SDK (137MB)
- [x] mkdocs.yml nav обновлён с «Стол заказов»
- [x] .gitignore для typedoc.json (генерируется при сборке)
### 13. GraphQL Subscriptions ✅
- [x] PubSubModule: глобальный PubSub provider
- [x] WebSocket support в GraphQL module (graphql-ws)
- [x] 5 Capital subscriptions: issueUpdated/Created, commitCreated/Updated, dataChanged
- [x] systemStatusChanged — подписка на статус системы (install/init/update)
- [x] sovietDataChanged — подписка на события собраний/решений
- [x] Events publishing: GenerationService, ProjectManagementService, VotingService, SystemService, MeetEventService
- [x] SDK: Subscriptions namespace (Capital)
- [x] Auth через connectionParams.token
- [x] `useGraphqlSubscription` composable на фронтенде
- [x] **7 страниц Capital** переведены с polling на подписки
- [x] **System store** — WebSocket мониторинг вместо setTimeout
- [x] **init-wallet** — удалён рекурсивный setTimeout(run, 10_000)
- [x] **MeetDetails, ListOfAgenda, WaitingRegistration** — подписки вместо setInterval
- [x] **Provider, ConnectionAgreement** — удалён setInterval polling
- [x] 0 использований setInterval в прикладном коде (только UI-анимация энергии)
@@ -0,0 +1,133 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import Blockchain from '../blockchain'
import config from '../configs'
import { getTotalRamUsage, globalRamStats } from '../utils/getTotalRamUsage'
import { addUser } from '../init/participant'
import { generateRandomUsername } from '../utils/randomUsername'
const blockchain = new Blockchain(config.network, config.private_keys)
let supplier: string
let customer: string
const fakeDocument = {
hash: '157192B276DA23CC84AB078FC8755C051C5F0430BF4802E55718221E6B76C777',
public_key: 'PUB_K1_5JhMfxbsNebajHcTEK8yC9uNN9Dit9hEmzE8ri8yMhhzzEtUA4',
signature: 'SIG_K1_KmKWPBC8dZGGDGhbKEoZEzPr3h5crRrR2uLdGRF5DJbeibY1MY1bZ9sPwHsgmPfiGFv9psfoCVsXFh9TekcLuvaeuxRKA8',
meta: '{}',
}
const testHash = '0000000000000000000000000000000000000000000000000000000000000001'
beforeAll(async () => {
await blockchain.update_pass_instance()
supplier = generateRandomUsername()
customer = generateRandomUsername()
console.log('supplier:', supplier)
console.log('customer:', customer)
await addUser(supplier)
await addUser(customer)
}, 500_000)
afterAll(() => {
console.log('\n📊 **MARKETPLACE RAM USAGE** 📊')
let total = 0
for (const [key, ram] of Object.entries(globalRamStats)) {
console.log(` ${key} = ${(ram / 1024).toFixed(2)} kb`)
total += ram
}
console.log(`\n💾 **TOTAL**: ${(total / 1024).toFixed(2)} kb\n`)
})
describe('Marketplace — orderoffer flow', () => {
it('контракт marketplace задеплоен', async () => {
const info = await blockchain.api.v1.chain.get_info()
expect(info).toBeDefined()
expect(info.chain_id).toBeDefined()
})
it('создание заявки orderoffer (заказчик)', async () => {
const coopname = config.coopname
const result = await blockchain.transact({
account: 'marketplace',
name: 'orderoffer',
authorization: [{ actor: coopname, permission: 'active' }],
data: {
coopname,
receiver_braname: coopname,
username: customer,
hash: testHash,
units: 10,
unit_cost: '100.0000 RUB',
product_lifecycle_secs: 2592000,
warranty_period_secs: 604800,
membership_fee_amount: '50.0000 RUB',
cancellation_fee_amount: '10.0000 RUB',
convert_in: fakeDocument,
meta: JSON.stringify({ title: 'Тестовый товар', description: 'Описание' }),
},
})
expect(result).toBeDefined()
console.log('orderoffer tx:', result?.response?.transaction_id?.substring(0, 16))
const ramUsed = await getTotalRamUsage(coopname)
globalRamStats['orderoffer'] = ramUsed
})
it('принятие заявки поставщиком (accept)', async () => {
const coopname = config.coopname
const result = await blockchain.transact({
account: 'marketplace',
name: 'accept',
authorization: [{ actor: coopname, permission: 'active' }],
data: {
coopname,
supplier_braname: coopname,
username: supplier,
request_hash: testHash,
convert_out: fakeDocument,
product_contribution_statement: fakeDocument,
},
})
expect(result).toBeDefined()
console.log('accept tx:', result?.response?.transaction_id?.substring(0, 16))
const ramUsed = await getTotalRamUsage(coopname)
globalRamStats['accept'] = ramUsed
})
})
describe('Marketplace — coopstock flow', () => {
const stockHash = '0000000000000000000000000000000000000000000000000000000000000002'
it('создание предложения из запасов кооператива', async () => {
const coopname = config.coopname
const result = await blockchain.transact({
account: 'marketplace',
name: 'coopstock',
authorization: [{ actor: coopname, permission: 'active' }],
data: {
coopname,
braname: coopname,
hash: stockHash,
units: 5,
unit_cost: '50.0000 RUB',
product_lifecycle_secs: 1296000,
warranty_period_secs: 302400,
membership_fee_amount: '25.0000 RUB',
meta: JSON.stringify({ title: 'Уценённый товар', description: 'Из запасов' }),
},
})
expect(result).toBeDefined()
console.log('coopstock tx:', result?.response?.transaction_id?.substring(0, 16))
})
})
@@ -26,6 +26,18 @@ namespace Document {
inline void add_document(std::vector<named_document>& docs, const name& name, const document2& doc) {
docs.push_back({name, doc});
}
/**
* @brief Удалить документ по имени (если существует)
* @param docs Вектор документов
* @param name Имя документа для удаления
*/
inline void remove_document(std::vector<named_document>& docs, const name& name) {
docs.erase(
std::remove_if(docs.begin(), docs.end(), [&](const named_document& nd) { return nd.name == name; }),
docs.end()
);
}
/**
* @brief Найти документ по имени
@@ -154,6 +154,9 @@ struct [[eosio::table, eosio::contract(MARKETPLACE)]] request {
eosio::time_point_sec warranty_delay_until;
eosio::time_point_sec deadline_for_receipt;
eosio::name delivery_type; /*!< тип доставки: "internal" (между КУ) или "external" (внешний сервис) */
eosio::name contribution_type; /*!< тип взноса: "share" (паевой) или "member" (членский - кооператив покупает) */
bool is_warranty_return = false;
uint64_t warranty_return_id;
@@ -234,7 +237,9 @@ typedef eosio::multi_index<
> requests_index;
static const std::set<eosio::name> marketplace_callback_actions = {
"authoffs2c"_n, // авторизация сегмента contribute для направления OFFER → ORDER
"authcontrib"_n, // авторизация взноса имуществом
"authreturn"_n, // авторизация возврата имуществом
"authoffs2c"_n, // авторизация сегмента contribute для направления OFFER → ORDER
"authoffc2r"_n, // авторизация сегмента return для направления OFFER → ORDER
"authordcont"_n, // авторизация сегмента contribute для направления ORDER → OFFER
"authordret"_n, // авторизация сегмента return для направления ORDER → OFFER
@@ -316,6 +321,9 @@ namespace DocumentNames {
static constexpr const name WRETURN_ACT = "wreturnact"_n; // акт гарантийного возврата
static constexpr const name WOFFER_ACT = "wofferact"_n; // акт предложения товара поставщику
static constexpr const name WACCEPT_ACT = "wacceptact"_n; // акт принятия/отказа поставщика
// Документы уничтожения
static constexpr const name DESTROY_ACT = "destroyact"_n; // акт уничтожения имущества
}
} // namespace Marketplace
@@ -10,6 +10,9 @@
#include "src/deliver_on_offer/supply.cpp"
#include "src/deliver_on_offer/supplcnf.cpp"
#include "src/deliver_on_offer/coopstock.cpp"
#include "src/deliver_on_offer/acceptstock.cpp"
#include "src/deliver_on_offer/reqreturn.cpp"
#include "src/deliver_on_offer/delivered.cpp"
#include "src/deliver_on_offer/receive.cpp"
#include "src/deliver_on_offer/receivecnf.cpp"
@@ -24,6 +27,14 @@
#include "src/shipment/receiveshipm.cpp"
#include "src/shipment/retransport.cpp"
// ORDER→OFFER direction
#include "src/deliver_on_order/createorder.cpp"
#include "src/deliver_on_order/respondoffer.cpp"
// Уничтожение и перепредложение
#include "src/deliver_on_offer/destroy.cpp"
#include "src/deliver_on_offer/reoffer.cpp"
// Диспуты
#include "src/dispute_on_offer/dispute.cpp"
#include "src/dispute_on_offer/wauthorize.cpp"
@@ -51,7 +51,7 @@ public:
[[eosio::action]] void migrate();
// Действия для создания заявок
[[eosio::action]] void orderoffer(eosio::name coopname, eosio::name receiver_braname, eosio::name username, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, eosio::asset cancellation_fee_amount, document2 product_return_statement, document2 convert_in, std::string meta);
[[eosio::action]] void orderoffer(eosio::name coopname, eosio::name receiver_braname, eosio::name username, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, eosio::asset cancellation_fee_amount, document2 convert_in, eosio::name delivery_type, eosio::name contribution_type, std::string meta);
static void cancel_request(eosio::name coopname, eosio::name username, checksum256 request_hash);
@@ -73,6 +73,17 @@ public:
[[eosio::action]] void receiveshipm(eosio::name coopname, checksum256 hash, document2 warehouse_receipt_act);
[[eosio::action]] void retransport(eosio::name coopname, checksum256 completed_hash, eosio::name new_driver_username, eosio::name source_braname, eosio::name new_destination_braname, std::vector<checksum256> request_hashes, document2 transport_act_sender);
// ORDER→OFFER direction (заказчик публикует → поставщики откликаются)
[[eosio::action]] void createorder(eosio::name coopname, eosio::name receiver_braname, eosio::name username, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, eosio::asset cancellation_fee_amount, document2 convert_in, eosio::name delivery_type, std::string meta);
[[eosio::action]] void respondoffer(eosio::name coopname, eosio::name supplier_braname, eosio::name username, checksum256 order_hash, checksum256 offer_hash, uint64_t units, uint32_t product_lifecycle_secs, document2 contribution_statement, document2 convert_out);
// Имущество из запасов кооператива
[[eosio::action]] void coopstock(eosio::name coopname, eosio::name braname, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, std::string meta);
[[eosio::action]] void acceptstock(eosio::name coopname, eosio::name username, checksum256 request_hash, document2 convert_in, document2 return_statement);
// Запрос возврата перед получением
[[eosio::action]] void reqreturn(eosio::name coopname, eosio::name username, checksum256 request_hash, document2 return_statement);
// Доставка заказчику
[[eosio::action]] void delivered(eosio::name coopname, eosio::name username, checksum256 request_hash);
[[eosio::action]] void receive(eosio::name coopname, eosio::name username, checksum256 request_hash, document2 document);
@@ -81,6 +92,10 @@ public:
[[eosio::action]] void decline(eosio::name coopname, eosio::name username, checksum256 request_hash, std::string meta);
[[eosio::action]] void cancel(eosio::name coopname, eosio::name username, checksum256 request_hash);
// Уничтожение и перепредложение
[[eosio::action]] void destroy(eosio::name coopname, checksum256 request_hash, document2 destruction_act);
[[eosio::action]] void reoffer(eosio::name coopname, checksum256 request_hash, checksum256 new_hash, eosio::asset new_unit_cost, std::string new_meta);
// Методы для работы с диспутом (гарантийный возврат)
[[eosio::action]] void dispute(eosio::name coopname, eosio::name username, checksum256 request_hash, document2 document);
[[eosio::action]] void wauthorize(eosio::name coopname, checksum256 request_hash, uint64_t wreturn_decision_id, document2 wreturn_authorization, uint64_t wsupply_decision_id, document2 wsupply_authorization);
@@ -2,13 +2,15 @@
\ingroup public_actions
\brief Принятие заявки поставщиком.
@details Поставщик принимает заявку orderoffer на поставку имущества и предоставляет необходимые документы.
@details Поставщик принимает заявку orderoffer на поставку имущества.
При принятии создаётся ОДНО заявление в совет — на имущественный паевой взнос.
Заявление на возврат будет создано позже, перед получением имущества заказчиком.
@param coopname Имя кооператива
@param supplier_braname Имя кооперативного участка поставщика
@param username Имя поставщика
@param request_hash Хэш заявки
@param convert_out Заявление на конвертацию
@param convert_out Заявление на конвертацию в кошелек (сохраняется для будущего использования)
@param product_contribution_statement Заявление на имущественный паевой взнос
@note Авторизация требуется от аккаунта: @p coopname
@@ -24,23 +26,14 @@
eosio::check(change.status == "active"_n, "Только активная заявка может быть принята");
eosio::check(change.type == "orderoffer"_n, "Метод accept применим только к заявкам типа orderoffer");
// Проверяем существование кооперативного участка поставщика
get_branch_or_fail(coopname, supplier_braname);
// Проверяем подписи документов
verify_document_or_fail(convert_out);
verify_document_or_fail(product_contribution_statement);
// Валидируем документы по registry_id (пока что нули)
Document::validate_registry_id(convert_out, 0);
Document::validate_registry_id(product_contribution_statement, 0);
// Получаем первоначальный документ возврата из заявки по имени
document2 initial_return_statement;
bool found = Document::find_document(change.documents, DocumentNames::RETURN_STMT, initial_return_statement);
eosio::check(found, "В заявке отсутствует заявление на возврат имущества");
// Обновляем заявку
auto change_itr = requests.find(change.id);
eosio::check(change_itr != requests.end(), "Заявка не найдена для обновления");
requests.modify(change_itr, _marketplace, [&](auto &o){
@@ -48,20 +41,18 @@
o.accepted_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
o.product_contributor = username;
o.supplier_braname = supplier_braname;
// Добавляем новые документы с именами
Document::add_document(o.documents, DocumentNames::CONVERT_TO, convert_out);
Document::add_document(o.documents, DocumentNames::CONTRIB_STMT, product_contribution_statement);
});
// Используем хэш заявки как идентификатор пакета решений
checksum256 agenda_hash = change.hash;
// Отправляем ПЕРВЫЙ вопрос в совет - по заявлению на имущественный паевой взнос
// Одно заявление в совет — на имущественный паевой взнос
::Soviet::create_agenda(
_marketplace,
coopname,
username,
get_valid_soviet_action("authcontrib"_n), // авторизация взноса имуществом
get_valid_soviet_action("authcontrib"_n),
agenda_hash,
_marketplace,
Marketplace::get_valid_marketplace_action("authcontrib"_n),
@@ -69,18 +60,4 @@
product_contribution_statement,
std::string("")
);
// Отправляем ВТОРОЙ вопрос в совет - по заявлению на возврат имущества
::Soviet::create_agenda(
_marketplace,
coopname,
username,
get_valid_soviet_action("authreturn"_n), // авторизация возврата имуществом
agenda_hash,
_marketplace,
Marketplace::get_valid_marketplace_action("authreturn"_n),
"declineacc"_n,
initial_return_statement,
std::string("")
);
};
};
@@ -0,0 +1,62 @@
/**
\ingroup public_actions
\brief Заказчик принимает предложение из запасов кооператива.
@details Заказчик блокирует средства и запрашивает возврат.
Поскольку имущество уже на балансе — авторизация взноса не нужна,
сразу создаётся заявление на возврат в совет.
@param coopname Имя кооператива
@param username Имя заказчика
@param request_hash Хэш заявки
@param convert_in Заявление на конвертацию из кошелька
@param return_statement Заявление на возврат паевого взноса имуществом
@note Авторизация требуется от аккаунта: @p coopname
**/
[[eosio::action]] void marketplace::acceptstock(eosio::name coopname, eosio::name username, checksum256 request_hash, document2 convert_in, document2 return_statement) {
require_auth(coopname);
requests_index requests(_marketplace, coopname.value);
auto change_opt = Marketplace::get_request_by_hash(coopname, request_hash);
eosio::check(change_opt.has_value(), "Заявка не найдена");
auto change = change_opt.value();
eosio::check(change.type == "coopstock"_n, "Метод acceptstock применим только к заявкам типа coopstock");
eosio::check(change.status == "delivered"_n, "Только доставленная заявка может быть принята");
get_participant_or_fail(coopname, username);
verify_document_or_fail(convert_in);
verify_document_or_fail(return_statement);
auto change_itr = requests.find(change.id);
eosio::check(change_itr != requests.end(), "Заявка не найдена для обновления");
requests.modify(change_itr, _marketplace, [&](auto &o) {
o.money_contributor = username;
o.status = "reqreturn"_n;
Document::add_document(o.documents, DocumentNames::CONVERT_FROM, convert_in);
Document::add_document(o.documents, DocumentNames::RETURN_STMT, return_statement);
});
// Блокируем средства заказчика
std::string memo = "Блокировка средств для заказа из запасов кооператива №" + std::to_string(change.id);
Wallet::sub_available_funds(_marketplace, coopname, username, change.total_cost, _wallet_program, memo);
Wallet::add_available_funds(_marketplace, coopname, username, change.total_cost, _marketplace_program, memo);
Wallet::block_funds(_marketplace, coopname, username, change.total_cost, _marketplace_program, memo);
// Сразу отправляем заявление на возврат в совет
::Soviet::create_agenda(
_marketplace,
coopname,
username,
get_valid_soviet_action("authreturn"_n),
change.hash,
_marketplace,
Marketplace::get_valid_marketplace_action("authreturn"_n),
"declineacc"_n,
return_statement,
std::string("")
);
};
@@ -2,7 +2,9 @@
\ingroup public_actions
\brief Авторизация заявления на имущественный паевой взнос советом кооператива.
@details Совет кооператива авторизует заявление на имущественный паевой взнос.
@details Совет авторизует заявление поставщика на взнос имуществом.
После авторизации заявка переходит в статус authorized — поставщик может начинать поставку.
Заявление на возврат будет подано и авторизовано отдельно, перед выдачей заказчику.
@param coopname Имя кооператива
@param request_hash Хэш заявки
@@ -20,26 +22,13 @@
eosio::check(change.status == "accepted"_n, "Только принятая заявка может быть авторизована");
// Проверяем подпись документа
verify_document_or_fail(authorization);
// Валидируем документ по registry_id (пока что ноль)
Document::validate_registry_id(authorization, 0);
// Обновляем заявку
auto change_itr = requests.find(change.id);
eosio::check(change_itr != requests.end(), "Заявка не найдена для обновления");
requests.modify(change_itr, _marketplace, [&](auto &o){
// Добавляем документ авторизации взноса с именем
Document::add_document(o.documents, DocumentNames::CONTRIB_AUTH, authorization);
// Проверяем, есть ли уже оба документа авторизации
bool has_contrib_auth = Document::has_document(o.documents, DocumentNames::CONTRIB_AUTH);
bool has_return_auth = Document::has_document(o.documents, DocumentNames::RETURN_AUTH);
// Если получили оба документа авторизации, переводим в статус authorized
if (has_contrib_auth && has_return_auth) {
o.status = "authorized"_n;
}
o.status = "authorized"_n;
});
};
};
@@ -1,8 +1,10 @@
/**
\ingroup public_actions
\brief Авторизация заявления на возврат имущества советом кооператива.
\brief Авторизация заявления на возврат паевого взноса имуществом.
@details Совет кооператива авторизует заявление на возврат имущества.
@details Совет авторизует заявление заказчика на возврат паевого взноса.
Вызывается ПОСЛЕ подачи заявления заказчиком (requestreturn),
когда имущество уже доставлено на КУ и готово к выдаче.
@param coopname Имя кооператива
@param request_hash Хэш заявки
@@ -18,28 +20,16 @@
eosio::check(change_opt.has_value(), "Заявка не найдена");
auto change = change_opt.value();
eosio::check(change.status == "accepted"_n, "Только принятая заявка может быть авторизована");
// Заявка должна быть в статусе delivered (имущество доставлено и ждёт выдачи)
eosio::check(change.status == "reqreturn"_n, "Возврат можно авторизовать только для заявки с запрошенным возвратом");
// Проверяем подпись документа
verify_document_or_fail(authorization);
// Валидируем документ по registry_id (пока что ноль)
Document::validate_registry_id(authorization, 0);
// Обновляем заявку
auto change_itr = requests.find(change.id);
eosio::check(change_itr != requests.end(), "Заявка не найдена для обновления");
requests.modify(change_itr, _marketplace, [&](auto &o){
// Добавляем документ авторизации возврата с именем
Document::add_document(o.documents, DocumentNames::RETURN_AUTH, authorization);
// Проверяем, есть ли уже оба документа авторизации
bool has_contrib_auth = Document::has_document(o.documents, DocumentNames::CONTRIB_AUTH);
bool has_return_auth = Document::has_document(o.documents, DocumentNames::RETURN_AUTH);
// Если получили оба документа авторизации, переводим в статус authorized
if (has_contrib_auth && has_return_auth) {
o.status = "authorized"_n;
}
o.status = "retauthorized"_n;
});
};
};
@@ -0,0 +1,72 @@
/**
\ingroup public_actions
\brief Создать заявку на имущество из запасов кооператива.
@details Кооператив (представитель КУ) создаёт предложение на имущество,
которое УЖЕ находится на балансе кооператива. В этом случае:
- Не требуется внешний поставщик
- Не требуется взнос имуществом (имущество уже на балансе)
- Не требуется авторизация взноса советом
- Требуется только авторизация возврата при получении заказчиком
Заявка создаётся со статусом supplied2 (имущество уже на складе КУ).
Это упрощённый путь для реализации уценённого или перепредложенного имущества.
@param coopname Имя кооператива
@param braname КУ, где хранится имущество
@param units Количество единиц
@param unit_cost Стоимость за единицу (может быть уценённой)
@param hash Хэш заявки
@param product_lifecycle_secs Срок годности
@param warranty_period_secs Гарантийный срок
@param membership_fee_amount Членский взнос
@param meta Метаданные (описание, фото)
@note Авторизация требуется от аккаунта: @p coopname
**/
[[eosio::action]] void marketplace::coopstock(eosio::name coopname, eosio::name braname, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, std::string meta) {
require_auth(coopname);
auto existing = get_request_by_hash(coopname, hash);
eosio::check(!existing.has_value(), "Заявка с таким хэшем уже существует");
auto coop = get_cooperative_or_fail(coopname);
eosio::check(unit_cost.symbol == coop.initial.symbol, "Неверный символ токена");
eosio::check(membership_fee_amount.symbol == coop.initial.symbol, "Неверный символ токена для членского взноса");
eosio::check(units > 0, "Количество единиц должно быть больше нуля");
get_branch_or_fail(coopname, braname);
eosio::asset base_cost = unit_cost * units;
eosio::asset total_cost = base_cost + membership_fee_amount;
eosio::asset zero_fee = eosio::asset(0, coop.initial.symbol);
requests_index requests(_marketplace, coopname.value);
uint64_t request_id = get_global_id(_marketplace, "requests"_n);
requests.emplace(_marketplace, [&](auto &i) {
i.id = request_id;
i.hash = hash;
i.type = "coopstock"_n;
i.username = coopname;
i.coopname = coopname;
i.status = "delivered"_n;
i.units = units;
i.unit_cost = unit_cost;
i.base_cost = base_cost;
i.membership_fee_amount = membership_fee_amount;
i.total_cost = total_cost;
i.product_lifecycle_secs = product_lifecycle_secs;
i.warranty_period_secs = warranty_period_secs;
i.money_contributor = ""_n;
i.product_contributor = coopname;
i.meta = meta;
i.created_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.supplied_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.delivered_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.cancellation_fee_amount = zero_fee;
i.receiver_braname = braname;
i.supplier_braname = braname;
i.warehouse = braname;
});
};
@@ -0,0 +1,79 @@
/**
\ingroup public_actions
\brief Уничтожение просроченного имущества.
@details Если имущество не получено заказчиком в срок (deadline_for_receipt)
и его срок годности истёк, кооператив может уничтожить его.
Требуется комиссионный акт и видео/фото подтверждение.
При уничтожении:
- Заблокированные средства заказчика разблокируются за вычетом cancellation_fee
- cancellation_fee зачисляется в фонд членских взносов
- Из фонда членских взносов поставщику выплачивается стоимость
@param coopname Имя кооператива
@param request_hash Хэш заявки
@param destruction_act Акт уничтожения имущества (с комиссией)
@note Авторизация требуется от аккаунта: @p coopname
**/
[[eosio::action]] void marketplace::destroy(eosio::name coopname, checksum256 request_hash, document2 destruction_act) {
require_auth(coopname);
requests_index requests(_marketplace, coopname.value);
auto change_opt = Marketplace::get_request_by_hash(coopname, request_hash);
eosio::check(change_opt.has_value(), "Заявка не найдена");
auto change = change_opt.value();
// Уничтожение возможно только для доставленного или непринятого имущества
eosio::check(
change.status == "delivered"_n || change.status == "supplied2"_n,
"Уничтожение возможно только для доставленного/поставленного имущества"
);
// Проверяем что срок получения истёк (если был установлен)
if (change.deadline_for_receipt.sec_since_epoch() > 0) {
eosio::check(
eosio::current_time_point().sec_since_epoch() > change.deadline_for_receipt.sec_since_epoch(),
"Срок получения ещё не истёк"
);
}
verify_document_or_fail(destruction_act);
auto change_itr = requests.find(change.id);
eosio::check(change_itr != requests.end(), "Заявка не найдена");
// Возврат средств заказчику за вычетом штрафа
if (change.money_contributor.value != 0) {
eosio::asset refund = change.total_cost - change.cancellation_fee_amount;
if (refund.amount > 0) {
std::string memo = "Возврат за уничтоженное имущество по заявке №" + std::to_string(change.id);
Wallet::unblock_funds(_marketplace, coopname, change.money_contributor, change.total_cost, _marketplace_program, memo);
Wallet::sub_available_funds(_marketplace, coopname, change.money_contributor, change.total_cost, _marketplace_program, memo);
Wallet::add_available_funds(_marketplace, coopname, change.money_contributor, refund, _wallet_program, memo);
}
// Штраф — в фонд членских взносов
if (change.cancellation_fee_amount.amount > 0) {
eosio::action(
eosio::permission_level{ _marketplace, "active"_n },
_fund,
"spreadamount"_n,
std::make_tuple(coopname, change.cancellation_fee_amount)
).send();
}
}
// Выплата поставщику (если был)
if (change.product_contributor.value != 0 && change.product_contributor != coopname) {
std::string memo = "Выплата поставщику за уничтоженное имущество по заявке №" + std::to_string(change.id);
Wallet::unblock_funds(_marketplace, coopname, change.product_contributor, change.base_cost, _marketplace_program, memo);
Wallet::sub_available_funds(_marketplace, coopname, change.product_contributor, change.base_cost, _marketplace_program, memo);
Wallet::add_available_funds(_marketplace, coopname, change.product_contributor, change.base_cost, _wallet_program, memo);
}
// Удаляем заявку
requests.erase(change_itr);
};
@@ -1,79 +1,64 @@
/**
\ingroup public_actions
\brief Создать заявку orderoffer - заказчик создает заявку на поставку товара от поставщика.
*
* Данный метод позволяет заказчику создать заявку на поставку товара от поставщика.
* Заявка содержит всю информацию о товаре, стоимости, документах и сразу блокирует средства заказчика.
*
* @param coopname Имя кооператива
* @param receiver_braname Имя кооперативного участка заказчика для получения товара
* @param username Имя заказчика
* @param hash Хэш заявки (уникальный идентификатор)
* @param units Количество единиц товара
* @param unit_cost Цена за единицу товара
* @param product_lifecycle_secs Время жизни продукта
* @param warranty_period_secs Гарантийный срок в секундах
* @param membership_fee_amount Сумма членского взноса
* @param cancellation_fee_amount Сумма комиссии за отмену заявки
* @param product_return_statement Заявление на возврат паевого взноса имуществом
* @param convert_in Заявление на конвертацию из кошелька в маркетплейс
* @param meta Метаданные о заявке
*
* @note Авторизация требуется от аккаунта: @p coopname
\brief Создать заявку orderoffer заказчик создаёт заявку на поставку товара.
@details Заказчик создаёт заявку на поставку. Средства блокируются сразу.
Заявление на возврат НЕ подаётся на этом этапе — оно будет подано перед получением
(requestreturn), когда известен точный вес/состав имущества.
@param coopname Имя кооператива
@param receiver_braname КУ заказчика для получения товара
@param username Имя заказчика
@param hash Хэш заявки
@param units Количество единиц товара
@param unit_cost Цена за единицу
@param product_lifecycle_secs Срок годности продукта
@param warranty_period_secs Гарантийный срок
@param membership_fee_amount Членский взнос
@param cancellation_fee_amount Штраф за отмену
@param convert_in Заявление на конвертацию из кошелька в маркетплейс
@param meta Метаданные
@note Авторизация требуется от аккаунта: @p coopname
*/
[[eosio::action]] void marketplace::orderoffer(eosio::name coopname, eosio::name receiver_braname, eosio::name username, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, eosio::asset cancellation_fee_amount, document2 product_return_statement, document2 convert_in, std::string meta) {
[[eosio::action]] void marketplace::orderoffer(eosio::name coopname, eosio::name receiver_braname, eosio::name username, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, eosio::asset cancellation_fee_amount, document2 convert_in, eosio::name delivery_type, eosio::name contribution_type, std::string meta) {
require_auth(coopname);
// Проверяем, что заявка с таким хэшем не существует
auto existing_request = get_request_by_hash(coopname, hash);
eosio::check(!existing_request.has_value(), "Заявка с таким хэшем уже существует");
// Проверяем, что символ токена совпадает с символом токена кооператива
auto coop = get_cooperative_or_fail(coopname);
eosio::check(unit_cost.symbol == coop.initial.symbol, "Неверный символ токена");
eosio::check(membership_fee_amount.symbol == coop.initial.symbol, "Неверный символ токена для членского взноса");
eosio::check(cancellation_fee_amount.symbol == coop.initial.symbol, "Неверный символ токена для комиссии отмены");
eosio::check(units > 0, "Количество единиц в заявке должно быть больше нуля");
eosio::check(units > 0, "Количество единиц должно быть больше нуля");
eosio::check(unit_cost.amount >= 0, "Цена не может быть отрицательной");
eosio::check(membership_fee_amount.amount >= 0, "Членский взнос не может быть отрицательным");
eosio::check(cancellation_fee_amount.amount >= 0, "Комиссия за отмену не может быть отрицательной");
// Проверяем, что пользователь является пайщиком кооператива
get_participant_or_fail(coopname, username);
// Проверяем существование кооперативного участка заказчика
get_branch_or_fail(coopname, receiver_braname);
// Проверяем существование программы маркетплейса
auto program = get_program_or_fail(coopname, _marketplace_program_id);
// Гарантийный срок возврата должен быть установлен
eosio::check(product_lifecycle_secs > 0, "Гарантийный срок возврата для имущества должен быть установлен");
eosio::check(product_lifecycle_secs > 0, "Срок годности должен быть установлен");
eosio::check(warranty_period_secs > 0, "Гарантийный срок должен быть больше нуля");
// Проводим проверку подписи документов
verify_document_or_fail(product_return_statement);
verify_document_or_fail(convert_in);
// Валидируем документы по registry_id (пока что нули как просил пользователь)
Document::validate_registry_id(product_return_statement, 0);
Document::validate_registry_id(convert_in, 0);
// Рассчитываем стоимость
eosio::asset base_cost = unit_cost * units;
eosio::asset total_cost = base_cost + membership_fee_amount;
// Проверяем что комиссия за отмену не превышает общую стоимость
eosio::check(cancellation_fee_amount <= total_cost, "Комиссия за отмену не может превышать общую стоимость заявки");
eosio::check(cancellation_fee_amount <= total_cost, "Комиссия за отмену не может превышать общую стоимость");
eosio::check(delivery_type == "internal"_n || delivery_type == "external"_n, "Тип доставки: internal или external");
eosio::check(contribution_type == "share"_n || contribution_type == "member"_n, "Тип взноса: share или member");
requests_index requests(_marketplace, coopname.value);
uint64_t request_id = get_global_id(_marketplace, "requests"_n);
std::string memo = "Начало поставки по программе №" + std::to_string(_marketplace_program_id) + " с ID: " + std::to_string(request_id);
// Создаем вектор именованных документов
std::vector<Document::named_document> documents;
Document::add_document(documents, DocumentNames::RETURN_STMT, product_return_statement);
Document::add_document(documents, DocumentNames::CONVERT_FROM, convert_in);
requests.emplace(_marketplace, [&](auto &i) {
@@ -95,16 +80,13 @@
i.created_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.cancellation_fee_amount = cancellation_fee_amount;
i.receiver_braname = receiver_braname;
i.delivery_type = delivery_type;
i.contribution_type = contribution_type;
i.documents = documents;
});
// Конвертируем и блокируем средства заказчика
std::string convert_memo = "Конвертация средств из ЦПП 'Цифровой Кошелёк' в ЦПП 'Маркетплейс' для заказа №" + std::to_string(request_id);
std::string convert_memo = "Конвертация средств для заказа" + std::to_string(request_id);
Wallet::sub_available_funds(_marketplace, coopname, username, total_cost, _wallet_program, convert_memo);
// Добавляем средства на ЦПП маркетплейса
Wallet::add_available_funds(_marketplace, coopname, username, total_cost, _marketplace_program, convert_memo);
// Блокируем средства на программе маркетплейса
Wallet::block_funds(_marketplace, coopname, username, total_cost, _marketplace_program, memo);
};
};
@@ -2,12 +2,14 @@
\ingroup public_actions
\brief Получение товара заказчиком.
@details Заказчик приходит на КУ для получения имущества. Председатель КУ подписывает акт и передаёт имущество заказчику.
@details Заказчик приходит на КУ для получения имущества.
Председатель КУ подписывает акт и передаёт имущество заказчику.
Получение возможно ТОЛЬКО после авторизации возврата советом (статус retauthorized).
@param coopname Имя кооператива
@param username Имя заказчика
@param username Имя заказчика (председатель КУ подписывает от лица кооператива)
@param request_hash Хэш заявки
@param document Акт получения имущества
@param document Акт получения имущества (подпись председателя КУ)
@note Авторизация требуется от аккаунта: @p coopname
**/
@@ -19,21 +21,17 @@
eosio::check(change_opt.has_value(), "Заявка не найдена");
auto change = change_opt.value();
eosio::check(change.status == "delivered"_n, "Получение возможно только после статуса delivered");
// Получение возможно только после авторизации возврата советом
eosio::check(change.status == "retauthorized"_n, "Получение возможно только после авторизации возврата советом");
eosio::check(change.money_contributor == username, "Недостаточно прав доступа");
// Проверяем подпись документа
verify_document_or_fail(document);
// Валидируем документ по registry_id (пока что ноль)
Document::validate_registry_id(document, 0);
// Обновляем заявку
auto change_itr = requests.find(change.id);
eosio::check(change_itr != requests.end(), "Заявка не найдена для обновления");
requests.modify(change_itr, _marketplace, [&](auto &o){
o.status = "received1"_n;
// Добавляем акт получения с именем
Document::add_document(o.documents, DocumentNames::RECEIVE_ACT, document);
});
};
};
@@ -0,0 +1,95 @@
/**
\ingroup public_actions
\brief Перепредложение непринятого имущества по пути coopstock.
@details Если заказчик не получил имущество в срок, но срок годности ещё не истёк,
председатель КУ может перепредложить его другим пайщикам. Это создаёт новую заявку
типа coopstock на основе текущей, с возможностью изменения цены.
Текущая заявка закрывается (средства возвращаются заказчику за вычетом штрафа),
создаётся новая заявка coopstock.
@param coopname Имя кооператива
@param request_hash Хэш текущей заявки
@param new_hash Хэш новой заявки
@param new_unit_cost Новая цена за единицу (может быть уценена)
@param new_meta Новые метаданные
@note Авторизация требуется от аккаунта: @p coopname
**/
[[eosio::action]] void marketplace::reoffer(eosio::name coopname, checksum256 request_hash, checksum256 new_hash, eosio::asset new_unit_cost, std::string new_meta) {
require_auth(coopname);
requests_index requests(_marketplace, coopname.value);
auto change_opt = Marketplace::get_request_by_hash(coopname, request_hash);
eosio::check(change_opt.has_value(), "Заявка не найдена");
auto change = change_opt.value();
eosio::check(
change.status == "delivered"_n || change.status == "supplied2"_n,
"Перепредложение возможно только для доставленного имущества"
);
auto coop = get_cooperative_or_fail(coopname);
eosio::check(new_unit_cost.symbol == coop.initial.symbol, "Неверный символ токена");
// Возврат средств заказчику за вычетом штрафа (если заказчик был)
if (change.money_contributor.value != 0) {
eosio::asset refund = change.total_cost - change.cancellation_fee_amount;
if (refund.amount > 0) {
std::string memo = "Возврат при перепредложении имущества по заявке №" + std::to_string(change.id);
Wallet::unblock_funds(_marketplace, coopname, change.money_contributor, change.total_cost, _marketplace_program, memo);
Wallet::sub_available_funds(_marketplace, coopname, change.money_contributor, change.total_cost, _marketplace_program, memo);
Wallet::add_available_funds(_marketplace, coopname, change.money_contributor, refund, _wallet_program, memo);
}
if (change.cancellation_fee_amount.amount > 0) {
eosio::action(
eosio::permission_level{ _marketplace, "active"_n },
_fund,
"spreadamount"_n,
std::make_tuple(coopname, change.cancellation_fee_amount)
).send();
}
}
// Удаляем старую заявку
auto change_itr = requests.find(change.id);
requests.erase(change_itr);
// Создаём новую заявку типа coopstock
eosio::asset zero_fee = eosio::asset(0, coop.initial.symbol);
eosio::asset new_base_cost = new_unit_cost * change.units;
eosio::asset new_total_cost = new_base_cost + change.membership_fee_amount;
auto new_existing = get_request_by_hash(coopname, new_hash);
eosio::check(!new_existing.has_value(), "Заявка с новым хэшем уже существует");
uint64_t new_id = get_global_id(_marketplace, "requests"_n);
requests.emplace(_marketplace, [&](auto &i) {
i.id = new_id;
i.hash = new_hash;
i.type = "coopstock"_n;
i.username = coopname;
i.coopname = coopname;
i.status = "delivered"_n;
i.units = change.units;
i.unit_cost = new_unit_cost;
i.base_cost = new_base_cost;
i.membership_fee_amount = change.membership_fee_amount;
i.total_cost = new_total_cost;
i.product_lifecycle_secs = change.product_lifecycle_secs;
i.warranty_period_secs = change.warranty_period_secs;
i.money_contributor = ""_n;
i.product_contributor = coopname;
i.meta = new_meta;
i.created_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.delivered_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.cancellation_fee_amount = zero_fee;
i.receiver_braname = change.warehouse;
i.supplier_braname = change.warehouse;
i.warehouse = change.warehouse;
});
};
@@ -0,0 +1,57 @@
/**
\ingroup public_actions
\brief Запрос на возврат паевого взноса имуществом — перед получением.
@details Заказчик подаёт заявление на возврат паевого взноса имуществом.
Вызывается когда имущество доставлено на КУ получателя (статус delivered).
Председатель КУ фиксирует факт выдачи и точные параметры имущества (вес, количество).
Создаёт пункт повестки для совета на авторизацию возврата.
@param coopname Имя кооператива
@param username Имя заказчика
@param request_hash Хэш заявки
@param return_statement Заявление на возврат паевого взноса (обновлённое, с точным весом)
@note Авторизация требуется от аккаунта: @p coopname
**/
[[eosio::action]] void marketplace::reqreturn(eosio::name coopname, eosio::name username, checksum256 request_hash, document2 return_statement) {
require_auth(coopname);
requests_index requests(_marketplace, coopname.value);
auto change_opt = Marketplace::get_request_by_hash(coopname, request_hash);
eosio::check(change_opt.has_value(), "Заявка не найдена");
auto change = change_opt.value();
// Имущество должно быть доставлено и готово к выдаче
eosio::check(change.status == "delivered"_n, "Запрос возврата возможен только для доставленного имущества");
eosio::check(change.money_contributor == username, "Только заказчик может запросить возврат");
verify_document_or_fail(return_statement);
Document::validate_registry_id(return_statement, 0);
auto change_itr = requests.find(change.id);
eosio::check(change_itr != requests.end(), "Заявка не найдена для обновления");
// Обновляем/добавляем заявление на возврат с актуальными данными
requests.modify(change_itr, _marketplace, [&](auto &o){
// Удаляем старое заявление на возврат если было
Document::remove_document(o.documents, DocumentNames::RETURN_STMT);
Document::add_document(o.documents, DocumentNames::RETURN_STMT, return_statement);
o.status = "reqreturn"_n;
});
// Отправляем заявление на возврат в совет
::Soviet::create_agenda(
_marketplace,
coopname,
username,
get_valid_soviet_action("authreturn"_n),
change.hash,
_marketplace,
Marketplace::get_valid_marketplace_action("authreturn"_n),
"declineacc"_n,
return_statement,
std::string("")
);
};
@@ -0,0 +1,82 @@
/**
\ingroup public_actions
\brief Создать заказ — заказчик публикует запрос «поставьте мне».
@details Заказчик создаёт заказ типа order с указанием желаемого количества,
стоимости, характеристик. Средства блокируются сразу.
Поставщики могут откликнуться встречными предложениями (offer).
@param coopname Имя кооператива
@param receiver_braname КУ заказчика
@param username Имя заказчика
@param hash Хэш заказа
@param units Желаемое количество
@param unit_cost Предлагаемая цена за единицу
@param product_lifecycle_secs Желаемый срок годности
@param warranty_period_secs Желаемый гарантийный срок
@param membership_fee_amount Членский взнос
@param cancellation_fee_amount Штраф за отмену
@param convert_in Заявление на конвертацию
@param delivery_type Тип доставки: "internal" или "external"
@param meta Метаданные
@note Авторизация: @p coopname
**/
[[eosio::action]] void marketplace::createorder(eosio::name coopname, eosio::name receiver_braname, eosio::name username, checksum256 hash, uint64_t units, eosio::asset unit_cost, uint32_t product_lifecycle_secs, uint32_t warranty_period_secs, eosio::asset membership_fee_amount, eosio::asset cancellation_fee_amount, document2 convert_in, eosio::name delivery_type, std::string meta) {
require_auth(coopname);
auto existing = get_request_by_hash(coopname, hash);
eosio::check(!existing.has_value(), "Заказ с таким хэшем уже существует");
auto coop = get_cooperative_or_fail(coopname);
eosio::check(unit_cost.symbol == coop.initial.symbol, "Неверный символ токена");
eosio::check(membership_fee_amount.symbol == coop.initial.symbol, "Неверный символ для членского взноса");
eosio::check(cancellation_fee_amount.symbol == coop.initial.symbol, "Неверный символ для штрафа");
eosio::check(units > 0, "Количество должно быть больше нуля");
eosio::check(delivery_type == "internal"_n || delivery_type == "external"_n, "Тип доставки: internal или external");
get_participant_or_fail(coopname, username);
get_branch_or_fail(coopname, receiver_braname);
auto program = get_program_or_fail(coopname, _marketplace_program_id);
verify_document_or_fail(convert_in);
eosio::asset base_cost = unit_cost * units;
eosio::asset total_cost = base_cost + membership_fee_amount;
eosio::check(cancellation_fee_amount <= total_cost, "Штраф не может превышать общую стоимость");
requests_index requests(_marketplace, coopname.value);
uint64_t request_id = get_global_id(_marketplace, "requests"_n);
std::string memo = "Заказ №" + std::to_string(request_id);
std::vector<Document::named_document> documents;
Document::add_document(documents, DocumentNames::CONVERT_FROM, convert_in);
requests.emplace(_marketplace, [&](auto &i) {
i.id = request_id;
i.hash = hash;
i.type = "order"_n;
i.username = username;
i.coopname = coopname;
i.status = "active"_n;
i.units = units;
i.unit_cost = unit_cost;
i.base_cost = base_cost;
i.membership_fee_amount = membership_fee_amount;
i.total_cost = total_cost;
i.product_lifecycle_secs = product_lifecycle_secs;
i.warranty_period_secs = warranty_period_secs;
i.money_contributor = username;
i.meta = meta;
i.created_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.cancellation_fee_amount = cancellation_fee_amount;
i.receiver_braname = receiver_braname;
i.documents = documents;
});
// Блокировка средств заказчика
Wallet::sub_available_funds(_marketplace, coopname, username, total_cost, _wallet_program, memo);
Wallet::add_available_funds(_marketplace, coopname, username, total_cost, _marketplace_program, memo);
Wallet::block_funds(_marketplace, coopname, username, total_cost, _marketplace_program, memo);
};
@@ -0,0 +1,88 @@
/**
\ingroup public_actions
\brief Поставщик откликается на заказ встречным предложением.
@details Поставщик видит заказ и предлагает поставку.
Создаёт встречную заявку типа offer, привязанную к order.
Заявление на взнос имуществом отправляется в совет.
@param coopname Имя кооператива
@param supplier_braname КУ поставщика
@param username Имя поставщика
@param order_hash Хэш заказа
@param offer_hash Хэш предложения
@param units Количество предлагаемых единиц
@param product_lifecycle_secs Срок годности от поставщика
@param contribution_statement Заявление на взнос имуществом
@param convert_out Заявление на конвертацию
@note Авторизация: @p coopname
**/
[[eosio::action]] void marketplace::respondoffer(eosio::name coopname, eosio::name supplier_braname, eosio::name username, checksum256 order_hash, checksum256 offer_hash, uint64_t units, uint32_t product_lifecycle_secs, document2 contribution_statement, document2 convert_out) {
require_auth(coopname);
auto order_opt = Marketplace::get_request_by_hash(coopname, order_hash);
eosio::check(order_opt.has_value(), "Заказ не найден");
auto order = order_opt.value();
eosio::check(order.type == "order"_n, "respondoffer применим только к заказам типа order");
eosio::check(order.status == "active"_n, "Заказ должен быть активным");
eosio::check(units > 0 && units <= order.units, "Недопустимое количество единиц");
auto existing_offer = get_request_by_hash(coopname, offer_hash);
eosio::check(!existing_offer.has_value(), "Предложение с таким хэшем уже существует");
get_participant_or_fail(coopname, username);
get_branch_or_fail(coopname, supplier_braname);
verify_document_or_fail(contribution_statement);
verify_document_or_fail(convert_out);
eosio::asset supplier_amount = order.unit_cost * units;
requests_index requests(_marketplace, coopname.value);
uint64_t offer_id = get_global_id(_marketplace, "requests"_n);
std::vector<Document::named_document> documents;
Document::add_document(documents, DocumentNames::CONTRIB_STMT, contribution_statement);
Document::add_document(documents, DocumentNames::CONVERT_TO, convert_out);
requests.emplace(_marketplace, [&](auto &i) {
i.id = offer_id;
i.hash = offer_hash;
i.type = "offer"_n;
i.username = username;
i.coopname = coopname;
i.status = "accepted"_n;
i.units = units;
i.unit_cost = order.unit_cost;
i.base_cost = supplier_amount;
i.membership_fee_amount = eosio::asset(0, order.unit_cost.symbol);
i.total_cost = supplier_amount;
i.product_lifecycle_secs = product_lifecycle_secs;
i.warranty_period_secs = order.warranty_period_secs;
i.money_contributor = order.money_contributor;
i.product_contributor = username;
i.meta = "";
i.created_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.accepted_at = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
i.cancellation_fee_amount = eosio::asset(0, order.unit_cost.symbol);
i.receiver_braname = order.receiver_braname;
i.supplier_braname = supplier_braname;
i.documents = documents;
});
// Отправляем заявление на взнос в совет
::Soviet::create_agenda(
_marketplace,
coopname,
username,
get_valid_soviet_action("authcontrib"_n),
offer_hash,
_marketplace,
Marketplace::get_valid_marketplace_action("authcontrib"_n),
"declineacc"_n,
contribution_statement,
std::string("")
);
};
File diff suppressed because one or more lines are too long
@@ -11,29 +11,25 @@ export type IUint64 = number | string
export interface IAccept {
coopname: IName
supplier_braname: IName
username: IName
request_hash: IChecksum256
convert_out: IDocument2
return_document: IDocument2
exchange_id: IUint64
document: IDocument2
}
export interface IArrived {
export interface IAddunits {
coopname: IName
hash: IChecksum256
transport_act_delivery: IDocument2
username: IName
exchange_id: IUint64
units: IUint64
}
export interface IAuthcontrib {
export interface IAuthorize {
coopname: IName
request_hash: IChecksum256
authorization: IDocument2
}
export interface IAuthreturn {
coopname: IName
request_hash: IChecksum256
authorization: IDocument2
exchange_id: IUint64
contribution_product_decision_id: IUint64
contribution_product_authorization: IDocument2
return_product_decision_id: IUint64
return_product_authorization: IDocument2
}
export interface IBalances extends IBalancesBase {
@@ -48,13 +44,13 @@ export interface IBalancesBase {
export interface ICancel {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
}
export interface IComplete {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
}
export interface ICounts extends ICountsBase {
@@ -66,39 +62,23 @@ export interface ICountsBase {
value: IUint64
}
export interface ICreateship {
coopname: IName
hash: IChecksum256
driver_username: IName
source_braname: IName
destination_braname: IName
request_hashes: IChecksum256[]
transport_act_sender: IDocument2
}
export interface IDecline {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
meta: string
}
export interface IDeclineacc {
coopname: IName
hash: IChecksum256
reason: string
}
export interface IDelivered {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
}
export interface IDispute {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
document: IDocument2
}
@@ -111,137 +91,116 @@ export interface IDocument2 {
signatures: ISignatureInfo[]
}
export interface IMigrate {
}
export interface INamedDocument {
name: IName
document: IDocument2
}
export interface IOrderoffer {
coopname: IName
receiver_braname: IName
export interface IExchangeParams {
username: IName
hash: IChecksum256
parent_id: IUint64
program_id: IUint64
coopname: IName
units: IUint64
unit_cost: IAsset
product_lifecycle_secs: IUint32
warranty_period_secs: IUint32
membership_fee_amount: IAsset
cancellation_fee_amount: IAsset
product_return_statement: IDocument2
convert_in: IDocument2
product_lifecycle_secs: IUint64
document?: IDocument2
data: string
meta: string
}
export interface IReceive {
export interface IMigrate {
}
export interface IModerate {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
cancellation_fee: IUint64
}
export interface INewid {
id: IUint64
type: IName
}
export interface IOffer {
params: IExchangeParams
}
export interface IOrder {
params: IExchangeParams
}
export interface IProhibit {
coopname: IName
username: IName
exchange_id: IUint64
meta: string
}
export interface IPublish {
coopname: IName
username: IName
exchange_id: IUint64
}
export interface IRecieve {
coopname: IName
username: IName
exchange_id: IUint64
document: IDocument2
}
export interface IReceivecnf {
export interface IRecievecnfrm {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
document: IDocument2
}
export interface IReceiveshipm {
coopname: IName
hash: IChecksum256
warehouse_receipt_act: IDocument2
}
export interface IRequest {
id: IUint64
hash: IChecksum256
parent_id: IUint64
program_id: IUint64
coopname: IName
type: IName
status: IName
username: IName
braname: IName
warehouse: IName
parent_username: IName
token_contract: IName
receiver_braname: IName
supplier_braname: IName
unit_cost: IAsset
base_cost: IAsset
membership_fee_amount: IAsset
supplier_amount: IAsset
total_cost: IAsset
units: IUint64
membership_fee: IAsset
remain_units: IUint64
blocked_units: IUint64
delivered_units: IUint64
data: string
meta: string
money_contributor: IName
product_contributor: IName
documents: INamedDocument[]
contribute_product_statement: IDocument2
return_product_statement: IDocument2
contribution_product_decision_id: IUint64
contribution_product_authorization: IDocument2
return_product_decision_id: IUint64
return_product_authorization: IDocument2
product_contribution_act_validation: IDocument2
product_contribution_act: IDocument2
product_recieve_act: IDocument2
product_recieve_act_validation: IDocument2
product_lifecycle_secs: IUint64
warranty_period_secs: IUint64
cancellation_fee: IUint64
cancellation_fee_amount: IAsset
warranty_delay_until: ITimePointSec
deadline_for_receipt: ITimePointSec
is_warranty_return: boolean
warranty_return_id: IUint64
created_at: ITimePointSec
accepted_at: ITimePointSec
supplied_at: ITimePointSec
delivered_at: ITimePointSec
received_at: ITimePointSec
recieved_at: ITimePointSec
completed_at: ITimePointSec
declined_at: ITimePointSec
disputed_at: ITimePointSec
canceled_at: ITimePointSec
}
export interface IRetransport {
coopname: IName
completed_hash: IChecksum256
new_driver_username: IName
source_braname: IName
new_destination_braname: IName
request_hashes: IChecksum256[]
transport_act_sender: IDocument2
}
export interface ISegment {
id: IUint64
request_id: IUint64
type: IName
status: IName
convert_in: IDocument2
statement: IDocument2
decision_id: IUint64
authorization: IDocument2
act1: IDocument2
act2: IDocument2
convert_out: IDocument2
transport_act_1: IDocument2
transport_act_2: IDocument2
transport_act_3: IDocument2
transport_act_4: IDocument2
coopactor: IName
username: IName
driver_username: IName
receive_from_driver_coopactor: IName
created_at: ITimePointSec
updated_at: ITimePointSec
}
export interface IShipment {
id: IUint64
hash: IChecksum256
coopname: IName
driver_username: IName
source_braname: IName
destination_braname: IName
status: IName
request_hashes: IChecksum256[]
documents: INamedDocument[]
created_at: ITimePointSec
loaded_at: ITimePointSec
delivered_at: ITimePointSec
completed_at: ITimePointSec
warranty_delay_until: ITimePointSec
deadline_for_receipt: ITimePointSec
is_warranty_return: boolean
warranty_return_id: IUint64
}
export interface ISignatureInfo {
@@ -254,54 +213,33 @@ export interface ISignatureInfo {
meta: string
}
export interface ISignbydriver {
coopname: IName
hash: IChecksum256
transport_act_driver: IDocument2
}
export interface ISupplcnf {
coopname: IName
username: IName
request_hash: IChecksum256
act: IDocument2
}
export interface ISupply {
coopname: IName
username: IName
request_hash: IChecksum256
act: IDocument2
}
export interface IWaccept {
coopname: IName
username: IName
request_hash: IChecksum256
accept: boolean
exchange_id: IUint64
document: IDocument2
}
export interface IWauthorize {
coopname: IName
request_hash: IChecksum256
wreturn_decision_id: IUint64
wreturn_authorization: IDocument2
wsupply_decision_id: IUint64
wsupply_authorization: IDocument2
}
export interface IWoffer {
export interface ISupplycnfrm {
coopname: IName
username: IName
request_hash: IChecksum256
exchange_id: IUint64
document: IDocument2
}
export interface IWreturn {
export interface IUnpublish {
coopname: IName
username: IName
request_hash: IChecksum256
document: IDocument2
exchange_id: IUint64
}
export interface IUpdate {
coopname: IName
username: IName
exchange_id: IUint64
remain_units: IUint64
unit_cost: IAsset
data: string
meta: string
}
@@ -0,0 +1,265 @@
import { Order } from '../src/models';
import { TypeOrmPaymentRepository } from '../src/infrastructure/database/typeorm/repositories/typeorm-payment.repository';
import { PaymentEntity } from '../src/infrastructure/database/typeorm/entities/payment.entity';
import { DataSource } from 'typeorm';
import config from '../src/config/config';
import { PaymentStatusEnum } from '../src/domain/gateway/enums/payment-status.enum';
import { PaymentTypeEnum, PaymentDirectionEnum, getPaymentDirection } from '../src/domain/gateway/enums/payment-type.enum';
import type { PaymentDomainInterface } from '../src/domain/gateway/interfaces/payment-domain.interface';
import { sha256 } from '../src/utils/sha256';
import { generator } from '../src/services/document.service';
export default {
name: 'Миграция платежей из MongoDB в PostgreSQL (унифицированная модель)',
validUntil: new Date('2025-07-03'), // Действует до
async up(): Promise<boolean> {
console.log('Выполнение миграции: Перенос платежей из MongoDB в PostgreSQL (унифицированная модель)');
try {
await generator.connect(config.mongoose.url);
// Инициализируем подключение к PostgreSQL
const dataSource = new DataSource({
type: 'postgres',
host: config.postgres.host,
port: Number(config.postgres.port),
username: config.postgres.username,
password: config.postgres.password,
database: config.postgres.database,
entities: [PaymentEntity],
synchronize: true, // Создаем таблицу если она не существует
});
await dataSource.initialize();
console.log('Подключение к PostgreSQL установлено');
// Создаем репозиторий для работы с платежами
const paymentRepository = new TypeOrmPaymentRepository(dataSource.getRepository(PaymentEntity));
// Получаем все заказы из MongoDB
const orders = await Order.find();
console.log(`Найдено ${orders.length} заказов в MongoDB`);
let migratedCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const order of orders) {
try {
// Генерируем hash из order_num для поиска существующих платежей
const hash = sha256(String(order.order_num));
// Проверяем, существует ли уже платеж с таким hash
const existingPayment = await paymentRepository.findByHash(hash);
if (existingPayment) {
console.log(`Платеж с hash ${hash} (order_num: ${order.order_num}) уже существует, пропускаем`);
skippedCount++;
continue;
}
// Мапим старые статусы на новые
const statusMapping: Record<string, PaymentStatusEnum> = {
pending: PaymentStatusEnum.PENDING,
paid: PaymentStatusEnum.PAID,
completed: PaymentStatusEnum.COMPLETED,
failed: PaymentStatusEnum.FAILED,
expired: PaymentStatusEnum.EXPIRED,
refunded: PaymentStatusEnum.REFUNDED,
};
// Мапим старые типы на новые
const typeMapping: Record<string, PaymentTypeEnum> = {
registration: PaymentTypeEnum.REGISTRATION,
deposit: PaymentTypeEnum.DEPOSIT,
};
const paymentType = typeMapping[order.type] || PaymentTypeEnum.REGISTRATION;
const paymentDirection = getPaymentDirection(paymentType);
const paymentStatus = statusMapping[order.status] || PaymentStatusEnum.PENDING;
// Проверяем, что все мигрируемые платежи действительно входящие
if (paymentDirection !== PaymentDirectionEnum.INCOMING) {
console.warn(`⚠️ Платеж ${order.order_num} имеет неожиданное направление ${paymentDirection}, пропускаем`);
skippedCount++;
continue;
}
// Создаем объект платежа согласно доменному интерфейсу
const paymentData: PaymentDomainInterface = {
hash, // используем сгенерированный hash вместо blockchain_num
coopname: order.creator,
username: order.username,
quantity: parseFloat(order.quantity),
symbol: order.symbol,
type: paymentType,
direction: paymentDirection,
status: paymentStatus,
provider: order.provider,
secret: order.secret,
message: order.message,
expired_at: order.expired_at,
created_at: (order as any).createdAt || new Date(), // Mongoose timestamps
updated_at: (order as any).updatedAt || new Date(),
payment_details: order.details
? {
data: order.details.data,
amount_plus_fee: order.details.amount_plus_fee,
amount_without_fee: order.details.amount_without_fee,
fee_amount: order.details.fee_amount,
fee_percent: order.details.fee_percent,
fact_fee_percent: order.details.fact_fee_percent,
tolerance_percent: order.details.tolerance_percent,
}
: undefined,
};
// Создаем платеж через репозиторий
await paymentRepository.create(paymentData);
migratedCount++;
console.log(
`✅ Мигрирован платеж ${order.order_num} -> hash: ${hash} (${paymentType}/${paymentDirection}, ${paymentStatus})`
);
} catch (error) {
errorCount++;
console.error(`❌ Ошибка при миграции платежа ${order.order_num}:`, error);
}
}
console.log('\n=== Результаты миграции ===');
console.log(`Мигрировано: ${migratedCount}`);
console.log(`Пропущено: ${skippedCount}`);
console.log(`Ошибок: ${errorCount}`);
console.log(`Всего обработано: ${orders.length}`);
// Проверяем, что все мигрированные платежи действительно входящие
console.log('\n=== Проверка миграции ===');
const allPayments = await paymentRepository.getAllPayments({}, { page: 1, limit: 1000, sortOrder: 'ASC' });
const incomingCount = allPayments.items.filter(
(p: PaymentDomainInterface) => p.direction === PaymentDirectionEnum.INCOMING
).length;
const outgoingCount = allPayments.items.filter(
(p: PaymentDomainInterface) => p.direction === PaymentDirectionEnum.OUTGOING
).length;
console.log(`Входящих платежей: ${incomingCount}`);
console.log(`Исходящих платежей: ${outgoingCount}`);
if (outgoingCount > 0) {
console.warn(`⚠️ Обнаружены исходящие платежи после миграции! Это может указывать на проблему.`);
}
// Дополнительная валидация данных
console.log('\n=== Валидация данных ===');
const registrationPayments = allPayments.items.filter(
(p: PaymentDomainInterface) => p.type === PaymentTypeEnum.REGISTRATION
).length;
const depositPayments = allPayments.items.filter(
(p: PaymentDomainInterface) => p.type === PaymentTypeEnum.DEPOSIT
).length;
console.log(`Регистрационных платежей: ${registrationPayments}`);
console.log(`Депозитных платежей: ${depositPayments}`);
// Проверяем обязательные поля
const paymentsWithMissingData = allPayments.items.filter(
(p: PaymentDomainInterface) => !p.coopname || !p.username || !p.quantity || !p.symbol || !p.provider || !p.secret
);
if (paymentsWithMissingData.length > 0) {
console.warn(`⚠️ Найдено ${paymentsWithMissingData.length} платежей с отсутствующими обязательными данными`);
paymentsWithMissingData.forEach((p: PaymentDomainInterface) => {
console.warn(` Платеж ${p.hash}: отсутствуют поля`, {
coopname: !p.coopname,
username: !p.username,
quantity: !p.quantity,
symbol: !p.symbol,
provider: !p.provider,
secret: !p.secret,
});
});
} else {
console.log('✅ Все платежи имеют обязательные поля');
}
// Проверяем корректность типов и направлений
const invalidDirections = allPayments.items.filter((p: PaymentDomainInterface) => {
const expectedDirection = getPaymentDirection(p.type);
return p.direction !== expectedDirection;
});
if (invalidDirections.length > 0) {
console.warn(`⚠️ Найдено ${invalidDirections.length} платежей с некорректным направлением`);
} else {
console.log('✅ Все платежи имеют корректные направления');
}
// Создаем базовые индексы для оптимизации запросов
console.log('\n=== Создание индексов ===');
try {
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_type ON payments(type);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_direction ON payments(direction);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_username ON payments(username);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_coopname ON payments(coopname);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_secret ON payments(secret);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_hash ON payments(hash);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_quantity ON payments(quantity);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_symbol ON payments(symbol);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_expired_at ON payments(expired_at);
`);
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_payments_composite ON payments(coopname, username, status, type);
`);
console.log('Базовые индексы созданы');
} catch (indexError) {
console.warn('Ошибка при создании индексов:', indexError);
}
// Закрываем подключение к PostgreSQL
await dataSource.destroy();
console.log('Подключение к PostgreSQL закрыто');
console.log('Миграция завершена: Перенос платежей в унифицированную модель выполнен успешно');
return true;
} catch (error) {
console.error('Ошибка при выполнении миграции платежей:', error);
return false;
}
},
};
+2
View File
@@ -69,6 +69,7 @@
],
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
"@casl/ability": "^6.8.0",
"@coopenomics/factory": "workspace:*",
"@coopenomics/notifications": "workspace:*",
"@coopenomics/provider-client": "2025.11.12-alpha-1",
@@ -126,6 +127,7 @@
"graphql": "^16.9.0",
"graphql-request": "^7.1.2",
"graphql-scalars": "^1.24.0",
"graphql-subscriptions": "^3.0.0",
"graphql-tools": "^9.0.2",
"graphql-type-json": "^0.3.2",
"graphql-ws": "^5.16.0",
+272 -1
View File
@@ -16,6 +16,13 @@ input AcceptChildOrderInput {
username: String!
}
input AcceptStockInput {
convert_in: SignedDigitalDocumentInput!
request_hash: String!
return_statement: SignedDigitalDocumentInput!
username: String!
}
type Account {
"""
объект аккаунта в блокчейне содержит системную информацию, такую как публичные ключи доступа, доступные вычислительные ресурсы, информация об установленном смарт-контракте, и т.д. и т.п. Это системный уровень обслуживания, где у каждого пайщика есть аккаунт, но не каждый аккаунт может быть пайщиком в каком-либо кооперативе. Все смарт-контракты устанавливаются и исполняются на этом уровне.
@@ -788,6 +795,30 @@ input AnswerInput {
vote: String!
}
type ApiKeyCreated {
allowed_operations: [String!]!
created_at: DateTime!
expires_at: DateTime
id: String!
"""Полный ключ — показывается ТОЛЬКО при создании"""
key: String!
key_prefix: String!
name: String!
}
type ApiKeyInfo {
allowed_operations: [String!]!
created_at: DateTime!
created_by: String!
expires_at: DateTime
id: String!
is_active: Boolean!
key_prefix: String!
last_used_at: DateTime
name: String!
}
"""Одобрение документа председателем совета"""
type Approval {
"""Дата создания записи"""
@@ -1989,6 +2020,12 @@ input CapitalCycleFilter {
status: CycleStatus
}
type CapitalDataChange {
action: String!
entity: String!
id: String
}
"""Долг в системе CAPITAL"""
type CapitalDebt {
"""Дата создания записи"""
@@ -4079,6 +4116,17 @@ type CooperativeOperatorAccount {
verifications: [Verification!]!
}
input CoopstockInput {
braname: String!
hash: String!
membership_fee_amount: String!
meta: String!
product_lifecycle_secs: Int!
unit_cost: String!
units: Int!
warranty_period_secs: Int!
}
"""Страна регистрации пользователя"""
enum Country {
Russia
@@ -4110,6 +4158,12 @@ input CreateAnnualGeneralMeetInput {
secretary: String!
}
input CreateApiKeyInput {
allowedOperations: [String!] = ["*"]
expiresInDays: Int
name: String!
}
input CreateBranchInput {
"""
Документ, на основании которого действует Уполномоченный (решение совета №СС-.. от ..)
@@ -4581,6 +4635,25 @@ input CreateProjectPropertyInput {
username: String!
}
input CreateShareLinkInput {
allowedActions: [String!]!
expiresInDays: Float
linkName: String
pageName: String!
pagePath: String!
targetType: ShareTargetType!
targetUsername: String
}
input CreateShipmentInput {
destination_braname: String!
driver_username: String!
hash: String!
request_hashes: [String!]!
source_braname: String!
transport_act: SignedDigitalDocumentInput!
}
input CreateSovietIndividualDataInput {
"""Дата рождения"""
birthdate: String!
@@ -5014,6 +5087,11 @@ type DesktopWorkspace {
title: String!
}
input DestroyRequestInput {
destruction_act: SignedDigitalDocumentInput!
request_hash: String!
}
input DisputeOnRequestInput {
"""Имя аккаунта кооператива"""
coopname: String!
@@ -6350,6 +6428,12 @@ type KeyWeight {
weight: Int!
}
enum LeadRequestPolicy {
BOTH
OFFERS_ONLY
ORDERS_ONLY
}
type LedgerHistoryResponse {
"""Текущая страница"""
currentPage: Int!
@@ -6498,6 +6582,22 @@ input MakeClearanceInput {
username: String!
}
type MarketplaceSettings {
allowed_category_ids: [String!]!
coopname: String!
cycles_enabled: Boolean!
external_delivery_enabled: Boolean!
id: String!
internal_delivery_enabled: Boolean!
lead_request_policy: LeadRequestPolicy!
max_cycle_days: Int!
max_unit_cost: String
min_unit_cost: String
moderation_required: Boolean!
publish_access_policy: PublishAccessPolicy!
publish_whitelist: [String!]!
}
type MatrixAccountStatusResponseDTO {
hasAccount: Boolean!
iframeUrl: String
@@ -6787,6 +6887,9 @@ type Mutation {
"""Подтвердить поставку имущества на заявку"""
acceptChildOrder(data: AcceptChildOrderInput!): Transaction!
"""Принять предложение из запасов кооператива"""
acceptStock(data: AcceptStockInput!): Transaction!
"""
Добавить активного пайщика, который вступил в кооператив, не используя платформу (заполнив заявление собственноручно, оплатив вступительный и минимальный паевый взносы, и получив протокол решения совета)
"""
@@ -6795,6 +6898,9 @@ type Mutation {
"""Добавить метод оплаты (банковский счёт или СБП)"""
addPaymentMethod(data: AddPaymentMethodInput!): PaymentMethod!
"""Добавить пайщика в белый список публикации"""
addToPublishWhitelist(username: String!): Boolean!
"""Добавить доверенное лицо кооперативного участка"""
addTrustedAccount(data: AddTrustedAccountInput!): Branch!
@@ -7061,11 +7167,19 @@ type Mutation {
"""
confirmSupplyOnRequest(data: ConfirmSupplyOnRequestInput!): Transaction!
"""Создать предложение из запасов кооператива"""
coopstock(data: CoopstockInput!): Transaction!
"""
Сгенерировать документ предложения повестки очередного общего собрания пайщиков
"""
createAnnualGeneralMeet(data: CreateAnnualGeneralMeetInput!): MeetAggregate!
"""
Создать API ключ кооператива. Полный ключ показывается только при создании.
"""
createApiKey(data: CreateApiKeyInput!): ApiKeyCreated!
"""Создать кооперативный участок"""
createBranch(data: CreateBranchInput!): Branch!
@@ -7090,6 +7204,12 @@ type Mutation {
"""
createProjectOfFreeDecision(data: CreateProjectFreeDecisionInput!): CreatedProjectFreeDecision!
"""Создать ссылку доступа к странице"""
createShareLink(data: CreateShareLinkInput!): ShareLink!
"""Создать перевозку (КУ отправителя)"""
createShipment(data: CreateShipmentInput!): Transaction!
"""Создать веб-пуш подписку для пользователя"""
createWebPushSubscription(data: CreateSubscriptionInput!): CreateSubscriptionResponse!
@@ -7117,6 +7237,9 @@ type Mutation {
"""Подтвердить доставку имущества Заказчику по заявке"""
deliverOnRequest(data: DeliverOnRequestInput!): Transaction!
"""Уничтожить просроченное имущество"""
destroyRequest(data: DestroyRequestInput!): Transaction!
"""Открыть спор по заявке"""
disputeOnRequest(data: DisputeOnRequestInput!): Transaction!
@@ -7175,7 +7298,7 @@ type Mutation {
generateRegistrationDocuments(data: GenerateRegistrationDocumentsInput!): GenerateRegistrationDocumentsOutput!
"""Генерация отчёта для ФНС/ФСС"""
generateReport(data: GenerateReportInput!): GeneratedReport!
generateReport(data: GenerateReportInput!, organization: OrganizationDataInput!): GeneratedReport!
"""Сгенерировать документ акта возврата имущества."""
generateReturnByAssetAct(data: ReturnByAssetActGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
@@ -7259,6 +7382,9 @@ type Mutation {
"""
receiveOnRequest(data: ReceiveOnRequestInput!): Transaction!
"""Приём перевозки на складе КУ получателя"""
receiveShipment(data: SignShipmentInput!): Transaction!
"""Обновить токен доступа аккаунта"""
refresh(data: RefreshInput!): RegisteredAccount!
@@ -7270,12 +7396,27 @@ type Mutation {
"""
registerParticipant(data: RegisterParticipantInput!): Account!
"""Удалить пайщика из белого списка публикации"""
removeFromPublishWhitelist(username: String!): Boolean!
"""Перепредложить имущество по новой цене"""
reofferRequest(data: ReofferRequestInput!): Transaction!
"""Запросить возврат паевого взноса имуществом (перед получением)"""
reqReturn(data: ReqReturnInput!): Transaction!
"""Заменить приватный ключ аккаунта"""
resetKey(data: ResetKeyInput!): Boolean!
"""Перезапуск общего собрания пайщиков"""
restartAnnualGeneralMeet(data: RestartAnnualGeneralMeetInput!): MeetAggregate!
"""Отозвать API ключ"""
revokeApiKey(id: String!): Boolean!
"""Отозвать ссылку доступа"""
revokeShareLink(id: String!): Boolean!
"""Выбрать кооперативный участок"""
selectBranch(data: SelectBranchInput!): Boolean!
@@ -7290,12 +7431,18 @@ type Mutation {
"""Сохранить приватный ключ в зашифрованном серверном хранилище"""
setWif(data: SetWifInput!): Boolean!
"""Водитель отмечает прибытие"""
shipmentArrived(data: SignShipmentInput!): Transaction!
"""Подписание решения председателем на общем собрании пайщиков"""
signByPresiderOnAnnualGeneralMeet(data: SignByPresiderOnAnnualGeneralMeetInput!): MeetAggregate!
"""Подписание решения секретарём на общем собрании пайщиков"""
signBySecretaryOnAnnualGeneralMeet(data: SignBySecretaryOnAnnualGeneralMeetInput!): MeetAggregate!
"""Подпись водителя на перевозке"""
signShipmentByDriver(data: SignShipmentInput!): Transaction!
"""
Начать процесс установки кооператива, установить ключ и получить код установки
"""
@@ -7333,6 +7480,9 @@ type Mutation {
"""Обновить расширение"""
updateExtension(data: ExtensionInput!): Extension!
"""Обновить настройки маркетплейса (только chairman)"""
updateMarketplaceSettings(data: UpdateMarketplaceSettingsInput!): MarketplaceSettings!
"""Обновить заявку"""
updateRequest(data: UpdateRequestInput!): Transaction!
@@ -7461,6 +7611,23 @@ type OrganizationCertificate {
username: String!
}
input OrganizationDataInput {
address: String
inn: String!
kpp: String!
ogrn: String!
okfs: String
okopf: String
oktmo: String!
okved: String!
orgName: String!
phone: String
signerFirstName: String!
signerLastName: String!
signerMiddleName: String
signerSnils: String
}
type OrganizationDetails {
"""ИНН"""
inn: String!
@@ -8653,6 +8820,12 @@ type PublicChairman {
middle_name: String!
}
enum PublishAccessPolicy {
ALL_MEMBERS
COUNCIL_ONLY
WHITELIST
}
input PublishProjectFreeDecisionInput {
"""Имя аккаунта кооператива"""
coopname: String!
@@ -8837,6 +9010,9 @@ type Query {
"""Получить список вопросов совета кооператива для голосования"""
getAgenda: [AgendaWithDocuments!]!
"""Получить список API ключей кооператива"""
getApiKeys: [ApiKeyInfo!]!
"""Получить список доступных типов отчётов"""
getAvailableReports: [AvailableReport!]!
@@ -8891,12 +9067,18 @@ type Query {
"""
getLedgerHistory(data: GetLedgerHistoryInput!): LedgerHistoryResponse!
"""Получить настройки маркетплейса"""
getMarketplaceSettings: MarketplaceSettings!
"""Получить данные собрания по хешу"""
getMeet(data: GetMeetInput!): MeetAggregate!
"""Получить список всех собраний кооператива"""
getMeets(data: GetMeetsInput!): [MeetAggregate!]!
"""Получить созданные мной ссылки доступа"""
getMyShareLinks: [ShareLink!]!
"""Получить список методов оплаты"""
getPaymentMethods(data: GetPaymentMethodsInput): PaymentMethodPaginationResult!
@@ -8920,6 +9102,9 @@ type Query {
"""Получить конфигурацию программ регистрации для кооператива"""
getRegistrationConfig(account_type: AccountType!, coopname: String!): RegistrationConfig!
"""Получить страницы, к которым мне предоставлен доступ"""
getSharedWithMe: [ShareLink!]!
"""Получить сводную публичную информацию о системе"""
getSystemInfo: SystemInfo!
@@ -9180,6 +9365,13 @@ type RegistrationProgram {
title: String!
}
input ReofferRequestInput {
new_hash: String!
new_meta: String!
new_unit_cost: String!
request_hash: String!
}
enum ReportType {
BUHOTCH
DUSN
@@ -9230,6 +9422,12 @@ input RepresentedByInput {
position: String!
}
input ReqReturnInput {
request_hash: String!
return_statement: SignedDigitalDocumentInput!
username: String!
}
input ResetKeyInput {
"""Публичный ключ для замены"""
public_key: String!
@@ -10028,6 +10226,25 @@ type Settings {
updated_at: DateTime!
}
type ShareLink {
allowed_actions: [String!]!
created_at: DateTime!
expires_at: DateTime
id: String!
is_active: Boolean!
link_name: String
page_name: String!
page_path: String!
target_type: ShareTargetType!
target_username: String
token: String!
}
enum ShareTargetType {
GUEST
MEMBER
}
input SignActAsChairmanInput {
"""Акт о вкладе результатов"""
act: SignedDigitalDocumentInput!
@@ -10080,6 +10297,11 @@ input SignBySecretaryOnAnnualGeneralMeetInput {
username: String!
}
input SignShipmentInput {
document: SignedDigitalDocumentInput!
hash: String!
}
type SignatureInfo {
id: Float!
is_valid: Boolean
@@ -10166,6 +10388,11 @@ input SignedDigitalDocumentInput {
version: String!
}
type SovietDataChange {
action: String!
entity: String!
}
input SovietMemberInput {
individual_data: CreateSovietIndividualDataInput!
role: String!
@@ -10244,6 +10471,31 @@ input SubmitVoteInput {
votes: [VoteDistributionInput!]!
}
type Subscription {
"""Подписка на создание коммитов"""
capitalCommitCreated(project_hash: String): CapitalCommit!
"""Подписка на обновления коммитов"""
capitalCommitUpdated(project_hash: String): CapitalCommit!
"""
Уведомление об изменении данных Capital (проекты, участники, голосования)
"""
capitalDataChanged: CapitalDataChange!
"""Подписка на создание задач"""
capitalIssueCreated(project_hash: String): CapitalIssue!
"""Подписка на обновления задач"""
capitalIssueUpdated(project_hash: String): CapitalIssue!
"""Уведомление об изменениях собраний, решений, повестки"""
sovietDataChanged: SovietDataChange!
"""Уведомление об изменении состояния системы (статус, конфигурация)"""
systemStatusChanged: SystemStatusChange!
}
type SubscriptionStatsDto {
"""Количество активных подписок"""
active: Int!
@@ -10345,6 +10597,11 @@ enum SystemStatus {
maintenance
}
type SystemStatusChange {
message: String
status: String!
}
type Token {
"""Дата истечения токена доступа"""
expires: DateTime!
@@ -10580,6 +10837,20 @@ input UpdateIssueInput {
title: String
}
input UpdateMarketplaceSettingsInput {
allowed_category_ids: [String!]
cycles_enabled: Boolean
external_delivery_enabled: Boolean
internal_delivery_enabled: Boolean
lead_request_policy: LeadRequestPolicy
max_cycle_days: Int
max_unit_cost: String
min_unit_cost: String
moderation_required: Boolean
publish_access_policy: PublishAccessPolicy
publish_whitelist: [String!]
}
input UpdateOrganizationDataInput {
"""Город"""
city: String!
+14
View File
@@ -16,6 +16,12 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
import { CaslModule } from './infrastructure/casl/casl.module';
import { PubSubModule } from './infrastructure/pubsub/pubsub.module';
import { ShareModule } from './infrastructure/share/share.module';
import { ApiKeyModule } from './infrastructure/api-keys/api-key.module';
import { ApiKeysAppModule } from './application/api-keys/api-keys.module';
import { ShareAppModule } from './application/share/share.module';
// Domain modules
import { AccountDomainModule } from './domain/account/account-domain.module';
@@ -73,6 +79,7 @@ import { SettingsApplicationModule } from './application/settings/settings.modul
import { RegistrationModule } from './application/registration/registration.module';
import { SearchModule } from './application/search/search.module';
import { ReportsExtensionModule } from './extensions/reports/reports-extension.module';
import { CooplaceExtensionModule } from './extensions/cooplace/cooplace-extension.module';
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
@Module({
@@ -95,6 +102,10 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
RedisModule,
NovuModule,
SearchInfrastructureModule,
CaslModule,
PubSubModule,
ShareModule,
ApiKeyModule,
EventsInfrastructureModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
@@ -152,7 +163,10 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
SettingsApplicationModule,
RegistrationModule,
SearchModule,
ShareAppModule,
ApiKeysAppModule,
ReportsExtensionModule,
CooplaceExtensionModule,
],
providers: [
{
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ApiKeyResolver } from './resolvers/api-key.resolver';
@Module({
providers: [ApiKeyResolver],
})
export class ApiKeysAppModule {}
@@ -0,0 +1,73 @@
import { ObjectType, Field, InputType, Int } from '@nestjs/graphql';
import { IsString, IsOptional, IsInt, IsArray } from 'class-validator';
@InputType('CreateApiKeyInput')
export class CreateApiKeyInputDTO {
@Field(() => String)
@IsString()
name!: string;
@Field(() => [String], { nullable: true, defaultValue: ['*'] })
@IsOptional()
@IsArray()
allowedOperations?: string[];
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
expiresInDays?: number;
}
@ObjectType('ApiKeyCreated')
export class ApiKeyCreatedDTO {
@Field(() => String)
id!: string;
@Field(() => String)
name!: string;
@Field(() => String, { description: 'Полный ключ — показывается ТОЛЬКО при создании' })
key!: string;
@Field(() => String)
key_prefix!: string;
@Field(() => [String])
allowed_operations!: string[];
@Field(() => Date, { nullable: true })
expires_at?: Date;
@Field(() => Date)
created_at!: Date;
}
@ObjectType('ApiKeyInfo')
export class ApiKeyInfoDTO {
@Field(() => String)
id!: string;
@Field(() => String)
name!: string;
@Field(() => String)
key_prefix!: string;
@Field(() => String)
created_by!: string;
@Field(() => [String])
allowed_operations!: string[];
@Field(() => Boolean)
is_active!: boolean;
@Field(() => Date, { nullable: true })
expires_at?: Date;
@Field(() => Date, { nullable: true })
last_used_at?: Date;
@Field(() => Date)
created_at!: Date;
}
@@ -0,0 +1,58 @@
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { ApiKeyService } from '~/infrastructure/api-keys/api-key.service';
import { CreateApiKeyInputDTO, ApiKeyCreatedDTO, ApiKeyInfoDTO } from '../dto/api-key.dto';
import { config } from '~/config';
@Resolver()
export class ApiKeyResolver {
constructor(private readonly apiKeyService: ApiKeyService) {}
@Mutation(() => ApiKeyCreatedDTO, {
name: 'createApiKey',
description: 'Создать API ключ кооператива. Полный ключ показывается только при создании.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async createApiKey(
@Args('data') data: CreateApiKeyInputDTO,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ApiKeyCreatedDTO> {
return this.apiKeyService.createKey({
coopname: config.coopname,
name: data.name,
createdBy: user.username,
allowedOperations: data.allowedOperations,
expiresInDays: data.expiresInDays,
});
}
@Query(() => [ApiKeyInfoDTO], {
name: 'getApiKeys',
description: 'Получить список API ключей кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async getApiKeys(): Promise<ApiKeyInfoDTO[]> {
return this.apiKeyService.listKeys(config.coopname) as any;
}
@Mutation(() => Boolean, {
name: 'revokeApiKey',
description: 'Отозвать API ключ',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async revokeApiKey(
@Args('id') id: string,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<boolean> {
await this.apiKeyService.revokeKey(id, user.username);
return true;
}
}
@@ -59,6 +59,16 @@ export class RolesGuard implements CanActivate {
return true;
}
// Проверка: есть ли у пользователя делегированные права (через share tokens)
if (user.grantedPermissions && Array.isArray(user.grantedPermissions)) {
const hasGranted = user.grantedPermissions.some(
(p: any) => p.action === 'read' || p.action === 'manage'
);
if (hasGranted) {
return true;
}
}
throw new UnauthorizedException(`Недостаточно прав доступа`);
}
}
@@ -0,0 +1,20 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString } from 'class-validator';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
@InputType('AcceptStockInput')
export class AcceptStockInputDTO {
@Field(() => String)
@IsString()
username!: string;
@Field(() => String)
@IsString()
request_hash!: string;
@Field(() => SignedDigitalDocumentInputDTO)
convert_in!: SignedDigitalDocumentInputDTO;
@Field(() => SignedDigitalDocumentInputDTO)
return_statement!: SignedDigitalDocumentInputDTO;
}
@@ -0,0 +1,37 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsString, IsInt } from 'class-validator';
@InputType('CoopstockInput')
export class CoopstockInputDTO {
@Field(() => String)
@IsString()
braname!: string;
@Field(() => String)
@IsString()
hash!: string;
@Field(() => Int)
@IsInt()
units!: number;
@Field(() => String)
@IsString()
unit_cost!: string;
@Field(() => Int)
@IsInt()
product_lifecycle_secs!: number;
@Field(() => Int)
@IsInt()
warranty_period_secs!: number;
@Field(() => String)
@IsString()
membership_fee_amount!: string;
@Field(() => String)
@IsString()
meta!: string;
}
@@ -0,0 +1,13 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString } from 'class-validator';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
@InputType('DestroyRequestInput')
export class DestroyRequestInputDTO {
@Field(() => String)
@IsString()
request_hash!: string;
@Field(() => SignedDigitalDocumentInputDTO)
destruction_act!: SignedDigitalDocumentInputDTO;
}
@@ -0,0 +1,21 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString } from 'class-validator';
@InputType('ReofferRequestInput')
export class ReofferRequestInputDTO {
@Field(() => String)
@IsString()
request_hash!: string;
@Field(() => String)
@IsString()
new_hash!: string;
@Field(() => String)
@IsString()
new_unit_cost!: string;
@Field(() => String)
@IsString()
new_meta!: string;
}
@@ -0,0 +1,17 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString } from 'class-validator';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
@InputType('ReqReturnInput')
export class ReqReturnInputDTO {
@Field(() => String)
@IsString()
username!: string;
@Field(() => String)
@IsString()
request_hash!: string;
@Field(() => SignedDigitalDocumentInputDTO)
return_statement!: SignedDigitalDocumentInputDTO;
}
@@ -11,8 +11,17 @@ import type { CreateChildOrderInputDomainInterface } from '~/domain/cooplace/int
import type { ReceiveOnRequestInputDomainInterface } from '~/domain/cooplace/interfaces/receive-on-request-input.interface';
import type { SupplyOnRequestInputDomainInterface } from '~/domain/cooplace/interfaces/supply-on-request-input.interface';
import { config } from '~/config';
import type { ReqReturnInputDTO } from '../dto/req-return-input.dto';
import type { CoopstockInputDTO } from '../dto/coopstock-input.dto';
import type { AcceptStockInputDTO } from '../dto/accept-stock-input.dto';
import type { DestroyRequestInputDTO } from '../dto/destroy-request-input.dto';
import type { ReofferRequestInputDTO } from '../dto/reoffer-request-input.dto';
@Injectable()
export class CooplaceInteractor {
private readonly config = config;
constructor(
private readonly documentDomainService: DocumentDomainService,
@Inject(COOPLACE_BLOCKCHAIN_PORT) private readonly cooplaceBlockchainPort: CooplaceBlockchainPort
@@ -170,4 +179,63 @@ export class CooplaceInteractor {
const result = await this.cooplaceBlockchainPort.updateRequest(data);
return result;
}
public async reqReturn(data: ReqReturnInputDTO): Promise<TransactResult> {
const doc: Record<string, any> = { ...(data.return_statement as any) };
doc.meta = JSON.stringify(doc.meta);
return await this.cooplaceBlockchainPort.reqReturn({
coopname: this.config.coopname,
username: data.username,
request_hash: data.request_hash,
return_statement: doc,
});
}
public async coopstock(data: CoopstockInputDTO): Promise<TransactResult> {
return await this.cooplaceBlockchainPort.coopstock({
coopname: this.config.coopname,
braname: data.braname,
hash: data.hash,
units: data.units,
unit_cost: data.unit_cost,
product_lifecycle_secs: data.product_lifecycle_secs,
warranty_period_secs: data.warranty_period_secs,
membership_fee_amount: data.membership_fee_amount,
meta: data.meta,
});
}
public async acceptStock(data: AcceptStockInputDTO): Promise<TransactResult> {
const cin: Record<string, any> = { ...(data.convert_in as any) };
cin.meta = JSON.stringify(cin.meta);
const rs: Record<string, any> = { ...(data.return_statement as any) };
rs.meta = JSON.stringify(rs.meta);
return await this.cooplaceBlockchainPort.acceptStock({
coopname: this.config.coopname,
username: data.username,
request_hash: data.request_hash,
convert_in: cin,
return_statement: rs,
});
}
public async destroyRequest(data: DestroyRequestInputDTO): Promise<TransactResult> {
const act: Record<string, any> = { ...(data.destruction_act as any) };
act.meta = JSON.stringify(act.meta);
return await this.cooplaceBlockchainPort.destroy({
coopname: this.config.coopname,
request_hash: data.request_hash,
destruction_act: act,
});
}
public async reofferRequest(data: ReofferRequestInputDTO): Promise<TransactResult> {
return await this.cooplaceBlockchainPort.reoffer({
coopname: this.config.coopname,
request_hash: data.request_hash,
new_hash: data.new_hash,
new_unit_cost: data.new_unit_cost,
new_meta: data.new_meta,
});
}
}
@@ -30,6 +30,11 @@ import { ReceiveOnRequestInputDTO } from '../dto/receive-on-request-input.dto';
import { SupplyOnRequestInputDTO } from '../dto/supply-on-request-input.dto';
import { UnpublishRequestInputDTO } from '../dto/unpublish-request-input.dto';
import { UpdateRequestInputDTO } from '../dto/update-request-input.dto';
import { ReqReturnInputDTO } from '../dto/req-return-input.dto';
import { CoopstockInputDTO } from '../dto/coopstock-input.dto';
import { AcceptStockInputDTO } from '../dto/accept-stock-input.dto';
import { DestroyRequestInputDTO } from '../dto/destroy-request-input.dto';
import { ReofferRequestInputDTO } from '../dto/reoffer-request-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
@Resolver()
@@ -301,4 +306,69 @@ export class CooplaceResolver {
): Promise<TransactionDTO> {
return this.cooplaceService.updateRequest(data);
}
@Mutation(() => TransactionDTO, {
name: 'reqReturn',
description: 'Запросить возврат паевого взноса имуществом (перед получением)',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async reqReturn(
@Args('data', { type: () => ReqReturnInputDTO }) data: ReqReturnInputDTO
): Promise<TransactionDTO> {
return this.cooplaceService.reqReturn(data);
}
@Mutation(() => TransactionDTO, {
name: 'coopstock',
description: 'Создать предложение из запасов кооператива',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async coopstock(
@Args('data', { type: () => CoopstockInputDTO }) data: CoopstockInputDTO
): Promise<TransactionDTO> {
return this.cooplaceService.coopstock(data);
}
@Mutation(() => TransactionDTO, {
name: 'acceptStock',
description: 'Принять предложение из запасов кооператива',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async acceptStock(
@Args('data', { type: () => AcceptStockInputDTO }) data: AcceptStockInputDTO
): Promise<TransactionDTO> {
return this.cooplaceService.acceptStock(data);
}
@Mutation(() => TransactionDTO, {
name: 'destroyRequest',
description: 'Уничтожить просроченное имущество',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async destroyRequest(
@Args('data', { type: () => DestroyRequestInputDTO }) data: DestroyRequestInputDTO
): Promise<TransactionDTO> {
return this.cooplaceService.destroyRequest(data);
}
@Mutation(() => TransactionDTO, {
name: 'reofferRequest',
description: 'Перепредложить имущество по новой цене',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async reofferRequest(
@Args('data', { type: () => ReofferRequestInputDTO }) data: ReofferRequestInputDTO
): Promise<TransactionDTO> {
return this.cooplaceService.reofferRequest(data);
}
}
@@ -7,6 +7,11 @@ import type { ReturnByAssetActGenerateDocumentInputDTO } from '../../document/do
import type { ReturnByAssetDecisionGenerateDocumentInputDTO } from '../../document/documents-dto/return-by-asset-decision-document.dto';
import type { ReturnByAssetStatementGenerateDocumentInputDTO } from '../../document/documents-dto/return-by-asset-statement-document.dto';
import { CooplaceInteractor } from '../interactors/cooplace.interactor';
import type { ReqReturnInputDTO } from '../dto/req-return-input.dto';
import type { CoopstockInputDTO } from '../dto/coopstock-input.dto';
import type { AcceptStockInputDTO } from '../dto/accept-stock-input.dto';
import type { DestroyRequestInputDTO } from '../dto/destroy-request-input.dto';
import type { ReofferRequestInputDTO } from '../dto/reoffer-request-input.dto';
import type { AcceptChildOrderInputDTO } from '../dto/accept-child-order-input.dto';
import type { CancelRequestInputDTO } from '../dto/cancel-request-input.dto';
import type { CompleteRequestInputDTO } from '../dto/complete-request-input.dto';
@@ -163,4 +168,24 @@ export class CooplaceService {
const result = await this.cooplaceInteractor.updateRequest(data);
return result;
}
public async reqReturn(data: ReqReturnInputDTO): Promise<TransactionDTO> {
return await this.cooplaceInteractor.reqReturn(data);
}
public async coopstock(data: CoopstockInputDTO): Promise<TransactionDTO> {
return await this.cooplaceInteractor.coopstock(data);
}
public async acceptStock(data: AcceptStockInputDTO): Promise<TransactionDTO> {
return await this.cooplaceInteractor.acceptStock(data);
}
public async destroyRequest(data: DestroyRequestInputDTO): Promise<TransactionDTO> {
return await this.cooplaceInteractor.destroyRequest(data);
}
public async reofferRequest(data: ReofferRequestInputDTO): Promise<TransactionDTO> {
return await this.cooplaceInteractor.reofferRequest(data);
}
}
@@ -12,5 +12,6 @@ import { LedgerDomainModule } from '~/domain/ledger/ledger-domain.module';
@Module({
imports: [LedgerDomainModule],
providers: [LedgerResolver, LedgerService, LedgerEventService, LedgerInteractor],
exports: [LedgerInteractor],
})
export class LedgerModule {}
@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { DocumentModule } from '../document/document.module';
import { MeetResolver } from './resolvers/meet.resolver';
import { MeetSubscriptionResolver } from './resolvers/meet-subscription.resolver';
import { MeetService } from './services/meet.service';
import { MeetEventService } from './services/meet-event.service';
import { MeetInteractor } from './interactors/meet.interactor';
import { AgendaNotificationService } from '../agenda/services/agenda-notification.service';
import { DecisionNotificationService } from '../agenda/services/decision-notification.service';
import { NovuModule } from '~/infrastructure/novu/novu.module';
import { PubSubModule } from '~/infrastructure/pubsub/pubsub.module';
import { MeetDomainModule } from '~/domain/meet/meet-domain.module';
import { UserDomainModule } from '~/domain/user/user-domain.module';
import { UserInfrastructureModule } from '~/infrastructure/user/user-infrastructure.module';
@@ -23,6 +25,7 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
DatabaseModule,
BlockchainModule,
NovuModule,
PubSubModule,
MeetDomainModule,
UserInfrastructureModule,
UserDomainModule,
@@ -32,6 +35,7 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
controllers: [],
providers: [
MeetResolver,
MeetSubscriptionResolver,
MeetService,
MeetEventService,
MeetInteractor,
@@ -0,0 +1,30 @@
import { Resolver, Subscription, ObjectType, Field } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
export const SOVIET_EVENTS = {
DATA_CHANGED: 'sovietDataChanged',
};
@ObjectType('SovietDataChange')
export class SovietDataChangeDTO {
@Field(() => String)
entity!: string;
@Field(() => String)
action!: string;
}
@Resolver()
export class MeetSubscriptionResolver {
constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {}
@Subscription(() => SovietDataChangeDTO, {
name: 'sovietDataChanged',
description: 'Уведомление об изменениях собраний, решений, повестки',
})
sovietDataChanged() {
return this.pubSub.asyncIterableIterator(SOVIET_EVENTS.DATA_CHANGED);
}
}
@@ -1,13 +1,16 @@
// application/meet/services/meet-event.service.ts
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { MeetInteractor } from '../interactors/meet.interactor';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import type { MeetDecisionDomainInterface } from '~/domain/meet/interfaces/meet-decision-domain.interface';
import { MeetContract } from 'cooptypes';
import type { IAction } from '~/types';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { SOVIET_EVENTS } from '../resolvers/meet-subscription.resolver';
/**
* Сервис обработки событий собраний
@@ -15,10 +18,20 @@ import type { IAction } from '~/types';
*/
@Injectable()
export class MeetEventService {
constructor(private readonly meetInteractor: MeetInteractor, private readonly logger: WinstonLoggerService) {
constructor(
private readonly meetInteractor: MeetInteractor,
private readonly logger: WinstonLoggerService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {
this.logger.setContext(MeetEventService.name);
}
private notifySovietChanged(entity: string, action: string) {
this.pubSub.publish(SOVIET_EVENTS.DATA_CHANGED, {
sovietDataChanged: { entity, action },
});
}
/**
* Обработчик события решения собрания из блокчейна
*/
@@ -51,6 +64,7 @@ export class MeetEventService {
});
this.logger.info(`Processed meet decision for ${event.data.hash}`);
this.notifySovietChanged('decision', 'processed');
} catch (error: any) {
this.logger.error(`Error processing meet decision: ${error.message}`, error.stack);
}
@@ -0,0 +1,75 @@
import { ObjectType, Field, InputType, registerEnumType } from '@nestjs/graphql';
import { IsString, IsOptional, IsArray, IsInt, IsEnum } from 'class-validator';
import { ShareTargetType } from '~/infrastructure/share/share-token.entity';
registerEnumType(ShareTargetType, { name: 'ShareTargetType' });
@InputType('CreateShareLinkInput')
export class CreateShareLinkInputDTO {
@Field(() => String)
@IsString()
pagePath!: string;
@Field(() => String)
@IsString()
pageName!: string;
@Field(() => ShareTargetType)
@IsEnum(ShareTargetType)
targetType!: ShareTargetType;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
targetUsername?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
linkName?: string;
@Field(() => [String])
@IsArray()
allowedActions!: string[];
@Field(() => Number, { nullable: true })
@IsOptional()
@IsInt()
expiresInDays?: number;
}
@ObjectType('ShareLink')
export class ShareLinkDTO {
@Field(() => String)
id!: string;
@Field(() => String)
page_path!: string;
@Field(() => String)
page_name!: string;
@Field(() => ShareTargetType)
target_type!: ShareTargetType;
@Field(() => String, { nullable: true })
target_username?: string;
@Field(() => String, { nullable: true })
link_name?: string;
@Field(() => [String])
allowed_actions!: string[];
@Field(() => String)
token!: string;
@Field(() => Boolean)
is_active!: boolean;
@Field(() => Date)
created_at!: Date;
@Field(() => Date, { nullable: true })
expires_at?: Date;
}
@@ -0,0 +1,70 @@
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { ShareService } from '~/infrastructure/share/share.service';
import { ShareLinkDTO, CreateShareLinkInputDTO } from '../dto/share.dto';
import { config } from '~/config';
@Resolver()
export class ShareResolver {
constructor(private readonly shareService: ShareService) {}
@Mutation(() => ShareLinkDTO, {
name: 'createShareLink',
description: 'Создать ссылку доступа к странице',
})
@UseGuards(GqlJwtAuthGuard)
async createShareLink(
@Args('data') data: CreateShareLinkInputDTO,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ShareLinkDTO> {
return this.shareService.createShareLink({
coopname: config.coopname,
createdBy: user.username,
pagePath: data.pagePath,
pageName: data.pageName,
targetType: data.targetType,
targetUsername: data.targetUsername,
linkName: data.linkName,
allowedActions: data.allowedActions,
expiresInDays: data.expiresInDays,
}) as any;
}
@Mutation(() => Boolean, {
name: 'revokeShareLink',
description: 'Отозвать ссылку доступа',
})
@UseGuards(GqlJwtAuthGuard)
async revokeShareLink(
@Args('id') id: string,
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<boolean> {
await this.shareService.revokeShareLink(id, user.username);
return true;
}
@Query(() => [ShareLinkDTO], {
name: 'getMyShareLinks',
description: 'Получить созданные мной ссылки доступа',
})
@UseGuards(GqlJwtAuthGuard)
async getMyShareLinks(
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ShareLinkDTO[]> {
return this.shareService.getShareLinks(config.coopname, user.username) as any;
}
@Query(() => [ShareLinkDTO], {
name: 'getSharedWithMe',
description: 'Получить страницы, к которым мне предоставлен доступ',
})
@UseGuards(GqlJwtAuthGuard)
async getSharedWithMe(
@CurrentUser() user: MonoAccountDomainInterface,
): Promise<ShareLinkDTO[]> {
return this.shareService.getSharedWithMe(config.coopname, user.username) as any;
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ShareResolver } from './resolvers/share.resolver';
@Module({
providers: [ShareResolver],
})
export class ShareAppModule {}
@@ -0,0 +1,30 @@
import { Resolver, Subscription, ObjectType, Field } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
export const SYSTEM_EVENTS = {
STATUS_CHANGED: 'systemStatusChanged',
};
@ObjectType('SystemStatusChange')
export class SystemStatusChangeDTO {
@Field(() => String)
status!: string;
@Field(() => String, { nullable: true })
message?: string;
}
@Resolver()
export class SystemSubscriptionResolver {
constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {}
@Subscription(() => SystemStatusChangeDTO, {
name: 'systemStatusChanged',
description: 'Уведомление об изменении состояния системы (статус, конфигурация)',
})
systemStatusChanged() {
return this.pubSub.asyncIterableIterator(SYSTEM_EVENTS.STATUS_CHANGED);
}
}
@@ -1,4 +1,5 @@
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { SystemInfoDTO } from '../dto/system.dto';
import { SystemInteractor } from '../interactors/system.interactor';
import { ProviderService } from '~/application/provider/services/provider.service';
@@ -20,6 +21,8 @@ import {
AGREEMENT_CONFIGURATION_SERVICE,
} from '~/domain/registration/services/agreement-configuration.service';
import type { AccountType } from '~/application/account/enum/account-type.enum';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { SYSTEM_EVENTS } from '../resolvers/system-subscription.resolver';
@Injectable()
export class SystemService {
@@ -27,7 +30,8 @@ export class SystemService {
private readonly systemInteractor: SystemInteractor,
private readonly providerService: ProviderService,
@Inject(AGREEMENT_CONFIGURATION_SERVICE)
private readonly agreementConfigService: AgreementConfigurationService
private readonly agreementConfigService: AgreementConfigurationService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
public async getInfo(): Promise<SystemInfoDTO> {
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { SystemService } from './services/system.service';
import { SystemResolver } from './resolvers/system.resolver';
import { SystemSubscriptionResolver } from './resolvers/system-subscription.resolver';
import { SystemDomainModule } from '~/domain/system/system-domain.module';
import { ProviderModule } from '~/application/provider/provider.module';
import { SystemInteractor } from './interactors/system.interactor';
@@ -16,6 +17,7 @@ import { PaymentMethodInfrastructureModule } from '~/infrastructure/payment-meth
import { AccountDomainModule } from '~/domain/account/account-domain.module';
import { SettingsInfrastructureModule } from '~/infrastructure/settings/settings-infrastructure.module';
import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
import { PubSubModule } from '~/infrastructure/pubsub/pubsub.module';
@Module({
imports: [
@@ -27,11 +29,13 @@ import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
PaymentMethodInfrastructureModule,
AccountDomainModule,
SettingsInfrastructureModule,
PubSubModule,
],
controllers: [],
providers: [
SystemService,
SystemResolver,
SystemSubscriptionResolver,
SystemInteractor,
LoadContactsInteractor,
WifInteractor,
@@ -1,6 +1,8 @@
import { MarketContract } from 'cooptypes';
import type { TransactResult } from '@wharfkit/session';
type DocumentInput = any;
export interface CooplaceBlockchainPort {
acceptRequest(data: MarketContract.Actions.AcceptRequest.IAcceptRequest): Promise<TransactResult>;
cancelRequest(data: MarketContract.Actions.CancelRequest.ICancelRequest): Promise<TransactResult>;
@@ -19,6 +21,13 @@ export interface CooplaceBlockchainPort {
supplyOnRequest(data: MarketContract.Actions.SupplyOnRequest.ISupplyOnRequest): Promise<TransactResult>;
unpublishRequest(data: MarketContract.Actions.UnpublishRequest.IUnpublishRequest): Promise<TransactResult>;
updateRequest(data: MarketContract.Actions.UpdateRequest.IUpdateRequest): Promise<TransactResult>;
// Новые actions
reqReturn(data: { coopname: string; username: string; request_hash: string; return_statement: DocumentInput }): Promise<TransactResult>;
coopstock(data: { coopname: string; braname: string; hash: string; units: number; unit_cost: string; product_lifecycle_secs: number; warranty_period_secs: number; membership_fee_amount: string; meta: string }): Promise<TransactResult>;
acceptStock(data: { coopname: string; username: string; request_hash: string; convert_in: DocumentInput; return_statement: DocumentInput }): Promise<TransactResult>;
destroy(data: { coopname: string; request_hash: string; destruction_act: DocumentInput }): Promise<TransactResult>;
reoffer(data: { coopname: string; request_hash: string; new_hash: string; new_unit_cost: string; new_meta: string }): Promise<TransactResult>;
}
export const COOPLACE_BLOCKCHAIN_PORT = Symbol('CooplaceBlockchainPort');
@@ -0,0 +1,103 @@
import { Resolver, Subscription, Args, ObjectType, Field } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { IssueOutputDTO } from '../dto/generation/issue.dto';
import { CommitOutputDTO } from '../dto/generation/commit.dto';
export const CAPITAL_EVENTS = {
ISSUE_UPDATED: 'capitalIssueUpdated',
ISSUE_CREATED: 'capitalIssueCreated',
COMMIT_CREATED: 'capitalCommitCreated',
COMMIT_UPDATED: 'capitalCommitUpdated',
DATA_CHANGED: 'capitalDataChanged',
};
@ObjectType('CapitalDataChange')
export class CapitalDataChangeDTO {
@Field(() => String)
entity!: string;
@Field(() => String)
action!: string;
@Field(() => String, { nullable: true })
id?: string;
}
@Resolver()
export class CapitalSubscriptionResolver {
constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {}
@Subscription(() => IssueOutputDTO, {
name: 'capitalIssueUpdated',
description: 'Подписка на обновления задач',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalIssueUpdated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalIssueUpdated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.ISSUE_UPDATED);
}
@Subscription(() => IssueOutputDTO, {
name: 'capitalIssueCreated',
description: 'Подписка на создание задач',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalIssueCreated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalIssueCreated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.ISSUE_CREATED);
}
@Subscription(() => CommitOutputDTO, {
name: 'capitalCommitCreated',
description: 'Подписка на создание коммитов',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalCommitCreated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalCommitCreated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.COMMIT_CREATED);
}
@Subscription(() => CommitOutputDTO, {
name: 'capitalCommitUpdated',
description: 'Подписка на обновления коммитов',
filter: (payload: any, variables: any) => {
if (variables.project_hash && payload.capitalCommitUpdated.project_hash !== variables.project_hash) {
return false;
}
return true;
},
})
capitalCommitUpdated(
@Args('project_hash', { nullable: true }) _projectHash?: string,
) {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.COMMIT_UPDATED);
}
@Subscription(() => CapitalDataChangeDTO, {
name: 'capitalDataChanged',
description: 'Уведомление об изменении данных Capital (проекты, участники, голосования)',
})
capitalDataChanged() {
return this.pubSub.asyncIterableIterator(CAPITAL_EVENTS.DATA_CHANGED);
}
}
@@ -1,4 +1,7 @@
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { CAPITAL_EVENTS } from '../resolvers/capital-subscription.resolver';
import { generateUniqueHash } from '~/utils/generate-hash.util';
import { GenerationInteractor } from '../use-cases/generation.interactor';
import type { CreateCommitInputDTO } from '../dto/generation/create-commit-input.dto';
@@ -57,6 +60,7 @@ import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mon
@Injectable()
export class GenerationService {
constructor(
@Inject(PUB_SUB) private readonly pubSub: PubSub,
private readonly generationInteractor: GenerationInteractor,
@Inject(STORY_REPOSITORY)
private readonly storyRepository: StoryRepository,
@@ -84,7 +88,13 @@ export class GenerationService {
*/
async createCommit(data: CreateCommitInputDTO, currentUser: MonoAccountDomainInterface): Promise<CommitOutputDTO> {
const commitEntity = await this.generationInteractor.createCommit(data, currentUser);
return await this.commitMapperService.toDTO(commitEntity, currentUser);
const result = await this.commitMapperService.toDTO(commitEntity, currentUser);
this.pubSub.publish(CAPITAL_EVENTS.COMMIT_CREATED, {
[CAPITAL_EVENTS.COMMIT_CREATED]: result,
});
return result;
}
/**
@@ -97,7 +107,13 @@ export class GenerationService {
};
const commitEntity = await this.generationInteractor.approveCommit(domainInput);
return await this.commitMapperService.toDTO(commitEntity, currentUser);
const result = await this.commitMapperService.toDTO(commitEntity, currentUser);
this.pubSub.publish(CAPITAL_EVENTS.COMMIT_UPDATED, {
[CAPITAL_EVENTS.COMMIT_UPDATED]: result,
});
return result;
}
/**
@@ -109,8 +125,15 @@ export class GenerationService {
master: currentUser.username,
};
const commitEntity = await this.generationInteractor.declineCommit(domainInput);
return await this.commitMapperService.toDTO(commitEntity, currentUser);
const result = await this.commitMapperService.toDTO(commitEntity, currentUser);
this.pubSub.publish(CAPITAL_EVENTS.COMMIT_UPDATED, {
[CAPITAL_EVENTS.COMMIT_UPDATED]: result,
});
return result;
}
// ============ STORY METHODS ============
@@ -424,11 +447,14 @@ export class GenerationService {
// Рассчитываем права доступа для задачи
const permissions = await this.permissionsService.calculateIssuePermissions(savedIssue, currentUser);
// Возвращаем задачу с правами доступа
return {
...savedIssue,
permissions,
} as IssueOutputDTO;
const issueResult = { ...savedIssue, permissions } as IssueOutputDTO;
// Публикуем событие для подписчиков
this.pubSub.publish(CAPITAL_EVENTS.ISSUE_CREATED, {
[CAPITAL_EVENTS.ISSUE_CREATED]: issueResult,
});
return issueResult;
}
/**
@@ -552,11 +578,14 @@ export class GenerationService {
// Рассчитываем права доступа для задачи
const permissions = await this.permissionsService.calculateIssuePermissions(updatedIssue, currentUser);
// Возвращаем задачу с правами доступа
return {
...updatedIssue,
permissions,
} as IssueOutputDTO;
const issueResult = { ...updatedIssue, permissions } as IssueOutputDTO;
// Публикуем событие для подписчиков
this.pubSub.publish(CAPITAL_EVENTS.ISSUE_UPDATED, {
[CAPITAL_EVENTS.ISSUE_UPDATED]: issueResult,
});
return issueResult;
}
/**
@@ -1,4 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { CAPITAL_EVENTS } from '../resolvers/capital-subscription.resolver';
import { ProjectManagementInteractor } from '../use-cases/project-management.interactor';
import type { CreateProjectInputDTO } from '../dto/project_management';
import type { TransactResult } from '@wharfkit/session';
@@ -22,30 +25,37 @@ import { DocumentInteractor } from '~/application/document/interactors/document.
import { ProjectMapperService } from './project-mapper.service';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
/**
* Сервис уровня приложения для управления проектами CAPITAL
* Обрабатывает запросы от ProjectManagementResolver
*/
@Injectable()
export class ProjectManagementService {
constructor(
private readonly projectManagementInteractor: ProjectManagementInteractor,
private readonly documentInteractor: DocumentInteractor,
private readonly projectMapperService: ProjectMapperService
private readonly projectMapperService: ProjectMapperService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
private notifyDataChanged(entity: string, action: string, id?: string) {
this.pubSub.publish(CAPITAL_EVENTS.DATA_CHANGED, {
[CAPITAL_EVENTS.DATA_CHANGED]: { entity, action, id },
});
}
/**
* Создание проекта в CAPITAL контракте
*/
async createProject(data: CreateProjectInputDTO, currentUser: MonoAccountDomainInterface): Promise<TransactResult> {
return await this.projectManagementInteractor.createProject(data, currentUser);
const result = await this.projectManagementInteractor.createProject(data, currentUser);
this.notifyDataChanged('project', 'created');
return result;
}
/**
* Редактирование проекта в CAPITAL контракте
*/
async editProject(data: EditProjectInputDTO): Promise<TransactResult> {
return await this.projectManagementInteractor.editProject(data);
const result = await this.projectManagementInteractor.editProject(data);
this.notifyDataChanged('project', 'updated');
return result;
}
/**
@@ -1,4 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { PUB_SUB } from '~/infrastructure/pubsub/pubsub.module';
import { CAPITAL_EVENTS } from '../resolvers/capital-subscription.resolver';
import { VotingInteractor } from '../use-cases/voting.interactor';
import type { StartVotingInputDTO } from '../dto/voting/start-voting-input.dto';
import type { SubmitVoteInputDTO } from '../dto/voting/submit-vote-input.dto';
@@ -12,33 +15,45 @@ import type { PaginationInputDomainInterface } from '~/domain/common/interfaces/
import { SegmentMapper } from '../../infrastructure/mappers/segment.mapper';
import { SegmentOutputDTO } from '../dto/segments/segment.dto';
/**
* Сервис уровня приложения для голосования в CAPITAL
* Обрабатывает запросы от VotingResolver
*/
@Injectable()
export class VotingService {
constructor(private readonly votingInteractor: VotingInteractor, private readonly segmentMapper: SegmentMapper) {}
constructor(
private readonly votingInteractor: VotingInteractor,
private readonly segmentMapper: SegmentMapper,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
private notifyDataChanged(entity: string, action: string) {
this.pubSub.publish(CAPITAL_EVENTS.DATA_CHANGED, {
[CAPITAL_EVENTS.DATA_CHANGED]: { entity, action },
});
}
/**
* Запуск голосования в CAPITAL контракте
*/
async startVoting(data: StartVotingInputDTO): Promise<TransactResult> {
return await this.votingInteractor.startVoting(data);
const result = await this.votingInteractor.startVoting(data);
this.notifyDataChanged('voting', 'started');
return result;
}
/**
* Голосование в CAPITAL контракте
*/
async submitVote(data: SubmitVoteInputDTO, username: string): Promise<TransactResult> {
return await this.votingInteractor.submitVote({ ...data, voter: username });
const result = await this.votingInteractor.submitVote({ ...data, voter: username });
this.notifyDataChanged('voting', 'voted');
return result;
}
/**
* Завершение голосования в CAPITAL контракте
*/
async completeVoting(data: CompleteVotingInputDTO): Promise<TransactResult> {
return await this.votingInteractor.completeVoting(data);
const result = await this.votingInteractor.completeVoting(data);
this.notifyDataChanged('voting', 'completed');
return result;
}
/**
@@ -1,5 +1,6 @@
import { forwardRef, Module } from '@nestjs/common';
import { BaseExtModule } from '../base.extension.module';
import { PubSubModule } from '~/infrastructure/pubsub/pubsub.module';
import { CapitalDatabaseModule } from './infrastructure/database/capital-database.module';
import { RegistrationInfrastructureModule } from '~/infrastructure/registration/registration-infrastructure.module';
import { Injectable } from '@nestjs/common';
@@ -218,6 +219,7 @@ import { ProcessInstanceTypeormRepository } from './infrastructure/repositories/
import { PROCESS_TEMPLATE_REPOSITORY, PROCESS_INSTANCE_REPOSITORY } from './domain/repositories/process.repository';
import { ProcessService } from './application/services/process.service';
import { ProcessResolver } from './application/resolvers/process.resolver';
import { CapitalSubscriptionResolver } from './application/resolvers/capital-subscription.resolver';
import { ProcessTemplateTypeormEntity } from './infrastructure/entities/process-template.entity';
import { ProcessInstanceTypeormEntity } from './infrastructure/entities/process-instance.entity';
import { CommentTypeormRepository } from './infrastructure/repositories/comment.typeorm-repository';
@@ -520,6 +522,7 @@ export class CapitalPlugin extends BaseExtModule {
forwardRef(() => RegistrationModule),
RegistrationInfrastructureModule,
EventEmitterModule.forRoot(),
PubSubModule,
],
providers: [
// Plugin
@@ -673,6 +676,7 @@ IssueIdGenerationService,
},
ProcessService,
ProcessResolver,
CapitalSubscriptionResolver,
{
provide: COMMENT_REPOSITORY,
useClass: CommentTypeormRepository,
@@ -0,0 +1,40 @@
import { Module } from '@nestjs/common';
import { MarketplaceDomainModule } from '../domain/marketplace-domain.module';
import { CategoryTreeResolver } from './resolvers/category-tree.resolver';
import { AttributeResolver } from './resolvers/attribute.resolver';
import { AvailableCategoryAdminResolver } from './resolvers/available-category-admin.resolver';
import { RequestResolver } from './resolvers/request.resolver';
import { CategoryTreeService, CATEGORY_TREE_SERVICE } from './services/category-tree.service';
/**
* Модуль приложения marketplace
* Содержит GraphQL резолверы и сервисы приложения
*/
@Module({
imports: [MarketplaceDomainModule],
providers: [
// GraphQL резолверы
CategoryTreeResolver,
AttributeResolver,
AvailableCategoryAdminResolver,
RequestResolver,
// Сервисы приложения
{
provide: CATEGORY_TREE_SERVICE,
useClass: CategoryTreeService,
},
CategoryTreeService,
],
exports: [
// Экспортируем сервисы для использования в других модулях
CATEGORY_TREE_SERVICE,
// Экспортируем резолверы для регистрации в GraphQL
CategoryTreeResolver,
AttributeResolver,
AvailableCategoryAdminResolver,
RequestResolver,
],
})
export class MarketplaceApplicationModule {}
@@ -0,0 +1,10 @@
import { InputType, Field, Int } from '@nestjs/graphql';
/**
* Input для добавления категорий
*/
@InputType()
export class AddAvailableCategoriesInput {
@Field(() => [Int], { description: 'ID категорий для добавления (целые категории)' })
categoryIds!: number[];
}
@@ -0,0 +1,11 @@
import { InputType, Field } from '@nestjs/graphql';
import { CategoryTypeInput } from './category-type-input.dto';
/**
* Input для добавления типов товаров
*/
@InputType()
export class AddAvailableCategoryTypesInput {
@Field(() => [CategoryTypeInput], { description: 'Типы товаров для добавления' })
categoryTypes!: CategoryTypeInput[];
}
@@ -0,0 +1,328 @@
import { ObjectType, Field, Int, registerEnumType } from '@nestjs/graphql';
import type { AttributeDomainEntity } from '../../domain/entities/attribute-domain.entity';
import type { DictionaryDomainEntity } from '../../domain/entities/dictionary-domain.entity';
import type { DictionaryValueDomainEntity } from '../../domain/entities/dictionary-value-domain.entity';
/**
* Enum для типов атрибутов
*/
export enum AttributeType {
STRING = 'string',
NUMBER = 'number',
BOOLEAN = 'boolean',
DATE = 'date',
URL = 'url',
}
registerEnumType(AttributeType, {
name: 'MarketplaceAttributeType',
description: 'Тип атрибута товара',
});
/**
* GraphQL DTO для значения словаря
*/
@ObjectType('MarketplaceDictionaryValue')
export class DictionaryValueDTO {
@Field(() => Int, { description: 'ID значения словаря' })
dictionaryValueId!: number;
@Field({ description: 'Значение' })
value!: string;
@Field({ description: 'Дополнительная информация', nullable: true })
info?: string;
@Field({ description: 'URL изображения', nullable: true })
picture?: string;
@Field(() => Int, { description: 'ID словаря' })
dictionaryId!: number;
@Field({ description: 'Есть ли изображение' })
hasPicture!: boolean;
@Field({ description: 'Есть ли дополнительная информация' })
hasInfo!: boolean;
@Field({ description: 'Полное описание значения' })
fullDescription!: string;
constructor(data: {
dictionaryValueId: number;
value: string;
info?: string;
picture?: string;
dictionaryId: number;
hasPicture: boolean;
hasInfo: boolean;
fullDescription: string;
}) {
this.dictionaryValueId = data.dictionaryValueId;
this.value = data.value;
this.info = data.info;
this.picture = data.picture;
this.dictionaryId = data.dictionaryId;
this.hasPicture = data.hasPicture;
this.hasInfo = data.hasInfo;
this.fullDescription = data.fullDescription;
}
static fromDomain(entity: DictionaryValueDomainEntity): DictionaryValueDTO {
return new DictionaryValueDTO({
dictionaryValueId: entity.dictionaryValueId,
value: entity.value,
info: entity.info,
picture: entity.picture,
dictionaryId: entity.dictionaryId,
hasPicture: entity.hasPicture(),
hasInfo: entity.hasInfo(),
fullDescription: entity.getFullDescription(),
});
}
}
/**
* GraphQL DTO для словаря
*/
@ObjectType('MarketplaceDictionary')
export class DictionaryDTO {
@Field(() => Int, { description: 'ID словаря' })
dictionaryId!: number;
@Field({ description: 'Название словаря', nullable: true })
name?: string;
@Field({ description: 'Описание словаря', nullable: true })
description?: string;
@Field(() => [DictionaryValueDTO], { description: 'Значения словаря' })
values!: DictionaryValueDTO[];
@Field(() => Int, { description: 'Количество значений' })
valuesCount!: number;
@Field({ description: 'Есть ли значения с изображениями' })
hasValuesWithPictures!: boolean;
constructor(data: {
dictionaryId: number;
name?: string;
description?: string;
values: DictionaryValueDTO[];
valuesCount: number;
hasValuesWithPictures: boolean;
}) {
this.dictionaryId = data.dictionaryId;
this.name = data.name;
this.description = data.description;
this.values = data.values;
this.valuesCount = data.valuesCount;
this.hasValuesWithPictures = data.hasValuesWithPictures;
}
static fromDomain(entity: DictionaryDomainEntity, includeValues = true): DictionaryDTO {
return new DictionaryDTO({
dictionaryId: entity.dictionaryId,
name: entity.name,
description: entity.description,
values: includeValues ? entity.values.map((value) => DictionaryValueDTO.fromDomain(value)) : [],
valuesCount: entity.getValuesCount(),
hasValuesWithPictures: entity.hasValuesWithPictures(),
});
}
}
/**
* GraphQL DTO для группы атрибутов
*/
@ObjectType('MarketplaceAttributeGroup')
export class AttributeGroupDTO {
@Field(() => Int, { description: 'ID группы', nullable: true })
groupId?: number;
@Field({ description: 'Название группы' })
groupName!: string;
@Field(() => [AttributeDTO], { description: 'Атрибуты в группе' })
attributes!: AttributeDTO[];
@Field(() => Int, { description: 'Количество атрибутов в группе' })
attributesCount!: number;
constructor(data: { groupId?: number; groupName: string; attributes: AttributeDTO[] }) {
this.groupId = data.groupId;
this.groupName = data.groupName;
this.attributes = data.attributes;
this.attributesCount = data.attributes.length;
}
}
/**
* GraphQL DTO для атрибута товара
*/
@ObjectType('MarketplaceAttribute')
export class AttributeDTO {
@Field(() => Int, { description: 'ID атрибута' })
attributeId!: number;
@Field({ description: 'Название атрибута' })
name!: string;
@Field({ description: 'Описание атрибута', nullable: true })
description?: string;
@Field(() => AttributeType, { description: 'Тип атрибута' })
type!: AttributeType;
@Field({ description: 'Является ли атрибут коллекцией' })
isCollection!: boolean;
@Field({ description: 'Является ли атрибут обязательным' })
isRequired!: boolean;
@Field({ description: 'Является ли атрибут аспектным' })
isAspect!: boolean;
@Field(() => Int, { description: 'Максимальное количество значений' })
maxValueCount!: number;
@Field({ description: 'Название группы', nullable: true })
groupName?: string;
@Field(() => Int, { description: 'ID группы', nullable: true })
groupId?: number;
@Field(() => Int, { description: 'ID словаря', nullable: true })
dictionaryId?: number;
@Field({ description: 'Зависит ли от категории' })
categoryDependent!: boolean;
@Field({ description: 'Комплексная коллекция' })
complexIsCollection!: boolean;
@Field(() => Int, { description: 'ID комплексного атрибута' })
attributeComplexId!: number;
@Field(() => DictionaryDTO, { description: 'Словарь значений', nullable: true })
dictionary?: DictionaryDTO;
@Field({ description: 'Имеет ли словарь' })
hasDictionary!: boolean;
@Field({ description: 'Можно ли изменить после создания товара' })
canBeModifiedAfterCreation!: boolean;
@Field(() => Int, { description: 'Максимальное количество значений (вычисленное)' })
maxValues!: number;
@Field({ description: 'Является ли комплексным атрибутом' })
isComplexAttribute!: boolean;
constructor(data: {
attributeId: number;
name: string;
description?: string;
type: AttributeType;
isCollection: boolean;
isRequired: boolean;
isAspect: boolean;
maxValueCount: number;
groupName?: string;
groupId?: number;
dictionaryId?: number;
categoryDependent: boolean;
complexIsCollection: boolean;
attributeComplexId: number;
dictionary?: DictionaryDTO;
hasDictionary: boolean;
canBeModifiedAfterCreation: boolean;
maxValues: number;
isComplexAttribute: boolean;
}) {
this.attributeId = data.attributeId;
this.name = data.name;
this.description = data.description;
this.type = data.type;
this.isCollection = data.isCollection;
this.isRequired = data.isRequired;
this.isAspect = data.isAspect;
this.maxValueCount = data.maxValueCount;
this.groupName = data.groupName;
this.groupId = data.groupId;
this.dictionaryId = data.dictionaryId;
this.categoryDependent = data.categoryDependent;
this.complexIsCollection = data.complexIsCollection;
this.attributeComplexId = data.attributeComplexId;
this.dictionary = data.dictionary;
this.hasDictionary = data.hasDictionary;
this.canBeModifiedAfterCreation = data.canBeModifiedAfterCreation;
this.maxValues = data.maxValues;
this.isComplexAttribute = data.isComplexAttribute;
}
static fromDomain(entity: AttributeDomainEntity, includeDictionary = true): AttributeDTO {
return new AttributeDTO({
attributeId: entity.attributeId,
name: entity.name,
description: entity.description,
type: entity.type as AttributeType,
isCollection: entity.isCollection,
isRequired: entity.isRequired,
isAspect: entity.isAspect,
maxValueCount: entity.maxValueCount,
groupName: entity.groupName,
groupId: entity.groupId,
dictionaryId: entity.dictionaryId,
categoryDependent: entity.categoryDependent,
complexIsCollection: entity.complexIsCollection,
attributeComplexId: entity.attributeComplexId,
dictionary: includeDictionary && entity.dictionary ? DictionaryDTO.fromDomain(entity.dictionary) : undefined,
hasDictionary: entity.hasDictionary(),
canBeModifiedAfterCreation: entity.canBeModifiedAfterCreation(),
maxValues: entity.getMaxValues(),
isComplexAttribute: entity.isComplexAttribute(),
});
}
}
/**
* GraphQL DTO для статистики атрибутов
*/
@ObjectType('MarketplaceAttributeStats')
export class AttributeStatsDTO {
@Field(() => Int, { description: 'Общее количество атрибутов' })
totalAttributes!: number;
@Field(() => Int, { description: 'Количество обязательных атрибутов' })
requiredAttributes!: number;
@Field(() => Int, { description: 'Количество аспектных атрибутов' })
aspectAttributes!: number;
@Field(() => Int, { description: 'Количество словарных атрибутов' })
dictionaryAttributes!: number;
@Field(() => Int, { description: 'Общее количество словарей' })
totalDictionaries!: number;
@Field(() => Int, { description: 'Общее количество значений словарей' })
totalDictionaryValues!: number;
constructor(data: {
totalAttributes: number;
requiredAttributes: number;
aspectAttributes: number;
dictionaryAttributes: number;
totalDictionaries: number;
totalDictionaryValues: number;
}) {
this.totalAttributes = data.totalAttributes;
this.requiredAttributes = data.requiredAttributes;
this.aspectAttributes = data.aspectAttributes;
this.dictionaryAttributes = data.dictionaryAttributes;
this.totalDictionaries = data.totalDictionaries;
this.totalDictionaryValues = data.totalDictionaryValues;
}
}
@@ -0,0 +1,19 @@
import { ObjectType, Field, Int } from '@nestjs/graphql';
/**
* DTO для статистики доступности
*/
@ObjectType('MarketplaceAvailabilityStats')
export class AvailabilityStatsDTO {
@Field(() => Int, { description: 'Общее количество доступных элементов' })
totalAvailable!: number;
@Field(() => Int, { description: 'Количество доступных категорий (целых)' })
categoriesCount!: number;
@Field(() => Int, { description: 'Количество доступных типов товаров' })
typesCount!: number;
@Field({ description: 'Есть ли ограничения по категориям' })
hasRestrictions!: boolean;
}
@@ -0,0 +1,37 @@
import { ObjectType, Field, Int } from '@nestjs/graphql';
/**
* DTO для доступной категории/типа
*/
@ObjectType('MarketplaceAvailableCategory')
export class AvailableCategoryDTO {
@Field(() => Int, { description: 'ID записи' })
id!: number;
@Field({ description: 'Название кооператива' })
coopname!: string;
@Field(() => Int, { description: 'ID категории' })
categoryId!: number;
@Field(() => Int, { description: 'ID типа товара (null = вся категория)', nullable: true })
typeId?: number;
@Field({ description: 'Активна ли категория/тип' })
isActive!: boolean;
@Field({ description: 'Кто добавил категорию/тип' })
addedBy!: string;
@Field({ description: 'Применяется к всей категории' })
isForEntireCategory!: boolean;
@Field({ description: 'Применяется к конкретному типу' })
isForSpecificType!: boolean;
@Field({ description: 'Дата создания' })
createdAt!: Date;
@Field({ description: 'Дата обновления' })
updatedAt!: Date;
}
@@ -0,0 +1,255 @@
import { ObjectType, Field, Int } from '@nestjs/graphql';
import type { CategoryDomainEntity } from '../../domain/entities/category-domain.entity';
import type { TypeDomainEntity } from '../../domain/entities/type-domain.entity';
/**
* GraphQL DTO для типа товара
*/
@ObjectType('MarketplaceProductType')
export class ProductTypeDTO {
@Field(() => Int, { description: 'ID типа товара' })
typeId!: number;
@Field({ description: 'Название типа товара' })
typeName!: string;
@Field({ description: 'Признак отключения типа' })
disabled!: boolean;
@Field(() => Int, { description: 'ID категории' })
descriptionCategoryId!: number;
@Field({ description: 'Полное название с категорией', nullable: true })
fullName?: string;
@Field({ description: 'Доступен ли тип для создания товаров' })
isAvailable!: boolean;
constructor(data: {
typeId: number;
typeName: string;
disabled: boolean;
descriptionCategoryId: number;
fullName?: string;
isAvailable: boolean;
}) {
this.typeId = data.typeId;
this.typeName = data.typeName;
this.disabled = data.disabled;
this.descriptionCategoryId = data.descriptionCategoryId;
this.fullName = data.fullName;
this.isAvailable = data.isAvailable;
}
/**
* Создать DTO из доменной сущности
*/
static fromDomain(entity: TypeDomainEntity): ProductTypeDTO {
return new ProductTypeDTO({
typeId: entity.typeId,
typeName: entity.typeName,
disabled: entity.disabled,
descriptionCategoryId: entity.descriptionCategoryId,
fullName: entity.getFullName(),
isAvailable: entity.isAvailable(),
});
}
}
/**
* GraphQL DTO для категории товаров
*/
@ObjectType('MarketplaceCategory')
export class CategoryDTO {
@Field(() => Int, { description: 'ID категории' })
descriptionCategoryId!: number;
@Field({ description: 'Название категории' })
categoryName!: string;
@Field({ description: 'Признак отключения категории' })
disabled!: boolean;
@Field(() => Int, { description: 'ID родительской категории', nullable: true })
parentId?: number;
@Field(() => [CategoryDTO], { description: 'Дочерние категории' })
children!: CategoryDTO[];
@Field(() => [ProductTypeDTO], { description: 'Типы товаров в категории' })
types!: ProductTypeDTO[];
@Field({ description: 'Полный путь к категории', nullable: true })
fullPath?: string;
@Field({ description: 'Является ли категория листовой (можно создавать товары)' })
isLeafCategory!: boolean;
@Field(() => Int, { description: 'Количество дочерних категорий' })
childrenCount!: number;
@Field(() => Int, { description: 'Количество типов товаров' })
typesCount!: number;
constructor(data: {
descriptionCategoryId: number;
categoryName: string;
disabled: boolean;
parentId?: number;
children: CategoryDTO[];
types: ProductTypeDTO[];
fullPath?: string;
isLeafCategory: boolean;
}) {
this.descriptionCategoryId = data.descriptionCategoryId;
this.categoryName = data.categoryName;
this.disabled = data.disabled;
this.parentId = data.parentId;
this.children = data.children;
this.types = data.types;
this.fullPath = data.fullPath;
this.isLeafCategory = data.isLeafCategory;
this.childrenCount = data.children.length;
this.typesCount = data.types.length;
}
/**
* Создать DTO из доменной сущности
*/
static fromDomain(entity: CategoryDomainEntity): CategoryDTO {
return new CategoryDTO({
descriptionCategoryId: entity.descriptionCategoryId,
categoryName: entity.categoryName,
disabled: entity.disabled,
parentId: entity.parentId,
children: entity.children.map((child) => CategoryDTO.fromDomain(child)),
types: entity.types.map((type) => ProductTypeDTO.fromDomain(type)),
fullPath: entity.getFullPath(),
isLeafCategory: entity.isLeafCategory(),
});
}
}
/**
* GraphQL DTO для статистики дерева категорий
*/
@ObjectType('MarketplaceCategoryTreeStats')
export class CategoryTreeStatsDTO {
@Field(() => Int, { description: 'Общее количество категорий' })
totalCategories!: number;
@Field(() => Int, { description: 'Количество корневых категорий' })
rootCategories!: number;
@Field(() => Int, { description: 'Количество листовых категорий' })
leafCategories!: number;
@Field(() => Int, { description: 'Количество отключенных категорий' })
disabledCategories!: number;
@Field(() => Int, { description: 'Общее количество типов товаров' })
totalTypes!: number;
@Field(() => Int, { description: 'Количество доступных типов товаров' })
availableTypes!: number;
constructor(data: {
totalCategories: number;
rootCategories: number;
leafCategories: number;
disabledCategories: number;
totalTypes: number;
availableTypes: number;
}) {
this.totalCategories = data.totalCategories;
this.rootCategories = data.rootCategories;
this.leafCategories = data.leafCategories;
this.disabledCategories = data.disabledCategories;
this.totalTypes = data.totalTypes;
this.availableTypes = data.availableTypes;
}
}
/**
* GraphQL DTO для результатов поиска по дереву
*/
@ObjectType('MarketplaceCategoryTreeSearchResult')
export class CategoryTreeSearchResultDTO {
@Field(() => [CategoryDTO], { description: 'Найденные категории' })
matchedCategories!: CategoryDTO[];
@Field(() => [ProductTypeDTO], { description: 'Найденные типы товаров' })
matchedTypes!: ProductTypeDTO[];
@Field(() => [CategoryDTO], { description: 'Результирующее дерево категорий' })
resultTree!: CategoryDTO[];
@Field(() => Int, { description: 'Общее количество найденных элементов' })
totalMatches!: number;
constructor(data: { matchedCategories: CategoryDTO[]; matchedTypes: ProductTypeDTO[]; resultTree: CategoryDTO[] }) {
this.matchedCategories = data.matchedCategories;
this.matchedTypes = data.matchedTypes;
this.resultTree = data.resultTree;
this.totalMatches = data.matchedCategories.length + data.matchedTypes.length;
}
}
/**
* GraphQL DTO для товара с путем к категории
*/
@ObjectType('MarketplaceProductWithPath')
export class ProductWithPathDTO {
@Field(() => ProductTypeDTO, { description: 'Тип товара' })
type!: ProductTypeDTO;
@Field(() => [CategoryDTO], { description: 'Путь к категории' })
categoryPath!: CategoryDTO[];
@Field({ description: 'Полный путь в виде строки' })
fullPath!: string;
constructor(data: { type: ProductTypeDTO; categoryPath: CategoryDTO[]; fullPath: string }) {
this.type = data.type;
this.categoryPath = data.categoryPath;
this.fullPath = data.fullPath;
}
}
/**
* GraphQL DTO для категории с типами
*/
@ObjectType('MarketplaceCategoryWithTypes')
export class CategoryWithTypesDTO {
@Field(() => CategoryDTO, { description: 'Категория' })
category!: CategoryDTO;
@Field(() => [ProductTypeDTO], { description: 'Найденные типы товаров в категории' })
matchedTypes!: ProductTypeDTO[];
@Field(() => [ProductTypeDTO], { description: 'Все типы товаров в категории' })
allTypes!: ProductTypeDTO[];
@Field(() => [CategoryDTO], { description: 'Путь к категории' })
categoryPath!: CategoryDTO[];
@Field(() => Int, { description: 'Количество найденных типов' })
matchedTypesCount!: number;
@Field(() => Int, { description: 'Общее количество типов' })
totalTypesCount!: number;
constructor(data: {
category: CategoryDTO;
matchedTypes: ProductTypeDTO[];
allTypes: ProductTypeDTO[];
categoryPath: CategoryDTO[];
}) {
this.category = data.category;
this.matchedTypes = data.matchedTypes;
this.allTypes = data.allTypes;
this.categoryPath = data.categoryPath;
this.matchedTypesCount = data.matchedTypes.length;
this.totalTypesCount = data.allTypes.length;
}
}
@@ -0,0 +1,13 @@
import { InputType, Field, Int } from '@nestjs/graphql';
/**
* Input для типа категории товара
*/
@InputType()
export class CategoryTypeInput {
@Field(() => Int, { description: 'ID категории' })
categoryId!: number;
@Field(() => Int, { description: 'ID типа товара' })
typeId!: number;
}
@@ -0,0 +1,242 @@
import { InputType, Field, Int, registerEnumType } from '@nestjs/graphql';
import { IsOptional, IsString, IsNumber, IsArray, IsEnum, IsNotEmpty, Min, IsUrl, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
// Регистрируем enum для GraphQL
export enum RequestTypeInput {
OFFER = 'offer',
ORDER = 'order',
}
export enum RequestImageTypeInput {
REGULAR = 'regular',
PRIMARY = 'primary',
COLOR_SAMPLE = 'color_sample',
IMAGE_360 = 'image_360',
}
registerEnumType(RequestTypeInput, {
name: 'RequestTypeInput',
description: 'Тип заявки: offer - предложение, order - заказ',
});
registerEnumType(RequestImageTypeInput, {
name: 'RequestImageTypeInput',
description: 'Тип изображения заявки',
});
/**
* Input для атрибута заявки
*/
@InputType()
export class RequestAttributeInput {
@Field(() => Int, { description: 'ID атрибута' })
@IsNumber()
attributeId!: number;
@Field({ description: 'Значение атрибута' })
@IsString()
@IsNotEmpty()
value!: string;
@Field(() => Int, { description: 'ID комплексного атрибута', nullable: true, defaultValue: 0 })
@IsOptional()
@IsNumber()
complexId?: number;
@Field(() => Int, { description: 'ID значения из словаря', nullable: true })
@IsOptional()
@IsNumber()
dictionaryValueId?: number;
}
/**
* Input для изображения заявки
*/
@InputType()
export class RequestImageInput {
@Field({ description: 'URL изображения' })
@IsString()
@IsNotEmpty()
@IsUrl({}, { message: 'Некорректный URL изображения' })
imageUrl!: string;
@Field(() => RequestImageTypeInput, { description: 'Тип изображения', defaultValue: RequestImageTypeInput.REGULAR })
@IsEnum(RequestImageTypeInput)
imageType!: RequestImageTypeInput;
@Field(() => Int, { description: 'Порядок сортировки', defaultValue: 0 })
@IsNumber()
@Min(0)
sortOrder!: number;
@Field({ description: 'Описание изображения', nullable: true })
@IsOptional()
@IsString()
description?: string;
}
/**
* Input для создания заявки
*/
@InputType()
export class CreateRequestInput {
@Field({ description: 'Имя аккаунта кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
@Field(() => RequestTypeInput, { description: 'Тип заявки: offer - предложение, order - заказ' })
@IsEnum(RequestTypeInput)
type!: RequestTypeInput;
// Основная информация о товаре
@Field({ description: 'Название товара (до 500 символов)' })
@IsString()
@IsNotEmpty()
name!: string;
@Field({ description: 'Артикул товара (до 50 символов)' })
@IsString()
@IsNotEmpty()
articleNumber!: string;
@Field({ description: 'Штрихкод товара', nullable: true })
@IsOptional()
@IsString()
barcode?: string;
// Категория и тип
@Field(() => Int, { description: 'ID категории' })
@IsNumber()
descriptionCategoryId!: number;
@Field(() => Int, { description: 'ID типа товара' })
@IsNumber()
typeId!: number;
// Цены
@Field(() => Number, { description: 'Цена товара' })
@IsNumber()
@Min(0.01)
price!: number;
@Field(() => Number, { description: 'Цена до скидки', nullable: true })
@IsOptional()
@IsNumber()
@Min(0.01)
oldPrice?: number;
@Field({ description: 'Валюта', defaultValue: 'RUB' })
@IsString()
@IsNotEmpty()
currencyCode!: string;
@Field({ description: 'Ставка НДС (0, 0.05, 0.07, 0.1, 0.2)' })
@IsString()
@IsNotEmpty()
vat!: string;
// Габариты и вес
@Field(() => Int, { description: 'Ширина упаковки', nullable: true })
@IsOptional()
@IsNumber()
@Min(1)
width?: number;
@Field(() => Int, { description: 'Высота упаковки', nullable: true })
@IsOptional()
@IsNumber()
@Min(1)
height?: number;
@Field(() => Int, { description: 'Глубина упаковки', nullable: true })
@IsOptional()
@IsNumber()
@Min(1)
depth?: number;
@Field({ description: 'Единица измерения габаритов', nullable: true, defaultValue: 'mm' })
@IsOptional()
@IsString()
dimensionUnit?: string;
@Field(() => Int, { description: 'Вес товара', nullable: true })
@IsOptional()
@IsNumber()
@Min(1)
weight?: number;
@Field({ description: 'Единица измерения веса', nullable: true, defaultValue: 'g' })
@IsOptional()
@IsString()
weightUnit?: string;
// Количества
@Field(() => Int, { description: 'Количество единиц товара' })
@IsNumber()
@Min(1)
units!: number;
// Время жизни и гарантии
@Field(() => Int, { description: 'Время жизни продукта в секундах', nullable: true })
@IsOptional()
@IsNumber()
@Min(0)
productLifecycleSecs?: number;
@Field(() => Int, { description: 'Гарантийный срок в днях', nullable: true })
@IsOptional()
@IsNumber()
@Min(0)
warrantyDays?: number;
// Дополнительные данные
@Field({ description: 'Дополнительные данные JSON', nullable: true })
@IsOptional()
@IsString()
data?: string;
@Field({ description: 'Метаданные JSON', nullable: true })
@IsOptional()
@IsString()
meta?: string;
// Атрибуты и изображения
@Field(() => [RequestAttributeInput], { description: 'Атрибуты товара', defaultValue: [] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => RequestAttributeInput)
attributes!: RequestAttributeInput[];
@Field(() => [RequestImageInput], { description: 'Изображения товара', defaultValue: [] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => RequestImageInput)
images!: RequestImageInput[];
// URL изображений
@Field({ description: 'URL главного изображения', nullable: true })
@IsOptional()
@IsString()
@IsUrl({}, { message: 'Некорректный URL главного изображения' })
primaryImageUrl?: string;
@Field({ description: 'URL образца цвета', nullable: true })
@IsOptional()
@IsString()
@IsUrl({}, { message: 'Некорректный URL образца цвета' })
colorImageUrl?: string;
// Геоограничения
@Field(() => [String], { description: 'Геоограничения', defaultValue: [] })
@IsArray()
@IsString({ each: true })
geoNames!: string[];
// Родительская заявка
@Field({ description: 'Хэш родительской заявки', nullable: true })
@IsOptional()
@IsString()
parentHash?: string;
}
@@ -0,0 +1,12 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNumber } from 'class-validator';
/**
* Input для поиска потенциальных совпадений
*/
@InputType()
export class FindPotentialMatchesInput {
@Field(() => Int, { description: 'ID заявки для поиска совпадений' })
@IsNumber()
requestId!: number;
}
@@ -0,0 +1,24 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional, IsNumber } from 'class-validator';
/**
* Input для получения атрибутов категории и типа
*/
@InputType()
export class GetCategoryAttributesInput {
@Field(() => Int, { description: 'ID категории' })
@IsNumber()
categoryId!: number;
@Field(() => Int, { description: 'ID типа товара' })
@IsNumber()
typeId!: number;
@Field({ description: 'Включать значения словарей', nullable: true, defaultValue: true })
@IsOptional()
includeDictionaryValues?: boolean;
@Field({ description: 'Только обязательные атрибуты', nullable: true, defaultValue: false })
@IsOptional()
onlyRequired?: boolean;
}
@@ -0,0 +1,12 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNumber } from 'class-validator';
/**
* Input для получения категории по ID
*/
@InputType()
export class GetCategoryByIdInput {
@Field(() => Int, { description: 'ID категории' })
@IsNumber()
categoryId!: number;
}
@@ -0,0 +1,28 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional, IsNumber, Min, Max } from 'class-validator';
/**
* Input для получения дерева категорий
*/
@InputType()
export class GetCategoryTreeInput {
@Field(() => Int, { description: 'ID корневой категории', nullable: true })
@IsOptional()
@IsNumber()
rootCategoryId?: number;
@Field({ description: 'Включать только доступные категории', nullable: true, defaultValue: false })
@IsOptional()
onlyAvailable?: boolean;
@Field({ description: 'Включать типы товаров', nullable: true, defaultValue: true })
@IsOptional()
includeTypes?: boolean;
@Field(() => Int, { description: 'Максимальная глубина дерева', nullable: true })
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
maxDepth?: number;
}
@@ -0,0 +1,29 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional, IsString, IsNumber, IsEnum, Min } from 'class-validator';
import { RequestTypeInput } from './create-request-input.dto';
/**
* Input для получения заявок кооператива
*/
@InputType()
export class GetCoopRequestsInput {
@Field({ description: 'Название кооператива' })
@IsString()
coopname!: string;
@Field(() => RequestTypeInput, { description: 'Тип заявки', nullable: true })
@IsOptional()
@IsEnum(RequestTypeInput)
type?: RequestTypeInput;
@Field({ description: 'Статус заявки', nullable: true })
@IsOptional()
@IsString()
status?: string;
@Field(() => Int, { description: 'Максимальное количество результатов', nullable: true, defaultValue: 50 })
@IsOptional()
@IsNumber()
@Min(1)
limit?: number;
}
@@ -0,0 +1,12 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNumber } from 'class-validator';
/**
* Input для получения типа товара по ID
*/
@InputType()
export class GetProductTypeByIdInput {
@Field(() => Int, { description: 'ID типа товара' })
@IsNumber()
typeId!: number;
}
@@ -0,0 +1,12 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString } from 'class-validator';
/**
* Input для получения заявки по хэшу
*/
@InputType()
export class GetRequestByHashInput {
@Field({ description: 'Хэш заявки' })
@IsString()
hash!: string;
}
@@ -0,0 +1,12 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNumber } from 'class-validator';
/**
* Input для получения заявки по ID
*/
@InputType()
export class GetRequestInput {
@Field(() => Int, { description: 'ID заявки' })
@IsNumber()
id!: number;
}
@@ -0,0 +1,13 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString, IsNotEmpty } from 'class-validator';
/**
* Input для получения статистики заявок
*/
@InputType()
export class GetRequestStatisticsInput {
@Field({ description: 'Название кооператива' })
@IsString()
@IsNotEmpty()
coopname!: string;
}
@@ -0,0 +1,16 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNumber } from 'class-validator';
/**
* Input для получения обязательных атрибутов
*/
@InputType()
export class GetRequiredAttributesInput {
@Field(() => Int, { description: 'ID категории' })
@IsNumber()
categoryId!: number;
@Field(() => Int, { description: 'ID типа товара' })
@IsNumber()
typeId!: number;
}
@@ -0,0 +1,14 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional, IsNumber, Min } from 'class-validator';
/**
* Input для получения заявок пользователя
*/
@InputType()
export class GetUserRequestsInput {
@Field(() => Int, { description: 'Максимальное количество результатов', nullable: true, defaultValue: 20 })
@IsOptional()
@IsNumber()
@Min(1)
limit?: number;
}
@@ -0,0 +1,106 @@
import { ObjectType, Field, InputType, Int, registerEnumType } from '@nestjs/graphql';
import { IsString, IsOptional, IsBoolean, IsInt, IsEnum, IsArray } from 'class-validator';
import { LeadRequestPolicy, PublishAccessPolicy } from '../../domain/entities/marketplace-settings.entity';
registerEnumType(LeadRequestPolicy, { name: 'LeadRequestPolicy' });
registerEnumType(PublishAccessPolicy, { name: 'PublishAccessPolicy' });
@InputType('UpdateMarketplaceSettingsInput')
export class UpdateMarketplaceSettingsInputDTO {
@Field(() => LeadRequestPolicy, { nullable: true })
@IsOptional()
@IsEnum(LeadRequestPolicy)
lead_request_policy?: LeadRequestPolicy;
@Field(() => PublishAccessPolicy, { nullable: true })
@IsOptional()
@IsEnum(PublishAccessPolicy)
publish_access_policy?: PublishAccessPolicy;
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
publish_whitelist?: string[];
@Field(() => Boolean, { nullable: true })
@IsOptional()
@IsBoolean()
moderation_required?: boolean;
@Field(() => Boolean, { nullable: true })
@IsOptional()
@IsBoolean()
cycles_enabled?: boolean;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
max_cycle_days?: number;
@Field(() => Boolean, { nullable: true })
@IsOptional()
@IsBoolean()
external_delivery_enabled?: boolean;
@Field(() => Boolean, { nullable: true })
@IsOptional()
@IsBoolean()
internal_delivery_enabled?: boolean;
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
allowed_category_ids?: string[];
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
min_unit_cost?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
max_unit_cost?: string;
}
@ObjectType('MarketplaceSettings')
export class MarketplaceSettingsDTO {
@Field(() => String)
id!: string;
@Field(() => String)
coopname!: string;
@Field(() => LeadRequestPolicy)
lead_request_policy!: LeadRequestPolicy;
@Field(() => PublishAccessPolicy)
publish_access_policy!: PublishAccessPolicy;
@Field(() => [String])
publish_whitelist!: string[];
@Field(() => Boolean)
moderation_required!: boolean;
@Field(() => Boolean)
cycles_enabled!: boolean;
@Field(() => Int)
max_cycle_days!: number;
@Field(() => Boolean)
external_delivery_enabled!: boolean;
@Field(() => Boolean)
internal_delivery_enabled!: boolean;
@Field(() => [String])
allowed_category_ids!: string[];
@Field(() => String, { nullable: true })
min_unit_cost?: string;
@Field(() => String, { nullable: true })
max_unit_cost?: string;
}
@@ -0,0 +1,12 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNumber } from 'class-validator';
/**
* Input для публикации заявки
*/
@InputType()
export class PublishRequestInput {
@Field(() => Int, { description: 'ID заявки для публикации' })
@IsNumber()
id!: number;
}
@@ -0,0 +1,10 @@
import { InputType, Field, Int } from '@nestjs/graphql';
/**
* Input для удаления категорий
*/
@InputType()
export class RemoveAvailableCategoriesInput {
@Field(() => [Int], { description: 'ID категорий для удаления' })
categoryIds!: number[];
}
@@ -0,0 +1,11 @@
import { InputType, Field } from '@nestjs/graphql';
import { CategoryTypeInput } from './category-type-input.dto';
/**
* Input для удаления типов товаров
*/
@InputType()
export class RemoveAvailableCategoryTypesInput {
@Field(() => [CategoryTypeInput], { description: 'Типы товаров для удаления' })
categoryTypes!: CategoryTypeInput[];
}
@@ -0,0 +1,14 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { CategoryTypeInput } from './category-type-input.dto';
/**
* Input для замены всех доступных элементов
*/
@InputType()
export class ReplaceAvailableItemsInput {
@Field(() => [Int], { description: 'ID категорий (целые категории)', defaultValue: [] })
categoryIds: number[] = [];
@Field(() => [CategoryTypeInput], { description: 'Типы товаров', defaultValue: [] })
categoryTypes: CategoryTypeInput[] = [];
}
@@ -0,0 +1,61 @@
import { ObjectType, Field, Int } from '@nestjs/graphql';
/**
* DTO для подсчета заявок по категории
*/
@ObjectType('MarketplaceCategoryRequestCount')
export class CategoryRequestCountDTO {
@Field(() => Int, { description: 'ID категории' })
categoryId!: number;
@Field({ description: 'Название категории' })
categoryName!: string;
@Field(() => Int, { description: 'Количество заявок' })
count!: number;
constructor(data: { categoryId: number; categoryName: string; count: number }) {
this.categoryId = data.categoryId;
this.categoryName = data.categoryName;
this.count = data.count;
}
}
/**
* DTO для статистики заявок
*/
@ObjectType('MarketplaceRequestStatistics')
export class RequestStatisticsDTO {
@Field(() => Int, { description: 'Общее количество заявок' })
totalRequests!: number;
@Field(() => Int, { description: 'Активные предложения' })
activeOffers!: number;
@Field(() => Int, { description: 'Активные заказы' })
activeOrders!: number;
@Field(() => Int, { description: 'Завершенные сделки' })
completedDeals!: number;
@Field(() => [CategoryRequestCountDTO], { description: 'Заявки по категориям' })
requestsByCategory!: CategoryRequestCountDTO[];
constructor(data: {
totalRequests: number;
activeOffers: number;
activeOrders: number;
completedDeals: number;
requestsByCategory: Array<{
categoryId: number;
categoryName: string;
count: number;
}>;
}) {
this.totalRequests = data.totalRequests;
this.activeOffers = data.activeOffers;
this.activeOrders = data.activeOrders;
this.completedDeals = data.completedDeals;
this.requestsByCategory = data.requestsByCategory.map((item) => new CategoryRequestCountDTO(item));
}
}
@@ -0,0 +1,500 @@
import { ObjectType, Field, Int, registerEnumType } from '@nestjs/graphql';
import { RequestDomainEntity, RequestType, RequestStatus } from '../../domain/entities/request-domain.entity';
import { RequestAttributeValueDomainEntity } from '../../domain/entities/request-attribute-value-domain.entity';
import { RequestImageDomainEntity, RequestImageType } from '../../domain/entities/request-image-domain.entity';
import { CategoryDTO } from './category-tree.dto';
import { ProductTypeDTO } from './category-tree.dto';
import { AttributeDTO } from './attribute.dto';
// Регистрируем enums для GraphQL
registerEnumType(RequestType, {
name: 'RequestType',
description: 'Тип заявки',
});
registerEnumType(RequestStatus, {
name: 'RequestStatus',
description: 'Статус заявки',
});
registerEnumType(RequestImageType, {
name: 'RequestImageType',
description: 'Тип изображения заявки',
});
/**
* DTO для значения атрибута заявки
*/
@ObjectType('MarketplaceRequestAttributeValue')
export class RequestAttributeValueDTO {
@Field(() => Int, { description: 'ID значения атрибута' })
id!: number;
@Field(() => Int, { description: 'ID атрибута' })
attributeId!: number;
@Field(() => AttributeDTO, { description: 'Информация об атрибуте' })
attribute!: AttributeDTO;
@Field(() => Int, { description: 'ID комплексного атрибута' })
complexId!: number;
@Field({ description: 'Значение атрибута' })
value!: string;
@Field(() => Int, { description: 'ID значения из словаря', nullable: true })
dictionaryValueId?: number;
@Field({ description: 'Является ли атрибут обязательным' })
isRequired!: boolean;
@Field({ description: 'Является ли атрибут аспектным' })
isAspect!: boolean;
@Field({ description: 'Тип атрибута' })
attributeType!: string;
@Field({ description: 'Группа атрибута', nullable: true })
attributeGroup?: string;
@Field({ description: 'Дата создания' })
createdAt!: Date;
constructor(data: {
id: number;
attributeId: number;
attribute: AttributeDTO;
complexId: number;
value: string;
dictionaryValueId?: number;
isRequired: boolean;
isAspect: boolean;
attributeType: string;
attributeGroup?: string;
createdAt: Date;
}) {
this.id = data.id;
this.attributeId = data.attributeId;
this.attribute = data.attribute;
this.complexId = data.complexId;
this.value = data.value;
this.dictionaryValueId = data.dictionaryValueId;
this.isRequired = data.isRequired;
this.isAspect = data.isAspect;
this.attributeType = data.attributeType;
this.attributeGroup = data.attributeGroup;
this.createdAt = data.createdAt;
}
static fromDomain(entity: RequestAttributeValueDomainEntity): RequestAttributeValueDTO {
return new RequestAttributeValueDTO({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: entity.id!,
attributeId: entity.attributeId,
attribute: AttributeDTO.fromDomain(entity.attribute),
complexId: entity.complexId,
value: entity.value,
dictionaryValueId: entity.dictionaryValueId,
isRequired: entity.isRequired(),
isAspect: entity.isAspect(),
attributeType: entity.getAttributeType(),
attributeGroup: entity.getAttributeGroup(),
createdAt: entity.createdAt,
});
}
}
/**
* DTO для изображения заявки
*/
@ObjectType('MarketplaceRequestImage')
export class RequestImageDTO {
@Field(() => Int, { description: 'ID изображения' })
id!: number;
@Field({ description: 'URL изображения' })
imageUrl!: string;
@Field(() => RequestImageType, { description: 'Тип изображения' })
imageType!: RequestImageType;
@Field(() => Int, { description: 'Порядок сортировки' })
sortOrder!: number;
@Field({ description: 'Описание изображения', nullable: true })
description?: string;
@Field({ description: 'Является ли главным изображением' })
isPrimary!: boolean;
@Field({ description: 'Является ли образцом цвета' })
isColorSample!: boolean;
@Field({ description: 'Является ли изображением 360°' })
is360Image!: boolean;
@Field({ description: 'Описание типа изображения' })
typeDescription!: string;
@Field({ description: 'Имя файла' })
fileName!: string;
@Field({ description: 'Дата создания' })
createdAt!: Date;
constructor(data: {
id: number;
imageUrl: string;
imageType: RequestImageType;
sortOrder: number;
description?: string;
isPrimary: boolean;
isColorSample: boolean;
is360Image: boolean;
typeDescription: string;
fileName: string;
createdAt: Date;
}) {
this.id = data.id;
this.imageUrl = data.imageUrl;
this.imageType = data.imageType;
this.sortOrder = data.sortOrder;
this.description = data.description;
this.isPrimary = data.isPrimary;
this.isColorSample = data.isColorSample;
this.is360Image = data.is360Image;
this.typeDescription = data.typeDescription;
this.fileName = data.fileName;
this.createdAt = data.createdAt;
}
static fromDomain(entity: RequestImageDomainEntity): RequestImageDTO {
return new RequestImageDTO({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: entity.id!,
imageUrl: entity.imageUrl,
imageType: entity.imageType,
sortOrder: entity.sortOrder,
description: entity.description,
isPrimary: entity.isPrimary(),
isColorSample: entity.isColorSample(),
is360Image: entity.is360Image(),
typeDescription: entity.getTypeDescription(),
fileName: entity.getFileName(),
createdAt: entity.createdAt,
});
}
}
/**
* DTO для заявки marketplace
*/
@ObjectType('MarketplaceRequest')
export class RequestDTO {
@Field(() => Int, { description: 'ID заявки' })
id!: number;
@Field({ description: 'Уникальный хэш заявки' })
hash!: string;
@Field({ description: 'Хэш родительской заявки', nullable: true })
parentHash?: string;
@Field({ description: 'Название кооператива' })
coopname!: string;
@Field({ description: 'Имя пользователя' })
username!: string;
@Field(() => RequestType, { description: 'Тип заявки' })
type!: RequestType;
@Field(() => RequestStatus, { description: 'Статус заявки' })
status!: RequestStatus;
// Основная информация о товаре
@Field({ description: 'Название товара' })
name!: string;
@Field({ description: 'Артикул товара' })
articleNumber!: string;
@Field({ description: 'Штрихкод товара', nullable: true })
barcode?: string;
// Категория и тип
@Field(() => Int, { description: 'ID категории' })
descriptionCategoryId!: number;
@Field(() => Int, { description: 'ID типа товара' })
typeId!: number;
@Field(() => CategoryDTO, { description: 'Информация о категории' })
category!: CategoryDTO;
@Field(() => ProductTypeDTO, { description: 'Информация о типе товара' })
productType!: ProductTypeDTO;
// Цены
@Field(() => Number, { description: 'Цена товара' })
price!: number;
@Field(() => Number, { description: 'Цена до скидки', nullable: true })
oldPrice?: number;
@Field({ description: 'Валюта' })
currencyCode!: string;
@Field({ description: 'Ставка НДС' })
vat!: string;
// Вычисляемые поля для цен
@Field(() => Number, { description: 'Цена как число' })
priceAsNumber!: number;
@Field(() => Number, { description: 'Старая цена как число', nullable: true })
oldPriceAsNumber?: number;
@Field({ description: 'Есть ли скидка' })
hasDiscount!: boolean;
@Field(() => Number, { description: 'Процент скидки' })
discountPercentage!: number;
// Габариты и вес
@Field(() => Int, { description: 'Ширина упаковки', nullable: true })
width?: number;
@Field(() => Int, { description: 'Высота упаковки', nullable: true })
height?: number;
@Field(() => Int, { description: 'Глубина упаковки', nullable: true })
depth?: number;
@Field({ description: 'Единица измерения габаритов', nullable: true })
dimensionUnit?: string;
@Field(() => Int, { description: 'Вес товара', nullable: true })
weight?: number;
@Field({ description: 'Единица измерения веса', nullable: true })
weightUnit?: string;
// Количества
@Field(() => Int, { description: 'Общее количество единиц' })
units!: number;
@Field(() => Int, { description: 'Доступное количество единиц' })
availableUnits!: number;
@Field(() => Int, { description: 'Количество проданных единиц' })
settledUnits!: number;
// Время жизни и гарантии
@Field(() => Int, { description: 'Время жизни продукта в секундах', nullable: true })
productLifecycleSecs?: number;
@Field(() => Int, { description: 'Гарантийный срок в днях', nullable: true })
warrantyDays?: number;
// Дополнительные данные
@Field({ description: 'Дополнительные данные', nullable: true })
data?: string;
@Field({ description: 'Метаданные', nullable: true })
meta?: string;
// Связанные сущности
@Field(() => [RequestAttributeValueDTO], { description: 'Атрибуты заявки' })
attributes!: RequestAttributeValueDTO[];
@Field(() => [RequestImageDTO], { description: 'Изображения заявки' })
images!: RequestImageDTO[];
@Field({ description: 'URL главного изображения', nullable: true })
primaryImageUrl?: string;
@Field({ description: 'URL образца цвета', nullable: true })
colorImageUrl?: string;
// Геоограничения
@Field(() => [String], { description: 'Геоограничения' })
geoNames!: string[];
// Статусы и проверки
@Field({ description: 'Является ли заявка активной' })
isActive!: boolean;
@Field({ description: 'Можно ли редактировать заявку' })
canBeEdited!: boolean;
@Field({ description: 'Является ли предложением' })
isOffer!: boolean;
@Field({ description: 'Является ли заказом' })
isOrder!: boolean;
@Field({ description: 'Заполнены ли все обязательные атрибуты' })
hasAllRequiredAttributes!: boolean;
// Временные метки
@Field({ description: 'Дата создания' })
createdAt!: Date;
@Field({ description: 'Дата обновления' })
updatedAt!: Date;
constructor(data: {
id: number;
hash: string;
parentHash?: string;
coopname: string;
username: string;
type: RequestType;
status: RequestStatus;
name: string;
articleNumber: string;
barcode?: string;
descriptionCategoryId: number;
typeId: number;
category: CategoryDTO;
productType: ProductTypeDTO;
price: number;
oldPrice?: number;
currencyCode: string;
vat: string;
priceAsNumber: number;
oldPriceAsNumber?: number;
hasDiscount: boolean;
discountPercentage: number;
width?: number;
height?: number;
depth?: number;
dimensionUnit?: string;
weight?: number;
weightUnit?: string;
units: number;
availableUnits: number;
settledUnits: number;
productLifecycleSecs?: number;
warrantyDays?: number;
data?: string;
meta?: string;
attributes: RequestAttributeValueDTO[];
images: RequestImageDTO[];
primaryImageUrl?: string;
colorImageUrl?: string;
geoNames: string[];
isActive: boolean;
canBeEdited: boolean;
isOffer: boolean;
isOrder: boolean;
hasAllRequiredAttributes: boolean;
createdAt: Date;
updatedAt: Date;
}) {
this.id = data.id;
this.hash = data.hash;
this.parentHash = data.parentHash;
this.coopname = data.coopname;
this.username = data.username;
this.type = data.type;
this.status = data.status;
this.name = data.name;
this.articleNumber = data.articleNumber;
this.barcode = data.barcode;
this.descriptionCategoryId = data.descriptionCategoryId;
this.typeId = data.typeId;
this.category = data.category;
this.productType = data.productType;
this.price = data.price;
this.oldPrice = data.oldPrice;
this.currencyCode = data.currencyCode;
this.vat = data.vat;
this.priceAsNumber = data.priceAsNumber;
this.oldPriceAsNumber = data.oldPriceAsNumber;
this.hasDiscount = data.hasDiscount;
this.discountPercentage = data.discountPercentage;
this.width = data.width;
this.height = data.height;
this.depth = data.depth;
this.dimensionUnit = data.dimensionUnit;
this.weight = data.weight;
this.weightUnit = data.weightUnit;
this.units = data.units;
this.availableUnits = data.availableUnits;
this.settledUnits = data.settledUnits;
this.productLifecycleSecs = data.productLifecycleSecs;
this.warrantyDays = data.warrantyDays;
this.data = data.data;
this.meta = data.meta;
this.attributes = data.attributes;
this.images = data.images;
this.primaryImageUrl = data.primaryImageUrl;
this.colorImageUrl = data.colorImageUrl;
this.geoNames = data.geoNames;
this.isActive = data.isActive;
this.canBeEdited = data.canBeEdited;
this.isOffer = data.isOffer;
this.isOrder = data.isOrder;
this.hasAllRequiredAttributes = data.hasAllRequiredAttributes;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
/**
* Создать DTO из доменной сущности
*/
static fromDomain(entity: RequestDomainEntity): RequestDTO {
return new RequestDTO({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: entity.id!,
hash: entity.hash,
parentHash: entity.parentHash,
coopname: entity.coopname,
username: entity.username,
type: entity.type,
status: entity.status,
name: entity.name,
articleNumber: entity.articleNumber,
barcode: entity.barcode,
descriptionCategoryId: entity.descriptionCategoryId,
typeId: entity.typeId,
category: CategoryDTO.fromDomain(entity.category),
productType: ProductTypeDTO.fromDomain(entity.productType),
price: entity.price,
oldPrice: entity.oldPrice,
currencyCode: entity.currencyCode,
vat: entity.vat,
priceAsNumber: entity.getPriceAsNumber(),
oldPriceAsNumber: entity.getOldPriceAsNumber() || undefined,
hasDiscount: entity.hasDiscount(),
discountPercentage: entity.getDiscountPercentage(),
width: entity.width,
height: entity.height,
depth: entity.depth,
dimensionUnit: entity.dimensionUnit,
weight: entity.weight,
weightUnit: entity.weightUnit,
units: entity.units,
availableUnits: entity.availableUnits,
settledUnits: entity.settledUnits,
productLifecycleSecs: entity.productLifecycleSecs,
warrantyDays: entity.warrantyDays,
data: entity.data,
meta: entity.meta,
attributes: entity.attributes.map((attr) => RequestAttributeValueDTO.fromDomain(attr)),
images: entity.images.map((img) => RequestImageDTO.fromDomain(img)),
primaryImageUrl: entity.primaryImageUrl,
colorImageUrl: entity.colorImageUrl,
geoNames: entity.geoNames,
isActive: entity.isActive(),
canBeEdited: entity.canBeEdited(),
isOffer: entity.isOffer(),
isOrder: entity.isOrder(),
hasAllRequiredAttributes: entity.hasAllRequiredAttributes(),
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
});
}
}
@@ -0,0 +1,41 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator';
/**
* Input для поиска атрибутов
*/
@InputType()
export class SearchAttributesInput {
@Field({ description: 'Текст для поиска' })
@IsString()
searchTerm!: string;
@Field(() => Int, { description: 'ID категории для фильтрации', nullable: true })
@IsOptional()
@IsNumber()
categoryId?: number;
@Field(() => Int, { description: 'ID типа товара для фильтрации', nullable: true })
@IsOptional()
@IsNumber()
typeId?: number;
@Field({ description: 'Только обязательные', nullable: true, defaultValue: false })
@IsOptional()
onlyRequired?: boolean;
@Field({ description: 'Только аспектные', nullable: true, defaultValue: false })
@IsOptional()
onlyAspect?: boolean;
@Field({ description: 'Только со словарями', nullable: true, defaultValue: false })
@IsOptional()
onlyWithDictionary?: boolean;
@Field(() => Int, { description: 'Максимальное количество результатов', nullable: true, defaultValue: 50 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
limit?: number;
}
@@ -0,0 +1,12 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString } from 'class-validator';
/**
* Input для поиска категорий
*/
@InputType()
export class SearchCategoriesInput {
@Field({ description: 'Текст для поиска по категориям и типам товаров' })
@IsString()
searchTerm!: string;
}
@@ -0,0 +1,23 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator';
/**
* Input для поиска значений словаря
*/
@InputType()
export class SearchDictionaryValuesInput {
@Field(() => Int, { description: 'ID словаря' })
@IsNumber()
dictionaryId!: number;
@Field({ description: 'Текст для поиска' })
@IsString()
searchTerm!: string;
@Field(() => Int, { description: 'Максимальное количество результатов', nullable: true, defaultValue: 50 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
limit?: number;
}
@@ -0,0 +1,18 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional, IsString, IsNumber, Min } from 'class-validator';
/**
* Input для поиска заявок
*/
@InputType()
export class SearchRequestsInput {
@Field({ description: 'Текст для поиска по названию товара' })
@IsString()
searchTerm!: string;
@Field(() => Int, { description: 'Максимальное количество результатов', nullable: true, defaultValue: 20 })
@IsOptional()
@IsNumber()
@Min(1)
limit?: number;
}
@@ -0,0 +1,15 @@
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNumber } from 'class-validator';
/**
* Input для валидации значений атрибута
*/
@InputType()
export class ValidateAttributeValuesInput {
@Field(() => Int, { description: 'ID атрибута' })
@IsNumber()
attributeId!: number;
@Field(() => [String], { description: 'Значения для валидации' })
values!: string[];
}
@@ -0,0 +1,212 @@
import { Resolver, Query, Args } from '@nestjs/graphql';
import { Injectable, UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/modules/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/modules/auth/guards/roles.guard';
import { AuthRoles } from '~/modules/auth/decorators/auth.decorator';
import { AttributeDomainService } from '../../domain/services/attribute-domain.service';
import { AttributeDTO, AttributeGroupDTO, AttributeStatsDTO, DictionaryValueDTO } from '../dto/attribute.dto';
import { SearchAttributesInput } from '../dto/search-attributes-input.dto';
import { GetCategoryAttributesInput } from '../dto/get-category-attributes-input.dto';
import { SearchDictionaryValuesInput } from '../dto/search-dictionary-values-input.dto';
import { ValidateAttributeValuesInput } from '../dto/validate-attribute-values-input.dto';
import { GetRequiredAttributesInput } from '../dto/get-required-attributes-input.dto';
/**
* Результат валидации атрибута
*/
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType('MarketplaceAttributeValidation')
export class AttributeValidationResult {
@Field({ description: 'Результат валидации' })
isValid!: boolean;
@Field(() => [String], { description: 'Список ошибок' })
errors!: string[];
constructor(data: { isValid: boolean; errors: string[] }) {
this.isValid = data.isValid;
this.errors = data.errors;
}
}
/**
* GraphQL резолвер для атрибутов marketplace
*/
@Resolver(() => AttributeDTO)
@Injectable()
export class AttributeResolver {
constructor(private readonly attributeService: AttributeDomainService) {}
/**
* Получить атрибуты для категории и типа товара
*/
@Query(() => [AttributeDTO], {
name: 'marketplaceCategoryAttributes',
description: 'Получить атрибуты для конкретной категории и типа товара marketplace',
})
async getCategoryAttributes(
@Args('input', { type: () => GetCategoryAttributesInput })
input: GetCategoryAttributesInput
): Promise<AttributeDTO[]> {
const result = await this.attributeService.getAttributesForCategoryTypeWithFilters({
categoryId: input.categoryId,
typeId: input.typeId,
includeDictionaryValues: input.includeDictionaryValues,
onlyRequired: input.onlyRequired,
});
return result.attributes.map((attr) => AttributeDTO.fromDomain(attr, input.includeDictionaryValues));
}
/**
* Получить группированные атрибуты для категории и типа товара
*/
@Query(() => [AttributeGroupDTO], {
name: 'marketplaceCategoryAttributesGrouped',
description: 'Получить группированные атрибуты для категории и типа товара marketplace',
})
async getCategoryAttributesGrouped(
@Args('input', { type: () => GetCategoryAttributesInput })
input: GetCategoryAttributesInput
): Promise<AttributeGroupDTO[]> {
// Получаем отфильтрованные атрибуты через доменный сервис
const result = await this.attributeService.getAttributesForCategoryTypeWithFilters({
categoryId: input.categoryId,
typeId: input.typeId,
includeDictionaryValues: input.includeDictionaryValues,
onlyRequired: input.onlyRequired,
});
// Группируем атрибуты
const groupedAttributes = new Map<string, typeof result.attributes>();
for (const attribute of result.attributes) {
const groupName = attribute.groupName || 'Без группы';
if (!groupedAttributes.has(groupName)) {
groupedAttributes.set(groupName, []);
}
groupedAttributes.get(groupName)?.push(attribute);
}
// Преобразуем в DTO
const resultGroups: AttributeGroupDTO[] = [];
for (const [groupName, attributes] of groupedAttributes.entries()) {
if (attributes.length > 0) {
const groupId = attributes[0].groupId;
resultGroups.push(
new AttributeGroupDTO({
groupId,
groupName,
attributes: attributes.map((attr) => AttributeDTO.fromDomain(attr, input.includeDictionaryValues)),
})
);
}
}
return resultGroups;
}
/**
* Получить обязательные атрибуты для категории и типа
*/
@Query(() => [AttributeDTO], {
name: 'marketplaceRequiredAttributes',
description: 'Получить обязательные атрибуты для категории и типа товара marketplace',
})
async getRequiredAttributes(
@Args('data', { type: () => GetRequiredAttributesInput })
data: GetRequiredAttributesInput
): Promise<AttributeDTO[]> {
const attributes = await this.attributeService.getRequiredAttributes(data.categoryId, data.typeId);
return attributes.map((attr) => AttributeDTO.fromDomain(attr));
}
/**
* Получить аспектные атрибуты для категории и типа
*/
@Query(() => [AttributeDTO], {
name: 'marketplaceAspectAttributes',
description: 'Получить аспектные атрибуты для категории и типа товара marketplace',
})
async getAspectAttributes(
@Args('data', { type: () => GetRequiredAttributesInput })
data: GetRequiredAttributesInput
): Promise<AttributeDTO[]> {
const attributes = await this.attributeService.getAspectAttributes(data.categoryId, data.typeId);
return attributes.map((attr) => AttributeDTO.fromDomain(attr));
}
/**
* Поиск атрибутов
*/
@Query(() => [AttributeDTO], {
name: 'marketplaceSearchAttributes',
description: 'Поиск атрибутов marketplace по названию',
})
async searchAttributes(
@Args('input', { type: () => SearchAttributesInput })
input: SearchAttributesInput
): Promise<AttributeDTO[]> {
const attributes = await this.attributeService.searchAttributesWithFilters({
searchTerm: input.searchTerm,
categoryId: input.categoryId,
typeId: input.typeId,
onlyRequired: input.onlyRequired,
onlyAspect: input.onlyAspect,
onlyWithDictionary: input.onlyWithDictionary,
limit: input.limit,
});
return attributes.map((attr) => AttributeDTO.fromDomain(attr));
}
/**
* Поиск значений словаря
*/
@Query(() => [DictionaryValueDTO], {
name: 'marketplaceSearchDictionaryValues',
description: 'Поиск значений словаря marketplace',
})
async searchDictionaryValues(
@Args('input', { type: () => SearchDictionaryValuesInput })
input: SearchDictionaryValuesInput
): Promise<DictionaryValueDTO[]> {
const values = await this.attributeService.searchDictionaryValues(
input.dictionaryId,
input.searchTerm,
input.limit || 50
);
return values.map((value) => DictionaryValueDTO.fromDomain(value));
}
/**
* Валидация значений атрибута
*/
@Query(() => AttributeValidationResult, {
name: 'marketplaceValidateAttributeValues',
description: 'Валидация значений атрибута marketplace',
})
async validateAttributeValues(
@Args('input', { type: () => ValidateAttributeValuesInput })
input: ValidateAttributeValuesInput
): Promise<AttributeValidationResult> {
const result = await this.attributeService.validateAttributeValues(input.attributeId, input.values);
return new AttributeValidationResult(result);
}
/**
* Получить статистику по атрибутам
*/
@Query(() => AttributeStatsDTO, {
name: 'marketplaceAttributeStats',
description: 'Получить статистику по атрибутам marketplace',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getAttributeStats(): Promise<AttributeStatsDTO> {
const stats = await this.attributeService.getAttributeStats();
return new AttributeStatsDTO(stats);
}
}
@@ -0,0 +1,244 @@
import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql';
import { Injectable, Inject, UseGuards } from '@nestjs/common';
import {
AvailableCategoryDomainService,
AVAILABLE_CATEGORY_DOMAIN_SERVICE,
} from '../../domain/services/available-category-domain.service';
import { CategoryTreeDomainService, CATEGORY_TREE_DOMAIN_SERVICE } from '../../domain/services/category-tree-domain.service';
import { CategoryDTO } from '../dto/category-tree.dto';
import { AvailableCategoryDTO } from '../dto/available-category.dto';
import { AvailabilityStatsDTO } from '../dto/availability-stats.dto';
import { AddAvailableCategoriesInput } from '../dto/add-available-categories-input.dto';
import { AddAvailableCategoryTypesInput } from '../dto/add-available-category-types-input.dto';
import { RemoveAvailableCategoriesInput } from '../dto/remove-available-categories-input.dto';
import { RemoveAvailableCategoryTypesInput } from '../dto/remove-available-category-types-input.dto';
import { ReplaceAvailableItemsInput } from '../dto/replace-available-items-input.dto';
import { GqlJwtAuthGuard } from '~/modules/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/modules/auth/guards/roles.guard';
import { AuthRoles } from '~/modules/auth/decorators/auth.decorator';
import { CurrentUser } from '~/modules/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import config from '~/config/config';
/**
* GraphQL резолвер для администрирования доступных категорий и типов товаров marketplace
*/
@Resolver(() => AvailableCategoryDTO)
@Injectable()
export class AvailableCategoryAdminResolver {
constructor(
@Inject(AVAILABLE_CATEGORY_DOMAIN_SERVICE)
private readonly availableCategoryService: AvailableCategoryDomainService,
@Inject(CATEGORY_TREE_DOMAIN_SERVICE)
private readonly categoryTreeService: CategoryTreeDomainService
) {}
/**
* Получить все доступные категории и типы для текущего кооператива
*/
@Query(() => [AvailableCategoryDTO], {
name: 'marketplaceGetAvailableCategories',
description: 'Получить все доступные категории и типы для кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async getAvailableCategories(): Promise<AvailableCategoryDTO[]> {
const availableCategories = await this.availableCategoryService.getAvailableCategories(config.coopname);
return availableCategories.map((cat) => ({
id: cat.id,
coopname: cat.coopname,
categoryId: cat.categoryId,
typeId: cat.typeId,
isActive: cat.isActive,
addedBy: cat.addedBy,
isForEntireCategory: cat.isForEntireCategory(),
isForSpecificType: cat.isForSpecificType(),
createdAt: cat.createdAt,
updatedAt: cat.updatedAt,
}));
}
/**
* Получить дерево доступных категорий и типов
*/
@Query(() => [CategoryDTO], {
name: 'marketplaceGetAvailableCategoryTree',
description: 'Получить дерево доступных категорий и типов для кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async getAvailableCategoryTree(): Promise<CategoryDTO[]> {
const availableTree = await this.categoryTreeService.buildAvailableCategoryTree(config.coopname);
return availableTree.map((category) => CategoryDTO.fromDomain(category));
}
/**
* Получить статистику по доступности категорий
*/
@Query(() => AvailabilityStatsDTO, {
name: 'marketplaceGetAvailabilityStats',
description: 'Получить статистику по доступности категорий в кооперативе',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async getAvailabilityStats(): Promise<AvailabilityStatsDTO> {
const stats = await this.availableCategoryService.getAvailabilityStats(config.coopname);
return {
totalAvailable: stats.totalAvailable,
categoriesCount: stats.categoriesCount,
typesCount: stats.typesCount,
hasRestrictions: stats.hasRestrictions,
};
}
/**
* Добавить категории в доступные (целые категории)
*/
@Mutation(() => [AvailableCategoryDTO], {
name: 'marketplaceAddAvailableCategories',
description: 'Добавить категории в доступные для кооператива (целые категории)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async addAvailableCategories(
@Args('input', { type: () => AddAvailableCategoriesInput })
input: AddAvailableCategoriesInput,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<AvailableCategoryDTO[]> {
const availableCategories = await this.availableCategoryService.addMultipleCategories(
config.coopname,
input.categoryIds,
currentUser?.username ?? 'system'
);
return this.mapToDTO(availableCategories);
}
/**
* Добавить типы товаров в доступные
*/
@Mutation(() => [AvailableCategoryDTO], {
name: 'marketplaceAddAvailableCategoryTypes',
description: 'Добавить конкретные типы товаров в доступные для кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async addAvailableCategoryTypes(
@Args('input', { type: () => AddAvailableCategoryTypesInput })
input: AddAvailableCategoryTypesInput,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<AvailableCategoryDTO[]> {
const availableCategories = await this.availableCategoryService.addMultipleCategoryTypes(
config.coopname,
input.categoryTypes,
currentUser?.username ?? 'system'
);
return this.mapToDTO(availableCategories);
}
/**
* Удалить категории из доступных
*/
@Mutation(() => Boolean, {
name: 'marketplaceRemoveAvailableCategories',
description: 'Удалить категории из доступных для кооператива (включая все их типы)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async removeAvailableCategories(
@Args('input', { type: () => RemoveAvailableCategoriesInput })
input: RemoveAvailableCategoriesInput
): Promise<boolean> {
await this.availableCategoryService.removeMultipleCategories(config.coopname, input.categoryIds);
return true;
}
/**
* Удалить типы товаров из доступных
*/
@Mutation(() => Boolean, {
name: 'marketplaceRemoveAvailableCategoryTypes',
description: 'Удалить конкретные типы товаров из доступных для кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async removeAvailableCategoryTypes(
@Args('input', { type: () => RemoveAvailableCategoryTypesInput })
input: RemoveAvailableCategoryTypesInput
): Promise<boolean> {
await this.availableCategoryService.removeMultipleCategoryTypes(config.coopname, input.categoryTypes);
return true;
}
/**
* Заменить все доступные категории и типы новым списком
*/
@Mutation(() => [AvailableCategoryDTO], {
name: 'marketplaceReplaceAvailableItems',
description: 'Заменить все доступные категории и типы новым списком',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async replaceAvailableItems(
@Args('input', { type: () => ReplaceAvailableItemsInput })
input: ReplaceAvailableItemsInput,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<AvailableCategoryDTO[]> {
const availableCategories = await this.availableCategoryService.replaceAvailableItems(
config.coopname,
input.categoryIds,
input.categoryTypes,
currentUser?.username ?? 'system'
);
return this.mapToDTO(availableCategories);
}
/**
* Очистить все доступные категории (сделать доступными все)
*/
@Mutation(() => Boolean, {
name: 'marketplaceClearAvailableCategories',
description: 'Очистить все доступные категории (сделать доступными все)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async clearAvailableCategories(@CurrentUser() currentUser: MonoAccountDomainInterface): Promise<boolean> {
await this.availableCategoryService.replaceAvailableItems(config.coopname, [], [], currentUser?.username ?? 'system');
return true;
}
/**
* Получить доступные правила для конкретной категории
*/
@Query(() => [AvailableCategoryDTO], {
name: 'marketplaceGetCategoryRules',
description: 'Получить все доступные правила для конкретной категории',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async getCategoryRules(@Args('categoryId', { type: () => Int }) categoryId: number): Promise<AvailableCategoryDTO[]> {
const rules = await this.availableCategoryService.getCategoryRules(config.coopname, categoryId);
return this.mapToDTO(rules);
}
/**
* Вспомогательный метод для маппинга в DTO
*/
private mapToDTO(availableCategories: any[]): AvailableCategoryDTO[] {
return availableCategories.map((cat) => ({
id: cat.id,
coopname: cat.coopname,
categoryId: cat.categoryId,
typeId: cat.typeId,
isActive: cat.isActive,
addedBy: cat.addedBy,
isForEntireCategory: cat.isForEntireCategory(),
isForSpecificType: cat.isForSpecificType(),
createdAt: cat.createdAt,
updatedAt: cat.updatedAt,
}));
}
}
@@ -0,0 +1,90 @@
import { Resolver, Query, Args, Int } from '@nestjs/graphql';
import { Inject, UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/modules/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/modules/auth/guards/roles.guard';
import { AuthRoles } from '~/modules/auth/decorators/auth.decorator';
import { CategoryTreeService, CATEGORY_TREE_SERVICE } from '../services/category-tree.service';
import { CategoryTreeDomainService, CATEGORY_TREE_DOMAIN_SERVICE } from '../../domain/services/category-tree-domain.service';
import { CategoryDTO, ProductTypeDTO } from '../dto/category-tree.dto';
import { CategoryTreeStatsDTO } from '../dto/category-tree.dto';
import { GetCategoryTreeInput } from '../dto/get-category-tree-input.dto';
import { GetCategoryByIdInput } from '../dto/get-category-by-id-input.dto';
import { GetProductTypeByIdInput } from '../dto/get-product-type-by-id-input.dto';
import { SearchCategoriesInput } from '../dto/search-categories-input.dto';
/**
* GraphQL резолвер для работы с деревом категорий marketplace
*/
@Resolver(() => CategoryDTO)
export class CategoryTreeResolver {
constructor(
@Inject(CATEGORY_TREE_SERVICE)
private readonly categoryTreeService: CategoryTreeService,
@Inject(CATEGORY_TREE_DOMAIN_SERVICE)
private readonly categoryTreeDomainService: CategoryTreeDomainService
) {}
@Query(() => [CategoryDTO], {
name: 'marketplaceGetCategoryTree',
description: 'Получить полное дерево категорий marketplace с типами товаров',
})
async getCategoryTree(
@Args('input', { type: () => GetCategoryTreeInput, nullable: true })
input?: GetCategoryTreeInput
): Promise<CategoryDTO[]> {
return this.categoryTreeService.getCategoryTree(input);
}
@Query(() => [CategoryDTO], {
name: 'marketplaceGetRootCategories',
description: 'Получить все корневые категории marketplace',
})
async getRootCategories(): Promise<CategoryDTO[]> {
return this.categoryTreeService.getRootCategories();
}
@Query(() => CategoryDTO, {
name: 'marketplaceGetCategoryById',
description: 'Получить категорию marketplace по ID',
nullable: true,
})
async getCategoryById(
@Args('data', { type: () => GetCategoryByIdInput })
data: GetCategoryByIdInput
): Promise<CategoryDTO | null> {
return this.categoryTreeService.getCategoryById(data.categoryId);
}
@Query(() => ProductTypeDTO, {
name: 'marketplaceGetProductTypeById',
description: 'Получить тип товара marketplace по ID',
nullable: true,
})
async getProductTypeById(
@Args('data', { type: () => GetProductTypeByIdInput })
data: GetProductTypeByIdInput
): Promise<ProductTypeDTO | null> {
return this.categoryTreeService.getProductTypeById(data.typeId);
}
@Query(() => [CategoryDTO], {
name: 'marketplaceGetSearchCategories',
description: 'Универсальный поиск по категориям и типам товаров',
})
async searchCategories(
@Args('data', { type: () => SearchCategoriesInput })
data: SearchCategoriesInput
): Promise<CategoryDTO[]> {
return this.categoryTreeService.search(data.searchTerm);
}
@Query(() => CategoryTreeStatsDTO, {
name: 'marketplaceGetCategoryTreeStats',
description: 'Получить статистику по дереву категорий',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getCategoryTreeStats(): Promise<CategoryTreeStatsDTO> {
return this.categoryTreeService.getCategoryTreeStats();
}
}
@@ -0,0 +1,91 @@
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UseGuards, Inject } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { config } from '~/config';
import { UpdateMarketplaceSettingsInputDTO, MarketplaceSettingsDTO } from '../dto/marketplace-settings.dto';
import { MARKETPLACE_SETTINGS_REPOSITORY, type MarketplaceSettingsRepository } from '../../domain/repositories/marketplace-settings.repository';
@Resolver(() => MarketplaceSettingsDTO)
export class MarketplaceSettingsResolver {
constructor(
@Inject(MARKETPLACE_SETTINGS_REPOSITORY)
private readonly settingsRepo: MarketplaceSettingsRepository,
) {}
@Query(() => MarketplaceSettingsDTO, {
name: 'getMarketplaceSettings',
description: 'Получить настройки маркетплейса',
})
@UseGuards(GqlJwtAuthGuard)
async getMarketplaceSettings(): Promise<MarketplaceSettingsDTO> {
const settings = await this.settingsRepo.findByCoopname(config.coopname);
if (settings) return settings as unknown as MarketplaceSettingsDTO;
return (await this.settingsRepo.upsert({
coopname: config.coopname,
lead_request_policy: 'both' as any,
publish_access_policy: 'all_members' as any,
publish_whitelist: [],
moderation_required: true,
cycles_enabled: true,
max_cycle_days: 30,
external_delivery_enabled: true,
internal_delivery_enabled: true,
allowed_category_ids: [],
})) as unknown as MarketplaceSettingsDTO;
}
@Mutation(() => MarketplaceSettingsDTO, {
name: 'updateMarketplaceSettings',
description: 'Обновить настройки маркетплейса (только chairman)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async updateMarketplaceSettings(
@Args('data') data: UpdateMarketplaceSettingsInputDTO,
): Promise<MarketplaceSettingsDTO> {
return (await this.settingsRepo.upsert({
coopname: config.coopname,
...data,
})) as unknown as MarketplaceSettingsDTO;
}
@Mutation(() => Boolean, {
name: 'addToPublishWhitelist',
description: 'Добавить пайщика в белый список публикации',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async addToPublishWhitelist(
@Args('username', { type: () => String }) username: string,
): Promise<boolean> {
const settings = await this.settingsRepo.findByCoopname(config.coopname);
if (!settings) return false;
if (settings.publish_whitelist.includes(username)) return true;
await this.settingsRepo.upsert({
coopname: config.coopname,
publish_whitelist: [...settings.publish_whitelist, username],
});
return true;
}
@Mutation(() => Boolean, {
name: 'removeFromPublishWhitelist',
description: 'Удалить пайщика из белого списка публикации',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async removeFromPublishWhitelist(
@Args('username', { type: () => String }) username: string,
): Promise<boolean> {
const settings = await this.settingsRepo.findByCoopname(config.coopname);
if (!settings) return false;
await this.settingsRepo.upsert({
coopname: config.coopname,
publish_whitelist: settings.publish_whitelist.filter(u => u !== username),
});
return true;
}
}
@@ -0,0 +1,280 @@
import { Resolver, Mutation, Query, Args, Int } from '@nestjs/graphql';
import { Injectable, Inject, UseGuards } from '@nestjs/common';
import { RequestDomainService, REQUEST_DOMAIN_SERVICE } from '../../domain/services/request-domain.service';
import { RolesGuard } from '~/modules/auth/guards/roles.guard';
import { AuthRoles } from '~/modules/auth/decorators/auth.decorator';
import { RequestDTO } from '../dto/request.dto';
import { CreateRequestInput, RequestTypeInput, RequestImageTypeInput } from '../dto/create-request-input.dto';
import { GetCoopRequestsInput } from '../dto/get-coop-requests-input.dto';
import { GetRequestStatisticsInput } from '../dto/get-request-statistics-input.dto';
import { RequestStatisticsDTO } from '../dto/request-statistics.dto';
import { SearchRequestsInput } from '../dto/search-requests-input.dto';
import { GetUserRequestsInput } from '../dto/get-user-requests-input.dto';
import { FindPotentialMatchesInput } from '../dto/find-potential-matches-input.dto';
import { PublishRequestInput } from '../dto/publish-request-input.dto';
import { GetRequestInput } from '../dto/get-request-input.dto';
import { GetRequestByHashInput } from '../dto/get-request-by-hash-input.dto';
import { RequestType, RequestStatus } from '../../domain/entities/request-domain.entity';
import { RequestImageType } from '../../domain/entities/request-image-domain.entity';
import { GqlJwtAuthGuard } from '~/modules/auth/guards/graphql-jwt-auth.guard';
import { CurrentUser } from '~/modules/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
/**
* GraphQL resolver для работы с заявками marketplace
*/
@Resolver(() => RequestDTO)
@Injectable()
export class RequestResolver {
constructor(
@Inject(REQUEST_DOMAIN_SERVICE)
private readonly requestService: RequestDomainService
) {}
/**
* Создать новую заявку
*/
@Mutation(() => RequestDTO, {
name: 'marketplaceCreateRequest',
description: 'Создать новую заявку на поставку или заказ товара',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['member', 'chairman'])
async createRequest(
@Args('data', { type: () => CreateRequestInput })
data: CreateRequestInput,
@CurrentUser() user: MonoAccountDomainInterface
): Promise<RequestDTO> {
// Преобразуем входные данные в параметры для доменного сервиса
const requestParams = {
coopname: data.coopname,
username: user.username,
type: this.convertRequestType(data.type),
name: data.name,
articleNumber: data.articleNumber,
descriptionCategoryId: data.descriptionCategoryId,
typeId: data.typeId,
price: data.price,
currencyCode: data.currencyCode || 'RUB',
vat: data.vat,
units: data.units,
barcode: data.barcode,
oldPrice: data.oldPrice,
width: data.width,
height: data.height,
depth: data.depth,
dimensionUnit: data.dimensionUnit || 'mm',
weight: data.weight,
weightUnit: data.weightUnit || 'g',
productLifecycleSecs: data.productLifecycleSecs,
warrantyDays: data.warrantyDays,
data: data.data,
meta: data.meta,
attributes: data.attributes.map((attr) => ({
attributeId: attr.attributeId,
value: attr.value,
complexId: attr.complexId || 0,
dictionaryValueId: attr.dictionaryValueId,
})),
images: data.images.map((img) => ({
imageUrl: img.imageUrl,
imageType: this.convertImageType(img.imageType),
sortOrder: img.sortOrder,
description: img.description,
})),
primaryImageUrl: data.primaryImageUrl,
colorImageUrl: data.colorImageUrl,
geoNames: data.geoNames || [],
parentHash: data.parentHash,
};
const request = await this.requestService.createRequest(requestParams);
return RequestDTO.fromDomain(request);
}
/**
* Получить заявку по ID
*/
@Query(() => RequestDTO, {
name: 'marketplaceGetRequest',
description: 'Получить заявку по ID',
nullable: true,
})
async getRequest(
@Args('data', { type: () => GetRequestInput })
data: GetRequestInput
): Promise<RequestDTO | null> {
const request = await this.requestService.findById(data.id);
return request ? RequestDTO.fromDomain(request) : null;
}
/**
* Получить заявку по хэшу
*/
@Query(() => RequestDTO, {
name: 'marketplaceGetRequestByHash',
description: 'Получить заявку по хэшу',
nullable: true,
})
async getRequestByHash(
@Args('data', { type: () => GetRequestByHashInput })
data: GetRequestByHashInput
): Promise<RequestDTO | null> {
const request = await this.requestService.findByHash(data.hash);
return request ? RequestDTO.fromDomain(request) : null;
}
/**
* Получить заявки пользователя
*/
@Query(() => [RequestDTO], {
name: 'marketplaceGetUserRequests',
description: 'Получить заявки текущего пользователя',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['member', 'chairman'])
async getUserRequests(
@CurrentUser() user: MonoAccountDomainInterface,
@Args('data', { type: () => GetUserRequestsInput, nullable: true })
data?: GetUserRequestsInput
): Promise<RequestDTO[]> {
const requests = await this.requestService.findRecentByUser(user.username, data?.limit);
return requests.map((req) => RequestDTO.fromDomain(req));
}
/**
* Получить заявки кооператива
*/
@Query(() => [RequestDTO], {
name: 'marketplaceGetCoopRequests',
description: 'Получить заявки кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['member', 'chairman'])
async getCoopRequests(
@Args('data', { type: () => GetCoopRequestsInput }) data: GetCoopRequestsInput
): Promise<RequestDTO[]> {
const filters = {
coopname: data.coopname,
...(data.type && { type: this.convertRequestType(data.type) }),
...(data.status && { status: data.status as RequestStatus }),
limit: data.limit,
};
const requests = await this.requestService.findWithFilters(filters);
return requests.map((req) => RequestDTO.fromDomain(req));
}
/**
* Поиск заявок по названию товара
*/
@Query(() => [RequestDTO], {
name: 'marketplaceSearchRequests',
description: 'Поиск заявок по названию товара',
})
async searchRequests(
@Args('data', { type: () => SearchRequestsInput })
data: SearchRequestsInput
): Promise<RequestDTO[]> {
const requests = await this.requestService.searchByProductName(data.searchTerm, data.limit);
return requests.map((req) => RequestDTO.fromDomain(req));
}
/**
* Найти потенциальные совпадения для заявки
*/
@Query(() => [RequestDTO], {
name: 'marketplaceFindPotentialMatches',
description: 'Найти потенциальные совпадения для заявки',
})
async findPotentialMatches(
@Args('data', { type: () => FindPotentialMatchesInput })
data: FindPotentialMatchesInput
): Promise<RequestDTO[]> {
const matches = await this.requestService.findPotentialMatches(data.requestId);
return matches.map((req) => RequestDTO.fromDomain(req));
}
// /**
// * Опубликовать заявку
// */
// @Mutation(() => RequestDTO, {
// name: 'marketplacePublishRequest',
// description: 'Опубликовать заявку для поиска совпадений',
// })
// @UseGuards(GqlJwtAuthGuard, RolesGuard)
// @AuthRoles(['member', 'chairman'])
// async publishRequest(
// @Args('data', { type: () => PublishRequestInput })
// data: PublishRequestInput,
// @CurrentUser() user: MonoAccountDomainInterface
// ): Promise<RequestDTO> {
// // Проверяем, что заявка принадлежит пользователю
// const request = await this.requestService.findById(data.id);
// if (!request) {
// throw new Error('Заявка не найдена');
// }
// if (request.username !== user.username) {
// throw new Error('Нет прав для публикации этой заявки');
// }
// const publishedRequest = await this.requestService.publishRequest(data.id);
// return RequestDTO.fromDomain(publishedRequest);
// }
/**
* Получить статистику заявок
*/
@Query(() => RequestStatisticsDTO, {
name: 'marketplaceGetRequestStatistics',
description: 'Получить статистику заявок кооператива',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getRequestStatistics(
@Args('data', { type: () => GetRequestStatisticsInput }) data: GetRequestStatisticsInput
): Promise<RequestStatisticsDTO> {
const stats = await this.requestService.getRequestStatistics(data.coopname);
return new RequestStatisticsDTO({
totalRequests: stats.totalRequests,
activeOffers: stats.activeOffers,
activeOrders: stats.activeOrders,
completedDeals: stats.completedDeals,
requestsByCategory: stats.requestsByCategory,
});
}
/**
* Преобразовать тип заявки из GraphQL в доменный
*/
private convertRequestType(type: RequestTypeInput): RequestType {
switch (type) {
case RequestTypeInput.OFFER:
return RequestType.OFFER;
case RequestTypeInput.ORDER:
return RequestType.ORDER;
default:
throw new Error(`Неизвестный тип заявки: ${type}`);
}
}
/**
* Преобразовать тип изображения из GraphQL в доменный
*/
private convertImageType(type: RequestImageTypeInput): RequestImageType {
switch (type) {
case RequestImageTypeInput.REGULAR:
return RequestImageType.REGULAR;
case RequestImageTypeInput.PRIMARY:
return RequestImageType.PRIMARY;
case RequestImageTypeInput.COLOR_SAMPLE:
return RequestImageType.COLOR_SAMPLE;
case RequestImageTypeInput.IMAGE_360:
return RequestImageType.IMAGE_360;
default:
throw new Error(`Неизвестный тип изображения: ${type}`);
}
}
}
@@ -0,0 +1,124 @@
import { Resolver, Mutation, Args, ObjectType, Field, InputType, Int } from '@nestjs/graphql';
import { UseGuards, Inject } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { IsString, IsArray } from 'class-validator';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import { COOPLACE_BLOCKCHAIN_PORT, type CooplaceBlockchainPort } from '~/domain/cooplace/interfaces/cooplace-blockchain.port';
import { config } from '~/config';
@InputType('CreateShipmentInput')
export class CreateShipmentInputDTO {
@Field(() => String)
@IsString()
hash!: string;
@Field(() => String)
@IsString()
driver_username!: string;
@Field(() => String)
@IsString()
source_braname!: string;
@Field(() => String)
@IsString()
destination_braname!: string;
@Field(() => [String])
@IsArray()
request_hashes!: string[];
@Field(() => SignedDigitalDocumentInputDTO)
transport_act!: SignedDigitalDocumentInputDTO;
}
@InputType('SignShipmentInput')
export class SignShipmentInputDTO {
@Field(() => String)
@IsString()
hash!: string;
@Field(() => SignedDigitalDocumentInputDTO)
document!: SignedDigitalDocumentInputDTO;
}
@Resolver()
export class ShipmentResolver {
constructor(
@Inject(COOPLACE_BLOCKCHAIN_PORT)
private readonly blockchainPort: CooplaceBlockchainPort,
) {}
@Mutation(() => TransactionDTO, {
name: 'createShipment',
description: 'Создать перевозку (КУ отправителя)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async createShipment(
@Args('data') data: CreateShipmentInputDTO,
): Promise<TransactionDTO> {
const doc: Record<string, unknown> = { ...(data.transport_act as any) };
doc.meta = JSON.stringify(doc.meta);
return (this.blockchainPort as any).createShipment?.({
coopname: config.coopname,
...data,
transport_act_sender: doc,
}) as any;
}
@Mutation(() => TransactionDTO, {
name: 'signShipmentByDriver',
description: 'Подпись водителя на перевозке',
})
@UseGuards(GqlJwtAuthGuard)
async signShipmentByDriver(
@Args('data') data: SignShipmentInputDTO,
): Promise<TransactionDTO> {
const doc: Record<string, unknown> = { ...(data.document as any) };
doc.meta = JSON.stringify(doc.meta);
return (this.blockchainPort as any).signByDriver?.({
coopname: config.coopname,
hash: data.hash,
transport_act_driver: doc,
}) as any;
}
@Mutation(() => TransactionDTO, {
name: 'shipmentArrived',
description: 'Водитель отмечает прибытие',
})
@UseGuards(GqlJwtAuthGuard)
async shipmentArrived(
@Args('data') data: SignShipmentInputDTO,
): Promise<TransactionDTO> {
const doc: Record<string, unknown> = { ...(data.document as any) };
doc.meta = JSON.stringify(doc.meta);
return (this.blockchainPort as any).arrived?.({
coopname: config.coopname,
hash: data.hash,
transport_act_delivery: doc,
}) as any;
}
@Mutation(() => TransactionDTO, {
name: 'receiveShipment',
description: 'Приём перевозки на складе КУ получателя',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async receiveShipment(
@Args('data') data: SignShipmentInputDTO,
): Promise<TransactionDTO> {
const doc: Record<string, unknown> = { ...(data.document as any) };
doc.meta = JSON.stringify(doc.meta);
return (this.blockchainPort as any).receiveShipment?.({
coopname: config.coopname,
hash: data.hash,
warehouse_receipt_act: doc,
}) as any;
}
}
@@ -0,0 +1,85 @@
import { Injectable, Inject } from '@nestjs/common';
import { CategoryTreeDomainService, CATEGORY_TREE_DOMAIN_SERVICE } from '../../domain/services/category-tree-domain.service';
import { CategoryDTO, ProductTypeDTO } from '../dto/category-tree.dto';
import { CategoryTreeStatsDTO } from '../dto/category-tree.dto';
import { GetCategoryTreeInput } from '../dto/get-category-tree-input.dto';
/**
* Сервис приложения для работы с деревом категорий
*/
@Injectable()
export class CategoryTreeService {
constructor(
@Inject(CATEGORY_TREE_DOMAIN_SERVICE)
private readonly categoryTreeDomainService: CategoryTreeDomainService
) {}
/**
* Получить полное дерево категорий с типами товаров
*/
async getCategoryTree(input?: GetCategoryTreeInput): Promise<CategoryDTO[]> {
const domainTree = await this.categoryTreeDomainService.buildCategoryTreeWithOptions({
rootCategoryId: input?.rootCategoryId,
onlyAvailable: input?.onlyAvailable,
includeTypes: input?.includeTypes,
maxDepth: input?.maxDepth,
});
return domainTree.map((category) => CategoryDTO.fromDomain(category));
}
/**
* Получить дерево категорий с фильтрацией по доступности
*/
async getAvailableCategoryTree(coopname?: string): Promise<CategoryDTO[]> {
const domainTree = await this.categoryTreeDomainService.buildAvailableCategoryTree(coopname);
return domainTree.map((category) => CategoryDTO.fromDomain(category));
}
/**
* Получить все root-категории
*/
async getRootCategories(): Promise<CategoryDTO[]> {
const rootCategories = await this.categoryTreeDomainService.getRootCategories();
return rootCategories.map((category) => CategoryDTO.fromDomain(category));
}
/**
* Получить категорию по ID
*/
async getCategoryById(categoryId: number): Promise<CategoryDTO | null> {
const category = await this.categoryTreeDomainService.getCategoryById(categoryId);
if (!category) return null;
return CategoryDTO.fromDomain(category);
}
/**
* Получить тип товара по ID
*/
async getProductTypeById(typeId: number): Promise<ProductTypeDTO | null> {
const type = await this.categoryTreeDomainService.getTypeById(typeId);
if (!type) return null;
return ProductTypeDTO.fromDomain(type);
}
/**
* Универсальный поиск по дереву категорий и типов товаров
* Возвращает дерево категорий на основе поискового запроса
*/
async search(searchTerm: string): Promise<CategoryDTO[]> {
const resultTree = await this.categoryTreeDomainService.search(searchTerm);
return resultTree.map((category) => CategoryDTO.fromDomain(category));
}
/**
* Получить статистику по дереву категорий
*/
async getCategoryTreeStats(): Promise<CategoryTreeStatsDTO> {
const stats = await this.categoryTreeDomainService.getCategoryTreeStats();
return new CategoryTreeStatsDTO(stats);
}
}
export const CATEGORY_TREE_SERVICE = Symbol('CATEGORY_TREE_SERVICE');
@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
/**
* Обработчик событий маркетплейса от парсера блокчейна.
* Синхронизирует состояние заявок в БД с блокчейном.
*/
@Injectable()
export class MarketplaceEventService {
constructor(private readonly logger: WinstonLoggerService) {
this.logger.setContext(MarketplaceEventService.name);
}
@OnEvent('action::marketplace::orderoffer')
async handleOrderOffer(event: any): Promise<void> {
this.logger.info(`Новая заявка orderoffer: ${event?.data?.hash || 'unknown'}`);
}
@OnEvent('action::marketplace::accept')
async handleAccept(event: any): Promise<void> {
this.logger.info(`Заявка принята: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::authcontrib')
async handleAuthContrib(event: any): Promise<void> {
this.logger.info(`Взнос авторизован: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::supply')
async handleSupply(event: any): Promise<void> {
this.logger.info(`Поставка: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::supplcnf')
async handleSupplyConfirm(event: any): Promise<void> {
this.logger.info(`Поставка подтверждена: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::delivered')
async handleDelivered(event: any): Promise<void> {
this.logger.info(`Доставлено: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::reqreturn')
async handleReqReturn(event: any): Promise<void> {
this.logger.info(`Запрос возврата: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::receive')
async handleReceive(event: any): Promise<void> {
this.logger.info(`Получение: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::receivecnf')
async handleReceiveConfirm(event: any): Promise<void> {
this.logger.info(`Получение подтверждено: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::complete')
async handleComplete(event: any): Promise<void> {
this.logger.info(`Поставка завершена: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::cancel')
async handleCancel(event: any): Promise<void> {
this.logger.info(`Заявка отменена: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::destroy')
async handleDestroy(event: any): Promise<void> {
this.logger.info(`Имущество уничтожено: ${event?.data?.request_hash || 'unknown'}`);
}
@OnEvent('action::marketplace::dispute')
async handleDispute(event: any): Promise<void> {
this.logger.info(`Претензия: ${event?.data?.request_hash || 'unknown'}`);
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MarketplaceSettingsResolver } from './application/resolvers/marketplace-settings.resolver';
import { ShipmentResolver } from './application/resolvers/shipment.resolver';
import { MarketplaceEventService } from './application/services/marketplace-event.service';
import { MatchService } from './domain/services/match.service';
import { CycleService } from './domain/services/cycle.service';
import { MARKETPLACE_SETTINGS_REPOSITORY } from './domain/repositories/marketplace-settings.repository';
import { MarketplaceSettingsTypeormEntity } from './infrastructure/entities/marketplace-settings.typeorm-entity';
import { MarketplaceSettingsTypeormRepository } from './infrastructure/adapters/marketplace-settings.typeorm-repository';
@Module({
imports: [
TypeOrmModule.forFeature([
MarketplaceSettingsTypeormEntity,
]),
],
providers: [
MarketplaceSettingsResolver,
ShipmentResolver,
MarketplaceEventService,
MatchService,
CycleService,
{ provide: MARKETPLACE_SETTINGS_REPOSITORY, useClass: MarketplaceSettingsTypeormRepository },
],
exports: [MatchService, CycleService],
})
export class CooplaceExtensionModule {}
@@ -0,0 +1,108 @@
import type { DictionaryDomainEntity } from './dictionary-domain.entity';
import type { CategoryTypeAttributeDomainEntity } from './category-type-attribute-domain.entity';
/**
* Доменная сущность атрибута товара для marketplace расширения
* Представляет характеристики товаров для конкретных категорий и типов товаров
*/
export class AttributeDomainEntity {
public readonly attributeId: number;
public readonly name: string;
public readonly description?: string;
public readonly type: string;
public readonly isCollection: boolean;
public readonly isRequired: boolean;
public readonly isAspect: boolean;
public readonly maxValueCount: number;
public readonly groupName?: string;
public readonly groupId?: number;
public readonly dictionaryId?: number;
public readonly categoryDependent: boolean;
public readonly complexIsCollection: boolean;
public readonly attributeComplexId: number;
public readonly dictionary?: DictionaryDomainEntity;
public readonly categoryTypeAttributes: CategoryTypeAttributeDomainEntity[];
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: {
attributeId: number;
name: string;
description?: string;
type: string;
isCollection: boolean;
isRequired: boolean;
isAspect: boolean;
maxValueCount: number;
groupName?: string;
groupId?: number;
dictionaryId?: number;
categoryDependent: boolean;
complexIsCollection: boolean;
attributeComplexId: number;
dictionary?: DictionaryDomainEntity;
categoryTypeAttributes?: CategoryTypeAttributeDomainEntity[];
createdAt?: Date;
updatedAt?: Date;
}) {
this.attributeId = data.attributeId;
this.name = data.name;
this.description = data.description;
this.type = data.type;
this.isCollection = data.isCollection;
this.isRequired = data.isRequired;
this.isAspect = data.isAspect;
this.maxValueCount = data.maxValueCount;
this.groupName = data.groupName;
this.groupId = data.groupId;
this.dictionaryId = data.dictionaryId;
this.categoryDependent = data.categoryDependent;
this.complexIsCollection = data.complexIsCollection;
this.attributeComplexId = data.attributeComplexId;
this.dictionary = data.dictionary;
this.categoryTypeAttributes = data.categoryTypeAttributes || [];
this.createdAt = data.createdAt || new Date();
this.updatedAt = data.updatedAt || new Date();
}
/**
* Проверяет, является ли атрибут словарным
*/
hasDictionary(): boolean {
return this.dictionaryId !== undefined && this.dictionaryId > 0;
}
/**
* Проверяет, можно ли изменить атрибут после создания товара
*/
canBeModifiedAfterCreation(): boolean {
return !this.isAspect;
}
/**
* Получает максимальное количество значений
*/
getMaxValues(): number {
if (this.isCollection) {
return this.maxValueCount > 0 ? this.maxValueCount : Number.MAX_SAFE_INTEGER;
}
return 1;
}
/**
* Получает группу атрибута
*/
getGroup(): { id?: number; name?: string } {
return {
id: this.groupId,
name: this.groupName,
};
}
/**
* Проверяет, является ли атрибут комплексным
*/
isComplexAttribute(): boolean {
return this.attributeComplexId > 0;
}
}
@@ -0,0 +1,92 @@
/**
* Доменная сущность для управления доступными категориями и типами товаров в кооперативе
* Определяет какие категории и типы товаров маркетплейса доступны в конкретном кооперативе
*/
export class AvailableCategoryDomainEntity {
public readonly id: number;
public readonly coopname: string;
public readonly categoryId: number;
public readonly typeId?: number; // null = доступна вся категория, число = только конкретный тип
public readonly isActive: boolean;
public readonly addedBy: string;
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: {
id?: number;
coopname: string;
categoryId: number;
typeId?: number;
isActive?: boolean;
addedBy: string;
createdAt?: Date;
updatedAt?: Date;
}) {
this.id = data.id || 0;
this.coopname = data.coopname;
this.categoryId = data.categoryId;
this.typeId = data.typeId;
this.isActive = data.isActive ?? true;
this.addedBy = data.addedBy;
this.createdAt = data.createdAt || new Date();
this.updatedAt = data.updatedAt || new Date();
}
/**
* Проверяет, активна ли категория/тип
*/
isActiveCategory(): boolean {
return this.isActive;
}
/**
* Проверяет, применяется ли к конкретному типу товара
*/
isForSpecificType(): boolean {
return this.typeId !== undefined && this.typeId !== null;
}
/**
* Проверяет, применяется ли к всей категории
*/
isForEntireCategory(): boolean {
return this.typeId === undefined || this.typeId === null;
}
/**
* Проверяет, доступен ли указанный тип товара
*/
isTypeAvailable(typeId: number): boolean {
if (!this.isActive) return false;
// Если правило для всей категории - любой тип доступен
if (this.isForEntireCategory()) {
return true;
}
// Если правило для конкретного типа - проверяем соответствие
return this.typeId === typeId;
}
/**
* Деактивирует категорию/тип
*/
deactivate(): AvailableCategoryDomainEntity {
return new AvailableCategoryDomainEntity({
...this,
isActive: false,
updatedAt: new Date(),
});
}
/**
* Активирует категорию/тип
*/
activate(): AvailableCategoryDomainEntity {
return new AvailableCategoryDomainEntity({
...this,
isActive: true,
updatedAt: new Date(),
});
}
}
@@ -0,0 +1,70 @@
import type { TypeDomainEntity } from './type-domain.entity';
/**
* Доменная сущность категории для marketplace расширения
* Представляет иерархическую структуру категорий из Ozon API
*/
export class CategoryDomainEntity {
public readonly descriptionCategoryId: number;
public readonly categoryName: string;
public readonly disabled: boolean;
public readonly parentId?: number;
public readonly parent?: CategoryDomainEntity;
public readonly children: CategoryDomainEntity[];
public readonly types: TypeDomainEntity[];
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: {
descriptionCategoryId: number;
categoryName: string;
disabled: boolean;
parentId?: number;
parent?: CategoryDomainEntity;
children?: CategoryDomainEntity[];
types?: TypeDomainEntity[];
createdAt?: Date;
updatedAt?: Date;
}) {
this.descriptionCategoryId = data.descriptionCategoryId;
this.categoryName = data.categoryName;
this.disabled = data.disabled;
this.parentId = data.parentId;
this.parent = data.parent;
this.children = data.children || [];
this.types = data.types || [];
this.createdAt = data.createdAt || new Date();
this.updatedAt = data.updatedAt || new Date();
}
/**
* Проверяет, является ли категория листовой (может содержать товары)
*/
isLeafCategory(): boolean {
return this.children.length === 0 && !this.disabled;
}
/**
* Получает полный путь к категории
*/
getFullPath(): string {
if (!this.parent) {
return this.categoryName;
}
return `${this.parent.getFullPath()} / ${this.categoryName}`;
}
/**
* Получает все дочерние категории рекурсивно
*/
getAllDescendants(): CategoryDomainEntity[] {
const descendants: CategoryDomainEntity[] = [];
for (const child of this.children) {
descendants.push(child);
descendants.push(...child.getAllDescendants());
}
return descendants;
}
}
@@ -0,0 +1,89 @@
import type { CategoryDomainEntity } from './category-domain.entity';
import type { TypeDomainEntity } from './type-domain.entity';
import type { AttributeDomainEntity } from './attribute-domain.entity';
/**
* Доменная сущность связи между категориями, типами товаров и их характеристиками
* Представляет отношение "многие ко многим" между категориями, типами и атрибутами из Ozon API
*/
export class CategoryTypeAttributeDomainEntity {
public readonly descriptionCategoryId: number;
public readonly typeId: number;
public readonly attributeId: number;
public readonly category: CategoryDomainEntity;
public readonly type: TypeDomainEntity;
public readonly attribute: AttributeDomainEntity;
public readonly categoryName: string;
public readonly typeName: string;
public readonly isFetched: boolean;
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: {
descriptionCategoryId: number;
typeId: number;
attributeId: number;
category: CategoryDomainEntity;
type: TypeDomainEntity;
attribute: AttributeDomainEntity;
categoryName: string;
typeName: string;
isFetched: boolean;
createdAt?: Date;
updatedAt?: Date;
}) {
this.descriptionCategoryId = data.descriptionCategoryId;
this.typeId = data.typeId;
this.attributeId = data.attributeId;
this.category = data.category;
this.type = data.type;
this.attribute = data.attribute;
this.categoryName = data.categoryName;
this.typeName = data.typeName;
this.isFetched = data.isFetched;
this.createdAt = data.createdAt || new Date();
this.updatedAt = data.updatedAt || new Date();
}
/**
* Получает уникальный идентификатор связи
*/
getCompositeKey(): string {
return `${this.descriptionCategoryId}-${this.typeId}-${this.attributeId}`;
}
/**
* Проверяет, является ли атрибут обязательным для данной категории и типа
*/
isRequired(): boolean {
return this.attribute.isRequired;
}
/**
* Проверяет, является ли атрибут аспектным
*/
isAspect(): boolean {
return this.attribute.isAspect;
}
/**
* Получает полное описание связи
*/
getDescription(): string {
return `${this.categoryName} / ${this.typeName} -> ${this.attribute.name}`;
}
/**
* Проверяет, загружены ли данные атрибута
*/
isDataFetched(): boolean {
return this.isFetched;
}
/**
* Получает группу атрибута
*/
getAttributeGroup(): { id?: number; name?: string } {
return this.attribute.getGroup();
}
}
@@ -0,0 +1,73 @@
import { RequestStatus, type RequestDomainEntity } from './request-domain.entity';
/**
* Статусы цепочки поставки
*/
export enum DeliveryChainStatus {
PLANNING = 'planning', // Планируется маршрут
ACTIVE = 'active', // Активная цепочка
COMPLETED = 'completed', // Завершена успешно
FAILED = 'failed', // Провалена
CANCELLED = 'cancelled', // Отменена
DISPUTED = 'disputed', // Есть споры в сегментах
}
/**
* Связанная заявка в цепочке
*/
export class LinkedRequest {
public readonly requestHash: string; // Хэш заявки
public readonly blockchainId: string; // ID блокчейна где находится заявка
constructor(data: { requestHash: string; blockchainId: string }) {
this.requestHash = data.requestHash;
this.blockchainId = data.blockchainId;
}
}
/**
* Цепочка поставки - связывает заявки для выполнения поставки
*/
export class DeliveryChainDomainEntity {
public readonly id?: number;
// Связанные заявки (обычно 2 - поставка и заказ)
public readonly linkedRequests: RequestDomainEntity[]; // Все заявки в цепочке
// Временные рамки
public readonly createdAt: Date;
constructor(data: { id?: number; status: DeliveryChainStatus; linkedRequests: RequestDomainEntity[]; createdAt?: Date }) {
this.id = data.id;
this.linkedRequests = data.linkedRequests;
this.createdAt = data.createdAt || new Date();
}
/**
* Проверить, завершена ли цепочка
*/
isCompleted(): boolean {
return this.linkedRequests.filter((r) => r.status === RequestStatus.COMPLETED).length === this.linkedRequests.length;
}
/**
* Проверить, активна ли цепочка
*/
isActive(): boolean {
return this.linkedRequests.filter((r) => r.status === RequestStatus.ACTIVE).length === this.linkedRequests.length;
}
/**
* Проверить, есть ли споры в цепочке
*/
hasDisputes(): boolean {
return this.linkedRequests.filter((r) => r.status === RequestStatus.DISPUTED).length > 0;
}
/**
* Получить все хэши заявок
*/
getAllRequestHashes(): string[] {
return this.linkedRequests.map((lr) => lr.hash);
}
}

Some files were not shown because too many files have changed in this diff Show More