[598-8][@ant] feat: паттерн doc_data в @coopenomics/factory + ввести 1102/1103 как канонные документы — приватные данные документа off-chain, on-chain только sha256 hash, шаблон обращается через зарезервированную {{ doc_data.* }}
Закрывает крайнее ревью PR #388 (2026-05-17): — components/factory/src/Services/DocData/index.ts: новый DocDataService — sha256 + Mongo upsert по hash (идемпотентный save), get по hash (null если запись удалена — graceful degradation). — components/factory/src/Factory/index.ts: в DocFactory методы saveDocData/getDocData/loadDocData; factory автоматически даёт фабрике помощник для подгрузки приватного payload по data.doc_data_hash. — components/factory/src/index.ts: Generator-фасад выставляет saveDocData/getDocData в IGenerator. — components/cooptypes/src/cooperative/document/index.ts: IDocDataRef { doc_data_hash?: string } — зарезервированная ссылка, которую Action любого нового документа расширяет. — components/factory/README.md + AGENTS.md: раздел «Приватные данные документа: doc_data» с потоком, зарезервированными именами и graceful degradation. — components/factory/test/doc-data.test.ts: unit — sha256, идемпотентность, стабильный порядок ключей, null при удалении, делегирование через Generator-фасад. 1102.MarketplaceAplReception: — cooptypes registry/1102: Action упрощён до канона 802.ReturnByAssetAct (registry_id, order_id, order_hash, act_id, transmitter, braname?, doc_data_hash?); семантика — `user` = пайщик-получатель (USERNAME), `transmitter` = USERNAME председателя КУ или доверенного им лица, передающего имущество (ФИО подставляется factory через getUser → getFirstLastMiddleName). Текст и переводы — 1-в-1 с 802 (юр.выверенный текст). Никаких «филиалов». — factory/Actions/1102, factory/Templates/1102: каноничная фабрика по образцу 802.ReturnByAssetAct (factory.getUser(username) → пайщик, factory.getUser(transmitter) → председатель); decision для marketplace — стаб (нет протокола совета на каждый order). — controller/extensions/marketplace/.../marketplace-apl-reception.service.ts: при сборке Action в Action идут только канонические поля; ФИО пайщика берётся по order.orderer_account, transmitter = chairman_account ?? created_by_operator_account. 1103.MarketplaceTransportNote: — cooptypes registry/1103: Action очищен — { ttn_number, cycle_id, shipment_id, accept_braname, supplier_account, total_amount, currency, doc_data_hash }. Все персональные данные экспедитора (ФИО, паспорт, телефон, госномер, адрес погрузки, время доставки) вынесены в interface PrivateData — payload off-chain. — Шаблон обращается через {{ doc_data.expeditor_full_name }}, {{ doc_data.vehicle_number }} и т.п. — factory/Actions/1103, factory/Templates/1103: фабрика загружает приватный payload через loadDocData; ошибка если doc_data_hash не задан или payload удалён. — controller/extensions/marketplace/.../marketplace-shipment-create.service.ts: убран MVP-stub — приватные поля экспедитора сохраняются через documentDomainService.saveDocData(...) → doc_data_hash → in Action; on-chain Action не содержит персональных данных. Прочие документы (700-1099, 300-304, 50-51, …) остаются как были — паттерн прогрессивный, для новых документов с PII и при явной потребности. Архитектурный артефакт (_bmad-output/planning-artifacts/architecture.md в blago проекте 1-prilozhenie-stol-zakazov): дополнен разделом «Document Generation Pattern: doc_data (Private Document Data Store)». Story 5.8 заведена в blago через `blago create req`.
This commit is contained in:
+22
-15
@@ -1,5 +1,5 @@
|
||||
import { InputType, Field, Int, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString, IsOptional, IsBoolean, IsInt, Min } from 'class-validator';
|
||||
import { Field, InputType, Int, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
@@ -10,19 +10,19 @@ type action = Cooperative.Registry.MarketplaceAplReception.Action;
|
||||
|
||||
@InputType('BaseMarketplaceAplReceptionMetaDocumentInput')
|
||||
class BaseMarketplaceAplReceptionMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Идентификатор Order\'а, к которому относится акт приёмки.' })
|
||||
@Field({ description: 'Идентификатор заказа пайщика, по которому формируется акт.' })
|
||||
@IsString()
|
||||
order_id!: string;
|
||||
|
||||
@Field({ description: 'Канонический хэш Order\'а в блокчейне.' })
|
||||
@Field({ description: 'Канонический хэш заказа в блокчейне.' })
|
||||
@IsString()
|
||||
order_hash!: string;
|
||||
|
||||
@Field({ description: 'Имя кооперативного участка-приёмника партии.' })
|
||||
@Field({ description: 'Имя кооперативного участка, выдающего имущество пайщику.' })
|
||||
@IsString()
|
||||
accept_braname!: string;
|
||||
|
||||
@Field({ description: 'Идентификатор записи акта приёмки.' })
|
||||
@Field({ description: 'Идентификатор записи акта в реестре marketplace.' })
|
||||
@IsString()
|
||||
reception_id!: string;
|
||||
|
||||
@@ -31,27 +31,27 @@ class BaseMarketplaceAplReceptionMetaDocumentInputDTO implements ExcludeCommonPr
|
||||
@Min(0)
|
||||
fact_quantity!: number;
|
||||
|
||||
@Field({ description: 'Сумма по Order\'у с учётом фактического количества.' })
|
||||
@Field({ description: 'Сумма по заказу с учётом фактического количества.' })
|
||||
@IsString()
|
||||
total_amount!: string;
|
||||
|
||||
@Field({ description: 'Account поставщика — отправителя партии.' })
|
||||
@Field({ description: 'Учётная запись поставщика, передавшего партию на кооперативный участок.' })
|
||||
@IsString()
|
||||
supplier_account!: string;
|
||||
|
||||
@Field({ description: 'Номер акта приёмки для шапки документа.' })
|
||||
@Field({ description: 'Номер акта для шапки документа.' })
|
||||
@IsString()
|
||||
act_id!: string;
|
||||
|
||||
@Field({
|
||||
description: 'Account поставщика — отправителя партии (строка «Передал заказ» в акте).',
|
||||
description: 'Учётная запись передающей стороны — председатель кооперативного участка или доверенное им лицо.',
|
||||
})
|
||||
@IsString()
|
||||
transmitter!: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Имя кооперативного участка-приёмника для ветки «филиал» в шаблоне акта.',
|
||||
description: 'Имя кооперативного участка, выдающего имущество.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -59,15 +59,22 @@ class BaseMarketplaceAplReceptionMetaDocumentInputDTO implements ExcludeCommonPr
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Account председателя — подписанта закрывающей подписи (если уже известен).',
|
||||
description: 'Учётная запись председателя — подписанта закрывающей подписи (если уже известен).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
chairman_account?: string;
|
||||
|
||||
@Field({
|
||||
description:
|
||||
'Флаг пропуска сохранения документа (preview-режим для отображения пользователю перед подписью).',
|
||||
nullable: true,
|
||||
description: 'Хэш приватного payload документа (если приватные данные хранятся отдельно).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
doc_data_hash?: string;
|
||||
|
||||
@Field({
|
||||
description: 'Сформировать документ без сохранения (preview-режим).',
|
||||
})
|
||||
@IsBoolean()
|
||||
skip_save!: boolean;
|
||||
@@ -96,7 +103,7 @@ export class MarketplaceAplReceptionSignedMetaDocumentInputDTO
|
||||
@InputType('MarketplaceAplReceptionSignedDocumentInput')
|
||||
export class MarketplaceAplReceptionSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => MarketplaceAplReceptionSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаданные подписанного акта приёмки — содержат order_id, order_hash, КУ-приёмник и фактическое количество.',
|
||||
description: 'Метаданные подписанного акта приёмки-передачи имущества.',
|
||||
})
|
||||
public readonly meta!: MarketplaceAplReceptionSignedMetaDocumentInputDTO;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface GeneratorPort {
|
||||
list<T = any>(collection: string, filter?: Record<string, any>): Promise<Cooperative.Document.IGetResponse<T>>;
|
||||
getHistory<T = any>(collection: string, filter: Record<string, any>): Promise<T[]>;
|
||||
search(query: string): Promise<ISearchResult[]>;
|
||||
|
||||
saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }>;
|
||||
|
||||
getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null>;
|
||||
}
|
||||
|
||||
export const GENERATOR_PORT = Symbol('GeneratorPort');
|
||||
|
||||
@@ -25,6 +25,14 @@ export class DocumentDomainService {
|
||||
return await this.generatorInfrastructureService.generateDocument(data);
|
||||
}
|
||||
|
||||
public async saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }> {
|
||||
return await this.generatorInfrastructureService.saveDocData(payload, registry_id);
|
||||
}
|
||||
|
||||
public async getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null> {
|
||||
return await this.generatorInfrastructureService.getDocData<P>(hash);
|
||||
}
|
||||
|
||||
public async getDocumentByHash(hash: string): Promise<DocumentDomainEntity | null> {
|
||||
const document = await this.documentRepository.findByHash(hash);
|
||||
return document;
|
||||
|
||||
+7
-10
@@ -218,23 +218,20 @@ export class MarketplaceAplReceptionService {
|
||||
const fact = input.reception.fact_quantity_per_order.find(
|
||||
(f) => f.order_id === input.order.id
|
||||
);
|
||||
const factQuantity = fact?.fact_quantity ?? input.order.quantity;
|
||||
const orderTotal = (factQuantity * Number.parseFloat(input.order.price_per_unit)).toFixed(4);
|
||||
// `user` шаблона 1102 = пайщик-получатель имущества (orderer_account).
|
||||
// `transmitter` = передающая сторона — председатель КУ или доверенное
|
||||
// им лицо (известен по chairman_account на этапе закрывающей подписи;
|
||||
// на этапе превью используется оператор, создавший АПП).
|
||||
const transmitter = input.chairman_account ?? input.reception.created_by_operator_account;
|
||||
const action: Cooperative.Registry.MarketplaceAplReception.Action = {
|
||||
registry_id: Cooperative.Registry.MarketplaceAplReception.registry_id,
|
||||
coopname: input.reception.coopname,
|
||||
username: input.username,
|
||||
username: input.order.orderer_account,
|
||||
order_id: input.order.id,
|
||||
order_hash: input.order.order_hash,
|
||||
accept_braname: input.reception.braname,
|
||||
reception_id: input.reception.id,
|
||||
act_id: this.formatActId(input.reception.id, input.order.id),
|
||||
transmitter: input.reception.offerer_account,
|
||||
transmitter,
|
||||
braname: input.reception.braname,
|
||||
fact_quantity: factQuantity,
|
||||
total_amount: orderTotal,
|
||||
supplier_account: input.reception.offerer_account,
|
||||
chairman_account: input.chairman_account,
|
||||
skip_save: true,
|
||||
};
|
||||
return this.documentDomainService.generateDocument({ data: action });
|
||||
|
||||
+16
-7
@@ -357,6 +357,21 @@ export class MarketplaceShipmentCreateService {
|
||||
ttn_number: string;
|
||||
ttn_data: MarketplaceShipmentTTNData;
|
||||
}): Promise<string> {
|
||||
const privatePayload: Cooperative.Registry.MarketplaceTransportNote.PrivateData = {
|
||||
expeditor_full_name: input.ttn_data.expeditor_full_name,
|
||||
expeditor_phone: input.ttn_data.expeditor_phone,
|
||||
expeditor_id_doc: input.ttn_data.expeditor_id_doc,
|
||||
vehicle_number: input.ttn_data.vehicle_number,
|
||||
loading_address: input.ttn_data.loading_address,
|
||||
loading_datetime: input.ttn_data.loading_datetime,
|
||||
delivery_datetime_estimate: input.ttn_data.delivery_datetime_estimate,
|
||||
};
|
||||
|
||||
const { hash: doc_data_hash } = await this.documentDomainService.saveDocData(
|
||||
privatePayload as unknown as Record<string, unknown>,
|
||||
Cooperative.Registry.MarketplaceTransportNote.registry_id
|
||||
);
|
||||
|
||||
const action: Cooperative.Registry.MarketplaceTransportNote.Action = {
|
||||
registry_id: Cooperative.Registry.MarketplaceTransportNote.registry_id,
|
||||
coopname: input.coopname,
|
||||
@@ -368,13 +383,7 @@ export class MarketplaceShipmentCreateService {
|
||||
supplier_account: input.supplier_account,
|
||||
total_amount: input.total_amount,
|
||||
currency: this.assetConfig.symbol,
|
||||
expeditor_full_name: input.ttn_data.expeditor_full_name,
|
||||
expeditor_phone: input.ttn_data.expeditor_phone,
|
||||
expeditor_id_doc: input.ttn_data.expeditor_id_doc,
|
||||
vehicle_number: input.ttn_data.vehicle_number,
|
||||
loading_address: input.ttn_data.loading_address,
|
||||
loading_datetime: input.ttn_data.loading_datetime,
|
||||
delivery_datetime_estimate: input.ttn_data.delivery_datetime_estimate,
|
||||
doc_data_hash,
|
||||
skip_save: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ export class GeneratorInfrastructureService implements GeneratorPort, OnModuleIn
|
||||
return await this.generator.search(query);
|
||||
}
|
||||
|
||||
async saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }> {
|
||||
return await this.generator.saveDocData(payload, registry_id);
|
||||
}
|
||||
|
||||
async getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null> {
|
||||
return await this.generator.getDocData<P>(hash);
|
||||
}
|
||||
|
||||
async generateDocument(body: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
|
||||
try {
|
||||
const generated = await this.generate(body.data, body.options);
|
||||
|
||||
@@ -112,6 +112,18 @@ export interface IGenerate extends Omit<Partial<IMetaDocument>, 'title'> {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарезервированная ссылка на приватный payload документа.
|
||||
* Action конкретного документа расширяет этот интерфейс, если хочет
|
||||
* хранить персональные/приватные данные off-chain — payload лежит в
|
||||
* коллекции `doc_private_data` фабрики, on-chain публикуется только
|
||||
* `doc_data_hash`. См. раздел «Document Generation Pattern: doc_data»
|
||||
* в архитектуре.
|
||||
*/
|
||||
export interface IDocDataRef {
|
||||
doc_data_hash?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс генерации заявления на вступление в кооператив
|
||||
*/
|
||||
|
||||
+26
-21
@@ -1,38 +1,44 @@
|
||||
import type { IDecisionData, IGenerate, IMetaDocument } from '../../document'
|
||||
import type { IDecisionData, IDocDataRef, IGenerate, IMetaDocument } from '../../document'
|
||||
import type { ICommonProgram, ICommonRequest, ICommonUser, ICooperativeData, IFirstLastMiddleName, IVars } from '../../model'
|
||||
import type { IOrganizationData } from '../../users'
|
||||
|
||||
export const registry_id = 1102
|
||||
|
||||
// Модель действия для генерации.
|
||||
// Один экземпляр документа = один Order в составе АПП; на одну АПП с
|
||||
// несколькими Order'ами рендерится столько же документов. Документ
|
||||
// подписывается поставщиком (action marketplace::signsupp) и
|
||||
// председателем КУ-приёмника (action marketplace::signchair) — payload
|
||||
// (act: IDocument2) одинаковый, отличается только подписант.
|
||||
export interface Action extends IGenerate {
|
||||
//
|
||||
// Семантика подписантов АПП выдачи пайщику (Эпик 5):
|
||||
// - `username` (из IGenerate) — USERNAME пайщика-получателя имущества.
|
||||
// ФИО подставляется factory через `getUser(username) → user`.
|
||||
// - `transmitter` — USERNAME передающей стороны: председатель
|
||||
// кооперативного участка или доверенное им лицо (или председатель
|
||||
// кооператива). ФИО подставляется factory через
|
||||
// `getUser(transmitter) → getFirstLastMiddleName`.
|
||||
//
|
||||
// Текст шаблона и переводы — 1-к-1 с 802.ReturnByAssetAct (юр.выверенный
|
||||
// текст). В Action попадают только переменные, которые подставляются в
|
||||
// шаблон; прочие признаки (например, идентификатор записи АПП в
|
||||
// инфраструктуре marketplace) попадают в meta автоматически.
|
||||
//
|
||||
// `doc_data_hash` — зарезервированное опциональное поле для приватных
|
||||
// данных off-chain (см. раздел «Document Generation Pattern: doc_data»).
|
||||
export interface Action extends IGenerate, IDocDataRef {
|
||||
registry_id: number
|
||||
/** id заказа пайщика, по которому формируется АПП. */
|
||||
order_id: string
|
||||
/** Канонический order_hash on-chain. */
|
||||
order_hash: string
|
||||
/** id записи АПП в инфраструктуре marketplace. */
|
||||
reception_id: string
|
||||
/** Уникальный номер акта (act_id) — выводится в шапке как «АКТ № …». */
|
||||
act_id: string
|
||||
/** Account поставщика — отправитель партии, попадает в строку «Передал заказ». */
|
||||
/** USERNAME председателя КУ или доверенного им лица — передающая сторона. */
|
||||
transmitter: string
|
||||
/** Имя кооперативного участка-приёмника (braname). */
|
||||
/** Имя кооперативного участка, выдающего имущество (braname). */
|
||||
braname?: string
|
||||
/** Account председателя КУ — подписант закрывающей подписи (заполняется при signchair). */
|
||||
chairman_account?: string
|
||||
}
|
||||
|
||||
export type Meta = IMetaDocument & Action
|
||||
|
||||
// Модель данных документа (структура повторяет 802.ReturnByAssetAct
|
||||
// один-к-одному — текст акта и переводы выверены юр.отделом, в Эпике 5
|
||||
// меняются только подставляемые значения).
|
||||
// Модель данных документа: структура повторяет 802.ReturnByAssetAct
|
||||
// один-к-одному.
|
||||
export interface Model {
|
||||
meta: IMetaDocument
|
||||
coop: ICooperativeData
|
||||
@@ -73,7 +79,6 @@ export const translations = {
|
||||
date: 'Дата',
|
||||
participant_full_name: 'ФИО/Наименование Пайщика',
|
||||
},
|
||||
// ... другие переводы
|
||||
}
|
||||
|
||||
export const exampleData = {
|
||||
@@ -85,7 +90,7 @@ export const exampleData = {
|
||||
is_branched: true,
|
||||
},
|
||||
user: {
|
||||
full_name_or_short_name: 'Иванов Иван Иванович',
|
||||
full_name_or_short_name: 'Петров Пётр Петрович',
|
||||
},
|
||||
request: {
|
||||
unit_of_measurement: 'шт.',
|
||||
@@ -109,9 +114,9 @@ export const exampleData = {
|
||||
full_abbr: 'Потребительский Кооператив',
|
||||
},
|
||||
transmitter: {
|
||||
last_name: 'Петров',
|
||||
first_name: 'Пётр',
|
||||
middle_name: 'Петрович',
|
||||
last_name: 'Иванов',
|
||||
first_name: 'Иван',
|
||||
middle_name: 'Иванович',
|
||||
},
|
||||
order_date: '01.05.2026',
|
||||
act_id: 'APL-2026-05-0001',
|
||||
|
||||
+59
-45
@@ -1,17 +1,46 @@
|
||||
import type { IGenerate, IMetaDocument } from '../../document'
|
||||
import type { IDocDataRef, IGenerate, IMetaDocument } from '../../document'
|
||||
import type { ICooperativeData, IFirstLastMiddleName, IVars } from '../../model'
|
||||
import type { IOrganizationData } from '../../users'
|
||||
|
||||
export const registry_id = 1103
|
||||
|
||||
// Модель действия для генерации транспортной накладной (ТТН) Варианта Б
|
||||
// (доставка через экспедитора). Документ генерируется при формировании
|
||||
// партии поставки (Shipment), сохраняется в локальном marketplace-реестре
|
||||
// `marketplace_ttn_document` и не публикуется в общий реестр документов
|
||||
// кооператива — экспедиторы пока не пайщики и подписывают перевозку
|
||||
// вне платформы. Ссылка на ТТН вшивается в АПП приёмки через
|
||||
// `Shipment.ttn_document_id`.
|
||||
export interface Action extends IGenerate {
|
||||
/**
|
||||
* Приватный payload транспортной накладной (ТТН).
|
||||
*
|
||||
* Хранится off-chain в коллекции `doc_private_data` фабрики; on-chain
|
||||
* публикуется только `doc_data_hash`. Содержимое — персональные данные
|
||||
* экспедитора и параметры физической перевозки, которые недопустимо
|
||||
* публиковать в блокчейн. См. раздел «Document Generation Pattern:
|
||||
* doc_data» в архитектуре.
|
||||
*/
|
||||
export interface PrivateData {
|
||||
/** Полное наименование экспедитора (ФИО). */
|
||||
expeditor_full_name: string
|
||||
/** Контактный телефон экспедитора. */
|
||||
expeditor_phone: string
|
||||
/** Документ, удостоверяющий личность экспедитора. */
|
||||
expeditor_id_doc: string
|
||||
/** Номер транспортного средства. */
|
||||
vehicle_number: string
|
||||
/** Адрес погрузки. */
|
||||
loading_address: string
|
||||
/** Дата и время погрузки (ISO-8601). */
|
||||
loading_datetime: string
|
||||
/** Плановая дата и время доставки на КУ (ISO-8601). */
|
||||
delivery_datetime_estimate: string
|
||||
}
|
||||
|
||||
// Модель действия для генерации транспортной накладной (ТТН) Варианта Б.
|
||||
// Документ генерируется при формировании партии поставки (Shipment),
|
||||
// сохраняется в локальном marketplace-реестре `marketplace_ttn_document`
|
||||
// и не публикуется в общий реестр документов кооператива — экспедиторы
|
||||
// пока не подписывают перевозку. Ссылка на ТТН вшивается в АПП приёмки
|
||||
// через `Shipment.ttn_document_id`.
|
||||
//
|
||||
// **Все персональные данные экспедитора и параметры перевозки** (ФИО,
|
||||
// телефон, паспорт, госномер, адрес погрузки и т.п.) хранятся off-chain
|
||||
// в `doc_private_data`; on-chain — только `doc_data_hash`.
|
||||
export interface Action extends IGenerate, IDocDataRef {
|
||||
registry_id: number
|
||||
/** Уникальный номер ТТН (формат `<COOP>-TTN-<CYCLE>-<KU>-<HEX>`). */
|
||||
ttn_number: string
|
||||
@@ -27,20 +56,8 @@ export interface Action extends IGenerate {
|
||||
total_amount: string
|
||||
/** Символ валюты, в которой выражена сумма. */
|
||||
currency: string
|
||||
/** Полное наименование экспедитора (ФИО). */
|
||||
expeditor_full_name: string
|
||||
/** Контактный телефон экспедитора. */
|
||||
expeditor_phone: string
|
||||
/** Документ, удостоверяющий личность экспедитора (тип + номер). */
|
||||
expeditor_id_doc: string
|
||||
/** Номер транспортного средства. */
|
||||
vehicle_number: string
|
||||
/** Адрес погрузки. */
|
||||
loading_address: string
|
||||
/** Дата и время погрузки (ISO-8601). */
|
||||
loading_datetime: string
|
||||
/** Плановая дата и время доставки на КУ (ISO-8601). */
|
||||
delivery_datetime_estimate: string
|
||||
/** sha256 хэш приватного payload (PrivateData) — обязательное для ТТН. */
|
||||
doc_data_hash: string
|
||||
}
|
||||
|
||||
export type Meta = IMetaDocument & Action
|
||||
@@ -58,13 +75,8 @@ export interface Model {
|
||||
supplier_account: string
|
||||
/** ФИО поставщика — резолвится фабрикой по `supplier_account`. */
|
||||
supplier: IFirstLastMiddleName
|
||||
expeditor_full_name: string
|
||||
expeditor_phone: string
|
||||
expeditor_id_doc: string
|
||||
vehicle_number: string
|
||||
loading_address: string
|
||||
loading_datetime: string
|
||||
delivery_datetime_estimate: string
|
||||
/** Приватные данные экспедитора и параметры перевозки (off-chain). */
|
||||
doc_data: PrivateData
|
||||
/** Карточка КУ-приёмника — резолвится фабрикой по `accept_braname`. */
|
||||
branch?: IOrganizationData
|
||||
}
|
||||
@@ -115,15 +127,15 @@ th { background-color: #f4f4f4; width: 35%; }
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{% trans 'expeditor_full_name_label' %}</th>
|
||||
<td>{{ expeditor_full_name }}</td>
|
||||
<td>{{ doc_data.expeditor_full_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans 'expeditor_phone_label' %}</th>
|
||||
<td>{{ expeditor_phone }}</td>
|
||||
<td>{{ doc_data.expeditor_phone }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans 'expeditor_id_doc_label' %}</th>
|
||||
<td>{{ expeditor_id_doc }}</td>
|
||||
<td>{{ doc_data.expeditor_id_doc }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -133,19 +145,19 @@ th { background-color: #f4f4f4; width: 35%; }
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{% trans 'vehicle_number_label' %}</th>
|
||||
<td>{{ vehicle_number }}</td>
|
||||
<td>{{ doc_data.vehicle_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans 'loading_address_label' %}</th>
|
||||
<td>{{ loading_address }}</td>
|
||||
<td>{{ doc_data.loading_address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans 'loading_datetime_label' %}</th>
|
||||
<td>{{ loading_datetime }}</td>
|
||||
<td>{{ doc_data.loading_datetime }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans 'delivery_datetime_estimate_label' %}</th>
|
||||
<td>{{ delivery_datetime_estimate }}</td>
|
||||
<td>{{ doc_data.delivery_datetime_estimate }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -168,7 +180,7 @@ th { background-color: #f4f4f4; width: 35%; }
|
||||
<tr>
|
||||
<th>{% trans 'transported' %}</th>
|
||||
<td>{% trans 'expeditor_role' %}</td>
|
||||
<td>{{ expeditor_full_name }}</td>
|
||||
<td>{{ doc_data.expeditor_full_name }}</td>
|
||||
<td>{% trans 'signature_placeholder' %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -229,13 +241,15 @@ export const exampleData = {
|
||||
first_name: 'Пётр',
|
||||
middle_name: 'Петрович',
|
||||
},
|
||||
expeditor_full_name: 'Сидоров Сидор Сидорович',
|
||||
expeditor_phone: '+7 (999) 123-45-67',
|
||||
expeditor_id_doc: 'Паспорт РФ 4500 123456',
|
||||
vehicle_number: 'А123БВ77',
|
||||
loading_address: 'г. Москва, ул. Поставщика, д. 1',
|
||||
loading_datetime: '2026-05-15 09:00',
|
||||
delivery_datetime_estimate: '2026-05-15 14:00',
|
||||
doc_data: {
|
||||
expeditor_full_name: 'Сидоров Сидор Сидорович',
|
||||
expeditor_phone: '+7 (999) 123-45-67',
|
||||
expeditor_id_doc: 'Паспорт РФ 4500 123456',
|
||||
vehicle_number: 'А123БВ77',
|
||||
loading_address: 'г. Москва, ул. Поставщика, д. 1',
|
||||
loading_datetime: '2026-05-15 09:00',
|
||||
delivery_datetime_estimate: '2026-05-15 14:00',
|
||||
},
|
||||
branch: {
|
||||
short_name: 'КУ-МОСКВА-1',
|
||||
},
|
||||
|
||||
@@ -47,6 +47,20 @@ src/
|
||||
|
||||
`PDFService` в `Services/Generator/` записывает HTML во временный файл, вызывает `weasyprint` через `child_process.exec`, читает результат. Требует установленного WeasyPrint в системе.
|
||||
|
||||
### Приватные данные документа: `doc_data`
|
||||
|
||||
Зарезервированный механизм для документов, содержащих персональные данные, которые не должны попадать в блокчейн.
|
||||
|
||||
- `Services/DocData/index.ts` — `DocDataService` с `save(payload, registry_id) → { hash }` (sha256, идемпотентный upsert) и `get(hash) → payload | null`.
|
||||
- Коллекция Mongo `doc_private_data` со схемой `{ hash, registry_id, payload, _created_at }`; уникальный индекс по `hash`.
|
||||
- В Action документа — зарезервированное поле `doc_data_hash: string` (см. `Cooperative.Document.IDocDataRef` в cooptypes).
|
||||
- `DocFactory.loadDocData(data)` — хелпер для конкретной фабрики: подгружает payload, фабрика кладёт в `combinedData.doc_data`.
|
||||
- Шаблон обращается как `{{ doc_data.<field> }}` — `doc_data` это зарезервированная переменная шаблона наравне с `meta`, `coop`, `vars`, `user`, `request`, `program`, `decision`, `branch`.
|
||||
- Generator-фасад: `generator.saveDocData(payload, registry_id)` / `generator.getDocData(hash)`.
|
||||
- Graceful degradation: если payload удалён из коллекции, документ остаётся верифицируемым по хэшу подписи (on-chain), но не регенерируется.
|
||||
|
||||
Применяется в новых документах с персональными данными (1103.MarketplaceTransportNote — данные экспедитора). Старые документы не переводим.
|
||||
|
||||
### Mock-система
|
||||
|
||||
- `src/Utils/testMocks.ts` — экспортирует `testMocks` (массив моков из `mocks/tables/` и `mocks/actions/`)
|
||||
|
||||
@@ -113,6 +113,74 @@ test/ # Тесты (Vitest)
|
||||
6. Подпись хеша документа EOSIO-ключом
|
||||
7. Генерация PDF через WeasyPrint
|
||||
|
||||
## Приватные данные документа: `doc_data`
|
||||
|
||||
Зарезервированный механизм для документов, содержащих персональные данные (ФИО экспедитора, паспорт, госномер, телефон и т.п.), которые **не должны попадать в блокчейн**.
|
||||
|
||||
### Поток
|
||||
|
||||
1. Контроллер собирает приватный payload и вызывает `generator.saveDocData(payload, registry_id)` — возвращается `{ hash }` (sha256, идемпотентный).
|
||||
2. `hash` передаётся on-chain в Action под зарезервированным именем `doc_data_hash`.
|
||||
3. При генерации/регенерации фабрика подгружает payload из `doc_private_data` через `generator.getDocData(hash)` и подставляет в `combinedData.doc_data`.
|
||||
4. Шаблон документа использует приватные поля как `{{ doc_data.<field> }}`.
|
||||
|
||||
### Пример
|
||||
|
||||
Контроллер:
|
||||
|
||||
```ts
|
||||
const { hash } = await generator.saveDocData({
|
||||
expeditor_full_name: 'Иванов Иван Иванович',
|
||||
passport: '4509 123456',
|
||||
phone: '+79991234567',
|
||||
vehicle_registration_number: 'А123ВВ77',
|
||||
}, 1103)
|
||||
|
||||
// в Action документа 1103 (MarketplaceTransportNote):
|
||||
await generator.generate({
|
||||
registry_id: 1103,
|
||||
coopname: 'voskhod',
|
||||
username: operatorAccount,
|
||||
shipment_id,
|
||||
doc_data_hash: hash,
|
||||
})
|
||||
```
|
||||
|
||||
Фабрика (`src/Actions/1103.MarketplaceTransportNote.ts`):
|
||||
|
||||
```ts
|
||||
const doc_data = await this.loadDocData<TransportNoteDocData>(data)
|
||||
const combinedData = { ...base, doc_data }
|
||||
```
|
||||
|
||||
Шаблон (`Templates/1103.MarketplaceTransportNote/index.ts`):
|
||||
|
||||
```html
|
||||
<tr><th>Экспедитор</th><td>{{ doc_data.expeditor_full_name }}</td></tr>
|
||||
<tr><th>Госномер</th><td>{{ doc_data.vehicle_registration_number }}</td></tr>
|
||||
```
|
||||
|
||||
### Зарезервированные имена шаблона
|
||||
|
||||
| Имя | Источник | Применение |
|
||||
|---|---|---|
|
||||
| `meta` | factory | заголовки, дата, lang, hash |
|
||||
| `coop` | factory | реквизиты кооператива |
|
||||
| `vars` | factory | переменные кооператива |
|
||||
| `user` | factory | подписант (по сертификату) |
|
||||
| `request` / `program` / `decision` | factory | контекст ЦПП |
|
||||
| `branch` | factory | данные КУ |
|
||||
| **`doc_data`** | **DocDataService** | **приватные данные документа** |
|
||||
|
||||
### Graceful degradation
|
||||
|
||||
Если запись в `doc_private_data` удалена (например, по запросу пайщика), документ остаётся **верифицируемым по хэшу подписи** (хэш зафиксирован on-chain), но не регенерируется. Это явное архитектурное решение: ценность блокчейн-подписи сохраняется; восстановление PDF — обратимо deprecated.
|
||||
|
||||
### Когда применять
|
||||
|
||||
- **Новые документы** с персональными данными — сразу через `doc_data`.
|
||||
- **Существующие документы** (700-1099, 300-304 и т.д.) — остаются без изменений. Паттерн прогрессивный, переводить старые документы при отсутствии необходимости не нужно.
|
||||
|
||||
## Ключевые зависимости
|
||||
|
||||
- **nunjucks / handlebars** — шаблонизаторы
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DraftContract } from 'cooptypes'
|
||||
import { MarketplaceAplReception } from '../Templates'
|
||||
import { DocFactory } from '../Factory'
|
||||
import type { IGeneratedDocument, IGenerationOptions, IMetaDocument, ITemplate } from '../Interfaces'
|
||||
import type { MongoDBConnector } from '../Services/Databazor'
|
||||
import type { ExternalOrganizationData } from '../Models'
|
||||
|
||||
export { MarketplaceAplReception as Template } from '../Templates'
|
||||
|
||||
export class Factory extends DocFactory<MarketplaceAplReception.Action> {
|
||||
constructor(storage: MongoDBConnector) {
|
||||
super(storage)
|
||||
}
|
||||
|
||||
async generateDocument(data: MarketplaceAplReception.Action, options?: IGenerationOptions): Promise<IGeneratedDocument> {
|
||||
let template: ITemplate<MarketplaceAplReception.Model>
|
||||
|
||||
if (process.env.SOURCE === 'local') {
|
||||
template = MarketplaceAplReception.Template
|
||||
}
|
||||
else {
|
||||
template = await this.getTemplate(DraftContract.contractName.production, MarketplaceAplReception.registry_id, data.block_num)
|
||||
}
|
||||
|
||||
const meta: IMetaDocument = await this.getMeta({ title: template.title, ...data })
|
||||
const coop = await this.getCooperative(data.coopname, data.block_num)
|
||||
const vars = await this.getVars(data.coopname, data.block_num)
|
||||
|
||||
// `user` = пайщик-получатель имущества (USERNAME = data.username).
|
||||
const user = await this.getUser(data.username, data.block_num)
|
||||
|
||||
// `transmitter` = USERNAME председателя КУ или доверенного им лицa —
|
||||
// тот, кто передаёт имущество пайщику.
|
||||
const transmitter = await this.getUser(data.transmitter, data.block_num)
|
||||
|
||||
const request = await this.getRequest(Number(data.order_id), data.block_num)
|
||||
|
||||
const commonUser = this.getCommonUser(user)
|
||||
|
||||
if (coop.is_branched && !data.braname)
|
||||
throw new Error('Branch name is required')
|
||||
|
||||
let branch: ExternalOrganizationData | undefined
|
||||
if (data.braname)
|
||||
branch = await this.getOrganization(data.braname, data.block_num)
|
||||
|
||||
const program = await this.getProgram(request.program_id)
|
||||
|
||||
// АПП выдачи пайщику в Marketplace не сопровождается отдельным
|
||||
// протоколом совета — поле `decision` заполняем заглушкой для
|
||||
// совместимости с шаблоном 802. Реальное основание выдачи —
|
||||
// консолидированная заявка cycle_id, она попадает в meta.
|
||||
const decision = {
|
||||
id: 0,
|
||||
date: meta.created_at.split(' ')[0] ?? '',
|
||||
time: meta.created_at.split(' ')[1] ?? '',
|
||||
votes_for: 0,
|
||||
votes_against: 0,
|
||||
votes_abstained: 0,
|
||||
voters_percent: 0,
|
||||
}
|
||||
|
||||
const combinedData: MarketplaceAplReception.Model = {
|
||||
meta,
|
||||
coop,
|
||||
vars,
|
||||
user: commonUser,
|
||||
request,
|
||||
decision,
|
||||
program,
|
||||
act_id: data.act_id,
|
||||
transmitter: this.getFirstLastMiddleName(transmitter.data),
|
||||
branch,
|
||||
}
|
||||
|
||||
await this.validate(combinedData, template.model)
|
||||
const translation = template.translations[meta.lang]
|
||||
const document: IGeneratedDocument = await this.generatePDF(commonUser.full_name_or_short_name, template.context, combinedData, translation, meta, options?.skip_save)
|
||||
|
||||
return document
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DraftContract } from 'cooptypes'
|
||||
import { MarketplaceTransportNote } from '../Templates'
|
||||
import { DocFactory } from '../Factory'
|
||||
import type { IGeneratedDocument, IGenerationOptions, IMetaDocument, ITemplate } from '../Interfaces'
|
||||
import type { MongoDBConnector } from '../Services/Databazor'
|
||||
import type { ExternalOrganizationData } from '../Models'
|
||||
|
||||
export { MarketplaceTransportNote as Template } from '../Templates'
|
||||
|
||||
export class Factory extends DocFactory<MarketplaceTransportNote.Action> {
|
||||
constructor(storage: MongoDBConnector) {
|
||||
super(storage)
|
||||
}
|
||||
|
||||
async generateDocument(data: MarketplaceTransportNote.Action, options?: IGenerationOptions): Promise<IGeneratedDocument> {
|
||||
let template: ITemplate<MarketplaceTransportNote.Model>
|
||||
|
||||
if (process.env.SOURCE === 'local') {
|
||||
template = MarketplaceTransportNote.Template
|
||||
}
|
||||
else {
|
||||
template = await this.getTemplate(DraftContract.contractName.production, MarketplaceTransportNote.registry_id, data.block_num)
|
||||
}
|
||||
|
||||
const meta: IMetaDocument = await this.getMeta({ title: template.title, ...data })
|
||||
const coop = await this.getCooperative(data.coopname, data.block_num)
|
||||
const vars = await this.getVars(data.coopname, data.block_num)
|
||||
|
||||
// ФИО поставщика — резолвим по supplier_account.
|
||||
const supplierUser = await this.getUser(data.supplier_account, data.block_num)
|
||||
const supplier = this.getFirstLastMiddleName(supplierUser.data)
|
||||
|
||||
// Карточка КУ-приёмника по braname (если кооператив бранчевый).
|
||||
let branch: ExternalOrganizationData | undefined
|
||||
if (data.accept_braname)
|
||||
branch = await this.getOrganization(data.accept_braname, data.block_num)
|
||||
|
||||
// Приватные данные экспедитора и параметры перевозки — из коллекции
|
||||
// `doc_private_data` по `doc_data_hash`. См. раздел «Document
|
||||
// Generation Pattern: doc_data» в архитектуре.
|
||||
const doc_data = await this.loadDocData<MarketplaceTransportNote.PrivateData>(data)
|
||||
if (!doc_data)
|
||||
throw new Error(`Приватный payload ТТН не найден по doc_data_hash=${data.doc_data_hash}`)
|
||||
|
||||
const combinedData: MarketplaceTransportNote.Model = {
|
||||
meta,
|
||||
coop,
|
||||
vars,
|
||||
ttn_number: data.ttn_number,
|
||||
cycle_id: data.cycle_id,
|
||||
shipment_id: data.shipment_id,
|
||||
accept_braname: data.accept_braname,
|
||||
total_amount: data.total_amount,
|
||||
currency: data.currency,
|
||||
supplier_account: data.supplier_account,
|
||||
supplier,
|
||||
doc_data,
|
||||
branch,
|
||||
}
|
||||
|
||||
await this.validate(combinedData, template.model)
|
||||
const translation = template.translations[meta.lang]
|
||||
const document: IGeneratedDocument = await this.generatePDF(`TTN-${data.ttn_number}`, template.context, combinedData, translation, meta, options?.skip_save)
|
||||
|
||||
return document
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,7 @@ export * as GeneratorOffer from './996.GeneratorOffer'
|
||||
export * as BlagorostProgramTemplate from './998.BlagorostProgramTemplate'
|
||||
export * as GenerationContractTemplate from './997.GenerationContractTemplate'
|
||||
export * as BlagorostOfferTemplate from './999.BlagorostOfferTemplate'
|
||||
|
||||
// Marketplace (Стол заказов) — Эпик 5
|
||||
export * as MarketplaceAplReception from './1102.MarketplaceAplReception'
|
||||
export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MongoDBConnector } from '../Services/Databazor'
|
||||
import { type ExternalEntrepreneurData, type ExternalIndividualData, type ExternalOrganizationData, type IVars, Individual, type InternalProjectData, Organization, PaymentMethod, Project, Vars } from '../Models'
|
||||
import type { IGenerate, IGenerationOptions } from '../Interfaces/Documents'
|
||||
import { PDFService } from '../Services/Generator'
|
||||
import { DocDataService } from '../Services/DocData'
|
||||
import packageJson from '../../package.json'
|
||||
import { Validator } from '../Services/Validator'
|
||||
import type { CooperativeData } from '../Models/Cooperative'
|
||||
@@ -26,15 +27,48 @@ export abstract class DocFactory<T extends IGenerate> {
|
||||
abstract generateDocument(data: T, options?: IGenerationOptions): Promise<IGeneratedDocument>
|
||||
|
||||
public storage: MongoDBConnector
|
||||
public docData: DocDataService
|
||||
|
||||
constructor(storage: MongoDBConnector) {
|
||||
this.storage = storage
|
||||
this.docData = new DocDataService(storage)
|
||||
}
|
||||
|
||||
async validate(combinedData: ICombinedData, schema: any) {
|
||||
return new Validator(schema, combinedData).validate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить приватный payload документа и получить его hash.
|
||||
* Hash безопасно публикуется on-chain как `doc_data_hash`; сам payload
|
||||
* остаётся off-chain в коллекции `doc_private_data`.
|
||||
* См. раздел «Document Generation Pattern: doc_data» в архитектуре.
|
||||
*/
|
||||
async saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }> {
|
||||
return this.docData.save(payload, registry_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать приватный payload по `doc_data_hash`.
|
||||
* Возвращает null если запись удалена (документ остаётся верифицируемым
|
||||
* по хэшу подписи, но не регенерируется — graceful degradation).
|
||||
*/
|
||||
async getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null> {
|
||||
return this.docData.get<P>(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Помощник для конкретных фабрик: подгружает приватный payload по
|
||||
* `data.doc_data_hash` и возвращает его (или null, если хэш не задан /
|
||||
* запись удалена). Фабрика кладёт результат в `combinedData.doc_data` —
|
||||
* шаблон документа обращается к полям как `{{ doc_data.<field> }}`.
|
||||
*/
|
||||
async loadDocData<P = Record<string, unknown>>(data: { doc_data_hash?: string }): Promise<P | null> {
|
||||
if (!data.doc_data_hash)
|
||||
return null
|
||||
return this.getDocData<P>(data.doc_data_hash)
|
||||
}
|
||||
|
||||
async getOrganization(username: string, block_num?: number): Promise<IOrganizationData> {
|
||||
const block_filter = block_num ? { block_num: { $lte: block_num } } : {}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { MongoDBConnector } from './MongoDBConnector'
|
||||
export { default as DataService } from './DataService'
|
||||
export { SearchService, type ISearchResult } from './SearchService'
|
||||
export { DocDataService, type IDocDataRecord } from '../DocData'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { Collection } from 'mongodb'
|
||||
import type { MongoDBConnector } from '../Databazor'
|
||||
import { calculateSha256 } from '../../Utils/calculateSHA'
|
||||
|
||||
/**
|
||||
* Хранилище приватных данных документа.
|
||||
*
|
||||
* On-chain в meta попадает только `doc_data_hash` (sha256 от стабильно
|
||||
* сериализованного payload). Сам payload (ФИО, паспорт, телефон, госномер
|
||||
* и прочие персональные поля экспедитора / подписанта) лежит в этой
|
||||
* коллекции отдельно и не публикуется в блокчейн.
|
||||
*
|
||||
* При генерации документа `DocFactory.generateDocument` сам подгружает
|
||||
* payload по `doc_data_hash` и подставляет под зарезервированной
|
||||
* переменной `doc_data` в combinedData — шаблон обращается как
|
||||
* `{{ doc_data.<field> }}`.
|
||||
*/
|
||||
|
||||
export interface IDocDataRecord<P = Record<string, unknown>> {
|
||||
hash: string
|
||||
registry_id: number
|
||||
payload: P
|
||||
_created_at: Date
|
||||
}
|
||||
|
||||
const COLLECTION_NAME = 'doc_private_data'
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object')
|
||||
return JSON.stringify(value)
|
||||
|
||||
if (Array.isArray(value))
|
||||
return `[${value.map(item => stableStringify(item)).join(',')}]`
|
||||
|
||||
const entries = Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const v = (value as Record<string, unknown>)[key]
|
||||
return `${JSON.stringify(key)}:${stableStringify(v)}`
|
||||
})
|
||||
|
||||
return `{${entries.join(',')}}`
|
||||
}
|
||||
|
||||
export class DocDataService {
|
||||
private readonly collection: Collection<IDocDataRecord>
|
||||
private indexEnsured = false
|
||||
|
||||
constructor(storage: MongoDBConnector) {
|
||||
this.collection = storage.getCollection<IDocDataRecord>(COLLECTION_NAME)
|
||||
}
|
||||
|
||||
private async ensureIndex(): Promise<void> {
|
||||
if (this.indexEnsured)
|
||||
return
|
||||
await this.collection.createIndex({ hash: 1 }, { unique: true })
|
||||
this.indexEnsured = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет приватный payload и возвращает его hash.
|
||||
* Идемпотентно: один и тот же payload даёт один и тот же hash,
|
||||
* повторный вызов не создаёт дублей.
|
||||
*/
|
||||
async save<P extends Record<string, unknown>>(
|
||||
payload: P,
|
||||
registry_id: number,
|
||||
): Promise<{ hash: string }> {
|
||||
await this.ensureIndex()
|
||||
|
||||
const serialized = stableStringify(payload)
|
||||
const hash = calculateSha256(Buffer.from(serialized, 'utf8'))
|
||||
|
||||
await this.collection.updateOne(
|
||||
{ hash },
|
||||
{
|
||||
$setOnInsert: {
|
||||
hash,
|
||||
registry_id,
|
||||
payload,
|
||||
_created_at: new Date(),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
)
|
||||
|
||||
return { hash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает приватный payload по хэшу. Возвращает null если запись
|
||||
* удалена (документ остаётся верифицируемым по хэшу подписи, но не
|
||||
* регенерируется — graceful degradation).
|
||||
*/
|
||||
async get<P = Record<string, unknown>>(hash: string): Promise<P | null> {
|
||||
const record = await this.collection.findOne({ hash })
|
||||
if (!record)
|
||||
return null
|
||||
return record.payload as P
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { JSONSchemaType } from 'ajv'
|
||||
import { Cooperative } from 'cooptypes'
|
||||
import type { ITemplate } from '../Interfaces'
|
||||
import { IMetaJSONSchema } from '../Schema/MetaSchema'
|
||||
import { CooperativeSchema, VarsSchema, decisionSchema, organizationSchema } from '../Schema'
|
||||
import { CommonRequestSchema } from '../Schema/CommonRequestSchema'
|
||||
import { CommonUserSchema } from '../Schema/CommonUserSchema'
|
||||
import { FirstLastMiddleNameSchema } from '../Schema/FirstLastMiddleNameSchema'
|
||||
import { CommonProgramSchema } from '../Schema/CommonProgramSchema'
|
||||
|
||||
export const registry_id = Cooperative.Registry.MarketplaceAplReception.registry_id
|
||||
|
||||
export type Action = Cooperative.Registry.MarketplaceAplReception.Action
|
||||
|
||||
export type Model = Cooperative.Registry.MarketplaceAplReception.Model
|
||||
|
||||
export const Schema: JSONSchemaType<Model> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
meta: IMetaJSONSchema,
|
||||
coop: CooperativeSchema,
|
||||
vars: VarsSchema,
|
||||
request: CommonRequestSchema,
|
||||
user: CommonUserSchema,
|
||||
decision: decisionSchema,
|
||||
act_id: { type: 'string' },
|
||||
transmitter: FirstLastMiddleNameSchema,
|
||||
program: CommonProgramSchema,
|
||||
branch: { ...organizationSchema, nullable: true },
|
||||
},
|
||||
required: ['meta', 'coop', 'vars', 'request', 'user', 'decision', 'act_id', 'transmitter', 'program'],
|
||||
additionalProperties: true,
|
||||
}
|
||||
|
||||
export const Template: ITemplate<Model> = {
|
||||
title: Cooperative.Registry.MarketplaceAplReception.title,
|
||||
description: Cooperative.Registry.MarketplaceAplReception.description,
|
||||
model: Schema,
|
||||
context: Cooperative.Registry.MarketplaceAplReception.context,
|
||||
translations: Cooperative.Registry.MarketplaceAplReception.translations,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { JSONSchemaType } from 'ajv'
|
||||
import { Cooperative } from 'cooptypes'
|
||||
import type { ITemplate } from '../Interfaces'
|
||||
import { IMetaJSONSchema } from '../Schema/MetaSchema'
|
||||
import { CooperativeSchema, VarsSchema, organizationSchema } from '../Schema'
|
||||
import { FirstLastMiddleNameSchema } from '../Schema/FirstLastMiddleNameSchema'
|
||||
|
||||
export const registry_id = Cooperative.Registry.MarketplaceTransportNote.registry_id
|
||||
|
||||
export type Action = Cooperative.Registry.MarketplaceTransportNote.Action
|
||||
|
||||
export type Model = Cooperative.Registry.MarketplaceTransportNote.Model
|
||||
|
||||
export type PrivateData = Cooperative.Registry.MarketplaceTransportNote.PrivateData
|
||||
|
||||
// Схема приватного payload (off-chain). Используется только для
|
||||
// контрактной проверки в тестах — продакшен валидирует на boundary
|
||||
// контроллера через class-validator DTO.
|
||||
const PrivateDataSchema: JSONSchemaType<PrivateData> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expeditor_full_name: { type: 'string' },
|
||||
expeditor_phone: { type: 'string' },
|
||||
expeditor_id_doc: { type: 'string' },
|
||||
vehicle_number: { type: 'string' },
|
||||
loading_address: { type: 'string' },
|
||||
loading_datetime: { type: 'string' },
|
||||
delivery_datetime_estimate: { type: 'string' },
|
||||
},
|
||||
required: [
|
||||
'expeditor_full_name',
|
||||
'expeditor_phone',
|
||||
'expeditor_id_doc',
|
||||
'vehicle_number',
|
||||
'loading_address',
|
||||
'loading_datetime',
|
||||
'delivery_datetime_estimate',
|
||||
],
|
||||
additionalProperties: true,
|
||||
}
|
||||
|
||||
export const Schema: JSONSchemaType<Model> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
meta: IMetaJSONSchema,
|
||||
coop: CooperativeSchema,
|
||||
vars: VarsSchema,
|
||||
ttn_number: { type: 'string' },
|
||||
cycle_id: { type: 'string' },
|
||||
shipment_id: { type: 'string' },
|
||||
accept_braname: { type: 'string' },
|
||||
total_amount: { type: 'string' },
|
||||
currency: { type: 'string' },
|
||||
supplier_account: { type: 'string' },
|
||||
supplier: FirstLastMiddleNameSchema,
|
||||
doc_data: PrivateDataSchema,
|
||||
branch: { ...organizationSchema, nullable: true },
|
||||
},
|
||||
required: [
|
||||
'meta',
|
||||
'coop',
|
||||
'vars',
|
||||
'ttn_number',
|
||||
'cycle_id',
|
||||
'shipment_id',
|
||||
'accept_braname',
|
||||
'total_amount',
|
||||
'currency',
|
||||
'supplier_account',
|
||||
'supplier',
|
||||
'doc_data',
|
||||
],
|
||||
additionalProperties: true,
|
||||
}
|
||||
|
||||
export const Template: ITemplate<Model> = {
|
||||
title: Cooperative.Registry.MarketplaceTransportNote.title,
|
||||
description: Cooperative.Registry.MarketplaceTransportNote.description,
|
||||
model: Schema,
|
||||
context: Cooperative.Registry.MarketplaceTransportNote.context,
|
||||
translations: Cooperative.Registry.MarketplaceTransportNote.translations,
|
||||
}
|
||||
@@ -68,6 +68,10 @@ export * as GenerationToCapitalizationConvertStatement from './1082.GenerationTo
|
||||
export * as CapitalizationToMainWalletConvertStatement from './1090.CapitalizationToMainWalletConvertStatement'
|
||||
export * as SosediAgreement from './699.SosediAgreement'
|
||||
|
||||
// Marketplace (Стол заказов) — Эпик 5
|
||||
export * as MarketplaceAplReception from './1102.MarketplaceAplReception'
|
||||
export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
|
||||
|
||||
export * as AnnualGeneralMeetingAgenda from './300.AnnualGeneralMeetingAgenda'
|
||||
export * as AnnualGeneralMeetingSovietDecision from './301.AnnualGeneralMeetingSovietDecision'
|
||||
export * as AnnualGeneralMeetingNotification from './302.AnnualGeneralMeetingNotification'
|
||||
|
||||
@@ -70,6 +70,8 @@ import * as GeneratorOfferTemplate from './995.GeneratorOfferTemplate'
|
||||
import * as GeneratorOffer from './996.GeneratorOffer'
|
||||
import * as GeneratorProgramTemplate from './994.GeneratorProgramTemplate'
|
||||
import * as BlagorostOffer from './1000.BlagorostOffer'
|
||||
import * as MarketplaceAplReception from './1102.MarketplaceAplReception'
|
||||
import * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
|
||||
|
||||
export const Registry = {
|
||||
1: WalletAgreement,
|
||||
@@ -131,4 +133,6 @@ export const Registry = {
|
||||
1081: GenerationToProjectConvertStatement,
|
||||
1082: GenerationToCapitalizationConvertStatement,
|
||||
1090: CapitalizationToMainWalletConvertStatement,
|
||||
1102: MarketplaceAplReception,
|
||||
1103: MarketplaceTransportNote,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { IFilterDocuments, IGeneratedDocument, Numbers, externalDataTypes,
|
||||
import type { IGenerate, IGenerationOptions } from './Interfaces/Documents'
|
||||
import * as Actions from './Actions'
|
||||
|
||||
import { type ISearchResult, MongoDBConnector, SearchService } from './Services/Databazor'
|
||||
import { DocDataService, type ISearchResult, MongoDBConnector, SearchService } from './Services/Databazor'
|
||||
import type { ExternalIndividualData } from './Models/Individual'
|
||||
import { Individual } from './Models/Individual'
|
||||
import type { ExternalEntrepreneurData, ExternalOrganizationData, IVars } from './Models'
|
||||
@@ -39,6 +39,16 @@ export interface IGenerator {
|
||||
generate: (data: IGenerate, options?: IGenerationOptions) => Promise<IGeneratedDocument>
|
||||
getDocument: (filter: Filter<IFilterDocuments>) => Promise<IGeneratedDocument>
|
||||
|
||||
/**
|
||||
* Сохранить приватные данные документа off-chain; возвращает hash для
|
||||
* публикации on-chain как `doc_data_hash`. См. раздел
|
||||
* «Document Generation Pattern: doc_data» в архитектуре.
|
||||
*/
|
||||
saveDocData: <P extends Record<string, unknown>>(payload: P, registry_id: number) => Promise<{ hash: string }>
|
||||
|
||||
/** Прочитать приватный payload по `doc_data_hash`. null — если запись удалена. */
|
||||
getDocData: <P = Record<string, unknown>>(hash: string) => Promise<P | null>
|
||||
|
||||
constructCooperative: (username: string, block_num?: number) => Promise<CooperativeData | null>
|
||||
save: ((type: 'individual', data: ExternalIndividualData) => Promise<InsertOneResult>) & ((type: 'entrepreneur', data: ExternalEntrepreneurData) => Promise<InsertOneResult>) & ((type: 'organization', data: ExternalOrganizationData) => Promise<InsertOneResult>) & ((type: 'paymentMethod', data: PaymentData) => Promise<InsertOneResult>) & ((type: 'vars', data: IVars) => Promise<InsertOneResult>) & ((type: 'project', data: ExternalProjectData) => Promise<InsertOneResult>) & ((type: 'udata', data: ExternalUdata) => Promise<InsertOneResult>)
|
||||
get: (type: dataTypes, filter: Filter<internalFilterTypes>) => Promise<externalDataTypes | null>
|
||||
@@ -64,6 +74,9 @@ export class Generator implements IGenerator {
|
||||
// Сервис поиска
|
||||
private searchService!: SearchService
|
||||
|
||||
// Сервис приватных данных документов (off-chain payload + on-chain hash)
|
||||
private docDataService!: DocDataService
|
||||
|
||||
// Метод подключения к хранилищу
|
||||
async connect(mongoUri: string): Promise<void> {
|
||||
this.storage = new MongoDBConnector(mongoUri)
|
||||
@@ -71,6 +84,9 @@ export class Generator implements IGenerator {
|
||||
// Инициализация сервиса поиска
|
||||
this.searchService = new SearchService(this.storage)
|
||||
|
||||
// Инициализация сервиса приватных данных документов
|
||||
this.docDataService = new DocDataService(this.storage)
|
||||
|
||||
// Инициализация фабрик документов
|
||||
this.factories = {
|
||||
[Actions.WalletAgreement.Template.registry_id]: new Actions.WalletAgreement.Factory(this.storage), // 1
|
||||
@@ -151,6 +167,10 @@ export class Generator implements IGenerator {
|
||||
[Actions.GenerationToCapitalizationConvertStatement.Template.registry_id]: new Actions.GenerationToCapitalizationConvertStatement.Factory(this.storage), // 1082
|
||||
|
||||
[Actions.CapitalizationToMainWalletConvertStatement.Template.registry_id]: new Actions.CapitalizationToMainWalletConvertStatement.Factory(this.storage), // 1090
|
||||
|
||||
// Marketplace (Стол заказов) — Эпик 5
|
||||
[Actions.MarketplaceAplReception.Template.registry_id]: new Actions.MarketplaceAplReception.Factory(this.storage), // 1102
|
||||
[Actions.MarketplaceTransportNote.Template.registry_id]: new Actions.MarketplaceTransportNote.Factory(this.storage), // 1103
|
||||
}
|
||||
await this.storage.connect()
|
||||
}
|
||||
@@ -175,6 +195,14 @@ export class Generator implements IGenerator {
|
||||
return await this.storage.getDocument(filter)
|
||||
}
|
||||
|
||||
async saveDocData<P extends Record<string, unknown>>(payload: P, registry_id: number): Promise<{ hash: string }> {
|
||||
return this.docDataService.save(payload, registry_id)
|
||||
}
|
||||
|
||||
async getDocData<P = Record<string, unknown>>(hash: string): Promise<P | null> {
|
||||
return this.docDataService.get<P>(hash)
|
||||
}
|
||||
|
||||
async save(type: 'individual', data: ExternalIndividualData): Promise<InsertOneResult>
|
||||
async save(type: 'entrepreneur', data: ExternalEntrepreneurData): Promise<InsertOneResult>
|
||||
async save(type: 'organization', data: ExternalOrganizationData): Promise<InsertOneResult>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { MongoDBConnector } from '../src/Services/Databazor'
|
||||
import { DocDataService } from '../src/Services/DocData'
|
||||
import { generator, mongoUri } from './utils'
|
||||
|
||||
describe('DocDataService — приватные данные документов', () => {
|
||||
let storage: MongoDBConnector
|
||||
let service: DocDataService
|
||||
|
||||
beforeAll(async () => {
|
||||
storage = new MongoDBConnector(mongoUri)
|
||||
await storage.connect()
|
||||
service = new DocDataService(storage)
|
||||
await storage.getCollection('doc_private_data').deleteMany({})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await storage.disconnect()
|
||||
})
|
||||
|
||||
it('save → возвращает sha256 hash и сохраняет payload', async () => {
|
||||
const payload = {
|
||||
expeditor_full_name: 'Иванов Иван Иванович',
|
||||
passport: '4509 123456',
|
||||
phone: '+79991234567',
|
||||
vehicle_registration_number: 'А123ВВ77',
|
||||
}
|
||||
|
||||
const { hash } = await service.save(payload, 1103)
|
||||
expect(hash).toMatch(/^[A-F0-9]{64}$/)
|
||||
|
||||
const restored = await service.get(hash)
|
||||
expect(restored).toEqual(payload)
|
||||
})
|
||||
|
||||
it('save идемпотентен: одинаковый payload → одинаковый hash, без дублей', async () => {
|
||||
const payload = { foo: 'bar', baz: 42 }
|
||||
|
||||
const r1 = await service.save(payload, 1102)
|
||||
const r2 = await service.save(payload, 1102)
|
||||
expect(r1.hash).toEqual(r2.hash)
|
||||
|
||||
const count = await storage.getCollection('doc_private_data').countDocuments({ hash: r1.hash })
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
|
||||
it('save стабилен по порядку ключей: {a,b} и {b,a} → один и тот же hash', async () => {
|
||||
const r1 = await service.save({ a: 1, b: 2 }, 1102)
|
||||
const r2 = await service.save({ b: 2, a: 1 }, 1102)
|
||||
expect(r1.hash).toEqual(r2.hash)
|
||||
})
|
||||
|
||||
it('get → null если запись удалена (graceful degradation)', async () => {
|
||||
const { hash } = await service.save({ tmp: 'будет удалён' }, 1102)
|
||||
await storage.getCollection('doc_private_data').deleteOne({ hash })
|
||||
|
||||
const restored = await service.get(hash)
|
||||
expect(restored).toBeNull()
|
||||
})
|
||||
|
||||
it('Generator-фасад: saveDocData / getDocData делегируются', async () => {
|
||||
await generator.connect(mongoUri)
|
||||
try {
|
||||
const payload = { signer_full_name: 'Петров Пётр Петрович' }
|
||||
const { hash } = await generator.saveDocData(payload, 1102)
|
||||
const restored = await generator.getDocData(hash)
|
||||
expect(restored).toEqual(payload)
|
||||
}
|
||||
finally {
|
||||
await generator.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,7 @@ export async function preLoading() {
|
||||
'udatas',
|
||||
'deltas',
|
||||
'actions',
|
||||
'doc_private_data',
|
||||
]
|
||||
|
||||
for (const collectionName of collectionsToClear) {
|
||||
|
||||
Reference in New Issue
Block a user