fix/refactor: quotation installment (#121)

* refactor/feat: i18n

* chore: clean log

* refactor: type

* refactor: installment and product table state relation

* refactor: handle split custom

---------

Co-authored-by: Thanaphon Frappet <thanaphon@frappet.com>
This commit is contained in:
puriphatt 2024-12-06 11:01:52 +07:00 committed by GitHub
parent 57aabf1deb
commit 1b4c06b182
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 357 additions and 77 deletions

View file

@ -6,7 +6,7 @@ import { storeToRefs } from 'pinia';
import WorkerItem from './WorkerItem.vue';
import DeleteButton from '../button/DeleteButton.vue';
import { precisionRound } from 'src/utils/arithmetic';
import { QuotationPayload } from 'stores/quotations/types';
import { ProductServiceList, QuotationPayload } from 'stores/quotations/types';
import { formatNumberDecimal, commaInput } from 'stores/utils';
import { useConfigStore } from 'stores/config';
@ -14,6 +14,7 @@ const props = defineProps<{
readonly?: boolean;
agentPrice: boolean;
installmentInput?: boolean;
maxInstallment?: number | null;
employeeRows?: {
foreignRefNo: string;
employeeName: string;
@ -29,6 +30,13 @@ const props = defineProps<{
defineEmits<{
(e: 'delete', index: number): void;
(
e: 'updateTable',
data: QuotationPayload['productServiceList'][number],
opt?: {
newInstallmentNo: number;
},
): void;
}>();
const configStore = useConfigStore();
@ -224,6 +232,26 @@ watch(
}
},
);
watch(
() => props.maxInstallment,
() => {
if (!props.maxInstallment) return;
let test: ProductServiceList[] = [];
const items = groupByServiceId(
rows.value.map((v, i) => Object.assign(v, { i })),
) || [{ title: '', product: [] }];
items.forEach((p) => {
test = test.concat(p.product.flatMap((item) => item));
});
test.forEach((p) => {
if ((props.maxInstallment || 0) < (p.installmentNo || 0)) {
p.installmentNo = Number(props.maxInstallment);
}
});
},
);
</script>
<template>
<div class="column">
@ -278,15 +306,24 @@ watch(
<q-td class="text-center">{{ props.rowIndex + 1 }}</q-td>
<q-td v-if="installmentInput">
<q-input
v-model="props.row.installmentNo"
:readonly
:bg-color="readonly ? 'transparent' : ''"
dense
min="0"
min="1"
:max="maxInstallment"
outlined
input-class="text-right"
type="number"
style="width: 60px"
:model-value="props.row.installmentNo"
@update:model-value="
(v) => {
$emit('updateTable', props.row, {
newInstallmentNo: Number(v),
});
props.row.installmentNo = Number(v);
}
"
></q-input>
</q-td>
<q-td>{{ props.row.product.code }}</q-td>
@ -309,12 +346,18 @@ watch(
:type="readonly ? 'text' : 'number'"
input-class="text-center"
style="width: 70px"
min="0"
min="1"
debounce="500"
v-model="props.row.amount"
@update:model-value="
(v) => {
$emit('updateTable', props.row);
}
"
/>
</q-td>
<q-td align="right">
<!-- TODO: -->
{{
formatNumberDecimal(
props.row.pricePerUnit +
@ -349,6 +392,7 @@ watch(
: '',
);
props.row.discount = x;
$emit('updateTable', props.row);
}
"
/>

View file

@ -134,6 +134,7 @@ export default {
customer: 'Customer',
individual: 'Individual',
unavailable: 'Unavailable',
selected: 'Selected {number} {msg}',
},
menu: {
@ -715,6 +716,7 @@ export default {
vatIncluded: 'Include VAT',
vatExcluded: 'Exclude VAT',
vat: 'VAT',
product: 'Product',
},
},

View file

@ -134,6 +134,7 @@ export default {
customer: 'ลูกค้า',
individual: 'รายบุคคล',
unavailable: 'ไม่พร้อมใช้งาน',
selected: '{number} {msg}ถูกเลือก',
},
menu: {
@ -707,6 +708,7 @@ export default {
vatIncluded: 'รวม VAT',
vatExcluded: 'ไม่รวม VAT',
vat: 'คำนวณ VAT',
product: 'สินค้า',
},
},

View file

@ -32,7 +32,12 @@ import { runOcr, parseResultMRZ } from 'src/utils/ocr';
// NOTE Import Types
import { View } from './types.ts';
import { QuotationPayload } from 'src/stores/quotations/types';
import {
PayCondition,
PaySplit,
ProductServiceList,
QuotationPayload,
} from 'src/stores/quotations/types';
import { EmployeeWorker } from 'src/stores/quotations/types';
import { Employee } from 'src/stores/employee/types';
import { Receipt } from 'src/stores/payment/types';
@ -136,6 +141,7 @@ const templateFormOption = ref<{ label: string; value: string }[]>([]);
const refSelectZoneEmployee = ref<InstanceType<typeof SelectZone>>();
const mrz = ref<Awaited<ReturnType<typeof parseResultMRZ>>>();
const toggleWorker = ref(true);
const tempTableProduct = ref<ProductServiceList[]>([]);
const tempPaySplitCount = ref(0);
const tempPaySplit = ref<
{ no: number; amount: number; name?: string; invoice?: boolean }[]
@ -165,6 +171,7 @@ const workerList = ref<Employee[]>([]);
const selectedProductGroup = ref('');
const selectedInstallmentNo = ref<number[]>([]);
const installmentAmount = ref<number>(0);
const selectedInstallment = ref();
const agentPrice = ref(false);
@ -492,7 +499,10 @@ async function convertInvoiceToSubmit() {
if (quotationFormData.value.id) {
invoiceFormData.value = {
installmentNo: selectedInstallmentNo.value,
amount: summaryPrice.value.finalPrice,
amount:
quotationFormData.value.payCondition === 'SplitCustom'
? installmentAmount.value
: summaryPrice.value.finalPrice,
quotationId: quotationFormData.value.id,
};
@ -668,9 +678,164 @@ function triggerProductServiceDialog() {
pageState.productServiceModal = true;
}
function handlePaySplitDiscount() {
if (readonly.value) return;
if (quotationFormData.value.payCondition === 'Split') {
// Calculate average discount
const paySplitArray = JSON.parse(JSON.stringify(tempPaySplit.value));
const numSplits = paySplitArray.length;
let totalDiscount = quotationFormData.value.discount || 0;
const averageDiscount = totalDiscount / numSplits;
// Adjust each amount
paySplitArray.forEach((split: PaySplit) => {
if (split.amount < averageDiscount) {
totalDiscount -= split.amount; // Subtract the full amount from totalDiscount
split.amount = 0; // Set the amount to 0
} else {
split.amount -= averageDiscount; // Subtract the average discount
totalDiscount -= averageDiscount; // Adjust remaining totalDiscount
}
});
// Distribute any remaining discount
if (totalDiscount > 0) {
paySplitArray.forEach((split: PaySplit) => {
if (totalDiscount === 0) return;
const deduction = Math.min(split.amount, totalDiscount);
split.amount -= deduction;
totalDiscount -= deduction;
});
}
// Update paySplit in quotationFormData
quotationFormData.value.paySplit = paySplitArray;
}
}
function handleChangePayType(type: PayCondition) {
if (
type === 'Split' &&
tempPaySplitCount.value &&
tempPaySplit.value &&
tempTableProduct.value
) {
quotationFormData.value.paySplitCount = tempPaySplitCount.value;
quotationFormData.value.paySplit = JSON.parse(
JSON.stringify(tempPaySplit.value),
);
productServiceList.value = productServiceList.value.map((item, index) => ({
...item,
installmentNo: tempTableProduct.value[index].installmentNo || 0,
}));
}
}
function handleUpdateProductTable(
data: QuotationPayload['productServiceList'][number],
opt?: {
newInstallmentNo: number;
},
) {
// calc price
const calc = (c: QuotationPayload['productServiceList'][number]) => {
const pricePerUnit = c.pricePerUnit || 0;
const discount = c.discount || 0;
return precisionRound(
pricePerUnit * c.amount -
discount +
(c.product.calcVat
? (pricePerUnit * c.amount - discount) * (config.value?.vat || 0.07)
: 0),
);
};
// installment
if (opt && opt.newInstallmentNo) {
const oldInstallmentNo = JSON.parse(JSON.stringify(data.installmentNo));
const oldPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === oldInstallmentNo,
);
const newPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === opt.newInstallmentNo,
);
if (oldPaySplit) oldPaySplit.amount = oldPaySplit.amount - calc(data);
if (newPaySplit) newPaySplit.amount = newPaySplit.amount + calc(data);
return;
}
// re calc price
const currTempPaySplit = tempPaySplit.value.find(
(v) => v.no === data.installmentNo,
);
const currPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === data.installmentNo,
);
const targetPaySplit = productService.value.filter(
(v) => v.installmentNo === data.installmentNo,
);
let targetPaySplitAmount: number[] = [];
targetPaySplitAmount = targetPaySplit.map((c) => {
return calc(c);
});
const totalSum = targetPaySplitAmount.reduce((sum, value) => sum + value, 0);
if (currTempPaySplit && currPaySplit) {
currTempPaySplit.amount = totalSum;
currPaySplit.amount = totalSum;
}
}
function toggleDeleteProduct(index: number) {
const currProduct = productServiceList.value[index];
let currPaySplit = quotationFormData.value.paySplit.find(
(v) => v.no === currProduct.installmentNo,
);
let currTempPaySplit = tempPaySplit.value.find(
(v) => v.no === currProduct.installmentNo,
);
// cal curr amount
if (currPaySplit && currTempPaySplit) {
const price = agentPrice.value
? currProduct.product.agentPrice
: currProduct.product.price;
const pricePerUnit = currProduct.product.vatIncluded
? precisionRound(price / (1 + (config.value?.vat || 0.07)))
: price;
const vat = precisionRound(
(pricePerUnit * currProduct.amount - currProduct.discount) *
(config.value?.vat || 0.07),
);
const finalPrice = precisionRound(
pricePerUnit * currProduct.amount +
vat -
Number(currProduct.discount || 0),
);
currTempPaySplit.amount = currPaySplit.amount - finalPrice;
currPaySplit.amount = currTempPaySplit.amount;
}
// product display
productServiceList.value.splice(index, 1);
productServiceList.value = [...productServiceList.value]; //
productServiceList.value = [...productServiceList.value];
// find split count
tempPaySplitCount.value = Math.max(
...productServiceList.value.map((v) => v.installmentNo || 0),
);
quotationFormData.value.paySplitCount = tempPaySplitCount.value;
// make split.length === split count
tempPaySplit.value.splice(quotationFormData.value.paySplitCount);
quotationFormData.value.paySplit.splice(
quotationFormData.value.paySplitCount,
);
}
async function assignWorkerToSelectedWorker() {
@ -678,6 +843,7 @@ async function assignWorkerToSelectedWorker() {
}
function convertToTable(nodes: Node[]) {
// TODO:
const _recursive = (v: Node): Node | Node[] => {
if (v.checked && v.children) return v.children.flatMap(_recursive);
if (v.checked) return v;
@ -703,6 +869,7 @@ function convertToTable(nodes: Node[]) {
});
productServiceList.value = list;
tempTableProduct.value = JSON.parse(JSON.stringify(list));
quotationFormData.value.paySplit = Array.apply(
null,
@ -907,7 +1074,18 @@ const productServiceNodes = ref<ProductTree>([]);
watch(
() => productServiceList.value,
() => {
productServiceNodes.value = quotationProductTree(productServiceList.value);
productServiceNodes.value = quotationProductTree(
productServiceList.value,
agentPrice.value,
config.value?.vat,
);
},
);
watch(
() => quotationFormData.value.discount,
() => {
handlePaySplitDiscount();
},
);
@ -1041,7 +1219,6 @@ async function getWorkerFromCriteria(
if (!ret) return false; // error, do not close dialog
// TODO: Merge employee into worker list
const deduplicate = ret.result.filter(
(a) => !selectedWorker.value.find((b) => a.id === b.id),
);
@ -1358,6 +1535,7 @@ async function getWorkerFromCriteria(
:installment-input="
quotationFormData.payCondition === 'SplitCustom'
"
:max-installment="quotationFormData.paySplitCount"
:readonly="readonly"
:agent-price="agentPrice"
:employee-rows="
@ -1386,8 +1564,11 @@ async function getWorkerFromCriteria(
@delete="toggleDeleteProduct"
:rows="productService"
@update:rows="
(v) => view === View.Quotation && (productServiceList = v)
(v) => {
view === View.Quotation && (productServiceList = v);
}
"
@update-table="handleUpdateProductTable"
/>
</div>
</q-expansion-item>
@ -1420,9 +1601,8 @@ async function getWorkerFromCriteria(
<template v-if="true">
<QuotationFormInfo
:view="view"
:installment-amount="installmentAmount"
:installment-no="selectedInstallmentNo"
:pay-split-count-fixed="tempPaySplitCount"
:pay-split-fixed="tempPaySplit"
v-model:pay-type="quotationFormData.payCondition"
v-model:pay-bank="payBank"
v-model:pay-split-count="quotationFormData.paySplitCount"
@ -1432,6 +1612,7 @@ async function getWorkerFromCriteria(
v-model:pay-bill-date="quotationFormData.payBillDate"
v-model:summary-price="summaryPrice"
class="q-mb-md"
@change-pay-type="handleChangePayType"
/>
</template>
</div>
@ -1642,9 +1823,24 @@ async function getWorkerFromCriteria(
selectedInstallmentNo = selectedInstallment.map(
(value: any) => value.no,
);
installmentAmount = selectedInstallment.reduce(
(total: number, value: any) => total + value.amount,
0,
);
}
"
row-key="no"
:no-data-label="$t('general.noDataTable')"
:rows-per-page-options="[0]"
hide-pagination
:selected-rows-label="
(n) =>
$t('general.selected', {
number: n,
msg: $t('productService.product.product'),
})
"
>
<template v-slot:header="props">
<q-tr
@ -1670,7 +1866,6 @@ async function getWorkerFromCriteria(
@click="
async () => {
if (props.row.invoiceId) {
// TODO: get invoice code
await getInvoiceCode(props.row.invoiceId);
selectedInstallmentNo =
@ -1680,6 +1875,8 @@ async function getWorkerFromCriteria(
v.invoiceId === props.row.invoiceId,
)
.map((v) => v.no) || [];
installmentAmount = props.row.amount;
view = View.Invoice;
}
}

View file

@ -14,18 +14,18 @@ import SelectInput from 'src/components/shared/SelectInput.vue';
import { storeToRefs } from 'pinia';
import { precisionRound } from 'src/utils/arithmetic';
import { PayCondition } from 'src/stores/quotations/types.ts';
defineEmits<{
(e: 'changePayType', type: PayCondition): void;
}>();
const props = defineProps<{
readonly?: boolean;
quotationNo?: string;
installmentNo?: number[];
paySplitCountFixed?: number;
paySplitFixed?: {
no: number;
amount: number;
name?: string;
invoice?: boolean;
}[];
installmentAmount?: number;
view?: View;
data?: {
total: number;
@ -42,14 +42,7 @@ const { data: config } = storeToRefs(configStore);
const payBillDate = defineModel<Date | null | undefined>('payBillDate', {
required: false,
});
const payType = defineModel<
| 'Full'
| 'Split'
| 'SplitCustom'
| 'BillFull'
| 'BillSplit'
| 'BillSplitCustom'
>('payType', { required: true });
const payType = defineModel<PayCondition>('payType', { required: true });
const paySplitCount = defineModel<number | null>('paySplitCount', {
default: 1,
});
@ -92,6 +85,17 @@ const payTypeOption = computed(() => [
]);
const amount4Show = ref<string[]>([]);
function handleSplitCount(count: number) {
if (count > paySplit.value.length) {
paySplit.value.push({ no: Number(count), amount: 0 });
} else {
paySplit.value[paySplit.value.length - 2].amount =
paySplit.value[paySplit.value.length - 1].amount +
paySplit.value[paySplit.value.length - 2].amount;
paySplit.value.pop();
}
}
function calculateInstallments(param: {
customIndex?: number;
customAmount?: number;
@ -159,31 +163,21 @@ function calculateInstallments(param: {
}
}
watch(
() => payType.value,
(v) => {
if (v === 'Split' && props.paySplitCountFixed && props.paySplitFixed) {
paySplitCount.value = props.paySplitCountFixed;
paySplit.value = JSON.parse(JSON.stringify(props.paySplitFixed));
}
},
);
watch(
() => [paySplitCount.value, summaryPrice.value.finalPrice],
([newCount, _newF], [oldCount, _oldF]) => {
if (
paySplitCount.value === 0 ||
!paySplitCount.value ||
!summaryPrice.value.finalPrice ||
props.readonly
) {
return;
}
calculateInstallments({ newCount: newCount || 0, oldCount: oldCount || 0 });
},
{ deep: true },
);
// watch(
// () => [paySplitCount.value, summaryPrice.value.finalPrice],
// ([newCount, _newF], [oldCount, _oldF]) => {
// if (
// paySplitCount.value === 0 ||
// !paySplitCount.value ||
// !summaryPrice.value.finalPrice ||
// props.readonly
// ) {
// return;
// }
// calculateInstallments({ newCount: newCount || 0, oldCount: oldCount || 0 });
// },
// { deep: true },
// );
</script>
<template>
@ -214,7 +208,13 @@ watch(
:option="payTypeOption"
:readonly
id="pay-type"
v-model="payType"
:model-value="payType"
@update:model-value="
(v) => {
payType = v as PayCondition;
$emit('changePayType', v as PayCondition);
}
"
/>
<div
@ -246,7 +246,12 @@ watch(
dense
outlined
min="1"
@update:model-value="(i) => (paySplitCount = Number(i))"
@update:model-value="
(i) => {
paySplitCount = Number(i);
handleSplitCount(Number(i));
}
"
/>
</div>
</div>
@ -347,7 +352,10 @@ watch(
{{ $t('quotation.summary') }}
</span>
</div>
<div class="q-pa-sm price-container">
<div
class="q-pa-sm price-container"
v-if="payType !== 'SplitCustom' || view !== View.Invoice"
>
<div class="row">
{{ $t('general.total') }}
<span class="q-ml-auto">
@ -415,6 +423,7 @@ watch(
input-class="text-right"
debounce="500"
:model-value="commaInput(finalDiscount.toString() || '0')"
@focus="(e) => (e.target as HTMLInputElement).select()"
@update:model-value="
(v) => {
if (typeof v === 'string') finalDiscount4Show = commaInput(v);
@ -435,7 +444,10 @@ watch(
<span class="q-ml-auto" style="color: var(--brand-1)">
{{
formatNumberDecimal(Math.max(summaryPrice.finalPrice, 0), 2) || 0
payType === 'SplitCustom' && view === View.Invoice
? formatNumberDecimal(Math.max(installmentAmount || 0, 0), 2) || 0
: formatNumberDecimal(Math.max(summaryPrice.finalPrice, 0), 2) ||
0
}}
฿
</span>

View file

@ -230,6 +230,7 @@ function mapCard() {
}
const productCount = ref(0);
function mapNode() {
refSelectZone.value?.assignSelect(
selectedItems.value,
@ -361,7 +362,7 @@ function mapNode() {
}
});
nodes.value = node;
nodes.value = JSON.parse(JSON.stringify(node));
pageState.addModal = false;
}
watch(
@ -404,7 +405,11 @@ watch(
:title="$t('general.list', { msg: $t('productService.title') })"
:submit-label="$t('general.select', { msg: $t('productService.title') })"
submit-icon="mdi-check"
:submit="() => $emit('submit', nodes)"
:submit="
() => {
$emit('submit', nodes);
}
"
>
<q-splitter
v-model="splitterModel"

View file

@ -145,8 +145,6 @@ export const useQuotationForm = defineStore('form-quotation', () => {
currentFormData.value = structuredClone(resetFormData);
console.log(currentFormData.value);
currentFormState.value.createdBy = (locale) =>
locale === 'eng'
? data.createdBy.firstNameEN + ' ' + data.createdBy.lastNameEN

View file

@ -1,4 +1,5 @@
import { QuotationFull } from 'src/stores/quotations/types';
import { precisionRound } from 'src/utils/arithmetic';
export type ProductTree = {
type?: string;
@ -40,6 +41,8 @@ export function quotationProductTree(
work?: QuotationFull['productServiceList'][number]['work'];
product: QuotationFull['productServiceList'][number]['product'];
}[],
agentPrice?: boolean,
vat?: number,
): ProductTree {
const ret: ProductTree = [];
@ -64,6 +67,13 @@ export function quotationProductTree(
const mapper = (
relation: (typeof work)['productOnWork'][number],
): ProductTree[number] => {
const price = agentPrice
? relation.product.agentPrice
: relation.product.price;
const pricePerUnit = relation.product.vatIncluded
? precisionRound(price / (1 + (vat || 0.07)))
: price;
return {
type: 'product',
id: relation.product.id,
@ -80,7 +90,7 @@ export function quotationProductTree(
),
value: {
vat: current.vat,
pricePerUnit: current.pricePerUnit,
pricePerUnit: pricePerUnit,
discount: current.discount,
amount: current.amount,
serviceId: service.id,

View file

@ -342,17 +342,7 @@ export type QuotationFull = {
};
export type QuotationPayload = {
productServiceList: {
workerIndex: number[];
vat?: number;
pricePerUnit?: number;
discount?: number;
amount: number;
product: ProductRelation;
installmentNo?: number;
work?: WorkRelation | null;
service?: ServiceRelation | null;
}[];
productServiceList: ProductServiceList[];
customerBranchId: string;
registeredBranchId: string;
urgent: boolean;
@ -437,3 +427,23 @@ export type PaymentPayload = {
date: Date;
amount: number;
};
export type ProductServiceList = {
workerIndex: number[];
vat?: number;
pricePerUnit?: number;
discount?: number;
amount: number;
product: ProductRelation;
installmentNo?: number;
work?: WorkRelation | null;
service?: ServiceRelation | null;
};
export type PaySplit = {
no: number;
amount: number;
name?: string;
invoice?: boolean;
invoiceId?: string;
};

View file

@ -24,13 +24,13 @@ const templates = {
}) => {
if (context?.paymentType === 'Full') {
return [
`**** เงื่อนไขเพิ่มเติม`,
`- เงื่อนไขการชำระเงิน แบบเต็มจำนวน`,
'**** เงื่อนไขเพิ่มเติม',
'- เงื่อนไขการชำระเงิน แบบเต็มจำนวน',
`&nbsp; จำนวน ${formatNumberDecimal(context?.amount || 0, 2)}`,
].join('<br/>');
} else {
return [
`**** เงื่อนไขเพิ่มเติม`,
'**** เงื่อนไขเพิ่มเติม',
`- เงื่อนไขการชำระเงิน แบบแบ่งจ่าย${context?.paymentType === 'SplitCustom' ? ' กำหนดเอง ' : ' '}${context?.installments?.length} งวด`,
...(context?.installments?.map(
(v) =>
@ -63,7 +63,7 @@ export function convertTemplate(
? template.converter(context?.[name as TemplateName] as any)
: template.converter,
);
console.log(ret);
// console.log(ret);
}
return ret;