This commit is contained in:
Your Name
2026-05-11 16:10:06 +08:00
parent 62d15efbaf
commit 15cf6324f6
8 changed files with 2349 additions and 19 deletions
+57
View File
@@ -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
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\YejiStatsLogic;
/**
* 提成结算业绩(独立于业绩看板 yejiStats 接口)
*
* - GET stats.commissionSettlement/overview
* - GET stats.commissionSettlement/orderLines
* - GET stats.commissionSettlement/confirmStatus
* - POST stats.commissionSettlement/saveReconcile
* - POST stats.commissionSettlement/confirmFinalize
* - GET stats.commissionSettlement/deptOptions
* - GET stats.commissionSettlement/channelOptions
*/
class CommissionSettlementController extends BaseAdminController
{
public function overview()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::commissionSettlementOverview($params, $this->adminId, $this->adminInfo));
}
public function orderLines()
{
@set_time_limit(120);
return $this->data(YejiStatsLogic::commissionSettlementOrderLines($this->request->get(), $this->adminId, $this->adminInfo));
}
public function confirmStatus()
{
return $this->data(YejiStatsLogic::commissionSettlementConfirmStatus($this->request->get(), $this->adminId, $this->adminInfo));
}
public function saveReconcile()
{
return $this->data(YejiStatsLogic::commissionSettlementSaveReconcile($this->request->post(), $this->adminId, $this->adminInfo));
}
public function confirmFinalize()
{
@set_time_limit(120);
return $this->data(YejiStatsLogic::commissionSettlementConfirmFinalize($this->request->post(), $this->adminId, $this->adminInfo));
}
public function deptOptions()
{
return $this->data(YejiStatsLogic::deptOptions($this->adminId, $this->adminInfo));
}
public function channelOptions()
{
return $this->data(YejiStatsLogic::channelOptions(false));
}
}
@@ -916,6 +916,8 @@ class YejiStatsLogic
/**
* 渠道下拉(按 source_group_name 分组),同时附带客户/成本统计便于排序展示
*
* @param bool $includeTagCustomerCounts 为 false 时跳过标签客户数统计(qywx_external_contact_tag 大表聚合较慢);提成结算等仅需筛选项时可关闭以提速。
*
* @return array{
* groups: array<int, array{
* group_name: string,
@@ -928,7 +930,7 @@ class YejiStatsLogic
* total: int
* }
*/
public static function channelOptions(): array
public static function channelOptions(bool $includeTagCustomerCounts = true): array
{
$rows = Db::name('qywx_media_channel')
->where('status', 1)
@@ -939,24 +941,26 @@ class YejiStatsLogic
return ['groups' => [], 'total' => 0];
}
// 顺路给 tag_* 渠道带上「打了该标签的客户数」(直接用 contact_tag 表
$tagIds = [];
foreach ($rows as $r) {
$code = (string) $r['channel_code'];
if (str_starts_with($code, 'tag_')) {
$tagIds[] = substr($code, 4);
}
}
// 顺路给 tag_* 渠道带上「打了该标签的客户数」(qywx_external_contact_tag 数据量大时聚合较慢,可按需关闭
$tagToCount = [];
if ($tagIds !== []) {
$cntRows = Db::name('qywx_external_contact_tag')
->whereIn('tag_id', $tagIds)
->fieldRaw('tag_id, COUNT(DISTINCT external_userid) AS c')
->group('tag_id')
->select()
->toArray();
foreach ($cntRows as $cr) {
$tagToCount[(string) $cr['tag_id']] = (int) $cr['c'];
if ($includeTagCustomerCounts) {
$tagIds = [];
foreach ($rows as $r) {
$code = (string) $r['channel_code'];
if (str_starts_with($code, 'tag_')) {
$tagIds[] = substr($code, 4);
}
}
if ($tagIds !== []) {
$cntRows = Db::name('qywx_external_contact_tag')
->whereIn('tag_id', $tagIds)
->fieldRaw('tag_id, COUNT(DISTINCT external_userid) AS c')
->group('tag_id')
->select()
->toArray();
foreach ($cntRows as $cr) {
$tagToCount[(string) $cr['tag_id']] = (int) $cr['c'];
}
}
}
@@ -4920,4 +4924,923 @@ class YejiStatsLogic
return $out;
}
/**
* 提成结算统计:渠道收窄条件应用到业务订单查询(与 sumPerformance「scoped」同源)。
*/
private static function applyCommissionSettlementChannelFilter(Query $query, array $c): void
{
if (empty($c['channelFilterActive'])) {
return;
}
/** @var array<int, mixed> $appointmentChannelValues */
$appointmentChannelValues = $c['appointmentChannelValues'] ?? [];
$tagDiagIds = $c['tagDiagIds'] ?? null;
$tagAssistantIds = $c['tagAssistantIds'] ?? null;
$tagFallback = !empty($c['tagFallback']);
$channelInfo = $c['channelInfo'] ?? null;
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
$query->whereRaw('0 = 1');
return;
}
$query->whereRaw($pack[0], $pack[1]);
return;
}
if ($tagDiagIds !== null && $tagDiagIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagAssistantIds !== null && $tagAssistantIds === [] && $tagFallback) {
$query->whereRaw('0 = 1');
return;
}
$att = self::sqlPerformanceAttributionAdminExpr();
$narrowed = false;
if ($tagDiagIds !== null) {
$idsSan = array_values(array_unique(array_filter(array_map('intval', $tagDiagIds), static fn (int $x): bool => $x > 0)));
if ($idsSan === []) {
$query->whereRaw('0 = 1');
return;
}
$narrowed = true;
$chunks = array_chunk($idsSan, 800);
if (\count($chunks) === 1) {
$query->whereIn('o.diagnosis_id', $chunks[0]);
} else {
$query->where(function ($qq) use ($chunks): void {
foreach ($chunks as $i => $chunk) {
if ($i === 0) {
$qq->whereIn('o.diagnosis_id', $chunk);
} else {
$qq->whereOr(function ($q2) use ($chunk): void {
$q2->whereIn('o.diagnosis_id', $chunk);
});
}
}
});
}
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
if ($ids === []) {
$query->whereRaw('0 = 1');
return;
}
$in = implode(',', $ids);
$query->whereRaw("({$att}) IN ({$in})");
$narrowed = true;
}
if (!$narrowed) {
$query->whereRaw('0 = 1');
}
}
/**
* 提成结算:部门筛选规范化键(与确认/结转 scope 一致)。
*
* @param int[]|string|null $deptIds
*/
private static function commissionSettlementDeptIdsKey($deptIds): string
{
if ($deptIds === null || $deptIds === '' || $deptIds === []) {
return '*';
}
if (is_array($deptIds)) {
$ids = array_map('intval', $deptIds);
} else {
$ids = array_map('intval', explode(',', (string) $deptIds));
}
$ids = array_values(array_unique(array_filter($ids, static function (int $x): bool {
return $x > 0;
})));
sort($ids);
return $ids === [] ? '*' : implode(',', $ids);
}
/**
* @return int[]
*/
private static function commissionSettlementCarryOrderIdsForTarget(string $targetSettlementMonth, string $scopeChannelCode, string $scopeDeptKey): array
{
$ids = Db::name('commission_settlement_carry')
->where('target_settlement_month', $targetSettlementMonth)
->where('scope_channel_code', $scopeChannelCode)
->where('scope_dept_ids_key', $scopeDeptKey)
->column('prescription_order_id');
return array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $x): bool {
return $x > 0;
})));
}
private static function commissionSettlementNextMonth(string $monthRaw): string
{
$t = strtotime($monthRaw . '-01 12:00:00');
return date('Y-m', strtotime('+1 month', $t !== false ? $t : time()));
}
/**
* @return array<string, mixed>|null
*/
private static function commissionSettlementGetConfirmRow(string $settlementMonth, string $scopeChannelCode, string $scopeDeptKey): ?array
{
$row = Db::name('commission_settlement_confirm')
->where('settlement_month', $settlementMonth)
->where('scope_channel_code', $scopeChannelCode)
->where('scope_dept_ids_key', $scopeDeptKey)
->find();
if (!$row) {
return null;
}
$r = is_array($row) ? $row : $row->toArray();
return [
'status' => (int) ($r['status'] ?? 0),
'reconcile_note' => (string) ($r['reconcile_note'] ?? ''),
'confirmed_admin_id' => (int) ($r['confirmed_admin_id'] ?? 0),
'confirmed_admin_name' => (string) ($r['confirmed_admin_name'] ?? ''),
'confirmed_at' => (int) ($r['confirmed_at'] ?? 0),
'confirmed_at_text' => !empty($r['confirmed_at']) ? date('Y-m-d H:i:s', (int) $r['confirmed_at']) : '',
'totals_json' => (string) ($r['totals_json'] ?? ''),
];
}
/**
* @return array<string, mixed>
*/
private static function commissionSettlementResolveBasePack(array $params, int $viewerAdminId, array $viewerAdminInfo): array
{
$monthRaw = trim((string) ($params['settlement_month'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}$/', $monthRaw)) {
throw new \think\Exception('请传入有效的结算月 settlement_month(格式 YYYY-MM');
}
$anchor = strtotime($monthRaw . '-01 12:00:00');
if ($anchor === false) {
throw new \think\Exception('结算月日期无效');
}
$prevMonthAnchor = strtotime('-1 month', $anchor);
$orderCreateStartTs = (int) strtotime(date('Y-m-01 00:00:00', $prevMonthAnchor));
$orderCreateEndTs = (int) strtotime(date('Y-m-t 23:59:59', $prevMonthAnchor));
$cutoffTs = (int) strtotime(date('Y-m-07 23:59:59', $anchor));
$orderMonthLabel = date('Y-m', $prevMonthAnchor);
$ctxParams = array_merge($params, [
'start_date' => date('Y-m-d', $orderCreateStartTs),
'end_date' => date('Y-m-d', $orderCreateEndTs),
]);
$c = self::resolveYejiContext($ctxParams);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptKey = self::commissionSettlementDeptIdsKey($params['dept_ids'] ?? null);
$scopeChannelCode = (string) ($c['channelCode'] ?? '');
$carryOrderIds = self::commissionSettlementCarryOrderIdsForTarget($monthRaw, $scopeChannelCode, $deptKey);
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$rxTable = self::tableWithPrefix('tcm_prescription');
$att = self::sqlPerformanceAttributionAdminExpr();
return [
'monthRaw' => $monthRaw,
'cutoffTs' => $cutoffTs,
'orderCreateStartTs' => $orderCreateStartTs,
'orderCreateEndTs' => $orderCreateEndTs,
'orderMonthLabel' => $orderMonthLabel,
'nextSettlementMonth' => self::commissionSettlementNextMonth($monthRaw),
'deptKey' => $deptKey,
'scopeChannelCode' => $scopeChannelCode,
'carryOrderIds' => $carryOrderIds,
'c' => $c,
'tableRowDeptIds' => $c['tableRowDeptIds'],
'deptById' => $c['deptById'],
'adminToPrimary' => $c['adminToPrimary'],
'diagTable' => $diagTable,
'rxTable' => $rxTable,
'att' => $att,
];
}
/**
* @param int[] $ids
*
* @return array<int, string>
*/
private static function commissionSettlementBatchAdminNames(array $ids): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
if ($ids === []) {
return [];
}
$rows = Db::name('admin')
->whereIn('id', $ids)
->whereNull('delete_time')
->column('name', 'id');
$out = [];
foreach ($rows as $id => $name) {
$out[(int) $id] = (string) $name;
}
return $out;
}
/**
* 签收 / 尾款时间:对物流与支付关联做 GROUP BY 后 LEFT JOIN,避免 SELECT 中逐行相关子查询导致 overview 超时。
*/
private static function commissionSettlementAttachExpressPayAggregates(Query $query): void
{
$etTable = self::tableWithPrefix('express_tracking');
$linkTbl = self::tableWithPrefix('tcm_prescription_order_pay_order');
$payTbl = self::tableWithPrefix('order');
$etAggSql = "(SELECT et.order_id, MAX(et.sign_time) AS cs_sign_ts FROM {$etTable} et "
. "WHERE et.order_type = 'prescription' AND et.delete_time IS NULL GROUP BY et.order_id) cs_et_sig";
$payAggSql = "(SELECT l.prescription_order_id AS cs_pay_oid, "
. 'MAX(CASE WHEN pay.order_type IN (5, 7) THEN UNIX_TIMESTAMP(pay.payment_time) END) AS cs_pay_tail_ts, '
. 'MAX(UNIX_TIMESTAMP(pay.payment_time)) AS cs_pay_any_ts '
. "FROM {$linkTbl} l INNER JOIN {$payTbl} pay ON pay.id = l.pay_order_id AND pay.delete_time IS NULL "
. 'WHERE pay.status = 2 GROUP BY l.prescription_order_id) cs_pay_agg';
// 须传入带子查询别名的字符串;ThinkPHP parseJoin 对 Db::raw 整段 JOIN 表会直接传给 parseTable,触发类型错误
$query->leftJoin($etAggSql, 'cs_et_sig.order_id = o.id');
$query->leftJoin($payAggSql, 'cs_pay_agg.cs_pay_oid = o.id');
}
/**
* @return array<int, mixed>
*/
private static function commissionSettlementSignPayFieldRawList(): array
{
return [
Db::raw('IFNULL(cs_et_sig.cs_sign_ts, 0) AS sign_ts'),
Db::raw('COALESCE(cs_pay_agg.cs_pay_tail_ts, cs_pay_agg.cs_pay_any_ts, 0) AS pay_ts'),
];
}
/**
* @param array<string, mixed> $pack commissionSettlementResolveBasePack 返回值
*/
private static function commissionSettlementBuildFilteredOrderQuery(array $pack): Query
{
$c = $pack['c'];
$diagTable = $pack['diagTable'];
$rxTable = $pack['rxTable'];
$att = $pack['att'];
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->join("{$rxTable} rx", 'rx.id = o.prescription_id AND rx.delete_time IS NULL AND IFNULL(rx.is_system_auto, 0) = 1', 'INNER');
self::commissionSettlementAttachExpressPayAggregates($query);
$query->whereNull('o.delete_time')
->where('o.fulfillment_status', 3)
->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", []);
$query->where(function ($qq) use ($pack) {
$qq->whereBetween('o.create_time', [$pack['orderCreateStartTs'], $pack['orderCreateEndTs']]);
if ($pack['carryOrderIds'] !== []) {
$qq->whereOr(function ($q2) use ($pack) {
$q2->whereIn('o.id', $pack['carryOrderIds']);
});
}
});
self::applyCommissionSettlementChannelFilter($query, $c);
return $query;
}
/**
* 提成结算业绩看板:上月(相对结算月)系统处方对应的已完成业务单,按签收与尾款支付是否在结算月 7 日 24 点前拆分「计入本期提成 / 顺延下期」。
*
* 规则摘要:
* - 订单池:结算月上一自然月内创建;关联处方 is_system_auto=1(系统代开);履约已完成 fulfillment_status=3;未软删。
* - 签收时间:express_tracking.order_type=prescription 下 MAX(sign_time)。
* - 尾款支付时间:关联支付单 status=2,优先 order_type∈(5,7),否则回退为任意已支付关联单的 MAX(payment_time)。
* - 计入本期:签收与尾款时间均已成立且不晚于结算月 7 日 23:59:59。
* - 其余已完成单计入「顺延下期」(含签收或支付缺失、或晚于截止)。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, tag_id?: string} $params
*
* @return array<string, mixed>
*/
public static function commissionSettlementOverview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$monthRaw = $pack['monthRaw'];
$cutoffTs = $pack['cutoffTs'];
$orderMonthLabel = $pack['orderMonthLabel'];
$c = $pack['c'];
$tableRowDeptIds = $pack['tableRowDeptIds'];
$deptById = $pack['deptById'];
$adminToPrimary = $pack['adminToPrimary'];
$carryFlip = array_flip($pack['carryOrderIds']);
$confirm = self::commissionSettlementGetConfirmRow($monthRaw, $pack['scopeChannelCode'], $pack['deptKey']);
$emptyTotal = [
'completed_order_count' => 0,
'completed_order_amount' => 0.0,
'current_period_count' => 0,
'current_period_amount' => 0.0,
'deferred_period_count' => 0,
'deferred_period_amount' => 0.0,
'carry_in_order_count' => 0,
];
if ($tableRowDeptIds === [] || $adminToPrimary === []) {
return [
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;统计系统代开处方对应的已完成业务订单;签收与尾款均在结算月 7 日 24 点前完成的金额计入本期提成,否则计入顺延下期。上期「确定业绩」写入的顺延订单会在本期并入统计。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => 0,
'confirm' => $confirm,
'rows' => [],
'assistant_rows' => [],
'doctor_rows' => [],
'total' => $emptyTotal,
];
}
$query = self::commissionSettlementBuildFilteredOrderQuery($pack);
$att = $pack['att'];
$signPayFields = self::commissionSettlementSignPayFieldRawList();
$rowsRaw = $query->field(array_merge([
'o.id',
'o.order_no',
'o.diagnosis_id',
'o.create_time',
'o.amount',
Db::raw("({$att}) AS assistant_id"),
Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'),
], $signPayFields))->select()->toArray();
$curAgg = [];
$defAgg = [];
$totAgg = [];
$cntByAsst = [];
$curCntByAsst = [];
$defCntByAsst = [];
$carryInCnt = 0;
$carryInByAssist = [];
$carryInByDoctor = [];
$totByDoctor = [];
$curByDoctor = [];
$defByDoctor = [];
$cntDoctorTot = [];
$cntDoctorCur = [];
$cntDoctorDef = [];
foreach ($rowsRaw as $r) {
$oid = (int) ($r['id'] ?? 0);
$isCarry = $oid > 0 && isset($carryFlip[$oid]);
if ($isCarry) {
$carryInCnt++;
}
$aid = (int) ($r['assistant_id'] ?? 0);
$doctorId = (int) ($r['doctor_id'] ?? 0);
$signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0);
$amt = round((float) ($r['amount'] ?? 0), 2);
if ($aid <= 0 || $amt < 0) {
continue;
}
$totAgg[$aid] = ($totAgg[$aid] ?? 0.0) + $amt;
$cntByAsst[$aid] = ($cntByAsst[$aid] ?? 0) + 1;
$eligible = $signTs > 0 && $payTs > 0 && $signTs <= $cutoffTs && $payTs <= $cutoffTs;
if ($eligible) {
$curAgg[$aid] = ($curAgg[$aid] ?? 0.0) + $amt;
$curCntByAsst[$aid] = ($curCntByAsst[$aid] ?? 0) + 1;
} else {
$defAgg[$aid] = ($defAgg[$aid] ?? 0.0) + $amt;
$defCntByAsst[$aid] = ($defCntByAsst[$aid] ?? 0) + 1;
}
if ($isCarry) {
$carryInByAssist[$aid] = ($carryInByAssist[$aid] ?? 0) + 1;
$carryInByDoctor[$doctorId] = ($carryInByDoctor[$doctorId] ?? 0) + 1;
}
$totByDoctor[$doctorId] = ($totByDoctor[$doctorId] ?? 0.0) + $amt;
$cntDoctorTot[$doctorId] = ($cntDoctorTot[$doctorId] ?? 0) + 1;
if ($eligible) {
$curByDoctor[$doctorId] = ($curByDoctor[$doctorId] ?? 0.0) + $amt;
$cntDoctorCur[$doctorId] = ($cntDoctorCur[$doctorId] ?? 0) + 1;
} else {
$defByDoctor[$doctorId] = ($defByDoctor[$doctorId] ?? 0.0) + $amt;
$cntDoctorDef[$doctorId] = ($cntDoctorDef[$doctorId] ?? 0) + 1;
}
}
$foldAmt = static function (array $byAssistant): array {
$outRows = [];
foreach ($byAssistant as $aid => $amt) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$outRows[] = ['assistant_id' => $aid, 'amt' => round((float) $amt, 2)];
}
return $outRows;
};
$foldCnt = static function (array $byAssistant): array {
$outRows = [];
foreach ($byAssistant as $aid => $v) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$outRows[] = ['assistant_id' => $aid, 'cnt' => (int) $v];
}
return $outRows;
};
$byDeptAmtTot = self::foldPerformanceRowsToDeptByTable($foldAmt($totAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
$byDeptAmtCur = self::foldPerformanceRowsToDeptByTable($foldAmt($curAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
$byDeptAmtDef = self::foldPerformanceRowsToDeptByTable($foldAmt($defAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
$byDeptCntTot = self::foldPerformanceRowsToDeptByTable($foldCnt($cntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$byDeptCntCur = self::foldPerformanceRowsToDeptByTable($foldCnt($curCntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$byDeptCntDef = self::foldPerformanceRowsToDeptByTable($foldCnt($defCntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$rows = [];
$sumCompletedCnt = 0;
$sumCompletedAmt = 0.0;
$sumCurCnt = 0;
$sumCurAmt = 0.0;
$sumDefCnt = 0;
$sumDefAmt = 0.0;
foreach ($tableRowDeptIds as $deptId) {
$deptId = (int) $deptId;
$cCnt = (int) ($byDeptCntTot[$deptId] ?? 0);
$cAmt = round((float) ($byDeptAmtTot[$deptId] ?? 0), 2);
$kCnt = (int) ($byDeptCntCur[$deptId] ?? 0);
$kAmt = round((float) ($byDeptAmtCur[$deptId] ?? 0), 2);
$dCnt = (int) ($byDeptCntDef[$deptId] ?? 0);
$dAmt = round((float) ($byDeptAmtDef[$deptId] ?? 0), 2);
$rows[] = [
'dept_id' => $deptId,
'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById),
'completed_order_count' => $cCnt,
'completed_order_amount' => $cAmt,
'current_period_count' => $kCnt,
'current_period_amount' => $kAmt,
'deferred_period_count' => $dCnt,
'deferred_period_amount' => $dAmt,
];
$sumCompletedCnt += $cCnt;
$sumCompletedAmt += $cAmt;
$sumCurCnt += $kCnt;
$sumCurAmt += $kAmt;
$sumDefCnt += $dCnt;
$sumDefAmt += $dAmt;
}
$assistantRows = [];
foreach (array_keys($totAgg) as $aid) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$pid = (int) ($adminToPrimary[$aid] ?? 0);
$assistantRows[] = [
'assistant_id' => $aid,
'assistant_name' => '',
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'carry_in_order_count' => (int) ($carryInByAssist[$aid] ?? 0),
'completed_order_count' => (int) ($cntByAsst[$aid] ?? 0),
'completed_order_amount' => round((float) ($totAgg[$aid] ?? 0), 2),
'current_period_count' => (int) ($curCntByAsst[$aid] ?? 0),
'current_period_amount' => round((float) ($curAgg[$aid] ?? 0), 2),
'deferred_period_count' => (int) ($defCntByAsst[$aid] ?? 0),
'deferred_period_amount' => round((float) ($defAgg[$aid] ?? 0), 2),
];
}
$assistNameMap = self::commissionSettlementBatchAdminNames(array_column($assistantRows, 'assistant_id'));
foreach ($assistantRows as &$ar) {
$aid = (int) ($ar['assistant_id'] ?? 0);
$ar['assistant_name'] = (string) ($assistNameMap[$aid] ?? ('#' . $aid));
}
unset($ar);
usort($assistantRows, static function (array $a, array $b): int {
$cmp = ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
return $cmp !== 0 ? $cmp : (($b['assistant_id'] ?? 0) <=> ($a['assistant_id'] ?? 0));
});
$doctorRows = [];
foreach (array_keys($totByDoctor) as $did) {
$did = (int) $did;
$pid = $did > 0 ? (int) ($adminToPrimary[$did] ?? 0) : 0;
$doctorRows[] = [
'doctor_id' => $did,
'doctor_name' => '',
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'carry_in_order_count' => (int) ($carryInByDoctor[$did] ?? 0),
'completed_order_count' => (int) ($cntDoctorTot[$did] ?? 0),
'completed_order_amount' => round((float) ($totByDoctor[$did] ?? 0), 2),
'current_period_count' => (int) ($cntDoctorCur[$did] ?? 0),
'current_period_amount' => round((float) ($curByDoctor[$did] ?? 0), 2),
'deferred_period_count' => (int) ($cntDoctorDef[$did] ?? 0),
'deferred_period_amount' => round((float) ($defByDoctor[$did] ?? 0), 2),
];
}
$doctorNameIds = array_values(array_filter(array_column($doctorRows, 'doctor_id'), static fn ($id) => (int) $id > 0));
$doctorNameMap = self::commissionSettlementBatchAdminNames($doctorNameIds);
foreach ($doctorRows as &$dr) {
$did = (int) ($dr['doctor_id'] ?? 0);
$dr['doctor_name'] = $did > 0 ? (string) ($doctorNameMap[$did] ?? ('#' . $did)) : '未关联开方人';
}
unset($dr);
usort($doctorRows, static function (array $a, array $b): int {
$za = (int) ($a['doctor_id'] ?? 0) === 0 ? 1 : 0;
$zb = (int) ($b['doctor_id'] ?? 0) === 0 ? 1 : 0;
if ($za !== $zb) {
return $za <=> $zb;
}
$cmp = ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
return $cmp !== 0 ? $cmp : (($b['doctor_id'] ?? 0) <=> ($a['doctor_id'] ?? 0));
});
return [
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;仅统计系统代开处方(is_system_auto=1)对应的履约已完成业务订单。签收时间取物流表签收时间最大值;尾款时间取关联支付单已支付记录(优先类型为尾款/全部)。签收与尾款均不晚于结算月 7 日 24 点的计入「本期提成」,其余已完成订单计入「顺延下期」。上期同渠道同部门范围内「确定业绩」产生的顺延订单,会计入本期订单池。「按医助下发」按业绩归属医助聚合;「按医生下发」按处方开方人(creator_id)聚合。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => $carryInCnt,
'confirm' => $confirm,
'rows' => $rows,
'assistant_rows' => $assistantRows,
'doctor_rows' => $doctorRows,
'total' => [
'completed_order_count' => $sumCompletedCnt,
'completed_order_amount' => round($sumCompletedAmt, 2),
'current_period_count' => $sumCurCnt,
'current_period_amount' => round($sumCurAmt, 2),
'deferred_period_count' => $sumDefCnt,
'deferred_period_amount' => round($sumDefAmt, 2),
'carry_in_order_count' => $carryInCnt,
],
];
}
/**
* 提成核对:订单明细(分页)。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, page?: int|string, page_size?: int|string, bucket?: string} $params bucket: 空|current|deferred
*
* @return array<string, mixed>
*/
public static function commissionSettlementOrderLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$cutoffTs = $pack['cutoffTs'];
$tableRowDeptIds = $pack['tableRowDeptIds'];
$deptById = $pack['deptById'];
$adminToPrimary = $pack['adminToPrimary'];
$carryFlip = array_flip($pack['carryOrderIds']);
$bucket = trim((string) ($params['bucket'] ?? ''));
$page = max(1, (int) ($params['page'] ?? 1));
$pageSize = min(100, max(10, (int) ($params['page_size'] ?? 20)));
if ($tableRowDeptIds === [] || $adminToPrimary === []) {
return ['list' => [], 'count' => 0, 'page' => $page, 'page_size' => $pageSize];
}
$query = self::commissionSettlementBuildFilteredOrderQuery($pack);
$att = $pack['att'];
$signPayFields = self::commissionSettlementSignPayFieldRawList();
$baseRows = $query->field(array_merge([
'o.id',
'o.order_no',
'o.diagnosis_id',
'o.create_time',
'o.amount',
Db::raw("({$att}) AS assistant_id"),
Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'),
], $signPayFields))->order('o.id', 'desc')->select()->toArray();
$mapped = [];
foreach ($baseRows as $r) {
$signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0);
$eligible = $signTs > 0 && $payTs > 0 && $signTs <= $cutoffTs && $payTs <= $cutoffTs;
$b = $eligible ? 'current' : 'deferred';
if ($bucket === 'current' && $b !== 'current') {
continue;
}
if ($bucket === 'deferred' && $b !== 'deferred') {
continue;
}
$oid = (int) ($r['id'] ?? 0);
$aid = (int) ($r['assistant_id'] ?? 0);
$doctorId = (int) ($r['doctor_id'] ?? 0);
$foldRows = [['assistant_id' => $aid, 'cnt' => 1]];
$byDept = self::foldPerformanceRowsToDeptByTable($foldRows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$primaryDeptId = 0;
foreach ($byDept as $did => $_c) {
$primaryDeptId = (int) $did;
break;
}
$mapped[] = [
'id' => $oid,
'order_no' => (string) ($r['order_no'] ?? ''),
'diagnosis_id' => (int) ($r['diagnosis_id'] ?? 0),
'amount' => round((float) ($r['amount'] ?? 0), 2),
'create_time' => (int) ($r['create_time'] ?? 0),
'create_time_text' => !empty($r['create_time']) ? date('Y-m-d H:i:s', (int) $r['create_time']) : '',
'sign_ts' => $signTs,
'sign_time_text' => $signTs > 0 ? date('Y-m-d H:i:s', $signTs) : '',
'pay_ts' => $payTs,
'pay_time_text' => $payTs > 0 ? date('Y-m-d H:i:s', $payTs) : '',
'assistant_id' => $aid,
'doctor_id' => $doctorId,
'assistant_name' => '',
'doctor_name' => '',
'bucket' => $b,
'is_carry_in' => $oid > 0 && isset($carryFlip[$oid]) ? 1 : 0,
'primary_dept_id' => $primaryDeptId,
'primary_dept_name' => $primaryDeptId > 0 ? self::formatYejiDeptRowDisplayName($primaryDeptId, $deptById) : '',
];
}
$nameIds = [];
foreach ($mapped as $row) {
$na = (int) ($row['assistant_id'] ?? 0);
if ($na > 0) {
$nameIds[$na] = true;
}
$nd = (int) ($row['doctor_id'] ?? 0);
if ($nd > 0) {
$nameIds[$nd] = true;
}
}
$adminNames = self::commissionSettlementBatchAdminNames(array_map('intval', array_keys($nameIds)));
foreach ($mapped as &$row) {
$na = (int) ($row['assistant_id'] ?? 0);
$nd = (int) ($row['doctor_id'] ?? 0);
$row['assistant_name'] = $na > 0 ? (string) ($adminNames[$na] ?? ('#' . $na)) : '—';
$row['doctor_name'] = $nd > 0 ? (string) ($adminNames[$nd] ?? ('#' . $nd)) : '未关联开方人';
}
unset($row);
$total = count($mapped);
$slice = array_slice($mapped, ($page - 1) * $pageSize, $pageSize);
return [
'list' => $slice,
'count' => $total,
'page' => $page,
'page_size' => $pageSize,
];
}
/**
* 当前筛选条件下的核对 / 确定状态。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string} $params
*
* @return array<string, mixed>
*/
public static function commissionSettlementConfirmStatus(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$row = self::commissionSettlementGetConfirmRow($pack['monthRaw'], $pack['scopeChannelCode'], $pack['deptKey']);
return [
'settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'confirm' => $row,
];
}
/**
* 保存核对备注(未确定前可反复保存)。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, reconcile_note?: string} $params
*/
public static function commissionSettlementSaveReconcile(array $params, int $viewerAdminId, array $viewerAdminInfo): array
{
if ($viewerAdminId <= 0) {
throw new \think\Exception('未登录');
}
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$note = mb_substr(trim((string) ($params['reconcile_note'] ?? '')), 0, 500);
$t = time();
$exists = Db::name('commission_settlement_confirm')
->where('settlement_month', $pack['monthRaw'])
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->find();
if ($exists) {
$ex = is_array($exists) ? $exists : $exists->toArray();
if ((int) ($ex['status'] ?? 0) === 1) {
throw new \think\Exception('已确定业绩的记录不可修改核对备注,如需更正请联系管理员处理数据');
}
Db::name('commission_settlement_confirm')->where('id', (int) $ex['id'])->update([
'reconcile_note' => $note,
'update_time' => $t,
]);
} else {
Db::name('commission_settlement_confirm')->insert([
'settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'status' => 0,
'reconcile_note' => $note,
'totals_json' => '',
'confirmed_admin_id' => 0,
'confirmed_admin_name' => '',
'confirmed_at' => 0,
'create_time' => $t,
'update_time' => $t,
]);
}
return ['ok' => true];
}
/**
* 确定本期业绩:写入快照,并将「顺延下期」订单结转至下一结算月同 scope。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, reconcile_note?: string} $params
*/
public static function commissionSettlementConfirmFinalize(array $params, int $viewerAdminId, array $viewerAdminInfo): array
{
if ($viewerAdminId <= 0) {
throw new \think\Exception('未登录');
}
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$tableRowDeptIds = $pack['tableRowDeptIds'];
$adminToPrimary = $pack['adminToPrimary'];
if ($tableRowDeptIds === [] || $adminToPrimary === []) {
throw new \think\Exception('当前账号无可操作的展示部门,无法确定业绩');
}
$exists = Db::name('commission_settlement_confirm')
->where('settlement_month', $pack['monthRaw'])
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->find();
if ($exists) {
$ex = is_array($exists) ? $exists : $exists->toArray();
if ((int) ($ex['status'] ?? 0) === 1) {
throw new \think\Exception('该结算月在同渠道同部门筛选下已确定业绩,请勿重复提交');
}
}
$overview = self::commissionSettlementOverview($params, $viewerAdminId, $viewerAdminInfo);
$total = $overview['total'] ?? [];
$query = self::commissionSettlementBuildFilteredOrderQuery($pack);
$att = $pack['att'];
$signPayFields = self::commissionSettlementSignPayFieldRawList();
$cutoffTs = $pack['cutoffTs'];
$rowsRaw = $query->field(array_merge([
'o.id',
'o.create_time',
Db::raw("({$att}) AS assistant_id"),
], $signPayFields))->select()->toArray();
$deferredIds = [];
foreach ($rowsRaw as $r) {
$signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0);
$eligible = $signTs > 0 && $payTs > 0 && $signTs <= $cutoffTs && $payTs <= $cutoffTs;
if (!$eligible) {
$deferredIds[] = (int) ($r['id'] ?? 0);
}
}
$deferredIds = array_values(array_unique(array_filter($deferredIds, static fn (int $x): bool => $x > 0)));
$adminName = (string) (Db::name('admin')->where('id', $viewerAdminId)->whereNull('delete_time')->value('name') ?: '');
$note = mb_substr(trim((string) ($params['reconcile_note'] ?? '')), 0, 500);
$t = time();
$next = $pack['nextSettlementMonth'];
$totalsJson = json_encode([
'total' => $total,
'deferred_ids' => $deferredIds,
'next_carry_month' => $next,
], JSON_UNESCAPED_UNICODE);
Db::startTrans();
try {
Db::name('commission_settlement_carry')
->where('source_settlement_month', $pack['monthRaw'])
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->delete();
foreach ($deferredIds as $poId) {
$dup = Db::name('commission_settlement_carry')
->where('prescription_order_id', $poId)
->where('target_settlement_month', $next)
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->find();
if ($dup) {
continue;
}
Db::name('commission_settlement_carry')->insert([
'prescription_order_id' => $poId,
'target_settlement_month' => $next,
'source_settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'create_time' => $t,
]);
}
if ($exists) {
$ex = is_array($exists) ? $exists : $exists->toArray();
Db::name('commission_settlement_confirm')->where('id', (int) $ex['id'])->update([
'status' => 1,
'reconcile_note' => $note !== '' ? $note : (string) ($ex['reconcile_note'] ?? ''),
'totals_json' => (string) $totalsJson,
'confirmed_admin_id' => $viewerAdminId,
'confirmed_admin_name' => mb_substr($adminName, 0, 64),
'confirmed_at' => $t,
'update_time' => $t,
]);
} else {
Db::name('commission_settlement_confirm')->insert([
'settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'status' => 1,
'reconcile_note' => $note,
'totals_json' => (string) $totalsJson,
'confirmed_admin_id' => $viewerAdminId,
'confirmed_admin_name' => mb_substr($adminName, 0, 64),
'confirmed_at' => $t,
'create_time' => $t,
'update_time' => $t,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'ok' => true,
'next_settlement_month' => $next,
'deferred_carry_count' => count($deferredIds),
];
}
}
@@ -0,0 +1,31 @@
-- 提成结算:核对记录 + 顺延结转(上期「确定业绩」时写入,下期统计自动并入订单池)
CREATE TABLE IF NOT EXISTS `zyt_commission_settlement_confirm` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`settlement_month` char(7) NOT NULL COMMENT '结算月 YYYY-MM',
`scope_channel_code` varchar(128) NOT NULL DEFAULT '' COMMENT '与查询渠道一致,空为全渠道',
`scope_dept_ids_key` varchar(255) NOT NULL DEFAULT '*' COMMENT '部门筛选键:* 或排序后的 id 逗号串',
`status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0已保存核对 1已确定业绩',
`reconcile_note` varchar(500) NOT NULL DEFAULT '' COMMENT '核对备注',
`totals_json` text COMMENT '确定时汇总快照 JSON',
`confirmed_admin_id` int unsigned NOT NULL DEFAULT 0,
`confirmed_admin_name` varchar(64) NOT NULL DEFAULT '',
`confirmed_at` int unsigned NOT NULL DEFAULT 0 COMMENT '确定业绩时间戳',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_scope` (`settlement_month`,`scope_channel_code`(64),`scope_dept_ids_key`(128))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提成结算核对/确定';
CREATE TABLE IF NOT EXISTS `zyt_commission_settlement_carry` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`prescription_order_id` int unsigned NOT NULL COMMENT '业务订单 tcm_prescription_order.id',
`target_settlement_month` char(7) NOT NULL COMMENT '计入哪个月结算池',
`source_settlement_month` char(7) NOT NULL COMMENT '从哪个月确定顺延出来',
`scope_channel_code` varchar(128) NOT NULL DEFAULT '',
`scope_dept_ids_key` varchar(255) NOT NULL DEFAULT '*',
`create_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_carry` (`prescription_order_id`,`target_settlement_month`,`scope_channel_code`(64),`scope_dept_ids_key`(64)),
KEY `idx_target_scope` (`target_settlement_month`,`scope_channel_code`(32),`scope_dept_ids_key`(64)),
KEY `idx_source_scope` (`source_settlement_month`,`scope_channel_code`(32),`scope_dept_ids_key`(64))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提成顺延结转';
@@ -0,0 +1,101 @@
-- 提成结算业绩:独立页面 fans/commission-settlement + stats.commissionSettlement/* 接口权限
-- 与业绩看板 yeji 同父菜单(pid = 业绩看板菜单 id 293);幂等
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
y.pid,
'C',
'提成结算业绩',
'',
96,
'fans/commission-settlement',
'/fans/commission-settlement',
'fans/commission-settlement',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT pid FROM `zyt_system_menu` WHERE id = 293 LIMIT 1) AS y
WHERE EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE id = 293)
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m WHERE m.component = 'fans/commission-settlement'
);
SET @commission_cs_pid := (
SELECT id FROM `zyt_system_menu` WHERE component = 'fans/commission-settlement' ORDER BY id DESC LIMIT 1
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@commission_cs_pid,
'A',
'提成结算-汇总',
'',
1,
'stats.commissionSettlement/overview',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT 1 AS `_`) AS `_exec`
WHERE @commission_cs_pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.pid = @commission_cs_pid AND m.perms = 'stats.commissionSettlement/overview'
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@commission_cs_pid,
'A',
'提成结算-部门下拉',
'',
2,
'stats.commissionSettlement/deptOptions',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT 1 AS `_`) AS `_exec`
WHERE @commission_cs_pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.pid = @commission_cs_pid AND m.perms = 'stats.commissionSettlement/deptOptions'
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@commission_cs_pid,
'A',
'提成结算-渠道下拉',
'',
3,
'stats.commissionSettlement/channelOptions',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT 1 AS `_`) AS `_exec`
WHERE @commission_cs_pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.pid = @commission_cs_pid AND m.perms = 'stats.commissionSettlement/channelOptions'
);
@@ -0,0 +1,101 @@
-- 提成结算:核对/确定业绩接口权限(挂在 fans/commission-settlement 页面菜单下)
SET @commission_cs_pid := (
SELECT id FROM `zyt_system_menu` WHERE component = 'fans/commission-settlement' ORDER BY id DESC LIMIT 1
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@commission_cs_pid,
'A',
'提成结算-核对明细',
'',
11,
'stats.commissionSettlement/orderLines',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT 1 AS `_`) AS `_exec`
WHERE @commission_cs_pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.pid = @commission_cs_pid AND m.perms = 'stats.commissionSettlement/orderLines'
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@commission_cs_pid,
'A',
'提成结算-确认状态',
'',
12,
'stats.commissionSettlement/confirmStatus',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT 1 AS `_`) AS `_exec`
WHERE @commission_cs_pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.pid = @commission_cs_pid AND m.perms = 'stats.commissionSettlement/confirmStatus'
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@commission_cs_pid,
'A',
'提成结算-保存核对',
'',
13,
'stats.commissionSettlement/saveReconcile',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT 1 AS `_`) AS `_exec`
WHERE @commission_cs_pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.pid = @commission_cs_pid AND m.perms = 'stats.commissionSettlement/saveReconcile'
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@commission_cs_pid,
'A',
'提成结算-确定业绩',
'',
14,
'stats.commissionSettlement/confirmFinalize',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM (SELECT 1 AS `_`) AS `_exec`
WHERE @commission_cs_pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.pid = @commission_cs_pid AND m.perms = 'stats.commissionSettlement/confirmFinalize'
);