feat(orders): «عدم سفارش» blocks app orders; import starts everyone allowed (MON-05)
Legacy stored customers.sts but no code read it, so the switch never stopped an order, and 313 active customers carry it, mostly as the sign-up default. Owner option 2: enforce it from now on, for orders placed from the app only (an operator can still order for the customer), and import every customer as allowed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1c850216e9
commit
4b7b2473fc
@@ -468,11 +468,17 @@ export async function customers(ctx: Ctx) {
|
||||
shopImageUrl: await ctx.imageUrl(r.shop_image_id, S),
|
||||
avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S),
|
||||
birthDate: birthday(r.birth_date, r.created_at),
|
||||
canPlaceOrders: int(r.sts) !== -1,
|
||||
// Owner ruling 2026-10-08 (MON-05, option 2): everyone may order after the
|
||||
// move. Legacy customers.sts was never read by any code — most of the 313
|
||||
// «عدم سفارش» rows are just the sign-up default (ApiMobileController.php:704)
|
||||
// — so carrying it over would suddenly block real customers once the new
|
||||
// system enforces the switch. Only a deliberate suspension after go-live counts.
|
||||
canPlaceOrders: true,
|
||||
isActive: int(r.active) !== -1,
|
||||
isArchived: int(r.is_archive) === 1,
|
||||
};
|
||||
if (r.reagent_id !== null && !owned.referredByAgentId) ctx.report.count(S, 'referrer not mapped (not a migrated expert)');
|
||||
if (int(r.sts) === -1) ctx.report.count(S, 'legacy «عدم سفارش» (never enforced) — set to allowed (owner, option 2)');
|
||||
if (r.birth_date !== null && !owned.birthDate) ctx.report.count(S, 'birth date dropped (sign-up default or impossible)');
|
||||
|
||||
const res = await upsert(ctx, 'customer', r.id, {
|
||||
|
||||
@@ -26,7 +26,7 @@ export class OrdersController {
|
||||
@CustomerAuth()
|
||||
@Post()
|
||||
create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.orders.createOrder(dto, me.id);
|
||||
return this.orders.createOrder(dto, me.id, { placedBy: 'customer' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,3 +48,37 @@ describe('OrdersService.createOrder — sales expert credit', () => {
|
||||
expect(created[0].salesAgentId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */
|
||||
describe('OrdersService.createOrder — order ban', () => {
|
||||
function build(canPlaceOrders: boolean) {
|
||||
const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } };
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn(async () => ({
|
||||
id: 'c1',
|
||||
canPlaceOrders,
|
||||
referredByAgentId: null,
|
||||
goldDebtGrams: new Prisma.Decimal(0),
|
||||
goldCreditLimitGrams: new Prisma.Decimal(1000),
|
||||
})),
|
||||
},
|
||||
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
};
|
||||
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
|
||||
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never);
|
||||
}
|
||||
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
|
||||
|
||||
it('refuses a suspended customer ordering from the app', async () => {
|
||||
await expect(build(false).createOrder(dto, 'c1', { placedBy: 'customer' })).rejects.toThrow('غیرفعال');
|
||||
});
|
||||
|
||||
it('lets an operator place an order for a suspended customer', async () => {
|
||||
await expect(build(false).createOrder(dto, 'c1', { placedBy: 'operator' })).resolves.toMatchObject({ id: 'o1' });
|
||||
});
|
||||
|
||||
it('lets an allowed customer order from the app', async () => {
|
||||
await expect(build(true).createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
@@ -38,10 +38,23 @@ export class OrdersService {
|
||||
* from the request body. The body's `customerId` is ignored; see the note
|
||||
* on CreateOrderDto.
|
||||
*/
|
||||
async createOrder(dto: CreateOrderDto, customerId: string) {
|
||||
async createOrder(
|
||||
dto: CreateOrderDto,
|
||||
customerId: string,
|
||||
opts: { placedBy: 'customer' | 'operator' } = { placedBy: 'operator' },
|
||||
) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
|
||||
// MON-05 (owner ruling 2026-10-08): «معلق کردن سفارش» stops the customer
|
||||
// ordering from the app. Legacy stored the switch (customers.sts) but no
|
||||
// code read it, so it stopped nothing. An operator placing an order FOR
|
||||
// the customer from the panel is not blocked — that is how a suspended
|
||||
// account is still served.
|
||||
if (opts.placedBy === 'customer' && !customer.canPlaceOrders) {
|
||||
throw new ForbiddenException('ثبت سفارش برای حساب شما فعلاً غیرفعال است. لطفاً با پشتیبانی تماس بگیرید.');
|
||||
}
|
||||
|
||||
// The apps still send customerId in the body (removed from them in Phase 2).
|
||||
// It is not used for anything; a mismatch means either a stale client or an
|
||||
// attempt to order on someone else's account, and both are worth seeing.
|
||||
|
||||
Reference in New Issue
Block a user