[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:
coopops
2026-05-17 19:40:22 +00:00
parent b85ae12b9f
commit 10158b5b33
24 changed files with 768 additions and 99 deletions
@@ -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;
@@ -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 });
@@ -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
}
/**
* Интерфейс генерации заявления на вступление в кооператив
*/
@@ -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',
@@ -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',
},
+14
View File
@@ -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/`)
+68
View File
@@ -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
}
}
+4
View File
@@ -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'
+34
View File
@@ -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,
}
+29 -1
View File
@@ -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>
+73
View File
@@ -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()
}
})
})
+1
View File
@@ -51,6 +51,7 @@ export async function preLoading() {
'udatas',
'deltas',
'actions',
'doc_private_data',
]
for (const collectionName of collectionsToClear) {