jws-backend/src/controllers/00-doc-template-controller.ts
Methapon2001 106343d33d
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 7s
feat: generate barcode
2025-05-07 10:54:22 +07:00

494 lines
18 KiB
TypeScript

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,
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 ? [templateGroup, ...DOCUMENT_PATH] : DOCUMENT_PATH,
);
if (ret) return ret.map((v) => v.fileName);
}
return await listFile(
(templateGroup ? [templateGroup, ...DOCUMENT_PATH] : DOCUMENT_PATH).join("/") + "/",
);
}
@Get("{documentTemplate}")
async getDocument(
@Path() documentTemplate: string,
@Query() data: string,
@Query() dataId: string,
@Query() dataOnly?: boolean,
@Query() templateGroup?: string,
): Promise<Readable | Record<string, any>> {
let record: Record<string, any>;
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<ArrayBufferLike> | 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<T>(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;
}
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);
}
}
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
);
}
}