Compare commits

..

15 Commits

Author SHA1 Message Date
shancheas 7bb539db0c fix(SPG-1266): Penyesuaian time zone server untuk field pilih waktu - meminimalisasi timezone user/ client 2025-06-25 16:39:01 +07:00
shancheas 92b54635d0 fix(SPG-1262): ONLINE BOOKING - Redirect ke antrian jika tidak sama dengan today - seharusnya diarahkan ke halaman login jika invoice tidak sama dengan today 2025-06-25 15:48:59 +07:00
shancheas 7be4c26ef2 fix(SPG-1270): Pada Invoice/ tagihan booking tambahkan kode booking nya dan informasi booking date dan jumlah yang harus dibayarkan 2025-06-25 15:19:59 +07:00
shancheas 23b3c31810 refactor(SPG-1199): remove unique constraint on item name and update validation logic in item managers 2025-06-25 14:52:41 +07:00
shancheas b96d24de1a feat: add query filter for active booking items in BookingItemManager 2025-06-24 12:44:00 +07:00
shancheas 5d3f9d7bff fix(SPG-1254): ONLINE BOOKING wahana tenant tidak muncul pada catalog online booking 2025-06-24 11:51:43 +07:00
shancheas 831593e743 Merge branch 'development' of ssh://git.eigen.co.id:2222/eigen/pos-be into development 2025-06-20 14:48:55 +07:00
shancheas 162bd0918f fix: ensure safe access to season period IDs in booking item pricing logic 2025-06-20 14:48:45 +07:00
firmanr 13b5838393 Merge pull request 'feat(SPG-1137): add time group at booking and refund detail' (#161) from feat/otp-cancel into development
Reviewed-on: #161
2025-06-19 18:43:57 +07:00
firmanr 9d1c240b6b Merge pull request 'feat(SPG-1236): setup otp checker guard' (#160) from feat/otp-cancel into development
Reviewed-on: #160
2025-06-19 17:10:07 +07:00
firmanr 69c2ee06cf Merge pull request 'feat(SPG-1236): implement otp checker guard on session period' (#159) from feat/otp-cancel into development
Reviewed-on: #159
2025-06-19 17:04:51 +07:00
firmanr 62cfb1f1a8 Merge pull request 'feat/otp-cancel' (#158) from feat/otp-cancel into development
Reviewed-on: #158
2025-06-19 16:36:52 +07:00
shancheas 09b0133bf4 fix: update booking item pricing logic to use current rate based on season period IDs 2025-06-19 15:34:12 +07:00
shancheas a77e6b0381 feat: add usage_type field to items and item_queues with corresponding database migration 2025-06-19 14:49:09 +07:00
shancheas a5da557dd9 feat: implement booking expired notification in WhatsApp service 2025-06-19 14:40:17 +07:00
23 changed files with 271 additions and 37 deletions

View File

@ -55,7 +55,7 @@ export class ValidateRelationHelper<Entity> {
const relationColumn =
data[relation.relation]?.[`${relation.singleQuery[0]}`];
if (
!!relationColumn &&
// !!relationColumn &&
this.mappingValidator(
relationColumn,
relation.singleQuery[1],

View File

@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUsageTypeToItem1750319148269 implements MigrationInterface {
name = 'AddUsageTypeToItem1750319148269';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "public"."item_queues_usage_type_enum" AS ENUM('one_time', 'multiple')`,
);
await queryRunner.query(
`ALTER TABLE "item_queues" ADD "usage_type" "public"."item_queues_usage_type_enum" NOT NULL DEFAULT 'one_time'`,
);
await queryRunner.query(
`CREATE TYPE "public"."items_usage_type_enum" AS ENUM('one_time', 'multiple')`,
);
await queryRunner.query(
`ALTER TABLE "items" ADD "usage_type" "public"."items_usage_type_enum" NOT NULL DEFAULT 'one_time'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "items" DROP COLUMN "usage_type"`);
await queryRunner.query(`DROP TYPE "public"."items_usage_type_enum"`);
await queryRunner.query(
`ALTER TABLE "item_queues" DROP COLUMN "usage_type"`,
);
await queryRunner.query(`DROP TYPE "public"."item_queues_usage_type_enum"`);
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveItemNameUnique1750834308368 implements MigrationInterface {
name = 'RemoveItemNameUnique1750834308368';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "items" DROP CONSTRAINT "UQ_213736582899b3599acaade2cd1"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "items" ADD CONSTRAINT "UQ_213736582899b3599acaade2cd1" UNIQUE ("name")`,
);
}
}

View File

@ -42,8 +42,11 @@ export class BookingItemManager extends IndexItemManager {
const { data, total } = result;
const hasRates = (this.filterParam.season_period_ids?.length ?? 0) > 0;
const items = data.map((item) => {
const currentRate = item.item_rates.find((rate) =>
this.filterParam.season_period_ids?.includes(rate.season_period_id),
);
const { item_rates, ...rest } = item;
const rate = item_rates?.[0]?.['price'] ?? rest.base_price;
const rate = currentRate?.['price'] ?? rest.base_price;
return {
...rest,
base_price: hasRates ? rate : rest.base_price,
@ -57,11 +60,8 @@ export class BookingItemManager extends IndexItemManager {
): SelectQueryBuilder<ItemEntity> {
const query = super.setQueryFilter(queryBuilder);
if (this.filterParam.season_period_ids) {
query.andWhere(`item_rates.season_period_id In (:...seasonIds)`, {
seasonIds: this.filterParam.season_period_ids,
});
}
query.andWhere(`${this.tableName}.status = 'active'`);
return query;
}
}

View File

@ -57,6 +57,7 @@ export class CreateBookingManager extends CreateTransactionManager {
time: this.data.booking_date,
id: this.data.id,
},
this.data.payment_total,
`snap/v4/redirection/${this.data.payment_midtrans_token}`,
);
}

View File

@ -23,6 +23,7 @@ export class ItemController {
): Promise<PaginationResponse<ItemEntity>> {
params.limit = 1000;
params.show_to_booking = true;
params.all_item = true;
this.indexManager.setFilterParam(params);
this.indexManager.setService(this.serviceData, TABLE_NAME.ITEM);
await this.indexManager.execute();

View File

@ -5,3 +5,8 @@ export enum ItemType {
FREE_GIFT = 'free gift',
OTHER = 'other',
}
export enum UsageType {
ONE_TIME = 'one_time',
MULTIPLE = 'multiple',
}

View File

@ -2,7 +2,7 @@ import { TABLE_NAME } from 'src/core/strings/constants/table.constants';
import { ItemQueueEntity } from '../../domain/entities/item-queue.entity';
import { Column, Entity, OneToMany } from 'typeorm';
import { BaseStatusModel } from 'src/core/modules/data/model/base-status.model';
import { ItemType } from '../../constants';
import { ItemType, UsageType } from '../../constants';
import { ItemModel } from 'src/modules/item-related/item/data/models/item.model';
@Entity(TABLE_NAME.ITEM_QUEUE)
@ -29,6 +29,13 @@ export class ItemQueueModel
})
item_type: ItemType;
@Column('enum', {
name: 'usage_type',
enum: UsageType,
default: UsageType.ONE_TIME,
})
usage_type: UsageType;
@OneToMany(() => ItemModel, (model) => model.item_queue, {
onUpdate: 'CASCADE',
})

View File

@ -1,5 +1,5 @@
import { BaseStatusEntity } from 'src/core/modules/domain/entities/base-status.entity';
import { ItemType } from '../../constants';
import { ItemType, UsageType } from '../../constants';
import { ItemEntity } from 'src/modules/item-related/item/domain/entities/item.entity';
export interface ItemQueueEntity extends BaseStatusEntity {
@ -11,4 +11,5 @@ export interface ItemQueueEntity extends BaseStatusEntity {
items: ItemEntity[];
use_notification?: boolean;
requiring_notification?: boolean;
usage_type?: UsageType;
}

View File

@ -40,6 +40,7 @@ export class DetailItemQueueManager extends BaseDetailManager<ItemQueueEntity> {
`${this.tableName}.call_preparation`,
`${this.tableName}.use_notification`,
`${this.tableName}.requiring_notification`,
`${this.tableName}.usage_type`,
`items.id`,
`items.created_at`,

View File

@ -43,7 +43,7 @@ export class IndexItemQueueManager extends BaseIndexManager<ItemQueueEntity> {
`${this.tableName}.call_preparation`,
`${this.tableName}.use_notification`,
`${this.tableName}.requiring_notification`,
`${this.tableName}.usage_type`,
`items.id`,
`items.created_at`,
`items.status`,

View File

@ -18,13 +18,14 @@ import { ItemRateModel } from 'src/modules/item-related/item-rate/data/models/it
import { GateModel } from 'src/modules/web-information/gate/data/models/gate.model';
import { ItemQueueModel } from 'src/modules/item-related/item-queue/data/models/item-queue.model';
import { TimeGroupModel } from 'src/modules/item-related/time-group/data/models/time-group.model';
import { UsageType } from 'src/modules/item-related/item-queue/constants';
@Entity(TABLE_NAME.ITEM)
export class ItemModel
extends BaseStatusModel<ItemEntity>
implements ItemEntity
{
@Column('varchar', { name: 'name', unique: true })
@Column('varchar', { name: 'name' })
name: string;
@Column('text', { name: 'booking_description', nullable: true })
@ -43,6 +44,13 @@ export class ItemModel
})
item_type: ItemType;
@Column('enum', {
name: 'usage_type',
enum: UsageType,
default: UsageType.ONE_TIME,
})
usage_type: UsageType;
@Column('bigint', { name: 'hpp', nullable: true })
hpp: number;

View File

@ -2,7 +2,7 @@ import { BaseStatusEntity } from 'src/core/modules/domain/entities/base-status.e
import { ItemType } from 'src/modules/item-related/item-category/constants';
import { LimitType } from '../../constants';
import { ItemRateEntity } from 'src/modules/item-related/item-rate/domain/entities/item-rate.entity';
import { UsageType } from 'src/modules/item-related/item-queue/constants';
export interface ItemEntity extends BaseStatusEntity {
name: string;
item_type: ItemType;
@ -20,6 +20,7 @@ export interface ItemEntity extends BaseStatusEntity {
show_to_booking: boolean;
breakdown_bundling?: boolean;
booking_description?: string;
usage_type?: UsageType;
item_rates?: ItemRateEntity[] | any[];
}

View File

@ -8,6 +8,7 @@ import { ItemEntity } from '../../entities/item.entity';
import { ItemModel } from '../../../data/models/item.model';
import { BaseCreateManager } from 'src/core/modules/domain/usecase/managers/base-create.manager';
import { ItemCreatedEvent } from '../../entities/event/item-created.event';
import { STATUS } from 'src/core/strings/constants/base.constants';
@Injectable()
export class CreateItemManager extends BaseCreateManager<ItemEntity> {
@ -29,11 +30,37 @@ export class CreateItemManager extends BaseCreateManager<ItemEntity> {
}
get validateRelations(): validateRelations[] {
return [];
const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id;
const relation =
this.data.bundling_items?.length > 0
? 'bundling_items'
: 'bundling_parents';
return timeGroupId != null
? [
{
relation: relation,
singleQuery: ['time_group_id', '!=', timeGroupId],
message: `Gagal Update! Time group item dan bundling item tidak sama`,
},
]
: [];
}
get uniqueColumns(): columnUniques[] {
return [{ column: 'name' }];
const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id;
return timeGroupId != null
? [
{
column: 'name',
query: `(status = '${STATUS.ACTIVE}' AND (${this.tableName}.time_group_id Is Null OR ${this.tableName}.time_group_id = '${timeGroupId}'))`,
},
]
: [
{
column: 'name',
query: `(status = '${STATUS.ACTIVE}')`,
},
];
}
get eventTopics(): EventTopics[] {

View File

@ -53,6 +53,7 @@ export class DetailItemManager extends BaseDetailManager<ItemEntity> {
`${this.tableName}.total_price`,
`${this.tableName}.base_price`,
`${this.tableName}.use_queue`,
`${this.tableName}.usage_type`,
`${this.tableName}.show_to_booking`,
`${this.tableName}.breakdown_bundling`,
`${this.tableName}.play_estimation`,

View File

@ -55,6 +55,7 @@ export class IndexItemManager extends BaseIndexManager<ItemEntity> {
`${this.tableName}.play_estimation`,
`${this.tableName}.show_to_booking`,
`${this.tableName}.booking_description`,
`${this.tableName}.usage_type`,
`item_category.id`,
`item_category.name`,

View File

@ -8,6 +8,7 @@ import {
columnUniques,
validateRelations,
} from 'src/core/strings/constants/interface.constants';
import { STATUS } from 'src/core/strings/constants/base.constants';
@Injectable()
export class UpdateItemManager extends BaseUpdateManager<ItemEntity> {
@ -39,11 +40,31 @@ export class UpdateItemManager extends BaseUpdateManager<ItemEntity> {
}
get validateRelations(): validateRelations[] {
return [];
const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id;
const relation =
this.data.bundling_items?.length > 0
? 'bundling_items'
: 'bundling_parents';
return timeGroupId != null
? [
{
relation: relation,
singleQuery: ['time_group_id', '!=', timeGroupId],
message: `Gagal Update! Time group item dan bundling item tidak sama`,
},
]
: [];
}
get uniqueColumns(): columnUniques[] {
return [];
const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id;
return [
{
column: 'name',
query: `(status = '${STATUS.ACTIVE}' AND (${this.tableName}.time_group_id Is Null OR ${this.tableName}.time_group_id = '${timeGroupId}'))`,
},
];
}
get entityTarget(): any {

View File

@ -6,6 +6,8 @@ import {
Param,
RelationParam,
} from 'src/core/modules/domain/entities/base-filter.entity';
import * as moment from 'moment';
import { ORDER_TYPE } from 'src/core/strings/constants/base.constants';
// TODO:
// Implementasikan filter by start_time, end_timen, dan max_usage_time
@ -13,6 +15,10 @@ import {
@Injectable()
export class IndexPublicTimeGroupManager extends BaseIndexManager<TimeGroupEntity> {
async prepareData(): Promise<void> {
Object.assign(this.filterParam, {
order_by: `${this.tableName}.start_time`,
order_type: ORDER_TYPE.ASC,
});
return;
}
@ -60,6 +66,15 @@ export class IndexPublicTimeGroupManager extends BaseIndexManager<TimeGroupEntit
queryBuilder: SelectQueryBuilder<TimeGroupEntity>,
): SelectQueryBuilder<TimeGroupEntity> {
queryBuilder.andWhere(`items.id is not null`);
if (!this.filterParam.date) {
const currentTime = moment().utcOffset('+07:00').format('HH:mm:ss');
queryBuilder.andWhere(`${this.tableName}.end_time >= :current_time`, {
current_time: currentTime,
});
}
return queryBuilder;
}
}

View File

@ -30,4 +30,12 @@ export class FilterTimeGroupDto
@ApiProperty({ type: 'string', required: false })
@ValidateIf((body) => body.max_usage_time_to)
max_usage_time_to: string;
@ApiProperty({
type: Date,
required: false,
example: '2024-01-01',
})
@ValidateIf((body) => body.date)
date: Date;
}

View File

@ -51,27 +51,27 @@ export class TicketDataService extends BaseDataService<QueueTicket> {
],
});
if (!order) {
const { customer_name, customer_phone } =
await this.transaction.findOneOrFail({
where: {
id,
},
});
// if (!order) {
// const { customer_name, customer_phone } =
// await this.transaction.findOneOrFail({
// where: {
// id,
// },
// });
const start = moment().startOf('day').valueOf();
const end = moment().endOf('day').valueOf();
const order = this.order.findOneOrFail({
relations: ['tickets'],
where: {
customer: customer_name,
phone: customer_phone,
date: Between(start, end),
},
});
// const start = moment().startOf('day').valueOf();
// const end = moment().endOf('day').valueOf();
// const order = this.order.findOneOrFail({
// relations: ['tickets'],
// where: {
// customer: customer_name,
// phone: customer_phone,
// date: Between(start, end),
// },
// });
return order;
}
// return order;
// }
return order;
}

View File

@ -56,7 +56,7 @@ export class QueueOrchestrator {
return order;
} catch (error) {
throw new UnauthorizedException({
message: 'Invoice tidak ditemukan',
message: 'Invoice tidak ditemukan untuk tanggal hari ini',
error: 'INVOICE_NOT_FOUND',
});
}

View File

@ -77,6 +77,22 @@ export class MidtransCallbackHandler
await whatsappService.bookingCreated(payload);
}
if (
transaction.status === STATUS.EXPIRED &&
transaction.type === TransactionType.ONLINE
) {
const whatsappService = new WhatsappService();
const formattedDate = moment(transaction.booking_date);
const payload = {
id: transaction.id,
phone: transaction.customer_phone,
code: transaction.invoice_code,
name: transaction.customer_name,
time: formattedDate.valueOf(),
};
await whatsappService.bookingExpired(payload);
}
this.eventBus.publish(
new TransactionChangeStatusEvent({
id: data_id,

View File

@ -198,6 +198,57 @@ export class WhatsappService {
);
}
async bookingExpired(data: WhatsappBookingCreate) {
const momentDate = moment(data.time);
const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY');
// const dayOfWeek = momentDate.day();
// const dayOfMonth = momentDate.date();
// const year = momentDate.year();
// const month = momentDate.month() + 1;
// const hour = momentDate.hour();
// const minute = momentDate.minute();
const payload = {
messaging_product: 'whatsapp',
to: phoneNumberOnly(data.phone), // recipient's phone number
type: 'template',
template: {
name: 'booking_expired',
language: {
code: 'id', // language code
},
components: [
{
type: 'body',
parameters: [
{
type: 'text',
parameter_name: 'customer',
text: data.name, // replace with name variable
},
{
type: 'text',
parameter_name: 'code',
text: data.code, // replace with queue_code variable
},
{
type: 'text',
parameter_name: 'booking_date',
text: fallbackValue,
},
],
},
],
},
};
const response = await this.sendMessage(payload);
if (response)
Logger.log(
`Notification register Booking for ${data.code} send to ${data.phone}`,
);
}
async rescheduleCreated(data: WhatsappBookingCreate) {
const imageUrl = `${BOOKING_QR_URL}${data.id}`;
@ -273,10 +324,23 @@ export class WhatsappService {
);
}
async bookingRegister(data: WhatsappBookingCreate, paymentUrl: string) {
async bookingRegister(
data: WhatsappBookingCreate,
total: number,
paymentUrl: string,
) {
const momentDate = moment(data.time);
const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY');
const formattedTotal = new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.format(total)
.replace('IDR', 'Rp');
const payload = {
messaging_product: 'whatsapp',
to: phoneNumberOnly(data.phone), // recipient's phone number
@ -300,6 +364,16 @@ export class WhatsappService {
parameter_name: 'booking_date',
text: fallbackValue,
},
{
type: 'text',
parameter_name: 'booking_code',
text: data.code,
},
{
type: 'text',
parameter_name: 'total',
text: formattedTotal,
},
],
},
{