Compare commits
10 Commits
f2da078c5a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 17223ced06 | |||
| 01ae67248a | |||
| 443b62eb8e | |||
| 4abc2ae6e5 | |||
| 204b4f2007 | |||
| a05643daf5 | |||
| e991215f5a | |||
| 13f21d4d3f | |||
| b9d7a4adc3 | |||
| 59e958e809 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260714120000_addCashShifts extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`
|
||||
create table "cash_shifts" (
|
||||
"id" char(26) not null,
|
||||
"created_at" timestamptz not null default now(),
|
||||
"updated_at" timestamptz not null default now(),
|
||||
"deleted_at" timestamptz null,
|
||||
"admin_id" char(26) not null,
|
||||
"restaurant_id" char(26) not null,
|
||||
"opened_at" timestamptz not null,
|
||||
"closed_at" timestamptz null,
|
||||
"opening_amount" numeric(10,0) not null default 0,
|
||||
"expected_amount" numeric(10,0) null,
|
||||
"counted_amount" numeric(10,0) null,
|
||||
"difference_amount" numeric(10,0) null,
|
||||
"orders" int not null default 0,
|
||||
"status" text check ("status" in ('open', 'closed')) not null default 'open',
|
||||
"notes" text null,
|
||||
constraint "cash_shifts_pkey" primary key ("id")
|
||||
);
|
||||
`);
|
||||
this.addSql(`create index "cash_shifts_created_at_index" on "cash_shifts" ("created_at");`);
|
||||
this.addSql(`create index "cash_shifts_deleted_at_index" on "cash_shifts" ("deleted_at");`);
|
||||
this.addSql(`create index "cash_shifts_admin_id_index" on "cash_shifts" ("admin_id");`);
|
||||
this.addSql(`create index "cash_shifts_restaurant_id_index" on "cash_shifts" ("restaurant_id");`);
|
||||
this.addSql(`create index "cash_shifts_restaurant_id_status_index" on "cash_shifts" ("restaurant_id", "status");`);
|
||||
this.addSql(`
|
||||
alter table "cash_shifts"
|
||||
add constraint "cash_shifts_admin_id_foreign"
|
||||
foreign key ("admin_id") references "admins" ("id") on update cascade;
|
||||
`);
|
||||
this.addSql(`
|
||||
alter table "cash_shifts"
|
||||
add constraint "cash_shifts_restaurant_id_foreign"
|
||||
foreign key ("restaurant_id") references "restaurants" ("id") on update cascade;
|
||||
`);
|
||||
|
||||
this.addSql(`alter table "orders" add column "cash_shift_id" char(26) null;`);
|
||||
this.addSql(`create index "orders_cash_shift_id_index" on "orders" ("cash_shift_id");`);
|
||||
this.addSql(`
|
||||
alter table "orders"
|
||||
add constraint "orders_cash_shift_id_foreign"
|
||||
foreign key ("cash_shift_id") references "cash_shifts" ("id") on update cascade on delete set null;
|
||||
`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table "orders" drop constraint if exists "orders_cash_shift_id_foreign";`);
|
||||
this.addSql(`drop index if exists "orders_cash_shift_id_index";`);
|
||||
this.addSql(`alter table "orders" drop column if exists "cash_shift_id";`);
|
||||
|
||||
this.addSql(`alter table "cash_shifts" drop constraint if exists "cash_shifts_restaurant_id_foreign";`);
|
||||
this.addSql(`alter table "cash_shifts" drop constraint if exists "cash_shifts_admin_id_foreign";`);
|
||||
this.addSql(`drop table if exists "cash_shifts" cascade;`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));`);
|
||||
}
|
||||
}
|
||||
@@ -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 = 'خطا در تایید پرداخت زرینپال',
|
||||
@@ -788,6 +800,15 @@ export const enum PagerMessage {
|
||||
CREATED_SUCCESS = 'درخواست پیج با موفقیت ایجاد شد',
|
||||
}
|
||||
|
||||
export const enum CashShiftMessage {
|
||||
NOT_FOUND = 'شیفت صندوق یافت نشد',
|
||||
ALREADY_OPEN = 'یک شیفت صندوق باز از قبل وجود دارد',
|
||||
ALREADY_CLOSED = 'این شیفت صندوق قبلا بسته شده است',
|
||||
ACCESS_DENIED = 'شما به این شیفت صندوق دسترسی ندارید',
|
||||
ADMIN_NOT_FOUND = 'مدیر یافت نشد',
|
||||
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
|
||||
}
|
||||
|
||||
export const enum UserSuccessMessage {
|
||||
ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد',
|
||||
SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد',
|
||||
|
||||
@@ -10,6 +10,9 @@ export enum Permission {
|
||||
|
||||
// Order Management
|
||||
MANAGE_ORDERS = 'manage_orders',
|
||||
VIEW_ORDER_REPORTS = 'view_order_reports',
|
||||
MANAGE_SHIFTS = 'manage_shifts',
|
||||
VIEW_ALL_SHIFTS = 'view_all_shifts',
|
||||
|
||||
// User & Admin Management
|
||||
MANAGE_ADMINS = 'manage_admins',
|
||||
@@ -46,6 +49,9 @@ export const PermissionTitles: Record<Permission, string> = {
|
||||
[Permission.MANAGE_FOODS]: 'مدیریت غذاها',
|
||||
[Permission.MANAGE_CATEGORIES]: 'مدیریت دستهبندیها',
|
||||
[Permission.MANAGE_ORDERS]: 'مدیریت سفارشات',
|
||||
[Permission.VIEW_ORDER_REPORTS]: 'مشاهده گزارشات سفارش',
|
||||
[Permission.MANAGE_SHIFTS]: 'مدیریت شیفتها',
|
||||
[Permission.VIEW_ALL_SHIFTS]: 'مشاهده همه شیفتها',
|
||||
[Permission.MANAGE_ADMINS]: 'مدیریت مدیران',
|
||||
[Permission.MANAGE_USERS]: 'مدیریت کاربران',
|
||||
[Permission.VIEW_REPORTS]: 'مشاهده گزارشات',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Controller, Get, Post, Param, UseGuards, Query, Body, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||
import { CashShiftsService } from '../providers/cash-shifts.service';
|
||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { OpenCashShiftDto } from '../dto/open-cash-shift.dto';
|
||||
import { CloseCashShiftDto } from '../dto/close-cash-shift.dto';
|
||||
import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto';
|
||||
|
||||
@ApiTags('cash-shifts')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class CashShiftsController {
|
||||
constructor(private readonly cashShiftsService: CashShiftsService) {}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_SHIFTS)
|
||||
@Post('admin/cash-shifts/open')
|
||||
@ApiOperation({ summary: 'Open a new cash shift' })
|
||||
@ApiBody({ type: OpenCashShiftDto })
|
||||
openShift(@RestId() restId: string, @AdminId() adminId: string, @Body() dto: OpenCashShiftDto) {
|
||||
return this.cashShiftsService.openShift(restId, adminId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_SHIFTS)
|
||||
@Patch('admin/cash-shifts/:shiftId/close')
|
||||
@ApiOperation({ summary: 'Close an open cash shift' })
|
||||
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
|
||||
@ApiBody({ type: CloseCashShiftDto })
|
||||
closeShift(
|
||||
@Param('shiftId') shiftId: string,
|
||||
@RestId() restId: string,
|
||||
@AdminId() adminId: string,
|
||||
@Body() dto: CloseCashShiftDto,
|
||||
) {
|
||||
return this.cashShiftsService.closeShift(shiftId, restId, adminId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_SHIFTS)
|
||||
@Get('admin/cash-shifts/current/summary')
|
||||
@ApiOperation({ summary: 'Calculate summary for the current open cash shift' })
|
||||
getCurrentShiftSummary(@RestId() restId: string, @AdminId() adminId: string) {
|
||||
return this.cashShiftsService.calculateCurrentShiftSummary(restId, adminId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_SHIFTS)
|
||||
@Get('admin/cash-shifts/current')
|
||||
@ApiOperation({ summary: 'Get the current open cash shift' })
|
||||
getCurrentOpenShift(@RestId() restId: string, @AdminId() adminId: string) {
|
||||
return this.cashShiftsService.getCurrentOpenShift(restId, adminId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_SHIFTS)
|
||||
@Get('admin/cash-shifts')
|
||||
@ApiOperation({ summary: 'List cash shifts with pagination and filters' })
|
||||
findAll(@RestId() restId: string, @AdminId() adminId: string, @Query() dto: FindCashShiftsDto) {
|
||||
return this.cashShiftsService.findAll(restId, adminId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_SHIFTS)
|
||||
@Get('admin/cash-shifts/:shiftId/summary')
|
||||
@ApiOperation({ summary: 'Calculate summary for a cash shift' })
|
||||
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
|
||||
getShiftSummary(
|
||||
@Param('shiftId') shiftId: string,
|
||||
@RestId() restId: string,
|
||||
@AdminId() adminId: string,
|
||||
) {
|
||||
return this.cashShiftsService.calculateShiftSummary(shiftId, restId, adminId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_SHIFTS)
|
||||
@Get('admin/cash-shifts/:shiftId')
|
||||
@ApiOperation({ summary: 'Get a cash shift by ID' })
|
||||
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
|
||||
findOne(
|
||||
@Param('shiftId') shiftId: string,
|
||||
@RestId() restId: string,
|
||||
@AdminId() adminId: string,
|
||||
) {
|
||||
return this.cashShiftsService.findOne(shiftId, restId, adminId);
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
@@ -146,7 +207,7 @@ export class OrdersController {
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@Permissions(Permission.VIEW_ORDER_REPORTS)
|
||||
@ApiOperation({ summary: 'Get order report grouped by food' })
|
||||
@Get('admin/orders/report/by-food')
|
||||
getFoodOrderReport(@RestId() restId: string, @Query() dto: FoodOrderReportDto) {
|
||||
@@ -154,7 +215,7 @@ export class OrdersController {
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@Permissions(Permission.VIEW_ORDER_REPORTS)
|
||||
@ApiOperation({ summary: 'Get order report grouped by day' })
|
||||
@Get('admin/orders/report/by-day')
|
||||
getDailyOrderReport(@RestId() restId: string, @Query() dto: DailyOrderReportDto) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 CloseCashShiftDto {
|
||||
@ApiProperty({ description: 'Physically counted cash amount', minimum: 0 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
countedAmount!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CashShiftStatus } from '../interface/cash-shift.interface';
|
||||
|
||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||
type SortOrder = (typeof sortOrderOptions)[number];
|
||||
|
||||
export class FindCashShiftsDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 10, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit: number = 10;
|
||||
|
||||
@ApiPropertyOptional({ enum: CashShiftStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(CashShiftStatus)
|
||||
status?: CashShiftStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adminId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'openedAt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderBy: string = 'openedAt';
|
||||
|
||||
@ApiPropertyOptional({ enum: sortOrderOptions, default: 'desc' })
|
||||
@IsOptional()
|
||||
@IsIn(sortOrderOptions)
|
||||
order: SortOrder = 'desc';
|
||||
}
|
||||
@@ -68,6 +68,11 @@ export class FindOrdersDto {
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cashShiftId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -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 OpenCashShiftDto {
|
||||
@ApiProperty({ description: 'Opening cash amount in drawer', minimum: 0 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
openingAmount!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Collection, Entity, Enum, Index, ManyToOne, OneToMany, Property } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Admin } from '../../admin/entities/admin.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { CashShiftStatus } from '../interface/cash-shift.interface';
|
||||
import { Order } from './order.entity';
|
||||
|
||||
@Entity({ tableName: 'cash_shifts' })
|
||||
@Index({ properties: ['restaurant'] })
|
||||
@Index({ properties: ['restaurant', 'status'] })
|
||||
@Index({ properties: ['admin'] })
|
||||
export class CashShift extends BaseEntity {
|
||||
@ManyToOne(() => Admin)
|
||||
admin!: Admin;
|
||||
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@Property({ columnType: 'timestamptz' })
|
||||
openedAt: Date = new Date();
|
||||
|
||||
@Property({ columnType: 'timestamptz', nullable: true })
|
||||
closedAt?: Date | null;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
openingAmount: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, nullable: true })
|
||||
expectedAmount?: number | null;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, nullable: true })
|
||||
countedAmount?: number | null;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, nullable: true })
|
||||
differenceAmount?: number | null;
|
||||
|
||||
@Property({ type: 'int', default: 0, fieldName: 'orders' })
|
||||
ordersCount: number = 0;
|
||||
|
||||
@Enum(() => CashShiftStatus)
|
||||
status: CashShiftStatus = CashShiftStatus.OPEN;
|
||||
|
||||
@Property({ type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
@OneToMany(() => Order, order => order.cashShift)
|
||||
shiftOrders = new Collection<Order>(this);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ import { OrderItem } from './order-item.entity';
|
||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||
import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||
import { Payment } from 'src/modules/payments/entities/payment.entity';
|
||||
import { CashShift } from './cash-shift.entity';
|
||||
|
||||
@Entity({ tableName: 'orders' })
|
||||
@Index({ properties: ['restaurant'] })
|
||||
@Index({ properties: ['restaurant', 'status'] })
|
||||
@Index({ properties: ['restaurant', 'orderNumber'] })
|
||||
@Index({ properties: ['cashShift'] })
|
||||
export class Order extends BaseEntity {
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
user?: User | null;
|
||||
@@ -32,6 +34,9 @@ export class Order extends BaseEntity {
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@ManyToOne(() => CashShift, { nullable: true })
|
||||
cashShift?: CashShift | null;
|
||||
|
||||
@ManyToOne(() => Delivery)
|
||||
deliveryMethod!: Delivery;
|
||||
|
||||
@@ -65,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;
|
||||
@@ -86,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;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PaymentMethodEnum } from '../../payments/interface/payment';
|
||||
|
||||
export enum CashShiftStatus {
|
||||
OPEN = 'open',
|
||||
CLOSED = 'closed',
|
||||
}
|
||||
|
||||
export const SHIFT_REVENUE_PAYMENT_METHODS = [
|
||||
PaymentMethodEnum.Cash,
|
||||
PaymentMethodEnum.Online,
|
||||
PaymentMethodEnum.CreditCard,
|
||||
] as const;
|
||||
|
||||
export interface CashShiftSummary {
|
||||
ordersCount: number;
|
||||
cashReceived: number;
|
||||
onlineReceived: number;
|
||||
creditCardReceived: number;
|
||||
totalReceived: number;
|
||||
expectedAmount: number;
|
||||
}
|
||||
@@ -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]: 'تحویل به گارسون',
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { OrdersService } from './providers/orders.service';
|
||||
import { CashShiftsService } from './providers/cash-shifts.service';
|
||||
import { OrdersController } from './controllers/orders.controller';
|
||||
import { CashShiftsController } from './controllers/cash-shifts.controller';
|
||||
import { Order } from './entities/order.entity';
|
||||
import { OrderItem } from './entities/order-item.entity';
|
||||
import { CashShift } from './entities/cash-shift.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||
import { Food } from '../foods/entities/food.entity';
|
||||
@@ -15,6 +18,7 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { OrderRepository } from './repositories/order.repository';
|
||||
import { CashShiftRepository } from './repositories/cash-shift.repository';
|
||||
import { OrderListeners } from './listeners/order.listeners';
|
||||
import { OrdersCrone } from './crone/order.crone';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
@@ -24,7 +28,7 @@ import { UserModule } from '../users/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, Food, UserAddress, PaymentMethod]),
|
||||
MikroOrmModule.forFeature([Order, OrderItem, CashShift, User, Restaurant, Food, UserAddress, PaymentMethod]),
|
||||
CartModule,
|
||||
UtilsModule,
|
||||
AuthModule,
|
||||
@@ -35,8 +39,8 @@ import { UserModule } from '../users/user.module';
|
||||
InventoryModule,
|
||||
forwardRef(() => UserModule)
|
||||
],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone],
|
||||
exports: [OrderRepository, OrdersService],
|
||||
controllers: [OrdersController, CashShiftsController],
|
||||
providers: [OrdersService, CashShiftsService, OrderRepository, CashShiftRepository, OrderListeners, OrdersCrone],
|
||||
exports: [OrderRepository, OrdersService, CashShiftsService],
|
||||
})
|
||||
export class OrdersModule { }
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { CashShift } from '../entities/cash-shift.entity';
|
||||
import { CashShiftStatus, CashShiftSummary } from '../interface/cash-shift.interface';
|
||||
import { CashShiftRepository } from '../repositories/cash-shift.repository';
|
||||
import { OpenCashShiftDto } from '../dto/open-cash-shift.dto';
|
||||
import { CloseCashShiftDto } from '../dto/close-cash-shift.dto';
|
||||
import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto';
|
||||
import { Admin } from '../../admin/entities/admin.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { CashShiftMessage } from 'src/common/enums/message.enum';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
import { calculateCashShiftSummary } from '../utils/calculate-cash-shift-summary.util';
|
||||
import { PermissionsService } from 'src/modules/roles/providers/permissions.service';
|
||||
|
||||
@Injectable()
|
||||
export class CashShiftsService {
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly cashShiftRepository: CashShiftRepository,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
async openShift(restaurantId: string, adminId: string, dto: OpenCashShiftDto): Promise<CashShift> {
|
||||
const existingOpenShift = await this.cashShiftRepository.findOpenShift(restaurantId);
|
||||
if (existingOpenShift) {
|
||||
throw new BadRequestException(CashShiftMessage.ALREADY_OPEN);
|
||||
}
|
||||
|
||||
const [admin, restaurant] = await Promise.all([
|
||||
this.em.findOne(Admin, { id: adminId }),
|
||||
this.em.findOne(Restaurant, { id: restaurantId }),
|
||||
]);
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundException(CashShiftMessage.ADMIN_NOT_FOUND);
|
||||
}
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException(CashShiftMessage.RESTAURANT_NOT_FOUND);
|
||||
}
|
||||
|
||||
const shift = this.em.create(CashShift, {
|
||||
admin,
|
||||
restaurant,
|
||||
openedAt: new Date(),
|
||||
openingAmount: dto.openingAmount,
|
||||
ordersCount: 0,
|
||||
notes: dto.notes ?? null,
|
||||
status: CashShiftStatus.OPEN,
|
||||
});
|
||||
|
||||
await this.em.persistAndFlush(shift);
|
||||
await this.em.populate(shift, ['admin', 'restaurant']);
|
||||
return shift;
|
||||
}
|
||||
|
||||
async closeShift(
|
||||
shiftId: string,
|
||||
restaurantId: string,
|
||||
adminId: string,
|
||||
dto: CloseCashShiftDto,
|
||||
): Promise<CashShift> {
|
||||
const shift = await this.em.findOne(
|
||||
CashShift,
|
||||
{ id: shiftId, restaurant: { id: restaurantId } },
|
||||
{ populate: ['admin', 'restaurant'] },
|
||||
);
|
||||
|
||||
if (!shift) {
|
||||
throw new NotFoundException(CashShiftMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.assertShiftAccess(shift, adminId, restaurantId);
|
||||
|
||||
if (shift.status === CashShiftStatus.CLOSED) {
|
||||
throw new BadRequestException(CashShiftMessage.ALREADY_CLOSED);
|
||||
}
|
||||
|
||||
const summary = await this.calculateShiftSummary(shiftId, restaurantId, adminId);
|
||||
const countedAmount = dto.countedAmount;
|
||||
|
||||
shift.expectedAmount = summary.expectedAmount;
|
||||
shift.countedAmount = countedAmount;
|
||||
shift.differenceAmount = countedAmount - summary.expectedAmount;
|
||||
shift.ordersCount = summary.ordersCount;
|
||||
shift.closedAt = new Date();
|
||||
shift.status = CashShiftStatus.CLOSED;
|
||||
if (dto.notes !== undefined) {
|
||||
shift.notes = dto.notes;
|
||||
}
|
||||
|
||||
await this.em.persistAndFlush(shift);
|
||||
return shift;
|
||||
}
|
||||
|
||||
async calculateShiftSummary(
|
||||
shiftId: string,
|
||||
restaurantId: string,
|
||||
adminId: string,
|
||||
): Promise<CashShiftSummary> {
|
||||
const shift = await this.findOne(shiftId, restaurantId, adminId);
|
||||
const orders = await this.getShiftOrders(shift, restaurantId);
|
||||
return calculateCashShiftSummary(Number(shift.openingAmount), orders);
|
||||
}
|
||||
|
||||
async calculateCurrentShiftSummary(
|
||||
restaurantId: string,
|
||||
adminId: string,
|
||||
): Promise<CashShiftSummary | null> {
|
||||
const shift = await this.getCurrentOpenShift(restaurantId, adminId);
|
||||
if (!shift) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orders = await this.getShiftOrders(shift, restaurantId);
|
||||
return calculateCashShiftSummary(Number(shift.openingAmount), orders);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
restaurantId: string,
|
||||
adminId: string,
|
||||
dto: FindCashShiftsDto,
|
||||
): Promise<PaginatedResult<CashShift>> {
|
||||
const canViewAll = await this.canViewAllShifts(adminId, restaurantId);
|
||||
const query = { ...dto };
|
||||
|
||||
if (!canViewAll) {
|
||||
query.adminId = adminId;
|
||||
}
|
||||
|
||||
return this.cashShiftRepository.findAllPaginated(restaurantId, query);
|
||||
}
|
||||
|
||||
async findOne(shiftId: string, restaurantId: string, adminId: string): Promise<CashShift> {
|
||||
const shift = await this.em.findOne(
|
||||
CashShift,
|
||||
{ id: shiftId, restaurant: { id: restaurantId } },
|
||||
{ populate: ['admin', 'restaurant'] },
|
||||
);
|
||||
|
||||
if (!shift) {
|
||||
throw new NotFoundException(CashShiftMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.assertShiftAccess(shift, adminId, restaurantId);
|
||||
return shift;
|
||||
}
|
||||
|
||||
async getCurrentOpenShift(restaurantId: string, adminId: string): Promise<CashShift | null> {
|
||||
const canViewAll = await this.canViewAllShifts(adminId, restaurantId);
|
||||
return this.cashShiftRepository.findOpenShift(restaurantId, canViewAll ? undefined : adminId);
|
||||
}
|
||||
|
||||
async assignOrderToOpenShift(order: Order, em: EntityManager = this.em): Promise<void> {
|
||||
if (order.cashShift) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restaurantId = typeof order.restaurant === 'string' ? order.restaurant : order.restaurant.id;
|
||||
const openShift = await em.findOne(CashShift, {
|
||||
restaurant: { id: restaurantId },
|
||||
status: CashShiftStatus.OPEN,
|
||||
});
|
||||
|
||||
if (!openShift) {
|
||||
return;
|
||||
}
|
||||
|
||||
order.cashShift = openShift;
|
||||
openShift.ordersCount += 1;
|
||||
em.persist(order);
|
||||
em.persist(openShift);
|
||||
}
|
||||
|
||||
private getShiftOrders(shift: CashShift, restaurantId: string): Promise<Order[]> {
|
||||
return this.em.find(
|
||||
Order,
|
||||
{ cashShift: shift, restaurant: { id: restaurantId } },
|
||||
{ populate: ['payments'] },
|
||||
);
|
||||
}
|
||||
|
||||
private async canViewAllShifts(adminId: string, restaurantId: string): Promise<boolean> {
|
||||
const permissions = await this.permissionsService.getAdminPermissions(adminId, restaurantId);
|
||||
return permissions.includes(Permission.VIEW_ALL_SHIFTS);
|
||||
}
|
||||
|
||||
private async assertShiftAccess(
|
||||
shift: CashShift,
|
||||
adminId: string,
|
||||
restaurantId: string,
|
||||
): Promise<void> {
|
||||
const canViewAll = await this.canViewAllShifts(adminId, restaurantId);
|
||||
if (canViewAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shiftAdminId = typeof shift.admin === 'string' ? shift.admin : shift.admin.id;
|
||||
if (shiftAdminId !== adminId) {
|
||||
throw new ForbiddenException(CashShiftMessage.ACCESS_DENIED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,11 +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 = {
|
||||
@@ -46,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],
|
||||
@@ -63,6 +68,7 @@ export class OrdersService {
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly cashShiftsService: CashShiftsService,
|
||||
) { }
|
||||
|
||||
async checkout(userId: string, restaurantId: string) {
|
||||
@@ -79,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);
|
||||
@@ -101,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,
|
||||
@@ -133,6 +144,7 @@ export class OrdersService {
|
||||
})),
|
||||
};
|
||||
await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto);
|
||||
await this.cashShiftsService.assignOrderToOpenShift(order, em);
|
||||
await em.flush();
|
||||
this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`);
|
||||
return order;
|
||||
@@ -169,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, {
|
||||
@@ -192,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);
|
||||
@@ -212,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,
|
||||
});
|
||||
@@ -230,6 +245,7 @@ export class OrdersService {
|
||||
items: orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity })),
|
||||
};
|
||||
await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto);
|
||||
await this.cashShiftsService.assignOrderToOpenShift(order, em);
|
||||
await em.flush();
|
||||
|
||||
this.logger.debug(`Admin created order ${order.id} for user ${user?.id || 'anonymous'} (restaurant: ${restaurantId})`);
|
||||
@@ -267,20 +283,136 @@ export class OrdersService {
|
||||
order.prepareTime = dto.prepareTime;
|
||||
}
|
||||
|
||||
if (feesChanged) {
|
||||
order.total = order.subTotal - order.totalDiscount + order.deliveryFee + order.packingFee + order.tax;
|
||||
// total / balance / pending payment.amount are recalculated by DB triggers
|
||||
await this.em.persistAndFlush(order);
|
||||
|
||||
const pendingPayment = await this.em.findOne(Payment, {
|
||||
order: { id: orderId },
|
||||
status: PaymentStatusEnum.Pending,
|
||||
if (feesChanged) {
|
||||
await this.em.refresh(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();
|
||||
});
|
||||
|
||||
if (pendingPayment) {
|
||||
pendingPayment.amount = order.total;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await this.em.persistAndFlush(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;
|
||||
}
|
||||
|
||||
@@ -347,6 +479,7 @@ export class OrdersService {
|
||||
orderBy: dto.orderBy,
|
||||
order: dto.order,
|
||||
excludeOnlinePendingPayment: true,
|
||||
cashShiftId: dto.cashShiftId,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -363,6 +496,8 @@ export class OrdersService {
|
||||
'userAddress',
|
||||
'carAddress',
|
||||
'paymentMethod',
|
||||
'cashShift',
|
||||
'cashShift.admin',
|
||||
'payments',
|
||||
'items',
|
||||
'items.food',
|
||||
@@ -409,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;
|
||||
@@ -425,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;
|
||||
@@ -435,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
|
||||
@@ -469,10 +728,6 @@ export class OrdersService {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (paymentMethod === PaymentMethodEnum.Online) {
|
||||
if (to === OrderStatus.PREPARING && from !== OrderStatus.PAID) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -703,7 +958,6 @@ export class OrdersService {
|
||||
restaurant: { id: restId },
|
||||
status: {
|
||||
$in: [
|
||||
OrderStatus.PAID,
|
||||
OrderStatus.PREPARING,
|
||||
OrderStatus.DELIVERED_TO_WAITER,
|
||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { FilterQuery } from '@mikro-orm/core';
|
||||
import { CashShift } from '../entities/cash-shift.entity';
|
||||
import { CashShiftStatus } from '../interface/cash-shift.interface';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
|
||||
type FindCashShiftsOpts = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: CashShiftStatus;
|
||||
adminId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashShiftRepository extends EntityRepository<CashShift> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, CashShift);
|
||||
}
|
||||
|
||||
async findAllPaginated(restId: string, opts: FindCashShiftsOpts = {}): Promise<PaginatedResult<CashShift>> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
adminId,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy = 'openedAt',
|
||||
order = 'desc',
|
||||
} = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const where: FilterQuery<CashShift> = { restaurant: { id: restId } };
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (adminId) {
|
||||
where.admin = { id: adminId };
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.openedAt = {};
|
||||
if (startDate) {
|
||||
where.openedAt.$gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.openedAt.$lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const [data, total] = await this.findAndCount(where, {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order },
|
||||
populate: ['admin', 'restaurant'],
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
findOpenShift(restId: string, adminId?: string): Promise<CashShift | null> {
|
||||
const where: FilterQuery<CashShift> = {
|
||||
restaurant: { id: restId },
|
||||
status: CashShiftStatus.OPEN,
|
||||
};
|
||||
|
||||
if (adminId) {
|
||||
where.admin = { id: adminId };
|
||||
}
|
||||
|
||||
return this.findOne(where, { populate: ['admin'] });
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type FindOrdersOpts = {
|
||||
orderBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
userId?: string;
|
||||
cashShiftId?: string;
|
||||
excludeOnlinePendingPayment?: boolean;
|
||||
};
|
||||
|
||||
@@ -43,6 +44,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
order = 'desc',
|
||||
userId,
|
||||
excludeOnlinePendingPayment = false,
|
||||
cashShiftId,
|
||||
} = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -70,6 +72,10 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
where.user = { id: userId };
|
||||
}
|
||||
|
||||
if (cashShiftId) {
|
||||
where.cashShift = { id: cashShiftId };
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
@@ -99,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 = [
|
||||
@@ -107,7 +113,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
{
|
||||
$or: [
|
||||
{ paymentMethod: { method: { $ne: PaymentMethodEnum.Online } } },
|
||||
{ status: { $ne: OrderStatus.PENDING_PAYMENT } },
|
||||
{ payments: { status: PaymentStatusEnum.Paid } },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -118,57 +124,9 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'items', 'items.food'] as never,
|
||||
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'cashShift.admin', 'payments'] as never,
|
||||
});
|
||||
|
||||
// Collect all (orderId, foodId) pairs for efficient review lookup
|
||||
const orderFoodPairs: Array<{ orderId: string; foodId: string }> = [];
|
||||
for (const order of data) {
|
||||
for (const item of order.items.getItems()) {
|
||||
if (item.food?.id) {
|
||||
orderFoodPairs.push({ orderId: order.id, foodId: item.food.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all relevant reviews in a single query
|
||||
const reviewsMap: Map<string, string> = new Map();
|
||||
if (orderFoodPairs.length > 0) {
|
||||
const orderIds = [...new Set(orderFoodPairs.map(p => p.orderId))];
|
||||
const foodIds = [...new Set(orderFoodPairs.map(p => p.foodId))];
|
||||
|
||||
const reviews = await this.em.find(
|
||||
Review,
|
||||
{
|
||||
order: { id: { $in: orderIds } },
|
||||
food: { id: { $in: foodIds } },
|
||||
},
|
||||
{
|
||||
fields: ['id', 'order', 'food'],
|
||||
populate: ['order', 'food'],
|
||||
},
|
||||
);
|
||||
|
||||
// Create a map: key = `${orderId}-${foodId}`, value = reviewId
|
||||
for (const review of reviews) {
|
||||
const key = `${review.order.id}-${review.food.id}`;
|
||||
reviewsMap.set(key, review.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Map reviewIds to foods
|
||||
for (const order of data) {
|
||||
for (const item of order.items.getItems()) {
|
||||
if (item.food) {
|
||||
const key = `${order.id}-${item.food.id}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const food = item.food as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
food.reviewId = reviewsMap.get(key) || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||
import { CashShiftSummary, SHIFT_REVENUE_PAYMENT_METHODS } from '../interface/cash-shift.interface';
|
||||
|
||||
const shiftRevenueMethods = new Set<PaymentMethodEnum>(SHIFT_REVENUE_PAYMENT_METHODS);
|
||||
|
||||
export function calculateCashShiftSummary(openingAmount: number, orders: Order[]): CashShiftSummary {
|
||||
let cashReceived = 0;
|
||||
let onlineReceived = 0;
|
||||
let creditCardReceived = 0;
|
||||
|
||||
for (const order of orders) {
|
||||
for (const payment of order.payments.getItems()) {
|
||||
if (payment.status !== PaymentStatusEnum.Paid || !shiftRevenueMethods.has(payment.method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const amount = Number(payment.amount);
|
||||
switch (payment.method) {
|
||||
case PaymentMethodEnum.Cash:
|
||||
cashReceived += amount;
|
||||
break;
|
||||
case PaymentMethodEnum.Online:
|
||||
onlineReceived += amount;
|
||||
break;
|
||||
case PaymentMethodEnum.CreditCard:
|
||||
creditCardReceived += amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalReceived = cashReceived + onlineReceived + creditCardReceived;
|
||||
|
||||
return {
|
||||
ordersCount: orders.length,
|
||||
cashReceived,
|
||||
onlineReceived,
|
||||
creditCardReceived,
|
||||
totalReceived,
|
||||
expectedAmount: Number(openingAmount) + totalReceived,
|
||||
};
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export class PaymentsController {
|
||||
@Post('admin/payments/verify/:paymentId')
|
||||
@ApiOperation({ summary: 'Verify cash/creditcard payment ' })
|
||||
@ApiParam({ name: 'paymentId', type: 'string', description: 'Payment ID' })
|
||||
verifyCashPayment(@Param('paymentId') paymentId: string) {
|
||||
verifyCashCreditPayment(@Param('paymentId') paymentId: string) {
|
||||
return this.paymentsService.verifyCashAnCreditPayment(paymentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'] },
|
||||
);
|
||||
|
||||
@@ -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
|
||||
// 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,
|
||||
|
||||
@@ -33,6 +33,9 @@ export const rolesData: RoleConfig[] = [
|
||||
name: 'حسابدار ',
|
||||
isSystem: false,
|
||||
permissionFilter: (name: Permission) =>
|
||||
name === Permission.VIEW_REPORTS || name === Permission.MANAGE_PAYMENTS || name === Permission.MANAGE_ORDERS,
|
||||
name === Permission.VIEW_REPORTS ||
|
||||
name === Permission.VIEW_ORDER_REPORTS ||
|
||||
name === Permission.MANAGE_PAYMENTS ||
|
||||
name === Permission.MANAGE_ORDERS,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user