feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
5 changed files with 609 additions and 0 deletions
Showing only changes of commit a1217f4482 - Show all commits

70
frontend/src/api/calc.ts Normal file
View File

@ -0,0 +1,70 @@
import request from './request'
import type { PageData } from './types'
/** 绩效台账行(金额/比例一律 str) */
export interface LedgerRow {
id: number
receipt_id: number | null
calc_version: number
is_active: number
calc_type: number
business_id: number
business_no: string
personnel_id: number
personnel_name: string
role_code: string
customer_type: string
product_category: string
ship_date: string | null
receipt_date: string
payment_days: number | null
rate_text: string
converted_profit: string | null
performance_amount: string | null
barter_applied: number
weight_r: number | null
payment_weight: string | null
payable_amount: string | null
settlement_month: string
is_probation: number
missing_cost: number
snapshot_json: Record<string, unknown> | null
calc_reason: string | null
}
export interface LedgerQueryParams {
pageNum?: number
pageSize?: number
settlement_month?: string
personnel_id?: number
business_id?: number
is_active?: number
calc_type?: number
}
export function listLedger(params: LedgerQueryParams): Promise<PageData<LedgerRow>> {
return request({ url: '/v1/calc/ledger', method: 'get', params })
}
export function getLedger(id: number): Promise<LedgerRow> {
return request({ url: `/v1/calc/ledger/${id}`, method: 'get' })
}
/** 核算回款:返回台账行 + repeated(业务级一次性计提标记) */
export function calcReceipt(receiptId: number): Promise<{ ledger: LedgerRow; repeated: boolean }> {
return request({ url: `/v1/calc/receipts/${receiptId}/calc`, method: 'post' })
}
export function recalcReceipt(receiptId: number, reason: string): Promise<LedgerRow> {
return request({
url: `/v1/calc/receipts/${receiptId}/recalc`,
method: 'post',
data: { reason },
})
}
export function batchCalc(receiptIds: number[]): Promise<
{ receipt_id: number; ok: boolean; repeated?: boolean; error?: string }[]
> {
return request({ url: '/v1/calc/batch', method: 'post', data: { receipt_ids: receiptIds } })
}

View File

@ -0,0 +1,105 @@
import request from './request'
import type { PageData } from './types'
/** 月结状态:1草稿 2待审批 3已审定(=已锁定)4已驳回 */
export const SETTLEMENT_STATUS = {
DRAFT: 1,
PENDING: 2,
APPROVED: 3,
REJECTED: 4,
} as const
export function settlementStatusLabel(status: number): string {
return (
{ 1: '草稿', 2: '待审批', 3: '已审定(锁定)', 4: '已驳回' }[status] ?? String(status)
)
}
export function settlementStatusTagType(
status: number,
): 'primary' | 'success' | 'danger' | 'warning' | 'info' {
return (
({ 1: 'info', 2: 'primary', 3: 'success', 4: 'danger', 5: 'info' } as const)[status] ?? 'info'
)
}
export interface SettlementItem {
id: number
personnel_id: number
personnel_name: string
role_code: string
calc_type: number
performance_sum: string
payable_sum: string
basic_amount: string | null
salary_mode: string | null
net_amount: string | null
is_probation: number
probation_note: string | null
}
export interface Settlement {
id: number
settlement_month: string
status: number
total_performance: string | null
total_payable: string | null
person_count: number
approval_order_no: string | null
submitter_name: string
locked_at: string | null
locked_by: string | null
create_time: string
}
export interface SettlementDetail extends Settlement {
items: SettlementItem[]
}
export interface SettlementQueryParams {
pageNum?: number
pageSize?: number
status?: number
}
export function listSettlements(params: SettlementQueryParams): Promise<PageData<Settlement>> {
return request({ url: '/v1/settlements', method: 'get', params })
}
export function getSettlement(id: number): Promise<SettlementDetail> {
return request({ url: `/v1/settlements/${id}`, method: 'get' })
}
export function generateSettlement(month: string): Promise<SettlementDetail> {
return request({ url: '/v1/settlements/generate', method: 'post', data: { month } })
}
export function submitSettlement(id: number): Promise<SettlementDetail> {
return request({ url: `/v1/settlements/${id}/submit`, method: 'post' })
}
/** 调整单(已锁定月份的补发/扣减) */
export interface AdjustmentRow {
id: number
order_no: string
settlement_month: string
personnel_name: string
direction: number
amount: string
reason: string
status: number
}
export function createAdjustment(data: {
settlement_month: string
personnel_id: number
direction: number
amount: string
reason: string
}): Promise<Record<string, unknown>> {
return request({ url: '/v1/settlements/adjustments', method: 'post', data })
}
export function listAdjustments(): Promise<AdjustmentRow[]> {
return request({ url: '/v1/settlements/adjustments', method: 'get' })
}

