fix(marketplace): выдача — финальная подпись в кабинете заказчика, факт фиксируется оператором при открытии (B1)

Кардинальный дефект: финальную подпись заказчика держал стол оператора
(2-й скан штрих-кода + ввод приватного ключа ЗАКАЗЧИКА на устройстве
оператора). Перенесено корректно, без изменения контракта:

- signiss1 не берёт actual_quantity (только акт), signiss2 берёт →
  relocation чисто backend+frontend.
- openIssuance принимает actual_quantity (правка оператора): зашивается в
  акт председателя и сохраняется снапшотом issuance_fact на открытии.
- finalizeIssuance больше не принимает actual_quantity/delivery_signer —
  backend берёт их из заказа (issuance_fact, chairman_account); заказчик
  факт не редактирует.
- Полный codegen: schema.gql + zeus (controller+sdk).
- Frontend оператора: IssueActOpenDialog получил таблицу сверки кол-ва +
  «Показать акт»; IssueActFinalizeDialog удалён, «Завершить выдачу»/2-й
  скан убраны, READY_TO_RECEIVE на столе оператора = «Ждём подпись
  заказчика».
- Frontend заказчика: новый OrdererFinalizeIssuanceDialog (read-only
  сверка + «Показать акт» + «Подписать и получить» своим ключом) на столе
  «Готово к получению».

