294 lines
14 KiB
Vue
294 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||
import { optionPage } from '@/api/metadata'
|
||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||
const permissionUser = usePermissionUser()
|
||
|
||
import PayrollAccessNotice from '@/components/feedback/PayrollAccessNotice.vue'
|
||
import LedgerCalculationDetails from '@/components/payroll/LedgerCalculationDetails.vue'
|
||
import { useUserStore } from '@/store/user'
|
||
const payrollUser = useUserStore()
|
||
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import * as calcApi from '@/api/calc'
|
||
import * as payrollApi from '@/api/payroll'
|
||
import type { LedgerRow } from '@/api/calc'
|
||
import { listPersonnel } from '@/api/personnel'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 绩效核算台账(M3 ④):核算事实的固化点。
|
||
*
|
||
* - 核算=把回款固化为台账行(每笔回款仅一次;同业务重复回款仅登记进度)
|
||
* - 重算=作废旧版本、按当前参数落新版本(管理员/财务授权,原因留痕)
|
||
* - 行展开看计算快照(所用参数版本与行级折算明细)
|
||
*/
|
||
|
||
const filters = reactive({
|
||
settlement_month: '',
|
||
is_active: 1 as number | undefined,
|
||
})
|
||
|
||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
|
||
usePagedList<LedgerRow, { settlement_month?: string; is_active?: number }>(calcApi.listLedger, () => ({
|
||
settlement_month: filters.settlement_month.trim() || undefined,
|
||
is_active: filters.is_active,
|
||
}))
|
||
|
||
onMounted(() => {
|
||
void load()
|
||
})
|
||
|
||
function resetFilters(): void {
|
||
filters.settlement_month = ''
|
||
filters.is_active = 1
|
||
search()
|
||
}
|
||
|
||
const recalcVisible = ref(false)
|
||
const recalcTarget = ref<LedgerRow | null>(null)
|
||
const recalcReason = ref('')
|
||
const allocationVisible = ref(false), allocationLoading = ref(false)
|
||
const allocationRows = ref<Record<string, unknown>[]>([])
|
||
const adjustmentOpen = ref(false), adjustmentBusy = ref(false)
|
||
const adjustmentPeople = ref<{ id: number; name: string }[]>([])
|
||
const adjustmentPeopleLoading = ref(false)
|
||
let adjustmentPeopleRequest = 0
|
||
async function searchAdjustmentPeople(keyword = ''): Promise<void> {
|
||
const request = ++adjustmentPeopleRequest
|
||
adjustmentPeopleLoading.value = true
|
||
try {
|
||
const result = await optionPage<Awaited<ReturnType<typeof listPersonnel>>['list'][number]>('personnel', {search: keyword || undefined})
|
||
if (request !== adjustmentPeopleRequest) return
|
||
const selected = adjustmentPeople.value.find(person => person.id === adjustment.personnel_id)
|
||
const rows = result.list.map(person => ({ id: person.id, name: person.personnel_name }))
|
||
adjustmentPeople.value = selected && !rows.some(person => person.id === selected.id) ? [selected, ...rows] : rows
|
||
} catch (error) { handledError(error) }
|
||
finally { if (request === adjustmentPeopleRequest) adjustmentPeopleLoading.value = false }
|
||
}
|
||
const adjustment = reactive({ledger_id: 0, personnel_id: 0, direction: 1, amount: '', reason: ''})
|
||
function openAdjustment(row: LedgerRow): void {
|
||
Object.assign(adjustment, {ledger_id: row.id, personnel_id: row.personnel_id, direction: 1, amount: '', reason: ''})
|
||
adjustmentPeople.value = [{ id: row.personnel_id, name: row.personnel_name }]
|
||
adjustmentOpen.value = true
|
||
void searchAdjustmentPeople()
|
||
}
|
||
async function saveAdjustment(): Promise<void> {
|
||
if (!adjustment.amount || !adjustment.reason.trim()) { ElMessage.warning('请填写差额和调整依据'); return }
|
||
adjustmentBusy.value = true
|
||
try { await payrollApi.allocationAdjustment(adjustment); ElMessage.success('调整单已生成,差额归生成当月'); adjustmentOpen.value = false }
|
||
catch (e) { handledError(e) } finally { adjustmentBusy.value = false }
|
||
}
|
||
async function showAllocations(row: LedgerRow): Promise<void> {
|
||
allocationVisible.value = true; allocationLoading.value = true; allocationRows.value = []
|
||
try { allocationRows.value = (await payrollApi.allocations(row.id)).list }
|
||
catch (e) { handledError(e) } finally { allocationLoading.value = false }
|
||
}
|
||
|
||
function openRecalc(row: LedgerRow): void {
|
||
// 聚合行(calc_type=2)receipt_id 为 NULL,无回款可重算(按钮已限量,防御)
|
||
if (row.receipt_id == null) return
|
||
recalcTarget.value = row
|
||
recalcReason.value = ''
|
||
recalcVisible.value = true
|
||
}
|
||
|
||
async function submitRecalc(): Promise<void> {
|
||
const row = recalcTarget.value
|
||
if (!row || row.receipt_id == null) return
|
||
if (!recalcReason.value.trim()) {
|
||
ElMessage.warning('重算必须填写原因')
|
||
return
|
||
}
|
||
try {
|
||
// 重算接口按回款 ID 定位(台账行主键 ≠ 回款 ID:传 row.id 会重算到错误回款)
|
||
if (row.snapshot_json?.engine === 'FIFO') await payrollApi.calculate(row.business_id, row.settlement_month, recalcReason.value.trim())
|
||
else await calcApi.recalcReceipt(row.receipt_id, recalcReason.value.trim())
|
||
ElMessage.success('重算完成(新版本已生效)')
|
||
recalcVisible.value = false
|
||
await load()
|
||
} catch (error) {
|
||
handledError(error)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<!-- 页面头:标题 + 一句口径说明(台账是核算事实的固化点)+ 签名水线 -->
|
||
<div class="page-head">
|
||
<h1 class="page-title">绩效台账</h1>
|
||
<p class="page-desc">
|
||
查看正式绩效台账及逐笔计算依据。重算保留历史版本;已生成工资的差额通过当月调整单处理。
|
||
</p>
|
||
<span class="waterline"></span>
|
||
</div>
|
||
<PayrollAccessNotice action="执行核算或生成调整单" />
|
||
|
||
<!--
|
||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||
-->
|
||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||
<el-form class="filter-bar" @submit.prevent>
|
||
<el-form-item label="归属月">
|
||
<el-input
|
||
v-model="filters.settlement_month"
|
||
placeholder="YYYY-MM"
|
||
clearable
|
||
style="width: 140px"
|
||
@keyup.enter="search"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="版本">
|
||
<!-- 可清空:清空=不加 is_active 过滤(axios 自动剔除 undefined),
|
||
即「全部版本」;0 精确匹配历史行,不能用来表达「含历史」 -->
|
||
<el-select
|
||
v-model="filters.is_active"
|
||
placeholder="全部版本"
|
||
clearable
|
||
style="width: 130px"
|
||
>
|
||
<el-option label="仅当前生效" :value="1" />
|
||
<el-option label="仅历史版本" :value="0" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="filter-bar__actions">
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</div>
|
||
</el-form>
|
||
|
||
<div class="card">
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="records"
|
||
stripe
|
||
empty-text="台账还没有核算行 —— 换个归属月看看,或先在回款管理里登记回款后再核算"
|
||
>
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<LedgerCalculationDetails :ledger="row" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="settlement_month" label="归属月" width="90" />
|
||
<el-table-column label="业务编号" min-width="150">
|
||
<template #default="{ row }">
|
||
<span class="cell-code">{{ row.business_no }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="personnel_name" label="归属人员" width="100" show-overflow-tooltip />
|
||
<el-table-column label="岗位" width="110"><template #default="{ row }">{{ ({salesperson: '业务员', sales_manager: '营销经理', info_officer: '信息员'} as Record<string, string>)[row.role_code] ?? '自定义岗位' }}</template></el-table-column>
|
||
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
||
<el-table-column label="天数" width="70" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.payment_days }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="计提比例" min-width="90" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.rate_text }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="折算毛利" min-width="100" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.converted_profit }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="绩效金额" min-width="100" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.performance_amount }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="应付绩效" min-width="100" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.payable_amount }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="版本" width="108">
|
||
<template #default="{ row }">
|
||
<span class="cell-code">v{{ row.calc_version }}</span>
|
||
<!-- 历史版本用中性 pill:被替代不是异常,只是不再是生效版本 -->
|
||
<span v-if="row.is_active === 0" class="ys-pill">已替代</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="210" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="row.is_active === 1 && row.calc_type === 1"
|
||
link
|
||
type="primary"
|
||
@click="openRecalc(row)"
|
||
:disabled="!permissionUser.can('payroll:calculate')"
|
||
:title="permissionUser.permissionReason('payroll:calculate')">
|
||
重算
|
||
</el-button>
|
||
<el-button v-if="row.snapshot_json?.engine === 'FIFO'" link type="primary" @click="showAllocations(row)">分配依据</el-button>
|
||
<el-button v-if="row.settlement_id" link type="primary" :disabled="!permissionUser.can('adjustments:create')" @click="openAdjustment(row)" :title="permissionUser.permissionReason('adjustments:create')">分配调整</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||
<div class="card-foot">
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
|
||
<p class="recalc-warn">
|
||
重算会作废当前版本(v{{ recalcTarget?.calc_version }})并按当前参数落新版本,
|
||
历史版本可追溯。请填写重算原因。
|
||
</p>
|
||
<el-input
|
||
v-model="recalcReason"
|
||
type="textarea"
|
||
:rows="2"
|
||
maxlength="255"
|
||
placeholder="重算原因(必填,留痕)"
|
||
/>
|
||
<template #footer>
|
||
<el-button @click="recalcVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="submitRecalc" :disabled="!permissionUser.can('payroll:calculate')" :title="permissionUser.permissionReason('payroll:calculate')">确认重算</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
<el-dialog v-model="allocationVisible" title="正式分配依据" width="min(1100px, 96vw)">
|
||
<el-table v-loading="allocationLoading" :data="allocationRows" empty-text="暂无正式分配明细">
|
||
<el-table-column prop="receipt_no" label="回款单" min-width="130" /><el-table-column prop="shipment_no" label="发货单" min-width="130" />
|
||
<el-table-column prop="shipment_date" label="发货日期" width="110" /><el-table-column prop="receipt_date" label="到账日期" width="110" />
|
||
<el-table-column prop="amount" label="本次分配金额" width="120" align="right" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" align="right" />
|
||
<el-table-column prop="performance_delta" label="本次计提" width="100" align="right" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" align="right" />
|
||
<el-table-column prop="input_id" label="资料版本编号" width="120" />
|
||
</el-table>
|
||
</el-dialog>
|
||
<el-dialog v-model="adjustmentOpen" title="二次分配差额调整" width="min(560px, 94vw)">
|
||
<el-alert :title="payrollUser.userInfo?.payroll_approval_enabled ? '原工资保持不变,补扣金额计入调整单生成当月。是否需要审核,按当前调整单设置执行。' : '原工资保持不变,补扣金额计入调整单生成当月。当前无需审核,提交后直接生效。'" type="info" :closable="false" />
|
||
<el-form label-position="top"><el-form-item label="调整人员"><el-select v-model="adjustment.personnel_id" filterable remote :remote-method="searchAdjustmentPeople" :loading="adjustmentPeopleLoading" placeholder="搜索并选择人员" aria-label="调整人员"><el-option v-for="person in adjustmentPeople" :key="person.id" :value="person.id" :label="person.name" /></el-select><p class="form-hint">可调整原归属人员或已登记交接范围内的人员。</p></el-form-item><el-form-item label="补扣方向"><el-select v-model="adjustment.direction"><el-option :value="1" label="补发" /><el-option :value="2" label="扣减" /></el-select></el-form-item><el-form-item label="差额(元)"><el-input v-model="adjustment.amount" /></el-form-item><el-form-item label="调整依据"><el-input v-model="adjustment.reason" type="textarea" maxlength="255" /></el-form-item></el-form>
|
||
<template #footer><el-button @click="adjustmentOpen = false">取消</el-button><el-button type="primary" :disabled="!permissionUser.can('adjustments:create')" :loading="adjustmentBusy" @click="saveAdjustment" :title="permissionUser.permissionReason('adjustments:create')">生成当月调整单</el-button></template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 筛选条(.filter-bar)、卡片与表尾(.card/.card-foot)的皮肤来自全局 index.css,
|
||
此处只保留本页特有的快照块与重算提示 */
|
||
|
||
|
||
.recalc-warn {
|
||
margin: 0 0 10px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
</style>
|