494 lines
18 KiB
TypeScript
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
|
|
);
|
|
}
|
|
}
|