View File

@ -137,6 +137,20 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
},
{
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
path: '/calc/ledger',
name: 'calc-ledger',
component: () => import('@/views/calc/ledger/index.vue'),
meta: { title: '绩效台账', group: '绩效工资', order: 17 },
},
{
path: '/settlements',
name: 'settlements',
component: () => import('@/views/settlements/index.vue'),
meta: { title: '月度结算', group: '绩效工资', order: 18 },
},
],
},
// 未匹配路由回首页;若未登录会被守卫拦到登录页

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import * as calcApi from '@/api/calc'
import type { LedgerRow } from '@/api/calc'
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, loading, load, search } = usePagedList<
LedgerRow,
{ settlement_month?: string; is_active?: number }
>(calcApi.listLedger, () => ({
settlement_month: filters.settlement_month.trim() || undefined,
is_active: filters.is_active,
}))
function resetFilters(): void {
filters.settlement_month = ''
filters.is_active = 1
search()
}
const recalcVisible = ref(false)
const recalcTarget = ref<LedgerRow | null>(null)
const recalcReason = ref('')
function openRecalc(row: LedgerRow): void {
recalcTarget.value = row
recalcReason.value = ''
recalcVisible.value = true
}
async function submitRecalc(): Promise<void> {
if (!recalcTarget.value) return
if (!recalcReason.value.trim()) {
ElMessage.warning('重算必须填写原因')
return
}
try {
await calcApi.recalcReceipt(recalcTarget.value.id, recalcReason.value.trim())
ElMessage.success('重算完成(新版本已生效)')
recalcVisible.value = false
await load()
} catch (error) {
handledError(error)
}
}
</script>
<template>
<div class="page">
<el-form inline class="filters" @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="版本">
<el-select v-model="filters.is_active" style="width: 130px">
<el-option label="仅当前生效" :value="1" />
<el-option label="含历史版本" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
</template>
</el-table-column>
<el-table-column prop="settlement_month" label="归属月" width="90" />
<el-table-column prop="business_no" label="业务编号" min-width="150" />
<el-table-column prop="personnel_name" label="归属人员" width="100" />
<el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column prop="payment_days" label="天数" width="70" />
<el-table-column prop="rate_text" label="计提比例" min-width="90" />
<el-table-column prop="converted_profit" label="折算毛利" min-width="100" />
<el-table-column prop="performance_amount" label="绩效金额" min-width="100" />
<el-table-column prop="payable_amount" label="应付绩效" min-width="100" />
<el-table-column label="版本" width="80">
<template #default="{ row }">
v{{ row.calc_version }}
<el-tag v-if="row.is_active === 0" size="small" type="info" disable-transitions>
已替代
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.is_active === 1 && row.calc_type === 1"
link
type="primary"
@click="openRecalc(row)"
>
重算
</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
v-model:page-num="pageNum"
v-model:page-size="pageSize"
:total="total"
@change="load"
/>
<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">确认重算</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
.snapshot {
margin: 0;
padding: 10px;
font-size: 12px;
line-height: 1.6;
background: #f4f5f8;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-all;
}
.recalc-warn {
margin: 0 0 10px;
font-size: 12px;
color: var(--ys-ink-3);
}
</style>

View File

