Merge branch 'develop'
All checks were successful
Spell Check / Spell Check with Typos (push) Successful in 8s

This commit is contained in:
Methapon2001 2025-03-11 10:47:16 +07:00
commit 910dc196c5
9 changed files with 543 additions and 96 deletions

View file

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Property" (
"id" TEXT NOT NULL,
"registeredBranchId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameEN" TEXT NOT NULL,
"type" JSONB NOT NULL,
"status" "Status" NOT NULL DEFAULT 'CREATED',
"statusOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Property_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Property" ADD CONSTRAINT "Property_registeredBranchId_fkey" FOREIGN KEY ("registeredBranchId") REFERENCES "Branch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -318,6 +318,7 @@ model Branch {
workflowTemplate WorkflowTemplate[]
taskOrder TaskOrder[]
notification Notification[]
property Property[]
}
model BranchBank {
@ -1004,6 +1005,23 @@ model Institution {
taskOrder TaskOrder[]
}
model Property {
id String @id @default(cuid())
registeredBranch Branch @relation(fields: [registeredBranchId], references: [id])
registeredBranchId String
name String
nameEN String
type Json
status Status @default(CREATED)
statusOrder Int @default(0)
createdAt DateTime @default(now())
}
model WorkflowTemplate {
id String @id @default(cuid())
name String

View file

@ -9,6 +9,9 @@ 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({
@ -66,7 +69,14 @@ const quotationData = (id: string) =>
export class DocTemplateController extends Controller {
@Get()
async getTemplate() {
return await listFile(`doc-template/`);
if (
process.env.DOCUMENT_TEMPLATE_PROVIDER &&
process.env.DOCUMENT_TEMPLATE_PROVIDER === "edm-api"
) {
const ret = await edmList("file", DOCUMENT_PATH);
if (ret) return ret.map((v) => v.fileName);
}
return await listFile(DOCUMENT_PATH.join("/") + "/");
}
@Get("{documentTemplate}")
@ -122,7 +132,29 @@ export class DocTemplateController extends Controller {
if (!data) throw notFoundError("Data");
if (dataOnly) return record;
const template = await getFileBuffer(`doc-template/${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);

View file

@ -12,14 +12,16 @@ import {
} from "@prisma/client";
import { Controller, Get, Query, Request, Route, Security, Tags } from "tsoa";
import prisma from "../db";
import { createPermCondition } from "../services/permission";
import { createPermCondition, createQueryPermissionCondition } from "../services/permission";
import { RequestWithUser } from "../interfaces/user";
import { precisionRound } from "../utils/arithmetic";
import dayjs from "dayjs";
import { json2csv } from "json-2-csv";
import { isSystem } from "../utils/keycloak";
import { jsonObjectFrom } from "kysely/helpers/postgres";
const permissionCondCompany = createPermCondition((_) => true);
const permissionQueryCondCompany = createQueryPermissionCondition((_) => true);
const VAT_DEFAULT = config.vat;
@ -645,125 +647,61 @@ export class StatsController extends Controller {
@Get("customer-dept")
async reportCustomerDept(@Request() req: RequestWithUser) {
let query = prisma.$kysely
.selectFrom("Quotation")
.leftJoin("Invoice", "Quotation.id", "Invoice.quotationId")
.selectFrom("Invoice")
.leftJoin("Quotation", "Quotation.id", "Invoice.quotationId")
.leftJoin("Payment", "Invoice.id", "Payment.invoiceId")
.leftJoin("CustomerBranch", "CustomerBranch.id", "Quotation.customerBranchId")
.leftJoin("Customer", "Customer.id", "CustomerBranch.customerId")
.select([
"CustomerBranch.id as customerBranchId",
"CustomerBranch.code as customerBranchCode",
"CustomerBranch.registerName as customerBranchRegisterName",
"CustomerBranch.registerNameEN as customerBranchRegisterNameEN",
"CustomerBranch.firstName as customerBranchFirstName",
"CustomerBranch.firstNameEN as customerBranchFirstNameEN",
"CustomerBranch.lastName as customerBranchFirstName",
"CustomerBranch.lastNameEN as customerBranchFirstNameEN",
"Customer.customerType",
"Quotation.id as quotationId",
"Quotation.code as quotationCode",
"Quotation.finalPrice as quotationValue",
.select((eb) => [
jsonObjectFrom(
eb
.selectFrom("CustomerBranch")
.select((eb) => [
jsonObjectFrom(
eb
.selectFrom("Customer")
.selectAll("Customer")
.whereRef("Customer.id", "=", "CustomerBranch.customerId"),
).as("customer"),
])
.selectAll("CustomerBranch")
.whereRef("CustomerBranch.id", "=", "Quotation.customerBranchId"),
).as("customerBranch"),
])
.select(["Payment.paymentStatus"])
.selectAll(["Invoice"])
.distinctOn("Quotation.id");
.distinctOn("Invoice.id");
if (!isSystem(req.user)) {
query = query.where(({ eb, exists }) =>
exists(
eb
.selectFrom("Branch")
.leftJoin("BranchUser", "BranchUser.branchId", "Branch.id")
.leftJoin("Branch as SubBranch", "SubBranch.headOfficeId", "Branch.id")
.leftJoin("BranchUser as SubBranchUser", "SubBranchUser.branchId", "SubBranch.id")
.leftJoin("Branch as HeadBranch", "HeadBranch.id", "Branch.id")
.leftJoin("BranchUser as HeadBranchUser", "HeadBranchUser.branchId", "HeadBranch.id")
.leftJoin("Branch as SubHeadBranch", "SubHeadBranch.headOfficeId", "HeadBranch.id")
.leftJoin(
"BranchUser as SubHeadBranchUser",
"SubHeadBranchUser.branchId",
"SubHeadBranch.id",
)
.where((eb) => {
const cond = [
eb("BranchUser.userId", "=", req.user.sub), // NOTE: if user belong to current branch.
eb("SubBranchUser.userId", "=", req.user.sub), // NOTE: if user belong to branch under current branch.
eb("HeadBranchUser.userId", "=", req.user.sub), // NOTE: if the current branch is under head branch user belong to.
eb("SubHeadBranchUser.userId", "=", req.user.sub), // NOTE: if the current branch is under the same head branch user belong to.
];
return eb.or(cond);
})
.select("Branch.id"),
),
);
query = query.where(permissionQueryCondCompany(req.user));
}
const ret = await query.execute();
const arr = ret.map((item) => {
const data: Record<string, any> = {};
for (const [key, val] of Object.entries(item)) {
if (key.startsWith("customerBranch")) {
if (!data["customerBranch"]) data["customerBranch"] = {};
data["customerBranch"][key.slice(14).slice(0, 1).toLowerCase() + key.slice(14).slice(1)] =
val;
} else if (key.startsWith("customerType")) {
data["customerBranch"]["customer"] = { customerType: val };
} else {
data[key as keyof typeof data] = val;
}
}
return data as Invoice & {
quotationId: string;
quotationCode: string;
quotationValue: number;
paymentStatus: PaymentStatus;
customerBranch: CustomerBranch & { customer: { customerType: CustomerType } };
};
});
return arr
return ret
.reduce<
{
paid: number;
unpaid: number;
customerBranch: CustomerBranch & { customer: { customerType: CustomerType } };
_quotation: { id: string; code: string; value: number }[];
customerBranch: CustomerBranch & { customer: Customer };
}[]
>((acc, item) => {
const exists = acc.find((v) => v.customerBranch.id === item.customerBranch.id);
const exists = acc.find((v) => v.customerBranch.id === item.customerBranch!.id);
const quotation = {
id: item.quotationId,
code: item.quotationCode,
value:
item.quotationValue -
(item.paymentStatus === "PaymentSuccess" && item.amount ? item.amount : 0),
};
if (!item.amount) return acc;
if (!exists) {
return acc.concat({
_quotation: [quotation],
customerBranch: item.customerBranch,
paid: item.paymentStatus === "PaymentSuccess" && item.amount ? item.amount : 0,
unpaid: quotation.value,
customerBranch: item.customerBranch as CustomerBranch & { customer: Customer },
paid: item.paymentStatus === "PaymentSuccess" ? item.amount : 0,
unpaid: item.paymentStatus !== "PaymentSuccess" ? item.amount : 0,
});
} else {
exists[item.paymentStatus === "PaymentSuccess" ? "paid" : "unpaid"] += item.amount;
}
const same = exists._quotation.find((v) => v.id === item.quotationId);
if (item.paymentStatus === "PaymentSuccess" && item.amount) {
exists.paid += item.amount;
if (same) same.value -= item.amount;
}
if (!same) exists._quotation.push(quotation);
exists.unpaid = exists._quotation.reduce((a, c) => a + c.value, 0);
return acc;
}, [])
.map((v) => {
return { ...v, _quotation: undefined };
});
.map((v) => ({ ...v, _quotation: undefined }));
}
}

View file

@ -0,0 +1,187 @@
import {
Body,
Controller,
Delete,
Get,
Path,
Post,
Put,
Query,
Request,
Route,
Security,
Tags,
} from "tsoa";
import { RequestWithUser } from "../interfaces/user";
import prisma from "../db";
import { Prisma, Status } from "@prisma/client";
import {
branchRelationPermInclude,
createPermCheck,
createPermCondition,
} from "../services/permission";
import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
import { notFoundError } from "../utils/error";
import { filterStatus } from "../services/prisma";
import { queryOrNot } from "../utils/relation";
type PropertyPayload = {
name: string;
nameEN: string;
type: Record<string, any>;
registeredBranchId?: string;
status?: Status;
};
const permissionCondCompany = createPermCondition((_) => true);
const permissionCheckCompany = createPermCheck((_) => true);
@Route("api/v1/property")
@Tags("Property")
@Security("keycloak")
export class PropertiesController extends Controller {
@Get()
async getProperties(
@Request() req: RequestWithUser,
@Query() page: number = 1,
@Query() pageSize: number = 30,
@Query() status?: Status,
@Query() query = "",
@Query() activeOnly?: boolean,
) {
const where = {
OR: queryOrNot(query, [{ name: { contains: query } }, { nameEN: { contains: query } }]),
AND: {
...filterStatus(activeOnly ? Status.ACTIVE : status),
registeredBranch: {
OR: permissionCondCompany(req.user, { activeOnly: true }),
},
},
} satisfies Prisma.PropertyWhereInput;
const [result, total] = await prisma.$transaction([
prisma.property.findMany({
where,
orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }],
take: pageSize,
skip: (page - 1) * pageSize,
}),
prisma.property.count({ where }),
]);
return {
result,
page,
pageSize,
total,
};
}
@Get("{propertyId}")
async getPropertyById(@Request() _req: RequestWithUser, @Path() propertyId: string) {
const record = await prisma.property.findFirst({
where: { id: propertyId },
orderBy: { createdAt: "asc" },
});
if (!record) throw notFoundError("Property");
return record;
}
@Post()
async createProperty(@Request() req: RequestWithUser, @Body() body: PropertyPayload) {
const where = {
OR: [{ name: { contains: body.name } }, { nameEN: { contains: body.nameEN } }],
AND: {
registeredBranch: {
OR: permissionCondCompany(req.user),
},
},
} satisfies Prisma.PropertyWhereInput;
const exists = await prisma.property.findFirst({ where });
if (exists) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"Property with this name already exists",
"samePropertyNameExists",
);
}
const userAffiliatedBranch = await prisma.branch.findFirst({
include: branchRelationPermInclude(req.user),
where: body.registeredBranchId
? { id: body.registeredBranchId }
: {
user: { some: { userId: req.user.sub } },
},
});
if (!userAffiliatedBranch) {
throw new HttpError(
HttpStatus.BAD_REQUEST,
"You must be affilated with at least one branch or specify branch to be registered (System permission required).",
"reqMinAffilatedBranch",
);
}
await permissionCheckCompany(req.user, userAffiliatedBranch);
return await prisma.property.create({
data: {
...body,
statusOrder: +(body.status === "INACTIVE"),
registeredBranchId: userAffiliatedBranch.id,
},
});
}
@Put("{propertyId}")
async updatePropertyById(
@Request() req: RequestWithUser,
@Path() propertyId: string,
@Body() body: PropertyPayload,
) {
const record = await prisma.property.findUnique({
where: { id: propertyId },
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
});
if (!record) throw notFoundError("Property");
await permissionCheckCompany(req.user, record.registeredBranch);
return await prisma.property.update({
where: { id: propertyId },
data: {
...body,
statusOrder: +(body.status === "INACTIVE"),
},
});
}
@Delete("{propertyId}")
async deletePropertyById(@Request() req: RequestWithUser, @Path() propertyId: string) {
const record = await prisma.property.findUnique({
where: { id: propertyId },
include: {
registeredBranch: {
include: branchRelationPermInclude(req.user),
},
},
});
if (!record) throw notFoundError("Property");
await permissionCheckCompany(req.user, record.registeredBranch);
return await prisma.property.delete({
where: { id: propertyId },
});
}
}

39
src/interfaces/edm.ts Normal file
View file

@ -0,0 +1,39 @@
export interface StorageFolder {
/**
* @prop Full path to this folder. It is used as key as there are no files or directories at the same location.
*/
pathname: string;
/**
* @prop Directory / Folder name.
*/
name: string;
createdAt: string | Date;
createdBy: string | Date;
}
export interface StorageFile {
/**
* @prop Full path to this folder. It is used as key as there are no files or directories at the same location.
*/
pathname: string;
fileName: string;
fileSize: number;
fileType: string;
title: string;
description: string;
author: string;
category: string[];
keyword: string[];
metadata: Record<string, unknown>;
path: string;
upload: boolean;
updatedAt: string | Date;
updatedBy: string;
createdAt: string | Date;
createdBy: string;
}

170
src/services/edm/edm-api.ts Normal file
View file

@ -0,0 +1,170 @@
import { DecodedJwt, createDecoder } from "fast-jwt";
import HttpError from "../../interfaces/http-error";
import HttpStatus from "../../interfaces/http-status";
import { StorageFile, StorageFolder } from "../../interfaces/edm";
const jwtDecode = createDecoder({ complete: true });
export type FileProps = Partial<
Pick<StorageFile, "title" | "description" | "author" | "keyword" | "category">
> & {
metadata?: { [key: string]: unknown };
};
const STORAGE_KEYCLOAK = process.env.EDM_KEYCLOAK!;
const STORAGE_KEYCLOAK_CLIENT = process.env.EDM_KEYCLOAK_CLIENT!;
const STORAGE_REALM = process.env.EDM_REALM!;
const STORAGE_URL = process.env.EDM_URL!;
const STORAGE_USER = process.env.EDM_ADMIN_USER!;
const STORAGE_PASSWORD = process.env.EDM_ADMIN_PASSWORD!;
let token: string | null = null;
let decoded: DecodedJwt | null = null;
/**
* Check if token is expired or will expire in 30 seconds
* @returns true if expire or can't get exp, false otherwise
*/
export function expireCheck(token: string, beforeExpire: number = 30) {
decoded = jwtDecode(token);
if (decoded && decoded.payload.exp) {
return Date.now() / 1000 >= decoded.payload.exp - beforeExpire;
}
return true;
}
/**
* Get token from id service if needed
*/
export async function getToken() {
if (!token || expireCheck(token)) {
const body = new URLSearchParams();
body.append("scope", "openid");
body.append("grant_type", "password");
body.append("client_id", STORAGE_KEYCLOAK_CLIENT || "edm");
body.append("username", STORAGE_USER);
body.append("password", STORAGE_PASSWORD);
const res = await fetch(
`${STORAGE_KEYCLOAK}/realms/${STORAGE_REALM}/protocol/openid-connect/token`,
{
method: "POST",
body: body,
},
).catch((e) => console.error(e));
if (!res) return;
const data = await res.json();
if (data && data.access_token) {
token = data.access_token;
}
}
return token;
}
/**
* @param path - Path that new folder will live
* @param name - Name of the folder to create
* @param recursive - Will create parent automatically
*/
export async function createFolder(path: string[], name: string, recursive: boolean = false) {
if (recursive && path.length > 0) {
await createFolder(path.slice(0, -1), path[path.length - 1], true);
}
const res = await fetch(`${STORAGE_URL}/storage/folder`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ path, name }),
}).catch((e) => console.error(e));
if (!res || !res.ok) {
return Boolean(console.error(res ? await res.json() : res));
}
return true;
}
/**
* @param path - Path that new file will live
* @param file - Name of the file to create
*/
export async function createFile(path: string[], file: string, props?: FileProps) {
const res = await fetch(`${STORAGE_URL}/storage/file`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
...props,
path,
file,
hidden: path.some((v) => v.startsWith(".")),
}),
}).catch((e) => console.error(e));
if (!res || !res.ok) {
return Boolean(console.error(res ? await res.json() : res));
}
return (await res.json()) as StorageFile & { uploadUrl: string };
}
export async function list(
operation: "file" | "folder",
path: string[],
): Promise<false | (StorageFile & { uploadUrl: string })[]> {
const res = await fetch(`${STORAGE_URL}/storage/list`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ operation, path, hidden: true }),
}).catch((e) => console.error(e));
if (!res || !res.ok) {
if (res && res.status === HttpStatus.NOT_FOUND) {
return [];
}
return Boolean(console.error(res ? await res.json() : res)) as false;
}
return await res.json();
}
export async function listFolder(path: string[]) {
return (await list("folder", path)) as StorageFolder[] | boolean;
}
export async function listFile(path: string[]) {
return (await list("file", path)) as StorageFile[] | boolean;
}
export async function downloadFile(
path: string[],
file: string,
): Promise<false | (StorageFile & { downloadUrl: string })> {
const res = await fetch(`${STORAGE_URL}/storage/file/download`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ path, file }),
}).catch((e) => console.error(e));
if (!res || !res.ok) {
if (res && res.status === HttpStatus.NOT_FOUND) {
throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบไฟล์ในระบบ");
}
console.error(res ? await res.json() : res);
return false;
}
return await res.json();
}

View file

@ -4,6 +4,8 @@ import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
import { RequestWithUser } from "../interfaces/user";
import { isSystem } from "../utils/keycloak";
import { ExpressionBuilder } from "kysely";
import { DB } from "../generated/kysely/types";
export function branchRelationPermInclude(user: RequestWithUser["user"]) {
return {
@ -133,3 +135,47 @@ export function createPermCheck(globalAllow: (user: RequestWithUser["user"]) =>
return branch;
};
}
export function createQueryPermissionCondition(
globalAllow: (user: RequestWithUser["user"]) => boolean,
opts?: { alwaysIncludeHead?: boolean },
) {
return (user: RequestWithUser["user"]) =>
({ eb, exists }: ExpressionBuilder<DB, keyof DB>) =>
exists(
eb
.selectFrom("Branch")
.leftJoin("BranchUser", "BranchUser.branchId", "Branch.id")
.leftJoin("Branch as SubBranch", "SubBranch.headOfficeId", "Branch.id")
.leftJoin("BranchUser as SubBranchUser", "SubBranchUser.branchId", "SubBranch.id")
.leftJoin("Branch as HeadBranch", "HeadBranch.id", "Branch.id")
.leftJoin("BranchUser as HeadBranchUser", "HeadBranchUser.branchId", "HeadBranch.id")
.leftJoin("Branch as SubHeadBranch", "SubHeadBranch.headOfficeId", "HeadBranch.id")
.leftJoin(
"BranchUser as SubHeadBranchUser",
"SubHeadBranchUser.branchId",
"SubHeadBranch.id",
)
.where((eb) => {
const cond = [
eb("BranchUser.userId", "=", user.sub), // NOTE: if user belong to current branch.
];
if (globalAllow?.(user) || opts?.alwaysIncludeHead) {
cond.push(
eb("SubBranchUser.userId", "=", user.sub), // NOTE: if user belong to branch under current branch.
);
}
if (globalAllow(user)) {
cond.push(
eb("HeadBranchUser.userId", "=", user.sub), // NOTE: if the current branch is under head branch user belong to.
eb("SubHeadBranchUser.userId", "=", user.sub), // NOTE: if the current branch is under the same head branch user belong to.
);
}
return eb.or(cond);
})
.select("Branch.id"),
);
}

View file

@ -41,6 +41,7 @@
{ "name": "Employee Other Info" },
{ "name": "Institution" },
{ "name": "Workflow" },
{ "name": "Property" },
{ "name": "Product Group" },
{ "name": "Product" },
{ "name": "Work" },