Merge branch 'cursor/self-input-stats'
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function getSelfInputOverview(params: any) {
|
||||||
|
return request.get({ url: '/stats.self_input/overview', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
|
||||||
|
export function getSelfInputMediaSourceOptions() {
|
||||||
|
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiLists(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_yeji/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiAdd(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/add', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiEdit(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/edit', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiDetail(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_yeji/detail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiDelete(params: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostLists(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_account_cost/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostAdd(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/add', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostEdit(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/edit', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostDetail(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_account_cost/detail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostDelete(params: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/delete', params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<el-select
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:clearable="clearable"
|
||||||
|
:filterable="filterable"
|
||||||
|
:allow-create="allowCreate"
|
||||||
|
:default-first-option="allowCreate"
|
||||||
|
:disabled="disabled"
|
||||||
|
:loading="loading"
|
||||||
|
:class="selectClass"
|
||||||
|
:style="selectStyle"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
@change="emit('change', $event)"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in normalizedOptions"
|
||||||
|
:key="item.name"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.name"
|
||||||
|
/>
|
||||||
|
<!-- 旧记录可能存在不在当前字典内的值:保留显示,避免编辑时反查不到 -->
|
||||||
|
<el-option
|
||||||
|
v-if="modelValue && !hasCurrentValue"
|
||||||
|
:key="`__legacy_${modelValue}`"
|
||||||
|
:label="`${modelValue}(已停用)`"
|
||||||
|
:value="modelValue"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
interface OptionItem {
|
||||||
|
name: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
options: Array<OptionItem | string>
|
||||||
|
loading?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
clearable?: boolean
|
||||||
|
filterable?: boolean
|
||||||
|
allowCreate?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
selectClass?: string
|
||||||
|
selectStyle?: string | Record<string, string>
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
loading: false,
|
||||||
|
placeholder: '请选择自媒体来源',
|
||||||
|
clearable: true,
|
||||||
|
filterable: true,
|
||||||
|
allowCreate: false,
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
change: [value: string]
|
||||||
|
'visible-change': [visible: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const normalizedOptions = computed<OptionItem[]>(() => {
|
||||||
|
return (props.options || []).map((item) =>
|
||||||
|
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCurrentValue = computed(() => {
|
||||||
|
return normalizedOptions.value.some((item) => item.name === props.modelValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onVisibleChange = (visible: boolean) => {
|
||||||
|
emit('visible-change', visible)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<div class="personal-account-cost-page">
|
||||||
|
<el-card class="!border-none mb-4" shadow="never">
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
<div class="w-1/2 md:w-1/3">
|
||||||
|
<div class="leading-10">当前筛选天数</div>
|
||||||
|
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2 md:w-1/3">
|
||||||
|
<div class="leading-10">累计账户消耗 (元)</div>
|
||||||
|
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
||||||
|
<el-form-item label="日期范围">
|
||||||
|
<daterange-picker
|
||||||
|
v-model:startTime="queryParams.start_date"
|
||||||
|
v-model:endTime="queryParams.end_date"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
:default-expand-all="true"
|
||||||
|
check-strictly
|
||||||
|
placeholder="全部部门"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
class="w-[220px]"
|
||||||
|
@change="resetPage"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源">
|
||||||
|
<media-source-select
|
||||||
|
v-model="queryParams.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="全部来源"
|
||||||
|
:allow-create="false"
|
||||||
|
select-class="w-[220px]"
|
||||||
|
@change="resetPage"
|
||||||
|
@visible-change="onMediaSourceDropdownVisible"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="w-[280px]" label="备注/录入人">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.remark"
|
||||||
|
placeholder="备注 / 创建人"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="resetPage"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<div>
|
||||||
|
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
|
||||||
|
<template #icon>
|
||||||
|
<icon name="el-icon-Plus" />
|
||||||
|
</template>
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||||
|
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||||
|
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="账户消耗" min-width="120">
|
||||||
|
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||||
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.dept_path"
|
||||||
|
:content="row.dept_path"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||||
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
|
||||||
|
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_account_cost/edit']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_account_cost/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="handleDelete(row.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<cost-edit-popup
|
||||||
|
v-if="showEdit"
|
||||||
|
ref="editRef"
|
||||||
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
|
@success="handleCostSuccess"
|
||||||
|
@close="showEdit = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup name="personalAccountCost">
|
||||||
|
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { getRoutePath } from '@/router'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
import CostEditPopup from './cost-edit.vue'
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const deptOptions = ref<any[]>([])
|
||||||
|
const {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
} = useMediaSourceOptions()
|
||||||
|
|
||||||
|
const deptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children',
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
|
||||||
|
|
||||||
|
const goSelfInput = () => {
|
||||||
|
if (selfInputPath.value) {
|
||||||
|
router.push(selfInputPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
media_source: '',
|
||||||
|
dept_id: undefined as number | undefined,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
|
fetchFun: personalAccountCostLists,
|
||||||
|
params: queryParams,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.start_date = ''
|
||||||
|
queryParams.end_date = ''
|
||||||
|
queryParams.media_source = ''
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.remark = ''
|
||||||
|
resetPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||||
|
if (visible) {
|
||||||
|
refreshMediaSourceOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCostSuccess = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
showEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
editRef.value?.open('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (row: Record<string, any>) => {
|
||||||
|
showEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
editRef.value?.open('edit')
|
||||||
|
editRef.value?.setFormData(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await feedback.confirm('确定删除该账户消耗记录?')
|
||||||
|
await personalAccountCostDelete({ id })
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await deptAll({ apply_data_scope: 1 })
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
deptOptions.value = res
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deptOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadMediaSourceOptions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.personal-account-cost-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-popup">
|
||||||
|
<popup
|
||||||
|
ref="popupRef"
|
||||||
|
:title="popupTitle"
|
||||||
|
:async="true"
|
||||||
|
width="520px"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||||
|
<el-form-item label="日期" prop="cost_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formData.cost_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源" prop="media_source">
|
||||||
|
<media-source-select
|
||||||
|
v-model="formData.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
select-class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="账户消耗" prop="amount">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.amount"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
personalAccountCostAdd,
|
||||||
|
personalAccountCostEdit,
|
||||||
|
} from '@/api/self_input_stats'
|
||||||
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'close'])
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||||
|
const mode = ref<'add' | 'edit'>('add')
|
||||||
|
|
||||||
|
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: '',
|
||||||
|
cost_date: '',
|
||||||
|
media_source: '',
|
||||||
|
amount: 0,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
|
||||||
|
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||||
|
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.id = ''
|
||||||
|
formData.cost_date = ''
|
||||||
|
formData.media_source = ''
|
||||||
|
formData.amount = 0
|
||||||
|
formData.remark = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
await personalAccountCostEdit(formData)
|
||||||
|
} else {
|
||||||
|
await personalAccountCostAdd(formData)
|
||||||
|
}
|
||||||
|
popupRef.value?.close()
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = (type: 'add' | 'edit' = 'add') => {
|
||||||
|
mode.value = type
|
||||||
|
resetForm()
|
||||||
|
popupRef.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFormData = (data: Record<string, any>) => {
|
||||||
|
formData.id = data.id ?? ''
|
||||||
|
formData.cost_date = data.cost_date ?? ''
|
||||||
|
formData.media_source = data.media_source ?? ''
|
||||||
|
formData.amount = Number(data.amount ?? 0)
|
||||||
|
formData.remark = data.remark ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setFormData,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
<template>
|
||||||
|
<div class="self-input-stats-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">最近30天</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
style="width: 260px"
|
||||||
|
@change="handleCustomDateChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
:default-expand-all="true"
|
||||||
|
check-strictly
|
||||||
|
placeholder="全部部门"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 220px"
|
||||||
|
@change="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="自媒体来源">
|
||||||
|
<media-source-select
|
||||||
|
v-model="queryParams.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="全部来源"
|
||||||
|
:allow-create="false"
|
||||||
|
select-style="width: 220px"
|
||||||
|
@change="handleQuery"
|
||||||
|
@visible-change="onMediaSourceDropdownVisible"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="stats-kpi-grid">
|
||||||
|
<div v-for="card in visibleSummaryCards" :key="card.key" class="stats-kpi-card">
|
||||||
|
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||||
|
{{ renderMetric(card.key, overview.summary, card.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-main">
|
||||||
|
<span class="card-title">业绩明细</span>
|
||||||
|
<span class="card-hint">{{ dateRangeText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-header-actions">
|
||||||
|
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
|
||||||
|
<template #icon>
|
||||||
|
<icon name="el-icon-Plus" />
|
||||||
|
</template>
|
||||||
|
新增业绩
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="accountCostEntryPath"
|
||||||
|
v-perms="['stats.personal_account_cost/lists']"
|
||||||
|
@click="goAccountCostEntry"
|
||||||
|
>
|
||||||
|
账户消耗录入
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="pager.loading"
|
||||||
|
:data="pager.lists"
|
||||||
|
border
|
||||||
|
size="large"
|
||||||
|
max-height="640"
|
||||||
|
>
|
||||||
|
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
|
||||||
|
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||||
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.dept_path"
|
||||||
|
:content="row.dept_path"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||||
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-for="col in visibleTableColumns"
|
||||||
|
:key="col.key"
|
||||||
|
:label="col.label"
|
||||||
|
:min-width="col.minWidth || 100"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ renderMetric(col.key, row, col.type) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_yeji/edit']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleEditYeji(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_yeji/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="handleDeleteYeji(row.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="fetchOverview" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<yeji-edit-popup
|
||||||
|
v-if="showYejiEdit"
|
||||||
|
ref="yejiEditRef"
|
||||||
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
|
@success="handleYejiSuccess"
|
||||||
|
@close="showYejiEdit = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup name="selfInputStats">
|
||||||
|
import {
|
||||||
|
getSelfInputOverview,
|
||||||
|
personalYejiDelete,
|
||||||
|
} from '@/api/self_input_stats'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { getRoutePath } from '@/router'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import YejiEditPopup from './yeji-edit.vue'
|
||||||
|
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
|
||||||
|
|
||||||
|
interface MetricCard {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: MetricType
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: MetricType
|
||||||
|
minWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
|
||||||
|
const showYejiEdit = ref(false)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
const deptOptions = ref<any[]>([])
|
||||||
|
const {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
} = useMediaSourceOptions()
|
||||||
|
|
||||||
|
const deptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children',
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = reactive<Record<string, any>>({
|
||||||
|
date_range: [],
|
||||||
|
summary: {},
|
||||||
|
can_view_finance: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
|
||||||
|
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
media_source: '',
|
||||||
|
dept_id: undefined as number | undefined,
|
||||||
|
time_type: 'today',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
|
||||||
|
|
||||||
|
const goAccountCostEntry = () => {
|
||||||
|
if (accountCostEntryPath.value) {
|
||||||
|
router.push(accountCostEntryPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOverviewList = async (params: Record<string, any>) => {
|
||||||
|
if (queryParams.time_type === 'custom') {
|
||||||
|
params.start_date = dateRange.value[0] || ''
|
||||||
|
params.end_date = dateRange.value[1] || ''
|
||||||
|
}
|
||||||
|
const res = await getSelfInputOverview(params)
|
||||||
|
Object.assign(overview, res?.extend || {})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await deptAll({ apply_data_scope: 1 })
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
deptOptions.value = res
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deptOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||||
|
if (visible) {
|
||||||
|
refreshMediaSourceOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleYejiSuccess = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
await fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadMediaSourceOptions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pager, getLists } = usePaging({
|
||||||
|
fetchFun: fetchOverviewList,
|
||||||
|
params: queryParams,
|
||||||
|
firstLoading: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryCards: MetricCard[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||||
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'order_amount', label: '订单金额', type: 'money' },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money' },
|
||||||
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
|
||||||
|
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
|
||||||
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
|
||||||
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
|
||||||
|
{ key: 'cash_cost', label: '现金成本', type: 'money' },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tableColumns: MetricColumn[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||||
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
||||||
|
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
|
||||||
|
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
|
||||||
|
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleSummaryCards = computed(() =>
|
||||||
|
canViewFinance.value
|
||||||
|
? summaryCards
|
||||||
|
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleTableColumns = computed(() =>
|
||||||
|
canViewFinance.value
|
||||||
|
? tableColumns
|
||||||
|
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const dateRangeText = computed(() => {
|
||||||
|
if (!overview.date_range?.length) return '未选择'
|
||||||
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchOverview = async () => {
|
||||||
|
try {
|
||||||
|
await getLists()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取自录转化统计失败:', error)
|
||||||
|
ElMessage.error(error?.msg || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeTypeChange = () => {
|
||||||
|
if (queryParams.time_type !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = () => {
|
||||||
|
if (dateRange.value.length === 2) {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.media_source = ''
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.time_type = 'today'
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
|
||||||
|
const value = source?.[key] ?? 0
|
||||||
|
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||||
|
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||||
|
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||||
|
return String(value ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddYeji = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
showYejiEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
yejiEditRef.value?.open('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditYeji = async (row: Record<string, any>) => {
|
||||||
|
showYejiEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
yejiEditRef.value?.open('edit')
|
||||||
|
yejiEditRef.value?.setFormData(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteYeji = async (id: number) => {
|
||||||
|
await feedback.confirm('确定删除该业绩记录?')
|
||||||
|
await personalYejiDelete({ id })
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchOverview()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.self-input-stats-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value.is-money {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-detail-card :deep(.el-card__body) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
|
||||||
|
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
|
||||||
|
*/
|
||||||
|
export interface MediaSourceOption {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaSourceOptions() {
|
||||||
|
const mediaSourceOptions = ref<MediaSourceOption[]>([])
|
||||||
|
const mediaSourceLoading = ref(false)
|
||||||
|
|
||||||
|
const normalize = (raw: any): MediaSourceOption[] => {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const list: MediaSourceOption[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const name = item.trim()
|
||||||
|
if (name && !seen.has(name)) {
|
||||||
|
seen.add(name)
|
||||||
|
list.push({ name, value: '' })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
const name = String(item.name ?? '').trim()
|
||||||
|
if (name && !seen.has(name)) {
|
||||||
|
seen.add(name)
|
||||||
|
list.push({ name, value: String(item.value ?? '') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMediaSourceOptions = async (force = false) => {
|
||||||
|
if (!force && mediaSourceOptions.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaSourceLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSelfInputMediaSourceOptions()
|
||||||
|
mediaSourceOptions.value = normalize(res)
|
||||||
|
} catch {
|
||||||
|
mediaSourceOptions.value = []
|
||||||
|
} finally {
|
||||||
|
mediaSourceLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-popup">
|
||||||
|
<popup
|
||||||
|
ref="popupRef"
|
||||||
|
:title="popupTitle"
|
||||||
|
:async="true"
|
||||||
|
width="640px"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||||
|
<el-form-item label="业绩日期" prop="yeji_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formData.yeji_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源" prop="media_source">
|
||||||
|
<media-source-select
|
||||||
|
v-model="formData.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
select-class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="加粉数" prop="add_fans_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.add_fans_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="总开口" prop="total_open_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.total_open_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="未回复" prop="unreplied_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.unreplied_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="付费挂号" prop="paid_appointment_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.paid_appointment_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="免费挂号" prop="free_appointment_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.free_appointment_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="面诊" prop="interview_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.interview_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="订单金额" prop="order_amount">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.order_amount"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="接诊诊单" prop="completed_order_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.completed_order_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
|
||||||
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'close'])
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||||
|
const mode = ref<'add' | 'edit'>('add')
|
||||||
|
|
||||||
|
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: '',
|
||||||
|
yeji_date: '',
|
||||||
|
media_source: '',
|
||||||
|
add_fans_count: 0,
|
||||||
|
total_open_count: 0,
|
||||||
|
unreplied_count: 0,
|
||||||
|
paid_appointment_count: 0,
|
||||||
|
free_appointment_count: 0,
|
||||||
|
interview_count: 0,
|
||||||
|
order_amount: 0,
|
||||||
|
completed_order_count: 0,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
|
||||||
|
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||||
|
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.id = ''
|
||||||
|
formData.yeji_date = ''
|
||||||
|
formData.media_source = ''
|
||||||
|
formData.add_fans_count = 0
|
||||||
|
formData.total_open_count = 0
|
||||||
|
formData.unreplied_count = 0
|
||||||
|
formData.paid_appointment_count = 0
|
||||||
|
formData.free_appointment_count = 0
|
||||||
|
formData.interview_count = 0
|
||||||
|
formData.order_amount = 0
|
||||||
|
formData.completed_order_count = 0
|
||||||
|
formData.remark = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
await personalYejiEdit(formData)
|
||||||
|
} else {
|
||||||
|
await personalYejiAdd(formData)
|
||||||
|
}
|
||||||
|
popupRef.value?.close()
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = (type: 'add' | 'edit' = 'add') => {
|
||||||
|
mode.value = type
|
||||||
|
resetForm()
|
||||||
|
popupRef.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFormData = (data: Record<string, any>) => {
|
||||||
|
formData.id = data.id ?? ''
|
||||||
|
formData.yeji_date = data.yeji_date ?? ''
|
||||||
|
formData.media_source = data.media_source ?? ''
|
||||||
|
formData.add_fans_count = Number(data.add_fans_count ?? 0)
|
||||||
|
formData.total_open_count = Number(data.total_open_count ?? 0)
|
||||||
|
formData.unreplied_count = Number(data.unreplied_count ?? 0)
|
||||||
|
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
|
||||||
|
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
|
||||||
|
formData.interview_count = Number(data.interview_count ?? 0)
|
||||||
|
formData.order_amount = Number(data.order_amount ?? 0)
|
||||||
|
formData.completed_order_count = Number(data.completed_order_count ?? 0)
|
||||||
|
formData.remark = data.remark ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setFormData,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\lists\stats\PersonalAccountCostLists;
|
||||||
|
use app\adminapi\logic\stats\PersonalAccountCostLogic;
|
||||||
|
use app\adminapi\validate\stats\PersonalAccountCostValidate;
|
||||||
|
|
||||||
|
class PersonalAccountCostController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function lists()
|
||||||
|
{
|
||||||
|
return $this->dataLists(new PersonalAccountCostLists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
$params = (new PersonalAccountCostValidate())->post()->goCheck('add');
|
||||||
|
$result = PersonalAccountCostLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PersonalAccountCostLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('添加成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit()
|
||||||
|
{
|
||||||
|
$params = (new PersonalAccountCostValidate())->post()->goCheck('edit');
|
||||||
|
$result = PersonalAccountCostLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PersonalAccountCostLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('编辑成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detail()
|
||||||
|
{
|
||||||
|
$params = (new PersonalAccountCostValidate())->goCheck('detail');
|
||||||
|
$detail = PersonalAccountCostLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||||
|
if ($detail === []) {
|
||||||
|
return $this->fail('记录不存在或无权查看');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->data($detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
$params = (new PersonalAccountCostValidate())->post()->goCheck('delete');
|
||||||
|
$result = PersonalAccountCostLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PersonalAccountCostLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('删除成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\lists\stats\PersonalYejiLists;
|
||||||
|
use app\adminapi\logic\stats\PersonalYejiLogic;
|
||||||
|
use app\adminapi\validate\stats\PersonalYejiValidate;
|
||||||
|
|
||||||
|
class PersonalYejiController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function lists()
|
||||||
|
{
|
||||||
|
return $this->dataLists(new PersonalYejiLists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
$params = (new PersonalYejiValidate())->post()->goCheck('add');
|
||||||
|
$result = PersonalYejiLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PersonalYejiLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('添加成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit()
|
||||||
|
{
|
||||||
|
$params = (new PersonalYejiValidate())->post()->goCheck('edit');
|
||||||
|
$result = PersonalYejiLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PersonalYejiLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('编辑成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detail()
|
||||||
|
{
|
||||||
|
$params = (new PersonalYejiValidate())->goCheck('detail');
|
||||||
|
$detail = PersonalYejiLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||||
|
if ($detail === []) {
|
||||||
|
return $this->fail('记录不存在或无权查看');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->data($detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
$params = (new PersonalYejiValidate())->post()->goCheck('delete');
|
||||||
|
$result = PersonalYejiLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PersonalYejiLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('删除成功', [], 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\logic\stats\SelfInputLogic;
|
||||||
|
|
||||||
|
class SelfInputController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function overview()
|
||||||
|
{
|
||||||
|
$result = SelfInputLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
|
||||||
|
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自媒体来源下拉:从已录入业绩/账户消耗去重提取(随录入动态变化)
|
||||||
|
*/
|
||||||
|
public function mediaSourceOptions()
|
||||||
|
{
|
||||||
|
$list = SelfInputLogic::mediaSourceOptions($this->adminId, $this->adminInfo);
|
||||||
|
|
||||||
|
return $this->data($list);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,19 @@ class AuthMiddleware
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自录转化统计:自媒体来源下拉,复用总览或账户消耗列表权限
|
||||||
|
if ($accessUri === 'stats.selfinput/mediasourceoptions') {
|
||||||
|
$selfInputAliases = [
|
||||||
|
'stats.selfinput/overview',
|
||||||
|
'stats.self_input/overview',
|
||||||
|
'stats.personalaccountcost/lists',
|
||||||
|
'stats.personal_account_cost/lists',
|
||||||
|
];
|
||||||
|
if (count(array_intersect($selfInputAliases, $AdminUris)) > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处方库列表:消费者开方页/处方库页导入共用,复用开方或处方库菜单权限
|
// 处方库列表:消费者开方页/处方库页导入共用,复用开方或处方库菜单权限
|
||||||
if ($accessUri === 'tcm.prescriptionlibrary/lists'
|
if ($accessUri === 'tcm.prescriptionlibrary/lists'
|
||||||
&& $this->matchPrescriptionLibraryListsPermission($adminUris)) {
|
&& $this->matchPrescriptionLibraryListsPermission($adminUris)) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\lists\stats;
|
||||||
|
|
||||||
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
|
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
|
||||||
|
use app\common\lists\ListsExtendInterface;
|
||||||
|
use app\common\lists\ListsSearchInterface;
|
||||||
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
|
use app\common\model\stats\PersonalAccountCost;
|
||||||
|
|
||||||
|
class PersonalAccountCostLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||||
|
{
|
||||||
|
use HasDataScopeFilter;
|
||||||
|
use PersonalStatsScopeTrait;
|
||||||
|
|
||||||
|
public function setSearch(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'%like%' => ['media_source', 'creator_name', 'remark'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseQuery()
|
||||||
|
{
|
||||||
|
$query = PersonalAccountCost::where($this->searchWhere);
|
||||||
|
$visibleIds = $this->getDataScopeVisibleAdminIds();
|
||||||
|
$deptId = (int) ($this->params['dept_id'] ?? 0);
|
||||||
|
|
||||||
|
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
|
||||||
|
if ($finalIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
} elseif ($finalIds !== null) {
|
||||||
|
$query->whereIn('creator_id', $finalIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
||||||
|
$query->whereBetween('cost_date', [$this->params['start_date'], $this->params['end_date']]);
|
||||||
|
} elseif (!empty($this->params['start_date'])) {
|
||||||
|
$query->where('cost_date', '>=', $this->params['start_date']);
|
||||||
|
} elseif (!empty($this->params['end_date'])) {
|
||||||
|
$query->where('cost_date', '<=', $this->params['end_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->params['media_source'])) {
|
||||||
|
$query->where('media_source', trim((string) $this->params['media_source']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lists(): array
|
||||||
|
{
|
||||||
|
$rows = $this->baseQuery()
|
||||||
|
->order(['cost_date' => 'desc', 'id' => 'desc'])
|
||||||
|
->limit($this->limitOffset, $this->limitLength)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return self::attachDeptInfoToRows($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return (int) $this->baseQuery()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extend(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total_amount' => round((float) $this->baseQuery()->sum('amount'), 2),
|
||||||
|
'days_count' => (int) $this->baseQuery()->distinct(true)->count('cost_date'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\lists\stats;
|
||||||
|
|
||||||
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
|
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
|
||||||
|
use app\common\lists\ListsSearchInterface;
|
||||||
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
|
use app\common\model\stats\PersonalYeji;
|
||||||
|
|
||||||
|
class PersonalYejiLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||||
|
{
|
||||||
|
use HasDataScopeFilter;
|
||||||
|
use PersonalStatsScopeTrait;
|
||||||
|
|
||||||
|
public function setSearch(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'%like%' => ['media_source', 'creator_name', 'remark'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseQuery()
|
||||||
|
{
|
||||||
|
$query = PersonalYeji::where($this->searchWhere);
|
||||||
|
$visibleIds = $this->getDataScopeVisibleAdminIds();
|
||||||
|
$deptId = (int) ($this->params['dept_id'] ?? 0);
|
||||||
|
|
||||||
|
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
|
||||||
|
if ($finalIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
} elseif ($finalIds !== null) {
|
||||||
|
$query->whereIn('creator_id', $finalIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
||||||
|
$query->whereBetween('yeji_date', [$this->params['start_date'], $this->params['end_date']]);
|
||||||
|
} elseif (!empty($this->params['start_date'])) {
|
||||||
|
$query->where('yeji_date', '>=', $this->params['start_date']);
|
||||||
|
} elseif (!empty($this->params['end_date'])) {
|
||||||
|
$query->where('yeji_date', '<=', $this->params['end_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->params['media_source'])) {
|
||||||
|
$query->where('media_source', trim((string) $this->params['media_source']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->params['creator_id'])) {
|
||||||
|
$query->where('creator_id', (int) $this->params['creator_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lists(): array
|
||||||
|
{
|
||||||
|
$rows = $this->baseQuery()
|
||||||
|
->order(['yeji_date' => 'desc', 'id' => 'desc'])
|
||||||
|
->limit($this->limitOffset, $this->limitLength)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return self::attachDeptInfoToRows($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return (int) $this->baseQuery()->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\stats\PersonalAccountCost;
|
||||||
|
|
||||||
|
class PersonalAccountCostLogic extends BaseLogic
|
||||||
|
{
|
||||||
|
use PersonalStatsScopeTrait;
|
||||||
|
|
||||||
|
public static function add(array $params, int $adminId, string $adminName): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$costDate = (string) $params['cost_date'];
|
||||||
|
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
|
||||||
|
if ($mediaSource === '') {
|
||||||
|
self::setError('请填写自媒体来源');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflict = self::findCostConflict($costDate, $mediaSource);
|
||||||
|
if ($conflict['conflict']) {
|
||||||
|
$by = $conflict['creator_name'] !== '' ? '(录入人:' . $conflict['creator_name'] . ')' : '';
|
||||||
|
self::setError('该日期下该渠道的账户消耗已存在' . $by . ',请直接编辑该记录,避免重复汇总');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersonalAccountCost::create([
|
||||||
|
'cost_date' => $costDate,
|
||||||
|
'media_source' => $mediaSource,
|
||||||
|
'amount' => round((float) ($params['amount'] ?? 0), 2),
|
||||||
|
'remark' => (string) ($params['remark'] ?? ''),
|
||||||
|
'creator_id' => $adminId,
|
||||||
|
'creator_name' => $adminName,
|
||||||
|
'updater_id' => $adminId,
|
||||||
|
'updater_name' => $adminName,
|
||||||
|
'dept_id' => self::resolvePrimaryDeptId($adminId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$model = PersonalAccountCost::find($params['id']);
|
||||||
|
if (!$model) {
|
||||||
|
self::setError('记录不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||||
|
self::setError('无权操作该记录');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->amount = round((float) ($params['amount'] ?? 0), 2);
|
||||||
|
$model->remark = (string) ($params['remark'] ?? '');
|
||||||
|
$model->updater_id = $adminId;
|
||||||
|
$model->updater_name = $adminName;
|
||||||
|
$model->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $id, int $adminId, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$model = PersonalAccountCost::find($id);
|
||||||
|
if (!$model) {
|
||||||
|
self::setError('记录不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||||
|
self::setError('无权操作该记录');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->delete();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function detail(int $id, int $adminId, array $adminInfo): array
|
||||||
|
{
|
||||||
|
$model = PersonalAccountCost::find($id);
|
||||||
|
if (!$model) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $model->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\common\model\auth\AdminDept;
|
||||||
|
use app\common\model\dept\Dept;
|
||||||
|
use app\common\model\stats\PersonalAccountCost;
|
||||||
|
use app\common\model\stats\PersonalYeji;
|
||||||
|
use app\common\service\DataScope\DataScopeService;
|
||||||
|
use think\facade\Config;
|
||||||
|
|
||||||
|
trait PersonalStatsScopeTrait
|
||||||
|
{
|
||||||
|
protected static function resolvePrimaryDeptId(int $adminId): int
|
||||||
|
{
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$deptId = AdminDept::where('admin_id', $adminId)->value('dept_id');
|
||||||
|
|
||||||
|
return (int) ($deptId ?: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $creatorIds
|
||||||
|
* @return array{0: array<int, int>, 1: array<int, string>, 2: array<int, string>}
|
||||||
|
* [adminId => deptId, deptId => name, deptId => "祖/父/当前"]
|
||||||
|
*/
|
||||||
|
protected static function loadAdminDeptMap(array $creatorIds): array
|
||||||
|
{
|
||||||
|
$creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds))));
|
||||||
|
if ($creatorIds === []) {
|
||||||
|
return [[], [], []];
|
||||||
|
}
|
||||||
|
$rows = AdminDept::whereIn('admin_id', $creatorIds)
|
||||||
|
->field('admin_id, dept_id')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$adminToDeptId = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||||
|
$deptId = (int) ($row['dept_id'] ?? 0);
|
||||||
|
if ($adminId <= 0 || $deptId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($adminToDeptId[$adminId])) {
|
||||||
|
$adminToDeptId[$adminId] = $deptId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($adminToDeptId === []) {
|
||||||
|
return [[], [], []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$allDeptRows = Dept::field('id, pid, name')->select()->toArray();
|
||||||
|
$deptIndex = [];
|
||||||
|
foreach ($allDeptRows as $row) {
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deptIndex[$id] = [
|
||||||
|
'pid' => (int) ($row['pid'] ?? 0),
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$deptNameMap = [];
|
||||||
|
$deptPathMap = [];
|
||||||
|
foreach (array_unique(array_values($adminToDeptId)) as $deptId) {
|
||||||
|
$deptId = (int) $deptId;
|
||||||
|
if ($deptId <= 0 || !isset($deptIndex[$deptId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deptNameMap[$deptId] = $deptIndex[$deptId]['name'];
|
||||||
|
$deptPathMap[$deptId] = self::resolveDeptPath($deptId, $deptIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$adminToDeptId, $deptNameMap, $deptPathMap];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从根节点到当前部门的完整链路(用 / 分隔)。
|
||||||
|
*
|
||||||
|
* @param array<int, array{pid: int, name: string}> $deptIndex
|
||||||
|
*/
|
||||||
|
private static function resolveDeptPath(int $deptId, array $deptIndex): string
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
$guard = 0;
|
||||||
|
$cursor = $deptId;
|
||||||
|
while ($cursor > 0 && isset($deptIndex[$cursor]) && $guard++ < 32) {
|
||||||
|
$node = $deptIndex[$cursor];
|
||||||
|
$name = trim($node['name']);
|
||||||
|
if ($name !== '') {
|
||||||
|
array_unshift($names, $name);
|
||||||
|
}
|
||||||
|
$cursor = $node['pid'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' / ', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
protected static function attachDeptInfoToRows(array $rows): array
|
||||||
|
{
|
||||||
|
if ($rows === []) {
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
$creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows);
|
||||||
|
[$adminToDeptId, $deptNameMap, $deptPathMap] = self::loadAdminDeptMap($creatorIds);
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||||
|
$deptId = $adminToDeptId[$creatorId] ?? 0;
|
||||||
|
$row['dept_id'] = $deptId;
|
||||||
|
$row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : '';
|
||||||
|
$row['dept_path'] = $deptId > 0 ? (string) ($deptPathMap[$deptId] ?? '') : '';
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 dept_id(包含其子部门)收窄可见 admin id 集合。
|
||||||
|
* 与 visibleAdminIds 取交集。
|
||||||
|
*
|
||||||
|
* @param array<int>|null $visibleAdminIds null 表示不限
|
||||||
|
* @return array<int>|null 返回 null 表示外部条件无需附加;返回 [] 表示无可见 admin
|
||||||
|
*/
|
||||||
|
protected static function intersectVisibleByDept(?array $visibleAdminIds, int $deptId): ?array
|
||||||
|
{
|
||||||
|
if ($deptId <= 0) {
|
||||||
|
return $visibleAdminIds;
|
||||||
|
}
|
||||||
|
$deptIds = DeptLogic::getSelfAndDescendantIds($deptId);
|
||||||
|
if ($deptIds === []) {
|
||||||
|
$deptIds = [$deptId];
|
||||||
|
}
|
||||||
|
$adminIds = AdminDept::whereIn('dept_id', $deptIds)->column('admin_id');
|
||||||
|
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0)));
|
||||||
|
|
||||||
|
if ($visibleAdminIds === null) {
|
||||||
|
return $adminIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_intersect($visibleAdminIds, $adminIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function normalizeMediaSource(string $mediaSource): string
|
||||||
|
{
|
||||||
|
return trim($mediaSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 project.self_input_stats_view_all_roles 一致:超管或白名单角色可见全部录入人数据。
|
||||||
|
*/
|
||||||
|
protected static function canViewAllSelfInputStats(array $adminInfo): bool
|
||||||
|
{
|
||||||
|
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$allow = Config::get('project.self_input_stats_view_all_roles', []);
|
||||||
|
$allow = array_map('intval', is_array($allow) ? $allow : []);
|
||||||
|
if ($allow === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||||
|
|
||||||
|
return count(array_intersect($myRoles, $allow)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int>|null null=全部录入人;[]=无可见;int[]=可见 creator_id 集合
|
||||||
|
*/
|
||||||
|
protected static function getVisibleCreatorIds(int $adminId, array $adminInfo): ?array
|
||||||
|
{
|
||||||
|
if (self::canViewAllSelfInputStats($adminInfo)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function assertRecordVisible(int $adminId, array $adminInfo, int $creatorId): bool
|
||||||
|
{
|
||||||
|
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
|
||||||
|
if ($visibleIds === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($creatorId, $visibleIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一天 + 同一渠道全局唯一(不分录入人):避免重复汇总。
|
||||||
|
* 命中时返回首个已存在记录的录入人姓名,便于前端提示。
|
||||||
|
*
|
||||||
|
* @return array{conflict: bool, creator_name: string}
|
||||||
|
*/
|
||||||
|
protected static function findYejiConflict(string $yejiDate, string $mediaSource, int $excludeId = 0): array
|
||||||
|
{
|
||||||
|
$query = PersonalYeji::where('yeji_date', $yejiDate)
|
||||||
|
->where('media_source', $mediaSource);
|
||||||
|
if ($excludeId > 0) {
|
||||||
|
$query->where('id', '<>', $excludeId);
|
||||||
|
}
|
||||||
|
$row = $query->field('id, creator_name')->find();
|
||||||
|
if (!$row) {
|
||||||
|
return ['conflict' => false, 'creator_name' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['conflict' => true, 'creator_name' => (string) ($row['creator_name'] ?? '')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一天 + 同一渠道全局唯一(不分录入人):避免重复汇总。
|
||||||
|
*
|
||||||
|
* @return array{conflict: bool, creator_name: string}
|
||||||
|
*/
|
||||||
|
protected static function findCostConflict(string $costDate, string $mediaSource, int $excludeId = 0): array
|
||||||
|
{
|
||||||
|
$query = PersonalAccountCost::where('cost_date', $costDate)
|
||||||
|
->where('media_source', $mediaSource);
|
||||||
|
if ($excludeId > 0) {
|
||||||
|
$query->where('id', '<>', $excludeId);
|
||||||
|
}
|
||||||
|
$row = $query->field('id, creator_name')->find();
|
||||||
|
if (!$row) {
|
||||||
|
return ['conflict' => false, 'creator_name' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['conflict' => true, 'creator_name' => (string) ($row['creator_name'] ?? '')];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\stats\PersonalYeji;
|
||||||
|
|
||||||
|
class PersonalYejiLogic extends BaseLogic
|
||||||
|
{
|
||||||
|
use PersonalStatsScopeTrait;
|
||||||
|
|
||||||
|
public static function add(array $params, int $adminId, string $adminName): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$yejiDate = (string) $params['yeji_date'];
|
||||||
|
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
|
||||||
|
if ($mediaSource === '') {
|
||||||
|
self::setError('请填写自媒体来源');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflict = self::findYejiConflict($yejiDate, $mediaSource);
|
||||||
|
if ($conflict['conflict']) {
|
||||||
|
$by = $conflict['creator_name'] !== '' ? '(录入人:' . $conflict['creator_name'] . ')' : '';
|
||||||
|
self::setError('该日期下该渠道的业绩已存在' . $by . ',请直接编辑该记录,避免重复汇总');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersonalYeji::create([
|
||||||
|
'yeji_date' => $yejiDate,
|
||||||
|
'media_source' => $mediaSource,
|
||||||
|
'add_fans_count' => (int) ($params['add_fans_count'] ?? 0),
|
||||||
|
'total_open_count' => (int) ($params['total_open_count'] ?? 0),
|
||||||
|
'unreplied_count' => (int) ($params['unreplied_count'] ?? 0),
|
||||||
|
'paid_appointment_count' => (int) ($params['paid_appointment_count'] ?? 0),
|
||||||
|
'free_appointment_count' => (int) ($params['free_appointment_count'] ?? 0),
|
||||||
|
'interview_count' => (int) ($params['interview_count'] ?? 0),
|
||||||
|
'order_amount' => round((float) ($params['order_amount'] ?? 0), 2),
|
||||||
|
'completed_order_count' => (int) ($params['completed_order_count'] ?? 0),
|
||||||
|
'remark' => (string) ($params['remark'] ?? ''),
|
||||||
|
'creator_id' => $adminId,
|
||||||
|
'creator_name' => $adminName,
|
||||||
|
'updater_id' => $adminId,
|
||||||
|
'updater_name' => $adminName,
|
||||||
|
'dept_id' => self::resolvePrimaryDeptId($adminId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$model = PersonalYeji::find($params['id']);
|
||||||
|
if (!$model) {
|
||||||
|
self::setError('记录不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||||
|
self::setError('无权操作该记录');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->add_fans_count = (int) ($params['add_fans_count'] ?? 0);
|
||||||
|
$model->total_open_count = (int) ($params['total_open_count'] ?? 0);
|
||||||
|
$model->unreplied_count = (int) ($params['unreplied_count'] ?? 0);
|
||||||
|
$model->paid_appointment_count = (int) ($params['paid_appointment_count'] ?? 0);
|
||||||
|
$model->free_appointment_count = (int) ($params['free_appointment_count'] ?? 0);
|
||||||
|
$model->interview_count = (int) ($params['interview_count'] ?? 0);
|
||||||
|
$model->order_amount = round((float) ($params['order_amount'] ?? 0), 2);
|
||||||
|
$model->completed_order_count = (int) ($params['completed_order_count'] ?? 0);
|
||||||
|
$model->remark = (string) ($params['remark'] ?? '');
|
||||||
|
$model->updater_id = $adminId;
|
||||||
|
$model->updater_name = $adminName;
|
||||||
|
$model->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $id, int $adminId, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$model = PersonalYeji::find($id);
|
||||||
|
if (!$model) {
|
||||||
|
self::setError('记录不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||||
|
self::setError('无权操作该记录');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->delete();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function detail(int $id, int $adminId, array $adminInfo): array
|
||||||
|
{
|
||||||
|
$model = PersonalYeji::find($id);
|
||||||
|
if (!$model) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $model->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\dict\DictData;
|
||||||
|
use app\common\model\stats\PersonalAccountCost;
|
||||||
|
use app\common\model\stats\PersonalYeji;
|
||||||
|
|
||||||
|
class SelfInputLogic extends BaseLogic
|
||||||
|
{
|
||||||
|
use PersonalStatsScopeTrait;
|
||||||
|
|
||||||
|
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||||
|
{
|
||||||
|
[$startDate, $endDate] = self::resolveTimeRange($params);
|
||||||
|
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||||
|
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 15)));
|
||||||
|
$mediaSource = trim((string) ($params['media_source'] ?? ''));
|
||||||
|
$deptId = (int) ($params['dept_id'] ?? 0);
|
||||||
|
|
||||||
|
$effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, $deptId);
|
||||||
|
|
||||||
|
$yejiQuery = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
|
||||||
|
if ($mediaSource !== '') {
|
||||||
|
$yejiQuery->where('media_source', $mediaSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = (int) (clone $yejiQuery)->count();
|
||||||
|
$rows = (clone $yejiQuery)
|
||||||
|
->order(['yeji_date' => 'desc', 'id' => 'desc'])
|
||||||
|
->page($pageNo, $pageSize)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$costMap = self::loadAccountCostMap($startDate, $endDate, $effectiveAdminIds, $mediaSource);
|
||||||
|
|
||||||
|
$lists = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$entity = self::normalizeYejiRow($row);
|
||||||
|
$costKey = self::buildCostKey(
|
||||||
|
(int) $entity['creator_id'],
|
||||||
|
(string) $entity['yeji_date'],
|
||||||
|
(string) $entity['media_source']
|
||||||
|
);
|
||||||
|
$entity['account_cost'] = round((float) ($costMap[$costKey] ?? 0), 2);
|
||||||
|
$lists[] = self::finalizeMetrics($entity);
|
||||||
|
}
|
||||||
|
$lists = self::attachDeptInfoToRows($lists);
|
||||||
|
|
||||||
|
$allYejiRows = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
|
||||||
|
if ($mediaSource !== '') {
|
||||||
|
$allYejiRows->where('media_source', $mediaSource);
|
||||||
|
}
|
||||||
|
$allYeji = $allYejiRows->select()->toArray();
|
||||||
|
|
||||||
|
$summaryCostMap = $costMap;
|
||||||
|
$summaryBase = self::emptyMetrics();
|
||||||
|
foreach ($allYeji as $row) {
|
||||||
|
$entity = self::normalizeYejiRow($row);
|
||||||
|
$costKey = self::buildCostKey(
|
||||||
|
(int) $entity['creator_id'],
|
||||||
|
(string) $entity['yeji_date'],
|
||||||
|
(string) $entity['media_source']
|
||||||
|
);
|
||||||
|
$entity['account_cost'] = round((float) ($summaryCostMap[$costKey] ?? 0), 2);
|
||||||
|
unset($summaryCostMap[$costKey]);
|
||||||
|
$entity = self::finalizeMetrics($entity);
|
||||||
|
$summaryBase = self::accumulateMetrics($summaryBase, $entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($summaryCostMap as $amount) {
|
||||||
|
$summaryBase['account_cost'] += round((float) $amount, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = self::finalizeMetrics($summaryBase);
|
||||||
|
$canViewFinance = self::canViewAllSelfInputStats($adminInfo);
|
||||||
|
|
||||||
|
if (!$canViewFinance) {
|
||||||
|
$summary = self::maskFinanceFields($summary);
|
||||||
|
foreach ($lists as &$item) {
|
||||||
|
$item = self::maskFinanceFields($item);
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => $summary,
|
||||||
|
'lists' => $lists,
|
||||||
|
'count' => $count,
|
||||||
|
'page_no' => $pageNo,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
'extend' => [
|
||||||
|
'summary' => $summary,
|
||||||
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'can_view_finance' => $canViewFinance,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务相关字段(账户消耗 / 现金成本 / ROI):非豁免角色不可见,剔除字段避免被嗅探。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $entity
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function maskFinanceFields(array $entity): array
|
||||||
|
{
|
||||||
|
foreach (['account_cost', 'cash_cost', 'roi'] as $key) {
|
||||||
|
unset($entity[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自媒体来源选项:来自字典「推广渠道」(type_value=channels),按 sort/id 排序。
|
||||||
|
* 用 dict_data.name 作为存储值(与历史录入兼容;保留 value 仅作展示标识)。
|
||||||
|
*
|
||||||
|
* @return array<int, array{name: string, value: string}>
|
||||||
|
*/
|
||||||
|
public static function mediaSourceOptions(int $adminId, array $adminInfo): array
|
||||||
|
{
|
||||||
|
unset($adminId, $adminInfo);
|
||||||
|
|
||||||
|
$rows = DictData::where('type_value', 'channels')
|
||||||
|
->where('status', 1)
|
||||||
|
->order(['sort' => 'desc', 'id' => 'asc'])
|
||||||
|
->field('name, value')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = self::normalizeMediaSource((string) ($row['name'] ?? ''));
|
||||||
|
if ($name === '' || isset($seen[$name])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$name] = true;
|
||||||
|
$list[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'value' => (string) ($row['value'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int>|null null = 不限;[] = 无可见
|
||||||
|
*/
|
||||||
|
private static function resolveEffectiveAdminIds(int $adminId, array $adminInfo, int $deptId): ?array
|
||||||
|
{
|
||||||
|
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
|
||||||
|
|
||||||
|
return self::intersectVisibleByDept($visibleIds, $deptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $effectiveAdminIds
|
||||||
|
*/
|
||||||
|
private static function buildYejiQuery(string $startDate, string $endDate, ?array $effectiveAdminIds)
|
||||||
|
{
|
||||||
|
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
|
||||||
|
if ($effectiveAdminIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
} elseif ($effectiveAdminIds !== null) {
|
||||||
|
$query->whereIn('creator_id', $effectiveAdminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $effectiveAdminIds
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private static function loadAccountCostMap(
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
?array $effectiveAdminIds,
|
||||||
|
string $mediaSource
|
||||||
|
): array {
|
||||||
|
$query = PersonalAccountCost::whereBetween('cost_date', [$startDate, $endDate]);
|
||||||
|
if ($effectiveAdminIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($effectiveAdminIds !== null) {
|
||||||
|
$query->whereIn('creator_id', $effectiveAdminIds);
|
||||||
|
}
|
||||||
|
if ($mediaSource !== '') {
|
||||||
|
$query->where('media_source', $mediaSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query
|
||||||
|
->fieldRaw('creator_id, cost_date, media_source, SUM(amount) AS total_amount')
|
||||||
|
->group('creator_id, cost_date, media_source')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = self::buildCostKey(
|
||||||
|
(int) $row['creator_id'],
|
||||||
|
(string) $row['cost_date'],
|
||||||
|
(string) $row['media_source']
|
||||||
|
);
|
||||||
|
$map[$key] = round((float) ($row['total_amount'] ?? 0), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildCostKey(int $creatorId, string $date, string $mediaSource): string
|
||||||
|
{
|
||||||
|
return $creatorId . '|' . $date . '|' . self::normalizeMediaSource($mediaSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function normalizeYejiRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'yeji_date' => (string) ($row['yeji_date'] ?? ''),
|
||||||
|
'media_source' => (string) ($row['media_source'] ?? ''),
|
||||||
|
'creator_id' => (int) ($row['creator_id'] ?? 0),
|
||||||
|
'creator_name' => (string) ($row['creator_name'] ?? ''),
|
||||||
|
'remark' => (string) ($row['remark'] ?? ''),
|
||||||
|
'add_fans_count' => (int) ($row['add_fans_count'] ?? 0),
|
||||||
|
'total_open_count' => (int) ($row['total_open_count'] ?? 0),
|
||||||
|
'unreplied_count' => (int) ($row['unreplied_count'] ?? 0),
|
||||||
|
'paid_appointment_count' => (int) ($row['paid_appointment_count'] ?? 0),
|
||||||
|
'free_appointment_count' => (int) ($row['free_appointment_count'] ?? 0),
|
||||||
|
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||||
|
'order_amount' => round((float) ($row['order_amount'] ?? 0), 2),
|
||||||
|
'completed_order_count' => (int) ($row['completed_order_count'] ?? 0),
|
||||||
|
'account_cost' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function emptyMetrics(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'add_fans_count' => 0,
|
||||||
|
'total_open_count' => 0,
|
||||||
|
'unreplied_count' => 0,
|
||||||
|
'paid_appointment_count' => 0,
|
||||||
|
'free_appointment_count' => 0,
|
||||||
|
'appointment_total_count' => 0,
|
||||||
|
'interview_count' => 0,
|
||||||
|
'order_amount' => 0.0,
|
||||||
|
'completed_order_count' => 0,
|
||||||
|
'account_cost' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $base
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function accumulateMetrics(array $base, array $row): array
|
||||||
|
{
|
||||||
|
$base['add_fans_count'] += (int) ($row['add_fans_count'] ?? 0);
|
||||||
|
$base['total_open_count'] += (int) ($row['total_open_count'] ?? 0);
|
||||||
|
$base['unreplied_count'] += (int) ($row['unreplied_count'] ?? 0);
|
||||||
|
$base['paid_appointment_count'] += (int) ($row['paid_appointment_count'] ?? 0);
|
||||||
|
$base['free_appointment_count'] += (int) ($row['free_appointment_count'] ?? 0);
|
||||||
|
$base['interview_count'] += (int) ($row['interview_count'] ?? 0);
|
||||||
|
$base['order_amount'] = round((float) $base['order_amount'] + (float) ($row['order_amount'] ?? 0), 2);
|
||||||
|
$base['completed_order_count'] += (int) ($row['completed_order_count'] ?? 0);
|
||||||
|
$base['account_cost'] = round((float) $base['account_cost'] + (float) ($row['account_cost'] ?? 0), 2);
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entity
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function finalizeMetrics(array $entity): array
|
||||||
|
{
|
||||||
|
$paidAppointmentCount = (int) ($entity['paid_appointment_count'] ?? 0);
|
||||||
|
$freeAppointmentCount = (int) ($entity['free_appointment_count'] ?? 0);
|
||||||
|
$appointmentTotalCount = $paidAppointmentCount + $freeAppointmentCount;
|
||||||
|
$interviewCount = (int) ($entity['interview_count'] ?? 0);
|
||||||
|
$addFansCount = (int) ($entity['add_fans_count'] ?? 0);
|
||||||
|
$totalOpenCount = (int) ($entity['total_open_count'] ?? 0);
|
||||||
|
$completedOrderCount = (int) ($entity['completed_order_count'] ?? 0);
|
||||||
|
$orderAmount = round((float) ($entity['order_amount'] ?? 0), 2);
|
||||||
|
$accountCost = round((float) ($entity['account_cost'] ?? 0), 2);
|
||||||
|
|
||||||
|
$entity['appointment_total_count'] = $appointmentTotalCount;
|
||||||
|
$entity['order_amount'] = $orderAmount;
|
||||||
|
$entity['account_cost'] = $accountCost;
|
||||||
|
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||||
|
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||||
|
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
|
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
||||||
|
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
|
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
|
$entity['avg_unit_price'] = self::safeDivideMoney($orderAmount, $completedOrderCount);
|
||||||
|
$entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
|
||||||
|
$entity['roi'] = self::safeDivideRatio($orderAmount, $accountCost);
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: string}
|
||||||
|
*/
|
||||||
|
private static function resolveTimeRange(array $params): array
|
||||||
|
{
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$timeType = (string) ($params['time_type'] ?? 'today');
|
||||||
|
|
||||||
|
switch ($timeType) {
|
||||||
|
case 'yesterday':
|
||||||
|
$startDate = date('Y-m-d', strtotime('-1 day'));
|
||||||
|
$endDate = $startDate;
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
$startDate = date('Y-m-d', strtotime('-6 days'));
|
||||||
|
$endDate = $today;
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
$startDate = date('Y-m-d', strtotime('-29 days'));
|
||||||
|
$endDate = $today;
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
$startDate = trim((string) ($params['start_date'] ?? ''));
|
||||||
|
$endDate = trim((string) ($params['end_date'] ?? ''));
|
||||||
|
if ($startDate === '' || $endDate === '') {
|
||||||
|
$startDate = $today;
|
||||||
|
$endDate = $today;
|
||||||
|
} elseif ($startDate > $endDate) {
|
||||||
|
[$startDate, $endDate] = [$endDate, $startDate];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'today':
|
||||||
|
default:
|
||||||
|
$startDate = $today;
|
||||||
|
$endDate = $today;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$startDate, $endDate];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function percent(int $numerator, int $denominator): float
|
||||||
|
{
|
||||||
|
if ($denominator <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($numerator / $denominator) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function safeDivideMoney(float $numerator, int $denominator): float
|
||||||
|
{
|
||||||
|
if ($denominator <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($numerator / $denominator, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function safeDivideRatio(float $numerator, float $denominator): float
|
||||||
|
{
|
||||||
|
if ($denominator <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($numerator / $denominator, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\validate\stats;
|
||||||
|
|
||||||
|
use app\common\model\stats\PersonalAccountCost;
|
||||||
|
use app\common\validate\BaseValidate;
|
||||||
|
|
||||||
|
class PersonalAccountCostValidate extends BaseValidate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'id' => 'require|checkExists',
|
||||||
|
'cost_date' => 'require|dateFormat:Y-m-d',
|
||||||
|
'media_source' => 'require|max:120',
|
||||||
|
'amount' => 'require|float|egt:0',
|
||||||
|
'remark' => 'max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'id.require' => '参数缺失',
|
||||||
|
'cost_date.require' => '请选择日期',
|
||||||
|
'cost_date.dateFormat' => '日期格式错误',
|
||||||
|
'media_source.require' => '请填写自媒体来源',
|
||||||
|
'media_source.max' => '自媒体来源不能超过120个字符',
|
||||||
|
'amount.require' => '请输入账户消耗金额',
|
||||||
|
'amount.float' => '账户消耗金额格式错误',
|
||||||
|
'amount.egt' => '账户消耗金额不能小于0',
|
||||||
|
'remark.max' => '备注不能超过255个字符',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sceneAdd()
|
||||||
|
{
|
||||||
|
return $this->remove('id', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneEdit()
|
||||||
|
{
|
||||||
|
return $this->remove('cost_date', true)->remove('media_source', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneDetail()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneDelete()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkExists($value)
|
||||||
|
{
|
||||||
|
$model = PersonalAccountCost::find($value);
|
||||||
|
if (!$model) {
|
||||||
|
return '记录不存在';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\validate\stats;
|
||||||
|
|
||||||
|
use app\common\model\stats\PersonalYeji;
|
||||||
|
use app\common\validate\BaseValidate;
|
||||||
|
|
||||||
|
class PersonalYejiValidate extends BaseValidate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'id' => 'require|checkExists',
|
||||||
|
'yeji_date' => 'require|dateFormat:Y-m-d',
|
||||||
|
'media_source' => 'require|max:120',
|
||||||
|
'add_fans_count' => 'require|integer|egt:0',
|
||||||
|
'total_open_count' => 'integer|egt:0',
|
||||||
|
'unreplied_count' => 'integer|egt:0',
|
||||||
|
'paid_appointment_count' => 'integer|egt:0',
|
||||||
|
'free_appointment_count' => 'integer|egt:0',
|
||||||
|
'interview_count' => 'integer|egt:0',
|
||||||
|
'order_amount' => 'float|egt:0',
|
||||||
|
'completed_order_count' => 'integer|egt:0',
|
||||||
|
'remark' => 'max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'id.require' => '参数缺失',
|
||||||
|
'yeji_date.require' => '请选择业绩日期',
|
||||||
|
'yeji_date.dateFormat' => '业绩日期格式错误',
|
||||||
|
'media_source.require' => '请填写自媒体来源',
|
||||||
|
'media_source.max' => '自媒体来源不能超过120个字符',
|
||||||
|
'add_fans_count.require' => '请填写加粉数',
|
||||||
|
'add_fans_count.integer' => '加粉数格式错误',
|
||||||
|
'add_fans_count.egt' => '加粉数不能小于0',
|
||||||
|
'remark.max' => '备注不能超过255个字符',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sceneAdd()
|
||||||
|
{
|
||||||
|
return $this->remove('id', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneEdit()
|
||||||
|
{
|
||||||
|
return $this->remove('yeji_date', true)->remove('media_source', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneDetail()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sceneDelete()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkExists($value)
|
||||||
|
{
|
||||||
|
$model = PersonalYeji::find($value);
|
||||||
|
if (!$model) {
|
||||||
|
return '记录不存在';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\model\stats;
|
||||||
|
|
||||||
|
use app\common\model\BaseModel;
|
||||||
|
use think\model\concern\SoftDelete;
|
||||||
|
|
||||||
|
class PersonalAccountCost extends BaseModel
|
||||||
|
{
|
||||||
|
use SoftDelete;
|
||||||
|
|
||||||
|
protected $name = 'personal_account_cost';
|
||||||
|
|
||||||
|
protected $deleteTime = 'delete_time';
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
|
||||||
|
protected $createTime = 'create_time';
|
||||||
|
|
||||||
|
protected $updateTime = 'update_time';
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\model\stats;
|
||||||
|
|
||||||
|
use app\common\model\BaseModel;
|
||||||
|
use think\model\concern\SoftDelete;
|
||||||
|
|
||||||
|
class PersonalYeji extends BaseModel
|
||||||
|
{
|
||||||
|
use SoftDelete;
|
||||||
|
|
||||||
|
protected $name = 'personal_yeji';
|
||||||
|
|
||||||
|
protected $deleteTime = 'delete_time';
|
||||||
|
|
||||||
|
protected $autoWriteTimestamp = true;
|
||||||
|
|
||||||
|
protected $createTime = 'create_time';
|
||||||
|
|
||||||
|
protected $updateTime = 'update_time';
|
||||||
|
}
|
||||||
@@ -123,6 +123,11 @@ return [
|
|||||||
// 订单管理列表(order/index):以下角色 ID + root 可查看全部订单,其余仅本人创建
|
// 订单管理列表(order/index):以下角色 ID + root 可查看全部订单,其余仅本人创建
|
||||||
'order_list_view_all_roles' => [0, 3, 4, 9, 6],
|
'order_list_view_all_roles' => [0, 3, 4, 9, 6],
|
||||||
|
|
||||||
|
// 自录转化统计(stats.self_input / personal_yeji / personal_account_cost):
|
||||||
|
// 以下角色 ID + root 在列表与总览中可见全部录入人的业绩/账户消耗(不受角色 data_scope 收窄)。
|
||||||
|
// 编辑/删除由菜单按钮权限(stats.personal_yeji/edit 等)控制,不在此配置。
|
||||||
|
'self_input_stats_view_all_roles' => [0, 3, 4, 9, 6],
|
||||||
|
|
||||||
// 业务订单列表金额统计:医助角色 ID;非支付审核白名单的医助仅统计其诊单 assistant_id=本人 的业绩
|
// 业务订单列表金额统计:医助角色 ID;非支付审核白名单的医助仅统计其诊单 assistant_id=本人 的业绩
|
||||||
'prescription_order_stats_assistant_role_id' => 2,
|
'prescription_order_stats_assistant_role_id' => 2,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- 自录数据统计菜单(挂在「数据统计」目录下)
|
||||||
|
-- 目录: /stats/self_input(自录数据统计)
|
||||||
|
-- 子菜单:
|
||||||
|
-- 自录转化统计 -> /stats/self_input
|
||||||
|
-- 账户消耗录入 -> /stats/self_input/account_cost
|
||||||
|
-- 子菜单 paths 为相对父级目录的路径片段。
|
||||||
|
|
||||||
|
-- ========== 已执行旧版 SQL 的环境:迁移到「自录数据统计」目录 ==========
|
||||||
|
SET @stats_root_id = (
|
||||||
|
SELECT `id` FROM `zyt_system_menu`
|
||||||
|
WHERE `paths` = '/stats' AND `type` = 'M'
|
||||||
|
ORDER BY `id` ASC
|
||||||
|
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
|
||||||
|
@stats_root_id, 'M', '自录数据统计', '', 5, '', 'self_input', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @stats_root_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu`
|
||||||
|
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @self_input_group_id = (
|
||||||
|
SELECT `id` FROM `zyt_system_menu`
|
||||||
|
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
|
||||||
|
ORDER BY `id` ASC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE `zyt_system_menu`
|
||||||
|
SET `pid` = @self_input_group_id,
|
||||||
|
`paths` = '',
|
||||||
|
`component` = 'stats/self_input/index',
|
||||||
|
`sort` = 1
|
||||||
|
WHERE `perms` = 'stats.self_input/overview'
|
||||||
|
AND @self_input_group_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE `zyt_system_menu`
|
||||||
|
SET `pid` = @self_input_group_id,
|
||||||
|
`paths` = 'account_cost',
|
||||||
|
`component` = 'stats/self_input/account_cost',
|
||||||
|
`sort` = 2
|
||||||
|
WHERE `perms` = 'stats.personal_account_cost/lists'
|
||||||
|
AND @self_input_group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ========== 全新安装:目录 + 子菜单 + 按钮权限 ==========
|
||||||
|
SET @stats_root_id = (
|
||||||
|
SELECT `id` FROM `zyt_system_menu`
|
||||||
|
WHERE `paths` = '/stats' AND `type` = 'M'
|
||||||
|
ORDER BY `id` ASC
|
||||||
|
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
|
||||||
|
@stats_root_id, 'M', '自录数据统计', '', 5, '', 'self_input', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @stats_root_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu`
|
||||||
|
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @self_input_group_id = (
|
||||||
|
SELECT `id` FROM `zyt_system_menu`
|
||||||
|
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
|
||||||
|
ORDER BY `id` ASC
|
||||||
|
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
|
||||||
|
@self_input_group_id, 'C', '自录转化统计', '', 1, 'stats.self_input/overview', '', 'stats/self_input/index', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @self_input_group_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'stats.self_input/overview'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @self_input_menu_id = (
|
||||||
|
SELECT `id` FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'stats.self_input/overview'
|
||||||
|
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 @self_input_menu_id, 'A', '查看业绩', '', 1, 'stats.personal_yeji/lists', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE @self_input_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_yeji/lists');
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
SELECT @self_input_menu_id, 'A', '新增业绩', '', 2, 'stats.personal_yeji/add', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE @self_input_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_yeji/add');
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
SELECT @self_input_menu_id, 'A', '编辑业绩', '', 3, 'stats.personal_yeji/edit', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE @self_input_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_yeji/edit');
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
SELECT @self_input_menu_id, 'A', '删除业绩', '', 4, 'stats.personal_yeji/delete', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE @self_input_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_yeji/delete');
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
SELECT
|
||||||
|
@self_input_group_id, 'C', '账户消耗录入', '', 2, 'stats.personal_account_cost/lists', 'account_cost', 'stats/self_input/account_cost', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @self_input_group_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'stats.personal_account_cost/lists'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @personal_cost_menu_id = (
|
||||||
|
SELECT `id` FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'stats.personal_account_cost/lists'
|
||||||
|
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 @personal_cost_menu_id, 'A', '新增消耗', '', 1, 'stats.personal_account_cost/add', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE @personal_cost_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_account_cost/add');
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
SELECT @personal_cost_menu_id, 'A', '编辑消耗', '', 2, 'stats.personal_account_cost/edit', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE @personal_cost_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_account_cost/edit');
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
SELECT @personal_cost_menu_id, 'A', '删除消耗', '', 3, 'stats.personal_account_cost/delete', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL WHERE @personal_cost_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_account_cost/delete');
|
||||||
|
|
||||||
|
-- 角色需勾选新目录「自录数据统计」后,子菜单权限才会一并生效(与 likeadmin 菜单树授权一致)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 业绩 & 账户消耗:将「同录入人+同日+同渠道」唯一改为「同日+同渠道」全局唯一,避免重复汇总。
|
||||||
|
-- 注意:执行前请先清理同一天同一渠道下多录入人重复的数据,否则索引创建会失败。
|
||||||
|
|
||||||
|
-- zyt_personal_yeji
|
||||||
|
ALTER TABLE `zyt_personal_yeji` DROP INDEX `uk_creator_date_media`;
|
||||||
|
ALTER TABLE `zyt_personal_yeji` ADD UNIQUE KEY `uk_date_media` (`yeji_date`, `media_source`);
|
||||||
|
|
||||||
|
-- zyt_personal_account_cost
|
||||||
|
ALTER TABLE `zyt_personal_account_cost` DROP INDEX `uk_creator_date_media`;
|
||||||
|
ALTER TABLE `zyt_personal_account_cost` ADD UNIQUE KEY `uk_date_media` (`cost_date`, `media_source`);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `zyt_personal_account_cost` (
|
||||||
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`cost_date` date NOT NULL COMMENT '消耗日期',
|
||||||
|
`media_source` varchar(120) NOT NULL DEFAULT '' COMMENT '自媒体来源',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '账户消耗金额',
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`creator_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '录入人ID',
|
||||||
|
`creator_name` varchar(64) NOT NULL DEFAULT '' COMMENT '录入人姓名',
|
||||||
|
`updater_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后修改人ID',
|
||||||
|
`updater_name` varchar(64) NOT NULL DEFAULT '' COMMENT '最后修改人姓名',
|
||||||
|
`dept_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '录入时部门ID快照',
|
||||||
|
`create_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
|
||||||
|
`update_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间',
|
||||||
|
`delete_time` int(11) unsigned DEFAULT NULL COMMENT '删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_date_media` (`cost_date`, `media_source`),
|
||||||
|
KEY `idx_cost_date` (`cost_date`),
|
||||||
|
KEY `idx_creator_id` (`creator_id`),
|
||||||
|
KEY `idx_dept_id` (`dept_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人账户消耗录入表';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `zyt_personal_yeji` (
|
||||||
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`yeji_date` date NOT NULL COMMENT '业绩日期',
|
||||||
|
`media_source` varchar(120) NOT NULL DEFAULT '' COMMENT '自媒体来源',
|
||||||
|
`add_fans_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '加粉数',
|
||||||
|
`total_open_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '总开口',
|
||||||
|
`unreplied_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '未回复',
|
||||||
|
`paid_appointment_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '付费挂号',
|
||||||
|
`free_appointment_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '免费挂号',
|
||||||
|
`interview_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '面诊',
|
||||||
|
`order_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
|
||||||
|
`completed_order_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '接诊诊单',
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`creator_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '录入人ID',
|
||||||
|
`creator_name` varchar(64) NOT NULL DEFAULT '' COMMENT '录入人姓名',
|
||||||
|
`updater_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后修改人ID',
|
||||||
|
`updater_name` varchar(64) NOT NULL DEFAULT '' COMMENT '最后修改人姓名',
|
||||||
|
`dept_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '录入时部门ID快照',
|
||||||
|
`create_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
|
||||||
|
`update_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间',
|
||||||
|
`delete_time` int(11) unsigned DEFAULT NULL COMMENT '删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_date_media` (`yeji_date`, `media_source`),
|
||||||
|
KEY `idx_yeji_date` (`yeji_date`),
|
||||||
|
KEY `idx_creator_id` (`creator_id`),
|
||||||
|
KEY `idx_dept_id` (`dept_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人业绩录入表';
|
||||||
Reference in New Issue
Block a user