import { createCanvas } from "canvas"; import JsBarcode from "jsbarcode"; import createReport from "docx-templates"; import ThaiBahtText from "thai-baht-text"; import { District, Province, SubDistrict } from "@prisma/client"; import { Readable } from "node:stream"; import { Controller, Get, Path, Query, Route, Tags } from "tsoa"; import prisma from "../db"; import { notFoundError } from "../utils/error"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { getFileBuffer, listFile } from "../utils/minio"; import { dateFormat } from "../utils/datetime"; import { downloadFile as edmDownloadFile, list as edmList } from "../services/edm/edm-api"; const DOCUMENT_PATH = process.env.DOCUMENT_TEMPLATE_LOCATION?.split("/").filter(Boolean) || []; const quotationData = (id: string) => prisma.quotation.findFirst({ where: { id, isDebitNote: false }, include: { registeredBranch: { include: { province: true, district: true, subDistrict: true, headOffice: { include: { province: true, district: true, subDistrict: true, }, }, }, }, customerBranch: { include: { customer: true, businessType: true, province: true, district: true, subDistrict: true, }, }, worker: { include: { employee: { include: { province: true, district: true, subDistrict: true, employeePassport: { orderBy: { expireDate: "desc" }, }, employeeWork: true, }, }, }, }, productServiceList: { include: { work: true, product: true, service: true, }, }, createdBy: { include: { province: true, district: true, subDistrict: true, }, }, }, }); const requestWorkData = (id: string, step?: number) => prisma.requestWork.findFirst({ where: { id }, include: { processByUser: true, productService: { include: { product: true, }, }, request: { include: { employee: { include: { subDistrict: true, district: true, province: true, }, }, quotation: true, }, }, stepStatus: { where: { step }, }, }, }); @Route("api/v1/doc-template") @Tags("Document Template") export class DocTemplateController extends Controller { @Get() async getTemplate(@Query() templateGroup?: string) { if ( process.env.DOCUMENT_TEMPLATE_PROVIDER && process.env.DOCUMENT_TEMPLATE_PROVIDER === "edm-api" ) { const ret = await edmList( "file", templateGroup ? [...DOCUMENT_PATH, templateGroup] : DOCUMENT_PATH, ); if (ret) return ret.map((v) => v.fileName); } return await listFile( (templateGroup ? [...DOCUMENT_PATH, templateGroup] : DOCUMENT_PATH).join("/") + "/", ); } @Get("{documentTemplate}") async getDocument( @Path() documentTemplate: string, @Query() data: string, @Query() dataId: string, @Query() dataOnly?: boolean, @Query() templateGroup?: string, ): Promise> { let record: Record; switch (data) { case "quotation": record = await quotationData(dataId).then(async (quotation) => replaceEmptyField({ quotation, customerBranch: quotation?.customerBranch, registeredBranch: quotation?.registeredBranch, employee: quotation?.worker.map((item) => item.employee), employeeCount: { all: quotation?.worker.length, male: quotation?.worker.filter((item) => item.employee.gender === "male").length, female: quotation?.worker.filter((item) => item.employee.gender === "female").length, }, employmentOffice: quotation && quotation.customerBranch.districtId ? await prisma.employmentOffice.findFirst({ where: { OR: [ { province: { district: { some: { id: quotation.customerBranch.districtId } }, }, district: { none: {} }, }, { district: { some: { districtId: quotation.customerBranch.districtId }, }, }, ], }, orderBy: [{ provinceId: "asc" }, { id: "asc" }], }) : undefined, }), ); break; case "request-work": record = await requestWorkData(dataId).then((requestWork) => ({ request: replaceEmptyField(requestWork?.request), requestWork: replaceEmptyField(requestWork), employee: requestWork?.request.employee, })); break; default: throw new HttpError(HttpStatus.BAD_REQUEST, "No data for template", "noDataTemplate"); } if (!data) throw notFoundError("Data"); if (dataOnly) return record; if (templateGroup) documentTemplate = templateGroup + "/" + documentTemplate; let template: Buffer | null = null; switch (process.env.DOCUMENT_TEMPLATE_PROVIDER) { case "edm-api": await edmDownloadFile(DOCUMENT_PATH, documentTemplate).then(async (payload) => { if (!payload) return; const res = await fetch(payload.downloadUrl); if (!res.ok) return; template = Buffer.from(await res.arrayBuffer()); }); break; case "local": default: template = await getFileBuffer(`${DOCUMENT_PATH.join("/")}/${documentTemplate}`); } if (!template) { throw new HttpError( HttpStatus.INTERNAL_SERVER_ERROR, "Failed to get template file", "templateGetFailed", ); } if (!data) Readable.from(template); const report = await createReport({ template, data: record, additionalJsContext: { date: (date: string, locale?: string) => dateFormat({ date, locale }), dateTime: (date: string, locale?: string) => dateFormat({ date, withTime: true, locale }), dateLong: (date: string, locale?: string) => dateFormat({ date, locale, monthStyle: "long" }), dateLongTime: (date: string, locale?: string) => dateFormat({ date, withTime: true, locale, monthStyle: "long" }), dateTH: (date: string) => dateFormat({ date, locale: "th-TH" }), dateTimeTH: (date: string) => dateFormat({ date, withTime: true, locale: "th-TH" }), dateLongTH: (date: string) => dateFormat({ date, locale: "th-TH", monthStyle: "long" }), dateTimeLongTH: (date: string) => dateFormat({ date, withTime: true, locale: "th-TH", monthStyle: "long" }), dateEN: (date: string) => dateFormat({ date, locale: "en-US" }), dateTimeEN: (date: string) => dateFormat({ date, withTime: true, locale: "en-US", monthStyle: "long" }), dateLongEN: (date: string) => dateFormat({ date, locale: "en-US" }), dateTimeLongEN: (date: string) => dateFormat({ date, withTime: true, locale: "en-US", monthStyle: "long" }), address, addressTH: (addr: FullAddress) => address(addr, "th"), addressEN: (addr: FullAddress) => address(addr, "en"), addressFull, addressFullTH: (addr: FullAddress) => addressFull(addr, "th"), addressFullEN: (addr: FullAddress) => addressFull(addr, "en"), gender, genderTH: (text: string) => gender(text, "th"), genderEN: (text: string) => gender(text, "en"), businessType, businessTypeEN: (text: string) => businessType(text, "en"), businessTypeTH: (text: string) => businessType(text, "th"), namePrefix, namePrefixEN: (text: string) => namePrefix(text, "en"), namePrefixTH: (text: string) => namePrefix(text, "th"), jobPosition, jobPositionEN: (text: string) => jobPosition(text, "en"), jobPositionTH: (text: string) => jobPosition(text, "th"), nationality, nationalityEN: (text: string) => nationality(text, "en"), nationalityTH: (text: string) => nationality(text, "th"), thaiBahtText: (input: string | number) => { ThaiBahtText(typeof input === "string" ? input.replaceAll(",", "") : input); }, barcode: async (data: string, width?: number, height?: number) => new Promise<{ width: number; height: number; data: string; extension: string; }>((resolve) => { const canvas = createCanvas(400, 100); JsBarcode(canvas, data); resolve({ width: width ?? 8, height: height ?? 3, data: canvas.toDataURL("image/jpeg").slice("data:image/jpeg;base64".length), extension: ".jpeg", }); }), }, }).then(Buffer.from); return Readable.from(report); } } function replaceEmptyField(data: T): T { try { return JSON.parse(JSON.stringify(data).replace(/null|\"\"/g, '"\-"')); } catch (e) { return data; } } type FullAddress = { address: string; addressEN: string; moo?: string; mooEN?: string; soi?: string; soiEN?: string; street?: string; streetEN?: string; province?: Province | null; district?: District | null; subDistrict?: SubDistrict | null; en?: boolean; }; function address(addr: FullAddress, lang: "th" | "en" = "en") { let fragments: string[]; switch (lang) { case "th": fragments = [`${addr.address},`]; if (addr.moo) fragments.push(`หมู่ ${addr.moo},`); if (addr.soi) fragments.push(`ซอย ${addr.soi},`); if (addr.street) fragments.push(`ถนน${addr.street},`); break; default: fragments = [`${addr.addressEN},`]; if (addr.mooEN) fragments.push(`Moo ${addr.mooEN},`); if (addr.soiEN) fragments.push(`Soi ${addr.soiEN},`); if (addr.streetEN) fragments.push(`${addr.streetEN} Rd.`); break; } return fragments.join(" "); } function addressFull(addr: FullAddress, lang: "th" | "en" = "en") { let fragments: string[]; switch (lang) { case "th": fragments = [`${addr.address},`]; if (addr.moo) fragments.push(`หมู่ ${addr.moo},`); if (addr.soi) fragments.push(`ซอย ${addr.soi},`); if (addr.street) fragments.push(`ถนน${addr.street},`); if (addr.subDistrict) { fragments.push(`${addr.province?.id === "10" ? "แขวง" : "ตำบล"}${addr.subDistrict.name},`); } if (addr.district) { fragments.push(`${addr.province?.id === "10" ? "เขต" : "อำเภอ"}${addr.district.name},`); } if (addr.province) fragments.push(`จังหวัด${addr.province.name},`); break; default: fragments = [`${addr.addressEN},`]; if (addr.mooEN) fragments.push(`Moo ${addr.mooEN},`); if (addr.soiEN) fragments.push(`Soi ${addr.soiEN},`); if (addr.streetEN) fragments.push(`${addr.streetEN} Rd.`); if (addr.subDistrict) { fragments.push(`${addr.subDistrict.nameEN} sub-district,`); } if (addr.district) fragments.push(`${addr.district.nameEN} district,`); if (addr.province) fragments.push(`${addr.province.nameEN},`); break; } if (addr.subDistrict) fragments.push(addr.subDistrict.zipCode); return fragments.join(" "); } function gender(text: string, lang: "th" | "en" = "en") { switch (lang) { case "th": return { male: "ชาย", female: "หญิง" }[text] || text; default: text.charAt(0).toUpperCase() + text.slice(1); } } /** * @deprecated */ function businessType(text: string, lang: "th" | "en" = "en") { switch (lang) { case "th": return ( { ["fisheries"]: "ประมง", ["continuous-fisheries"]: "ต่อเนื่องประมงทะเล", ["agriculture"]: "เกษตรและปศุสัตว์", ["construction"]: "กิจการก่อสร้าง", ["domesticHelper"]: "ผู้รับใช้ในบ้าน", ["continuousAgriculture"]: "กิจการต่อเนื่องการเกษตร", ["continuousButchery"]: "ต่อเนื่องปศุสัตว์โรงฆ่าสัตว์ ชำแหละ", ["recycling"]: "กิจการรีไซเคิล", ["mining"]: "เหมืองแร่/เหมืองหิน", ["metal"]: "จำหน่ายผลิตภัณฑ์โลหะ", ["food"]: "จำหน่ายอาหารและเครื่องดื่ม", ["soilBasedProducts"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์จากดิน", ["constructionMaterials"]: "ผลิตหรือจำหน่ายวัสดุก่อสร้าง", ["stone"]: "แปรรูปหิน", ["cloth"]: "ผลิตหรือจำหน่ายเสื้อผ้าสำเร็จรูป", ["plastic"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์พลาสติก", ["paper"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์กระดาษ", ["electronics"]: "ผลิตหรือจำหน่ายผลิตภัณฑ์อิเล็กทรอนิกส์", ["transport"]: "ขนถ่ายสินค้าทางบก น้ำ คลังสินค้า", ["market"]: "ค้าส่ง ค้าปลีก แผงลอย", ["car"]: "อู่ซ่อมรถ ล้าง อัดฉีด", ["fuel"]: "สถานีบริการน้ำมัน แก้ส เชื้อเพลิง", ["institution"]: "สถานศึกษา มูลนิธิ สมาคม สถานพยาบาล", ["service"]: "การให้บริการต่างๆ", ["coordinator"]: "งานผู้ประสานงานด้านภาษากัมพูชา ลาว หรือเมียนมา", ["seafood"]: "แปรรูปสัตว์น้ำ", }[text] || text ); default: return ( { ["fisheries"]: "Fisheries", ["continuous-fisheries"]: "Continuous fisheries", ["agriculture"]: "Agriculture and livestock", ["construction"]: "Construction business", ["domesticHelper"]: "Domestic helper", ["continuousAgriculture"]: "Continuous agricultural operation", ["continuousButchery"]: "Continuous livestock slaughter and processing ", ["recycling"]: "Recycling business", ["mining"]: "Mining/quarry", ["metal"]: "Metal products distribution", ["food"]: "Food and beverage distribution", ["soilBasedProducts"]: "Manufacture or sell products made from soil", ["constructionMaterials"]: "Manufacture or sell construction materials", ["stone"]: "Stone processing", ["cloth"]: "Manufacture or sell ready-to-wear clothing", ["plastic"]: "Manufacture or sell plastic products", ["paper"]: "Manufacture or sell paper products", ["electronics"]: "Manufacture or sell electronic products", ["transport"]: "Transport goods by land, water, and operate warehouses", ["market"]: "Wholesale, retail, floating panels", ["car"]: "Auto repair shop, car wash, and detailing", ["fuel"]: "Gas station, fuel station, and service station", ["institution"]: "Educational institution, foundation, association, hospital", ["service"]: "Various services", ["coordinator"]: "Coordinator for Khmer, Laos, or Myanmar language services", ["seafood"]: "Processing seafood", }[text] || text ); } } function namePrefix(text: string, lang: "th" | "en" = "en") { switch (lang) { case "th": return { mr: "นาย", mrs: "นาง", miss: "นางสาว" }[text] || text; default: text.charAt(0).toUpperCase() + text.slice(1); } } function nationality(text: string, lang: "th" | "en" = "en") { switch (lang) { case "th": return ( { ["THA"]: "ไทย", // spellchecker:disable-line ["MMR"]: "เมียนมา", ["LAO"]: "ลาว", ["KHM"]: "กัมพูชา", ["VNM"]: "เวียดนาม", ["PHL"]: "ฟิลิปปินส์", ["CHN"]: "จีน", }[text] || text ); default: return ( { ["THA"]: "Thai", // spellchecker:disable-line ["MMR"]: "Myanmar", ["LAO"]: "Laos", ["KHM"]: "Khmer", ["VNM"]: "Vietnam", ["PHL"]: "Philippines", ["CHN"]: "China", }[text] || text ); } } function jobPosition(text: string, lang: "th" | "en" = "en") { switch (lang) { case "th": return ( { ["labourer"]: "กรรมกร", ["boatsMechanic"]: "ช่างเครื่องยนต์ในเรือประมงทะเล", ["domesticHelper"]: "ผู้รับใช้ในบ้าน", ["coordinator"]: "งานผู้ประสานงานด้านภาษากัมพูชา ลาว หรือเมียนมา", }[text] || text ); default: return ( { labourer: "Labourer", boatsMechanic: "Marine engine mechanic on fishing boats", domesticHelper: "Domestic helper", coordinator: "Coordinator for Khmer, Laos, or Myanmar language services", }[text] || text ); } }