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:
+2
-2
@@ -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,
|
||||
|
||||
+52
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+75
-20
@@ -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, 'Заявление не подписано');
|
||||
|
||||
Reference in New Issue
Block a user