From 21da30a47d0703d3f019d9f2254e190d053f852a Mon Sep 17 00:00:00 2001 From: akbarsaputrait Date: Thu, 19 Sep 2024 23:42:55 +0700 Subject: [PATCH] [FIX-54] Owner/Cashier - Print Order Bill --- .../restaurant/order/detail.controller.ts | 88 +++++++++++++++++- .../restaurant/order/order.controller.ts | 3 + .../restaurant/order/detail.controller.ts | 88 +++++++++++++++++- src/core/services/pdf.service.ts | 15 ++- src/database/entities/core/order.entity.ts | 18 ++++ src/database/entities/owner/owner.entity.ts | 7 +- src/database/entities/staff/user.entity.ts | 7 +- .../migrations/1726761382132-order-staff.ts | 17 ++++ .../migrations/1726762144604-order-owner.ts | 17 ++++ src/library/pubsub/pubsub.lib.ts | 2 + templates/pdf/bill.hbs | 91 +++++++++++++++++++ 11 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 src/database/migrations/1726761382132-order-staff.ts create mode 100644 src/database/migrations/1726762144604-order-owner.ts create mode 100644 templates/pdf/bill.hbs diff --git a/src/app/owner/restaurant/order/detail.controller.ts b/src/app/owner/restaurant/order/detail.controller.ts index fa5685a..f101825 100644 --- a/src/app/owner/restaurant/order/detail.controller.ts +++ b/src/app/owner/restaurant/order/detail.controller.ts @@ -2,20 +2,24 @@ import { Rest } from '@core/decorators/restaurant.decorator'; import { Me } from '@core/decorators/user.decorator'; import { OwnerAuthGuard } from '@core/guards/auth.guard'; import { OwnerGuard } from '@core/guards/owner.guard'; +import { PdfService } from '@core/services/pdf.service'; import { PermAct, PermOwner } from '@core/services/role.service'; import { Notification, NotificationType } from '@db/entities/core/notification.entity'; import { OrderProduct, OrderProductStatus } from '@db/entities/core/order-product.entity'; import { Order, OrderStatus } from '@db/entities/core/order.entity'; import { Owner } from '@db/entities/owner/owner.entity'; import { ProductStock } from '@db/entities/owner/product-stock.entity'; +import { Restaurant } from '@db/entities/owner/restaurant.entity'; import { Table, TableStatus } from '@db/entities/owner/table.entity'; import { OrderTransformer } from '@db/transformers/order.transformer'; import { GenericException } from '@lib/exceptions/generic.exception'; import { ValidationException } from '@lib/exceptions/validation.exception'; +import { config } from '@lib/helpers/config.helper'; import { time } from '@lib/helpers/time.helper'; -import { titleCase } from '@lib/helpers/utils.helper'; +import { titleCase, writeFile } from '@lib/helpers/utils.helper'; import { Validator } from '@lib/helpers/validator.helper'; -import Socket from '@lib/pubsub/pubsub.lib'; +import Logger from '@lib/logger/logger.library'; +import Socket, { PubSubEventType, PubSubPayloadType, PubSubStatus } from '@lib/pubsub/pubsub.lib'; import { Permissions } from '@lib/rbac'; import AppDataSource from '@lib/typeorm/datasource.typeorm'; import { uuid } from '@lib/uid/uuid.library'; @@ -25,6 +29,8 @@ import { In } from 'typeorm'; @Controller(':order_id') @UseGuards(OwnerAuthGuard()) export class DetailController { + constructor(private pdf: PdfService) {} + static async action(order: Order, action: OrderStatus, actor: Owner) { try { // @TODO: How "REJECTED" flow works @@ -196,4 +202,82 @@ export class DetailController { return response.item(order, OrderTransformer); } + + @Get('/print') + @UseGuards(OwnerGuard) + @Permissions(`${PermOwner.Order}@${PermAct.R}`) + async generateBill(@Param() param, @Res() response, @Me() me: Owner, @Rest() restaurant: Restaurant) { + const request_id = uuid(); + const processing = async (): Promise => { + const order = await Order.findOneByOrFail({ id: param.order_id }); + + if (order.status !== OrderStatus.Completed) { + throw new GenericException(`Only completed order can be printed`); + } + + const location = await order.location; + const staff = await order.staff; + const owner = await order.owner; + + const products: { name: string; qty: string; price: string; total: string }[] = []; + const items = await order.order_products; + for (const item of items) { + const productVar = await item.product_variant; + const product = await productVar.product; + products.push({ + name: product.name.length > 20 ? product.name.substring(0, 17) + '...' : product.name, + qty: item.qty.toString(), + price: item.price.toLocaleString('id-ID', { style: 'currency', currency: 'IDR' }), + total: (item.price * item.qty).toLocaleString('id-ID', { style: 'currency', currency: 'IDR' }), + }); + } + + const payload = { + restaurantName: restaurant.name, + restaurantAddress: location.address || '-', + restaurantPhone: restaurant.phone || '-', + receiptNumber: order.number, + billedAt: time(order.billed_at).tz('Asia/Jakarta').format('YYYY-MM-DD HH:mm:ss'), + cashierName: (owner || staff).logName, + items: products, + total: order.net_total.toLocaleString('id-ID', { style: 'currency', currency: 'IDR' }), + }; + + const pdf = await this.pdf.billInvoice(payload); + const dir = `${config.getPublicPath()}/files`; + const filename = `${time().unix()}-order-bill.pdf`; + return writeFile(dir, filename, pdf); + }; + + processing() + .then((path) => { + // send event + Socket.getInstance().event(me.id, { + request_id, + status: PubSubStatus.Success, + type: PubSubEventType.OwnerGetBill, + payload: { + type: PubSubPayloadType.Download, + body: { + mime: 'text/href', + name: `${time().unix()}-order-bill.pdf`, + content: config.getDownloadURI(path), + }, + }, + }); + }) + .catch(async (error) => { + // send event + Socket.getInstance().event(me.id, { + request_id, + status: PubSubStatus.Fail, + type: PubSubEventType.OwnerGetBill, + error: error.message, + }); + + Logger.getInstance().notify(error); + }); + + return response.data({ request_id }); + } } diff --git a/src/app/owner/restaurant/order/order.controller.ts b/src/app/owner/restaurant/order/order.controller.ts index 62220b2..16131a5 100644 --- a/src/app/owner/restaurant/order/order.controller.ts +++ b/src/app/owner/restaurant/order/order.controller.ts @@ -4,6 +4,7 @@ import { Rest } from '@core/decorators/restaurant.decorator'; import { Me } from '@core/decorators/user.decorator'; import { OwnerAuthGuard } from '@core/guards/auth.guard'; import { OwnerGuard } from '@core/guards/owner.guard'; +import { PdfService } from '@core/services/pdf.service'; import { PermAct, PermOwner } from '@core/services/role.service'; import { Order } from '@db/entities/core/order.entity'; import { Location } from '@db/entities/owner/location.entity'; @@ -25,6 +26,8 @@ import * as ExcelJS from 'exceljs'; @Controller() @UseGuards(OwnerAuthGuard()) export class OrderController { + constructor(private pdf: PdfService) {} + @Get() @UseGuards(OwnerGuard) @Permissions(`${PermOwner.Order}@${PermAct.R}`) diff --git a/src/app/staff/restaurant/order/detail.controller.ts b/src/app/staff/restaurant/order/detail.controller.ts index ce87c54..6fb5dce 100644 --- a/src/app/staff/restaurant/order/detail.controller.ts +++ b/src/app/staff/restaurant/order/detail.controller.ts @@ -2,20 +2,24 @@ import { Rest } from '@core/decorators/restaurant.decorator'; import { Me } from '@core/decorators/user.decorator'; import { StaffAuthGuard } from '@core/guards/auth.guard'; import { StaffGuard } from '@core/guards/staff.guard'; +import { PdfService } from '@core/services/pdf.service'; import { PermAct, PermStaff } from '@core/services/role.service'; import { Notification, NotificationType } from '@db/entities/core/notification.entity'; import { OrderProduct, OrderProductStatus } from '@db/entities/core/order-product.entity'; import { Order, OrderStatus } from '@db/entities/core/order.entity'; import { ProductStock } from '@db/entities/owner/product-stock.entity'; +import { Restaurant } from '@db/entities/owner/restaurant.entity'; import { Table, TableStatus } from '@db/entities/owner/table.entity'; import { StaffUser } from '@db/entities/staff/user.entity'; import { OrderTransformer } from '@db/transformers/order.transformer'; import { GenericException } from '@lib/exceptions/generic.exception'; import { ValidationException } from '@lib/exceptions/validation.exception'; +import { config } from '@lib/helpers/config.helper'; import { time } from '@lib/helpers/time.helper'; -import { titleCase } from '@lib/helpers/utils.helper'; +import { titleCase, writeFile } from '@lib/helpers/utils.helper'; import { Validator } from '@lib/helpers/validator.helper'; -import Socket from '@lib/pubsub/pubsub.lib'; +import Logger from '@lib/logger/logger.library'; +import Socket, { PubSubEventType, PubSubPayloadType, PubSubStatus } from '@lib/pubsub/pubsub.lib'; import { Permissions } from '@lib/rbac'; import AppDataSource from '@lib/typeorm/datasource.typeorm'; import { uuid } from '@lib/uid/uuid.library'; @@ -25,6 +29,8 @@ import { In } from 'typeorm'; @Controller(':order_id') @UseGuards(StaffAuthGuard()) export class DetailController { + constructor(private pdf: PdfService) {} + static async action(order: Order, action: OrderStatus, actor: StaffUser) { try { // @TODO: How "REJECTED" flow works @@ -196,4 +202,82 @@ export class DetailController { return response.item(order, OrderTransformer); } + + @Get('/print') + @UseGuards(StaffGuard) + @Permissions(`${PermStaff.Order}@${PermAct.R}`) + async generateBill(@Param() param, @Res() response, @Me() me: StaffUser, @Rest() restaurant: Restaurant) { + const request_id = uuid(); + const processing = async (): Promise => { + const order = await Order.findOneByOrFail({ id: param.order_id }); + + if (order.status !== OrderStatus.Completed) { + throw new GenericException(`Only completed order can be printed`); + } + + const location = await order.location; + const staff = await order.staff; + const owner = await order.owner; + + const products: { name: string; qty: string; price: string; total: string }[] = []; + const items = await order.order_products; + for (const item of items) { + const productVar = await item.product_variant; + const product = await productVar.product; + products.push({ + name: product.name.length > 20 ? product.name.substring(0, 17) + '...' : product.name, + qty: item.qty.toString(), + price: item.price.toLocaleString('id-ID', { style: 'currency', currency: 'IDR' }), + total: (item.price * item.qty).toLocaleString('id-ID', { style: 'currency', currency: 'IDR' }), + }); + } + + const payload = { + restaurantName: restaurant.name, + restaurantAddress: location.address || '-', + restaurantPhone: restaurant.phone || '-', + receiptNumber: order.number, + billedAt: time(order.billed_at).tz('Asia/Jakarta').format('YYYY-MM-DD HH:mm:ss'), + cashierName: (owner || staff).logName, + items: products, + total: order.net_total.toLocaleString('id-ID', { style: 'currency', currency: 'IDR' }), + }; + + const pdf = await this.pdf.billInvoice(payload); + const dir = `${config.getPublicPath()}/files`; + const filename = `${time().unix()}-order-bill.pdf`; + return writeFile(dir, filename, pdf); + }; + + processing() + .then((path) => { + // send event + Socket.getInstance().event(me.id, { + request_id, + status: PubSubStatus.Success, + type: PubSubEventType.StaffGetBill, + payload: { + type: PubSubPayloadType.Download, + body: { + mime: 'text/href', + name: `${time().unix()}-order-bill.pdf`, + content: config.getDownloadURI(path), + }, + }, + }); + }) + .catch(async (error) => { + // send event + Socket.getInstance().event(me.id, { + request_id, + status: PubSubStatus.Fail, + type: PubSubEventType.StaffGetBill, + error: error.message, + }); + + Logger.getInstance().notify(error); + }); + + return response.data({ request_id }); + } } diff --git a/src/core/services/pdf.service.ts b/src/core/services/pdf.service.ts index c492962..b439e09 100644 --- a/src/core/services/pdf.service.ts +++ b/src/core/services/pdf.service.ts @@ -15,7 +15,6 @@ export class PdfService { create(template: string, data: any, options?: IPdfOption) { const html = fs.readFileSync(`${config.getTemplatePath()}/pdf/${template}.hbs`, 'utf8'); const compiled = handlebars.compile(html)(data); - const htmlToPdf = new HTMLToPDF(compiled, { waitForNetworkIdle: options.waitForNetworkIdle || false, browserOptions: { @@ -31,6 +30,20 @@ export class PdfService { return htmlToPdf.convert(); } + async billInvoice(data: any) { + return this.create('bill', data, { + pdf: { + width: '80mm', + margin: { + top: 0.5, + bottom: 0.5, + left: 0.5, + right: 0.5, + }, + }, + }); + } + async tableLabel(data: any) { return this.create( 'label-table', diff --git a/src/database/entities/core/order.entity.ts b/src/database/entities/core/order.entity.ts index fb8cbf1..5604a70 100644 --- a/src/database/entities/core/order.entity.ts +++ b/src/database/entities/core/order.entity.ts @@ -10,8 +10,10 @@ import { import { Exclude } from 'class-transformer'; import { Brackets, Column, Generated, Index, JoinColumn, ManyToOne, OneToMany, SelectQueryBuilder } from 'typeorm'; import { Location } from '../owner/location.entity'; +import { Owner } from '../owner/owner.entity'; import { Restaurant } from '../owner/restaurant.entity'; import { Table } from '../owner/table.entity'; +import { StaffUser } from '../staff/user.entity'; import { Customer } from './customer.entity'; import { OrderProduct } from './order-product.entity'; @@ -95,6 +97,22 @@ export class Order extends BaseEntity { @ManyToOne(() => Location, (loc) => loc.orders, { onDelete: 'CASCADE' }) location: Promise; + @Exclude() + @ForeignColumn() + staff_id: string; + + @JoinColumn() + @ManyToOne(() => StaffUser, (user) => user.orders, { onDelete: 'SET NULL' }) + staff: Promise; + + @Exclude() + @ForeignColumn() + owner_id: string; + + @JoinColumn() + @ManyToOne(() => Owner, (user) => user.orders, { onDelete: 'SET NULL' }) + owner: Promise; + @Exclude() @OneToMany(() => OrderProduct, (op) => op.order) order_products: Promise; diff --git a/src/database/entities/owner/owner.entity.ts b/src/database/entities/owner/owner.entity.ts index a9140f8..5dd56a0 100644 --- a/src/database/entities/owner/owner.entity.ts +++ b/src/database/entities/owner/owner.entity.ts @@ -11,7 +11,8 @@ import { StatusColumn, } from '@lib/typeorm/decorators'; import { Exclude } from 'class-transformer'; -import { JoinColumn, ManyToOne, OneToOne } from 'typeorm'; +import { JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; +import { Order } from '../core/order.entity'; import { Role } from '../core/role.entity'; import { Location } from './location.entity'; import { Restaurant } from './restaurant.entity'; @@ -85,6 +86,10 @@ export class Owner extends BaseEntity { @OneToOne(() => Media, (media) => media.owner) image: Promise; + @Exclude() + @OneToMany(() => Order, (order) => order.staff) + orders: Promise; + get isVerified() { return this.verified_at !== null; } diff --git a/src/database/entities/staff/user.entity.ts b/src/database/entities/staff/user.entity.ts index 72c8a80..f6eae90 100644 --- a/src/database/entities/staff/user.entity.ts +++ b/src/database/entities/staff/user.entity.ts @@ -12,7 +12,8 @@ import { StatusColumn, } from '@lib/typeorm/decorators'; import { Exclude } from 'class-transformer'; -import { JoinColumn, ManyToOne, OneToOne } from 'typeorm'; +import { JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; +import { Order } from '../core/order.entity'; import { Restaurant } from '../owner/restaurant.entity'; import { StaffRole } from './role.entity'; @@ -81,6 +82,10 @@ export class StaffUser extends BaseEntity { @ManyToOne(() => Restaurant, { onDelete: 'SET NULL' }) restaurant: Promise; + @Exclude() + @OneToMany(() => Order, (order) => order.staff) + orders: Promise; + get isBlocked() { return this.status === StaffUserStatus.Blocked; } diff --git a/src/database/migrations/1726761382132-order-staff.ts b/src/database/migrations/1726761382132-order-staff.ts new file mode 100644 index 0000000..c791b63 --- /dev/null +++ b/src/database/migrations/1726761382132-order-staff.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OrderStaff1726761382132 implements MigrationInterface { + name = 'OrderStaff1726761382132'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order\` ADD \`staff_id\` varchar(26) NULL`); + await queryRunner.query( + `ALTER TABLE \`order\` ADD CONSTRAINT \`FK_5c126f83b53f2c6c4caafdb914d\` FOREIGN KEY (\`staff_id\`) REFERENCES \`staff_user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order\` DROP FOREIGN KEY \`FK_5c126f83b53f2c6c4caafdb914d\``); + await queryRunner.query(`ALTER TABLE \`order\` DROP COLUMN \`staff_id\``); + } +} diff --git a/src/database/migrations/1726762144604-order-owner.ts b/src/database/migrations/1726762144604-order-owner.ts new file mode 100644 index 0000000..3280175 --- /dev/null +++ b/src/database/migrations/1726762144604-order-owner.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OrderOwner1726762144604 implements MigrationInterface { + name = 'OrderOwner1726762144604'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order\` ADD \`owner_id\` varchar(26) NULL`); + await queryRunner.query( + `ALTER TABLE \`order\` ADD CONSTRAINT \`FK_d9181c2d154dfb71af0e18d9669\` FOREIGN KEY (\`owner_id\`) REFERENCES \`owner\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`order\` DROP FOREIGN KEY \`FK_d9181c2d154dfb71af0e18d9669\``); + await queryRunner.query(`ALTER TABLE \`order\` DROP COLUMN \`owner_id\``); + } +} diff --git a/src/library/pubsub/pubsub.lib.ts b/src/library/pubsub/pubsub.lib.ts index 4b9a233..62651af 100644 --- a/src/library/pubsub/pubsub.lib.ts +++ b/src/library/pubsub/pubsub.lib.ts @@ -12,10 +12,12 @@ export enum PubSubEventType { OwnerCreateStock = 'owner_create_stock', OwnerGetTableLabel = 'owner_get_table_label', OwnerGetOrderExcel = 'owner_get_order_excel', + OwnerGetBill = 'owner_get_bill', // Staff StaffCreateStock = 'staff_create_stock', StaffGetTableLabel = 'staff_get_table_label', + StaffGetBill = 'staff_get_bill', // Customer CustomerCreateOrder = 'customer_create_order', diff --git a/templates/pdf/bill.hbs b/templates/pdf/bill.hbs new file mode 100644 index 0000000..b9cb43b --- /dev/null +++ b/templates/pdf/bill.hbs @@ -0,0 +1,91 @@ + + + + + + Struk Pembelian Restoran + + + +
+
{{restaurantName}}
+
{{restaurantAddress}}
+
Telp: {{restaurantPhone}}
+
+ +
+
Nomor Struk: #{{receiptNumber}}
+
Tanggal: {{billedAt}}
+
Kasir: {{cashierName}}
+
+ + + + + + + + + + + + {{#each items}} + + + + + + + {{/each}} + +
ItemQtyHargaTotal
{{this.name}}{{this.qty}}{{this.price}}{{this.total}}
+ +
+
Total: {{total}}
+
+ + + + \ No newline at end of file