Эпик 7 ревью: ownership, fail-fast tx_hash, limits, cleanup

CRITICAL:
- resolver: добавлена ownership-проверка `:own`/`:own-KU` для
  marketplaceReturnClaim и marketplaceListReturnClaimsByBraname —
  matrix capability guard'ом не закрывает data-уровень (matrix явно
  делегирует ownership resolver'у).
- service: extractTxHash → fail-fast ConflictException вместо записи
  фейкового tx_hash `<action>-<claim.id>` в БД при пустом ответе цепи
  (5 мест: submretrn/aprretrem/rejretrem/accretrn/rejretrn). Аналог
  фикса PR #7 для signiss1/signiss2.
- service: reason_text лимит 500 → 2000 (соответствие AC Story 7.1).
- service: warranty_until === null → fail-closed (ConflictException
  «гарантия не предусмотрена»). Раньше null читался как «без проверки»,
  возврат принимался даже на заказы без гарантии.
- frontend SubmitReturnClaimDialog: payload-документ генерируется один
  раз вместе с preview (с reason_text + defect_category), подписывается
  именно показанный snapshot — устраняет hash-mismatch preview ≠ signed.

HIGH:
- service: assertBranameMatchesClaim — backend проверяет совпадение
  input.braname с claim.delivery_braname перед on-chain action.
  Раньше председатель КУ-X мог одобрить заявление с delivery=КУ-Y
  (контракт проверял только is_user_authorized signer для braname,
  не cross-link с return_request).
- service: удалён костыль barcode-валидации
  `.includes(order_id.slice(0,8))` — UUID Order'а никогда не пересекается
  с EAN-13 inventory; проверка ложно отбивала валидные сканы.
  Полноценная сверка с marketplace_inventory — Эпик 5/9.
- service: validatePhotoPayloads — размер фото (10 МБ) валидируется
  в service до bucket.put, не полагаясь только на @UseBucket maxBytes.
- service: cleanupBucketPhotos — orphan-фото удаляются из bucket'а
  при провале on-chain submit'а (submretrn/accretrn/rejretrn).
- adapter listByDeliveryBraname: default без status возвращает ВСЕ
  заявления (включая архив). Раньше default был ACTIVE_STATUSES — это
  делало архивную секцию в operator-столе всегда пустой.

Frontend:
- maxlength 500 → 2000 для reason_text в q-input.

Known limitations (Phase 2):
- chairman_account lookup через `coop_ku.chairman_account` (сейчас
  fallback `chairmanAccount = delivery_braname` в notifications +
  resolver — ownership `:own-KU` приближение).
- Загрузка фото base64-payload одной mutation (AC требует per-file
  upload в bucket → image_registry_id). Реальный лимит body-parser
  упрётся в ~10MB.
- C++ submretrn не сохраняет delivery_braname в return_request →
  cross-branch decision разрешён by design контракта (backend закрывает
  ту же дыру service-level проверкой).
- BarcodeScanner в OnSiteDecisionDialog — mock-режим (UX-DR26 Phase 2).
- BarcodeScanner backend-сверка с marketplace_inventory — Эпик 5/9.
- Уведомление председателю шлётся на delivery_braname account
  (TODO в notification.service.ts) — Phase 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-20 15:40:43 +00:00
