Compare commits

..

7 Commits

Author SHA1 Message Date
morteza 17223ced06 edit order
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-18 20:56:29 +03:30
morteza 01ae67248a review
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-17 10:53:42 +03:30
morteza 443b62eb8e review 2026-07-17 10:18:39 +03:30
morteza 4abc2ae6e5 fix review table migration
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-15 12:39:53 +03:30
morteza 204b4f2007 update review entity
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-15 12:07:58 +03:30
morteza a05643daf5 order status when admin create new order for user
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 21:21:16 +03:30
morteza e991215f5a update order status 2026-07-14 21:04:07 +03:30
31 changed files with 1907 additions and 423 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260714170013_changeOrderStatus extends Migration {
public async up(): Promise<void> {
// remove old constraint
this.addSql(`
ALTER TABLE orders
DROP CONSTRAINT IF EXISTS orders_status_check;
`);
// migrate data
this.addSql(`
UPDATE orders
SET status='new'
WHERE status='pendingPayment';
`);
this.addSql(`
UPDATE orders
SET status='completed'
WHERE status='paid';
`);
// create new constraint
this.addSql(`
ALTER TABLE orders
ADD CONSTRAINT orders_status_check
CHECK (
status IN (
'new',
'preparing',
'deliveredToWaiter',
'deliveredToReceptionist',
'shipped',
'canceled',
'completed'
)
);
`);
}
public async down(): Promise<void> {
this.addSql(`
ALTER TABLE "orders"
DROP CONSTRAINT IF EXISTS "orders_status_check";
`);
this.addSql(`
UPDATE "orders"
SET "status" = 'pendingPayment'
WHERE "status" = 'new';
`);
this.addSql(`
UPDATE "orders"
SET "status" = 'paid'
WHERE "status" = 'completed';
`);
this.addSql(`
ALTER TABLE "orders"
ADD CONSTRAINT "orders_status_check"
CHECK (
"status" IN (
'pendingPayment',
'preparing',
'deliveredToWaiter',
'deliveredToReceptionist',
'shipped',
'paid',
'canceled',
'completed'
)
);
`);
}
}
@@ -0,0 +1,26 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260715083002_task extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "reviews" drop constraint "reviews_order_id_foreign";`);
this.addSql(`drop index "reviews_order_id_index";`);
this.addSql(`alter table "reviews" drop constraint "reviews_order_id_food_id_user_id_unique";`);
this.addSql(`alter table "reviews" drop column "order_id";`);
this.addSql(`alter table "reviews" add column "is_buyer" boolean not null default false;`);
this.addSql(`alter table "reviews" add constraint "reviews_food_id_user_id_unique" unique ("food_id", "user_id");`);
}
override async down(): Promise<void> {
this.addSql(`alter table "reviews" drop constraint "reviews_food_id_user_id_unique";`);
this.addSql(`alter table "reviews" add column "order_id" char(26) not null;`);
this.addSql(`alter table "reviews" add constraint "reviews_order_id_foreign" foreign key ("order_id") references "orders" ("id") on update cascade;`);
this.addSql(`create index "reviews_order_id_index" on "reviews" ("order_id");`);
this.addSql(`alter table "reviews" add constraint "reviews_order_id_food_id_user_id_unique" unique ("order_id", "food_id", "user_id");`);
}
}
@@ -0,0 +1,17 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260715120000_dropReviewsOrderId extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "reviews" drop column if exists "order_id";`);
}
override async down(): Promise<void> {
this.addSql(`alter table "reviews" add column "order_id" char(26) not null;`);
this.addSql(`alter table "reviews" add constraint "reviews_order_id_foreign" foreign key ("order_id") references "orders" ("id") on update cascade;`);
this.addSql(`create index "reviews_order_id_index" on "reviews" ("order_id");`);
this.addSql(`alter table "reviews" drop constraint if exists "reviews_food_id_user_id_unique";`);
this.addSql(`alter table "reviews" add constraint "reviews_order_id_food_id_user_id_unique" unique ("order_id", "food_id", "user_id");`);
}
}
@@ -0,0 +1,17 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260716120000_addReviewsParentId extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "reviews" add column "parent_id" char(26) null;`);
this.addSql(`alter table "reviews" add constraint "reviews_parent_id_foreign" foreign key ("parent_id") references "reviews" ("id") on update cascade on delete set null;`);
this.addSql(`create index "reviews_parent_id_index" on "reviews" ("parent_id");`);
}
override async down(): Promise<void> {
this.addSql(`alter table "reviews" drop constraint "reviews_parent_id_foreign";`);
this.addSql(`drop index "reviews_parent_id_index";`);
this.addSql(`alter table "reviews" drop column "parent_id";`);
}
}
@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260716123000_nullableReviewUserForReplies extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "reviews" alter column "user_id" drop not null;`);
}
override async down(): Promise<void> {
this.addSql(`alter table "reviews" alter column "user_id" set not null;`);
}
}
@@ -0,0 +1,17 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260717100000_addReviewsAdminId extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "reviews" add column "admin_id" char(26) null;`);
this.addSql(`alter table "reviews" add constraint "reviews_admin_id_foreign" foreign key ("admin_id") references "admins" ("id") on update cascade on delete set null;`);
this.addSql(`create index "reviews_admin_id_index" on "reviews" ("admin_id");`);
}
override async down(): Promise<void> {
this.addSql(`alter table "reviews" drop constraint "reviews_admin_id_foreign";`);
this.addSql(`drop index "reviews_admin_id_index";`);
this.addSql(`alter table "reviews" drop column "admin_id";`);
}
}
@@ -0,0 +1,139 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Adds paid_amount, balance, refunded_amount on orders.
* Values are maintained by PostgreSQL functions + triggers from payments / order.total.
*
* Definitions:
* - paid_amount = SUM(payments.amount) WHERE status = 'paid' AND deleted_at IS NULL
* - refunded_amount = SUM(payments.amount) WHERE status = 'refunded' AND deleted_at IS NULL
* - balance = total - paid_amount (remaining amount due)
*/
export class Migration20260718093000_addOrderPaymentAmounts extends Migration {
override async up(): Promise<void> {
this.addSql(`
alter table "orders"
add column "paid_amount" numeric(10,0) not null default 0,
add column "balance" numeric(10,0) not null default 0,
add column "refunded_amount" numeric(10,0) not null default 0;
`);
this.addSql(`
create or replace function recalculate_order_payment_amounts(p_order_id char(26))
returns void
language plpgsql
as $$
declare
v_paid numeric(10,0);
v_refunded numeric(10,0);
begin
if p_order_id is null then
return;
end if;
select
coalesce(sum(p.amount) filter (where p.status = 'paid'), 0),
coalesce(sum(p.amount) filter (where p.status = 'refunded'), 0)
into v_paid, v_refunded
from payments p
where p.order_id = p_order_id
and p.deleted_at is null;
update orders o
set
paid_amount = v_paid,
refunded_amount = v_refunded,
balance = o.total - v_paid
where o.id = p_order_id;
end;
$$;
`);
this.addSql(`
create or replace function trg_payments_recalculate_order_payment_amounts()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'DELETE' then
perform recalculate_order_payment_amounts(old.order_id);
return old;
end if;
perform recalculate_order_payment_amounts(new.order_id);
if tg_op = 'UPDATE' and old.order_id is distinct from new.order_id then
perform recalculate_order_payment_amounts(old.order_id);
end if;
return new;
end;
$$;
`);
this.addSql(`
create trigger payments_recalculate_order_payment_amounts
after insert or update of amount, status, order_id, deleted_at or delete
on payments
for each row
execute procedure trg_payments_recalculate_order_payment_amounts();
`);
this.addSql(`
create or replace function trg_orders_sync_balance()
returns trigger
language plpgsql
as $$
begin
new.balance := coalesce(new.total, 0) - coalesce(new.paid_amount, 0);
return new;
end;
$$;
`);
this.addSql(`
create trigger orders_sync_balance
before insert or update of total, paid_amount
on orders
for each row
execute procedure trg_orders_sync_balance();
`);
// Backfill existing rows: set balance from total, then recalc from payments
this.addSql(`
update orders
set balance = total - paid_amount;
`);
this.addSql(`
do $$
declare
r record;
begin
for r in
select distinct order_id
from payments
where deleted_at is null
and status in ('paid', 'refunded')
loop
perform recalculate_order_payment_amounts(r.order_id);
end loop;
end;
$$;
`);
}
override async down(): Promise<void> {
this.addSql(`drop trigger if exists orders_sync_balance on orders;`);
this.addSql(`drop trigger if exists payments_recalculate_order_payment_amounts on payments;`);
this.addSql(`drop function if exists trg_orders_sync_balance();`);
this.addSql(`drop function if exists trg_payments_recalculate_order_payment_amounts();`);
this.addSql(`drop function if exists recalculate_order_payment_amounts(char(26));`);
this.addSql(`
alter table "orders"
drop column "paid_amount",
drop column "balance",
drop column "refunded_amount";
`);
}
}
@@ -0,0 +1,229 @@
import { Migration } from '@mikro-orm/migrations';
/**
* DB-maintained order money fields.
*
* Inputs (set by app): coupon_discount, tax, delivery_fee, packing_fee
*
* Derived:
* - order_items.total_price = (unit_price - discount) * quantity
* - sub_total = SUM(unit_price * quantity) from non-deleted items
* - items_discount = SUM(discount * quantity) from non-deleted items
* - total_items = SUM(quantity) from non-deleted items
* - total_discount = coupon_discount + items_discount
* - total = GREATEST(0, sub_total - total_discount) + tax + delivery_fee + packing_fee
* - balance = total - paid_amount (also synced by payment-amount triggers)
*
* Also syncs pending payment.amount when order.total changes.
*/
export class Migration20260718110000_addOrderTotalsCalculation extends Migration {
override async up(): Promise<void> {
// --- order_items.total_price ---
this.addSql(`
create or replace function trg_order_items_sync_total_price()
returns trigger
language plpgsql
as $$
begin
new.total_price := (coalesce(new.unit_price, 0) - coalesce(new.discount, 0)) * coalesce(new.quantity, 0);
return new;
end;
$$;
`);
this.addSql(`
create trigger order_items_sync_total_price
before insert or update of unit_price, discount, quantity
on order_items
for each row
execute procedure trg_order_items_sync_total_price();
`);
// --- recalculate order aggregates from items + fee inputs ---
this.addSql(`
create or replace function recalculate_order_totals(p_order_id char(26))
returns void
language plpgsql
as $$
declare
v_sub_total numeric(10,0);
v_items_discount numeric(10,0);
v_total_items int;
v_total numeric(10,0);
begin
if p_order_id is null then
return;
end if;
select
coalesce(sum(oi.unit_price * oi.quantity), 0),
coalesce(sum(oi.discount * oi.quantity), 0),
coalesce(sum(oi.quantity), 0)::int
into v_sub_total, v_items_discount, v_total_items
from order_items oi
where oi.order_id = p_order_id
and oi.deleted_at is null;
update orders o
set
sub_total = v_sub_total,
items_discount = v_items_discount,
total_items = v_total_items,
total_discount = coalesce(o.coupon_discount, 0) + v_items_discount,
total = greatest(
0,
v_sub_total - (coalesce(o.coupon_discount, 0) + v_items_discount)
) + coalesce(o.tax, 0) + coalesce(o.delivery_fee, 0) + coalesce(o.packing_fee, 0),
balance = (
greatest(
0,
v_sub_total - (coalesce(o.coupon_discount, 0) + v_items_discount)
) + coalesce(o.tax, 0) + coalesce(o.delivery_fee, 0) + coalesce(o.packing_fee, 0)
) - coalesce(o.paid_amount, 0)
where o.id = p_order_id
returning o.total into v_total;
-- keep pending payments in sync with the new order total
update payments p
set amount = v_total
where p.order_id = p_order_id
and p.status = 'pending'
and p.deleted_at is null
and p.amount is distinct from v_total;
end;
$$;
`);
this.addSql(`
create or replace function trg_order_items_recalculate_order_totals()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'DELETE' then
perform recalculate_order_totals(old.order_id);
return old;
end if;
perform recalculate_order_totals(new.order_id);
if tg_op = 'UPDATE' and old.order_id is distinct from new.order_id then
perform recalculate_order_totals(old.order_id);
end if;
return new;
end;
$$;
`);
this.addSql(`
create trigger order_items_recalculate_order_totals
after insert or update of unit_price, discount, quantity, order_id, deleted_at or delete
on order_items
for each row
execute procedure trg_order_items_recalculate_order_totals();
`);
// When fee / coupon / tax inputs change, recompute total (items unchanged)
this.addSql(`
create or replace function trg_orders_recalculate_totals_from_inputs()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'UPDATE' and (
old.coupon_discount is not distinct from new.coupon_discount
and old.tax is not distinct from new.tax
and old.delivery_fee is not distinct from new.delivery_fee
and old.packing_fee is not distinct from new.packing_fee
) then
return new;
end if;
new.total_discount := coalesce(new.coupon_discount, 0) + coalesce(new.items_discount, 0);
new.total := greatest(
0,
coalesce(new.sub_total, 0) - new.total_discount
) + coalesce(new.tax, 0) + coalesce(new.delivery_fee, 0) + coalesce(new.packing_fee, 0);
new.balance := new.total - coalesce(new.paid_amount, 0);
return new;
end;
$$;
`);
this.addSql(`
create trigger orders_recalculate_totals_from_inputs
before insert or update of coupon_discount, tax, delivery_fee, packing_fee
on orders
for each row
execute procedure trg_orders_recalculate_totals_from_inputs();
`);
// Sync pending payment amount when total changes (e.g. fee update)
this.addSql(`
create or replace function trg_orders_sync_pending_payment_amount()
returns trigger
language plpgsql
as $$
begin
if tg_op = 'UPDATE' and old.total is not distinct from new.total then
return new;
end if;
update payments p
set amount = new.total
where p.order_id = new.id
and p.status = 'pending'
and p.deleted_at is null
and p.amount is distinct from new.total;
return new;
end;
$$;
`);
// Must be AFTER UPDATE (not UPDATE OF total): BEFORE triggers may change NEW.total
// when only fee columns are in the client UPDATE, and UPDATE OF would not fire then.
this.addSql(`
create trigger orders_sync_pending_payment_amount
after update
on orders
for each row
when (old.total is distinct from new.total)
execute procedure trg_orders_sync_pending_payment_amount();
`);
// Backfill item total_price and order aggregates
this.addSql(`
update order_items
set total_price = (coalesce(unit_price, 0) - coalesce(discount, 0)) * coalesce(quantity, 0)
where total_price is distinct from (coalesce(unit_price, 0) - coalesce(discount, 0)) * coalesce(quantity, 0);
`);
this.addSql(`
do $$
declare
r record;
begin
for r in select id from orders where deleted_at is null
loop
perform recalculate_order_totals(r.id);
end loop;
end;
$$;
`);
}
override async down(): Promise<void> {
this.addSql(`drop trigger if exists orders_sync_pending_payment_amount on orders;`);
this.addSql(`drop trigger if exists orders_recalculate_totals_from_inputs on orders;`);
this.addSql(`drop trigger if exists order_items_recalculate_order_totals on order_items;`);
this.addSql(`drop trigger if exists order_items_sync_total_price on order_items;`);
this.addSql(`drop function if exists trg_orders_sync_pending_payment_amount();`);
this.addSql(`drop function if exists trg_orders_recalculate_totals_from_inputs();`);
this.addSql(`drop function if exists trg_order_items_recalculate_order_totals();`);
this.addSql(`drop function if exists trg_order_items_sync_total_price();`);
this.addSql(`drop function if exists recalculate_order_totals(char(26));`);
}
}
+12
View File
@@ -660,6 +660,11 @@ export const enum ReviewMessage {
COMMENT_CREATED = 'نظر با موفقیت ثبت شد',
COMMENT_UPDATED = 'نظر با موفقیت به‌روزرسانی شد',
COMMENT_DELETED = 'نظر با موفقیت حذف شد',
CANNOT_REPLY_TO_REPLY = 'امکان پاسخ به پاسخ وجود ندارد',
REPLY_CREATED = 'پاسخ با موفقیت ثبت شد',
REPLY_UPDATED = 'پاسخ با موفقیت به‌روزرسانی شد',
REPLY_DELETED = 'پاسخ با موفقیت حذف شد',
REPLY_NOT_FOUND = 'پاسخی برای این نظر یافت نشد',
}
export const enum IconMessage {
@@ -699,6 +704,12 @@ export const enum OrderMessage {
DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL = 'مبلغ تخفیف بیشتر از مبلغ سفارش است',
CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER = 'امکان ویرایش سفارش لغو شده یا تکمیل شده وجود ندارد',
NO_FIELDS_TO_UPDATE = 'حداقل یک فیلد برای بروزرسانی الزامی است',
ORDER_ITEM_NOT_FOUND = 'آیتم سفارش یافت نشد',
CANNOT_REMOVE_LAST_ORDER_ITEM = 'سفارش باید حداقل یک آیتم داشته باشد',
REFUND_NOT_ALLOWED = 'امکان بازگشت وجه برای این سفارش وجود ندارد',
REFUND_AMOUNT_EXCEEDS_PAID = 'مبلغ بازگشت وجه بیشتر از مبلغ پرداخت‌شده است',
REFUND_AMOUNT_EXCEEDS_OVERPAYMENT = 'مبلغ بازگشت وجه بیشتر از مانده منفی سفارش است',
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
}
export const enum CartMessage {
@@ -752,6 +763,7 @@ export const enum PaymentMessage {
CARD_OWNER_REQUIRED = 'نام صاحب کارت برای پرداخت کارت به کارت الزامی است',
PAYMENT_ALREADY_PAID = 'پرداخت قبلا انجام شده است',
EXISTING_PENDING_PAYMENT_MISMATCH = 'پرداخت در انتظار موجود با روش پرداخت سفارش مطابقت ندارد',
REFUND_WALLET_NOT_FOUND = 'کیف پول کاربر برای بازگشت وجه یافت نشد',
ZARINPAL_INVALID_RESPONSE = 'پاسخ نامعتبر از API زرین‌پال',
ZARINPAL_PAYMENT_REQUEST_ERROR = 'خطا در درخواست پرداخت زرین‌پال',
ZARINPAL_VERIFY_ERROR = 'خطا در تایید پرداخت زرین‌پال',
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body } from '@nestjs/common';
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } from '@nestjs/swagger';
import { OrdersService } from '../providers/orders.service';
import { AuthGuard } from '../../auth/guards/auth.guard';
@@ -13,8 +13,11 @@ import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
@ApiTags('orders')
@ApiBearerAuth()
@@ -94,6 +97,78 @@ export class OrdersController {
return this.ordersService.findOne(orderId, restId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/fees')
@ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminUpdateOrderFeesDto })
updateOrderFees(
@Param('orderId') orderId: string,
@Body() dto: AdminUpdateOrderFeesDto,
@RestId() restId: string,
) {
return this.ordersService.updateOrderFees(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/items')
@ApiOperation({ summary: 'Add an item to an existing order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminAddOrderItemDto })
addOrderItem(
@Param('orderId') orderId: string,
@Body() dto: AdminAddOrderItemDto,
@RestId() restId: string,
) {
return this.ordersService.addOrderItem(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/items/:itemId')
@ApiOperation({ summary: 'Update quantity of an order item' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiParam({ name: 'itemId', description: 'Order item ID' })
@ApiBody({ type: AdminUpdateOrderItemDto })
updateOrderItem(
@Param('orderId') orderId: string,
@Param('itemId') itemId: string,
@Body() dto: AdminUpdateOrderItemDto,
@RestId() restId: string,
) {
return this.ordersService.updateOrderItemQuantity(orderId, restId, itemId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Delete('admin/orders/:orderId/items/:itemId')
@ApiOperation({ summary: 'Remove an item from an order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiParam({ name: 'itemId', description: 'Order item ID' })
removeOrderItem(
@Param('orderId') orderId: string,
@Param('itemId') itemId: string,
@RestId() restId: string,
) {
return this.ordersService.removeOrderItem(orderId, restId, itemId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/refund')
@ApiOperation({ summary: 'Refund order payment(s) by amount' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminRefundOrderDto })
refundOrder(
@Param('orderId') orderId: string,
@Body() dto: AdminRefundOrderDto,
@RestId() restId: string,
) {
return this.ordersService.refundOrder(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/:status')
@@ -114,20 +189,6 @@ export class OrdersController {
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/fees')
@ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminUpdateOrderFeesDto })
updateOrderFees(
@Param('orderId') orderId: string,
@Body() dto: AdminUpdateOrderFeesDto,
@RestId() restId: string,
) {
return this.ordersService.updateOrderFees(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS)
@ApiOperation({ summary: 'Get Stats for report page' })
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
export class AdminAddOrderItemDto {
@ApiProperty({ description: 'Food ID' })
@IsString()
@IsNotEmpty()
foodId: string;
@ApiProperty({ description: 'Quantity', minimum: 1, default: 1 })
@IsNumber()
@Min(1)
quantity: number;
}
@@ -0,0 +1,16 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class AdminRefundOrderDto {
@ApiProperty({ description: 'Refund amount', minimum: 1 })
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@ApiPropertyOptional({ description: 'Refund description / reason' })
@IsOptional()
@IsString()
description?: string;
}
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, Min } from 'class-validator';
export class AdminUpdateOrderItemDto {
@ApiProperty({ description: 'New quantity', minimum: 1 })
@IsNumber()
@Min(1)
quantity: number;
}
@@ -21,6 +21,7 @@ export class OrderItem extends BaseEntity {
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
discount: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0 })
totalPrice!: number;
/** (unitPrice - discount) * quantity. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
totalPrice: number = 0;
}
+21 -4
View File
@@ -70,14 +70,17 @@ export class Order extends BaseEntity {
@Property({ type: 'jsonb', nullable: true })
couponDetail?: OrderCouponDetail | null;
/** Sum of item discounts. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
itemsDiscount: number = 0;
/** couponDiscount + itemsDiscount. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
totalDiscount: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0 })
subTotal!: number;
/** Sum of unitPrice * quantity. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
subTotal: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
tax: number = 0;
@@ -91,9 +94,23 @@ export class Order extends BaseEntity {
@Property({ type: 'int', nullable: true })
prepareTime?: number | null;
@Property({ type: 'decimal', precision: 10, scale: 0 })
total!: number;
/** GREATEST(0, subTotal - totalDiscount) + tax + deliveryFee + packingFee. Maintained by DB triggers. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
total: number = 0;
/** Sum of payments with status `paid`. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
paidAmount: number = 0;
/** Remaining amount due (`total - paidAmount`). Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
balance: number = 0;
/** Sum of payments with status `refunded`. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
refundedAmount: number = 0;
/** Sum of item quantities. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'int', default: 0 })
totalItems: number = 0;
@@ -17,8 +17,7 @@ export interface OrderCarAddress {
}
export enum OrderStatus {
PENDING_PAYMENT = 'pendingPayment',
PAID = 'paid',
NEW = 'new',
PREPARING = 'preparing',
DELIVERED_TO_WAITER = 'deliveredToWaiter',
DELIVERED_TO_RECEPTIONIST = 'deliveredToReceptionist',
@@ -35,8 +35,7 @@ export class OrderListeners {
private getStatusFarsi(status: OrderStatus): string {
const statusMap: Record<OrderStatus, string> = {
[OrderStatus.PENDING_PAYMENT]: ر انتظار پرداخت',
[OrderStatus.PAID]: 'پرداخت شده',
[OrderStatus.NEW]: 'جدید',
[OrderStatus.PREPARING]: 'در حال آماده‌سازی',
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
+295 -47
View File
@@ -24,12 +24,17 @@ import { BulkReserveFoodDto } from 'src/modules/inventory/dto/bulk-reserve-food.
import { StatusTransitionRef } from '../interface/order.interface';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
import { OrderMessage } from 'src/common/enums/message.enum';
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
import { CashShiftsService } from './cash-shifts.service';
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum';
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
type ValidatedCartForOrder = {
@@ -47,8 +52,7 @@ export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
private static readonly STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELED, OrderStatus.PREPARING],
[OrderStatus.PAID]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
[OrderStatus.NEW]: [ OrderStatus.CANCELED, OrderStatus.PREPARING],
[OrderStatus.PREPARING]: [OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.SHIPPED, OrderStatus.CANCELED],
[OrderStatus.DELIVERED_TO_WAITER]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
@@ -81,19 +85,22 @@ export class OrdersService {
paymentMethod: validated.paymentMethod,
couponDiscount: cart.couponDiscount || 0,
couponDetail: cart.coupon,
itemsDiscount: cart.itemsDiscount || 0,
totalDiscount: cart.totalDiscount || 0,
subTotal: cart.subTotal || 0,
tax: cart.tax || 0,
deliveryFee: cart.deliveryFee || 0,
packingFee: 0,
total: cart.total || 0,
totalItems: cart.totalItems || 0,
description: cart.description,
printInvoice: cart.printInvoice ?? false,
tableNumber: cart.tableNumber,
status: OrderStatus.PENDING_PAYMENT,
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
status: OrderStatus.NEW,
history: [{ status: OrderStatus.NEW, changedAt: new Date() }],
subTotal: 0,
total: 0,
totalItems: 0,
totalDiscount: 0,
itemsDiscount: 0,
paidAmount: 0,
balance: 0,
refundedAmount: 0,
});
em.persist(order);
@@ -103,20 +110,22 @@ export class OrdersService {
this.assertFoodHasSufficientStock(food, quantity);
const totalPrice = (unitPrice - discount) * quantity;
const orderItem = em.create(OrderItem, {
order,
food,
quantity,
unitPrice,
discount,
totalPrice,
totalPrice: 0,
});
em.persist(orderItem);
}
// Flush so DB triggers compute subTotal / total / totalItems from items
await em.flush();
await em.refresh(order);
const payment = em.create(Payment, {
order,
amount: order.total,
@@ -172,18 +181,16 @@ export class OrdersService {
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
const subTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
// Validate admin discount against item totals (not persisted — DB recomputes order money fields)
const itemsSubTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const itemsDiscount = orderItemsData.reduce((sum, item) => sum + item.discount * item.quantity, 0);
const deliveryFee = delivery.deliveryFeeType === DeliveryFeeTypeEnum.FIXED ? Number(delivery.deliveryFee) : 0;
const packingFee = dto.packingFee ?? 0;
const adminDiscount = dto.discountAmount ?? 0;
const maxDiscount = subTotal - itemsDiscount;
const maxDiscount = itemsSubTotal - itemsDiscount;
if (adminDiscount > maxDiscount) {
throw new BadRequestException(OrderMessage.DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL);
}
const totalDiscount = itemsDiscount + adminDiscount;
const total = subTotal - totalDiscount + deliveryFee + packingFee;
const totalItems = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
const order = await this.em.transactional(async em => {
const order = em.create(Order, {
@@ -195,19 +202,22 @@ export class OrdersService {
paymentMethod,
couponDiscount: adminDiscount,
couponDetail: null,
itemsDiscount,
totalDiscount,
subTotal,
tax: 0,
deliveryFee,
packingFee,
total,
totalItems,
description: dto.description,
printInvoice: false,
tableNumber: dto.tableNumber,
status: OrderStatus.PENDING_PAYMENT,
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
status: OrderStatus.COMPLETED,
history: [{ status: OrderStatus.NEW, changedAt: new Date() }],
subTotal: 0,
total: 0,
totalItems: 0,
totalDiscount: 0,
itemsDiscount: 0,
paidAmount: 0,
balance: 0,
refundedAmount: 0,
});
em.persist(order);
@@ -215,15 +225,17 @@ export class OrdersService {
for (const itemData of orderItemsData) {
const { food, quantity, unitPrice, discount } = itemData;
this.assertFoodHasSufficientStock(food, quantity);
const totalPrice = (unitPrice - discount) * quantity;
const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice });
const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice: 0 });
em.persist(orderItem);
}
await em.flush();
await em.refresh(order);
const payment = em.create(Payment, {
order,
amount: order.total,
status: PaymentStatusEnum.Pending,
status: PaymentStatusEnum.Paid,
method: order.paymentMethod.method,
gateway: order.paymentMethod.gateway ?? null,
});
@@ -271,20 +283,136 @@ export class OrdersService {
order.prepareTime = dto.prepareTime;
}
// total / balance / pending payment.amount are recalculated by DB triggers
await this.em.persistAndFlush(order);
if (feesChanged) {
order.total = order.subTotal - order.totalDiscount + order.deliveryFee + order.packingFee + order.tax;
const pendingPayment = await this.em.findOne(Payment, {
order: { id: orderId },
status: PaymentStatusEnum.Pending,
});
if (pendingPayment) {
pendingPayment.amount = order.total;
}
await this.em.refresh(order);
}
await this.em.persistAndFlush(order);
return order;
}
async addOrderItem(orderId: string, restId: string, dto: AdminAddOrderItemDto): Promise<Order> {
const order = await this.getOrderWithItemsOrFail(orderId, restId);
this.assertOrderEditable(order);
const existingItem = order.items.getItems().find(item => item.food.id === dto.foodId);
if (existingItem) {
const newQuantity = existingItem.quantity + dto.quantity;
return this.updateOrderItemQuantity(orderId, restId, existingItem.id, { quantity: newQuantity });
}
const [itemData] = await this.buildOrderItemsDataFromDto([{ foodId: dto.foodId, quantity: dto.quantity }], restId);
await this.em.transactional(async em => {
const orderItem = em.create(OrderItem, {
order,
food: itemData.food,
quantity: itemData.quantity,
unitPrice: itemData.unitPrice,
discount: itemData.discount,
totalPrice: 0,
});
em.persist(orderItem);
await this.inventoryService.deductFromInventory(em, {
items: [{ foodId: itemData.food.id, quantity: itemData.quantity }],
});
await em.flush();
});
return this.findOne(orderId, restId);
}
async updateOrderItemQuantity(
orderId: string,
restId: string,
itemId: string,
dto: AdminUpdateOrderItemDto,
): Promise<Order> {
const order = await this.getOrderWithItemsOrFail(orderId, restId);
this.assertOrderEditable(order);
const orderItem = order.items.getItems().find(item => item.id === itemId);
if (!orderItem) {
throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND);
}
if (orderItem.quantity === dto.quantity) {
return this.findOne(orderId, restId);
}
const delta = dto.quantity - orderItem.quantity;
const foodId = orderItem.food.id;
if (delta > 0) {
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['inventory'] });
if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND);
this.assertFoodHasSufficientStock(food, delta);
}
await this.em.transactional(async em => {
orderItem.quantity = dto.quantity;
em.persist(orderItem);
if (delta > 0) {
await this.inventoryService.deductFromInventory(em, {
items: [{ foodId, quantity: delta }],
});
} else {
await this.inventoryService.restoreToInventory(em, {
items: [{ foodId, quantity: Math.abs(delta) }],
});
}
await em.flush();
});
return this.findOne(orderId, restId);
}
async removeOrderItem(orderId: string, restId: string, itemId: string): Promise<Order> {
const order = await this.getOrderWithItemsOrFail(orderId, restId);
this.assertOrderEditable(order);
const items = order.items.getItems();
if (items.length <= 1) {
throw new BadRequestException(OrderMessage.CANNOT_REMOVE_LAST_ORDER_ITEM);
}
const orderItem = items.find(item => item.id === itemId);
if (!orderItem) {
throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND);
}
const foodId = orderItem.food.id;
const quantity = orderItem.quantity;
await this.em.transactional(async em => {
await this.inventoryService.restoreToInventory(em, {
items: [{ foodId, quantity }],
});
em.remove(orderItem);
await em.flush();
});
return this.findOne(orderId, restId);
}
private assertOrderEditable(order: Order) {
if ([OrderStatus.CANCELED, OrderStatus.COMPLETED].includes(order.status)) {
throw new BadRequestException(OrderMessage.CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER);
}
}
private async getOrderWithItemsOrFail(orderId: string, restId: string): Promise<Order> {
const order = await this.em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['items', 'items.food', 'items.food.inventory'] },
);
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
return order;
}
@@ -369,6 +497,7 @@ export class OrdersService {
'carAddress',
'paymentMethod',
'cashShift',
'cashShift.admin',
'payments',
'items',
'items.food',
@@ -415,6 +544,130 @@ export class OrdersService {
);
return order;
}
async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise<Order> {
return this.em.transactional(async em => {
const order = await em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['payments', 'user', 'restaurant', 'paymentMethod'] },
);
if (!order) {
throw new NotFoundException(OrderMessage.NOT_FOUND);
}
const paidAmount = Number(order.paidAmount ?? 0);
const balance = Number(order.balance ?? 0);
const isCanceled = order.status === OrderStatus.CANCELED;
const canRefund = balance < 0 || (isCanceled && paidAmount > 0);
if (!canRefund) {
throw new BadRequestException(OrderMessage.REFUND_NOT_ALLOWED);
}
const amount = Number(dto.amount);
if (!amount || amount <= 0) {
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
}
if (amount > paidAmount) {
throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_PAID);
}
if (!isCanceled && balance < 0 && amount > Math.abs(balance)) {
throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_OVERPAYMENT);
}
const paidPayments = order.payments
.getItems()
.filter(p => p.status === PaymentStatusEnum.Paid && Number(p.amount) > 0)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
if (paidPayments.length === 0) {
throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND);
}
let remaining = amount;
let walletRefundAmount = 0;
for (const payment of paidPayments) {
if (remaining <= 0) break;
const paymentAmount = Number(payment.amount);
const refundFromPayment = Math.min(paymentAmount, remaining);
if (refundFromPayment === paymentAmount) {
payment.status = PaymentStatusEnum.Refunded;
if (dto.description) {
payment.description = dto.description;
}
} else {
payment.amount = paymentAmount - refundFromPayment;
const refundPayment = em.create(Payment, {
order,
amount: refundFromPayment,
method: payment.method,
gateway: payment.gateway ?? null,
status: PaymentStatusEnum.Refunded,
description: dto.description ?? null,
referenceId: payment.referenceId ?? null,
transactionId: payment.transactionId ?? null,
});
em.persist(refundPayment);
}
if (payment.method === PaymentMethodEnum.Wallet) {
walletRefundAmount += refundFromPayment;
}
remaining -= refundFromPayment;
em.persist(payment);
}
if (remaining > 0) {
throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND);
}
if (walletRefundAmount > 0) {
if (!order.user) {
throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
}
const latestWalletTx = await em.findOne(
WalletTransaction,
{
user: { id: order.user.id },
restaurant: { id: order.restaurant.id },
},
{ orderBy: { createdAt: 'DESC' } },
);
if (!latestWalletTx) {
throw new NotFoundException(PaymentMessage.REFUND_WALLET_NOT_FOUND);
}
const newBalance = Number(latestWalletTx.balance ?? 0) + walletRefundAmount;
const creditTx = em.create(WalletTransaction, {
user: order.user,
restaurant: order.restaurant,
amount: walletRefundAmount,
type: WalletTransactionType.CREDIT,
reason: WalletTransactionReason.ORDER_REFUND,
balance: newBalance,
});
em.persist(creditTx);
}
await em.flush();
return em.findOneOrFail(
Order,
{ id: orderId },
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
);
});
}
/* Helpers */
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
const paymentMethod = order.paymentMethod?.method;
@@ -431,8 +684,8 @@ export class OrdersService {
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false;
if (to === OrderStatus.CANCELED) {
// only allow orders with status of PENDING_PAYMENT and PAID are allowed to be canceled by user
if (ref === 'user' && ![OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(from)) {
// only allow orders with status of NEW are allowed to be canceled by user
if (ref === 'user' && ![OrderStatus.NEW].includes(from)) {
return false;
} else if (ref === 'admin') {
return true;
@@ -441,7 +694,7 @@ export class OrdersService {
// only allow orders with status of PENDING_PAYMENT and payment
// method of cash are allowed to move to CONFIRMED directly
if (
from == OrderStatus.PENDING_PAYMENT &&
from == OrderStatus.NEW &&
to == OrderStatus.PREPARING &&
paymentMethod !== PaymentMethodEnum.Cash &&
paymentMethod !== PaymentMethodEnum.CreditCard
@@ -475,10 +728,6 @@ export class OrdersService {
return false;
}
if (paymentMethod === PaymentMethodEnum.Online) {
if (to === OrderStatus.PREPARING && from !== OrderStatus.PAID) return false;
}
return true;
}
@@ -709,7 +958,6 @@ export class OrdersService {
restaurant: { id: restId },
status: {
$in: [
OrderStatus.PAID,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
@@ -105,7 +105,7 @@ export class OrderRepository extends EntityRepository<Order> {
where.$or = searchConditions;
}
// Filter: Exclude orders with payment method Online and status pending_payment
// Filter: Exclude online orders that have no paid payment
if (excludeOnlinePendingPayment) {
const existingConditions = where.$and || [];
where.$and = [
@@ -113,7 +113,7 @@ export class OrderRepository extends EntityRepository<Order> {
{
$or: [
{ paymentMethod: { method: { $ne: PaymentMethodEnum.Online } } },
{ status: { $ne: OrderStatus.PENDING_PAYMENT } },
{ payments: { status: PaymentStatusEnum.Paid } },
],
},
];
@@ -124,7 +124,7 @@ export class OrderRepository extends EntityRepository<Order> {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'payments'] as never,
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'cashShift.admin', 'payments'] as never,
});
const totalPages = Math.ceil(total / limit);
@@ -28,7 +28,7 @@ export class PaymentsService {
const ctx = await this.loadAndValidateOrder(orderId);
// Idempotency: avoid creating/charging again for already-paid orders
if (ctx.order.status === OrderStatus.PAID) {
if (ctx.order.payments.find(p => p.status === PaymentStatusEnum.Paid)) {
return { paymentUrl: null };
}
@@ -112,7 +112,7 @@ export class PaymentsService {
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
if (!order.user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
if (order.status === OrderStatus.PAID) {
if (order.payments.find(p => p.status === PaymentStatusEnum.Paid)) {
return;
}
@@ -152,7 +152,6 @@ export class PaymentsService {
payment.status = PaymentStatusEnum.Paid;
payment.paidAt = new Date();
order.status = OrderStatus.PAID;
em.persist([ payment, order, newWalletTransaction]);
await em.flush();
@@ -232,9 +231,6 @@ export class PaymentsService {
}
this.markPaid(payment, result.referenceId);
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
payment.order.status = OrderStatus.PAID;
}
await em.flush();
return payment;
@@ -275,9 +271,7 @@ export class PaymentsService {
if (payment.status === PaymentStatusEnum.Paid) {
throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
}
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
payment.order.status = OrderStatus.PAID;
}
payment.status = PaymentStatusEnum.Paid;
payment.paidAt = new Date();
em.persist(payment);
@@ -4,6 +4,7 @@ import { CreateReviewDto } from '../dto/create-review.dto';
import { UpdateReviewDto } from '../dto/update-review.dto';
import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto';
import { ChangeStatusDto } from '../dto/change-status.dto';
import { ReplyReviewDto } from '../dto/reply-review.dto';
import {
ApiTags,
ApiOperation,
@@ -18,6 +19,7 @@ import {
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { UserId } from 'src/common/decorators/user-id.decorator';
import { AdminId } from 'src/common/decorators/admin-id.decorator';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { ReviewStatus } from '../enums/review-status.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
@@ -114,7 +116,7 @@ export class ReviewController {
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
findAllAdmin(@Query() dto: FindReviewsDto, @RestId() restId: string) {
return this.reviewService.findAll({ ...dto, restId: restId });
return this.reviewService.findAll({ ...dto, topLevel: true, restId: restId });
}
@UseGuards(AdminAuthGuard)
@@ -135,7 +137,7 @@ export class ReviewController {
@ApiOperation({ summary: 'review detail' })
@ApiParam({ name: 'id', required: true })
reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) {
return this.reviewService.findById(reviewId, restaurantId);
return this.reviewService.findWithReplies(reviewId, restaurantId);
}
@UseGuards(AdminAuthGuard)
@@ -153,6 +155,33 @@ export class ReviewController {
return this.reviewService.changeStatus(reviewId, changeStatusDto.status, restaurantId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_REVIEWS)
@Post('admin/reviews/:id/reply')
@ApiOperation({ summary: 'Create or update restaurant reply to a review' })
@ApiParam({ name: 'id', required: true, description: 'Parent review ID' })
@ApiBody({ type: ReplyReviewDto })
@ApiOkResponse({ description: 'The created or updated reply' })
reply(
@Param('id') reviewId: string,
@Body() replyReviewDto: ReplyReviewDto,
@RestId() restaurantId: string,
@AdminId() adminId: string,
) {
return this.reviewService.reply(reviewId, restaurantId, adminId, replyReviewDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_REVIEWS)
@Delete('admin/reviews/:id/reply')
@ApiOperation({ summary: 'Delete restaurant reply to a review' })
@ApiParam({ name: 'id', required: true, description: 'Parent review ID' })
removeReply(@Param('id') reviewId: string, @RestId() restaurantId: string) {
return this.reviewService.removeReply(reviewId, restaurantId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_REVIEWS)
@ApiBearerAuth()
@@ -9,11 +9,6 @@ export class CreateReviewDto {
@ApiProperty({ description: 'Food ID' })
foodId: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ description: 'Order ID' })
orderId: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
@@ -0,0 +1,10 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ReplyReviewDto {
@IsNotEmpty()
@IsString()
@MaxLength(2000)
@ApiProperty({ description: 'Restaurant reply comment', example: 'از بازخورد شما سپاسگزاریم' })
comment: string;
}
+14 -7
View File
@@ -2,24 +2,25 @@ import { Entity, Index, ManyToOne, Property, Unique, Enum } from '@mikro-orm/cor
import { BaseEntity } from '../../../common/entities/base.entity';
import { Food } from '../../foods/entities/food.entity';
import { User } from '../../users/entities/user.entity';
import { Order } from 'src/modules/orders/entities/order.entity';
import { Admin } from '../../admin/entities/admin.entity';
import { PositivePoint, NegativePoint } from '../enums/review-point.enum';
import { ReviewStatus } from '../enums/review-status.enum';
@Entity({ tableName: 'reviews' })
@Unique({ properties: ['order', 'food', 'user'] })
@Unique({ properties: ['food', 'user'] })
@Index({ properties: ['food', 'status'] })
@Index({ properties: ['user'] })
@Index({ properties: ['order'] })
@Index({ properties: ['parent'] })
@Index({ properties: ['admin'] })
export class Review extends BaseEntity {
@ManyToOne(() => Order)
order: Order;
@Property({ type: 'boolean' })
isBuyer: boolean = false
@ManyToOne(() => Food)
food: Food;
@ManyToOne(() => User)
user: User;
@ManyToOne(() => User, { nullable: true })
user?: User;
@Property({ type: 'text', nullable: true })
comment?: string;
@@ -36,4 +37,10 @@ export class Review extends BaseEntity {
@Enum(() => ReviewStatus)
@Property({ type: 'string', default: ReviewStatus.PENDING })
status: ReviewStatus = ReviewStatus.PENDING;
@ManyToOne(() => Review, { nullable: true })
parent?: Review;
@ManyToOne(() => Admin, { nullable: true })
admin?: Admin;
}
@@ -5,8 +5,6 @@ export class ReviewCreatedEvent {
public readonly userId: string,
public readonly foodId: string,
public readonly foodName: string,
public readonly orderId: string,
public readonly orderNumber: string | number,
public readonly rating: number,
) {}
}
@@ -46,13 +46,12 @@ export class ReviewListeners {
restaurantId: event.restaurantId,
message: {
title: NotifTitleEnum.REVIEW_CREATED,
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ${event.orderNumber} ثبت شد`,
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ثبت شد`,
sms: {
templateId: this.reviewCreatedSmsTemplateId,
parameters: {
foodName: event.foodName,
rating: event.rating.toString(),
orderNumber: String(event.orderNumber),
},
},
pushNotif: {
@@ -46,6 +46,7 @@ export class FoodRatingCronService {
food: { id: food.id },
status: ReviewStatus.APPROVED,
deletedAt: null,
parent: null,
},
{ fields: ['rating'] },
);
+110 -49
View File
@@ -1,21 +1,24 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { CreateReviewDto } from '../dto/create-review.dto';
import { UpdateReviewDto } from '../dto/update-review.dto';
import { ReplyReviewDto } from '../dto/reply-review.dto';
import { FindRestuarantReviewsDto, FindReviewsDto } from '../dto/find-reviews.dto';
import { ReviewRepository } from '../repositories/review.repository';
import { FoodRepository } from '../../foods/repositories/food.repository';
import { UserRepository } from '../../users/repositories/user.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData } from '@mikro-orm/core';
import { RequiredEntityData, wrap } from '@mikro-orm/core';
import { Review } from '../entities/review.entity';
import { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum';
import { Order } from '../../orders/entities/order.entity';
import { OrderItem } from '../../orders/entities/order-item.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { ReviewStatus } from '../enums/review-status.enum';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ReviewCreatedEvent } from '../events/review.events';
import { sanitizeText } from '../../utils/sanitize.util';
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
import { Admin } from '../../admin/entities/admin.entity';
@Injectable()
export class ReviewService {
@@ -28,9 +31,9 @@ export class ReviewService {
) { }
async create(userId: string, createReviewDto: CreateReviewDto): Promise<Review> {
const { foodId, orderId, rating, comment, positivePoints, negativePoints } = createReviewDto;
const { foodId, rating, comment, positivePoints, negativePoints } = createReviewDto;
const food = await this.foodRepository.findOne({ id: foodId });
const food = await this.foodRepository.findOne({ id: foodId }, { populate: ['restaurant'] });
if (!food) {
throw new NotFoundException(FoodMessage.NOT_FOUND);
}
@@ -40,34 +43,14 @@ export class ReviewService {
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
}
// Find order and verify it belongs to the user, populate restaurant to get restaurantId
const order = await this.em.findOne(Order, { id: orderId, user: { id: userId } }, { populate: ['restaurant'] });
if (!order) {
throw new NotFoundException('Order not found or does not belong to the current user');
}
// Verify the food is in the order
const orderItem = await this.em.findOne(OrderItem, {
order: { id: orderId },
food: { id: foodId },
});
if (!orderItem) {
throw new BadRequestException('Food is not in the specified order');
}
// Check if user already commented on this food for this order
const existingReview = await this.reviewRepository.findOne({
order: { id: orderId },
food: { id: foodId },
user: { id: userId },
});
if (existingReview) {
throw new BadRequestException(ReviewMessage.ALREADY_COMMENTED);
}
const isBuyer = await this.em.count(Order, {
status: OrderStatus.COMPLETED,
items: { food: { id: foodId } },
user: { id: userId }
}) > 0;
const data: RequiredEntityData<Review> = {
order,
isBuyer: true,
food,
user,
rating,
@@ -92,12 +75,10 @@ export class ReviewService {
ReviewCreatedEvent.name,
new ReviewCreatedEvent(
review.id,
order.restaurant.id,
food.restaurant.id,
userId,
foodId,
food.title || 'Unknown Food',
orderId,
order.orderNumber || '',
food.title || '',
rating,
),
);
@@ -105,7 +86,7 @@ export class ReviewService {
return review;
}
async findAll(dto: FindReviewsDto) {
async findAll(dto: FindReviewsDto & { topLevel?: boolean }) {
return this.reviewRepository.findAllPaginated(dto);
}
@@ -118,25 +99,102 @@ export class ReviewService {
return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id });
}
async findById(id: string, restaurantId: string): Promise<Review> {
const review = await this.reviewRepository.findOne(
{ id, order: { restaurant: { id: restaurantId } } },
{ populate: ['food', 'user', 'order'] },
async findWithReplies(reviewId: string, restaurantId: string): Promise<Review[]> {
const reviews = await this.reviewRepository.find(
{
$and: [
{
$or: [
{ id: reviewId },
{ parent: { id: reviewId } }
]
},
{ food: { restaurant: { id: restaurantId } } },
]
},
{ populate: ['food', 'user', 'admin'] },
);
if (!review) {
if (!reviews) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
return review;
return reviews;
}
async reply(reviewId: string, restaurantId: string, adminId: string, dto: ReplyReviewDto): Promise<Review> {
const parent = await this.reviewRepository.findOne(
{ id: reviewId, food: { restaurant: { id: restaurantId } }, parent: null },
{ populate: ['food'] },
);
if (!parent) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
const comment = sanitizeText(dto.comment);
if (!comment) {
throw new BadRequestException('متن پاسخ الزامی است');
}
const existingReply = await this.reviewRepository.findOne({
parent: reviewId,
deletedAt: null,
});
const admin = this.em.getReference(Admin, adminId);
if (existingReply) {
existingReply.comment = comment;
existingReply.status = ReviewStatus.APPROVED;
existingReply.admin = admin;
await this.em.persistAndFlush(existingReply);
return existingReply;
}
const reply = this.reviewRepository.create({
isBuyer: false,
food: parent.food,
comment,
rating: 0,
status: ReviewStatus.APPROVED,
parent: parent.id,
admin,
});
await this.em.persistAndFlush(reply);
return reply;
}
async removeReply(reviewId: string, restaurantId: string): Promise<void> {
const parent = await this.reviewRepository.findOne({
id: reviewId,
food: { restaurant: { id: restaurantId } },
parent: null,
});
if (!parent) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
const reply = await this.reviewRepository.findOne({
parent: reviewId,
deletedAt: null,
});
if (!reply) {
throw new NotFoundException(ReviewMessage.REPLY_NOT_FOUND);
}
reply.deletedAt = new Date();
await this.em.persistAndFlush(reply);
}
async update(id: string, userId: string, dto: UpdateReviewDto, isAdmin: boolean = false): Promise<Review> {
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user',] });
if (!review) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
// Only allow user to update their own reviews (unless admin)
if (!isAdmin && review.user.id !== userId) {
if (!isAdmin && review.user?.id !== userId) {
throw new BadRequestException(ReviewMessage.CAN_ONLY_UPDATE_OWN);
}
@@ -165,7 +223,7 @@ export class ReviewService {
}
async changeStatus(reviewId: string, status: ReviewStatus, restaurantId: string): Promise<Review> {
const review = await this.reviewRepository.findOne({ id: reviewId, order: { restaurant: { id: restaurantId } } });
const review = await this.reviewRepository.findOne({ id: reviewId, food: { restaurant: { id: restaurantId } } });
if (!review) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
@@ -175,27 +233,30 @@ export class ReviewService {
}
async remove(id: string, userId: string, isAdmin: boolean = false) {
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user'] });
if (!review) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
// Only allow user to delete their own reviews (unless admin)
if (!isAdmin && review.user.id !== userId) {
if (!isAdmin && review.user?.id !== userId) {
throw new BadRequestException(ReviewMessage.CAN_ONLY_DELETE_OWN);
}
const foodId = review.food.id;
const isTopLevel = !review.parent;
review.deletedAt = new Date();
await this.em.persistAndFlush(review);
// Update food rating average
await this.updateFoodRating(foodId);
// Update food rating average only for top-level reviews
if (isTopLevel) {
await this.updateFoodRating(foodId);
}
}
private async updateFoodRating(foodId: string): Promise<void> {
const reviews = await this.reviewRepository.find(
{ food: { id: foodId }, status: ReviewStatus.APPROVED, deletedAt: null },
{ food: { id: foodId }, status: ReviewStatus.APPROVED, deletedAt: null, parent: null },
{ fields: ['rating'] },
);
@@ -14,6 +14,8 @@ type FindReviewsOpts = {
orderBy?: string;
restId?: string;
order?: 'asc' | 'desc';
parentId?: string;
topLevel?: boolean;
};
@Injectable()
@@ -27,11 +29,20 @@ export class ReviewRepository extends EntityRepository<Review> {
* Supports: foodId, userId, status, ordering.
*/
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> {
const { page = 1, limit = 10, foodId, restId, userId, status, orderBy = 'createdAt', order = 'desc' } = opts;
const { page = 1, limit = 10, parentId, topLevel,
foodId, restId, userId, status, orderBy = 'createdAt', order = 'desc' } = opts;
const offset = (page - 1) * limit;
// Only list top-level reviews; replies are nested under their parent
const where: FilterQuery<Review> = {};
if (topLevel) {
where.parent = null;
}
if (parentId) {
where.parent = { id: parentId };
}
if (foodId) {
where.food = { id: foodId };
@@ -53,7 +64,7 @@ export class ReviewRepository extends EntityRepository<Review> {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['food', 'user'],
populate: ['user', 'admin', 'parent', 'parent.user'],
});
const totalPages = Math.ceil(total / limit);
@@ -5,7 +5,7 @@ import { EntityManager } from '@mikro-orm/postgresql';
import { OrderStatus } from '../../orders/interface/order.interface';
const COUNTED_ORDER_STATUSES = [
OrderStatus.PAID,
OrderStatus.NEW,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,