@ -0,0 +1,245 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
generateSettlement,
getSettlement,
listSettlements,
settlementStatusLabel,
settlementStatusTagType,
submitSettlement,
type Settlement,
type SettlementDetail,
} from '@/api/settlements'
import AppPagination from '@/components/table/AppPagination.vue'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
import { formatDateTime } from '@/utils/format'
/**
* 月度结算(M3 ④):按确认回款生成快照 → 提交审批 → 通过即锁定。
*
* 锁定后源数据改动被拦截(回款/发货写路径 400),调整走调整单。
* 详情抽屉展示按人明细(三口径净工资与试用期说明)。
*/
const filters = reactive({
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search } = usePagedList<
Settlement,
{ status?: number }
>(listSettlements, () => ({ status: filters.status }))
function resetFilters(): void {
filters.status = undefined
search()
}
const generateMonth = ref('')
const generating = ref(false)
async function handleGenerate(): Promise<void> {
if (!/^\d{4}-\d{2}$/.test(generateMonth.value)) {
ElMessage.warning('请输入结算月份(YYYY-MM)')
return
}
generating.value = true
try {
await generateSettlement(generateMonth.value)
ElMessage.success('月结已生成(草稿)')
await search()
} catch (error) {
handledError(error)
} finally {
generating.value = false
}
}
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<SettlementDetail | null>(null)
async function openDetail(row: Settlement): Promise<void> {
detailVisible.value = true
detailLoading.value = true
try {
detail.value = await getSettlement(row.id)
} catch (error) {
handledError(error)
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
const submitting = ref(false)
async function handleSubmit(row: Settlement): Promise<void> {
try {
const out = await submitSettlement(row.id)
ElMessage.success(
out.status === 3 ? '审批通过,月结已锁定' : '已提交审批(待财务复核)',
)
detail.value = await getSettlement(row.id)
await load()
} catch (error) {
handledError(error)
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="page">
<div class="toolbar">
<div class="gen-row">
<el-input
v-model="generateMonth"
placeholder="结算月份 YYYY-MM"
style="width: 160px"
maxlength="7"
/>
<el-button type="primary" :loading="generating" @click="handleGenerate">
生成月结快照
</el-button>
</div>
</div>
<el-form inline class="filters" @submit.prevent>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
<el-option label="草稿" :value="1" />
<el-option label="待审批" :value="2" />
<el-option label="已审定(锁定)" :value="3" />
<el-option label="已驳回" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="settlement_month" label="结算月份" width="100" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="settlementStatusTagType(row.status)" size="small" disable-transitions>
{{ settlementStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_performance" label="绩效合计" min-width="110" />
<el-table-column prop="total_payable" label="应付合计" min-width="110" />
<el-table-column prop="person_count" label="人数" width="70" />
<el-table-column prop="approval_order_no" label="审批单号" min-width="150" />
<el-table-column label="提交时间" min-width="150">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.status === 1 || row.status === 4"
link
type="success"
:loading="submitting"
@click="handleSubmit(row)"
>
提交审批
</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
v-model:page-num="pageNum"
v-model:page-size="pageSize"
:total="total"
@change="load"
/>
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-head">
<span class="detail-month">{{ detail.settlement_month }}</span>
<el-tag :type="settlementStatusTagType(detail.status)" size="small" disable-transitions>
{{ settlementStatusLabel(detail.status) }}
</el-tag>
</div>
<div class="detail-meta">
<span>审批单号:{{ detail.approval_order_no || '—' }}</span>
<span>提交人:{{ detail.submitter_name }}</span>
<span v-if="detail.locked_by">锁定人:{{ detail.locked_by }}</span>
</div>
<el-table :data="detail.items" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" min-width="90" />
<el-table-column label="类型" width="90">
<template #default="{ row }">
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
</template>
</el-table-column>
<el-table-column prop="performance_sum" label="绩效" min-width="90" />
<el-table-column prop="payable_sum" label="应付" min-width="90" />
<el-table-column prop="net_amount" label="净工资" min-width="90" />
<el-table-column label="试用期" width="70">
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
</el-table-column>
</el-table>
<p v-if="detail.status === 1 || detail.status === 4" class="hint">
草稿/已驳回状态可重新生成快照后再次提交。
</p>
<p v-else-if="detail.status === 3" class="hint">已锁定:源数据改动只产生提示与调整单。</p>
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.gen-row {
display: flex;
gap: 10px;
}
.filters {
margin-bottom: 4px;
}
.detail-head {
display: flex;
align-items: center;
gap: 10px;
}
.detail-month {
font-size: 15px;
font-weight: 600;
color: var(--ys-ink);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 0;
font-size: 12px;
color: var(--ys-ink-3);
}
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--ys-ink-3);
}
</style>