Files
hrm/app/components/handover/useSendHandoverEmail.js
2026-04-06 15:53:14 +07:00

378 lines
16 KiB
JavaScript

import { render } from '@vue-email/render';
import Template1 from '@/lib/email/templates/Template1.vue';
import { forEachAsync, groupBy, last } from 'es-toolkit';
export default function useSendHandoverEmail(filter, templateName) {
const products = ref(null);
const contents = ref(null);
const isSending = ref(false);
const emailTemplate = ref(null);
const templateNameRef = toRef(templateName);
watch(templateNameRef, async () => {
await populateEmailTemplate();
populateProducts();
})
const {
$dayjs,
$getdata,
$insertapi,
$numtoString,
$numberToVietnamese,
$numberToVietnameseCurrency,
$formatDateVN,
$paymentQR,
$getFirstAndLastName,
$snackbar,
$store,
} = useNuxtApp();
const buildQrHtml = (url) => `
<div style="text-align: center; margin-top: 16px">
<img
src="${url}"
alt="VietQR"
width="500"
style="display: inline-block"
/>
</div>
`;
const formatDateVNText = (input, { withTime = false, withSeconds = false } = {}) => {
if (!input) return '';
const date = new Date(input);
if (isNaN(date.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
const day = pad(date.getDate());
const month = pad(date.getMonth() + 1);
const year = date.getFullYear();
let result = `${day} tháng ${month} năm ${year}`;
if (withTime) {
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
result += ` ${hours}:${minutes}`;
if (withSeconds) {
const seconds = pad(date.getSeconds());
result += `:${seconds}`;
}
}
return result;
};
function buildContentPayment(product) {
const {
trade_code,
prdbk__transaction__customer__type__code: customerType,
prdbk__transaction__customer__code: customerCode,
prdbk__transaction__customer__fullname: customerName,
prdbk__transaction__resvtxn__psh__cycle: cycle,
} = product;
if (customerType?.toLowerCase() === 'cn') {
if (customerName.length < 14) {
return `${trade_code} ${customerCode} ${customerName} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
} else {
return `${trade_code} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
} else return `${trade_code} ${customerCode} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
function sanitizeContentPayment(text, maxLength = 80) {
if (!text) return '';
return text
.normalize('NFD') // bỏ dấu tiếng Việt
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9 _-]/g, '') // bỏ ký tự lạ
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
}
function addDays(days) {
const today = new Date();
today.setDate(today.getDate() + days);
return today;
}
function replaceTemplateVars(html, product) {
const {
trade_code,
number_of_floors,
elpro__current_area,
prdbk__transaction__date,
prdbk__transaction__customer__code: cusCode,
prdbk__transaction__customer__fullname: cusName,
prdbk__transaction__customer__legal_code: cusLegalCode,
prdbk__transaction__customer__issued_date: cusIssuedDate,
prdbk__transaction__customer__type__code: cusTypeCode,
prdbk__transaction__customer__contact_address: cusContactAddress,
prdbk__transaction__customer__address: cusAddress,
prdbk__transaction__customer__phone: cusPhone,
prdbk__transaction__customer__email: cusEmail,
prdbk__transaction__customer__orgncust__bank_account: orgBankAccount,
prdbk__transaction__customer__orgncust__bank_name: orgBankName,
prdbk__transaction__customer__orgncust__orgrep__people__fullname: orgPeopleFullname,
prdbk__transaction__customer__orgncust__orgrep__relation__name: orgRelationName,
prdbk__transaction__co_op__people__fullname: coopFullname,
prdbk__transaction__co_op__people__legal_code: coopLegalCode,
prdbk__transaction__co_op__people__address: coopAddress,
prdbk__transaction__co_op__people__contact_address: coopContactAddress,
prdbk__transaction__co_op__people__phone: coopPhone,
prdbk__transaction__co_op__people__email: coopEmail,
prdbk__transaction__resvtxn__psh__amount: amount,
prdbk__transaction__resvtxn__psh__paid_amount: paid_amount,
prdbk__transaction__resvtxn__psh__penalty_remain: penalty_remain,
prdbk__transaction__resvtxn__psh__remain_amount: remain_amount,
prdbk__transaction__resvtxn__psh__cycle: cycle,
prdbk__transaction__resvtxn__psh__to_date: to_date,
} = product;
const getName = () => {
if (cusTypeCode === 'CN') {
return cusName.length < 14 ? cusName : $getFirstAndLastName(cusName);
}
return '';
}
const issuedDate = $dayjs(cusIssuedDate);
const issued_date_d = issuedDate.date();
const issued_date_m = issuedDate.month() + 1;
const issued_date_y = issuedDate.year();
let replaced = html
.replaceAll('[day]', String(new Date().getDate()).padStart(2, '0'))
.replaceAll('[month]', String(new Date().getMonth() + 1).padStart(2, '0'))
.replaceAll('[year]', new Date().getFullYear())
.replaceAll('[product.trade_code]', trade_code)
.replaceAll('[product.elpro__current_area]', elpro__current_area)
.replaceAll('[product.elpro__current_area_in_words]', $numberToVietnameseCurrency(elpro__current_area))
.replaceAll('[product.number_of_floors]', number_of_floors)
.replaceAll('[customer.name]', cusName)
.replaceAll('[customer.code]', cusCode)
.replaceAll('[customer.fullname]', cusName)
.replaceAll('[customer.legal_code]', cusLegalCode)
.replaceAll('[customer.address]', cusAddress)
.replaceAll('[customer.contact_address]', cusContactAddress || cusAddress || '')
.replaceAll('[customer.phone]', cusPhone)
.replaceAll('[customer.email]', cusEmail)
.replaceAll('[issued_date_d]', issued_date_d)
.replaceAll('[issued_date_m]', issued_date_m)
.replaceAll('[issued_date_y]', issued_date_y)
.replaceAll('[customer.orgncust.bank_account]', orgBankAccount)
.replaceAll('[customer.orgncust.bank_name]', orgBankName)
.replaceAll('[customer.orgncust.orgrep.people.fullname]', orgPeopleFullname)
.replaceAll('[customer.orgncust.orgrep.relation.name]', orgRelationName)
.replaceAll('[co_op.people.fullname]', coopFullname)
.replaceAll('[co_op.people.legal_code]', coopLegalCode)
.replaceAll('[co_op.people.address]', coopAddress)
.replaceAll('[co_op.people.contact_address]', coopContactAddress || coopAddress || '')
.replaceAll('[co_op.people.phone]', coopPhone || '')
.replaceAll('[co_op.people.email]', coopEmail || '')
if (templateNameRef.value !== 'Biên bản bàn giao đất - Cá nhân') {
replaced = replaced
.replaceAll('[product.trade_code_payment]', sanitizeContentPayment(trade_code))
.replaceAll('[transaction.date]', formatDateVNText(prdbk__transaction__date) || '')
.replaceAll('[customer.name]', getName())
.replaceAll('[payment_schedule.remain_amount]', $numtoString((remain_amount).toFixed()) || '')
.replaceAll('[payment_schedule.paid_amount]', $numtoString(paid_amount.toFixed()) || '')
.replaceAll('[payment_schedule.penalty_remain]', $numtoString(penalty_remain.toFixed()) || '')
.replaceAll('[payment_schedule.amount]', $numtoString(amount) || '')
.replaceAll('[payment_schedule.remain_amount_in_word]', $numberToVietnameseCurrency(remain_amount) || '')
.replaceAll('[payment_schedule.to_date]', $formatDateVN(to_date) || '')
.replaceAll('[payment_schedule.expiration_date]', $formatDateVN(addDays(3)) || '')
.replaceAll('[payment_schedule.cycle]', $numtoString(cycle) || '')
.replaceAll(
'[payment_schedule.cycle-in-words]',
cycle == 0 ? 'đặt cọc' : `đợt thứ ${$numberToVietnamese(cycle).toLowerCase()}`,
)
.replaceAll('[payment_schedule.note]', cycle == 0 ? 'đặt cọc' : `đợt ${cycle}`)
}
return replaced;
}
function quillToEmailHtml(html) {
return html
// ALIGN (chỉ replace align class, không phá class khác)
.replace(/class="([^"]*?)ql-align-center([^"]*?)"/g, 'style="text-align:center;"')
.replace(/class="([^"]*?)ql-align-right([^"]*?)"/g, 'style="text-align:right;"')
.replace(/class="([^"]*?)ql-align-left([^"]*?)"/g, 'style="text-align:left;"')
.replace(/class="([^"]*?)ql-align-justify([^"]*?)"/g, 'style="text-align:justify;"')
// FONT SIZE (xóa class size)
.replace(/ql-size-small/g, '')
.replace(/ql-size-large/g, '')
.replace(/ql-size-huge/g, '')
// REMOVE EMPTY CLASS
.replace(/class=""/g, '')
// FIX FIGURE MARGIN LEFT + RIGHT
.replace(
/<figure([^>]*?)style="([^"]*)"/g,
(match, before, styles) => {
if (!/margin-left\s*:/.test(styles)) {
styles += ';margin-left:0';
}
if (!/margin-right\s*:/.test(styles)) {
styles += ';margin-right:0';
}
return `<figure${before}style="${styles}"`;
}
);
}
async function send() {
isSending.value = true;
$snackbar(`Đang gửi ${contents.value.length} email thông báo...`);
await forEachAsync(contents.value, async (bigContent, i) => {
const { imageUrl, keyword } = bigContent;
const tempEm = {
content: toRaw(bigContent),
previewMode: true,
};
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
let emailHtml = await render(Template1, tempEm);
// If no image URL provided, remove image section from HTML
if ((imageUrl ?? '').trim() === '') {
emailHtml = emailHtml
.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, '')
.replace(/\n\s*\n\s*\n/g, '\n\n');
}
// Replace keywords in HTML
if (keyword && keyword.length > 0) {
keyword.forEach(({ keyword, value }) => {
if (keyword && value) {
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, 'g');
emailHtml = emailHtml.replace(regex, value);
}
});
}
const subject = replaceTemplateVars(bigContent.subject, products.value[i]) || 'Thông báo từ Utopia Villas & Resort';
const response = await $insertapi(
'sendemail',
{
to: products.value[i].prdbk__transaction__customer__email,
content: emailHtml,
subject
},
undefined,
false,
);
if (response !== null) {
await $insertapi('productnote', {
ref: products.value[i].id,
user: $store.login.id,
detail: `Đã gửi ${templateNameRef.value} cho sản phẩm ${products.value[i].trade_code} vào lúc ${$dayjs().format('HH:mm ngày DD/MM/YYYY')}.`
}, undefined, false);
}
});
setTimeout(() => {
$snackbar('Thông báo đã được gửi thành công đến khách hàng.');
isSending.value = false;
}, 1000);
}
async function populateProducts(idFilters = {}) {
const productsData = await $getdata(
'product',
undefined,
{
filter: {
...filter.value,
...idFilters,
},
sort: 'prdbk__transaction__resvtxn__psh__cycle',
values: 'prdbk__transaction,prdbk__transaction__code,prdbk__transaction__date,prdbk__transaction__txncurrent__detail__status__name,prdbk,prdbk__transaction__policy__code,prdbk__transaction__sale_price,prdbk__transaction__discount_amount,prdbk__transaction__customer,prdbk__transaction__customer__code,prdbk__transaction__customer__type__code,prdbk__transaction__customer__phone,prdbk__transaction__customer__fullname,prdbk__transaction__customer__legal_code,prdbk__transaction__customer__issued_date,prdbk__transaction__customer__contact_address,prdbk__transaction__customer__address,prdbk__transaction__customer__email,prdbk__transaction__customer__orgncust__bank_account,prdbk__transaction__customer__orgncust__bank_name,prdbk__transaction__customer__orgncust__orgrep__people__fullname,prdbk__transaction__customer__orgncust__orgrep__relation__name,prdbk__transaction__co_op,prdbk__transaction__co_op__people,prdbk__transaction__co_op__people__fullname,prdbk__transaction__co_op__people__legal_code,prdbk__transaction__co_op__people__address,prdbk__transaction__co_op__people__contact_address,prdbk__transaction__co_op__people__phone,prdbk__transaction__amount_remain,prdbk__transaction__amount_received,prdbk__transaction__resvtxn__psh,prdbk__transaction__resvtxn__psh__code,prdbk__transaction__resvtxn__psh__amount_remain,prdbk__transaction__resvtxn__psh__paid_amount,prdbk__transaction__resvtxn__psh__penalty_amount,prdbk__transaction__resvtxn__psh__penalty_remain,prdbk__transaction__resvtxn__psh__remain_amount,prdbk__transaction__resvtxn__psh__amount,prdbk__transaction__resvtxn__psh__cycle,prdbk__transaction__resvtxn__psh__to_date,locked_until,note,cart,cart__name,cart__code,cart__dealer,cart__dealer__code,cart__dealer__name,direction,type,zone_type,dealer,link,type__name,dealer__code,dealer__name,id,code,trade_code,land_lot_code,zone_code,zone_type__name,lot_area,building_area,total_built_area,number_of_floors,land_lot_size,origin_price,price_excluding_vat,direction__name,villa_model,product_type,template_name,project,project__name,status,status__code,status__name,status__color,status__sale_status,status__sale_status__color,create_time,elpro__stage,elpro__area_before,elpro__current_area,elpro__area_gap,elpro__rate_amount,elpro__date',
}
);
// group by trade_code first
products.value = groupByProduct(productsData);
contents.value = products.value.map((product) => {
const qr = templateNameRef.value === 'Mail Thông báo bàn giao đất' ? buildQrHtml($paymentQR(buildContentPayment(product), 'TCB')) : '';
const content = emailTemplate.value?.content.content + qr;
const message = quillToEmailHtml(replaceTemplateVars(content, product)); // replace property `content` with `message`, then fill info
return {
...emailTemplate.value?.content,
message,
content: undefined,
}
});
}
/**
* Group product objects by same product id
* [{G.B01}, {G.B01}, {G.B01}] => {G.B01, with sum amount, sum remain_amount, etc.}
*/
function groupByProduct(products) {
const uniqueProducts = groupBy(products, (p) => p.trade_code);
const uniqueProductsWithSummedFields = Object.entries(uniqueProducts).map(([_, sameProductRows]) => {
// same product different schedules
const summedFields = {
prdbk__transaction__resvtxn__psh__amount: sameProductRows.reduce((prev, curr) => prev + curr.prdbk__transaction__resvtxn__psh__amount, 0),
prdbk__transaction__resvtxn__psh__paid_amount: sameProductRows.reduce((prev, curr) => prev + curr.prdbk__transaction__resvtxn__psh__paid_amount, 0),
prdbk__transaction__resvtxn__psh__amount_remain: sameProductRows.reduce((prev, curr) => prev + curr.prdbk__transaction__resvtxn__psh__amount_remain, 0),
prdbk__transaction__resvtxn__psh__penalty_remain: sameProductRows.reduce((prev, curr) => prev + curr.prdbk__transaction__resvtxn__psh__penalty_remain, 0),
prdbk__transaction__resvtxn__psh__remain_amount: sameProductRows.reduce((prev, curr) => prev + curr.prdbk__transaction__resvtxn__psh__remain_amount, 0),
}
// console.table(summedFields);
const productWithSummedFields = {
...last(sameProductRows),
...summedFields
}
return productWithSummedFields;
});
return uniqueProductsWithSummedFields;
}
async function populateEmailTemplate() {
if (templateNameRef.value) {
const emailTemplateData = await $getdata(
'emailtemplate',
{ name__icontains: templateNameRef.value },
undefined,
true,
);
emailTemplate.value = emailTemplateData;
}
}
onMounted(async () => {
await populateEmailTemplate();
populateProducts();
});
watch(filter, () => {
populateProducts();
}, { deep: true });
watch(() => $store.selectedProductsForHandoverEmail, (val) => {
populateProducts({ id__in: val });
})
return { contents, send, isSending };
}