fix(stats/self_input): 渠道字典、财务脱敏与同日同渠道全局唯一
自媒体来源改推广渠道字典;非白名单隐藏账户消耗/ROI;部门展示完整路径;业绩与消耗按日期+渠道全局去重。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,15 +14,32 @@
|
|||||||
@change="emit('change', $event)"
|
@change="emit('change', $event)"
|
||||||
@visible-change="onVisibleChange"
|
@visible-change="onVisibleChange"
|
||||||
>
|
>
|
||||||
<el-option v-for="item in options" :key="item" :label="item" :value="item" />
|
<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>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
withDefaults(
|
interface OptionItem {
|
||||||
|
name: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
options: string[]
|
options: Array<OptionItem | string>
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
@@ -49,6 +66,16 @@ const emit = defineEmits<{
|
|||||||
'visible-change': [visible: boolean]
|
'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) => {
|
const onVisibleChange = (visible: boolean) => {
|
||||||
emit('visible-change', visible)
|
emit('visible-change', visible)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,17 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
|
<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="creator_name" min-width="100" />
|
||||||
<el-table-column label="部门" prop="dept_name" min-width="140" show-overflow-tooltip>
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.dept_name">{{ row.dept_name }}</span>
|
<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>
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -121,6 +129,7 @@
|
|||||||
v-if="showEdit"
|
v-if="showEdit"
|
||||||
ref="editRef"
|
ref="editRef"
|
||||||
:media-source-options="mediaSourceOptions"
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
@success="handleCostSuccess"
|
@success="handleCostSuccess"
|
||||||
@close="showEdit = false"
|
@close="showEdit = false"
|
||||||
/>
|
/>
|
||||||
@@ -237,4 +246,9 @@ onMounted(() => {
|
|||||||
.personal-account-cost-page {
|
.personal-account-cost-page {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
<media-source-select
|
<media-source-select
|
||||||
v-model="formData.media_source"
|
v-model="formData.media_source"
|
||||||
:options="mediaSourceOptions"
|
:options="mediaSourceOptions"
|
||||||
placeholder="请选择或输入自媒体来源"
|
:loading="mediaSourceLoading"
|
||||||
:allow-create="true"
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
:disabled="mode === 'edit'"
|
:disabled="mode === 'edit'"
|
||||||
select-class="w-full"
|
select-class="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -63,13 +64,16 @@ import {
|
|||||||
import Popup from '@/components/popup/index.vue'
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
const props = withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
mediaSourceOptions?: string[]
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
mediaSourceOptions: () => [],
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<div class="stats-kpi-grid">
|
<div class="stats-kpi-grid">
|
||||||
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card">
|
<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-label">{{ card.label }}</div>
|
||||||
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||||
{{ renderMetric(card.key, overview.summary, card.type) }}
|
{{ renderMetric(card.key, overview.summary, card.type) }}
|
||||||
@@ -105,14 +105,22 @@
|
|||||||
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
|
<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="media_source" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||||
<el-table-column label="部门" prop="dept_name" min-width="140" show-overflow-tooltip>
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.dept_name">{{ row.dept_name }}</span>
|
<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>
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
v-for="col in tableColumns"
|
v-for="col in visibleTableColumns"
|
||||||
:key="col.key"
|
:key="col.key"
|
||||||
:label="col.label"
|
:label="col.label"
|
||||||
:min-width="col.minWidth || 100"
|
:min-width="col.minWidth || 100"
|
||||||
@@ -154,6 +162,7 @@
|
|||||||
v-if="showYejiEdit"
|
v-if="showYejiEdit"
|
||||||
ref="yejiEditRef"
|
ref="yejiEditRef"
|
||||||
:media-source-options="mediaSourceOptions"
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
@success="handleYejiSuccess"
|
@success="handleYejiSuccess"
|
||||||
@close="showYejiEdit = false"
|
@close="showYejiEdit = false"
|
||||||
/>
|
/>
|
||||||
@@ -210,8 +219,12 @@ const deptTreeProps = {
|
|||||||
const overview = reactive<Record<string, any>>({
|
const overview = reactive<Record<string, any>>({
|
||||||
date_range: [],
|
date_range: [],
|
||||||
summary: {},
|
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({
|
const queryParams = reactive({
|
||||||
media_source: '',
|
media_source: '',
|
||||||
dept_id: undefined as number | undefined,
|
dept_id: undefined as number | undefined,
|
||||||
@@ -311,6 +324,18 @@ const tableColumns: MetricColumn[] = [
|
|||||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
|
{ 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(() => {
|
const dateRangeText = computed(() => {
|
||||||
if (!overview.date_range?.length) return '未选择'
|
if (!overview.date_range?.length) return '未选择'
|
||||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
@@ -414,6 +439,11 @@ onMounted(async () => {
|
|||||||
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-kpi-label {
|
.stats-kpi-label {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,12 +1,42 @@
|
|||||||
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
|
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自录转化统计:自媒体来源下拉(从已录入业绩/消耗去重,两页共用同一接口)
|
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
|
||||||
|
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
|
||||||
*/
|
*/
|
||||||
|
export interface MediaSourceOption {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
export function useMediaSourceOptions() {
|
export function useMediaSourceOptions() {
|
||||||
const mediaSourceOptions = ref<string[]>([])
|
const mediaSourceOptions = ref<MediaSourceOption[]>([])
|
||||||
const mediaSourceLoading = ref(false)
|
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) => {
|
const loadMediaSourceOptions = async (force = false) => {
|
||||||
if (!force && mediaSourceOptions.value.length > 0) {
|
if (!force && mediaSourceOptions.value.length > 0) {
|
||||||
return
|
return
|
||||||
@@ -14,7 +44,7 @@ export function useMediaSourceOptions() {
|
|||||||
mediaSourceLoading.value = true
|
mediaSourceLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getSelfInputMediaSourceOptions()
|
const res = await getSelfInputMediaSourceOptions()
|
||||||
mediaSourceOptions.value = Array.isArray(res) ? res : []
|
mediaSourceOptions.value = normalize(res)
|
||||||
} catch {
|
} catch {
|
||||||
mediaSourceOptions.value = []
|
mediaSourceOptions.value = []
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
<media-source-select
|
<media-source-select
|
||||||
v-model="formData.media_source"
|
v-model="formData.media_source"
|
||||||
:options="mediaSourceOptions"
|
:options="mediaSourceOptions"
|
||||||
placeholder="请选择或输入自媒体来源"
|
:loading="mediaSourceLoading"
|
||||||
:allow-create="true"
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
:disabled="mode === 'edit'"
|
:disabled="mode === 'edit'"
|
||||||
select-class="w-full"
|
select-class="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -134,13 +135,16 @@ import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
|
|||||||
import Popup from '@/components/popup/index.vue'
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
const props = withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
mediaSourceOptions?: string[]
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
mediaSourceOptions: () => [],
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ class PersonalAccountCostLogic extends BaseLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::isCostDuplicate($adminId, $costDate, $mediaSource)) {
|
$conflict = self::findCostConflict($costDate, $mediaSource);
|
||||||
self::setError('该日期下该自媒体来源的账户消耗已存在,请直接编辑');
|
if ($conflict['conflict']) {
|
||||||
|
$by = $conflict['creator_name'] !== '' ? '(录入人:' . $conflict['creator_name'] . ')' : '';
|
||||||
|
self::setError('该日期下该渠道的账户消耗已存在' . $by . ',请直接编辑该记录,避免重复汇总');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ trait PersonalStatsScopeTrait
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, int> $creatorIds
|
* @param array<int, int> $creatorIds
|
||||||
* @return array{0: array<int, int>, 1: array<int, string>}
|
* @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
|
protected static function loadAdminDeptMap(array $creatorIds): array
|
||||||
{
|
{
|
||||||
$creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds))));
|
$creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds))));
|
||||||
if ($creatorIds === []) {
|
if ($creatorIds === []) {
|
||||||
return [[], []];
|
return [[], [], []];
|
||||||
}
|
}
|
||||||
$rows = AdminDept::whereIn('admin_id', $creatorIds)
|
$rows = AdminDept::whereIn('admin_id', $creatorIds)
|
||||||
->field('admin_id, dept_id')
|
->field('admin_id, dept_id')
|
||||||
@@ -51,14 +52,57 @@ trait PersonalStatsScopeTrait
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$deptNameMap = [];
|
if ($adminToDeptId === []) {
|
||||||
$deptIds = array_values(array_unique($adminToDeptId));
|
return [[], [], []];
|
||||||
if ($deptIds !== []) {
|
|
||||||
$deptNameMap = Dept::whereIn('id', $deptIds)
|
|
||||||
->column('name', 'id');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$adminToDeptId, $deptNameMap];
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,12 +115,13 @@ trait PersonalStatsScopeTrait
|
|||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
$creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows);
|
$creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows);
|
||||||
[$adminToDeptId, $deptNameMap] = self::loadAdminDeptMap($creatorIds);
|
[$adminToDeptId, $deptNameMap, $deptPathMap] = self::loadAdminDeptMap($creatorIds);
|
||||||
foreach ($rows as &$row) {
|
foreach ($rows as &$row) {
|
||||||
$creatorId = (int) ($row['creator_id'] ?? 0);
|
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||||
$deptId = $adminToDeptId[$creatorId] ?? 0;
|
$deptId = $adminToDeptId[$creatorId] ?? 0;
|
||||||
$row['dept_id'] = $deptId;
|
$row['dept_id'] = $deptId;
|
||||||
$row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : '';
|
$row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : '';
|
||||||
|
$row['dept_path'] = $deptId > 0 ? (string) ($deptPathMap[$deptId] ?? '') : '';
|
||||||
}
|
}
|
||||||
unset($row);
|
unset($row);
|
||||||
|
|
||||||
@@ -154,27 +199,44 @@ trait PersonalStatsScopeTrait
|
|||||||
return in_array($creatorId, $visibleIds, true);
|
return in_array($creatorId, $visibleIds, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function isYejiDuplicate(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): bool
|
/**
|
||||||
|
* 同一天 + 同一渠道全局唯一(不分录入人):避免重复汇总。
|
||||||
|
* 命中时返回首个已存在记录的录入人姓名,便于前端提示。
|
||||||
|
*
|
||||||
|
* @return array{conflict: bool, creator_name: string}
|
||||||
|
*/
|
||||||
|
protected static function findYejiConflict(string $yejiDate, string $mediaSource, int $excludeId = 0): array
|
||||||
{
|
{
|
||||||
$query = PersonalYeji::where('creator_id', $creatorId)
|
$query = PersonalYeji::where('yeji_date', $yejiDate)
|
||||||
->where('yeji_date', $yejiDate)
|
|
||||||
->where('media_source', $mediaSource);
|
->where('media_source', $mediaSource);
|
||||||
if ($excludeId > 0) {
|
if ($excludeId > 0) {
|
||||||
$query->where('id', '<>', $excludeId);
|
$query->where('id', '<>', $excludeId);
|
||||||
}
|
}
|
||||||
|
$row = $query->field('id, creator_name')->find();
|
||||||
|
if (!$row) {
|
||||||
|
return ['conflict' => false, 'creator_name' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
return $query->count() > 0;
|
return ['conflict' => true, 'creator_name' => (string) ($row['creator_name'] ?? '')];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function isCostDuplicate(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): bool
|
/**
|
||||||
|
* 同一天 + 同一渠道全局唯一(不分录入人):避免重复汇总。
|
||||||
|
*
|
||||||
|
* @return array{conflict: bool, creator_name: string}
|
||||||
|
*/
|
||||||
|
protected static function findCostConflict(string $costDate, string $mediaSource, int $excludeId = 0): array
|
||||||
{
|
{
|
||||||
$query = PersonalAccountCost::where('creator_id', $creatorId)
|
$query = PersonalAccountCost::where('cost_date', $costDate)
|
||||||
->where('cost_date', $costDate)
|
|
||||||
->where('media_source', $mediaSource);
|
->where('media_source', $mediaSource);
|
||||||
if ($excludeId > 0) {
|
if ($excludeId > 0) {
|
||||||
$query->where('id', '<>', $excludeId);
|
$query->where('id', '<>', $excludeId);
|
||||||
}
|
}
|
||||||
|
$row = $query->field('id, creator_name')->find();
|
||||||
|
if (!$row) {
|
||||||
|
return ['conflict' => false, 'creator_name' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
return $query->count() > 0;
|
return ['conflict' => true, 'creator_name' => (string) ($row['creator_name'] ?? '')];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ class PersonalYejiLogic extends BaseLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::isYejiDuplicate($adminId, $yejiDate, $mediaSource)) {
|
$conflict = self::findYejiConflict($yejiDate, $mediaSource);
|
||||||
self::setError('该日期下该自媒体来源的业绩已存在,请直接编辑');
|
if ($conflict['conflict']) {
|
||||||
|
$by = $conflict['creator_name'] !== '' ? '(录入人:' . $conflict['creator_name'] . ')' : '';
|
||||||
|
self::setError('该日期下该渠道的业绩已存在' . $by . ',请直接编辑该记录,避免重复汇总');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace app\adminapi\logic\stats;
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\model\dict\DictData;
|
||||||
use app\common\model\stats\PersonalAccountCost;
|
use app\common\model\stats\PersonalAccountCost;
|
||||||
use app\common\model\stats\PersonalYeji;
|
use app\common\model\stats\PersonalYeji;
|
||||||
|
|
||||||
@@ -75,6 +76,15 @@ class SelfInputLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$summary = self::finalizeMetrics($summaryBase);
|
$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 [
|
return [
|
||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
@@ -85,39 +95,56 @@ class SelfInputLogic extends BaseLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'can_view_finance' => $canViewFinance,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从已录入的业绩/账户消耗中提取去重后的自媒体来源(与列表数据权限一致)。
|
* 财务相关字段(账户消耗 / 现金成本 / ROI):非豁免角色不可见,剔除字段避免被嗅探。
|
||||||
*
|
*
|
||||||
* @return string[]
|
* @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
|
public static function mediaSourceOptions(int $adminId, array $adminInfo): array
|
||||||
{
|
{
|
||||||
$effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, 0);
|
unset($adminId, $adminInfo);
|
||||||
if ($effectiveAdminIds === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$map = [];
|
$rows = DictData::where('type_value', 'channels')
|
||||||
foreach ([PersonalYeji::class, PersonalAccountCost::class] as $modelClass) {
|
->where('status', 1)
|
||||||
$query = $modelClass::where('media_source', '<>', '');
|
->order(['sort' => 'desc', 'id' => 'asc'])
|
||||||
if ($effectiveAdminIds !== null) {
|
->field('name, value')
|
||||||
$query->whereIn('creator_id', $effectiveAdminIds);
|
->select()
|
||||||
}
|
->toArray();
|
||||||
$rows = $query->group('media_source')->column('media_source');
|
|
||||||
foreach ($rows as $raw) {
|
|
||||||
$norm = self::normalizeMediaSource((string) $raw);
|
|
||||||
if ($norm !== '') {
|
|
||||||
$map[$norm] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$list = array_keys($map);
|
$list = [];
|
||||||
sort($list, SORT_STRING);
|
$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 $list;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS `zyt_personal_account_cost` (
|
|||||||
`update_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 '删除时间',
|
`delete_time` int(11) unsigned DEFAULT NULL COMMENT '删除时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_creator_date_media` (`creator_id`, `cost_date`, `media_source`),
|
UNIQUE KEY `uk_date_media` (`cost_date`, `media_source`),
|
||||||
KEY `idx_cost_date` (`cost_date`),
|
KEY `idx_cost_date` (`cost_date`),
|
||||||
KEY `idx_creator_id` (`creator_id`),
|
KEY `idx_creator_id` (`creator_id`),
|
||||||
KEY `idx_dept_id` (`dept_id`)
|
KEY `idx_dept_id` (`dept_id`)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `zyt_personal_yeji` (
|
|||||||
`update_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 '删除时间',
|
`delete_time` int(11) unsigned DEFAULT NULL COMMENT '删除时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_creator_date_media` (`creator_id`, `yeji_date`, `media_source`),
|
UNIQUE KEY `uk_date_media` (`yeji_date`, `media_source`),
|
||||||
KEY `idx_yeji_date` (`yeji_date`),
|
KEY `idx_yeji_date` (`yeji_date`),
|
||||||
KEY `idx_creator_id` (`creator_id`),
|
KEY `idx_creator_id` (`creator_id`),
|
||||||
KEY `idx_dept_id` (`dept_id`)
|
KEY `idx_dept_id` (`dept_id`)
|
||||||
|
|||||||
Reference in New Issue
Block a user