[598][@ant] feat: отказ в приёмке на подписи поставщика — снятые оператором позиции (факт=0) уходят declineorder с полным возвратом, председатель закрывает только принятые

Разнос группы приёмки на принятые (факт>0) и отклонённые (факт=0) на шаге
подписи поставщика: signsupp только по принятым, declineorder по нулям
(полный возврат стоимости и членского взноса заказчику без штрафа + erase).
Вся партия некондиция → приёмка CANCELLED + shipment CANCELLED. Председатель
(signchair/ACCEPTED_TO_COOP/склад/выплаты) работает только по принятым.
DRY: declineOrdersAtReception переиспользует runDeclineChain+уведомления.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-15 13:22:29 +00:00
parent 14582ba54e
commit b3f2b0e466
3 changed files with 276 additions and 14 deletions
@@ -123,6 +123,10 @@ function buildMocks() {
resolvePayoutMethod: jest.fn().mockResolvedValue(null),
} as any;
const supplierActionService = {
declineOrdersAtReception: jest.fn().mockResolvedValue([]),
} as any;
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
@@ -143,6 +147,7 @@ function buildMocks() {
inventoryRepo,
coreGateway,
supplierSettings,
supplierActionService,
documentDomainService,
logger,
};
@@ -161,6 +166,7 @@ function buildService(mocks: ReturnType<typeof buildMocks>): MarketplaceAplRecep
{ symbol: 'RUB', decimals: 4 },
mocks.coreGateway,
mocks.supplierSettings,
mocks.supplierActionService,
mocks.documentDomainService,
new EventEmitter2(),
mocks.logger
@@ -375,4 +381,128 @@ describe('MarketplaceAplReceptionService — FR45 AC5 compensating-rollback', ()
expect(mocks.receptionRepo.applySignatures).not.toHaveBeenCalled();
});
});
describe('отказ в приёмке (некондиция): разнос принято/отклонено', () => {
it('signAsSupplier: позиция с факт=0 уходит в declineOrdersAtReception, signsupp только по принятой, статус → PENDING_CHAIRMAN', async () => {
const reception = buildReception({
fact_quantity_per_order: [
{ order_id: 'order-1', fact_quantity: 2 }, // принято
{ order_id: 'order-2', fact_quantity: 0 }, // снято оператором (некондиция)
],
});
const accepted = buildOrder({ id: 'order-1', order_hash: '0xorder1hash' });
const rejected = buildOrder({ id: 'order-2', order_hash: '0xorder2hash' });
mocks.receptionRepo.findById.mockResolvedValue(reception);
mocks.orderRepo.findByCycleId.mockResolvedValue([accepted, rejected]);
(mocks.chainPort.signSupp as jest.Mock).mockResolvedValue({
response: { transaction_id: 'tx-supplier-ok' },
});
mocks.receptionRepo.applySignatures.mockImplementation(async (_id, patch) => {
Object.assign(reception, patch);
return reception;
});
await service.signAsSupplier({
coopname: 'voskhod',
supplier_account: 'supplier1',
apl_reception_id: 'apl-1',
signed_documents: buildSignedDocs(['order-1']),
});
// signsupp — только по принятой позиции (одна).
expect(mocks.chainPort.signSupp).toHaveBeenCalledTimes(1);
// Снятая позиция ушла в отказ приёмки (полный возврат заказчику).
expect(mocks.supplierActionService.declineOrdersAtReception).toHaveBeenCalledTimes(1);
const declineArg = mocks.supplierActionService.declineOrdersAtReception.mock.calls[0][0];
expect(declineArg.orders.map((o: any) => o.id)).toEqual(['order-2']);
// Акт уходит председателю на закрывающую подпись.
const patch = mocks.receptionRepo.applySignatures.mock.calls[0][1] as any;
expect(patch.status).toBe(MarketplaceAplReceptionStatuses.PENDING_CHAIRMAN_RECEPTION_SIGN);
});
it('signAsSupplier: вся партия некондиция (все факт=0) → нет signsupp, отказ всех, приёмка CANCELLED + shipment CANCELLED', async () => {
const reception = buildReception({
fact_quantity_per_order: [
{ order_id: 'order-1', fact_quantity: 0 },
{ order_id: 'order-2', fact_quantity: 0 },
],
});
const o1 = buildOrder({ id: 'order-1', order_hash: '0xorder1hash' });
const o2 = buildOrder({ id: 'order-2', order_hash: '0xorder2hash' });
mocks.receptionRepo.findById.mockResolvedValue(reception);
mocks.orderRepo.findByCycleId.mockResolvedValue([o1, o2]);
mocks.receptionRepo.applySignatures.mockImplementation(async (_id, patch) => {
Object.assign(reception, patch);
return reception;
});
const result = await service.signAsSupplier({
coopname: 'voskhod',
supplier_account: 'supplier1',
apl_reception_id: 'apl-1',
signed_documents: [],
});
expect(mocks.chainPort.signSupp).not.toHaveBeenCalled();
const declineArg = mocks.supplierActionService.declineOrdersAtReception.mock.calls[0][0];
expect(declineArg.orders.map((o: any) => o.id).sort()).toEqual(['order-1', 'order-2']);
const patch = mocks.receptionRepo.applySignatures.mock.calls[0][1] as any;
expect(patch.status).toBe(MarketplaceAplReceptionStatuses.CANCELLED);
expect(mocks.shipmentRepo.applyStatusTransition).toHaveBeenCalledWith('ship-1', 'CANCELLED');
expect(result.apl_reception.status).toBe(MarketplaceAplReceptionStatuses.CANCELLED);
});
it('signAsChairman: отклонённую в приёмке позицию (факт=0) НЕ подписывает и НЕ продвигает в ACCEPTED_TO_COOP', async () => {
const reception = buildReception({
status: MarketplaceAplReceptionStatuses.PENDING_CHAIRMAN_RECEPTION_SIGN,
supplier_signed_at: new Date('2026-05-19T01:00:00Z'),
supplier_signsupp_tx_hash: 'tx-supplier-ok',
fact_quantity_per_order: [
{ order_id: 'order-1', fact_quantity: 2 }, // принято
{ order_id: 'order-2', fact_quantity: 0 }, // отклонено в приёмке
],
});
const accepted = buildOrder({ id: 'order-1', order_hash: '0xorder1hash' });
// Отклонённая позиция в PG уже терминальна.
const rejected = buildOrder({
id: 'order-2',
order_hash: '0xorder2hash',
status: 'CANCELLED_BY_SUPPLIER',
});
mocks.receptionRepo.findById.mockResolvedValue(reception);
mocks.orderRepo.findByCycleId.mockResolvedValue([accepted, rejected]);
(mocks.chainPort.signChair as jest.Mock).mockResolvedValue({
response: { transaction_id: 'tx-chair-ok' },
});
mocks.receptionRepo.applySignatures.mockImplementation(async (_id, patch) => {
Object.assign(reception, patch);
return reception;
});
await service.signAsChairman({
coopname: 'voskhod',
chairman_account: 'chair1',
apl_reception_id: 'apl-1',
signed_documents: buildSignedDocs(['order-1']),
});
// signchair — только по принятой позиции.
expect(mocks.chainPort.signChair).toHaveBeenCalledTimes(1);
// В ACCEPTED_TO_COOP продвигается только принятая, отклонённая — нет.
expect(mocks.orderRepo.applyStatusTransition).toHaveBeenCalledTimes(1);
expect(mocks.orderRepo.applyStatusTransition).toHaveBeenCalledWith(
'order-1',
'ACCEPTED_TO_COOP',
expect.any(String)
);
expect(mocks.orderRepo.applyStatusTransition).not.toHaveBeenCalledWith(
'order-2',
'ACCEPTED_TO_COOP',
expect.any(String)
);
});
});
});
@@ -84,6 +84,10 @@ import {
MARKETPLACE_SUPPLIER_SETTINGS_SERVICE,
MarketplaceSupplierSettingsService,
} from './marketplace-supplier-settings.service';
import {
MARKETPLACE_ORDER_SUPPLIER_ACTION_SERVICE,
MarketplaceOrderSupplierActionService,
} from './marketplace-order-supplier-action.service';
import { formatPayoutDestination } from '../shared/payout-destination.util';
import type { PaymentMethodDomainEntity } from '~/domain/payment-method/entities/method-domain.entity';
import type { MarketplaceAplReceptionDomainEntity } from '../../domain/entities/marketplace-apl-reception.entity';
@@ -217,6 +221,8 @@ export class MarketplaceAplReceptionService {
private readonly coreGateway: GatewayInteractorPort,
@Inject(MARKETPLACE_SUPPLIER_SETTINGS_SERVICE)
private readonly supplierSettings: MarketplaceSupplierSettingsService,
@Inject(MARKETPLACE_ORDER_SUPPLIER_ACTION_SERVICE)
private readonly supplierActionService: MarketplaceOrderSupplierActionService,
private readonly documentDomainService: DocumentDomainService,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
@@ -239,8 +245,12 @@ export class MarketplaceAplReceptionService {
): Promise<DocumentDomainEntity[]> {
const reception = await this.loadReception(coopname, apl_reception_id);
const groupOrders = await this.loadGroupOrders(reception);
// Поставщик подписывает акт только по принятым позициям (факт > 0). Снятые
// оператором позиции (факт = 0, некондиция) в акт не попадают — они уходят
// отказом в приёмке (declineorder) на шаге подписи поставщика.
const { accepted } = this.splitGroupByFact(reception, groupOrders);
const docs: DocumentDomainEntity[] = [];
for (const order of groupOrders) {
for (const order of accepted) {
docs.push(
await this.generateReceptionDocument({
reception,
@@ -603,12 +613,42 @@ export class MarketplaceAplReceptionService {
);
}
// Разнос позиций: оператор на стойке снял некондицию (факт = 0). Поставщик
// подтверждает приёмку целиком — подписывает акт по принятым позициям и тем
// же действием отменяет поставку снятых (отказ в приёмке без штрафа, полный
// возврат заказчику). Симметрия с отказом пайщика на выдаче.
const groupOrders = await this.loadGroupOrders(reception);
const { accepted, rejected } = this.splitGroupByFact(reception, groupOrders);
// Вся партия некондиция: подписывать нечего. Отклоняем все позиции (полный
// возврат заказчикам, поставщику без штрафа) и закрываем приёмку как
// отменённую — имущество поставщик увозит, принимать нечего.
if (accepted.length === 0) {
await this.supplierActionService.declineOrdersAtReception({
coopname: reception.coopname,
offerer_account: input.supplier_account,
orders: rejected,
reason: 'Отказ в приёмке: вся партия снята оператором (некондиция)',
});
const cancelled = await this.receptionRepo.applySignatures(reception.id, {
status: MarketplaceAplReceptionStatuses.CANCELLED,
});
await this.shipmentRepo.applyStatusTransition(
reception.shipment_id,
MarketplaceShipmentStatuses.CANCELLED
);
this.logger.log(
`АПП ${reception.id}: вся партия отклонена в приёмке (${rejected.length} поз.) — приёмка отменена, заказчикам полный возврат.`
);
this.emitReceptionStatusChanged(cancelled);
return { apl_reception: cancelled };
}
let txHash: string;
try {
const groupOrders = await this.loadGroupOrders(reception);
txHash = await this.submitOnChainSignSupp(
reception,
groupOrders,
accepted,
input.signed_documents,
input.supplier_account
);
@@ -621,6 +661,18 @@ export class MarketplaceAplReceptionService {
);
}
// Снятые позиции — отказ в приёмке (полный возврат заказчику, без штрафа).
// Best-effort: подпись по принятым уже на цепи, сбой отказа отдельной
// позиции логируется внутри сервиса и не валит фиксацию подписи.
if (rejected.length > 0) {
await this.supplierActionService.declineOrdersAtReception({
coopname: reception.coopname,
offerer_account: input.supplier_account,
orders: rejected,
reason: 'Отказ в приёмке: позиция снята оператором (некондиция)',
});
}
const updated = await this.receptionRepo.applySignatures(reception.id, {
supplier_signed_at: new Date(),
supplier_signsupp_tx_hash: txHash,
@@ -631,7 +683,9 @@ export class MarketplaceAplReceptionService {
status: MarketplaceAplReceptionStatuses.PENDING_CHAIRMAN_RECEPTION_SIGN,
});
this.logger.log(`АПП ${reception.id}: подпись поставщика принята (tx=${txHash}).`);
this.logger.log(
`АПП ${reception.id}: подпись поставщика принята (tx=${txHash}); принято ${accepted.length} поз., отклонено в приёмке ${rejected.length} поз.`
);
this.emitReceptionStatusChanged(updated);
@@ -648,12 +702,18 @@ export class MarketplaceAplReceptionService {
);
}
// Председатель закрывает приёмку только по принятым позициям (факт > 0).
// Снятые при подписи поставщика позиции уже отклонены (declineorder, заказ
// терминальный) — их нет в акте, в signchair, в переходе ACCEPTED_TO_COOP,
// на складе и в выплатах.
const groupOrders = await this.loadGroupOrders(reception);
const { accepted: acceptedOrders } = this.splitGroupByFact(reception, groupOrders);
let txHash: string;
try {
const groupOrders = await this.loadGroupOrders(reception);
txHash = await this.submitOnChainSignChair(
reception,
groupOrders,
acceptedOrders,
input.signed_documents,
input.chairman_account
);
@@ -674,10 +734,10 @@ export class MarketplaceAplReceptionService {
status: MarketplaceAplReceptionStatuses.ACCEPTED_TO_COOP,
});
// Order'ы группы → ACCEPTED_TO_COOP (FR19a выдача разблокируется только
// после этого момента).
const orders = await this.orderRepo.findByCycleId(reception.coopname, reception.cycle_id);
for (const o of orders.filter((x) => x.delivery_braname === reception.braname)) {
// Принятые Order'ы → ACCEPTED_TO_COOP (FR19a выдача разблокируется только
// после этого момента). Отклонённые в приёмке сюда не попадают — они уже
// терминальные (CANCELLED_BY_SUPPLIER).
for (const o of acceptedOrders) {
await this.orderRepo.applyStatusTransition(
o.id,
'ACCEPTED_TO_COOP',
@@ -693,13 +753,13 @@ export class MarketplaceAplReceptionService {
// Имущество, принятое по акту, появляется на складе КУ сразу — независимо
// от маркировки (штрих-код опционален). Полку и штрих-код оператор назначит
// позже на столе раскладки/маркировки.
await this.materializeInventory(updated, orders);
await this.materializeInventory(updated, acceptedOrders);
// Story 5.6 / 598-16 (L12): инициируем выплаты поставщику per-Order
// через gateway. Кассир увидит каждую выплату в общем реестре
// платежей кооператива и подтвердит/отклонит её там.
const orderHashByOrderId = new Map(orders.map((o) => [o.id, o.order_hash] as const));
await this.initiatePayouts(updated, orders, orderHashByOrderId);
const orderHashByOrderId = new Map(acceptedOrders.map((o) => [o.id, o.order_hash] as const));
await this.initiatePayouts(updated, acceptedOrders, orderHashByOrderId);
// Story 598-20: push кассиру о новой партии выплат — одно
// суммарное уведомление на АПП с агрегатом по поставщику.
@@ -713,7 +773,7 @@ export class MarketplaceAplReceptionService {
this.eventBus.emit(MARKETPLACE_CASHIER_NEW_PAYMENT_EVENT, event);
this.logger.log(
`АПП ${reception.id}: закрывающая подпись председателя ${input.chairman_account} принята (tx=${txHash}); выплаты по ${orders.filter((x) => x.delivery_braname === reception.braname).length} заказам инициированы через gateway.`
`АПП ${reception.id}: закрывающая подпись председателя ${input.chairman_account} принята (tx=${txHash}); выплаты по ${acceptedOrders.length} заказам инициированы через gateway.`
);
this.emitReceptionStatusChanged(updated);
@@ -927,6 +987,36 @@ export class MarketplaceAplReceptionService {
return orders.filter((o) => o.delivery_braname === reception.braname);
}
/**
* Разнос заказов группы на принятые (факт > 0 — идут в акт, на баланс
* кооператива и в выплату поставщику) и отклонённые в приёмке (факт = 0 —
* оператор снял позицию: некондиция). Потолок факта = акцепт (см.
* buildFactQuantity); отсутствие записи факта трактуется как полный приём.
* Заказы с уже терминальным статусом (например, ранее отклонённые в этой же
* приёмке при повторном вызове) в принятые не попадают.
*/
private splitGroupByFact(
reception: MarketplaceAplReceptionDomainEntity,
groupOrders: MarketplaceOrderDomainEntity[]
): { accepted: MarketplaceOrderDomainEntity[]; rejected: MarketplaceOrderDomainEntity[] } {
const factByOrderId = new Map(
reception.fact_quantity_per_order.map((f) => [f.order_id, f.fact_quantity])
);
const accepted: MarketplaceOrderDomainEntity[] = [];
const rejected: MarketplaceOrderDomainEntity[] = [];
for (const o of groupOrders) {
const fact = factByOrderId.get(o.id) ?? o.quantity;
if (
o.status === MarketplaceOrderStatuses.CANCELLED_BY_SUPPLIER ||
o.status === MarketplaceOrderStatuses.CANCELLED_BY_ORDERER
) {
continue;
}
(fact > 0 ? accepted : rejected).push(o);
}
return { accepted, rejected };
}
/**
* Оприходование склада: на каждый принятый Order группы (fact_quantity > 0)
* заводим позицию инвентаря в статусе RECEIVED — имущество физически на
@@ -225,6 +225,48 @@ export class MarketplaceOrderSupplierActionService {
return { cycle_id: null, orders: declined, tx_hashes: txHashes };
}
/**
* Отказ в приёмке (некондиция): поставщик привёз позиции, которые оператор
* снял с приёмки (факт = 0). Переиспользует тот же on-chain путь, что и
* pre-cycle отказ — `declineorder` (полный возврат резерва и членского взноса
* заказчику без штрафа + erase), но заказы здесь уже акцептованы и в партии:
* статус ACCEPTED / SUPPLY_PREPARED, не ACTIVE, и `cycle_id != null`. Поэтому
* это отдельный публичный вход, минующий guard `ACTIVE && cycle_id == null`
* массового pre-cycle отказа; контракт `declineorder` сам допускает эти
* статусы (имущество ещё не оприходовано — клоубэка нет).
*
* Best-effort per-order: накладная подпись поставщика по принятым позициям
* уже зафиксирована вызывающим, откатить её нельзя — поэтому сбой отказа по
* отдельной позиции логируется (заказчику возврат не выполнен, требуется
* ручной разбор), но не валит всю операцию. Ownership заказов гарантирован
* вызывающим (приёмка принадлежит этому поставщику).
*/
async declineOrdersAtReception(input: {
coopname: string;
offerer_account: string;
orders: MarketplaceOrderDomainEntity[];
reason: string;
}): Promise<MarketplaceOrderDomainEntity[]> {
const reason = (input.reason ?? '').trim() || 'Отказ в приёмке: некондиция';
const declined: MarketplaceOrderDomainEntity[] = [];
for (const order of input.orders) {
if (order.supplier_account !== input.offerer_account) continue;
try {
const res = await this.runDeclineChain(order, input.offerer_account, reason);
declined.push(res.order);
} catch (err: any) {
this.logger.error(
`MarketplaceOrderSupplierActionService.declineOrdersAtReception: отказ позиции ${order.id} (hash=${order.order_hash}) упал: ${err.message}; заказчику ${order.orderer_account} возврат не выполнен — требуется ручной разбор.`,
err.stack
);
}
}
if (declined.length > 0) {
await this.emitDeclinedNotifications(declined, reason);
}
return declined;
}
private async emitDeclinedNotifications(
declined: MarketplaceOrderDomainEntity[],
reason: string