Files
17168ERP/web/admin/transfer/personal_reconcile.aspx
2025-08-29 01:27:25 +08:00

529 lines
20 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<%@ Page Title="個人-沖帳流程" Language="C#" MasterPageFile="~/admin/Templates/TBS5ADM001/MasterPage.master" AutoEventWireup="true" EnableEventValidation="false" CodeFile="personal_reconcile.aspx.cs" Inherits="admin_transfer_personal_reconcile" %>
<asp:Content ID="Content1" ContentPlaceHolderID="page_header" runat="Server">
</asp:Content>
<asp:Content ID="Content3" ContentPlaceHolderID="page_nav" runat="Server">
<h5 class="mb-0">個人 - 沖帳流程</h5>
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="ContentPlaceHolder1" runat="Server">
<div id="reconcile-app">
<v-app>
<v-container>
<v-card>
<v-card-title class="bg-primary white--text text-center">
<h5 class="mb-0">個人 - 沖帳流程</h5>
</v-card-title>
<v-card-text>
<v-data-table
:headers="headers"
:items="items"
:loading="loading"
loading-text="載入中..."
class="elevation-1"
item-key="id"
>
<template v-slot:item.follower="{ item }">
<span :title="item.f_num">{{ item.follower }}</span>
</template>
<template v-slot:item.acc_name="{ item }">
<span>
<span v-if="hasTransferDraft(item.draft)" style="margin-right: 4px;">📝</span>{{ item.acc_name }}
</span>
</template>
<template v-slot:item.check_date="{ item }">
<span>{{ item.check_date | date }}</span>
</template>
<template v-slot:item.check_memo="{ item }">
<span>{{ item.check_memo }}</span>
</template>
<template v-slot:item.check_status="{ item }">
<span>{{ item.check_status }}</span>
</template>
<template v-slot:item.verify_note="{ item }">
<span>{{ item.verify_note }}</span>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
small
color="success"
@click="showReconcileDialog(item)"
>
沖帳
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
<!-- 沖帳明細對話框 -->
<v-dialog v-model="dialog.show" max-width="960px">
<v-card>
<v-card-title class="grey lighten-2">
<span v-if="dialog.selected && hasTransferDraft(dialog.selected.draft)" style="margin-right: 8px;">📝</span>沖帳明細
<v-spacer></v-spacer>
<v-btn icon @click="dialog.show = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="mb-0 pb-0">
<div v-if="dialog.selected">
<div class="row my-2">
<h6 class="col">信眾:{{ dialog.selected.follower }}</h6>
<div class="col">
<span class="font-weight-bold">入帳金額:</span>
<span class="text-primary">{{ dialog.selected.check_amount | currency }}</span>
</div>
<div class="col">
<span class="font-weight-bold">已沖金額:</span>
<span :class="{'text-danger': isOverPaid, 'text-dark': !isOverPaid}">{{ sumReconcile | currency }}</span>
</div>
<div class="col">
<span class="font-weight-bold">未繳餘款:</span>
<span class="text-danger">{{ remainDue | currency }}</span>
</div>
<div class="col">
<span class="font-weight-bold">入帳後餘額:</span>
<span class="text-success">{{ overPaid | currency }}</span>
</div>
</div>
<v-data-table
:headers="dialog.headers"
:items="dialog.items"
class="elevation-1 mt-3"
hide-default-footer
:disable-pagination="true"
>
<template v-slot:item.activity_name="{ item }">
<div>
<div>{{ item.activity_name }}</div>
<div class="text-muted" style="font-size:12px;">{{ item.order_no }}</div>
</div>
</template>
<template v-slot:item.paid="{ item }">
<span>{{ item.paid | currency }}</span>
</template>
<template v-slot:item.due="{ item }">
<span>{{ item.due | currency }}</span>
</template>
<template v-slot:item.reg_time="{ item }">
<span>{{ item.reg_time | date }}</span>
</template>
<template v-slot:item.reconcile="{ item, index }">
<v-text-field
v-model="item.reconcile"
type="number"
dense
outlined
hide-details="auto"
:rules="[
v => !isNaN(Number(v)) || '請輸入數字',
v => Number(v) >= 0 || '不可小於 0',
v => Number(v) <= Number(item.due) || `不可大於待繳金額 ${item.due}`
]"
@blur="validateAndUpdateReconcile($event.target.value, item, index)"
></v-text-field>
</template>
</v-data-table>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<div>
<div v-if="hasUnallocated && remainDue > 0" class="text-dark mb-2">
⚠️ 尚有未分配的入帳金額,請確認是否全部分配
</div>
<div v-if="hasUnallocated && remainDue === 0" class="text-info mb-2">
已無未繳項目,剩餘入帳金額將成為餘額
</div>
<div v-if="!canConfirm && buttonErrorMessage" class="text-danger">
❌ {{ buttonErrorMessage }}
</div>
</div>
<v-spacer></v-spacer>
<v-btn color="info" @click="redistributeReconcile" class="mr-2">重新分配</v-btn>
<v-btn color="orange" @click="saveDraft" class="mr-2">暫存</v-btn>
<v-btn color="primary" @click="confirmReconcile" :disabled="!canConfirm">確認沖帳</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>
</div>
</asp:Content>
<asp:Content ID="Content5" ContentPlaceHolderID="footer_script" runat="Server">
<script src="draft-utils.js"></script>
<script>
// 確保 DraftUtils 已載入
if (typeof window.DraftUtils === 'undefined') {
console.warn('DraftUtils 未載入,使用備用方案');
window.DraftUtils = {
hasTransferDraft: function(draft) { return false; },
getDraftField: function(draft, field) { return null; },
updateDraftField: function(draft, field, value) {
return { [field]: value };
}
};
}
new Vue({
el: '#reconcile-app',
vuetify: new Vuetify(),
data() {
return {
loading: false,
headers: [
//{ text: '信眾#', value: 'f_num' },
{ text: '信眾', value: 'follower' },
{ text: '入帳銀行/帳戶', value: 'acc_name' },
{ text: '入帳日期', value: 'check_date' },
{ text: '帳簿備註', value: 'check_memo' },
{ text: '入帳金額', value: 'check_amount' },
//{ text: '狀態', value: 'check_status' },
{ text: '核對記錄', value: 'verify_note' },
{ text: '沖帳', value: 'actions', sortable: false }
],
items: [],
dialog: {
show: false,
selected: null,
headers: [
{ text: '法會/報名單號', value: 'activity_name' },
{ text: '報名日期', value: 'reg_time' },
{ text: '項目', value: 'actitem_name' },
{ text: '應繳金額', value: 'price', sortable: false },
{ text: '已繳金額', value: 'paid', sortable: false },
{ text: '待繳金額', value: 'due', sortable: false },
{ text: '沖帳金額', value: 'reconcile', sortable: false }
],
items: [],
loading: false,
errorMessage: ''
},
hasUnallocated: false,
draftDataChanged: false
}
},
computed: {
sumReconcile() {
return this.dialog.items.reduce((sum, item) => sum + (Number(item.reconcile) || 0), 0);
},
remainDue() {
const totalDue = this.dialog.items.reduce((sum, item) => sum + (Number(item.due) || 0), 0);
return totalDue - this.sumReconcile;
},
overPaid() {
if (!this.dialog.selected) return 0;
return this.dialog.selected.check_amount - this.sumReconcile;
},
isOverPaid() {
if (!this.dialog.selected) return false;
return this.sumReconcile > this.dialog.selected.check_amount;
},
canConfirm() {
if (!this.dialog.selected) return false;
const hasReconcile = this.dialog.items.some(item => Number(item.reconcile) > 0);
if (!hasReconcile) return false;
const validAmounts = this.dialog.items.every(item => {
const amount = Number(item.reconcile) || 0;
return amount >= 0 && amount <= Number(item.due);
});
if (!validAmounts) return false;
if (this.sumReconcile > this.dialog.selected.check_amount) return false;
// 有未分配金額時,只有在還有未繳餘款的情況下才禁用按鈕
// 如果未繳餘款為 0表示沒有更多項目可沖帳應允許確認
if (this.hasUnallocated && this.remainDue > 0) return false;
return true;
},
buttonErrorMessage() {
if (!this.dialog.selected) return '';
const hasReconcile = this.dialog.items.some(item => Number(item.reconcile) > 0);
if (!hasReconcile) return '請至少輸入一筆沖帳金額';
const invalidItem = this.dialog.items.find(item => {
const amount = Number(item.reconcile) || 0;
return amount < 0 || amount > Number(item.due);
});
if (invalidItem) return '沖帳金額超出可沖帳範圍';
if (this.sumReconcile > this.dialog.selected.check_amount) {
return '已沖金額不可大於入帳金額';
}
if (this.hasUnallocated && this.remainDue > 0) {
return '尚有未繳項目,請將入帳金額完全分配';
}
return '';
}
},
mounted() {
this.loadTableData();
},
filters: {
currency(val) {
if (!val) return '0';
return Number(val).toLocaleString();
},
date(val) {
if (!val) return '';
const date = new Date(val);
return date.toLocaleDateString();
}
},
methods: {
// 檢查是否有 DraftUtils 可用
hasTransferDraft(draft) {
return window.DraftUtils && window.DraftUtils.hasTransferDraft && window.DraftUtils.hasTransferDraft(draft);
},
// 使用全域 DraftUtils 工具函數
showError(message) {
this.dialog.errorMessage = message;
},
clearError() {
this.dialog.errorMessage = '';
},
loadTableData() {
this.loading = true;
axios.get('../../api/transfer_register/personal_reconcile_list')
.then(res => {
this.items = res.data;
})
.catch(() => {
this.items = [];
})
.finally(() => {
this.loading = false;
});
},
showReconcileDialog(item) {
this.dialog.selected = item;
this.dialog.items = [];
this.dialog.show = true;
// 依 f_num 呼叫 API 取得訂單明細
if (item.f_num) {
axios.get('../../api/transfer_register/follower_orders', { params: { f_num: item.f_num } })
.then(res => {
this.dialog.items = res.data;
this.loadFromDraftOrAutoDistribute();
})
.catch(() => {
this.dialog.items = [];
this.updateSumReconcile();
});
} else {
this.dialog.items = [];
this.updateSumReconcile();
}
},
autoDistributeReconcile() {
// 先進先出分配沖帳金額
let remainAmount = this.dialog.selected ? this.dialog.selected.check_amount : 0;
// 先將所有項目的 reconcile 清為 0
this.dialog.items.forEach(item => {
this.$set(item, 'reconcile', 0);
});
// 依報名日期排序(先進先出)
const sortedItems = [...this.dialog.items].sort((a, b) =>
new Date(a.reg_time) - new Date(b.reg_time)
);
// 逐項分配
sortedItems.forEach(item => {
if (remainAmount > 0) {
const canPay = Math.min(item.due, remainAmount);
const originalItem = this.dialog.items.find(i => i === item);
if (originalItem) {
this.$set(originalItem, 'reconcile', canPay);
}
remainAmount -= canPay;
}
});
this.updateSumReconcile();
},
redistributeReconcile() {
// 重新依先進先出原則分配沖帳金額
this.autoDistributeReconcile();
},
updateSumReconcile() {
// 只更新 hasUnallocated 狀態
const maxTotal = this.dialog.selected ? (this.dialog.selected.check_amount || 0) : 0;
this.hasUnallocated = maxTotal > this.sumReconcile;
},
loadFromDraftOrAutoDistribute() {
const draft = this.dialog.selected ? this.dialog.selected.draft : null;
if (draft && draft.trim() && window.DraftUtils && window.DraftUtils.getDraftField) {
const transferDraft = window.DraftUtils.getDraftField(draft, 'transfer_draft');
if (transferDraft && Array.isArray(transferDraft)) {
this.loadFromDraft(transferDraft);
} else {
this.autoDistributeReconcile();
}
} else {
this.autoDistributeReconcile();
}
},
loadFromDraft(draftData) {
this.draftDataChanged = false;
// 先將所有項目的 reconcile 設為 0
this.dialog.items.forEach(item => {
this.$set(item, 'reconcile', 0);
});
// 從 draft 資料填入沖帳金額
draftData.forEach(draftItem => {
const matchedItem = this.dialog.items.find(item =>
item.num === draftItem.pro_order_detail_num
);
if (matchedItem) {
this.$set(matchedItem, 'reconcile', draftItem.reconcile || 0);
} else {
// 找不到對應的項目,標記資料已改動
this.draftDataChanged = true;
}
});
this.updateSumReconcile();
// 如果有資料改動,提醒用戶
if (this.draftDataChanged) {
setTimeout(() => {
alert('資料已改動,請注意金額一致性');
}, 100);
}
},
saveDraft() {
try {
// 組成新的 transfer_draft 資料
const transferDraftData = this.dialog.items
.filter(item => Number(item.reconcile) > 0)
.map(item => ({
pro_order_detail_num: item.num,
reconcile: Number(item.reconcile)
}));
// 使用工具函數安全地更新 draft 物件
const currentDraft = this.dialog.selected.draft || '';
let draftJson = '';
if (window.DraftUtils && window.DraftUtils.updateDraftField) {
const newDraftObj = window.DraftUtils.updateDraftField(currentDraft, 'transfer_draft', transferDraftData);
draftJson = JSON.stringify(newDraftObj);
} else {
// 如果 DraftUtils 不可用,使用簡單的格式
draftJson = JSON.stringify({ transfer_draft: transferDraftData });
}
// 更新 draft 欄位
const updateData = {
id: this.dialog.selected.id,
activity_num: this.dialog.selected.activity_num,
name: this.dialog.selected.name,
phone: this.dialog.selected.phone,
pay_type: this.dialog.selected.pay_type,
account_last5: this.dialog.selected.account_last5,
amount: this.dialog.selected.amount,
pay_mode: this.dialog.selected.pay_mode,
note: this.dialog.selected.note,
proof_img: this.dialog.selected.proof_img,
status: this.dialog.selected.status,
f_num_match: this.dialog.selected.f_num_match,
f_num: this.dialog.selected.f_num,
acc_num: this.dialog.selected.acc_num,
check_date: this.dialog.selected.check_date,
check_amount: this.dialog.selected.check_amount,
check_memo: this.dialog.selected.check_memo,
check_status: this.dialog.selected.check_status,
acc_kind: this.dialog.selected.acc_kind,
member_num: this.dialog.selected.member_num,
verify_time: this.dialog.selected.verify_time,
verify_note: this.dialog.selected.verify_note,
draft: draftJson // 更新 draft 欄位
};
axios.put(`../../api/transfer_register/${this.dialog.selected.id}`, updateData)
.then(() => {
alert('暫存成功!');
// 更新本地資料
this.dialog.selected.draft = draftJson;
})
.catch(err => {
alert('暫存失敗:' + (err.response?.data?.message || err.message));
});
} catch (e) {
alert('暫存失敗:資料格式錯誤');
}
},
confirmReconcile() {
if (!this.canConfirm) {
this.showError(this.buttonErrorMessage);
return;
}
this.dialog.loading = true;
const over_payment = this.dialog.selected.check_amount - this.sumReconcile;
const postData = {
transfer_register_id: this.dialog.selected.id,
details: this.dialog.items
.filter(item => item.reconcile > 0)
.map(item => ({
pro_order_detail_num: item.num,
amount: Number(item.reconcile),
reg_time: new Date().toISOString(),
demo: `${this.dialog.selected.check_memo || ''}`
})),
over_payment: over_payment
};
axios.post('../../api/transfer_register/reconcile', postData)
.then(res => {
// 顯示成功訊息
const message = res.data && res.data.message ? res.data.message : '沖帳完成';
msgtop(message, 'success');
this.dialog.show = false;
this.loadTableData(); // 重新載入清單
})
.catch(err => {
let errorMessage = '沖帳失敗,請聯繫系統管理員';
if (err.response && err.response.data) {
if (typeof err.response.data === 'string') {
errorMessage = err.response.data;
} else if (err.response.data.message) {
errorMessage = err.response.data.message;
if (err.response.data.exceptionMessage) {
errorMessage += `\\n${err.response.data.exceptionMessage}`;
}
}
}
alert(errorMessage);
})
.finally(() => {
this.dialog.loading = false;
});
},
validateAndUpdateReconcile(value, item, index) {
let numValue = Number(value);
if (isNaN(numValue)) {
numValue = 0;
}
numValue = Math.round(numValue);
this.$set(item, 'reconcile', numValue);
this.updateSumReconcile();
}
}
});
</script>
</asp:Content>