更新
This commit is contained in:
@@ -103,3 +103,60 @@ export function doctorDailyStatsOverview(params: {
|
||||
}) {
|
||||
return request.get({ url: '/stats.doctorDailyStats/overview', params })
|
||||
}
|
||||
|
||||
/** 提成结算接口计算较重,单独放宽超时(默认多为 60s) */
|
||||
const COMMISSION_SETTLEMENT_TIMEOUT_MS = 120000
|
||||
|
||||
/** 提成结算业绩(独立于业绩看板 yejiStats) */
|
||||
export function commissionSettlementOverview(params: {
|
||||
settlement_month: string
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get(
|
||||
{ url: '/stats.commissionSettlement/overview', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function commissionSettlementDeptOptions() {
|
||||
return request.get({ url: '/stats.commissionSettlement/deptOptions' })
|
||||
}
|
||||
|
||||
export function commissionSettlementChannelOptions() {
|
||||
return request.get({ url: '/stats.commissionSettlement/channelOptions' })
|
||||
}
|
||||
|
||||
/** 提成核对:订单明细分页 bucket: 空|current|deferred */
|
||||
export function commissionSettlementOrderLines(params: {
|
||||
settlement_month: string
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
bucket?: string
|
||||
}) {
|
||||
return request.get(
|
||||
{ url: '/stats.commissionSettlement/orderLines', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function commissionSettlementConfirmStatus(params: {
|
||||
settlement_month: string
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.commissionSettlement/confirmStatus', params })
|
||||
}
|
||||
|
||||
export function commissionSettlementSaveReconcile(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/saveReconcile', params })
|
||||
}
|
||||
|
||||
export function commissionSettlementConfirmFinalize(params: Record<string, any>) {
|
||||
return request.post(
|
||||
{ url: '/stats.commissionSettlement/confirmFinalize', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,988 @@
|
||||
<template>
|
||||
<div class="cs-page">
|
||||
<header class="cs-masthead">
|
||||
<div class="cs-masthead__inner">
|
||||
<div>
|
||||
<h1 class="cs-masthead__title">提成结算业绩</h1>
|
||||
<p class="cs-masthead__desc">
|
||||
系统代开处方对应的已完成业务订单;按结算月 7 日截止拆分本期提成与顺延下期;可按部门汇总或下发至业绩归属医助与处方开方医生
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-card class="cs-panel filter-card" shadow="never">
|
||||
<div class="filter-surface">
|
||||
<div class="filter-row filter-row--top">
|
||||
<div class="filter-field">
|
||||
<span class="lbl">结算月</span>
|
||||
<el-date-picker
|
||||
v-model="settlementMonth"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择结算月"
|
||||
class="filter-month"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<span class="lbl">渠道来源</span>
|
||||
<el-select
|
||||
v-model="selectedChannel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="全部"
|
||||
class="filter-select filter-select--channel"
|
||||
:loading="channelOptionsLoading"
|
||||
@change="loadOverview"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option-group
|
||||
v-for="g in channelGroups"
|
||||
:key="g.group_name"
|
||||
:label="g.group_name"
|
||||
>
|
||||
<el-option
|
||||
v-for="ch in g.channels"
|
||||
:key="ch.channel_code"
|
||||
:label="ch.channel_name"
|
||||
:value="ch.channel_code"
|
||||
/>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-field filter-field--wide">
|
||||
<span class="lbl">展示部门</span>
|
||||
<div
|
||||
class="filter-select filter-select--dept filter-dept-tree-wrap"
|
||||
v-loading="deptOptionsLoading"
|
||||
>
|
||||
<el-tree-select
|
||||
v-model="selectedDeptIds"
|
||||
:data="deptTreeOptions"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
filterable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
:default-expand-all="true"
|
||||
placeholder="默认:所有「中心」部门"
|
||||
class="filter-dept-tree-select"
|
||||
node-key="id"
|
||||
:props="deptTreeSelectProps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="filter-btn filter-btn--query"
|
||||
:loading="loading"
|
||||
@click="loadOverview"
|
||||
>
|
||||
<el-icon class="filter-btn__icon"><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button class="filter-btn filter-btn--reset" :disabled="loading" @click="handleReset">
|
||||
<el-icon class="filter-btn__icon"><RefreshRight /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="cs-alert">
|
||||
<template #title>
|
||||
<span class="cs-alert__title">统计口径</span>
|
||||
</template>
|
||||
<div class="cs-alert__body">
|
||||
<p v-if="meta.rule_note">{{ meta.rule_note }}</p>
|
||||
<p v-else>
|
||||
订单创建月为<strong>结算月的上一自然月</strong>;仅<strong>系统代开处方</strong>(完成挂号等自动生成)对应的<strong>履约已完成</strong>业务订单。
|
||||
<strong>签收</strong>时间取物流签收时间;<strong>尾款支付</strong>取关联支付单已支付时间(优先尾款/全部费用类型)。
|
||||
签收与尾款均在<strong>结算月 7 日 24:00 前</strong>完成的计入「本期提成」,其余计入「顺延下期」。
|
||||
</p>
|
||||
<p v-if="meta.cutoff_end" class="cs-alert__meta">
|
||||
当前结算月:<strong>{{ meta.settlement_month }}</strong>
|
||||
· 订单创建月:<strong>{{ meta.order_month }}</strong>
|
||||
· 截止时刻:<strong>{{ meta.cutoff_end }}</strong>
|
||||
<template v-if="meta.channel_name">
|
||||
· 渠道:<strong>{{ meta.channel_name }}</strong>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</el-alert>
|
||||
|
||||
<el-card class="cs-panel cs-reconcile-card" shadow="never">
|
||||
<div class="cs-reconcile">
|
||||
<div class="cs-reconcile__head">
|
||||
<div class="cs-reconcile__tags">
|
||||
<el-tag v-if="confirmInfo?.status === 1" type="success" effect="dark">已确定业绩</el-tag>
|
||||
<el-tag v-else type="warning" effect="plain">待确定业绩</el-tag>
|
||||
<span
|
||||
v-if="confirmInfo?.status === 1 && confirmInfo.confirmed_at_text"
|
||||
class="cs-reconcile__meta"
|
||||
>
|
||||
{{ confirmInfo.confirmed_admin_name || '—' }} · {{ confirmInfo.confirmed_at_text }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cs-reconcile__toolbar">
|
||||
<el-button type="primary" plain @click="openReconcileDialog">业绩明细核对</el-button>
|
||||
<el-button :disabled="confirmInfo?.status === 1 || reconcileSaving" @click="handleSaveReconcile">
|
||||
保存核对备注
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
:disabled="confirmInfo?.status === 1 || confirmSubmitting"
|
||||
@click="handleConfirmFinalize"
|
||||
>
|
||||
确定本期业绩
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="reconcileNote"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="核对备注(可与财务线下对齐后填写;确定业绩前可反复保存)"
|
||||
:disabled="confirmInfo?.status === 1"
|
||||
class="cs-reconcile__note"
|
||||
/>
|
||||
<p v-if="Number(totalAgg.carry_in_order_count) > 0" class="cs-reconcile__carry">
|
||||
上期顺延汇入本期:<strong>{{ totalAgg.carry_in_order_count }}</strong> 单(已并入当前汇总)
|
||||
</p>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="cs-panel cs-summary-card" shadow="never">
|
||||
<el-tabs v-model="summaryTab" class="cs-summary-tabs">
|
||||
<el-tab-pane label="部门汇总" name="dept">
|
||||
<div class="cs-table-head">
|
||||
<span class="cs-table-head__title">部门汇总</span>
|
||||
<span class="cs-table-head__hint">
|
||||
金额单位:元;与业绩看板相同的部门摊列规则(业绩归属医助 → 展示「中心」)
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
class="cs-table"
|
||||
show-summary
|
||||
:summary-method="getSummaries"
|
||||
>
|
||||
<el-table-column prop="dept_name" label="部门" min-width="120" fixed />
|
||||
<el-table-column prop="completed_order_count" label="已完成单数" min-width="104" align="right" />
|
||||
<el-table-column prop="completed_order_amount" label="已完成金额" min-width="112" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_period_count" label="本期提成单数" min-width="118" align="right" />
|
||||
<el-table-column prop="current_period_amount" label="本期提成金额" min-width="118" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.current_period_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deferred_period_count" label="顺延下期单数" min-width="118" align="right" />
|
||||
<el-table-column prop="deferred_period_amount" label="顺延下期金额" min-width="118" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.deferred_period_amount) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="按医助下发" name="assistant">
|
||||
<div class="cs-table-head">
|
||||
<span class="cs-table-head__title">业绩归属医助</span>
|
||||
<span class="cs-table-head__hint">
|
||||
按诊单业绩归属规则聚合到具体医助;「顺延汇入」为上期结转进入本期订单池的单数
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="assistantRows"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
class="cs-table"
|
||||
show-summary
|
||||
:summary-method="getPersonSummaries"
|
||||
>
|
||||
<el-table-column prop="assistant_name" label="医助" min-width="120" fixed show-overflow-tooltip />
|
||||
<el-table-column prop="primary_dept_name" label="归属中心" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="carry_in_order_count" label="顺延汇入单数" min-width="112" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="已完成单数" min-width="104" align="right" />
|
||||
<el-table-column prop="completed_order_amount" label="已完成金额" min-width="112" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_period_count" label="本期提成单数" min-width="118" align="right" />
|
||||
<el-table-column prop="current_period_amount" label="本期提成金额" min-width="118" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.current_period_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deferred_period_count" label="顺延下期单数" min-width="118" align="right" />
|
||||
<el-table-column prop="deferred_period_amount" label="顺延下期金额" min-width="118" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.deferred_period_amount) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="按医生下发" name="doctor">
|
||||
<div class="cs-table-head">
|
||||
<span class="cs-table-head__title">处方开方医生</span>
|
||||
<span class="cs-table-head__hint">
|
||||
按处方创建账号(开方人)聚合;可与医助维度交叉核对
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="doctorRows"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
class="cs-table"
|
||||
show-summary
|
||||
:summary-method="getPersonSummaries"
|
||||
>
|
||||
<el-table-column prop="doctor_name" label="医生" min-width="120" fixed show-overflow-tooltip />
|
||||
<el-table-column prop="primary_dept_name" label="归属中心" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="carry_in_order_count" label="顺延汇入单数" min-width="112" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="已完成单数" min-width="104" align="right" />
|
||||
<el-table-column prop="completed_order_amount" label="已完成金额" min-width="112" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_period_count" label="本期提成单数" min-width="118" align="right" />
|
||||
<el-table-column prop="current_period_amount" label="本期提成金额" min-width="118" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.current_period_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deferred_period_count" label="顺延下期单数" min-width="118" align="right" />
|
||||
<el-table-column prop="deferred_period_amount" label="顺延下期金额" min-width="118" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.deferred_period_amount) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="reconcileDialogVisible"
|
||||
title="业绩明细核对"
|
||||
width="1040px"
|
||||
destroy-on-close
|
||||
class="cs-dialog"
|
||||
@open="onReconcileDialogOpen"
|
||||
>
|
||||
<div class="cs-dialog__bar">
|
||||
<el-radio-group v-model="linesBucket" size="small" @change="loadOrderLines(1)">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="current">本期提成</el-radio-button>
|
||||
<el-radio-button label="deferred">顺延下期</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-table v-loading="linesLoading" :data="linesRows" border stripe size="small" max-height="440">
|
||||
<el-table-column prop="order_no" label="业务订单号" min-width="116" show-overflow-tooltip />
|
||||
<el-table-column prop="assistant_name" label="业绩医助" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="doctor_name" label="开方医生" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="primary_dept_name" label="归属部门" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="amount" label="金额" width="108" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分段" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.bucket === 'current'" type="success" size="small">本期</el-tag>
|
||||
<el-tag v-else type="warning" size="small">顺延</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结转" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_carry_in">是</span>
|
||||
<span v-else class="cs-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sign_time_text" label="签收时间" min-width="152" show-overflow-tooltip />
|
||||
<el-table-column prop="pay_time_text" label="尾款支付" min-width="152" show-overflow-tooltip />
|
||||
<el-table-column prop="create_time_text" label="订单创建" width="152" />
|
||||
</el-table>
|
||||
<div class="cs-dialog__pager">
|
||||
<el-pagination
|
||||
layout="total, prev, pager, next"
|
||||
:total="linesTotal"
|
||||
:page-size="linesPageSize"
|
||||
:current-page="linesPage"
|
||||
@current-change="loadOrderLines"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Search, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
commissionSettlementChannelOptions,
|
||||
commissionSettlementConfirmFinalize,
|
||||
commissionSettlementDeptOptions,
|
||||
commissionSettlementOrderLines,
|
||||
commissionSettlementOverview,
|
||||
commissionSettlementSaveReconcile,
|
||||
} from '@/api/stats'
|
||||
|
||||
interface DeptOption {
|
||||
id: number
|
||||
name: string
|
||||
pid: number
|
||||
path: string
|
||||
}
|
||||
|
||||
interface DeptTreeNode extends DeptOption {
|
||||
children?: DeptTreeNode[]
|
||||
}
|
||||
|
||||
const deptTreeSelectProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children',
|
||||
}
|
||||
|
||||
function buildDeptTreeFromFlat(flat: DeptOption[]): DeptTreeNode[] {
|
||||
if (!flat.length) return []
|
||||
const map = new Map<number, DeptTreeNode>()
|
||||
for (const d of flat) {
|
||||
map.set(d.id, { ...d, children: [] })
|
||||
}
|
||||
const roots: DeptTreeNode[] = []
|
||||
for (const d of flat) {
|
||||
const node = map.get(d.id)!
|
||||
const pid = Number(d.pid)
|
||||
if (pid > 0 && map.has(pid)) {
|
||||
map.get(pid)!.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const stripEmptyChildren = (n: DeptTreeNode) => {
|
||||
if (n.children?.length) {
|
||||
n.children.forEach(stripEmptyChildren)
|
||||
} else {
|
||||
delete n.children
|
||||
}
|
||||
}
|
||||
roots.forEach(stripEmptyChildren)
|
||||
const sortLevel = (nodes: DeptTreeNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
const ac = a.name.includes('中心') ? -1 : 1
|
||||
const bc = b.name.includes('中心') ? -1 : 1
|
||||
if (ac !== bc) return ac - bc
|
||||
return a.id - b.id
|
||||
})
|
||||
for (const n of nodes) {
|
||||
if (n.children?.length) sortLevel(n.children)
|
||||
}
|
||||
}
|
||||
sortLevel(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
interface ChannelOption {
|
||||
channel_code: string
|
||||
channel_name: string
|
||||
source_tag_id: string
|
||||
source_group_name: string
|
||||
customer_count: number
|
||||
}
|
||||
|
||||
interface ChannelGroup {
|
||||
group_name: string
|
||||
channels: ChannelOption[]
|
||||
}
|
||||
|
||||
interface CsRow {
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
completed_order_count: number
|
||||
completed_order_amount: number
|
||||
current_period_count: number
|
||||
current_period_amount: number
|
||||
deferred_period_count: number
|
||||
deferred_period_amount: number
|
||||
}
|
||||
|
||||
interface CsAssistRow {
|
||||
assistant_id: number
|
||||
assistant_name: string
|
||||
primary_dept_id: number
|
||||
primary_dept_name: string
|
||||
carry_in_order_count: number
|
||||
completed_order_count: number
|
||||
completed_order_amount: number
|
||||
current_period_count: number
|
||||
current_period_amount: number
|
||||
deferred_period_count: number
|
||||
deferred_period_amount: number
|
||||
}
|
||||
|
||||
interface CsDoctorRow {
|
||||
doctor_id: number
|
||||
doctor_name: string
|
||||
primary_dept_id: number
|
||||
primary_dept_name: string
|
||||
carry_in_order_count: number
|
||||
completed_order_count: number
|
||||
completed_order_amount: number
|
||||
current_period_count: number
|
||||
current_period_amount: number
|
||||
deferred_period_count: number
|
||||
deferred_period_amount: number
|
||||
}
|
||||
|
||||
type CsPersonMetricRow = CsAssistRow | CsDoctorRow
|
||||
|
||||
function defaultSettlementMonth(): string {
|
||||
const d = new Date()
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
const settlementMonth = ref<string>(defaultSettlementMonth())
|
||||
const loading = ref(false)
|
||||
const deptOptions = ref<DeptOption[]>([])
|
||||
const deptOptionsLoading = ref(false)
|
||||
const deptTreeOptions = computed(() => buildDeptTreeFromFlat(deptOptions.value))
|
||||
const selectedDeptIds = ref<number[]>([])
|
||||
const channelGroups = ref<ChannelGroup[]>([])
|
||||
const channelOptionsLoading = ref(false)
|
||||
const selectedChannel = ref<string>('')
|
||||
|
||||
const rows = ref<CsRow[]>([])
|
||||
const assistantRows = ref<CsAssistRow[]>([])
|
||||
const doctorRows = ref<CsDoctorRow[]>([])
|
||||
const summaryTab = ref<'dept' | 'assistant' | 'doctor'>('dept')
|
||||
const totalAgg = ref<{
|
||||
completed_order_count?: number
|
||||
completed_order_amount?: number
|
||||
current_period_count?: number
|
||||
current_period_amount?: number
|
||||
deferred_period_count?: number
|
||||
deferred_period_amount?: number
|
||||
carry_in_order_count?: number
|
||||
}>({})
|
||||
const meta = reactive({
|
||||
settlement_month: '',
|
||||
order_month: '',
|
||||
cutoff_end: '',
|
||||
rule_note: '',
|
||||
channel_name: '',
|
||||
})
|
||||
|
||||
const confirmInfo = ref<{
|
||||
status?: number
|
||||
reconcile_note?: string
|
||||
confirmed_admin_name?: string
|
||||
confirmed_at_text?: string
|
||||
} | null>(null)
|
||||
const reconcileNote = ref('')
|
||||
const reconcileSaving = ref(false)
|
||||
const confirmSubmitting = ref(false)
|
||||
|
||||
const reconcileDialogVisible = ref(false)
|
||||
const linesLoading = ref(false)
|
||||
const linesBucket = ref<string>('all')
|
||||
const linesRows = ref<Record<string, any>[]>([])
|
||||
const linesTotal = ref(0)
|
||||
const linesPage = ref(1)
|
||||
const linesPageSize = ref(20)
|
||||
|
||||
function formatMoney(v: number | null | undefined) {
|
||||
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
|
||||
return `¥ ${Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
async function loadDeptOptions() {
|
||||
deptOptionsLoading.value = true
|
||||
try {
|
||||
const res: DeptOption[] = await commissionSettlementDeptOptions()
|
||||
deptOptions.value = (res || []).sort((a, b) => {
|
||||
const aIsCenter = a.name.includes('中心') ? -1 : 1
|
||||
const bIsCenter = b.name.includes('中心') ? -1 : 1
|
||||
if (aIsCenter !== bIsCenter) return aIsCenter - bIsCenter
|
||||
return a.id - b.id
|
||||
})
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
} finally {
|
||||
deptOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChannelOptions() {
|
||||
channelOptionsLoading.value = true
|
||||
try {
|
||||
const res: any = await commissionSettlementChannelOptions()
|
||||
channelGroups.value = res?.groups || []
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
} finally {
|
||||
channelOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const sm = settlementMonth.value
|
||||
if (!sm) {
|
||||
ElMessage.warning('请选择结算月')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await commissionSettlementOverview(buildQueryParams() as any)
|
||||
rows.value = res?.rows || []
|
||||
assistantRows.value = res?.assistant_rows || []
|
||||
doctorRows.value = res?.doctor_rows || []
|
||||
totalAgg.value = res?.total || {}
|
||||
meta.settlement_month = res?.settlement_month || sm
|
||||
meta.order_month = res?.order_month || ''
|
||||
meta.cutoff_end = res?.cutoff_end || ''
|
||||
meta.rule_note = res?.rule_note || ''
|
||||
meta.channel_name = res?.channel_name || ''
|
||||
confirmInfo.value = res?.confirm ?? null
|
||||
reconcileNote.value =
|
||||
res?.confirm?.reconcile_note != null ? String(res.confirm.reconcile_note) : ''
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '加载失败')
|
||||
rows.value = []
|
||||
assistantRows.value = []
|
||||
doctorRows.value = []
|
||||
totalAgg.value = {}
|
||||
confirmInfo.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryParams(): Record<string, any> {
|
||||
const sm = settlementMonth.value
|
||||
const p: Record<string, any> = { settlement_month: sm }
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
function openReconcileDialog() {
|
||||
if (!settlementMonth.value) {
|
||||
ElMessage.warning('请选择结算月')
|
||||
return
|
||||
}
|
||||
reconcileDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onReconcileDialogOpen() {
|
||||
linesBucket.value = 'all'
|
||||
loadOrderLines(1)
|
||||
}
|
||||
|
||||
async function loadOrderLines(page: number) {
|
||||
if (!settlementMonth.value) return
|
||||
linesLoading.value = true
|
||||
linesPage.value = page
|
||||
try {
|
||||
const p: Record<string, any> = {
|
||||
...buildQueryParams(),
|
||||
page,
|
||||
page_size: linesPageSize.value,
|
||||
}
|
||||
if (linesBucket.value && linesBucket.value !== 'all') {
|
||||
p.bucket = linesBucket.value
|
||||
}
|
||||
const res: any = await commissionSettlementOrderLines(p as any)
|
||||
linesRows.value = res?.list || []
|
||||
linesTotal.value = Number(res?.count ?? 0)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '加载明细失败')
|
||||
linesRows.value = []
|
||||
linesTotal.value = 0
|
||||
} finally {
|
||||
linesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveReconcile() {
|
||||
if (!settlementMonth.value) {
|
||||
ElMessage.warning('请选择结算月')
|
||||
return
|
||||
}
|
||||
if (confirmInfo.value?.status === 1) {
|
||||
ElMessage.warning('已确定业绩不可修改备注')
|
||||
return
|
||||
}
|
||||
reconcileSaving.value = true
|
||||
try {
|
||||
await commissionSettlementSaveReconcile({
|
||||
...buildQueryParams(),
|
||||
reconcile_note: reconcileNote.value,
|
||||
})
|
||||
ElMessage.success('已保存核对备注')
|
||||
await loadOverview()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '保存失败')
|
||||
} finally {
|
||||
reconcileSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmFinalize() {
|
||||
if (!settlementMonth.value) {
|
||||
ElMessage.warning('请选择结算月')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定后将为「顺延下期」订单写入结转记录;下一结算月在相同渠道与部门筛选下自动并入统计。已确定后不可在线撤销,请与财务核对后再操作。',
|
||||
'确定本期业绩',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
confirmSubmitting.value = true
|
||||
try {
|
||||
const out: any = await commissionSettlementConfirmFinalize({
|
||||
...buildQueryParams(),
|
||||
reconcile_note: reconcileNote.value,
|
||||
})
|
||||
ElMessage.success(
|
||||
`已确定本期业绩;结转 ${Number(out?.deferred_carry_count ?? 0)} 单至 ${String(out?.next_settlement_month ?? '下期')}`
|
||||
)
|
||||
await loadOverview()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '提交失败')
|
||||
} finally {
|
||||
confirmSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
settlementMonth.value = defaultSettlementMonth()
|
||||
selectedDeptIds.value = []
|
||||
selectedChannel.value = ''
|
||||
loadOverview()
|
||||
}
|
||||
|
||||
function getPersonSummaries(param: { columns: Array<{ property?: string }>; data: CsPersonMetricRow[] }) {
|
||||
const { columns } = param
|
||||
const t = totalAgg.value
|
||||
const sums: string[] = []
|
||||
columns.forEach((column, index: number) => {
|
||||
if (index === 0) {
|
||||
sums[index] = '合计'
|
||||
return
|
||||
}
|
||||
const prop = column.property as string | undefined
|
||||
if (!t || !prop) {
|
||||
sums[index] = ''
|
||||
return
|
||||
}
|
||||
if (prop === 'primary_dept_name') {
|
||||
sums[index] = ''
|
||||
return
|
||||
}
|
||||
if (prop === 'carry_in_order_count') {
|
||||
sums[index] = String(t.carry_in_order_count ?? 0)
|
||||
return
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
completed_order_count: String(t.completed_order_count ?? 0),
|
||||
completed_order_amount: formatMoney(t.completed_order_amount),
|
||||
current_period_count: String(t.current_period_count ?? 0),
|
||||
current_period_amount: formatMoney(t.current_period_amount),
|
||||
deferred_period_count: String(t.deferred_period_count ?? 0),
|
||||
deferred_period_amount: formatMoney(t.deferred_period_amount),
|
||||
}
|
||||
sums[index] = map[String(prop)] ?? ''
|
||||
})
|
||||
return sums
|
||||
}
|
||||
|
||||
function getSummaries(param: { columns: Array<{ property?: string }>; data: CsRow[] }) {
|
||||
const { columns } = param
|
||||
const t = totalAgg.value
|
||||
const sums: string[] = []
|
||||
columns.forEach((column, index: number) => {
|
||||
if (index === 0) {
|
||||
sums[index] = '合计'
|
||||
return
|
||||
}
|
||||
const prop = column.property as keyof CsRow | undefined
|
||||
if (!t || !prop) {
|
||||
sums[index] = ''
|
||||
return
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
completed_order_count: String(t.completed_order_count ?? 0),
|
||||
completed_order_amount: formatMoney(t.completed_order_amount),
|
||||
current_period_count: String(t.current_period_count ?? 0),
|
||||
current_period_amount: formatMoney(t.current_period_amount),
|
||||
deferred_period_count: String(t.deferred_period_count ?? 0),
|
||||
deferred_period_amount: formatMoney(t.deferred_period_amount),
|
||||
}
|
||||
sums[index] = map[String(prop)] ?? ''
|
||||
})
|
||||
return sums
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||||
await loadOverview()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cs-page {
|
||||
min-width: 0;
|
||||
--cs-brand: #6366f1;
|
||||
--cs-brand-soft: #eef2ff;
|
||||
--cs-ink: #1e293b;
|
||||
--cs-muted: #64748b;
|
||||
--cs-line: #e2e8f0;
|
||||
--cs-surface: #ffffff;
|
||||
--cs-page: #f1f5f9;
|
||||
--cs-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--cs-radius: 16px;
|
||||
--cs-radius-sm: 10px;
|
||||
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
color: var(--cs-ink);
|
||||
background: var(--cs-page);
|
||||
}
|
||||
|
||||
.cs-masthead {
|
||||
margin-bottom: 24px;
|
||||
&__inner {
|
||||
padding: 28px 32px;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
border-radius: var(--cs-radius);
|
||||
box-shadow: var(--cs-shadow-sm);
|
||||
}
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
&__desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-panel {
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--cs-radius);
|
||||
border: 1px solid var(--cs-line);
|
||||
background: var(--cs-surface);
|
||||
box-shadow: var(--cs-shadow-sm);
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
.filter-surface {
|
||||
padding: 20px;
|
||||
background: var(--cs-surface);
|
||||
}
|
||||
.filter-row--top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 16px 20px;
|
||||
}
|
||||
.filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--cs-muted);
|
||||
}
|
||||
}
|
||||
.filter-field--wide {
|
||||
flex: 1 1 260px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.filter-month {
|
||||
width: 168px;
|
||||
}
|
||||
.filter-select--channel {
|
||||
width: 220px;
|
||||
}
|
||||
.filter-dept-tree-select {
|
||||
width: 100%;
|
||||
min-width: 260px;
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 8px 20px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--cs-radius-sm);
|
||||
&__icon {
|
||||
margin-right: 4px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
&--query {
|
||||
background: var(--cs-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
&--reset {
|
||||
border: 1px solid var(--cs-line);
|
||||
background: var(--cs-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.cs-alert {
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--cs-radius-sm);
|
||||
&__title {
|
||||
font-weight: 700;
|
||||
}
|
||||
&__body {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--cs-muted);
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
&__meta {
|
||||
margin-top: 10px !important;
|
||||
font-size: 12px;
|
||||
color: var(--cs-ink);
|
||||
}
|
||||
}
|
||||
|
||||
.cs-summary-card {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
padding: 12px 20px 0;
|
||||
border-bottom: 1px solid var(--cs-line);
|
||||
}
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
height: 0;
|
||||
}
|
||||
:deep(.el-tabs__content) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-table-head {
|
||||
padding: 14px 20px 10px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--cs-line);
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
&__hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--cs-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.cs-table {
|
||||
width: 100%;
|
||||
:deep(.el-table__header th) {
|
||||
background: #f8fafc !important;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
:deep(.el-table__footer-wrapper td) {
|
||||
font-weight: 700;
|
||||
background: #fff7ed !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-reconcile-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-reconcile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
&__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: var(--cs-muted);
|
||||
}
|
||||
&__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
&__note {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
&__carry {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--cs-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-dialog__bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cs-dialog__pager {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cs-muted {
|
||||
color: var(--cs-muted);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user