Merge pull request 'Эпик 8 ревью: tx_hash трекинг + fail-fast + cron-фиксы' (#9) from chore/review-E8-spisanie-fixes into marketplace2

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-05-20 17:03:03 +00:00
3 changed files with 129 additions and 34 deletions
@@ -1,4 +1,4 @@
import { Injectable, UseGuards } from '@nestjs/common';
import { BadRequestException, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Cooperative } from 'cooptypes';
import config from '~/config/config';
@@ -178,7 +178,7 @@ export class MarketplaceWriteoffResolver {
): Promise<GeneratedDocumentDTO> {
const draft = await this.service.getProposal(data.draft_id);
if (!draft.is_draft) {
throw new Error('Подписать Заявление можно только для черновика (DRAFT)');
throw new BadRequestException('Подписать Заявление можно только для черновика (DRAFT)');
}
const proposalHash = this.service.computeProposalHash({
coopname: draft.coopname,
@@ -81,6 +81,11 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
return;
}
// Сейчас крон-сборка покрывает ОДИН тип кандидата из AC Story 8.1
// (expiry_date истекает). Три других категории (RETURNED_TO_WAREHOUSE,
// EXCESS_RETURNED_TO_WAREHOUSE, items «без юр. оформления» старше
// threshold writeoff_returned_age_days) — Phase 2: требуется
// расширение marketplace_inventory.status + поля returned_at / age_days.
const horizon = new Date(Date.now() + graceDays * 86_400_000);
const candidates = await this.inventoryRepo.find({
where: {
@@ -99,18 +104,39 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
return;
}
const items: Parameters<typeof this.writeoffService.createDraft>[0]['items'] = candidates.map(
(inv) => ({
braname: inv.braname,
asset_title: inv.product_name_snapshot,
quantity: String(inv.quantity_per_label),
// Stub: до Phase 2 нет attached unit cost — крон ставит 0 placeholder,
// председатель руками поправит при ревью drafts'а. Это даёт UX —
// позиции уже видны, остаётся только проставить суммы.
amount: (0).toFixed(this.assetConfig.decimals),
reason: this.deriveReason(inv.expiry_date, horizon),
inventory_id: inv.id,
})
// Фильтруем позиции без unit_cost — без суммы validateAndNormalizeItems
// отобьёт весь draft. До появления attached unit_cost (Phase 2) такие
// позиции крон не включает; председатель добавит их вручную.
const itemsWithCost = candidates.filter((inv) => {
const cost = this.resolveUnitCost(inv);
return cost !== null && cost > 0;
});
if (itemsWithCost.length === 0) {
this.logger.info(
`[WRITEOFF_CRON] найдено ${candidates.length} кандидатов, но ни у одного нет unit_cost — DRAFT не создан, председателю отправлено напоминание (coopname=${coopname})`
);
this.eventBus.emit('marketplace.writeoff.cron.reminder', {
coopname,
fired_at: new Date().toISOString(),
candidates_count: candidates.length,
});
return;
}
const items: Parameters<typeof this.writeoffService.createDraft>[0]['items'] = itemsWithCost.map(
(inv) => {
const unitCost = this.resolveUnitCost(inv) as number;
const total = unitCost * Number(inv.quantity_per_label);
return {
braname: inv.braname,
asset_title: inv.product_name_snapshot,
quantity: String(inv.quantity_per_label),
amount: total.toFixed(this.assetConfig.decimals),
reason: this.deriveReason(inv.expiry_date, horizon),
inventory_id: inv.id,
};
}
);
const created = await this.writeoffService.createDraft({
@@ -140,4 +166,18 @@ export class MarketplaceWriteoffCronService implements OnModuleInit {
return 'Срок годности истекает в ближайшее время';
return 'Скоропорт';
}
private resolveUnitCost(inv: MarketplaceInventoryEntity): number | null {
const candidates: Array<string | number | null | undefined> = [
(inv as unknown as { unit_cost?: string | number }).unit_cost,
(inv as unknown as { unit_price?: string | number }).unit_price,
(inv as unknown as { price_per_unit?: string | number }).price_per_unit,
];
for (const v of candidates) {
if (v === null || v === undefined) continue;
const n = typeof v === 'number' ? v : Number(v);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
}
@@ -238,8 +238,17 @@ export class MarketplaceWriteoffService {
);
}
// Pre-gate: защита от двойного клика — если другой проект с тем же hash уже
// успешно отправлен в совет, повторный submit перезапишет on-chain состояние.
const alreadySubmitted = await this.repo.findByHash(draft.coopname, proposalHash);
if (alreadySubmitted && alreadySubmitted.id !== draft.id) {
throw new ConflictException(
`Проект с этим расчётом уже отправлен в совет (id=${alreadySubmitted.id}, статус=${alreadySubmitted.status}).`
);
}
// 1. on-chain propwroff: фиксируем wroffprops::proposed
await this.chainPort.propWroff({
const propTx = await this.chainPort.propWroff({
coopname: draft.coopname,
proposed_by: input.chairman_account,
proposal_hash: proposalHash,
@@ -253,7 +262,7 @@ export class MarketplaceWriteoffService {
});
// 2. createagenda: ставим повестку совета с callback'ами marketplace
await this.chainPort.createWriteoffAgenda({
const agendaTx = await this.chainPort.createWriteoffAgenda({
coopname: draft.coopname,
username: input.chairman_account,
proposal_hash: proposalHash,
@@ -266,6 +275,14 @@ export class MarketplaceWriteoffService {
}),
});
const propTxHash = this.extractTxHash(propTx);
const agendaTxHash = this.extractTxHash(agendaTx);
if (!propTxHash || !agendaTxHash) {
throw new ConflictException(
'Подача проекта в совет: цепь не вернула tx_hash (propwroff/createagenda). Повторите.'
);
}
// 3. PG: DRAFT → ON_AGENDA (decision_id заполнит реактор-наблюдатель за soviet.decisions)
const submitted = await this.repo.submitToCouncil(draft.id, {
proposal_hash: proposalHash,
@@ -277,7 +294,12 @@ export class MarketplaceWriteoffService {
at: new Date().toISOString(),
actor: input.chairman_account,
action: 'submitted_to_council',
payload: { proposal_hash: proposalHash, items_count: draft.items.length },
payload: {
proposal_hash: proposalHash,
items_count: draft.items.length,
propwroff_tx_hash: propTxHash,
createagenda_tx_hash: agendaTxHash,
},
},
});
@@ -332,8 +354,17 @@ export class MarketplaceWriteoffService {
total_amount: updated.total_amount,
});
// Сразу запускаем исполнение
await this.executeAuthorizedProposal(updated.id, input.authorized_by ?? proposal.proposed_by_account ?? input.coopname);
// Сразу запускаем исполнение. Signer должен быть пользователем
// (chairman), уполномоченным по КУ каждой позиции; coopname как fallback
// запрещён — это аккаунт кооператива, не пайщик, и контракт execwroff
// не сможет проверить is_user_authorized.
const signer = input.authorized_by ?? proposal.proposed_by_account;
if (!signer) {
throw new ConflictException(
`Проект списания ${proposal.id}: после авторизации нет ни authorized_by, ни proposed_by — исполнение не запущено.`
);
}
await this.executeAuthorizedProposal(updated.id, signer);
}
async onCouncilDeclined(input: {
@@ -396,27 +427,17 @@ export class MarketplaceWriteoffService {
for (let i = 0; i < working.items.length; i++) {
const item = working.items[i];
if (item.executed) continue;
let execTxHash: string;
try {
await this.chainPort.execWroff({
const execTx = await this.chainPort.execWroff({
coopname: working.coopname,
signer,
proposal_hash: working.proposal_hash,
item_index: String(i) as MarketContract.Actions.ExecWroff.IExecWroff['item_index'],
});
working = await this.repo.markItemExecuted(id, i, {
at: new Date().toISOString(),
actor: signer,
action: 'item_executed',
payload: { item_index: i, braname: item.braname, amount: item.amount },
});
if (item.inventory_id) {
try {
await this.inventoryRepo.applyStatusTransition(item.inventory_id, 'WRITTEN_OFF');
} catch (e) {
this.logger.warn(
`[WRITEOFF] не удалось перевести inventory ${item.inventory_id} в WRITTEN_OFF: ${(e as Error).message}`
);
}
execTxHash = this.extractTxHash(execTx);
if (!execTxHash) {
throw new Error('on-chain execwroff не вернул tx_hash');
}
} catch (e) {
this.logger.error(
@@ -424,6 +445,24 @@ export class MarketplaceWriteoffService {
);
throw e;
}
working = await this.repo.markItemExecuted(id, i, {
at: new Date().toISOString(),
actor: signer,
action: 'item_executed',
payload: {
item_index: i,
braname: item.braname,
amount: item.amount,
execwroff_tx_hash: execTxHash,
},
});
if (item.inventory_id) {
// Inventory transition обязателен — иначе позиция остаётся
// RETURNED_TO_WAREHOUSE в БД и может повторно попасть в следующий
// cron-цикл. Если update упадёт — re-throw, чтобы видна была проблема
// и proposal остался в EXECUTING для retry.
await this.inventoryRepo.applyStatusTransition(item.inventory_id, 'WRITTEN_OFF');
}
}
const finalized = await this.repo.markFullyExecuted(id, {
@@ -504,6 +543,22 @@ export class MarketplaceWriteoffService {
return value.toFixed(this.assetDecimals);
}
private extractTxHash(tx: unknown): string {
const candidate = tx as
| {
response?: { transaction_id?: string };
resolved?: { transaction?: { id?: string } };
transaction?: { id?: string };
}
| undefined;
return (
candidate?.response?.transaction_id ??
candidate?.resolved?.transaction?.id ??
candidate?.transaction?.id ??
''
);
}
private verifyDocumentSignature(document: ISignedDocumentDomainInterface): void {
const sig = document.signatures?.[0];
if (!sig) throw new HttpApiError(http.BAD_REQUEST, 'Заявление не подписано');