feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
1 parent
1b074576fe
commit
108bf87225
154 files changed
+9515
-645
No files matched your search
@@ -287,3 +287,9 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
- 预警徽标用后端 `warnings.total`(全部命中数),`items` 仅为展示上限内的子集。
|
||||
- `package.json` 新增依赖 `echarts@5.6.0`(MIT);type-check 与 build 通过(2026-10-19)。
|
||||
- e2e `e2e/role_home_flow.py`:普通用户登录落地、菜单隐藏、刷新保持、直连 `/dashboard` 与报表页回退;需 `E2E_ADMIN_PASSWORD`。
|
||||
|
||||
## 2026-10-30 正式核算入口
|
||||
|
||||
业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。
|
||||
|
||||
本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。
|
||||
@@ -20,6 +20,8 @@ export interface UserInfo {
|
||||
username: string
|
||||
real_name: string | null
|
||||
roles: string[]
|
||||
can_write_payroll?: boolean
|
||||
payroll_approval_enabled?: boolean
|
||||
last_login_time?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface LedgerRow {
|
||||
payment_weight: string | null
|
||||
payable_amount: string | null
|
||||
settlement_month: string
|
||||
settlement_id?: number | null
|
||||
is_probation: number
|
||||
missing_cost: number
|
||||
snapshot_json: Record<string, unknown> | null
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
export interface PayrollInput { id: number; version: number; source: string; fingerprint: string; data: Record<string, unknown> }
|
||||
export interface MonthState { month: string; allocation_locked: boolean; maker_name: string | null }
|
||||
export interface Ownership { shipment_item_id: number; personnel_id?: number; manager_id?: number; unit_cost: string }
|
||||
export interface CalculationResult { month: string; business_id: number; ledger_ids: number[]; performance: string; payable: string; start_payment_ok: boolean }
|
||||
const base = '/v1/payroll'
|
||||
export const getInput = (id: number): Promise<PayrollInput | null> => request({url: `${base}/businesses/${id}/input`})
|
||||
export const monthState = (month: string): Promise<MonthState> => request({url: `${base}/months/${month}`})
|
||||
export const saveInput = (id: number, data: {month: string; expected_version: number; evidence: string; opening_balance: string; history_confirmed: true; guidance_finalized: true; shipment_ownerships: Ownership[]}): Promise<{version: number}> => request({url: `${base}/businesses/${id}/input`, method: 'post', data})
|
||||
export const calculate = (id: number, month: string, reason?: string): Promise<CalculationResult> => request({url: `${base}/businesses/${id}/calculate`, method: 'post', data: {month, reason}})
|
||||
export const allocations = (ledgerId: number): Promise<PageData<Record<string, unknown>>> => request({url: `${base}/ledger/${ledgerId}/allocations`, params: {pageSize: 200}})
|
||||
export const allocationAdjustment = (data: {ledger_id: number; personnel_id: number; direction: number; amount: string; reason: string}): Promise<unknown> => request({url: `${base}/allocation-adjustments`, method: 'post', data})
|
||||
@@ -11,7 +11,7 @@ export const SETTLEMENT_STATUS = {
|
||||
|
||||
export function settlementStatusLabel(status: number): string {
|
||||
return (
|
||||
{ 1: '草稿', 2: '待审批', 3: '已审定(锁定)', 4: '已驳回' }[status] ?? String(status)
|
||||
{ 1: '草稿', 2: '待审批', 3: '已完成(锁定)', 4: '已驳回' }[status] ?? String(status)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Settlement {
|
||||
total_payable: string | null
|
||||
person_count: number
|
||||
approval_order_no: string | null
|
||||
approval_required: boolean
|
||||
submitter_name: string
|
||||
locked_at: string | null
|
||||
locked_by: string | null
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import service, { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
export type FactKind = 'PERSONNEL' | 'PRODUCT' | 'CUSTOMER' | 'GROUP' | 'GUIDANCE' | 'RECEIPT' | 'SHIPMENT' | 'HANDOVER' | 'MANAGER' | 'CORRECTION'
|
||||
export type WorkKind = 'MASTER_PREVIEW' | 'MASTER_APPLY' | 'CALC_PREVIEW' | 'BUSINESS_PREVIEW' | 'BUSINESS_APPLY'
|
||||
export interface Fact { id: number; kind: FactKind; fact_key: string; version: number; data: Record<string, unknown>; evidence: string; requested_by: number; create_time: string }
|
||||
export interface WorkRun { run_no: string; kind: WorkKind; status: string; input: Record<string, unknown>; summary: Record<string, unknown> | null; snapshot_hash: string | null; error_code: string | null; started_at: string | null; finished_at: string | null; create_time: string; request_key: string; formal_payroll_written: false }
|
||||
export interface WorkLine { line_no: number; entity_type: string; source_id: string; action: string; reason: string | null; data: Record<string, unknown> }
|
||||
export interface SourceChoice { source_id: string; source_no: string | null; name: string; parent_id: string; date: string }
|
||||
const base = '/v1/system/tplus'
|
||||
export function listFacts(params: {kind?: string; pageNum?: number; pageSize?: number}): Promise<PageData<Fact>> { return request({url: `${base}/facts`, params}) }
|
||||
export function saveFact(data: {kind: FactKind; fact_key: string; expected_version: number; evidence: string; data: Record<string, unknown>}): Promise<Fact> { return request({url: `${base}/facts`, method: 'post', data}) }
|
||||
export function factHistory(id: number): Promise<PageData<Fact>> { return request({url: `${base}/facts/${id}/history`, params: {pageSize: 200}}) }
|
||||
export function listWorks(params: {kind?: string; pageNum?: number; pageSize?: number}): Promise<PageData<WorkRun>> { return request({url: `${base}/work-runs`, params}) }
|
||||
export function createWork(data: {kind: WorkKind; request_key: string; entity_types?: string[]; source_ids?: string[]; preview_no?: string; replay_no?: string; month?: string; group_keys?: string[]}): Promise<WorkRun> { return request({url: `${base}/work-runs`, method: 'post', data}) }
|
||||
export function getWork(number: string): Promise<WorkRun> { return request({url: `${base}/work-runs/${encodeURIComponent(number)}`}) }
|
||||
export function workLines(number: string, params: {pageNum?: number; pageSize?: number; action?: string}): Promise<PageData<WorkLine>> { return request({url: `${base}/work-runs/${encodeURIComponent(number)}/lines`, params}) }
|
||||
export function sourceChoices(entity: string, search = ''): Promise<PageData<SourceChoice>> { return request({url: `${base}/source-records`, params: {entity, search, pageSize: 200}}) }
|
||||
export async function downloadReport(number: string): Promise<Blob> {
|
||||
const response = await service.get(`${base}/work-runs/${encodeURIComponent(number)}/report`, {responseType: 'blob'})
|
||||
return response.data as Blob
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export interface TPlusBatch {
|
||||
finished_at: string | null
|
||||
can_retry: boolean
|
||||
error_summary: string | null
|
||||
trigger_type?: 'MANUAL' | 'SCHEDULED' | 'AUTO_RETRY'
|
||||
automatic_attempt?: number
|
||||
}
|
||||
export interface TPlusDetail extends TPlusBatch { audit_result: AuditReport | null }
|
||||
export interface TPlusError {
|
||||
@@ -53,6 +55,19 @@ export interface TPlusOverview {
|
||||
compatibility_status: string
|
||||
business_import_enabled: boolean
|
||||
latest_audit: AuditReport | null
|
||||
automation?: AutomationStatus
|
||||
}
|
||||
export interface AutomationStatus {
|
||||
enabled: boolean
|
||||
weekday: number
|
||||
time: string
|
||||
timezone: string
|
||||
stop_requested: boolean
|
||||
worker_phase: string
|
||||
heartbeat_at: string | null
|
||||
last_success_at: string | null
|
||||
next_retry_at: string | null
|
||||
alerts: {code: string; message: string; batch_no?: string | null}[]
|
||||
}
|
||||
export interface BatchQuery { status?: TaskStatus; task_type?: TaskType; pageNum?: number; pageSize?: number }
|
||||
export interface ErrorQuery { entity_type?: EntityType; pageNum?: number; pageSize?: number }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { RouteRecordNormalized, Router } from 'vue-router'
|
||||
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
import { ROLE_ADMIN } from '@/utils/constants'
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,8 @@ export function isMenuRecord(record: RouteRecordNormalized): boolean {
|
||||
/** 当前角色是否可访问该路由(与守卫、菜单过滤同源) */
|
||||
export function canAccessRoute(record: RouteRecordNormalized, roles: string[]): boolean {
|
||||
if (record.meta.public) return false
|
||||
if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN)) return false
|
||||
if (record.meta.requiresPayrollApproval && !useUserStore().userInfo?.payroll_approval_enabled) return false
|
||||
if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN) && !(record.meta.allowPayrollMaker && useUserStore().canWritePayroll)) return false
|
||||
if (
|
||||
record.meta.allowedRoles &&
|
||||
!record.meta.allowedRoles.some((role) => roles.includes(role))
|
||||
|
||||
@@ -14,6 +14,8 @@ declare module 'vue-router' {
|
||||
title?: string
|
||||
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
|
||||
requiresAdmin?: boolean
|
||||
allowPayrollMaker?: boolean
|
||||
requiresPayrollApproval?: boolean
|
||||
/** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)——
|
||||
* 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */
|
||||
allowedRoles?: string[]
|
||||
@@ -67,7 +69,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/system/tplus',
|
||||
name: 'system-tplus',
|
||||
component: () => import('@/views/system/tplus/index.vue'),
|
||||
meta: { title: 'T+ 数据同步', icon: 'tplus', group: '系统管理', requiresAdmin: true, order: 10 },
|
||||
meta: { title: 'T+ 数据同步', icon: 'tplus', group: '系统管理', requiresAdmin: true, allowPayrollMaker: true, order: 10 },
|
||||
},
|
||||
{
|
||||
path: '/system/roles',
|
||||
@@ -119,7 +121,8 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'system-approvals',
|
||||
component: () => import('@/views/system/approvals/index.vue'),
|
||||
meta: {
|
||||
title: '审批管理',
|
||||
title: '审核记录',
|
||||
requiresPayrollApproval: true,
|
||||
icon: 'approvals',
|
||||
group: '系统管理',
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
@@ -295,7 +298,11 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
|
||||
if (to.meta.requiresPayrollApproval && !userStore.userInfo?.payroll_approval_enabled) {
|
||||
return firstAccessiblePath(router, userStore.roles)
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN) && !(to.meta.allowPayrollMaker && userStore.canWritePayroll)) {
|
||||
const home = firstAccessiblePath(router, userStore.roles)
|
||||
// 根路径/catch-all 的重定向不提示;用户手输受限 URL 才提示
|
||||
if (!to.redirectedFrom) ElMessage.error('需要管理员权限')
|
||||
|
||||
@@ -14,6 +14,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string>(getToken())
|
||||
const userInfo = ref<authApi.UserInfo | null>(null)
|
||||
|
||||
const canWritePayroll = computed(() => userInfo.value?.can_write_payroll === true)
|
||||
const roles = computed<string[]>(() => userInfo.value?.roles ?? [])
|
||||
const displayName = computed<string>(() => userInfo.value?.real_name || userInfo.value?.username || '')
|
||||
|
||||
@@ -49,5 +50,5 @@ export const useUserStore = defineStore('user', () => {
|
||||
clearToken()
|
||||
}
|
||||
|
||||
return { token, userInfo, roles, displayName, login, fetchUserInfo, logout, resetAuth }
|
||||
return { token, userInfo, canWritePayroll, roles, displayName, login, fetchUserInfo, logout, resetAuth }
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -474,7 +477,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增客户</el-button>
|
||||
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
|
||||
</div>
|
||||
|
||||
@@ -558,13 +561,13 @@ onMounted(() => {
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
归属变更
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -639,7 +642,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -683,7 +686,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="ownershipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit">
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit" :disabled="!payrollUser.canWritePayroll">
|
||||
提交变更
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -421,7 +424,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增人员</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增人员</el-button>
|
||||
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
|
||||
</div>
|
||||
|
||||
@@ -468,15 +471,15 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="270" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openSubTables(row)">
|
||||
区域与计划
|
||||
</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
{{ row.status === 1 ? '设为离职' : '设为在职' }}
|
||||
</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -560,7 +563,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -571,13 +574,13 @@ onMounted(() => {
|
||||
<div class="sub-section-head">
|
||||
<span class="sub-section-title">辐射区域</span>
|
||||
<span>
|
||||
<el-button size="small" @click="addRegionRow">添加区域</el-button>
|
||||
<el-button size="small" @click="addRegionRow" :disabled="!payrollUser.canWritePayroll">添加区域</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="regionSaving"
|
||||
@click="saveRegions"
|
||||
>
|
||||
:disabled="!payrollUser.canWritePayroll">
|
||||
保存区域
|
||||
</el-button>
|
||||
</span>
|
||||
@@ -600,7 +603,7 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeRegionRow($index)">
|
||||
<el-button link type="danger" size="small" @click="removeRegionRow($index)" :disabled="!payrollUser.canWritePayroll">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -612,8 +615,8 @@ onMounted(() => {
|
||||
<div class="sub-section-head">
|
||||
<span class="sub-section-title">拓展计划</span>
|
||||
<span>
|
||||
<el-button size="small" @click="addPlanRow">添加计划</el-button>
|
||||
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans">
|
||||
<el-button size="small" @click="addPlanRow" :disabled="!payrollUser.canWritePayroll">添加计划</el-button>
|
||||
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans" :disabled="!payrollUser.canWritePayroll">
|
||||
保存计划
|
||||
</el-button>
|
||||
</span>
|
||||
@@ -641,7 +644,7 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removePlanRow($index)">
|
||||
<el-button link type="danger" size="small" @click="removePlanRow($index)" :disabled="!payrollUser.canWritePayroll">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -242,7 +245,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增产品</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增产品</el-button>
|
||||
<span class="toolbar-note">常规下架用「停用」,删除是物理删除、不可恢复</span>
|
||||
</div>
|
||||
|
||||
@@ -291,12 +294,12 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
{{ row.status === 1 ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -356,7 +359,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as payroll from '@/api/payroll'
|
||||
import { listPersonnel, type PersonnelItem } from '@/api/personnel'
|
||||
import { listShipments, getShipmentDetail } from '@/api/shipments'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
const open = defineModel<boolean>({required: true})
|
||||
const props = defineProps<{businessId: number}>()
|
||||
const user = useUserStore()
|
||||
const month = ref(''), opening = ref(''), evidence = ref(''), confirmed = ref(false)
|
||||
const busy = ref(false), loading = ref(false), failed = ref(false), locked = ref(true)
|
||||
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
|
||||
const people = ref<PersonnelItem[]>([])
|
||||
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string})[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true; failed.value = false; result.value = null
|
||||
try {
|
||||
input.value = await payroll.getInput(props.businessId)
|
||||
rows.value = []
|
||||
if (!input.value || input.value.source === 'MANUAL') {
|
||||
const [persons, ships] = await Promise.all([listPersonnel({pageSize: 200}), listShipments({business_id: props.businessId, pageSize: 200})])
|
||||
if (persons.total > 200 || ships.total > 200) throw new Error('资料超过单次处理范围,请按业务拆分后处理')
|
||||
people.value = persons.list
|
||||
const details = await Promise.all(ships.list.map(s => getShipmentDetail(s.id)))
|
||||
rows.value = details.flatMap(s => s.items.map(i => ({shipment_item_id: i.id, name: `${s.shipment_no} · ${i.product_name}`, ship_date: s.ship_date, amount: i.amount, unit_cost: ''})))
|
||||
if (input.value) {
|
||||
const data = input.value.data as {group?: {opening_balance?: string}; shipments?: {line_id: string; personnel_id: string; manager_id: string; cost_total: string; quantity: string; unit_cost?: string}[]; evidence?: string}
|
||||
opening.value = data.group?.opening_balance ?? ''
|
||||
evidence.value = data.evidence ?? ''
|
||||
for (const row of rows.value) {
|
||||
const previous = data.shipments?.find(s => s.line_id === String(row.shipment_item_id))
|
||||
if (previous) { row.personnel_id = Number(previous.personnel_id); row.manager_id = Number(previous.manager_id); row.unit_cost = previous.unit_cost ?? '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (month.value) await checkMonth()
|
||||
} catch (e) { failed.value = true; handledError(e) } finally { loading.value = false }
|
||||
}
|
||||
async function checkMonth(): Promise<void> {
|
||||
locked.value = true
|
||||
if (!month.value) return
|
||||
try { locked.value = (await payroll.monthState(month.value)).allocation_locked } catch (e) { handledError(e) }
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (!month.value || !confirmed.value || !opening.value || !evidence.value.trim() || rows.value.some(r => !r.personnel_id || !r.manager_id || !r.unit_cost)) {
|
||||
ElMessage.warning('请填写月份、期初、依据及每条发货的人员、经理、指导成本,并核实历史承接'); return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await payroll.saveInput(props.businessId, {month: month.value, expected_version: input.value?.version ?? 0,
|
||||
evidence: evidence.value.trim(), opening_balance: opening.value, history_confirmed: true, guidance_finalized: true,
|
||||
shipment_ownerships: rows.value.map(({shipment_item_id, personnel_id, manager_id, unit_cost}) => ({shipment_item_id, personnel_id, manager_id, unit_cost}))})
|
||||
input.value = await payroll.getInput(props.businessId)
|
||||
ElMessage.success('核算资料已保存为新版本')
|
||||
} catch (e) { handledError(e) } finally { busy.value = false }
|
||||
}
|
||||
async function run(): Promise<void> {
|
||||
if (!month.value) { ElMessage.warning('请选择核算月份'); return }
|
||||
try { await ElMessageBox.confirm('本次将写入正式绩效台账。请确认资料已核对,尚未生成工资。', '正式核算', {type: 'warning'}) } catch { return }
|
||||
busy.value = true
|
||||
try { result.value = await payroll.calculate(props.businessId, month.value); ElMessage.success('正式核算完成') }
|
||||
catch (e) { handledError(e) } finally { busy.value = false }
|
||||
}
|
||||
watch(open, value => { if (value) { month.value = ''; opening.value = ''; evidence.value = ''; confirmed.value = false; locked.value = true; void load() } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="open" title="正式工资核算" width="min(1100px, 96vw)">
|
||||
<div v-loading="loading">
|
||||
<el-alert v-if="failed" title="资料加载失败,请重试。" type="error" :closable="false" /><el-button v-if="failed" @click="load">重新加载</el-button>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="核算月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" @change="checkMonth" /></el-form-item>
|
||||
<el-alert v-if="month && locked" title="该月工资已生成,分配方案已锁定;差额请走当月调整单。" type="warning" :closable="false" />
|
||||
<p>资料版本:{{ input?.version ?? '尚未准备' }};来源:{{ input?.source === 'MANUAL' ? '人工核实' : input ? '源单据导入' : '—' }}</p>
|
||||
<template v-if="!failed && (!input || input.source === 'MANUAL')">
|
||||
<el-form-item label="2026 年 2 月末期初应收(元,核实为零时填写 0)"><el-input v-model="opening" :disabled="!user.canWritePayroll || locked" /></el-form-item>
|
||||
<el-form-item label="核对依据及修改原因"><el-input v-model="evidence" :disabled="!user.canWritePayroll || locked" maxlength="500" /></el-form-item>
|
||||
<el-table :data="rows" empty-text="请先登记发货及产品明细">
|
||||
<el-table-column prop="name" label="发货明细" min-width="190" /><el-table-column prop="amount" label="不含运费销售额" width="130" />
|
||||
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!user.canWritePayroll || locked" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :disabled="!user.canWritePayroll || locked" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="150"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!user.canWritePayroll || locked" aria-label="含税单位指导成本" /></template></el-table-column>
|
||||
</el-table>
|
||||
<p><el-checkbox v-model="confirmed" :disabled="!user.canWritePayroll || locked">已核实期初与历史处理范围,发货月份指导成本录入完毕并封存</el-checkbox></p>
|
||||
<el-button :disabled="!user.canWritePayroll || locked || failed" :loading="busy" @click="save">保存核算资料</el-button>
|
||||
</template>
|
||||
<el-button type="primary" :disabled="!user.canWritePayroll || locked || failed || !input" :loading="busy" @click="run">生成正式台账</el-button>
|
||||
</el-form>
|
||||
<el-alert v-if="result" :title="`计提 ${result.performance} 元,本期应付 ${result.payable} 元;${result.start_payment_ok ? '已达到起付门槛' : '尚未达到起付门槛'}`" type="success" :closable="false" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -30,6 +31,10 @@ import { handledError } from '@/utils/error'
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
import PayrollDialog from './PayrollDialog.vue'
|
||||
const payrollUser = useUserStore()
|
||||
const payrollOpen = ref(false), payrollBusinessId = ref(0)
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
const filters = reactive({
|
||||
@@ -146,7 +151,7 @@ const form = reactive({
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// ==================== 二次分配方案(合同刚建立时可录入;不强制合计 100%) ====================
|
||||
// ==================== 历史比例方案(新核算按发货归属)(合同刚建立时可录入;不强制合计 100%) ====================
|
||||
|
||||
interface AllocationDraft {
|
||||
personnel_id: number | undefined
|
||||
@@ -156,32 +161,6 @@ interface AllocationDraft {
|
||||
|
||||
const allocationRows = ref<AllocationDraft[]>([])
|
||||
|
||||
/** 合计(0-999.99 不做上限:需求原文不要求合计 100%);余额 = 100 - 合计 */
|
||||
const allocationTotal = computed(() => {
|
||||
let total = 0
|
||||
for (const row of allocationRows.value) {
|
||||
const value = Number(row.ratio)
|
||||
if (Number.isFinite(value)) total += value
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const allocationHint = computed(() => {
|
||||
if (allocationRows.value.length === 0) return ''
|
||||
const total = Number(allocationTotal.value.toFixed(2))
|
||||
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
|
||||
const rest = Number((100 - total).toFixed(2))
|
||||
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归负责人)`
|
||||
})
|
||||
|
||||
function addAllocationRow(): void {
|
||||
allocationRows.value.push({ personnel_id: undefined, ratio: '' })
|
||||
}
|
||||
|
||||
function removeAllocationRow(index: number): void {
|
||||
allocationRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 分配行校验:人员非重、比例 0-100 两位小数;空行(未选人员且比例为 0)自动忽略 */
|
||||
function validateAllocationRows(rows: AllocationDraft[]): string | null {
|
||||
const active = rows.filter((r) => r.personnel_id !== undefined || Number(r.ratio) > 0)
|
||||
@@ -669,7 +648,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增业务</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增业务</el-button>
|
||||
<span class="toolbar-note">状态流转与归属调整走行内操作,每次变更都写留痕</span>
|
||||
</div>
|
||||
|
||||
@@ -731,6 +710,7 @@ onMounted(() => {
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="payrollBusinessId = row.id; payrollOpen = true">正式核算</el-button>
|
||||
<el-tooltip
|
||||
content="已完成或已终止的业务禁止编辑"
|
||||
:disabled="row.status === 1"
|
||||
@@ -741,7 +721,7 @@ onMounted(() => {
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="row.status !== 1"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.status !== 1)"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
@@ -754,18 +734,18 @@ onMounted(() => {
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="row.status !== 1"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.status !== 1)"
|
||||
@click="openStatus(row)"
|
||||
>
|
||||
状态
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)" :disabled="!payrollUser.canWritePayroll">
|
||||
归属
|
||||
</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -876,61 +856,16 @@ onMounted(() => {
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<!-- 二次分配方案:合同刚建立时可录入(编辑为全量替换);不强制合计 100% -->
|
||||
<div class="items-head">
|
||||
<span class="items-title">
|
||||
二次分配方案
|
||||
<span class="alloc-note">(可选;各参与人比例不要求合计 100%,余额归负责人)</span>
|
||||
</span>
|
||||
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
||||
</div>
|
||||
<el-table :data="allocationRows" stripe size="small" class="items-table alloc-table">
|
||||
<el-table-column label="参与业务员" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.personnel_id"
|
||||
placeholder="选择参与人"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in personnelOptions"
|
||||
:key="option.id"
|
||||
:label="personnelLabel(option)"
|
||||
:value="option.id"
|
||||
:disabled="option.status !== 1"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分配比例(%)" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.ratio"
|
||||
placeholder="两位小数,0-100"
|
||||
maxlength="6"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeAllocationRow($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="alloc-empty">未设置二次分配 —— 该业务的全部绩效归负责营销人员。</span>
|
||||
</template>
|
||||
<div v-if="allocationRows.length" class="items-head"><span class="items-title">历史比例方案(仅供查看)</span></div>
|
||||
<el-table v-if="allocationRows.length" :data="allocationRows" size="small">
|
||||
<el-table-column label="原参与人员"><template #default="{row}">{{ personnelOptions.find(p => p.id === row.personnel_id)?.name ?? row.personnel_id }}</template></el-table-column>
|
||||
<el-table-column prop="ratio" label="原分配比例(%)" />
|
||||
</el-table>
|
||||
<div v-if="allocationRows.length > 0" class="alloc-total" :class="{ 'is-full': allocationTotal === 100 }">
|
||||
{{ allocationHint }}
|
||||
</div>
|
||||
<p class="page-desc">新核算按各人发货对应绩效分配,请使用“正式核算”维护发货归属。历史比例不参与新核算。</p>
|
||||
|
||||
<div class="items-head">
|
||||
<span class="items-title">产品明细(至少 1 行,编辑为全量替换)</span>
|
||||
<el-button size="small" @click="addItemRow">添加一行</el-button>
|
||||
<el-button size="small" @click="addItemRow" :disabled="!payrollUser.canWritePayroll">添加一行</el-button>
|
||||
</div>
|
||||
<el-table :data="itemRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="产品名称" min-width="170">
|
||||
@@ -978,7 +913,7 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -990,7 +925,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -1019,7 +954,7 @@ onMounted(() => {
|
||||
<div class="op-warn">状态流转后终态不可再变更;变更将写入状态流转时间线。</div>
|
||||
<template #footer>
|
||||
<el-button @click="statusVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="statusSubmitting" @click="handleStatusSubmit">
|
||||
<el-button type="primary" :loading="statusSubmitting" @click="handleStatusSubmit" :disabled="!payrollUser.canWritePayroll">
|
||||
确认变更
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -1076,7 +1011,7 @@ onMounted(() => {
|
||||
<div class="op-warn">归属变更仅影响变更后核算,不回溯历史。</div>
|
||||
<template #footer>
|
||||
<el-button @click="ownershipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit">
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit" :disabled="!payrollUser.canWritePayroll">
|
||||
提交调整
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -1182,7 +1117,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-title">二次分配方案(含历史版本)</div>
|
||||
<div class="sub-section-title">历史比例方案(新核算按发货归属)(含历史版本)</div>
|
||||
<el-table
|
||||
v-if="detail && (detail.allocation_history?.length ?? 0) > 0"
|
||||
:data="detail.allocation_history"
|
||||
@@ -1370,6 +1305,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
<PayrollDialog v-model="payrollOpen" :business-id="payrollBusinessId" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -313,7 +316,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增回款</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增回款</el-button>
|
||||
<span class="toolbar-note">已核算的回款不可变更;批次一旦指定,回款天数即按发货日派生</span>
|
||||
</div>
|
||||
|
||||
@@ -385,7 +388,7 @@ onMounted(() => {
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="row.settlement_status === 1"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.settlement_status === 1)"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
@@ -403,7 +406,7 @@ onMounted(() => {
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="row.settlement_status === 1"
|
||||
:disabled="!payrollUser.canWritePayroll || (row.settlement_status === 1)"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
@@ -496,7 +499,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
@@ -321,7 +324,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增发货单</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!payrollUser.canWritePayroll">新增发货单</el-button>
|
||||
<span class="toolbar-note">发货明细与运费在弹窗内维护,提交后自动回写业务发货额</span>
|
||||
</div>
|
||||
|
||||
@@ -366,9 +369,9 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)" :disabled="!payrollUser.canWritePayroll">编辑</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -430,7 +433,7 @@ onMounted(() => {
|
||||
|
||||
<div class="items-head">
|
||||
<span class="items-title">发货明细(至少 1 行,编辑为全量替换)</span>
|
||||
<el-button size="small" @click="addItemRow">添加一行</el-button>
|
||||
<el-button size="small" @click="addItemRow" :disabled="!payrollUser.canWritePayroll">添加一行</el-button>
|
||||
</div>
|
||||
<el-table :data="itemRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="产品名称" min-width="180">
|
||||
@@ -460,7 +463,7 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeItemRow($index)" :disabled="!payrollUser.canWritePayroll">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -472,7 +475,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!payrollUser.canWritePayroll">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import * as calcApi from '@/api/calc'
|
||||
import * as payrollApi from '@/api/payroll'
|
||||
import type { LedgerRow } from '@/api/calc'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
@@ -40,6 +44,25 @@ function resetFilters(): void {
|
||||
const recalcVisible = ref(false)
|
||||
const recalcTarget = ref<LedgerRow | null>(null)
|
||||
const recalcReason = ref('')
|
||||
const allocationVisible = ref(false), allocationLoading = ref(false)
|
||||
const allocationRows = ref<Record<string, unknown>[]>([])
|
||||
const adjustmentOpen = ref(false), adjustmentBusy = ref(false)
|
||||
const adjustment = reactive({ledger_id: 0, personnel_id: 0, direction: 1, amount: '', reason: ''})
|
||||
function openAdjustment(row: LedgerRow): void {
|
||||
Object.assign(adjustment, {ledger_id: row.id, personnel_id: row.personnel_id, direction: 1, amount: '', reason: ''})
|
||||
adjustmentOpen.value = true
|
||||
}
|
||||
async function saveAdjustment(): Promise<void> {
|
||||
if (!adjustment.amount || !adjustment.reason.trim()) { ElMessage.warning('请填写差额和调整依据'); return }
|
||||
adjustmentBusy.value = true
|
||||
try { await payrollApi.allocationAdjustment(adjustment); ElMessage.success('调整单已生成,差额归生成当月'); adjustmentOpen.value = false }
|
||||
catch (e) { handledError(e) } finally { adjustmentBusy.value = false }
|
||||
}
|
||||
async function showAllocations(row: LedgerRow): Promise<void> {
|
||||
allocationVisible.value = true; allocationLoading.value = true; allocationRows.value = []
|
||||
try { allocationRows.value = (await payrollApi.allocations(row.id)).list }
|
||||
catch (e) { handledError(e) } finally { allocationLoading.value = false }
|
||||
}
|
||||
|
||||
function openRecalc(row: LedgerRow): void {
|
||||
// 聚合行(calc_type=2)receipt_id 为 NULL,无回款可重算(按钮已限量,防御)
|
||||
@@ -58,7 +81,8 @@ async function submitRecalc(): Promise<void> {
|
||||
}
|
||||
try {
|
||||
// 重算接口按回款 ID 定位(台账行主键 ≠ 回款 ID:传 row.id 会重算到错误回款)
|
||||
await calcApi.recalcReceipt(row.receipt_id, recalcReason.value.trim())
|
||||
if (row.snapshot_json?.engine === 'FIFO') await payrollApi.calculate(row.business_id, row.settlement_month, recalcReason.value.trim())
|
||||
else await calcApi.recalcReceipt(row.receipt_id, recalcReason.value.trim())
|
||||
ElMessage.success('重算完成(新版本已生效)')
|
||||
recalcVisible.value = false
|
||||
await load()
|
||||
@@ -165,16 +189,19 @@ async function submitRecalc(): Promise<void> {
|
||||
<span v-if="row.is_active === 0" class="ys-pill">已替代</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="210" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.is_active === 1 && row.calc_type === 1"
|
||||
link
|
||||
type="primary"
|
||||
@click="openRecalc(row)"
|
||||
:disabled="!payrollUser.canWritePayroll"
|
||||
>
|
||||
重算
|
||||
</el-button>
|
||||
<el-button v-if="row.snapshot_json?.engine === 'FIFO'" link type="primary" @click="showAllocations(row)">分配依据</el-button>
|
||||
<el-button v-if="row.settlement_id" link type="primary" :disabled="!payrollUser.canWritePayroll" @click="openAdjustment(row)">分配调整</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -205,10 +232,24 @@ async function submitRecalc(): Promise<void> {
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="recalcVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitRecalc">确认重算</el-button>
|
||||
<el-button type="primary" @click="submitRecalc" :disabled="!payrollUser.canWritePayroll">确认重算</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<el-dialog v-model="allocationVisible" title="正式分配依据" width="min(1100px, 96vw)">
|
||||
<el-table v-loading="allocationLoading" :data="allocationRows" empty-text="暂无正式分配明细">
|
||||
<el-table-column prop="receipt_no" label="回款单" min-width="130" /><el-table-column prop="shipment_no" label="发货单" min-width="130" />
|
||||
<el-table-column prop="shipment_date" label="发货日期" width="110" /><el-table-column prop="receipt_date" label="到账日期" width="110" />
|
||||
<el-table-column prop="amount" label="本次分配金额" width="120" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" />
|
||||
<el-table-column prop="performance_delta" label="本次计提" width="100" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" />
|
||||
<el-table-column prop="input_id" label="资料版本编号" width="120" />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="adjustmentOpen" title="二次分配差额调整" width="min(560px, 94vw)">
|
||||
<el-alert title="原工资保持不变,补扣计入调整单生成当月,并沿用现有审批流程。" type="info" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="调整人员编号"><el-input-number v-model="adjustment.personnel_id" :min="1" /></el-form-item><el-form-item label="补扣方向"><el-select v-model="adjustment.direction"><el-option :value="1" label="补发" /><el-option :value="2" label="扣减" /></el-select></el-form-item><el-form-item label="差额(元)"><el-input v-model="adjustment.amount" /></el-form-item><el-form-item label="调整依据"><el-input v-model="adjustment.reason" type="textarea" maxlength="255" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="adjustmentOpen = false">取消</el-button><el-button type="primary" :disabled="!payrollUser.canWritePayroll" :loading="adjustmentBusy" @click="saveAdjustment">生成当月调整单</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
generateSettlement,
|
||||
getSettlement,
|
||||
listSettlements,
|
||||
settlementStatusLabel,
|
||||
submitSettlement,
|
||||
settlementStatusLabel,
|
||||
type Settlement,
|
||||
type SettlementDetail,
|
||||
} from '@/api/settlements'
|
||||
@@ -44,12 +47,13 @@ function resetFilters(): void {
|
||||
* 状态条自动跟随、点击状态条时下拉也自动跟随,不需要额外同步逻辑;
|
||||
* 复用 search() 意味着**不新增任何后端请求口径**。
|
||||
*/
|
||||
const FLOW_STEPS = [
|
||||
const ALL_FLOW_STEPS = [
|
||||
{ value: 1, label: '草稿', tone: 'draft' },
|
||||
{ value: 2, label: '待审批', tone: 'wait' },
|
||||
{ value: 3, label: '已审定(锁定)', tone: 'lock' },
|
||||
{ value: 3, label: '已完成(锁定)', tone: 'lock' },
|
||||
{ value: 4, label: '已驳回', tone: 'reject' },
|
||||
] as const
|
||||
const FLOW_STEPS = computed(() => payrollUser.userInfo?.payroll_approval_enabled ? ALL_FLOW_STEPS : ALL_FLOW_STEPS.filter(s => s.value === 3))
|
||||
|
||||
/** 点当前段再点一次即取消筛选(idle 态),避免「点了没反应」的死角 */
|
||||
function pickStatus(status: number): void {
|
||||
@@ -82,7 +86,7 @@ async function handleGenerate(): Promise<void> {
|
||||
generating.value = true
|
||||
try {
|
||||
await generateSettlement(generateMonth.value)
|
||||
ElMessage.success('月结已生成(草稿)')
|
||||
ElMessage.success('工资已生成并锁定,无需系统审批')
|
||||
await search()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
@@ -109,20 +113,10 @@ async function openDetail(row: Settlement): Promise<void> {
|
||||
}
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
async function handleSubmit(row: Settlement): Promise<void> {
|
||||
try {
|
||||
const out = await submitSettlement(row.id)
|
||||
ElMessage.success(
|
||||
out.status === 3 ? '审批通过,月结已锁定' : '已提交审批(待财务复核)',
|
||||
)
|
||||
detail.value = await getSettlement(row.id)
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
submitting.value = true
|
||||
try { await submitSettlement(row.id); ElMessage.success('已提交审核'); await load() }
|
||||
catch (e) { handledError(e) } finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -131,7 +125,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">月度结算</h1>
|
||||
<p class="page-desc">
|
||||
按月生成绩效结算批次;送审后锁定,人数与金额不再随源数据变化
|
||||
按月生成工资并锁定分配;审核功能默认关闭,完成后可直接查看和导出
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
@@ -163,7 +157,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
style="width: 160px"
|
||||
maxlength="7"
|
||||
/>
|
||||
<el-button type="primary" :loading="generating" @click="handleGenerate">
|
||||
<el-button type="primary" :loading="generating" @click="handleGenerate" :disabled="!payrollUser.canWritePayroll">
|
||||
生成月结快照
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -176,9 +170,9 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option label="草稿" :value="1" />
|
||||
<el-option label="待审批" :value="2" />
|
||||
<el-option label="已审定(锁定)" :value="3" />
|
||||
<el-option label="已驳回" :value="4" />
|
||||
<el-option v-if="payrollUser.userInfo?.payroll_approval_enabled" label="待审批" :value="2" />
|
||||
<el-option label="已完成(锁定)" :value="3" />
|
||||
<el-option v-if="payrollUser.userInfo?.payroll_approval_enabled" label="已驳回" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
@@ -212,13 +206,13 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<span class="cell-num">{{ row.person_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审批单号" min-width="140">
|
||||
<el-table-column v-if="payrollUser.userInfo?.payroll_approval_enabled" label="审批单号" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.approval_order_no" class="cell-code">{{ row.approval_order_no }}</span>
|
||||
<span v-else class="cell-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提交时间" min-width="150">
|
||||
<el-table-column label="制作时间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-muted">{{ formatDateTime(row.create_time) }}</span>
|
||||
</template>
|
||||
@@ -226,16 +220,8 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || row.status === 4"
|
||||
link
|
||||
type="success"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit(row)"
|
||||
>
|
||||
提交审批
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="row.approval_required && (row.status === 1 || row.status === 4) && payrollUser.userInfo?.payroll_approval_enabled" link type="success" :disabled="!payrollUser.canWritePayroll" :loading="submitting" @click="handleSubmit(row)">提交审核</el-button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -268,9 +254,8 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
审批人才能区分「没有这个字段」与「有字段但为空」。
|
||||
-->
|
||||
<dl class="kv">
|
||||
<dt>审批单号</dt>
|
||||
<dd>{{ detail.approval_order_no || '—' }}</dd>
|
||||
<dt>提交人</dt>
|
||||
<template v-if="detail.approval_order_no"><dt>历史审批单号</dt><dd>{{ detail.approval_order_no }}</dd></template>
|
||||
<dt>制作人</dt>
|
||||
<dd>{{ detail.submitter_name || '—' }}</dd>
|
||||
<dt>锁定人</dt>
|
||||
<dd>{{ detail.locked_by || '—' }}</dd>
|
||||
@@ -305,7 +290,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
||||
</el-table>
|
||||
|
||||
<p v-if="detail.status === 1 || detail.status === 4" class="drawer-hint">
|
||||
草稿/已驳回状态可重新生成快照后再次提交。
|
||||
审核默认关闭,工资生成后直接完成并锁定。开启审核时,草稿或已驳回记录可重新生成后提交。
|
||||
</p>
|
||||
<p v-else-if="detail.status === 3" class="drawer-hint">
|
||||
已锁定:源数据改动只产生提示与调整单。
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api/tplus-workbench'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { factKinds } from './workbench-labels'
|
||||
|
||||
const open = defineModel<boolean>({required: true})
|
||||
const props = defineProps<{fact: api.Fact | null}>()
|
||||
const emit = defineEmits<{saved: []}>()
|
||||
const kind = ref<api.FactKind>('PERSONNEL')
|
||||
const key = ref(''), evidence = ref(''), version = ref(0), saving = ref(false)
|
||||
const d = reactive<Record<string, unknown>>({})
|
||||
const shipments = ref(''), receipts = ref(''), excluded = ref('')
|
||||
const options = ref<api.SourceChoice[]>([])
|
||||
const sourceEntity: Partial<Record<api.FactKind, string>> = {PERSONNEL: 'personnel', PRODUCT: 'products', CUSTOMER: 'customers', RECEIPT: 'receipts', SHIPMENT: 'shipment_items', CORRECTION: 'receipts'}
|
||||
const ids = (value: string): string[] => value.split(/[\s,,]+/).map(v => v.trim()).filter(Boolean)
|
||||
async function search(value = ''): Promise<void> {
|
||||
const entity = sourceEntity[kind.value]
|
||||
if (!entity) { options.value = []; return }
|
||||
try { options.value = (await api.sourceChoices(entity, value)).list } catch (e) { handledError(e) }
|
||||
}
|
||||
function resetData(): void { for (const field of Object.keys(d)) delete d[field]; key.value = ''; version.value = 0; evidence.value = ''; shipments.value = ''; receipts.value = ''; excluded.value = ''; void search() }
|
||||
watch(kind, () => { if (!props.fact) resetData() })
|
||||
watch(open, value => {
|
||||
if (!value) return
|
||||
resetData()
|
||||
if (props.fact) {
|
||||
kind.value = props.fact.kind; key.value = props.fact.fact_key; version.value = props.fact.version
|
||||
evidence.value = props.fact.evidence; Object.assign(d, props.fact.data)
|
||||
shipments.value = (d.shipment_ids as string[] | undefined)?.join(',') ?? ''
|
||||
receipts.value = (d.receipt_ids as string[] | undefined)?.join(',') ?? ''
|
||||
excluded.value = (d.excluded_receipt_ids as string[] | undefined)?.join(',') ?? ''
|
||||
}
|
||||
void search()
|
||||
})
|
||||
function chosen(value: string): void {
|
||||
const item = options.value.find(v => v.source_id === value)
|
||||
if (kind.value === 'PERSONNEL' && item) { key.value = item.source_no ?? ''; d.code = key.value; d.name = item.name }
|
||||
else key.value = value
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (saving.value) return
|
||||
if (!key.value || !evidence.value.trim()) { ElMessage.warning('请填写资料标识和确认依据'); return }
|
||||
const data = {...d}
|
||||
if (kind.value === 'GUIDANCE' && d.basis === 'UNIT') delete data.shipment_line_id
|
||||
if (kind.value === 'GROUP') data.guidance_month_basis = 'SHIPMENT'
|
||||
if (kind.value === 'GROUP' && !d.prepayment_parameter_basis) data.prepayment_parameter_basis = null
|
||||
if (kind.value === 'PERSONNEL') { data.code = key.value; data.status = d.status ?? 1 }
|
||||
if (kind.value === 'GROUP') {
|
||||
Object.assign(data, {shipment_ids: ids(shipments.value), receipt_ids: ids(receipts.value), excluded_receipt_ids: ids(excluded.value),
|
||||
scope_confirmed: Boolean(d.scope_confirmed), opening_confirmed: Boolean(d.opening_confirmed),
|
||||
historical_settlement_confirmed: Boolean(d.historical_settlement_confirmed), august_confirmed: Boolean(d.august_confirmed)})
|
||||
}
|
||||
if (kind.value === 'RECEIPT') data.voucher_confirmed = Boolean(d.voucher_confirmed)
|
||||
if (kind.value === 'SHIPMENT') data.sales_amount_excludes_freight = Boolean(d.sales_amount_excludes_freight)
|
||||
if (kind.value === 'SHIPMENT' && data.freight_amount === '') data.freight_amount = null
|
||||
if (kind.value === 'HANDOVER') data.shipping_ownership_confirmed = Boolean(d.shipping_ownership_confirmed)
|
||||
saving.value = true
|
||||
try { await api.saveFact({kind: kind.value, fact_key: key.value, expected_version: version.value, evidence: evidence.value, data}); ElMessage.success('资料已保存,历史版本保留'); open.value = false; emit('saved') }
|
||||
catch (e) { handledError(e) } finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="open" title="核算补充资料" width="min(720px, 94vw)" :close-on-click-modal="false">
|
||||
<el-alert title="只登记已核对的事实;未明确的口径请留空,预演会列出待补原因。" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="fact-form" @submit.prevent="save">
|
||||
<el-form-item label="资料类型"><el-select v-model="kind" :disabled="!!fact" aria-label="资料类型"><el-option v-for="(label, value) in factKinds" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item v-if="sourceEntity[kind] && !fact" label="从已读取的源数据选择"><el-select filterable remote :remote-method="search" placeholder="按T+编号搜索;最多显示200条" aria-label="选择源数据" @change="chosen"><el-option v-for="item in options" :key="item.source_id" :value="item.source_id" :label="`${item.source_no ?? item.source_id} ${item.name} ${item.date}`" /></el-select></el-form-item>
|
||||
<el-form-item :label="kind === 'PERSONNEL' ? '人员工号(保留前导零)' : ['PRODUCT', 'CUSTOMER', 'RECEIPT', 'SHIPMENT', 'CORRECTION'].includes(kind) ? '对应源内部ID' : '资料标识(英文、数字或连字符)'"><el-input v-model="key" :disabled="!!fact" aria-label="资料标识" /></el-form-item>
|
||||
<template v-if="kind === 'PERSONNEL'">
|
||||
<el-form-item label="姓名"><el-input v-model="d.name" aria-label="姓名" /></el-form-item>
|
||||
<el-form-item label="岗位"><el-select v-model="d.role_code" aria-label="岗位"><el-option label="业务员" value="salesperson" /><el-option label="经理" value="sales_manager" /></el-select></el-form-item>
|
||||
<el-form-item label="在职状态"><el-select v-model="d.status" aria-label="在职状态"><el-option label="在职" :value="1" /><el-option label="离职" :value="0" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-if="kind === 'PRODUCT'">
|
||||
<el-form-item label="核算产品类型"><el-select v-model="d.category" aria-label="核算产品类型"><el-option label="市政水利" value="municipal_water" /><el-option label="轨道交通" value="rail_transit" /></el-select></el-form-item>
|
||||
<el-form-item label="与T+一致的计量单位"><el-input v-model="d.unit" aria-label="计量单位" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="kind === 'CUSTOMER'">
|
||||
<el-form-item label="绩效客户分类"><el-select v-model="d.customer_type" aria-label="绩效客户分类"><el-option label="个人户" value="personal" /><el-option label="经理个人户" value="manager_personal" /><el-option label="企业户" value="enterprise" /></el-select></el-form-item>
|
||||
<el-form-item label="客户公司负责人姓名"><el-input v-model="d.owner_name" aria-label="客户公司负责人" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="kind === 'GROUP'">
|
||||
<el-form-item label="T+客户内部ID"><el-input v-model="d.customer_id" aria-label="T+客户内部ID" /></el-form-item>
|
||||
<el-form-item label="纳入平账的发货单内部ID(逗号分隔)"><el-input v-model="shipments" type="textarea" aria-label="平账发货单" /></el-form-item>
|
||||
<el-form-item label="纳入平账的收款单内部ID(逗号分隔)"><el-input v-model="receipts" type="textarea" aria-label="平账收款单" /></el-form-item>
|
||||
<el-form-item label="截至2026年2月28日的未收金额(无余额填0)"><el-input v-model="d.opening_balance" aria-label="期初应收余额" /></el-form-item>
|
||||
<el-form-item label="其中已由旧软件核算或已发放的收款ID"><el-input v-model="excluded" type="textarea" aria-label="排除历史已核算款" /></el-form-item>
|
||||
<p class="page-desc">按同一 T+ 发货客户名头统一平账,不按合同拆组;成本固定取发货月份,计提参数按到账日取值。</p>
|
||||
<div class="checks"><el-checkbox v-model="d.scope_confirmed">本客户平账资料已核实</el-checkbox><el-checkbox v-model="d.opening_confirmed">期初应收已核实</el-checkbox><el-checkbox v-model="d.historical_settlement_confirmed">历史已核算及已发放款已核实</el-checkbox></div>
|
||||
</template>
|
||||
<template v-if="kind === 'GUIDANCE'">
|
||||
<el-form-item label="T+产品内部ID"><el-input v-model="d.product_id" aria-label="指导价产品" /></el-form-item>
|
||||
<el-form-item label="指导价月份"><el-date-picker v-model="d.month" type="month" value-format="YYYY-MM" aria-label="指导价月份" /></el-form-item>
|
||||
<el-form-item label="成本类型"><el-select v-model="d.basis" aria-label="成本类型"><el-option label="单位指导成本" value="UNIT" /><el-option label="指定发货明细的总成本" value="LINE_TOTAL" /></el-select></el-form-item>
|
||||
<el-form-item label="含税指导成本(元)"><el-input v-model="d.amount" aria-label="含税指导成本" /></el-form-item>
|
||||
<el-form-item label="计量单位"><el-input v-model="d.unit" aria-label="指导价单位" /></el-form-item>
|
||||
<el-form-item v-if="d.basis === 'LINE_TOTAL'" label="发货明细内部ID"><el-input v-model="d.shipment_line_id" aria-label="指导价发货明细" /></el-form-item>
|
||||
<el-checkbox v-model="d.is_finalized" :disabled="Boolean(props.fact?.data.is_finalized)">该发货月份指导成本已录入完毕并封存(封存后不能修改)</el-checkbox>
|
||||
</template>
|
||||
<template v-if="kind === 'RECEIPT'">
|
||||
<el-form-item label="会计凭证编号或核对记录"><el-input v-model="d.voucher_reference" aria-label="会计凭证依据" /></el-form-item>
|
||||
<el-checkbox v-model="d.voucher_confirmed">已核实本收款单生成了凭证(已审核不等于已生成凭证)</el-checkbox>
|
||||
</template>
|
||||
<template v-if="kind === 'SHIPMENT'">
|
||||
<el-form-item label="所属业务标识"><el-input v-model="d.business_key" aria-label="发货业务标识" /></el-form-item>
|
||||
<el-form-item label="该次发货归属人员工号"><el-input v-model="d.personnel_id" aria-label="发货归属工号" /></el-form-item>
|
||||
<el-checkbox v-model="d.sales_amount_excludes_freight">已核实该明细含税销售额已剔除运费</el-checkbox>
|
||||
<el-form-item v-if="!d.sales_amount_excludes_freight" label="该明细应扣除的运费(元;已核实无运费填0,未核实留空)"><el-input v-model="d.freight_amount" aria-label="发货明细运费" /></el-form-item>
|
||||
<p class="page-desc">发货日期取原销货单日期。日期更正办法尚未确认,本轮暂不开放修改。</p>
|
||||
</template>
|
||||
<template v-if="kind === 'CORRECTION'">
|
||||
<el-form-item label="更正对象"><el-select v-model="d.entity" aria-label="更正对象"><el-option label="发货更正单" value="SHIPMENT" /><el-option label="跨客户回款" value="RECEIPT" /></el-select></el-form-item>
|
||||
<el-form-item label="正负更正配对标识"><el-input v-model="d.pair_key" aria-label="更正配对标识" /></el-form-item>
|
||||
<template v-if="d.entity === 'RECEIPT'"><el-form-item label="归入原业务标识"><el-input v-model="d.redirect_business_key" aria-label="归入原业务标识" /></el-form-item></template>
|
||||
<el-checkbox v-else v-model="d.exclude_performance">保留源记录但排除绩效核算</el-checkbox>
|
||||
</template>
|
||||
<template v-if="kind === 'HANDOVER' || kind === 'MANAGER'">
|
||||
<el-form-item label="所属业务标识"><el-input v-model="d.business_key" aria-label="关系业务标识" /></el-form-item>
|
||||
<el-form-item label="生效日期"><el-date-picker v-model="d.effective_date" value-format="YYYY-MM-DD" aria-label="生效日期" /></el-form-item>
|
||||
<template v-if="kind === 'HANDOVER'">
|
||||
<el-form-item label="交接类型"><el-select v-model="d.mode" aria-label="交接类型"><el-option label="离职交接" value="RESIGNATION" /><el-option label="在职人员交接" value="ACTIVE" /></el-select></el-form-item>
|
||||
<el-form-item label="交出人员工号"><el-input v-model="d.from_personnel_id" aria-label="交出人员" /></el-form-item><el-form-item label="接收人员工号"><el-input v-model="d.to_personnel_id" aria-label="接收人员" /></el-form-item>
|
||||
<el-checkbox v-model="d.shipping_ownership_confirmed">已逐次核对交接前后发货归属,未按固定比例分摊</el-checkbox>
|
||||
</template>
|
||||
<el-form-item v-else label="分管经理工号"><el-input v-model="d.manager_id" aria-label="分管经理" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item label="确认依据与修改原因(必填)"><el-input v-model="evidence" type="textarea" :rows="3" maxlength="500" aria-label="确认依据与修改原因" placeholder="记录确认人、日期、例单或更正原因;不要填写账号密码" /></el-form-item>
|
||||
<p v-if="fact" class="page-desc">正在编辑第 {{ version }} 版,保存后新增版本,旧内容保留。</p>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="open = false">取消</el-button><el-button type="primary" :loading="saving" @click="save" :disabled="!payrollUser.canWritePayroll">保存资料</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fact-form { margin-top: 16px; } .checks { display: grid; gap: 8px; margin-bottom: 16px; }
|
||||
.el-select, .el-date-editor { width: 100%; } .el-checkbox { white-space: normal; height: auto; line-height: 1.6; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/user'
|
||||
const payrollUser = useUserStore()
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api/tplus-workbench'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import FactEditor from './FactEditor.vue'
|
||||
import { actions, factKinds, reasonText, workKinds } from './workbench-labels'
|
||||
|
||||
const tab = ref('import'), entities = ref(['personnel']), idsText = ref('')
|
||||
const month = ref(''), groupText = ref(''), creating = ref(false), failed = ref(false)
|
||||
const runs = ref<api.WorkRun[]>([]), facts = ref<api.Fact[]>([]), total = ref(0), page = ref(1)
|
||||
const factsTotal = ref(0), factPage = ref(1), factKind = ref<string>(), loading = ref(false)
|
||||
const editing = ref(false), editedFact = ref<api.Fact | null>(null)
|
||||
const current = ref<api.WorkRun | null>(null), lines = ref<api.WorkLine[]>([]), linePage = ref(1), linesTotal = ref(0)
|
||||
const details = ref(false), action = ref<string>(), history = ref<api.Fact[]>([]), historyOpen = ref(false)
|
||||
const requestKeys = new Map<string, string>()
|
||||
const split = (s: string): string[] => s.split(/[\s,,]+/).map(v => v.trim()).filter(Boolean)
|
||||
const counts = computed(() => (current.value?.summary?.counts ?? {}) as Record<string, number>)
|
||||
const summaries = computed(() => Object.entries((current.value?.summary?.groups ?? {}) as Record<string, Record<string, unknown>>).map(([key, v]) => ({key, ...v})))
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true; failed.value = false
|
||||
try {
|
||||
const [a, b] = await Promise.all([api.listWorks({pageNum: page.value, pageSize: 10}), api.listFacts({kind: factKind.value, pageNum: factPage.value, pageSize: 10})])
|
||||
runs.value = a.list; total.value = a.total; facts.value = b.list; factsTotal.value = b.total
|
||||
} catch (e) { failed.value = true; handledError(e) } finally { loading.value = false }
|
||||
}
|
||||
async function queue(kind: api.WorkKind, preview?: api.WorkRun): Promise<void> {
|
||||
if (creating.value) return
|
||||
if (kind === 'MASTER_PREVIEW' && !entities.value.length) { ElMessage.warning('请选择基础数据'); return }
|
||||
if (kind === 'CALC_PREVIEW' && (!month.value || !split(groupText.value).length)) { ElMessage.warning('请选择核算月份和平账组'); return }
|
||||
if ((kind === 'MASTER_APPLY' || kind === 'BUSINESS_APPLY')) {
|
||||
try { await ElMessageBox.confirm('将按预览导入资料或正式业务单据。冲突和待补行保留,工资台账不会写入。', '执行基础导入', {type: 'warning'}) } catch { return }
|
||||
}
|
||||
const input = (kind === 'MASTER_APPLY' || kind === 'BUSINESS_APPLY') ? {kind, preview_no: preview?.run_no}
|
||||
: kind === 'MASTER_PREVIEW' ? {kind, entity_types: entities.value, source_ids: split(idsText.value)}
|
||||
: {kind, month: month.value, group_keys: split(groupText.value)}
|
||||
const intent = JSON.stringify(input), key = requestKeys.get(intent) ?? crypto.randomUUID()
|
||||
requestKeys.set(intent, key); creating.value = true
|
||||
try { await api.createWork({...input, request_key: key}); requestKeys.delete(intent); ElMessage.success('任务已排队,由读取服务处理'); await refresh() }
|
||||
catch (e) { handledError(e) } finally { creating.value = false }
|
||||
}
|
||||
async function loadLines(): Promise<void> {
|
||||
if (!current.value) return
|
||||
try { const p = await api.workLines(current.value.run_no, {pageNum: linePage.value, pageSize: 20, action: action.value}); lines.value = p.list; linesTotal.value = p.total }
|
||||
catch (e) { handledError(e) }
|
||||
}
|
||||
async function show(run: api.WorkRun): Promise<void> {
|
||||
try { current.value = await api.getWork(run.run_no); linePage.value = 1; action.value = undefined; details.value = true; await loadLines() } catch (e) { handledError(e) }
|
||||
}
|
||||
function edit(fact: api.Fact | null): void { editedFact.value = fact; editing.value = true }
|
||||
async function showHistory(fact: api.Fact): Promise<void> {
|
||||
try { history.value = (await api.factHistory(fact.id)).list; historyOpen.value = true } catch (e) { handledError(e) }
|
||||
}
|
||||
async function exportReport(): Promise<void> {
|
||||
if (!current.value) return
|
||||
try { const blob = await api.downloadReport(current.value.run_no), url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = `${current.value.run_no}-核对报告.csv`; anchor.click(); URL.revokeObjectURL(url) } catch (e) { handledError(e) }
|
||||
}
|
||||
async function replay(): Promise<void> {
|
||||
if (!current.value || creating.value) return
|
||||
creating.value = true
|
||||
try { await api.createWork({kind: 'CALC_PREVIEW', replay_no: current.value.run_no, request_key: crypto.randomUUID()}); ElMessage.success('已按原快照排队重跑'); await refresh() }
|
||||
catch (e) { handledError(e) } finally { creating.value = false }
|
||||
}
|
||||
function value(data: Record<string, unknown>, key: string): string { return data[key] == null ? '—' : String(data[key]) }
|
||||
function rowName(data: Record<string, unknown>): string {
|
||||
const v = data.values as Record<string, unknown> | undefined
|
||||
return String(v?.personnel_name ?? v?.product_name ?? v?.company_name ?? data.source_name ?? data.receipt_no ?? '—')
|
||||
}
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card card-body workbench" aria-labelledby="workbench-title">
|
||||
<div class="section-head"><div><h2 id="workbench-title">基础导入与核算预演</h2><p class="page-desc">先核对资料和资金分配过程,预演不生成正式工资,不改变月结或已发放结果。</p></div><el-button :loading="loading" @click="refresh">刷新工作区</el-button></div>
|
||||
<el-alert v-if="failed" type="error" title="工作区加载失败,请确认数据库迁移已执行后刷新。" :closable="false" show-icon />
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="基础导入" name="import">
|
||||
<el-form label-position="top" class="create-form" @submit.prevent="queue('MASTER_PREVIEW')">
|
||||
<el-form-item label="本次预览的数据"><el-checkbox-group v-model="entities" aria-label="导入数据类型"><el-checkbox value="personnel">人员</el-checkbox><el-checkbox value="products">产品</el-checkbox><el-checkbox value="customers">客户</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item label="指定源内部ID(选填,逗号分隔;留空处理所选类型全部数据)"><el-input v-model="idsText" aria-label="指定导入范围" /></el-form-item>
|
||||
<el-button type="primary" :loading="creating" @click="queue('MASTER_PREVIEW')" :disabled="!payrollUser.canWritePayroll">生成导入预览</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="核算预演" name="calc">
|
||||
<el-alert title="平账范围、期初应收、历史已核算款和指导价等资料不足时,相应记录显示待补原因,不按零成本试算。" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="create-form" @submit.prevent="queue('CALC_PREVIEW')">
|
||||
<el-form-item label="核对月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" aria-label="核对月份" /></el-form-item>
|
||||
<el-form-item label="已登记的平账组标识(逗号分隔)"><el-input v-model="groupText" aria-label="预演平账组" placeholder="请先在补充资料中登记平账范围" /></el-form-item>
|
||||
<el-button type="primary" :loading="creating" @click="queue('CALC_PREVIEW')" :disabled="!payrollUser.canWritePayroll">生成核算预演</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="正式业务导入" name="business-import">
|
||||
<el-alert title="预览核实后导入正式业务、发货和回款;生成工资需另行正式核算。" type="info" :closable="false" />
|
||||
<el-form class="create-form" label-position="top"><el-form-item label="资料核对月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" /></el-form-item><el-form-item label="已核实的独立业务平账组"><el-input v-model="groupText" placeholder="填写组标识,以逗号或换行分隔" /></el-form-item><el-button type="primary" :disabled="!payrollUser.canWritePayroll" :loading="creating" @click="queue('BUSINESS_PREVIEW')">生成正式导入预览</el-button></el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="补充资料" name="facts">
|
||||
<div class="toolbar"><el-select v-model="factKind" clearable placeholder="全部资料" aria-label="筛选资料类型" @change="factPage = 1; refresh()"><el-option v-for="(label, key) in factKinds" :key="key" :value="key" :label="label" /></el-select><el-button type="primary" @click="edit(null)" :disabled="!payrollUser.canWritePayroll">新增补充资料</el-button></div>
|
||||
<el-table v-loading="loading" :data="facts" empty-text="暂无补充资料,请按客户确认结果填写。">
|
||||
<el-table-column label="资料类型" min-width="150"><template #default="{row}">{{ factKinds[row.kind as api.FactKind] }}</template></el-table-column><el-table-column prop="fact_key" label="标识" min-width="120" /><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据" min-width="230" /><el-table-column label="操作" min-width="130"><template #default="{row}"><el-button link type="primary" @click="edit(row)" :disabled="!payrollUser.canWritePayroll">查看 / 编辑</el-button><el-button link @click="showHistory(row)">历史</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="factsTotal" :page-num="factPage" :page-size="10" @page-change="factPage = $event; refresh()" @size-change="refresh" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<h3>工作区任务</h3>
|
||||
<el-table v-loading="loading" :data="runs" empty-text="暂无任务,可先生成基础导入预览。">
|
||||
<el-table-column label="任务" min-width="200"><template #default="{row}"><el-button link type="primary" @click="show(row)">{{ workKinds[row.kind as api.WorkKind] }}</el-button><p class="page-desc">{{ row.run_no }}</p></template></el-table-column>
|
||||
<el-table-column label="状态" min-width="115"><template #default="{row}">{{ actions[row.status] ?? row.status }}</template></el-table-column>
|
||||
<el-table-column label="创建时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column>
|
||||
<el-table-column label="异常原因" min-width="180"><template #default="{row}">{{ reasonText(row.error_code) || '—' }}</template></el-table-column>
|
||||
<el-table-column label="操作" min-width="170"><template #default="{row}"><el-button link type="primary" @click="show(row)">查看明细</el-button><el-button v-if="(row.kind === 'MASTER_PREVIEW' || row.kind === 'BUSINESS_PREVIEW') && ['SUCCEEDED','PARTIAL'].includes(row.status)" link type="primary" :disabled="!payrollUser.canWritePayroll || (creating)" @click="queue(row.kind === 'BUSINESS_PREVIEW' ? 'BUSINESS_APPLY' : 'MASTER_APPLY', row)">执行导入</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="total" :page-num="page" :page-size="10" @page-change="page = $event; refresh()" @size-change="refresh" />
|
||||
<FactEditor v-model="editing" :fact="editedFact" @saved="refresh" />
|
||||
<el-dialog v-model="details" title="处理结果与逐笔依据" width="min(1240px, 96vw)">
|
||||
<template v-if="current">
|
||||
<el-alert title="仅为导入或预演结果,未写入正式工资台账。" type="info" :closable="false" />
|
||||
<p class="fingerprint">输入指纹:{{ current.snapshot_hash ?? '尚未处理' }}</p>
|
||||
<div class="counts"><span v-for="(count, key) in counts" :key="key">{{ actions[key] ?? key }}:{{ count }}</span></div>
|
||||
<el-table v-if="summaries.length" :data="summaries"><el-table-column prop="key" label="平账组" min-width="100" /><el-table-column prop="receipt_total" label="纳入回款" min-width="110" /><el-table-column prop="excluded_receipt_total" label="未纳入回款" min-width="115" /><el-table-column prop="allocated_total" label="分配合计" min-width="110" /><el-table-column prop="opening_remaining" label="剩余旧应收" min-width="110" /><el-table-column prop="prepayment_remaining" label="预收待发货" min-width="110" /><el-table-column prop="performance_total" label="累计可核对计提" min-width="130" /><el-table-column prop="month_performance_total" label="本月可核对计提" min-width="140" /><el-table-column label="待补说明" min-width="180"><template #default="{row}">{{ reasonText(row.reasons) || '逐笔见下表;经理工资尚未生成' }}</template></el-table-column></el-table>
|
||||
<div class="toolbar"><el-select v-model="action" clearable placeholder="全部处理结果" aria-label="筛选处理结果" @change="linePage = 1; loadLines()"><el-option v-for="(label,key) in actions" :key="key" :value="key" :label="label" /></el-select><el-button @click="show(current)">刷新结果</el-button><el-button v-if="current.kind === 'CALC_PREVIEW' && ['SUCCEEDED', 'PARTIAL'].includes(current.status)" :loading="creating" @click="replay" :disabled="!payrollUser.canWritePayroll">按原快照重跑</el-button><el-button :disabled="!current.snapshot_hash" @click="exportReport">导出核对报告</el-button></div>
|
||||
<el-table :data="lines" empty-text="任务尚未处理或当前筛选没有记录">
|
||||
<el-table-column prop="source_id" label="源记录 / 平账组" min-width="130" /><el-table-column label="处理结果" min-width="100"><template #default="{row}">{{ actions[row.action] ?? row.action }}</template></el-table-column>
|
||||
<el-table-column label="名称 / 回款单" min-width="150"><template #default="{row}">{{ rowName(row.data) }}</template></el-table-column>
|
||||
<el-table-column label="发货单 / 明细" min-width="140"><template #default="{row}">{{ value(row.data, 'shipment_no') }} / {{ value(row.data, 'line_id') }}</template></el-table-column>
|
||||
<el-table-column v-for="[key,label] in [['accounting_voucher_code','关联凭证示例'],['amount','匹配金额'],['shipment_remaining_after','分配后该明细剩余应收'],['days','回款天数'],['allocated_cost','指导成本分摊'],['converted_profit','折算利润'],['profit_threshold','利润门槛(%)'],['rate_percent','计提比例'],['performance','计提金额'],['personnel_id','归属工号']]" :key="key" :label="label" min-width="110"><template #default="{row}">{{ value(row.data, key!) }}</template></el-table-column>
|
||||
<el-table-column label="待补或处理原因" min-width="220"><template #default="{row}">{{ reasonText(row.reason) || (row.data.excluded_manager_own ? '经理本人业务,已排除绩效汇总' : row.data.manager_pending ? '经理分管关系待补,不生成经理工资' : '—') }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="linesTotal" :page-num="linePage" :page-size="20" @page-change="linePage = $event; loadLines()" @size-change="loadLines" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="historyOpen" title="补充资料修改历史" width="min(850px, 94vw)"><el-table :data="history"><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据 / 修改原因" min-width="260" /><el-table-column prop="requested_by" label="操作人ID" width="110" /><el-table-column label="时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column></el-table></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench { margin-top: 24px; } .section-head, .toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
h2 { margin: 0 0 8px; font-size: 18px; } h3 { margin: 24px 0 12px; font-size: 16px; } .create-form { max-width: 720px; padding: 16px 0; }
|
||||
.toolbar { justify-content: flex-start; margin-top: 16px; flex-wrap: wrap; } .toolbar .el-select { width: 220px; } .fingerprint { overflow-wrap: anywhere; color: var(--ys-ink-2); font-size: 12px; } .counts { display: flex; gap: 24px; margin: 16px 0; flex-wrap: wrap; }
|
||||
.workbench :deep(.el-table .el-button + .el-button) { margin-left: 12px; }
|
||||
@media(max-width: 640px) { .section-head { align-items: flex-start; flex-direction: column; } .toolbar .el-select { width: 100%; } }
|
||||
</style>
|
||||
@@ -6,10 +6,17 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { newRequestKey } from '@/utils/uuid'
|
||||
import AuditResult from './AuditResult.vue'
|
||||
import WorkbenchPanel from './WorkbenchPanel.vue'
|
||||
import BatchDetailDialog from './BatchDetailDialog.vue'
|
||||
import CreateTaskDialog from './CreateTaskDialog.vue'
|
||||
import { scopeLabel, statuses } from './labels'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const user = useUserStore()
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const phases: Record<string, string> = {UNKNOWN: '尚无心跳记录', IDLE: '等待任务', RUNNING: '处理中', STOPPED: '已停止'}
|
||||
|
||||
const overview = ref<api.TPlusOverview | null>(null)
|
||||
const overviewFailed = ref(false)
|
||||
@@ -35,7 +42,7 @@ async function refresh(): Promise<void> {
|
||||
async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
if (retrying.value) return
|
||||
retrying.value = row.batch_no
|
||||
const key = retryKeys.get(row.batch_no) ?? crypto.randomUUID()
|
||||
const key = retryKeys.get(row.batch_no) ?? newRequestKey()
|
||||
retryKeys.set(row.batch_no, key)
|
||||
try {
|
||||
const batch = await api.retryTask(row.batch_no, key)
|
||||
@@ -60,11 +67,17 @@ onMounted(refresh)
|
||||
<div class="tplus-page">
|
||||
<header class="page-head">
|
||||
<div><h1 class="page-title">T+ 数据同步</h1><p class="page-desc">读取源数据、检查关联,并追踪每次处理结果。</p><div class="waterline" /></div>
|
||||
<div class="head-actions"><el-button :loading="refreshing" @click="refresh">刷新</el-button><el-button type="primary" :disabled="!overview?.is_configured || overviewFailed" @click="creating = true">新建任务</el-button></div>
|
||||
<div class="head-actions"><el-button :loading="refreshing" @click="refresh">刷新</el-button><el-button type="primary" :disabled="!user.canWritePayroll || !overview?.is_configured || overviewFailed" @click="creating = true">新建任务</el-button></div>
|
||||
</header>
|
||||
<el-alert type="warning" title="当前仅保存原始数据,未写入工资业务表。SQL Server 2008 R2 同版本验收及业务口径确认仍待完成。" :closable="false" show-icon />
|
||||
<el-alert type="warning" title="Docker 恢复库用于开发。基础数据先预览再导入;核算预演与正式工资隔离。旧版 SQL Server 兼容性在正式交付前另行验证。" :closable="false" show-icon />
|
||||
<el-alert v-if="overviewFailed" class="config-note" type="error" title="概况加载失败,请点击刷新重试" :closable="false" />
|
||||
<p v-else class="config-note" role="status">{{ overview?.configuration_message ?? '正在加载配置状态…' }}</p>
|
||||
<section v-if="overview?.automation" class="card card-body automation-status" aria-label="自动采集与运行状态">
|
||||
<div class="automation-heading"><h2>自动采集与运行状态</h2><el-tag :type="overview.automation.enabled ? 'success' : 'info'">{{ overview.automation.stop_requested ? '已请求停止' : overview.automation.enabled ? '自动排队已启用' : '自动排队未启用' }}</el-tag></div>
|
||||
<p class="page-desc">每周{{ weekdays[overview.automation.weekday] }} {{ overview.automation.time }}(北京时间)检查采集;正式导入和工资生成仍需制作人确认。</p>
|
||||
<dl class="automation-grid"><div><dt>采集进程</dt><dd>{{ phases[overview.automation.worker_phase] ?? '待检查' }}</dd></div><div><dt>最近心跳</dt><dd>{{ formatDateTime(overview.automation.heartbeat_at) }}</dd></div><div><dt>最近成功采集</dt><dd>{{ formatDateTime(overview.automation.last_success_at) }}</dd></div><div><dt>下次有限重试</dt><dd>{{ formatDateTime(overview.automation.next_retry_at) }}</dd></div></dl>
|
||||
<el-alert v-for="alert in overview.automation.alerts" :key="alert.code" class="automation-alert" :title="alert.message" type="warning" :closable="false" show-icon><el-button v-if="alert.batch_no" link type="primary" @click="detailNumber = alert.batch_no">查看相关任务</el-button></el-alert>
|
||||
</section>
|
||||
<div v-if="overview" class="task-counts" aria-label="任务数量概况"><span v-for="(label, key) in statuses" :key="key">{{ label }} <b class="cell-num">{{ (overview.status_counts[key] ?? 0).toLocaleString() }}</b></span><span>累计新增快照 <b>{{ overview.total_staged_records.toLocaleString() }}</b></span></div>
|
||||
<section class="batch-card" aria-label="同步批次">
|
||||
<div class="filter-bar">
|
||||
@@ -73,13 +86,13 @@ onMounted(refresh)
|
||||
</div>
|
||||
<div class="card card-body"><el-table v-loading="loading" :data="records" empty-text="暂无任务,可新建读取或关联检查" class="data-table">
|
||||
<el-table-column label="批次 / 创建时间" min-width="235"><template #default="{ row }"><el-button link type="primary" class="cell-code" @click="detailNumber = row.batch_no">{{ row.batch_no }}</el-button><p class="batch-time">{{ formatDateTime(row.create_time) }}</p></template></el-table-column>
|
||||
<el-table-column label="任务 / 范围" min-width="180"><template #default="{ row }"><b>{{ row.task_type === 'AUDIT' ? '关联检查' : '读取数据' }}</b><p class="batch-scope">{{ scopeLabel(row) }}</p></template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status as api.TaskStatus] }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="source_record_count" label="源记录" width="100" align="right" />
|
||||
<el-table-column prop="staged_record_count" label="新增" width="90" align="right" />
|
||||
<el-table-column prop="skipped_record_count" label="重复跳过" width="100" align="right" />
|
||||
<el-table-column prop="failed_record_count" label="异常" width="80" align="right" />
|
||||
<el-table-column label="操作" width="140" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="detailNumber = row.batch_no">查看</el-button><el-button v-if="row.can_retry" link type="primary" :disabled="!overview?.is_configured || overviewFailed || !!retrying" :loading="retrying === row.batch_no" @click="retry(row)">重试</el-button></template></el-table-column>
|
||||
<el-table-column label="任务 / 范围" min-width="180"><template #default="{ row }"><b>{{ row.task_type === 'AUDIT' ? '关联检查' : '读取数据' }}</b><el-tag v-if="row.trigger_type === 'SCHEDULED'" size="small">每周采集</el-tag><el-tag v-if="row.trigger_type === 'AUTO_RETRY'" size="small" type="warning">自动重试 {{ row.automatic_attempt }}/2</el-tag><p class="batch-scope">{{ scopeLabel(row) }}</p></template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status as api.TaskStatus] ?? row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="source_record_count" label="源记录" width="100" align="right" class-name="cell-num" />
|
||||
<el-table-column prop="staged_record_count" label="新增" width="90" align="right" class-name="cell-num" />
|
||||
<el-table-column prop="skipped_record_count" label="重复跳过" width="100" align="right" class-name="cell-num" />
|
||||
<el-table-column prop="failed_record_count" label="异常" width="80" align="right" class-name="cell-num" />
|
||||
<el-table-column label="操作" width="140" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="detailNumber = row.batch_no">查看</el-button><el-button v-if="row.can_retry" link type="primary" :disabled="!user.canWritePayroll || !overview?.is_configured || overviewFailed || !!retrying" :loading="retrying === row.batch_no" @click="retry(row)">重试</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pager-scroll"><AppPagination :total="total" :page-num="pageNum" :page-size="pageSize" @page-change="onPageChange" @size-change="onSizeChange" /></div></div>
|
||||
</section>
|
||||
@@ -88,6 +101,7 @@ onMounted(refresh)
|
||||
<AuditResult v-if="overview?.latest_audit" :report="overview.latest_audit" />
|
||||
<p v-else class="page-desc">暂无关联检查结果。新建“关联检查”任务后,可在此查看覆盖数量与待确认口径。</p>
|
||||
</section>
|
||||
<WorkbenchPanel />
|
||||
<CreateTaskDialog v-model="creating" :configured="!!overview?.is_configured && !overviewFailed" @created="created" />
|
||||
<BatchDetailDialog v-model="detailNumber" />
|
||||
</div>
|
||||
@@ -105,6 +119,13 @@ onMounted(refresh)
|
||||
.batch-time, .batch-scope { margin: 4px 0 0; color: var(--ys-ink-2); font-size: 12px; }
|
||||
.latest-audit { margin-top: 20px; }
|
||||
.latest-audit h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.automation-status { margin-top: 16px; }
|
||||
.automation-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.automation-heading h2 { font-size: 16px; margin: 0; }
|
||||
.automation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
||||
.automation-grid dt { color: var(--ys-ink-2); font-size: 12px; }
|
||||
.automation-grid dd { margin: 8px 0 0; font-variant-numeric: tabular-nums; }
|
||||
.automation-alert { margin-top: 8px; }
|
||||
.pager-scroll { overflow-x: auto; }
|
||||
@media (max-width: 640px) {
|
||||
.page-head { align-items: flex-start; gap: 16px; flex-direction: column; }
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export const factKinds = { PERSONNEL: '人员名单', PRODUCT: '产品分类与单位', CUSTOMER: '客户分类与负责人', GROUP: '平账范围与期初应收', GUIDANCE: '月度指导成本', RECEIPT: '收款凭证核实', SHIPMENT: '发货归属与运费核实', HANDOVER: '业务交接', MANAGER: '经理分管业务', CORRECTION: 'T+更正单处理' }
|
||||
export const workKinds = { MASTER_PREVIEW: '基础导入预览', MASTER_APPLY: '基础数据导入', CALC_PREVIEW: '核算预演', BUSINESS_PREVIEW: '正式业务导入预览', BUSINESS_APPLY: '正式业务导入' }
|
||||
export const actions: Record<string, string> = { EXCLUDED: '不纳入本次预演', CREATE: '新增', UPDATE: '更新', SKIP: '跳过', PENDING: '待补充', CONFLICT: '冲突', READY: '可核对', LEGACY: '旧应收冲平', WAITING_SHIPMENT: '预收待发货', QUEUED: '排队中', RUNNING: '处理中', SUCCEEDED: '处理完成', PARTIAL: '部分待处理', FAILED: '失败' }
|
||||
export const reasons: Record<string, string> = {
|
||||
CUSTOMER_SCOPE_CONFLICT: '同一客户的期初或历史范围不一致,请先合并核对资料',
|
||||
GUIDANCE_FINALIZATION_REQUIRED: '发货月份指导成本尚未录入完毕并封存',
|
||||
CUSTOMER_SCOPE_ALREADY_SELECTED: '该客户已纳入本次平账,重复选择已跳过',
|
||||
FORMAL_SOURCE_CHANGED: '源单据或依据与正式版本不同,请先核对差异',
|
||||
FORMAL_TARGET_CHANGED: '正式单据与已导入资料不一致,请先核对差异',
|
||||
SOURCE_ALREADY_IMPORTED: '该源单据已经导入,不能重复占用',
|
||||
CUSTOMER_IMPORT_REQUIRED: '请先完成客户基础导入',
|
||||
PRODUCT_IMPORT_REQUIRED: '请先完成产品基础导入',
|
||||
PERSONNEL_IMPORT_REQUIRED: '相关人员尚未在本账套唯一关联,请核对工号及基础导入',
|
||||
BUSINESS_DATA_PENDING: '该客户业务资料仍待补充,请查看对应预演结果',
|
||||
NOT_IN_PERSONNEL_ROSTER: '不在已确认人员名单中', PERSONNEL_IDENTITY_CONFLICT: '工号与名单姓名不一致',
|
||||
BUSINESS_ROLE_MISSING: '系统岗位不存在', PRODUCT_CATEGORY_PENDING: '产品类型待填写',
|
||||
PRODUCT_UNIT_PENDING: '产品单位待核对', NOT_CUSTOMER_ONLY: '往来单位性质不是仅客户',
|
||||
CUSTOMER_ATTRIBUTE_PENDING: '客户属性或负责人待核对', TARGET_MISSING: '原导入目标已不存在',
|
||||
TARGET_UNIQUE_CONFLICT: '与现有数据重名或唯一键冲突', DUPLICATE_SOURCE_IDENTITY: '源数据身份重复',
|
||||
ROSTER_SOURCE_MISSING: '名单中的工号未在T+找到', PREVIEW_STALE: '数据或资料已变化,请重新预览',
|
||||
SOURCE_REFRESH_REQUIRED: '请先完整读取相关源数据', SOURCE_CHANGED: '源账套配置已变化',
|
||||
INTERRUPTED: '任务被中断,请重新创建任务', EXECUTION_ERROR: '执行失败,请核对配置和输入',
|
||||
GROUP_SCOPE_PENDING: '平账范围待明确', OPENING_BALANCE_PENDING: '期初应收待核实',
|
||||
HISTORICAL_SETTLEMENT_PENDING: '历史已核算及已发放款待核实',
|
||||
VOUCHER_STATE_PENDING: '收款尚未核实已生成凭证', GUIDANCE_PENDING: '缺少对应指导成本',
|
||||
GUIDANCE_MONTH_BASIS_PENDING: '指导价取发货月还是回款月尚未明确',
|
||||
AUGUST_RULE_PENDING: '2026年8月后的调整参数尚未确认', PERSONNEL_PENDING: '归属人员待确认',
|
||||
CUSTOMER_CATEGORY_PENDING: '客户分类待确认', PARAMETERS_PENDING: '计提参数待补充',
|
||||
NEGATIVE_OR_ZERO_DOCUMENT: '负数或零金额单据需单独核对', NEGATIVE_EVENT_REVIEW_REQUIRED: '更正、红冲或作废业务需人工核对',
|
||||
LEGACY_SYSTEM_CALCULATES: '此款只冲旧应收,绩效由原软件计算', NO_ACTUAL_SHIPMENT: '尚无实际发货,不计提绩效',
|
||||
FREIGHT_PENDING: '含税销售额是否已剔除运费待核实', SHIPPING_OWNERSHIP_PENDING: '发货所属业务和人员待核实',
|
||||
HANDOVER_SHIPMENTS_PENDING: '在职交接的发货归属待确认', TAX_AMOUNT_PENDING: '源含税金额缺失',
|
||||
GROUP_SOURCE_MISSING: '所选单据未在本次完整快照中找到', GROUP_CUSTOMER_MISMATCH: '平账组包含其他客户的单据',
|
||||
SHIPMENT_DETAILS_MISSING: '发货明细不完整', OVERLAPPING_GROUPS: '单据被多个平账组重复选取',
|
||||
AMOUNT_PRECISION_PENDING: '金额精度超过分,需核对舍入口径', DATA_VALUE_INVALID: '源日期或金额格式需核对',
|
||||
REPLAY_UNAVAILABLE: '该记录缺少可重跑快照或规则版本已变化,请保留原报告并新建预演',
|
||||
QUANTITY_PENDING: '单位成本对应的发货数量需核对',
|
||||
GROUP_FACT_REQUIRED: '尚未登记该平账组',
|
||||
PREPAYMENT_PARAMETERS_PENDING: '预收业务采用发货日还是收款日参数待确认',
|
||||
HANDOVER_PENDING: '离职日期或接手依据待补,不能猜测归属',
|
||||
ACCOUNTING_VOUCHER_INVALID: '关联凭证缺失、已作废或制单状态异常',
|
||||
VOUCHER_EVIDENCE_CONFLICT: '凭证关联与人工核实不一致,请复核',
|
||||
NOT_GENERATED_VOUCHER: '无关联总账凭证,不纳入本次预演',
|
||||
NOT_RECEIPT: '属于付款单,不作为销售回款核算',
|
||||
SHIPMENT_STATE_PENDING: '销货单尚未核实为生效状态',
|
||||
CORRECTION_EXCLUDED: '已核对更正单,保留源记录但不参与绩效核算',
|
||||
REDIRECTED_RECEIPT: '回款已按核对结果归入原业务,当前客户不重复计提',
|
||||
}
|
||||
export function reasonText(value: unknown): string {
|
||||
const parts = Array.isArray(value) ? value : String(value ?? '').split(',')
|
||||
return parts.filter(Boolean).map(v => reasons[String(v)] ?? String(v)).join(';')
|
||||
}
|
||||
Reference in new issue
Block a user