PayrollManagementSystem/frontend/src/views/calc/ledger/index.vue
liuwenxin b3de35c2ec fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
2026-11-06 16:27:45 +08:00

294 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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.

<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>