Правка ЦЕНЫ (не только кол-ва) — отдельным шагом B2 (затрагивает контракт).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-30 14:27:16 +00:00
parent 42b669eb45
commit f2b0e8fd0b
15 changed files with 510 additions and 491 deletions
+5 -10
View File
@@ -7806,19 +7806,11 @@ type MarketplaceExpressPickupCandidate {
}
input MarketplaceFinalizeIssuanceInput {
"""Фактически выдаваемое количество единиц (равно/меньше/больше заказа)."""
actual_quantity: Int!
"""
Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком.
"""
delivery_signer: String!
"""Идентификатор заказа, который выдаём пайщику."""
"""Идентификатор заказа, который получаем."""
order_id: ID!
"""
Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями.
Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями.
"""
signed_document: MarketplaceIssueActSignedDocumentInput!
}
@@ -8361,6 +8353,9 @@ type MarketplaceOnboardingState {
}
input MarketplaceOpenIssuanceInput {
"""Фактически выдаваемое количество единиц (равно/меньше/больше заказа)."""
actual_quantity: Int!
"""Идентификатор заказа, выдачу которого открываем."""
order_id: ID!
@@ -6,6 +6,12 @@ import { MarketplaceOrderDTO } from './marketplace-order.dto';
/**
* Story 6.1: вход на открытие выдачи председателем КУ (первая подпись АПП).
*
* `actual_quantity` — фактически выдаваемое количество единиц. Оператор КУ
* сверяет привезённое имущество с заказом и фиксирует факт именно в момент
* открытия выдачи (взвешивание/пересчёт на стойке). Это количество зашивается
* в подписываемый председателем акт и сохраняется на заказе; финальная подпись
* заказчика факт уже не редактирует.
*/
@InputType('MarketplaceOpenIssuanceInput')
export class MarketplaceOpenIssuanceInputDTO {
@@ -14,6 +20,13 @@ export class MarketplaceOpenIssuanceInputDTO {
@IsNotEmpty()
public readonly order_id!: string;
@Field(() => Int, {
description: 'Фактически выдаваемое количество единиц (равно/меньше/больше заказа).',
})
@IsInt()
@Min(1)
public readonly actual_quantity!: number;
@Field(() => MarketplaceIssueActSignedDocumentInputDTO, {
description:
'Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись.',
@@ -26,37 +39,22 @@ export class MarketplaceOpenIssuanceInputDTO {
/**
* Story 6.3: вход на финальную подпись заказчика (получение имущества).
*
* `actual_quantity` указывается оператором перед сканированием подписи и
* влияет на корректирующие операции в композитной транзакции `signiss2`
* (см. FR23-25 и контракт `signiss2.cpp`).
*
* `delivery_signer` — учётная запись стороны кооператива (председатель КУ
* или доверенное им лицо), подписывающая акт вместе с заказчиком.
* Заказчик ставит финальную подпись в своём кабинете на своём устройстве своим
* ключом — он только подтверждает уже сформированный акт, факт не редактирует.
* Поэтому фактическое количество и сторона кооператива, открывшая выдачу,
* берутся backend'ом из заказа (зафиксированы оператором при открытии), а не
* передаются заказчиком.
*/
@InputType('MarketplaceFinalizeIssuanceInput')
export class MarketplaceFinalizeIssuanceInputDTO {
@Field(() => ID, { description: 'Идентификатор заказа, который выдаём пайщику.' })
@Field(() => ID, { description: 'Идентификатор заказа, который получаем.' })
@IsString()
@IsNotEmpty()
public readonly order_id!: string;
@Field(() => Int, {
description: 'Фактически выдаваемое количество единиц (равно/меньше/больше заказа).',
})
@IsInt()
@Min(1)
public readonly actual_quantity!: number;
@Field(() => String, {
description: 'Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком.',
})
@IsString()
@IsNotEmpty()
public readonly delivery_signer!: string;
@Field(() => MarketplaceIssueActSignedDocumentInputDTO, {
description:
'Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями.',
'Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями.',
})
@ValidateNested()
@Type(() => MarketplaceIssueActSignedDocumentInputDTO)
@@ -76,6 +76,7 @@ export class MarketplaceIssuanceResolver {
coopname: config.coopname,
chairman_account: member.username,
order_id: data.order_id,
actual_quantity: data.actual_quantity,
signed_document: data.signed_document,
});
return new MarketplaceIssuanceResultDTO({
@@ -99,8 +100,6 @@ export class MarketplaceIssuanceResolver {
coopname: config.coopname,
orderer_account: member.username,
order_id: data.order_id,
actual_quantity: data.actual_quantity,
delivery_signer: data.delivery_signer,
signed_document: data.signed_document,
});
return new MarketplaceIssuanceResultDTO({
@@ -147,7 +146,8 @@ export class MarketplaceIssuanceResolver {
const doc = await this.service.getOpenIssuanceSignablePayload(
coopname,
data.order_id,
member.username
member.username,
data.actual_quantity
);
return toGeneratedDocumentDTO(doc);
}
@@ -37,6 +37,7 @@ export interface MarketplaceOpenIssuanceInput {
coopname: string;
chairman_account: string;
order_id: string;
actual_quantity: number;
signed_document: MarketplaceIssueActSignedDocumentInputDTO;
}
@@ -44,8 +45,6 @@ export interface MarketplaceFinalizeIssuanceInput {
coopname: string;
orderer_account: string;
order_id: string;
actual_quantity: number;
delivery_signer: string;
signed_document: MarketplaceIssueActSignedDocumentInputDTO;
}
@@ -61,21 +60,25 @@ export interface MarketplaceIssuanceResult {
*
* Flow:
*
* 1. `openIssuance(order_id, chairman_signed_document)` — председатель
* КУ выдачи открывает выдачу первой подписью. Backend верифицирует
* подпись, отправляет on-chain `signiss1`, переводит Order
* 1. `openIssuance(order_id, actual_quantity, chairman_signed_document)` —
* оператор КУ сверяет привезённое имущество с заказом и фиксирует
* фактическое количество (`actual_quantity`) прямо при открытии выдачи;
* председатель КУ подписывает акт с этим количеством. Backend верифицирует
* подпись, сохраняет снапшот фактической выдачи (`issuance_fact`),
* отправляет on-chain `signiss1`, переводит Order
* ACCEPTED_TO_COOP → READY_TO_RECEIVE и эмитит push заказчику
* `marketplace-order-ready` (FR22).
*
* 2. `finalizeIssuance(order_id, actual_quantity, delivery_signer,
* signed_document)` — заказчик закрывает выдачу финальной подписью.
* Backend верифицирует обе подписи в `signed_document.signatures`,
* отправляет on-chain `signiss2` с указанным `actual_quantity` — C++
* контракт сам исполняет корректирующие операции
* (`o.mkt.unlock` если actual<ordered / `o.mkt.lock` если actual>ordered,
* FR23) и `o.mkt.consum` (BURN w.mkt.order, Дт 86 / Кт 10, FR24).
* Backend сохраняет снапшот фактической выдачи (`issuance_fact`) и
* переводит Order READY_TO_RECEIVE → RECEIVED.
* 2. `finalizeIssuance(order_id, signed_document)` — заказчик закрывает
* выдачу финальной подписью в своём кабинете на своём устройстве: он
* лишь подтверждает уже сформированный акт, факт не редактирует.
* `actual_quantity` и `delivery_signer` backend берёт из заказа
* (зафиксированы оператором при открытии). Backend верифицирует обе
* подписи в `signed_document.signatures`, отправляет on-chain `signiss2`
* с этим `actual_quantity` — C++ контракт сам исполняет корректирующие
* операции (`o.mkt.unlock` если actual<ordered / `o.mkt.lock` если
* actual>ordered, FR23) и `o.mkt.consum` (BURN w.mkt.order, Дт 86 /
* Кт 10, FR24). Order переводится READY_TO_RECEIVE → RECEIVED.
*
* При расхождении фактического количества с заказом и нехватке средств
* пайщика на доплату (`actual > заказ`, L6 guard) транзакция `signiss2`
@@ -105,7 +108,8 @@ export class MarketplaceIssuanceService {
async getOpenIssuanceSignablePayload(
coopname: string,
order_id: string,
chairman_account: string
chairman_account: string,
actual_quantity?: number
): Promise<DocumentDomainEntity> {
const order = await this.loadOrder(coopname, order_id);
if (order.status !== 'ACCEPTED_TO_COOP') {
@@ -113,10 +117,14 @@ export class MarketplaceIssuanceService {
`Заказ в статусе «${order.status}», открытие выдачи недопустимо.`
);
}
// Оператор сверяет факт при открытии: акт формируется на фактически
// выдаваемое количество. Если оператор ещё не скорректировал — по заказу.
const fact_quantity =
actual_quantity && actual_quantity > 0 ? actual_quantity : order.quantity;
return this.generateIssueActDocument({
order,
transmitter: chairman_account,
actual_quantity: order.quantity,
actual_quantity: fact_quantity,
});
}
@@ -163,9 +171,16 @@ export class MarketplaceIssuanceService {
if (order.chairman_signed_at !== null) {
throw new ConflictException('Первая подпись выдачи уже зафиксирована для этого заказа.');
}
if (input.actual_quantity <= 0) {
throw new BadRequestException('Фактическое количество должно быть больше нуля.');
}
this.verifyDocumentSignature(input.signed_document);
// Факт фиксируется оператором при открытии выдачи и сохраняется на заказе;
// финальная подпись заказчика берёт его из снапшота, а не редактирует.
const factSnapshot = this.buildIssuanceFactSnapshot(order, input.actual_quantity);
const act = new SignedDigitalDocumentInputDTO(input.signed_document).toDocument() as MarketContract.Actions.SignIss1.ISignIss1['act'];
let tx;
@@ -199,6 +214,9 @@ export class MarketplaceIssuanceService {
chairman_account: input.chairman_account,
signiss1_tx_hash: txHash,
current_warehouse_braname: order.delivery_braname,
// факт зафиксирован оператором при открытии — сохраняем снапшот, чтобы
// финальная подпись заказчика взяла actual_quantity отсюда.
issuance_fact: factSnapshot,
// канон 2-подписи: сохраняем подписанный председателем документ, чтобы
// заказчик получил его как DocumentAggregate и наложил вторую подпись.
issue_act_signiss1_document:
@@ -226,12 +244,12 @@ export class MarketplaceIssuanceService {
input: MarketplaceFinalizeIssuanceInput
): Promise<MarketplaceIssuanceResult> {
const order = await this.loadOrder(input.coopname, input.order_id);
// Канон выдачи (UX-DR4): финальную подпись заказчик ставит на устройстве
// оператора КУ (operator-assisted POS) либо со своего стола. Авторизуем
// по самой подписи, а не по JWT submitter'а: закрывающую подпись акта
// обязан нести ключ заказчика-владельца заказа. Криптовалидность всех
// подписей проверяется ниже в verifyDocumentSignature; submitter уже
// ограничен RoleGuard'ом 'Issuance','sign:final'.
// Канон выдачи: финальную подпись заказчик ставит сам в своём кабинете на
// своём устройстве своим ключом. Авторизуем по самой подписи, а не по JWT
// submitter'а: закрывающую подпись акта обязан нести ключ заказчика-владельца
// заказа. Криптовалидность всех подписей проверяется ниже в
// verifyDocumentSignature; submitter уже ограничен RoleGuard'ом
// 'Issuance','sign:final'.
const ordererSigned = (input.signed_document.signatures ?? []).some(
(sig) => sig.signer === order.orderer_account
);
@@ -250,9 +268,19 @@ export class MarketplaceIssuanceService {
'Финальная подпись выдачи уже зафиксирована для этого заказа.'
);
}
if (input.actual_quantity <= 0) {
throw new BadRequestException(
'Фактическое количество должно быть больше нуля.'
// Факт зафиксирован оператором при открытии выдачи (issuance_fact); сторона
// кооператива — председатель, открывший выдачу (chairman_account). Заказчик
// ничего из этого не передаёт и не редактирует.
const actual_quantity = order.issuance_fact?.actual_quantity;
if (!actual_quantity || actual_quantity <= 0) {
throw new ConflictException(
`Заказ ${order.id}: фактическое количество не зафиксировано при открытии выдачи — финализация недоступна.`
);
}
const delivery_signer = order.chairman_account;
if (!delivery_signer) {
throw new ConflictException(
`Заказ ${order.id}: не найден председатель, открывший выдачу — финализация недоступна.`
);
}
@@ -266,8 +294,8 @@ export class MarketplaceIssuanceService {
coopname: order.coopname,
orderer: order.orderer_account,
order_hash: order.order_hash,
actual_quantity: input.actual_quantity,
delivery_signer: input.delivery_signer,
actual_quantity,
delivery_signer,
act,
});
} catch (err) {
@@ -286,21 +314,21 @@ export class MarketplaceIssuanceService {
`Не получен tx_hash от блокчейна для finalize issuance order ${order.id}. Повторите подписание.`
);
}
const factSnapshot = this.buildIssuanceFactSnapshot(order, input.actual_quantity);
const factSnapshot = order.issuance_fact ?? this.buildIssuanceFactSnapshot(order, actual_quantity);
const warrantyUntil =
order.warranty_period_secs > 0
? new Date(Date.now() + order.warranty_period_secs * 1000)
: null;
const updated = await this.orderRepo.applyIssuanceFinalized(order.id, {
delivery_signer_account: input.delivery_signer,
delivery_signer_account: delivery_signer,
signiss2_tx_hash: txHash,
issuance_fact: factSnapshot,
warranty_until: warrantyUntil,
});
this.logger.log(
`Выдача order ${order.id} завершена: actual_quantity=${input.actual_quantity}, diff=${factSnapshot.diff_state}, fact_cost=${factSnapshot.fact_cost} (tx=${txHash}).`
`Выдача order ${order.id} завершена: actual_quantity=${actual_quantity}, diff=${factSnapshot.diff_state}, fact_cost=${factSnapshot.fact_cost} (tx=${txHash}).`
);
return { order: updated, tx_hash: txHash };
@@ -124,7 +124,8 @@ export interface MarketplaceOrderDomainRepository
* Story 6.1: применяет первую подпись АПП-выдачи (председатель КУ открыл
* выдачу). Переводит Order ACCEPTED_TO_COOP → READY_TO_RECEIVE и
* заполняет `current_warehouse_braname` (= delivery_braname),
* `chairman_signed_at`, `chairman_account`, `signiss1_tx_hash`.
* `chairman_signed_at`, `chairman_account`, `signiss1_tx_hash`,
* `issuance_fact` (факт зафиксирован оператором при открытии).
*/
applyIssuanceOpened(
id: string,
@@ -132,6 +133,7 @@ export interface MarketplaceOrderDomainRepository
chairman_account: string;
signiss1_tx_hash: string;
current_warehouse_braname: string;
issuance_fact: MarketplaceOrderIssuanceFactSnapshot;
issue_act_signiss1_document: ISignedDocumentDomainInterface;
}
): Promise<MarketplaceOrderDomainEntity>;
@@ -277,6 +277,7 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
chairman_account: string;
signiss1_tx_hash: string;
current_warehouse_braname: string;
issuance_fact: MarketplaceOrderIssuanceFactSnapshot;
issue_act_signiss1_document: ISignedDocumentDomainInterface;
}
): Promise<MarketplaceOrderDomainEntity> {
@@ -288,6 +289,7 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
chairman_account: patch.chairman_account,
signiss1_tx_hash: patch.signiss1_tx_hash,
current_warehouse_braname: patch.current_warehouse_braname,
issuance_fact: patch.issuance_fact,
issue_act_signiss1_document: patch.issue_act_signiss1_document,
} as Record<string, unknown>
);
+17 -25
View File
@@ -6679,13 +6679,9 @@ export type ValueTypes = {
['...on MarketplaceExpressPickupCandidate']?: Omit<ValueTypes["MarketplaceExpressPickupCandidate"], "...on MarketplaceExpressPickupCandidate">
}>;
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number | Variable<any, string>,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string | Variable<any, string>,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: ValueTypes["ID"] | Variable<any, string>,
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: ValueTypes["MarketplaceIssueActSignedDocumentInput"] | Variable<any, string>
};
/** Параметры запроса одного заказа. */
@@ -7082,6 +7078,8 @@ export type ValueTypes = {
['...on MarketplaceOnboardingState']?: Omit<ValueTypes["MarketplaceOnboardingState"], "...on MarketplaceOnboardingState">
}>;
["MarketplaceOpenIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number | Variable<any, string>,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: ValueTypes["ID"] | Variable<any, string>,
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
@@ -16761,13 +16759,9 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: ResolverInputTypes["ID"],
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: ResolverInputTypes["MarketplaceIssueActSignedDocumentInput"]
};
/** Параметры запроса одного заказа. */
@@ -17153,6 +17147,8 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceOpenIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: ResolverInputTypes["ID"],
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
@@ -26517,13 +26513,9 @@ export type ModelTypes = {
total_units: number
};
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: ModelTypes["ID"],
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: ModelTypes["MarketplaceIssueActSignedDocumentInput"]
};
/** Параметры запроса одного заказа. */
@@ -26897,6 +26889,8 @@ export type ModelTypes = {
template_registry_id: number
};
["MarketplaceOpenIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: ModelTypes["ID"],
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
@@ -37189,13 +37183,9 @@ export type GraphQLTypes = {
['...on MarketplaceExpressPickupCandidate']: Omit<GraphQLTypes["MarketplaceExpressPickupCandidate"], "...on MarketplaceExpressPickupCandidate">
};
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: GraphQLTypes["ID"],
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"]
};
/** Параметры запроса одного заказа. */
@@ -37592,7 +37582,9 @@ export type GraphQLTypes = {
['...on MarketplaceOnboardingState']: Omit<GraphQLTypes["MarketplaceOnboardingState"], "...on MarketplaceOnboardingState">
};
["MarketplaceOpenIssuanceInput"]: {
/** Идентификатор заказа, выдачу которого открываем. */
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: GraphQLTypes["ID"],
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
signed_document: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"]
@@ -65,10 +65,11 @@ export async function listMyReadyToReceive(): Promise<MarketplaceOrderIssuanceVi
export async function getChairmanSignablePayload(
order_id: string,
actual_quantity?: number,
): Promise<MarketplaceGeneratedDocumentView> {
const { [Queries.Marketplace.IssueActChairmanSignablePayload.name]: result } =
await client.Query(Queries.Marketplace.IssueActChairmanSignablePayload.query, {
variables: { data: { order_id } },
variables: { data: { order_id, actual_quantity } },
});
return result as MarketplaceGeneratedDocumentView;
}
@@ -86,28 +87,29 @@ export async function getOrdererSignablePayload(
export async function openIssuance(
order_id: string,
actual_quantity: number,
signed_document: SignedDocumentInput,
): Promise<MarketplaceIssuanceResultView> {
const { [Mutations.Marketplace.OpenIssuance.name]: result } = await client.Mutation(
Mutations.Marketplace.OpenIssuance.mutation,
{ variables: { data: { order_id, signed_document } } },
{ variables: { data: { order_id, actual_quantity, signed_document } } },
);
return result;
}
/**
* Финальную подпись заказчик ставит сам в своём кабинете своим ключом — он
* лишь подтверждает уже сформированный акт. Фактическое количество и сторона
* кооператива берутся backend'ом из заказа (зафиксированы оператором при
* открытии), поэтому здесь передаём только подписанный документ.
*/
export async function finalizeIssuance(
order_id: string,
actual_quantity: number,
delivery_signer: string,
signed_document: SignedDocumentInput,
): Promise<MarketplaceIssuanceResultView> {
const { [Mutations.Marketplace.FinalizeIssuance.name]: result } = await client.Mutation(
Mutations.Marketplace.FinalizeIssuance.mutation,
{
variables: {
data: { order_id, actual_quantity, delivery_signer, signed_document },
},
},
{ variables: { data: { order_id, signed_document } } },
);
return result;
}
@@ -1,277 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Classes } from '@coopenomics/sdk';
import { PrivateKey } from '@wharfkit/session';
import { useGlobalStore } from 'src/shared/store';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import { BarcodeScanner } from 'src/widgets/Marketplace/BarcodeScanner';
import { CorrectionTable, type CorrectionRow } from 'src/widgets/Marketplace/CorrectionTable';
import {
finalizeIssuance,
getOrdererSignablePayload,
type MarketplaceOrderIssuanceView,
} from '../api';
/**
* Story 6.3 / FR23-FR25: full-screen takeover для финальной подписи
* АПП-выдачи на ПВЗ.
*
* Поток:
* 1. Оператор сканирует штрих-код заказа (BarcodeScanner) — verification,
* что выдаётся правильный заказ.
* 2. CorrectionTable показывает «план vs факт» — оператор корректирует
* `actual_quantity` (равно / меньше / больше); UI считает
* `fact_cost = actual_quantity × unit_price` и `diff_state`.
* 3. Backend отдаёт акт, уже подписанный председателем при открытии
* выдачи (signatureId=1, on-chain signiss1) — в виде агрегата
* (rawDocument + document с подписью председателя).
* 4. Заказчик вводит свой приватный ключ и накладывает финальную
* подпись (signatureId=2) поверх подписи председателя; документ не
* перегенерируется. `delivery_signer` = председатель, открывший
* выдачу (order.chairman_account).
* 5. Подписанный документ уходит mutation `marketplaceFinalizeIssuance`
* с указанием `actual_quantity` и `delivery_signer` — C++ контракт
* исполняет три ветки сверки в одной композитной транзакции
* `signiss2`: при `actual < ordered` срабатывает `o.mkt.unblk`,
* при `actual > ordered` — серия `o.wal.conv + o.mkt.assign +
* o.mkt.block` на разницу (FR23). Если средств не хватает на
* разницу — транзакция фейлится с человеческим сообщением (FR25 /
* L6 guard), backend пробрасывает его клиенту.
*/
const props = defineProps<{
modelValue: boolean;
order: MarketplaceOrderIssuanceView | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'finalized'): void;
}>();
const globalStore = useGlobalStore();
const STEP_SCAN = 'scan' as const;
const STEP_FACT = 'fact' as const;
const STEP_SIGN = 'sign' as const;
type Step = typeof STEP_SCAN | typeof STEP_FACT | typeof STEP_SIGN;
const step = ref<Step>(STEP_SCAN);
const scannedCode = ref<string>('');
const actualQuantity = ref<number>(0);
const ordererWif = ref<string>('');
const signing = ref(false);
const factCost = computed<string>(() => {
if (!props.order) return '0';
const unit = Number.parseFloat(props.order.price_per_unit);
return (actualQuantity.value * unit).toFixed(4);
});
const diffState = computed<'equal' | 'less' | 'more'>(() => {
if (!props.order) return 'equal';
if (actualQuantity.value === props.order.quantity) return 'equal';
if (actualQuantity.value < props.order.quantity) return 'less';
return 'more';
});
const diffHint = computed<string>(() => {
if (!props.order) return '';
if (diffState.value === 'equal') return 'Факт совпадает с заказом — корректирующие операции не нужны.';
if (diffState.value === 'less')
return `Выдаётся меньше заказа — разница ${props.order.quantity - actualQuantity.value} ед. вернётся на доступный паевой пайщика операцией o.mkt.unblk.`;
return `Выдаётся больше заказа — разница ${actualQuantity.value - props.order.quantity} ед. потребует доплаты с паевого (o.wal.conv + o.mkt.assign + o.mkt.block).`;
});
const correctionRows = computed<CorrectionRow[]>(() => {
if (!props.order) return [];
return [
{
sku: props.order.id.slice(0, 8),
title: `Заказ ${props.order.id.slice(0, 8)}`,
unit: 'ед.',
expected: props.order.quantity,
fact: actualQuantity.value,
},
];
});
watch(
() => [props.modelValue, props.order?.id],
([visible]) => {
if (visible && props.order) {
step.value = STEP_SCAN;
scannedCode.value = '';
actualQuantity.value = props.order.quantity;
ordererWif.value = '';
}
},
{ immediate: false },
);
function onScanned(code: string): void {
scannedCode.value = code;
step.value = STEP_FACT;
}
function onCorrectionChange(payload: { sku: string; fact: number }): void {
actualQuantity.value = Math.max(0, payload.fact);
}
function goToSign(): void {
if (actualQuantity.value <= 0) {
FailAlert(new Error('Фактическое количество должно быть больше нуля.'));
return;
}
step.value = STEP_SIGN;
}
function backToFact(): void {
step.value = STEP_FACT;
}
async function confirm(): Promise<void> {
if (!props.order) return;
if (step.value !== STEP_SIGN) {
if (step.value === STEP_SCAN) {
FailAlert(new Error('Сначала отсканируйте штрих-код заказа.'));
} else {
goToSign();
}
return;
}
const operatorWif = globalStore.wif?.toString();
if (!operatorWif) {
FailAlert(new Error('Приватный ключ оператора не найден. Войдите в кооператив.'));
return;
}
if (!ordererWif.value.trim()) {
FailAlert(new Error('Заказчик должен предоставить свой приватный ключ для подписи акта.'));
return;
}
const deliverySigner = props.order.chairman_account;
if (!deliverySigner) {
FailAlert(new Error('Не найден председатель, открывший выдачу. Сначала откройте выдачу первой подписью.'));
return;
}
signing.value = true;
try {
// Валидация приватного ключа заказчика — ранний фейл до запроса акта,
// если введён мусор.
try {
PrivateKey.from(ordererWif.value.trim());
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Некорректный приватный ключ заказчика: ${message}`);
}
// Backend отдаёт акт, уже подписанный председателем при открытии выдачи
// (signatureId=1, on-chain signiss1). Заказчик накладывает финальную
// подпись (signatureId=2) поверх — документ не перегенерируется.
const aggregate = await getOrdererSignablePayload(props.order.id);
const ordererSigner = new Classes.Document(ordererWif.value.trim());
const fullSigned = await ordererSigner.signDocument(
aggregate.rawDocument,
props.order.orderer_account,
2,
[aggregate.document],
);
await finalizeIssuance(
props.order.id,
actualQuantity.value,
deliverySigner,
fullSigned,
);
SuccessAlert('Заказ выдан. Статус заказа — RECEIVED.');
emit('finalized');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось завершить выдачу');
} finally {
signing.value = false;
}
}
function cancel(): void {
emit('update:modelValue', false);
}
const confirmLabel = computed(() => {
if (step.value === STEP_SCAN) return 'Сначала отсканируйте штрих-код';
if (step.value === STEP_FACT) return 'Перейти к подписи';
return 'Подписать и выдать';
});
const confirmDisabled = computed(
() => signing.value || (step.value === STEP_SIGN && (!ordererWif.value.trim() || actualQuantity.value <= 0)),
);
</script>
<template lang="pug">
TakeoverDialog(
:model-value="modelValue"
title="Финальная подпись акта выдачи"
:lead-text="order ? `Заказ ${order.id.slice(0, 8)} · заказчик ${order.orderer_account}` : ''"
:kind="diffState === 'more' ? 'warning' : 'success'"
:confirm-label="confirmLabel"
cancel-label="Закрыть"
:loading="signing"
:disable-confirm="confirmDisabled"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@confirm="confirm"
@cancel="cancel"
)
template(#default)
.mp-issue-finalize
q-stepper(
:model-value="step"
flat
bordered
animated
active-color="primary"
done-color="positive"
)
q-step(:name="STEP_SCAN" title="Штрих-код" icon="qr_code_scanner" :done="step !== STEP_SCAN")
BarcodeScanner(start-label="Сканировать штрих-код заказа" @scanned="onScanned")
.text-caption.text-grey.q-mt-sm
| Отсканируйте штрих-код заказа камерой или USB-сканером, чтобы сверить его с выдаваемым имуществом.
q-step(:name="STEP_FACT" title="Сверка факта" icon="balance" :done="step === STEP_SIGN")
.q-mb-sm Заказ {{ order?.id.slice(0, 8) }} · план {{ order?.quantity }} ед. · подтверждённый штрих-код «{{ scannedCode }}»
CorrectionTable(:rows="correctionRows" @change="onCorrectionChange")
q-banner.q-mt-md(rounded :class="diffState === 'more' ? 'bg-warning text-dark' : (diffState === 'less' ? 'bg-info text-white' : 'bg-positive text-white')")
| {{ diffHint }}
.q-mt-sm.text-grey.text-caption
| Фактическая стоимость: {{ factCost }} (вычислено как {{ actualQuantity }} × {{ order?.price_per_unit ?? '-' }}).
.row.justify-end.q-mt-md
q-btn(unelevated no-caps color="primary" label="Перейти к подписи" @click="goToSign")
q-step(:name="STEP_SIGN" title="Финальная подпись" icon="draw")
q-banner.q-mb-md(rounded class="bg-primary text-white")
| Председатель уже подписал акт при открытии выдачи. Заказчик ставит финальную подпись своим приватным ключом поверх подписи председателя имущество переходит к нему.
q-input(
v-model="ordererWif"
outlined
dense
type="password"
label="Приватный ключ заказчика (WIF)"
hint="Заказчик вводит свой ключ на устройстве оператора либо сканирует QR. После подписи UI не сохраняет ключ."
)
.row.q-mt-md.q-gutter-md
q-btn(flat no-caps label="Назад к сверке" @click="backToFact")
q-space
</template>
<style scoped lang="scss">
.mp-issue-finalize {
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
}
</style>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { Classes } from '@coopenomics/sdk';
import { useGlobalStore } from 'src/shared/store';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import { CorrectionTable, type CorrectionRow } from 'src/widgets/Marketplace/CorrectionTable';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import {
getChairmanSignablePayload,
openIssuance,
@@ -12,19 +14,21 @@ import {
} from '../api';
/**
* Story 6.1 / FR21: full-screen takeover для первой подписи АПП-выдачи —
* председатель кооперативного участка открывает выдачу заказа пайщику.
* Story 6.1 / FR21: full-screen takeover для открытия выдачи на ПВЗ.
*
* Открытие выдачи делает оператор КУ. Именно здесь фиксируется факт: оператор
* сверяет привезённое имущество с заказом, взвешивает/пересчитывает и
* корректирует фактически выдаваемое количество. Это количество зашивается в
* подписываемый председателем акт и сохраняется на заказе — финальная подпись
* заказчика факт уже не редактирует.
*
* Поток:
* 1. Бэкенд возвращает preview документа (registry_id=1102) — HTML
* отображается в окне; председатель убеждается, что данные акта
* совпадают с заказом.
* 2. Председатель нажимает «Подписать и открыть выдачу» — UI забирает
* приватный ключ из useGlobalStore и подписывает hash акта на месте
* (signatureId=1, signer = текущий пайщик).
* 3. Подписанный документ уходит mutation `marketplaceOpenIssuance` —
* backend верифицирует подпись и отправляет on-chain `signiss1`.
* Заказ получает статус READY_TO_RECEIVE, заказчик — push.
* 1. Оператор корректирует количество в таблице сверки (предзаполнено заказом).
* 2. По кнопке «Показать акт» можно посмотреть сформированный документ.
* 3. «Открыть выдачу» — UI берёт акт на фактическое количество, подписывает
* его ключом председателя текущей сессии (signatureId=1) и отправляет в
* `openIssuance`. Заказ переходит в «Готово к получению», заказчику —
* уведомление; финальную подпись он поставит сам в своём кабинете.
*/
const props = defineProps<{
@@ -39,33 +43,88 @@ const emit = defineEmits<{
const globalStore = useGlobalStore();
const actualQuantity = ref<number>(0);
const previewHtml = ref<string>('');
const previewLoading = ref(false);
const signing = ref(false);
const unitShort = computed(() =>
props.order ? marketplaceUnitShort(props.order.unit_of_measure) : 'ед.',
);
const factCost = computed<string>(() => {
if (!props.order) return '0';
const unit = Number.parseFloat(props.order.price_per_unit);
return (actualQuantity.value * unit).toFixed(4);
});
const diffState = computed<'equal' | 'less' | 'more'>(() => {
if (!props.order) return 'equal';
if (actualQuantity.value === props.order.quantity) return 'equal';
if (actualQuantity.value < props.order.quantity) return 'less';
return 'more';
});
const diffHint = computed<string>(() => {
if (!props.order) return '';
if (diffState.value === 'equal') return 'Факт совпадает с заказом.';
if (diffState.value === 'less')
return `Выдаётся меньше заказа на ${props.order.quantity - actualQuantity.value} ${unitShort.value} — разница вернётся заказчику.`;
return `Выдаётся больше заказа на ${actualQuantity.value - props.order.quantity} ${unitShort.value} — потребуется доплата с паевого заказчика.`;
});
const correctionRows = computed<CorrectionRow[]>(() => {
if (!props.order) return [];
return [
{
sku: props.order.id.slice(0, 8),
title: props.order.product_name || 'Товар по предложению',
unit: unitShort.value,
expected: props.order.quantity,
fact: actualQuantity.value,
},
];
});
watch(
() => [props.modelValue, props.order?.id],
async ([visible]) => {
if (!visible || !props.order) {
([visible]) => {
if (visible && props.order) {
actualQuantity.value = props.order.quantity;
previewHtml.value = '';
return;
}
previewLoading.value = true;
try {
const doc = await getChairmanSignablePayload(props.order.id);
previewHtml.value = doc.html;
} catch (e) {
FailAlert(e, 'Не удалось загрузить предварительный акт выдачи');
emit('update:modelValue', false);
} finally {
previewLoading.value = false;
}
},
{ immediate: false },
);
function onCorrectionChange(payload: { sku: string; fact: number }): void {
actualQuantity.value = Math.max(0, payload.fact);
// Документ зависит от факта — сбрасываем устаревший превью.
previewHtml.value = '';
}
async function loadPreview(): Promise<void> {
if (!props.order || actualQuantity.value <= 0) {
FailAlert(new Error('Сначала укажите фактическое количество больше нуля.'));
return;
}
previewLoading.value = true;
try {
const doc = await getChairmanSignablePayload(props.order.id, actualQuantity.value);
previewHtml.value = doc.html;
} catch (e) {
FailAlert(e, 'Не удалось сформировать акт выдачи');
} finally {
previewLoading.value = false;
}
}
async function confirm(): Promise<void> {
if (!props.order) return;
if (actualQuantity.value <= 0) {
FailAlert(new Error('Фактическое количество должно быть больше нуля.'));
return;
}
const wifKey = globalStore.wif?.toString();
if (!wifKey) {
FailAlert(new Error('Приватный ключ не найден. Войдите в кооператив.'));
@@ -73,11 +132,11 @@ async function confirm(): Promise<void> {
}
signing.value = true;
try {
const generated = await getChairmanSignablePayload(props.order.id);
const generated = await getChairmanSignablePayload(props.order.id, actualQuantity.value);
const docSigner = new Classes.Document(wifKey);
const signed = await docSigner.signDocument(generated, globalStore.username, 1);
await openIssuance(props.order.id, signed);
SuccessAlert('Выдача открыта. Заказчику отправлено уведомление.');
await openIssuance(props.order.id, actualQuantity.value, signed);
SuccessAlert('Выдача открыта. Ждём подпись заказчика — он подтвердит получение в своём кабинете.');
emit('opened');
emit('update:modelValue', false);
} catch (e) {
@@ -96,28 +155,35 @@ function cancel(): void {
TakeoverDialog(
:model-value="modelValue"
title="Открытие выдачи заказа"
:lead-text="order ? `Заказ ${order.id.slice(0, 8)} · ${order.quantity} ед. на сумму ${formatAsset2Digits(order.total_cost)} ₽` : ''"
kind="info"
:lead-text="order ? `Заказ ${order.id.slice(0, 8)} · к выдаче ${formatAsset2Digits(factCost)} ₽` : ''"
:kind="diffState === 'more' ? 'warning' : 'info'"
confirm-label="Подписать и открыть выдачу"
cancel-label="Закрыть"
:loading="signing"
:disable-confirm="!previewHtml || signing"
:disable-confirm="actualQuantity <= 0 || signing"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@confirm="confirm"
@cancel="cancel"
)
template(#default)
.mp-issue-open-dialog
q-card(v-if="previewLoading" flat bordered).q-pa-md
q-spinner(color="primary" size="32px")
.q-ml-md Готовлю акт выдачи
.mp-issue-open-dialog__intro.text-body2.text-grey
| Сверьте привезённое имущество с заказом и укажите фактически выдаваемое количество оно войдёт в акт.
q-card(v-else-if="previewHtml" flat bordered).mp-issue-open-dialog__preview
CorrectionTable(:rows="correctionRows" @change="onCorrectionChange")
q-banner.mp-issue-open-dialog__hint(rounded :class="diffState === 'more' ? 'bg-warning text-dark' : (diffState === 'less' ? 'bg-info text-white' : 'bg-positive text-white')")
| {{ diffHint }}
.mp-issue-open-dialog__sum.text-body2
| К выдаче: {{ formatAsset2Digits(factCost) }} ({{ actualQuantity }} {{ unitShort }})
.mp-issue-open-dialog__preview-actions
q-btn(flat no-caps icon="description" label="Показать акт" :loading="previewLoading" @click="loadPreview")
q-card(v-if="previewHtml" flat bordered).mp-issue-open-dialog__preview
q-card-section.q-pa-md
div(v-html="previewHtml")
q-card(v-else flat bordered).q-pa-md
.text-negative Предварительный документ не сформирован.
</template>
<style scoped lang="scss">
@@ -126,6 +192,15 @@ TakeoverDialog(
flex-direction: column;
gap: var(--p-4, 16px);
&__sum {
font-variant-numeric: tabular-nums;
}
&__preview-actions {
display: flex;
justify-content: flex-start;
}
&__preview {
max-height: 60vh;
overflow: auto;
@@ -16,22 +16,20 @@ import {
type MarketplaceOrderIssuanceView,
} from '../api';
import IssueActOpenDialog from './IssueActOpenDialog.vue';
import IssueActFinalizeDialog from './IssueActFinalizeDialog.vue';
/**
* Story 6.1 / 6.3 / 6.6: operator-стол выдачи имущества пайщику.
*
* Показывает Order'ы на КУ выдачи в статусах ACCEPTED_TO_COOP (ожидают
* открытия первой подписью председателя — `signiss1`) и READY_TO_RECEIVE
* (ожидают финальной подписи заказчика на ПВЗ — `signiss2`).
* открытия выдачи оператором) и READY_TO_RECEIVE (выдача открыта — ждём,
* когда заказчик подтвердит получение в своём кабинете).
*
* - «Открыть выдачу» вызывает IssueActOpenDialog — full-screen takeover
* с превью акта (registry_id=1102) и подписью председателя.
* - «Завершить выдачу» вызывает IssueActFinalizeDialog — BarcodeScanner
* для проверки штрих-кода заказа + CorrectionTable со сверкой
* факт vs заказ + двойная подпись (заказчик + delivery_signer) и
* композитная транзакция `signiss2` с корректирующими операциями
* по FR23-FR25.
* Роль оператора заканчивается на открытии: «Открыть выдачу» вызывает
* IssueActOpenDialog — full-screen takeover, где оператор сверяет факт
* (корректирует количество) и подписывает акт ключом председателя. Дальше
* заказ ждёт финальную подпись заказчика — оператор свободен и может
* обслуживать следующего. Финальную подпись заказчик ставит сам в своём
* кабинете на своём устройстве (стол «Готово к получению»).
*/
// Активный КУ оператора — из общего контекста стола (без ввода кода вручную).
@@ -43,7 +41,6 @@ const items = ref<MarketplaceOrderIssuanceView[]>([]);
const loading = ref(false);
const openDialog = ref(false);
const finalizeDialog = ref(false);
const selectedOrder = ref<MarketplaceOrderIssuanceView | null>(null);
const columns: QTableProps['columns'] = [
@@ -90,11 +87,6 @@ function startOpen(item: MarketplaceOrderIssuanceView): void {
openDialog.value = true;
}
function startFinalize(item: MarketplaceOrderIssuanceView): void {
selectedOrder.value = item;
finalizeDialog.value = true;
}
// QR-код получения (Story 14.4): оператор сканирует account-bound код заказчика
// → резолвим аккаунт против ленты своего КУ и показываем РАЗОМ все его заказы
// на выдачу.
@@ -112,16 +104,15 @@ const pickupOrders = computed(() =>
),
);
// Открыть/завершить выдачу одного заказа. Панель агрегированных заказов
// заказчика остаётся открытой под полноэкранным шагом выдачи — по `load()`
// список пересчитывается, выданный заказ уходит, оператор продолжает следующий.
// Открыть выдачу одного заказа. Панель агрегированных заказов заказчика
// остаётся открытой под полноэкранным шагом открытия — по `load()` список
// пересчитывается, открытый заказ уходит в «ждём заказчика», оператор
// продолжает следующий.
function startIssuanceStep(order: MarketplaceOrderIssuanceView): void {
if (order.status === 'ACCEPTED_TO_COOP') {
startOpen(order);
} else if (order.status === 'READY_TO_RECEIVE') {
startFinalize(order);
} else {
FailAlert(new Error('Заказ не в статусе выдачи.'));
FailAlert(new Error('Выдача уже открыта — ждём подтверждение заказчика.'));
}
}
@@ -151,10 +142,6 @@ function onOpened(): void {
void load();
}
function onFinalized(): void {
void load();
}
watch(braname, () => void load());
onMounted(async () => {
@@ -177,7 +164,7 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
template(v-else)
PageHint(storage-key='mp:operator-issuance:banner-dismissed')
| Заказы, принятые кооперативом на ваш пункт выдачи. Откройте выдачу подписью председателя, затем завершите её на стойке с заказчиком.
| Заказы, принятые кооперативом на ваш пункт выдачи. Сверьте имущество и откройте выдачу подписью председателя дальше заказчик подтвердит получение сам в своём кабинете.
.issuance__toolbar
BaseButton(variant='secondary', @click='scanDialogOpen = true')
@@ -208,15 +195,9 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
template(#icon-left)
q-icon(name='draw', size='16px')
| Открыть выдачу
BaseButton(
v-else-if='props.row.status === "READY_TO_RECEIVE"',
variant='primary',
size='sm',
@click='startFinalize(props.row)'
)
template(#icon-left)
q-icon(name='inventory', size='16px')
| Завершить выдачу
.issuance__await(v-else-if='props.row.status === "READY_TO_RECEIVE"')
q-icon(name='hourglass_empty', size='16px')
| Ждём подпись заказчика
template(#no-data)
EmptyState(
@@ -231,11 +212,6 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
:order='selectedOrder',
@opened='onOpened'
)
IssueActFinalizeDialog(
v-model='finalizeDialog',
:order='selectedOrder',
@finalized='onFinalized'
)
BaseDialog(v-model='scanDialogOpen', title='Сканирование QR заказа', size='sm')
QrScanner(@scanned='onQrScanned')
@@ -261,15 +237,9 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
template(#icon-left)
q-icon(name='draw', size='16px')
| Открыть
BaseButton(
v-else-if='o.status === "READY_TO_RECEIVE"',
variant='primary',
size='sm',
@click='startIssuanceStep(o)'
)
template(#icon-left)
q-icon(name='inventory', size='16px')
| Завершить
.issuance__await(v-else-if='o.status === "READY_TO_RECEIVE"')
q-icon(name='hourglass_empty', size='16px')
| Ждём заказчика
</template>
<style scoped lang="scss">
@@ -327,6 +297,15 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
color: var(--p-ink-2);
font-variant-numeric: tabular-nums;
}
&__await {
display: inline-flex;
align-items: center;
gap: var(--p-1, 4px);
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
white-space: nowrap;
}
}
@media (max-width: 768px) {
@@ -4,9 +4,16 @@ import { client } from 'src/shared/api/client';
export type {
MarketplaceOrderIssuanceView,
MarketplaceOrderIssuanceFactView,
MarketplaceIssuanceAggregateView,
MarketplaceIssuanceResultView,
SignedDocumentInput,
} from '../../OperatorIssuance/api';
import type { MarketplaceOrderIssuanceView } from '../../OperatorIssuance/api';
// Финальную подпись заказчик ставит сам в своём кабинете: переиспользуем
// запрос акта (агрегат с подписью председателя) и mutation финализации.
export { getOrdererSignablePayload, finalizeIssuance } from '../../OperatorIssuance/api';
export async function listMyReadyToReceive(): Promise<MarketplaceOrderIssuanceView[]> {
const { [Queries.Marketplace.ListMyReadyToReceive.name]: result } = await client.Query(
Queries.Marketplace.ListMyReadyToReceive.query,
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Classes } from '@coopenomics/sdk';
import { useGlobalStore } from 'src/shared/store';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { BaseButton, BaseDialog } from 'src/shared/ui/base';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import {
getOrdererSignablePayload,
finalizeIssuance,
type MarketplaceOrderIssuanceView,
} from '../api';
/**
* Story 6.3: финальная подпись заказчика — подтверждение получения имущества.
*
* Заказчик подтверждает получение сам в своём кабинете на своём устройстве
* своим ключом. Он не редактирует факт: количество и стоимость уже
* зафиксированы оператором при открытии выдачи и показаны в таблице только
* для ознакомления. Заказчик может посмотреть акт и поставить финальную
* подпись (signatureId=2) поверх подписи председателя — имущество переходит
* к нему, backend исполняет корректирующие операции (`signiss2`).
*/
const props = defineProps<{
modelValue: boolean;
order: MarketplaceOrderIssuanceView | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'finalized'): void;
}>();
const globalStore = useGlobalStore();
const signing = ref(false);
const previewHtml = ref<string>('');
const previewLoading = ref(false);
const unitShort = computed(() =>
props.order ? marketplaceUnitShort(props.order.unit_of_measure) : 'ед.',
);
// Факт зафиксирован оператором при открытии выдачи — берём из заказа.
const factQuantity = computed<number>(() => props.order?.issuance_fact?.actual_quantity ?? props.order?.quantity ?? 0);
const factCost = computed<string>(() => props.order?.issuance_fact?.fact_cost ?? props.order?.total_cost ?? '0');
const diffState = computed<string>(() => props.order?.issuance_fact?.diff_state ?? 'equal');
const diffHint = computed<string>(() => {
if (!props.order) return '';
if (diffState.value === 'equal') return 'Выдаётся ровно по заказу.';
if (diffState.value === 'less') return 'Выдаётся меньше заказа — разница вернётся на ваш паевой остаток.';
return 'Выдаётся больше заказа — разница спишется доплатой с вашего паевого остатка.';
});
watch(
() => [props.modelValue, props.order?.id],
([visible]) => {
if (visible) previewHtml.value = '';
},
{ immediate: false },
);
async function loadPreview(): Promise<void> {
if (!props.order) return;
previewLoading.value = true;
try {
const aggregate = await getOrdererSignablePayload(props.order.id);
previewHtml.value = aggregate.rawDocument.html;
} catch (e) {
FailAlert(e, 'Не удалось загрузить акт выдачи');
} finally {
previewLoading.value = false;
}
}
async function confirm(): Promise<void> {
if (!props.order) return;
const wif = globalStore.wif?.toString();
if (!wif) {
FailAlert(new Error('Приватный ключ не найден. Войдите в кооператив.'));
return;
}
signing.value = true;
try {
// Backend отдаёт акт, уже подписанный председателем при открытии выдачи
// (signatureId=1). Заказчик накладывает финальную подпись (signatureId=2)
// поверх — документ не перегенерируется.
const aggregate = await getOrdererSignablePayload(props.order.id);
const signer = new Classes.Document(wif);
const fullSigned = await signer.signDocument(
aggregate.rawDocument,
globalStore.username,
2,
[aggregate.document],
);
await finalizeIssuance(props.order.id, fullSigned);
SuccessAlert('Имущество получено. Заказ закрыт.');
emit('finalized');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось подтвердить получение');
} finally {
signing.value = false;
}
}
function cancel(): void {
emit('update:modelValue', false);
}
</script>
<template lang="pug">
BaseDialog(
:model-value="modelValue"
title="Подтверждение получения"
maximized
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
)
.mp-orderer-finalize(v-if="order")
.text-caption.text-grey
| Пункт выдачи: {{ [order.delivery_point_name, order.delivery_point_address].filter(Boolean).join(' · ') || order.delivery_braname }}
.mp-orderer-finalize__title.text-subtitle1
| {{ order.product_name || 'Товар по предложению' }}
table.mp-orderer-finalize__table
thead
tr
th
th.text-right Заказ
th.text-right К получению
tbody
tr
td Количество
td.text-right {{ order.quantity }} {{ unitShort }}
td.text-right {{ factQuantity }} {{ unitShort }}
tr
td Сумма
td.text-right {{ formatAsset2Digits(order.total_cost) }}
td.text-right {{ formatAsset2Digits(factCost) }}
q-banner.mp-orderer-finalize__hint(rounded :class="diffState === 'more' ? 'bg-warning text-dark' : (diffState === 'less' ? 'bg-info text-white' : 'bg-positive text-white')")
| {{ diffHint }}
.mp-orderer-finalize__preview-actions
q-btn(flat no-caps icon="description" label="Показать акт" :loading="previewLoading" @click="loadPreview")
q-card(v-if="previewHtml" flat bordered).mp-orderer-finalize__preview
q-card-section.q-pa-md
div(v-html="previewHtml")
template(#footer)
BaseButton(variant="ghost", :disabled="signing", @click="cancel") Отмена
BaseButton(variant="primary", :loading="signing", @click="confirm")
template(#icon-left)
q-icon(name="draw", size="16px")
| Подписать и получить
</template>
<style scoped lang="scss">
.mp-orderer-finalize {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
&__table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
th,
td {
padding: var(--p-2, 8px);
border-bottom: 1px solid var(--p-line);
}
th {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
font-weight: 600;
}
}
&__preview-actions {
display: flex;
justify-content: flex-start;
}
&__preview {
max-height: 60vh;
overflow: auto;
}
}
</style>
@@ -12,6 +12,7 @@ import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { encodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace';
import { listMyReadyToReceive, type MarketplaceOrderIssuanceView } from '../api';
import OrdererFinalizeIssuanceDialog from './OrdererFinalizeIssuanceDialog.vue';
/**
* Story 6.3 / FR22: orderer-стол «Готово к получению».
@@ -33,6 +34,19 @@ const coopname = computed(() => String(route.params.coopname ?? ''));
const items = ref<MarketplaceOrderIssuanceView[]>([]);
const loading = ref(false);
// Финальная подпись заказчика — в своём кабинете на своём устройстве.
const finalizeDialogOpen = ref(false);
const selectedOrder = ref<MarketplaceOrderIssuanceView | null>(null);
function startFinalize(order: MarketplaceOrderIssuanceView): void {
selectedOrder.value = order;
finalizeDialogOpen.value = true;
}
function onFinalized(): void {
void load();
}
// Story 14.4: один account-bound код получения. Заказчик показывает его
// оператору выдачи — тот резолвит аккаунт против ленты своего КУ и видит разом
// все готовые к выдаче заказы этого заказчика. Код привязан к личности, не к
@@ -90,6 +104,7 @@ const columns: QTableProps['columns'] = [
align: 'left',
format: (v: unknown) => formatDate(v),
},
{ name: 'actions', label: '', field: 'id', align: 'right' },
];
async function load(): Promise<void> {
@@ -117,7 +132,7 @@ q-page.ready(role="region", aria-label="Готово к получению")
RefreshButton(:loading="loading", @refresh="load")
PageHint(storage-key="mp:ready-to-receive:banner-dismissed")
| Оператор открыл выдачу этих заказов на пункте. Приходите на участок для сверки имущества и финальной подписи.
| Оператор открыл выдачу этих заказов на пункте. Получите имущество на участке и подтвердите получение подписью здесь на своём устройстве, своим ключом.
q-table(
:rows="items",
@@ -127,6 +142,13 @@ q-page.ready(role="region", aria-label="Готово к получению")
bordered,
:loading="loading"
)
template(#body-cell-actions="props")
q-td(:props="props")
BaseButton(variant="primary", size="sm", @click="startFinalize(props.row)")
template(#icon-left)
q-icon(name="draw", size="16px")
| Подписать и получить
template(#no-data)
EmptyState(
title="Нет заказов, готовых к получению",
@@ -135,6 +157,12 @@ q-page.ready(role="region", aria-label="Готово к получению")
template(#icon)
q-icon(name="inventory_2", size="48px")
OrdererFinalizeIssuanceDialog(
v-model="finalizeDialogOpen",
:order="selectedOrder",
@finalized="onFinalized"
)
BaseDialog(v-model="myCodeDialogOpen", title="Мой код получения", size="sm")
.ready__qr(v-if="myReceiveCode")
HandoffQr(
+17 -25
View File
@@ -6679,13 +6679,9 @@ export type ValueTypes = {
['...on MarketplaceExpressPickupCandidate']?: Omit<ValueTypes["MarketplaceExpressPickupCandidate"], "...on MarketplaceExpressPickupCandidate">
}>;
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number | Variable<any, string>,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string | Variable<any, string>,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: ValueTypes["ID"] | Variable<any, string>,
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: ValueTypes["MarketplaceIssueActSignedDocumentInput"] | Variable<any, string>
};
/** Параметры запроса одного заказа. */
@@ -7082,6 +7078,8 @@ export type ValueTypes = {
['...on MarketplaceOnboardingState']?: Omit<ValueTypes["MarketplaceOnboardingState"], "...on MarketplaceOnboardingState">
}>;
["MarketplaceOpenIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number | Variable<any, string>,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: ValueTypes["ID"] | Variable<any, string>,
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
@@ -16761,13 +16759,9 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: ResolverInputTypes["ID"],
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: ResolverInputTypes["MarketplaceIssueActSignedDocumentInput"]
};
/** Параметры запроса одного заказа. */
@@ -17153,6 +17147,8 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceOpenIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: ResolverInputTypes["ID"],
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
@@ -26517,13 +26513,9 @@ export type ModelTypes = {
total_units: number
};
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: ModelTypes["ID"],
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: ModelTypes["MarketplaceIssueActSignedDocumentInput"]
};
/** Параметры запроса одного заказа. */
@@ -26897,6 +26889,8 @@ export type ModelTypes = {
template_registry_id: number
};
["MarketplaceOpenIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: ModelTypes["ID"],
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
@@ -37189,13 +37183,9 @@ export type GraphQLTypes = {
['...on MarketplaceExpressPickupCandidate']: Omit<GraphQLTypes["MarketplaceExpressPickupCandidate"], "...on MarketplaceExpressPickupCandidate">
};
["MarketplaceFinalizeIssuanceInput"]: {
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Учётная запись стороны кооператива, подписывающей акт вместе с заказчиком. */
delivery_signer: string,
/** Идентификатор заказа, который выдаём пайщику. */
/** Идентификатор заказа, который получаем. */
order_id: GraphQLTypes["ID"],
/** Подписанный заказчиком и стороной кооператива акт выдачи. Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
/** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */
signed_document: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"]
};
/** Параметры запроса одного заказа. */
@@ -37592,7 +37582,9 @@ export type GraphQLTypes = {
['...on MarketplaceOnboardingState']: Omit<GraphQLTypes["MarketplaceOnboardingState"], "...on MarketplaceOnboardingState">
};
["MarketplaceOpenIssuanceInput"]: {
/** Идентификатор заказа, выдачу которого открываем. */
/** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */
actual_quantity: number,
/** Идентификатор заказа, выдачу которого открываем. */
order_id: GraphQLTypes["ID"],
/** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */
signed_document: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"]