[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:
+130
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+104
-14
@@ -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 — имущество физически на
|
||||
|
||||
+42
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user