parent 9435942b3d
commit 0f20dc0eb7
6 changed files with 163 additions and 42 deletions
@@ -1,4 +1,4 @@
import { Injectable, UseGuards } from '@nestjs/common';
import { ForbiddenException, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
@@ -206,8 +206,18 @@ export class MarketplaceReturnClaimResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'read:own-KU')
async marketplaceListReturnClaimsByBraname(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListReturnClaimsByBranameInputDTO
): Promise<MarketplaceReturnClaimDTO[]> {
// Ownership `:own-KU` — MVP-приближение: член-operator является
// chairman'ом braname если его username совпадает с account КУ
// (соответствует текущему fallback'у marketplace-notification.service.ts).
// Полная интеграция с `coop_ku.chairman_account` — Phase 2.
if (member.username !== data.delivery_braname) {
throw new ForbiddenException(
'Чтение заявлений возможно только для участка, председателем которого вы являетесь.'
);
}
const claims = await this.service.listByDeliveryBraname(
config.coopname,
data.delivery_braname
@@ -221,8 +231,20 @@ export class MarketplaceReturnClaimResolver {
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('ReturnClaim', 'read:own')
async marketplaceReturnClaim(@Args('claim_id') claim_id: string): Promise<MarketplaceReturnClaimDTO> {
async marketplaceReturnClaim(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('claim_id') claim_id: string
): Promise<MarketplaceReturnClaimDTO> {
const claim = await this.service.findById(config.coopname, claim_id);
// Ownership-проверка `:own` — matrix capability проверена guard'ом,
// здесь верифицируем что пайщик действительно владелец заявления
// (orderer Order'а либо председатель КУ доставки этого заявления).
const isOwnerOrderer = claim.orderer_account === member.username;
const isChairmanOfDeliveryKu = member.marketplace_roles.includes('operator')
&& claim.delivery_braname === member.username;
if (!isOwnerOrderer && !isChairmanOfDeliveryKu) {
throw new ForbiddenException('Это чужое заявление на возврат.');
}
return this.toClaimDTO(claim);
}
@@ -88,6 +88,14 @@ export class MarketplaceReturnClaimImagesService {
return this.bucket.getReadUrl(bucketKey);
}
/**
* Удаляет orphaned-объект из bucket'а — вызывается из сервиса при провале
* on-chain submit'а, чтобы не оставлять mёртвые файлы в `stol-zakazov:images`.
*/
async deletePhoto(bucketKey: string): Promise<void> {
await this.bucket.delete(bucketKey);
}
private extFromMime(mime: string): string {
switch (mime) {
case 'image/jpeg':
@@ -218,8 +218,8 @@ export class MarketplaceReturnClaimService {
if (!input.reason_text || input.reason_text.trim().length === 0) {
throw new BadRequestException('Опишите причину возврата.');
}
if (input.reason_text.length > 500) {
throw new BadRequestException('Причина возврата не должна превышать 500 символов.');
if (input.reason_text.length > 2000) {
throw new BadRequestException('Причина возврата не должна превышать 2000 символов.');
}
if (!Array.isArray(input.photos) || input.photos.length === 0) {
throw new BadRequestException('Приложите хотя бы одну фотографию товара.');
@@ -227,6 +227,7 @@ export class MarketplaceReturnClaimService {
if (input.photos.length > 10) {
throw new BadRequestException('Можно приложить не более 10 фотографий.');
}
this.validatePhotoPayloads(input.photos);
const order = await this.loadOrderForReturn(input.coopname, input.order_id, input.orderer_account);
const actual_quantity = this.resolveActualQuantity(order, input.actual_quantity);
@@ -275,14 +276,21 @@ export class MarketplaceReturnClaimService {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Подача заявления на возврат order ${order.id}: on-chain submretrn упал (${message}); заявление не зарегистрировано.`
`Подача заявления на возврат order ${order.id}: on-chain submretrn упал (${message}); заявление не зарегистрировано, фото удалены из bucket.`
);
await this.cleanupBucketPhotos(photos);
throw new ConflictException(
`Подача заявления на возврат не выполнена: ${message}. Повторите попытку.`
);
}
const txHash = this.extractTxHash(tx) || `submretrn-${claimId}`;
const txHash = this.extractTxHash(tx);
if (!txHash) {
await this.cleanupBucketPhotos(photos);
throw new ConflictException(
'Подача заявления на возврат: цепь не вернула tx_hash — заявление не зарегистрировано, попробуйте ещё раз.'
);
}
const claim = await this.claimRepo.create({
id: claimId,
coopname: order.coopname,
@@ -333,6 +341,7 @@ export class MarketplaceReturnClaimService {
`Заявление в статусе «${claim.status}», удалённое одобрение недопустимо.`
);
}
this.assertBranameMatchesClaim(claim, input.braname, 'удалённое одобрение');
if (input.signed_decision) this.verifySignatures(input.signed_decision);
@@ -359,7 +368,12 @@ export class MarketplaceReturnClaimService {
);
}
const txHash = this.extractTxHash(tx) || `aprretrem-${claim.id}`;
const txHash = this.extractTxHash(tx);
if (!txHash) {
throw new ConflictException(
'Одобрение очного визита: цепь не вернула tx_hash — статус не меняем, попробуйте ещё раз.'
);
}
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'remote',
decision: 'approve_visit',
@@ -392,6 +406,7 @@ export class MarketplaceReturnClaimService {
`Заявление в статусе «${claim.status}», удалённый отказ недопустим.`
);
}
this.assertBranameMatchesClaim(claim, input.braname, 'удалённый отказ');
if (input.signed_decision) this.verifySignatures(input.signed_decision);
const decisionDoc = input.signed_decision
@@ -418,7 +433,12 @@ export class MarketplaceReturnClaimService {
);
}
const txHash = this.extractTxHash(tx) || `rejretrem-${claim.id}`;
const txHash = this.extractTxHash(tx);
if (!txHash) {
throw new ConflictException(
'Отказ удалённо: цепь не вернула tx_hash — статус не меняем, попробуйте ещё раз.'
);
}
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'remote',
decision: 'reject_remote',
@@ -450,16 +470,10 @@ export class MarketplaceReturnClaimService {
`Заявление в статусе «${claim.status}», приём возврата на месте недопустим.`
);
}
if (input.scanned_barcode) {
// FR32: барскод сверяется с Order. В MVP минимальная сверка — что
// штрих-код принадлежит заказу. Глубокая логика inventory будет
// реализована вместе с canonical Inventory (Эпик 5/9).
if (!input.scanned_barcode.includes(claim.order_id.slice(0, 8))) {
throw new BadRequestException(
'Штрих-код не соответствует заказу. Проверьте имущество.'
);
}
}
this.assertBranameMatchesClaim(claim, input.braname, 'приём возврата на месте');
// Story 7.3/FR32: считанный штрих-код фиксируется в decision_log/inspection
// для аудита; полноценная сверка с marketplace_inventory будет реализована
// вместе с canonical Inventory (Эпик 5/9).
if (input.signed_decision) this.verifySignatures(input.signed_decision);
const decisionDoc = input.signed_decision
@@ -486,14 +500,21 @@ export class MarketplaceReturnClaimService {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Приём возврата claim ${claim.id}: on-chain accretrn упал (${message}); compensating forward не выполнен.`
`Приём возврата claim ${claim.id}: on-chain accretrn упал (${message}); compensating forward не выполнен, фото осмотра удалены из bucket.`
);
await this.cleanupBucketPhotos(inspectionPhotos);
throw new ConflictException(
`Приём возврата на цепи не выполнен: ${message}. Compensating forward не применён.`
);
}
const txHash = this.extractTxHash(tx) || `accretrn-${claim.id}`;
const txHash = this.extractTxHash(tx);
if (!txHash) {
await this.cleanupBucketPhotos(inspectionPhotos);
throw new ConflictException(
'Приём возврата: цепь не вернула tx_hash — compensating forward не подтверждён, статус не меняем.'
);
}
const at = new Date();
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'on_site',
@@ -543,6 +564,7 @@ export class MarketplaceReturnClaimService {
`Заявление в статусе «${claim.status}», отказ на месте недопустим.`
);
}
this.assertBranameMatchesClaim(claim, input.braname, 'отказ на месте');
if (input.signed_decision) this.verifySignatures(input.signed_decision);
const decisionDoc = input.signed_decision
@@ -570,14 +592,21 @@ export class MarketplaceReturnClaimService {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Отказ на месте claim ${claim.id}: on-chain rejretrn упал (${message}); статус не меняется.`
`Отказ на месте claim ${claim.id}: on-chain rejretrn упал (${message}); статус не меняется, фото осмотра удалены из bucket.`
);
await this.cleanupBucketPhotos(inspectionPhotos);
throw new ConflictException(
`Отказ на цепи не выполнен: ${message}.`
);
}
const txHash = this.extractTxHash(tx) || `rejretrn-${claim.id}`;
const txHash = this.extractTxHash(tx);
if (!txHash) {
await this.cleanupBucketPhotos(inspectionPhotos);
throw new ConflictException(
'Отказ на месте: цепь не вернула tx_hash — статус не меняем, попробуйте ещё раз.'
);
}
const at = new Date();
const entry: MarketplaceReturnClaimDecisionLogEntry = {
stage: 'on_site',
@@ -631,7 +660,12 @@ export class MarketplaceReturnClaimService {
`Возврат возможен только по выданному заказу (текущий статус «${order.status}»).`
);
}
if (order.warranty_until !== null && order.warranty_until.getTime() <= Date.now()) {
if (order.warranty_until === null) {
throw new ConflictException(
'По этому заказу гарантия не предусмотрена — возврат невозможен.'
);
}
if (order.warranty_until.getTime() <= Date.now()) {
throw new ConflictException(
`Гарантийный срок истёк ${order.warranty_until.toISOString().slice(0, 10)}.`
);
@@ -639,6 +673,51 @@ export class MarketplaceReturnClaimService {
return order;
}
private assertBranameMatchesClaim(
claim: MarketplaceReturnClaimDomainEntity,
braname: string,
actionLabel: string
): void {
if (!braname || braname.trim().length === 0) {
throw new BadRequestException(`Не указан кооперативный участок для действия «${actionLabel}».`);
}
if (claim.delivery_braname !== braname) {
throw new ForbiddenException(
`Заявление привязано к кооперативному участку «${claim.delivery_braname}»; действие «${actionLabel}» от участка «${braname}» недопустимо.`
);
}
}
private async cleanupBucketPhotos(photos: MarketplaceReturnClaimPhoto[]): Promise<void> {
if (!photos || photos.length === 0) return;
for (const photo of photos) {
try {
await this.imagesService.deletePhoto(photo.bucket_key);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Cleanup фото возврата ${photo.bucket_key} не выполнен (${message}); orphaned-объект остаётся в bucket.`
);
}
}
}
private validatePhotoPayloads(files: MarketplaceReturnClaimImageUploadDTO[]): void {
const MAX_BYTES = 10 * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (!f || typeof f.base64 !== 'string' || f.base64.length === 0) {
throw new BadRequestException(`Фото #${i + 1}: пустое содержимое.`);
}
const approxBytes = Math.floor((f.base64.length * 3) / 4);
if (approxBytes > MAX_BYTES) {
throw new BadRequestException(
`Фото #${i + 1}: размер ${(approxBytes / 1024 / 1024).toFixed(1)} МБ превышает лимит 10 МБ.`
);
}
}
}
private resolveActualQuantity(order: MarketplaceOrderDomainEntity, requested?: number): number {
const factQty = order.issuance_fact?.actual_quantity ?? order.quantity;
if (requested === undefined || requested === null) return factQty;
@@ -727,6 +806,7 @@ export class MarketplaceReturnClaimService {
if (input.files.length > 10) {
throw new BadRequestException('Можно приложить не более 10 фотографий очного осмотра.');
}
this.validatePhotoPayloads(input.files);
return this.uploadPhotos({
files: input.files,
claimId: input.claimId,
@@ -61,8 +61,9 @@ export interface MarketplaceReturnClaimDomainRepository {
/**
* Заявления, отображаемые в operator-столе на КУ доставки исходного
* заказа. По умолчанию — нефинализированные (PENDING_CHAIRMAN_REVIEW +
* APPROVED_FOR_VISIT); при `status` запрашивается явная подвыборка.
* заказа. Если `status` не передан — возвращаются ВСЕ заявления
* (operator-стол показывает три секции: pending, approved, archive);
* при `status` запрашивается явная подвыборка.
*/
listByDeliveryBraname(
coopname: string,
@@ -96,12 +96,14 @@ export class MarketplaceReturnClaimRepositoryAdapter
delivery_braname: string,
status?: MarketplaceReturnClaimStatus | MarketplaceReturnClaimStatus[]
): Promise<MarketplaceReturnClaimDomainEntity[]> {
// Если status не передан — возвращаем ВСЕ заявления (включая архив):
// operator-стол на КУ показывает три секции (pending/approved/archive),
// и архивная секция требует финальные статусы. Раньше дефолт был
// ACTIVE_STATUSES — это делало секцию архива всегда пустой.
const where: Record<string, unknown> = { coopname, delivery_braname };
where.status = Array.isArray(status)
? In(status)
: status
? status
: In(ACTIVE_STATUSES);
if (status !== undefined) {
where.status = Array.isArray(status) ? In(status) : status;
}
const rows = await this.repo.find({ where, order: { created_at: 'DESC' } });
return rows.map((r) => this.mapper.toDomain(r));
}
@@ -66,6 +66,11 @@ const actualQuantity = ref<number | null>(null);
const photos = ref<ReturnClaimPhotoUploadInput[]>([]);
const previewHtml = ref<string>('');
const previewLoading = ref(false);
// Документ, который реально подписывается — генерируется ОДИН раз вместе с
// preview (с полными reason_text/defect_category/actual_quantity). При confirm()
// подписываем именно его — гарантия, что пайщик подписал то, что видел.
type GeneratedDocumentSnapshot = Awaited<ReturnType<typeof getReturnClaimSignablePayload>>;
const signableDocument = ref<GeneratedDocumentSnapshot | null>(null);
const submitting = ref(false);
watch(
@@ -78,6 +83,7 @@ watch(
actualQuantity.value = null;
photos.value = [];
previewHtml.value = '';
signableDocument.value = null;
}
},
{ immediate: false },
@@ -111,11 +117,19 @@ async function loadPreview(): Promise<void> {
if (!props.orderId) return;
previewLoading.value = true;
try {
// Генерируем и preview, и подписываемый документ одним вызовом со ВСЕМИ
// полями (reason_text, defect_category, actual_quantity). Hash документа
// зависит от полей — если позднее их изменить, нужно загрузить заново;
// подписываем именно сохранённый snapshot, чтобы пайщик не подписал
// документ, отличный от показанного preview.
const doc = await getReturnClaimSignablePayload({
order_id: props.orderId,
actual_quantity: actualQuantity.value ?? undefined,
reason_text: reasonText.value,
defect_category: defectCategory.value || undefined,
});
previewHtml.value = doc.html;
signableDocument.value = doc;
} catch (e) {
FailAlert(e, 'Не удалось сформировать предварительное заявление');
step.value = STEP_PHOTOS;
@@ -129,8 +143,8 @@ function goToPhotos(): void {
FailAlert(new Error('Опишите причину возврата.'));
return;
}
if (reasonText.value.length > 500) {
FailAlert(new Error('Причина возврата не должна превышать 500 символов.'));
if (reasonText.value.length > 2000) {
FailAlert(new Error('Причина возврата не должна превышать 2000 символов.'));
return;
}
step.value = STEP_PHOTOS;
@@ -154,7 +168,7 @@ async function confirm(): Promise<void> {
await goToSign();
return;
}
if (!previewHtml.value) {
if (!signableDocument.value) {
FailAlert(new Error('Заявление ещё формируется, подождите.'));
return;
}
@@ -166,14 +180,8 @@ async function confirm(): Promise<void> {
submitting.value = true;
try {
const generated = await getReturnClaimSignablePayload({
order_id: props.orderId,
actual_quantity: actualQuantity.value ?? undefined,
reason_text: reasonText.value,
defect_category: defectCategory.value || undefined,
});
const signer = new Classes.Document(wifKey);
const signed = await signer.signDocument(generated, globalStore.username, 1);
const signed = await signer.signDocument(signableDocument.value, globalStore.username, 1);
await createReturnClaim({
order_id: props.orderId,
@@ -230,7 +238,7 @@ const confirmDisabled = computed(() => {
if (submitting.value) return true;
if (step.value === STEP_DESCRIBE) return !reasonText.value.trim();
if (step.value === STEP_PHOTOS) return photos.value.length === 0;
return !previewHtml.value;
return !signableDocument.value;
});
</script>
@@ -264,7 +272,7 @@ TakeoverDialog(
type="textarea"
label="Причина возврата"
counter
maxlength="500"
maxlength="2000"
autogrow
)
q-select(