增加档案功能
This commit is contained in:
@@ -37,6 +37,11 @@ export function updateEmployeeFile(id, data) {
|
||||
return request({ url: `${BASE}/update/${id}`, method: 'post', data });
|
||||
}
|
||||
|
||||
/** 更新已建档员工的学历照片、身份证正反面 */
|
||||
export function updateEmployeeFileCertificates(id, data) {
|
||||
return request({ url: `${BASE}/certificates/${id}`, method: 'post', data });
|
||||
}
|
||||
|
||||
/** 删除档案(软删除) */
|
||||
export function deleteEmployeeFile(id) {
|
||||
return request({ url: `${BASE}/delete/${id}`, method: 'delete' });
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 薪酬管理
|
||||
|
||||
export function getCompensationEmployees(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/employees',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getCompensationDashboard(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/dashboard',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getCompensationSchemes(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/schemes',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createCompensationScheme(data) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/schemes',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompensationScheme(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/schemes/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCompensationScheme(id) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/schemes/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function getPayrollList(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/payrolls',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getPayrollDetail(id) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createPayroll(data) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/payrolls',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePayroll(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePayroll(id) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePayrollStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}/status`,
|
||||
method: 'post',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
@@ -69,65 +69,6 @@ const staticMainChildren = [
|
||||
component: () => import("@/views/user/userProfile.vue"),
|
||||
meta: { requiresAuth: true, title: "用户中心" }
|
||||
},
|
||||
// 组织架构(组织 / 员工 / 职位)
|
||||
// 进销存(erp)与办公自动化(oa)各有独立页面,但读写同一份组织数据。
|
||||
{
|
||||
path: "/apps/erp/organization",
|
||||
name: "ErpOrganization",
|
||||
component: () => import("@/views/apps/erp/organization/index.vue"),
|
||||
meta: { requiresAuth: true, title: "组织架构", modulePath: "/apps/erp" }
|
||||
},
|
||||
{
|
||||
path: "/apps/erp/employee",
|
||||
name: "ErpEmployee",
|
||||
component: () => import("@/views/apps/erp/employee/index.vue"),
|
||||
meta: { requiresAuth: true, title: "员工管理", modulePath: "/apps/erp" }
|
||||
},
|
||||
{
|
||||
path: "/apps/erp/position",
|
||||
name: "ErpPosition",
|
||||
component: () => import("@/views/apps/erp/position/index.vue"),
|
||||
meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/erp" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/organization",
|
||||
name: "Organization",
|
||||
component: () => import("@/views/apps/organization/components/OrganizationPage.vue"),
|
||||
props: { module: "oa" },
|
||||
meta: { requiresAuth: true, title: "组织架构", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/employee",
|
||||
name: "Employee",
|
||||
component: () => import("@/views/apps/organization/components/EmployeePage.vue"),
|
||||
props: { module: "oa" },
|
||||
meta: { requiresAuth: true, title: "人员管理", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/position",
|
||||
name: "Position",
|
||||
component: () => import("@/views/apps/organization/components/PositionPage.vue"),
|
||||
props: { module: "oa" },
|
||||
meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/employeefile",
|
||||
name: "EmployeeFile",
|
||||
component: () => import("@/views/apps/oa/employeefile/index.vue"),
|
||||
meta: { requiresAuth: true, title: "员工档案", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/schedule",
|
||||
name: "OaSchedule",
|
||||
component: () => import("@/views/apps/oa/schedule/index.vue"),
|
||||
meta: { requiresAuth: true, title: "日程管理", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/tools/passwordStore",
|
||||
name: "BackendPasswordStore",
|
||||
component: () => import("@/views/tools/passwordStore/index.vue"),
|
||||
meta: { requiresAuth: true, title: "密码存储" }
|
||||
},
|
||||
// 兼容拼写错误的路径重定向
|
||||
{
|
||||
path: "/apps/erp/dashborad",
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<div class="compensation-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>薪酬管理</h2>
|
||||
<p>维护员工薪酬方案,核算、确认并发放月度工资</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Setting" @click="schemeVisible = true">薪酬方案</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增薪资单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-toolbar">
|
||||
<span>统计月份</span>
|
||||
<el-date-picker
|
||||
v-model="dashboardMonth"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
:clearable="false"
|
||||
style="width: 150px"
|
||||
@change="loadDashboard"
|
||||
/>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-label">核算员工</div>
|
||||
<div class="stat-value">{{ dashboard.employee_count || 0 }}</div>
|
||||
<div class="stat-sub">{{ dashboard.payroll_month || dashboardMonth }} 薪资单</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-label">应发工资</div>
|
||||
<div class="stat-value">¥{{ money(dashboard.total_gross_salary) }}</div>
|
||||
<div class="stat-sub">税前及各类补贴合计</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="stat-label">实发工资</div>
|
||||
<div class="stat-value">¥{{ money(dashboard.total_net_salary) }}</div>
|
||||
<div class="stat-sub">扣除五险一金及个税后</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="stat-label">待处理</div>
|
||||
<div class="stat-value">{{ (dashboard.draft_count || 0) + (dashboard.confirmed_count || 0) }}</div>
|
||||
<div class="stat-sub">草稿 {{ dashboard.draft_count || 0 }} / 待发放 {{ dashboard.confirmed_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="filters.payroll_month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="工资月份"
|
||||
clearable
|
||||
style="width: 145px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索员工姓名"
|
||||
clearable
|
||||
style="width: 190px"
|
||||
@keyup.enter="loadPayrolls"
|
||||
/>
|
||||
<el-select v-model="filters.status" placeholder="全部状态" clearable style="width: 125px">
|
||||
<el-option label="草稿" value="0" />
|
||||
<el-option label="已确认" value="1" />
|
||||
<el-option label="已发放" value="2" />
|
||||
<el-option label="已作废" value="3" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="listLoading" :data="payrolls" stripe>
|
||||
<el-table-column prop="payroll_month" label="工资月份" width="110" />
|
||||
<el-table-column prop="employee_name" label="员工" min-width="110" />
|
||||
<el-table-column prop="department" label="部门" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="position" label="职位" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="应发工资" width="130" align="right">
|
||||
<template #default="{ row }">¥{{ money(row.gross_salary) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="扣款合计" width="125" align="right">
|
||||
<template #default="{ row }">¥{{ money(row.total_deduction) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实发工资" width="135" align="right">
|
||||
<template #default="{ row }"><strong class="net-money">¥{{ money(row.net_salary) }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="update_time" label="更新时间" width="170">
|
||||
<template #default="{ row }">{{ dateTime(row.update_time || row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="250">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 0" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-dropdown v-if="row.status < 2" trigger="click" @command="status => changeStatus(row, status)">
|
||||
<el-button link type="primary">更多<el-icon class="more-icon"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="row.status === 0" :command="1">确认薪资单</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.status === 1" :command="2">标记为已发放</el-dropdown-item>
|
||||
<el-dropdown-item :command="3" divided>作废薪资单</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button v-if="row.status === 0" link type="danger" @click="removePayroll(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无薪资单,请先新增月度薪资单" /></template>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadPayrolls"
|
||||
@size-change="loadPayrolls"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="payrollVisible" :title="editingID ? '编辑薪资单' : '新增薪资单'" width="940px" top="4vh" destroy-on-close @closed="resetPayrollForm">
|
||||
<el-form ref="payrollFormRef" :model="payrollForm" :rules="payrollRules" label-width="94px">
|
||||
<div class="form-grid main-fields">
|
||||
<el-form-item label="工资月份" prop="payroll_month">
|
||||
<el-date-picker v-model="payrollForm.payroll_month" type="month" value-format="YYYY-MM" :clearable="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="员工" prop="employee_id">
|
||||
<el-select v-model="payrollForm.employee_id" filterable placeholder="选择员工" style="width: 100%" @change="onEmployeeChange">
|
||||
<el-option v-for="employee in employees" :key="employee.id" :label="employeeLabel(employee)" :value="employee.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="应用方案">
|
||||
<el-select v-model="selectedSchemeID" clearable placeholder="可选:套用已有薪酬方案" style="width: 100%" @change="applyScheme">
|
||||
<el-option v-for="scheme in matchingSchemes" :key="scheme.id" :label="scheme.scheme_name" :value="scheme.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="payrollForm.remark" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="salary-section">
|
||||
<div class="section-title"><span class="dot addition" />应发项目</div>
|
||||
<div class="salary-grid">
|
||||
<MoneyInput v-model="payrollForm.base_salary" label="基本工资" />
|
||||
<MoneyInput v-model="payrollForm.post_allowance" label="岗位津贴" />
|
||||
<MoneyInput v-model="payrollForm.performance_salary" label="绩效工资" />
|
||||
<MoneyInput v-model="payrollForm.transport_allowance" label="交通补贴" />
|
||||
<MoneyInput v-model="payrollForm.meal_allowance" label="餐补" />
|
||||
<MoneyInput v-model="payrollForm.communication_allowance" label="通讯补贴" />
|
||||
<MoneyInput v-model="payrollForm.overtime_pay" label="加班工资" />
|
||||
<MoneyInput v-model="payrollForm.bonus" label="奖金" />
|
||||
<MoneyInput v-model="payrollForm.other_addition" label="其他应发" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="salary-section">
|
||||
<div class="section-title"><span class="dot deduction" />扣款项目</div>
|
||||
<div class="salary-grid">
|
||||
<MoneyInput v-model="payrollForm.leave_deduction" label="请假扣款" />
|
||||
<MoneyInput v-model="payrollForm.late_deduction" label="迟到早退扣款" />
|
||||
<MoneyInput v-model="payrollForm.other_deduction" label="其他扣款" />
|
||||
<MoneyInput v-model="payrollForm.social_insurance" label="个人社保" />
|
||||
<MoneyInput v-model="payrollForm.housing_fund" label="个人公积金" />
|
||||
<MoneyInput v-model="payrollForm.personal_income_tax" label="个人所得税" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="salary-section custom-items">
|
||||
<div class="section-title">
|
||||
<span>自定义薪资项</span>
|
||||
<el-button type="primary" link :icon="Plus" @click="addItem">添加项目</el-button>
|
||||
</div>
|
||||
<div v-for="(item, index) in payrollForm.items" :key="item.key" class="custom-item">
|
||||
<el-select v-model="item.item_type" style="width: 105px">
|
||||
<el-option label="增项" :value="1" />
|
||||
<el-option label="扣项" :value="2" />
|
||||
</el-select>
|
||||
<el-input v-model="item.item_name" placeholder="项目名称,如:补发工资" style="width: 230px" />
|
||||
<el-input-number v-model="item.amount" :min="0" :precision="2" :controls="false" placeholder="金额" style="width: 140px" />
|
||||
<el-input v-model="item.remark" placeholder="备注(选填)" />
|
||||
<el-button text type="danger" :icon="Delete" @click="payrollForm.items.splice(index, 1)" />
|
||||
</div>
|
||||
<el-empty v-if="!payrollForm.items.length" :image-size="45" description="暂无自定义项目" />
|
||||
</div>
|
||||
|
||||
<div class="amount-summary">
|
||||
<div><span>应发合计</span><strong class="addition-text">¥{{ money(grossTotal) }}</strong></div>
|
||||
<div><span>扣款合计</span><strong class="deduction-text">¥{{ money(deductionTotal) }}</strong></div>
|
||||
<div class="net"><span>实发工资</span><strong>¥{{ money(netTotal) }}</strong></div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="payrollVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saveLoading" @click="savePayroll">保存薪资单</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="薪资单详情" width="820px">
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
<div><strong>{{ detail.employee_name }}</strong><span>{{ detail.department || "未设置部门" }} · {{ detail.position || "未设置职位" }}</span></div>
|
||||
<div><span>{{ detail.payroll_month }} 薪资</span><el-tag :type="statusType(detail.status)">{{ statusText(detail.status) }}</el-tag></div>
|
||||
</div>
|
||||
<div class="detail-summary">
|
||||
<div><span>应发工资</span><strong>¥{{ money(detail.gross_salary) }}</strong></div>
|
||||
<div><span>扣款合计</span><strong>¥{{ money(detail.total_deduction) }}</strong></div>
|
||||
<div class="net"><span>实发工资</span><strong>¥{{ money(detail.net_salary) }}</strong></div>
|
||||
</div>
|
||||
<el-descriptions :column="3" border class="detail-descriptions">
|
||||
<el-descriptions-item v-for="entry in detailEntries" :key="entry.label" :label="entry.label">¥{{ money(entry.value) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="detail.items?.length" class="detail-items">
|
||||
<h4>自定义薪资项</h4>
|
||||
<el-table :data="detail.items" size="small">
|
||||
<el-table-column prop="item_name" label="项目名称" />
|
||||
<el-table-column label="类型" width="100"><template #default="{ row }"><el-tag :type="row.item_type === 1 ? 'success' : 'danger'" size="small">{{ row.item_type === 1 ? "增项" : "扣项" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="金额" width="150" align="right"><template #default="{ row }">¥{{ money(row.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="detail.remark" class="detail-remark">备注:{{ detail.remark }}</div>
|
||||
</template>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="schemeVisible" title="薪酬方案管理" width="980px" destroy-on-close>
|
||||
<div class="scheme-toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openSchemeCreate">新增方案</el-button>
|
||||
</div>
|
||||
<el-table :data="schemes" max-height="420">
|
||||
<el-table-column prop="scheme_name" label="方案名称" min-width="150" />
|
||||
<el-table-column label="员工" width="130"><template #default="{ row }">{{ employeeName(row.employee_id) }}</template></el-table-column>
|
||||
<el-table-column prop="effective_date" label="生效日期" width="115" />
|
||||
<el-table-column prop="expiry_date" label="失效日期" width="115"><template #default="{ row }">{{ row.expiry_date || "长期" }}</template></el-table-column>
|
||||
<el-table-column label="固定月薪" width="130" align="right"><template #default="{ row }">¥{{ money(fixedSchemeAmount(row)) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="85"><template #default="{ row }"><el-tag :type="row.status === 0 ? 'success' : 'info'" size="small">{{ row.status === 0 ? "生效中" : "已停用" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }"><el-button link type="primary" @click="openSchemeEdit(row)">编辑</el-button><el-button link type="danger" @click="removeScheme(row)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="scheme-form" v-if="schemeFormVisible">
|
||||
<div class="section-title">{{ schemeEditingID ? "编辑薪酬方案" : "新增薪酬方案" }}</div>
|
||||
<el-form ref="schemeFormRef" :model="schemeForm" :rules="schemeRules" label-width="100px">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="方案名称" prop="scheme_name"><el-input v-model="schemeForm.scheme_name" /></el-form-item>
|
||||
<el-form-item label="适用员工" prop="employee_id"><el-select v-model="schemeForm.employee_id" filterable style="width: 100%"><el-option v-for="employee in employees" :key="employee.id" :label="employeeLabel(employee)" :value="employee.id" /></el-select></el-form-item>
|
||||
<el-form-item label="生效日期" prop="effective_date"><el-date-picker v-model="schemeForm.effective_date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="失效日期"><el-date-picker v-model="schemeForm.expiry_date" value-format="YYYY-MM-DD" clearable style="width: 100%" /></el-form-item>
|
||||
</div>
|
||||
<div class="salary-grid">
|
||||
<MoneyInput v-model="schemeForm.base_salary" label="基本工资" /><MoneyInput v-model="schemeForm.post_allowance" label="岗位津贴" /><MoneyInput v-model="schemeForm.performance_salary" label="绩效工资" /><MoneyInput v-model="schemeForm.transport_allowance" label="交通补贴" /><MoneyInput v-model="schemeForm.meal_allowance" label="餐补" /><MoneyInput v-model="schemeForm.communication_allowance" label="通讯补贴" /><MoneyInput v-model="schemeForm.social_insurance_base" label="社保缴费基数" /><MoneyInput v-model="schemeForm.housing_fund_base" label="公积金缴存基数" />
|
||||
<el-form-item label="社保个人比例"><el-input-number v-model="schemeForm.social_insurance_rate" :min="0" :max="1" :step="0.01" :precision="4" :controls="false" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="公积金个人比例"><el-input-number v-model="schemeForm.housing_fund_rate" :min="0" :max="1" :step="0.01" :precision="4" :controls="false" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="方案状态"><el-radio-group v-model="schemeForm.status"><el-radio :value="0">生效</el-radio><el-radio :value="1">停用</el-radio></el-radio-group></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="备注"><el-input v-model="schemeForm.remark" type="textarea" :rows="2" /></el-form-item>
|
||||
<div class="scheme-actions"><el-button @click="schemeFormVisible = false">取消</el-button><el-button type="primary" :loading="schemeSaving" @click="saveScheme">保存方案</el-button></div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineComponent, h, onMounted, reactive, ref } from "vue"
|
||||
import { ArrowDown, Delete, Plus, Refresh, Search, Setting } from "@element-plus/icons-vue"
|
||||
import { ElInputNumber, ElFormItem, ElMessage, ElMessageBox } from "element-plus"
|
||||
import {
|
||||
createCompensationScheme, createPayroll, deleteCompensationScheme, deletePayroll,
|
||||
getCompensationDashboard, getCompensationEmployees, getCompensationSchemes,
|
||||
getPayrollDetail, getPayrollList, updateCompensationScheme, updatePayroll, updatePayrollStatus
|
||||
} from "@/api/oaCompensation"
|
||||
|
||||
const MoneyInput = defineComponent({
|
||||
props: { modelValue: Number, label: String },
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
return () => h(ElFormItem, { label: props.label }, {
|
||||
default: () => h(ElInputNumber, { modelValue: props.modelValue, "onUpdate:modelValue": value => emit("update:modelValue", value || 0), min: 0, precision: 2, controls: false, style: "width: 100%" })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const currentMonth = () => new Date().toISOString().slice(0, 7)
|
||||
const dashboardMonth = ref(currentMonth())
|
||||
const dashboard = ref({})
|
||||
const listLoading = ref(false)
|
||||
const payrolls = ref([])
|
||||
const employees = ref([])
|
||||
const schemes = ref([])
|
||||
const filters = reactive({ payroll_month: "", keyword: "", status: "" })
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
const payrollVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const schemeVisible = ref(false)
|
||||
const schemeFormVisible = ref(false)
|
||||
const saveLoading = ref(false)
|
||||
const schemeSaving = ref(false)
|
||||
const editingID = ref(0)
|
||||
const schemeEditingID = ref(0)
|
||||
const selectedSchemeID = ref(null)
|
||||
const detail = ref(null)
|
||||
const payrollFormRef = ref()
|
||||
const schemeFormRef = ref()
|
||||
|
||||
function emptyPayroll() {
|
||||
return { employee_id: null, scheme_id: null, payroll_month: currentMonth(), base_salary: 0, post_allowance: 0, performance_salary: 0, transport_allowance: 0, meal_allowance: 0, communication_allowance: 0, overtime_pay: 0, bonus: 0, other_addition: 0, leave_deduction: 0, late_deduction: 0, other_deduction: 0, social_insurance: 0, housing_fund: 0, personal_income_tax: 0, remark: "", items: [] }
|
||||
}
|
||||
function emptyScheme() {
|
||||
return { employee_id: null, scheme_name: "", effective_date: new Date().toISOString().slice(0, 10), expiry_date: "", base_salary: 0, post_allowance: 0, performance_salary: 0, transport_allowance: 0, meal_allowance: 0, communication_allowance: 0, social_insurance_base: 0, housing_fund_base: 0, social_insurance_rate: 0, housing_fund_rate: 0, status: 0, remark: "" }
|
||||
}
|
||||
const payrollForm = reactive(emptyPayroll())
|
||||
const schemeForm = reactive(emptyScheme())
|
||||
const payrollRules = { employee_id: [{ required: true, message: "请选择员工", trigger: "change" }], payroll_month: [{ required: true, message: "请选择工资月份", trigger: "change" }] }
|
||||
const schemeRules = { employee_id: [{ required: true, message: "请选择员工", trigger: "change" }], scheme_name: [{ required: true, message: "请输入方案名称", trigger: "blur" }], effective_date: [{ required: true, message: "请选择生效日期", trigger: "change" }] }
|
||||
|
||||
const matchingSchemes = computed(() => schemes.value.filter(item => item.employee_id === payrollForm.employee_id && item.status === 0))
|
||||
const grossTotal = computed(() => sum(payrollForm, ["base_salary", "post_allowance", "performance_salary", "transport_allowance", "meal_allowance", "communication_allowance", "overtime_pay", "bonus", "other_addition"]) + customTotal(1))
|
||||
const deductionTotal = computed(() => sum(payrollForm, ["leave_deduction", "late_deduction", "other_deduction", "social_insurance", "housing_fund", "personal_income_tax"]) + customTotal(2))
|
||||
const netTotal = computed(() => grossTotal.value - deductionTotal.value)
|
||||
const detailEntries = computed(() => detail.value ? [
|
||||
{ label: "基本工资", value: detail.value.base_salary }, { label: "岗位津贴", value: detail.value.post_allowance }, { label: "绩效工资", value: detail.value.performance_salary },
|
||||
{ label: "交通补贴", value: detail.value.transport_allowance }, { label: "餐补", value: detail.value.meal_allowance }, { label: "通讯补贴", value: detail.value.communication_allowance },
|
||||
{ label: "加班工资", value: detail.value.overtime_pay }, { label: "奖金", value: detail.value.bonus }, { label: "其他应发", value: detail.value.other_addition },
|
||||
{ label: "请假扣款", value: detail.value.leave_deduction }, { label: "迟到早退扣款", value: detail.value.late_deduction }, { label: "其他扣款", value: detail.value.other_deduction },
|
||||
{ label: "个人社保", value: detail.value.social_insurance }, { label: "个人公积金", value: detail.value.housing_fund }, { label: "个人所得税", value: detail.value.personal_income_tax }
|
||||
] : [])
|
||||
|
||||
function sum(data, keys) { return keys.reduce((total, key) => total + Number(data[key] || 0), 0) }
|
||||
function customTotal(type) { return payrollForm.items.filter(item => item.item_type === type).reduce((total, item) => total + Number(item.amount || 0), 0) }
|
||||
function money(value) { return Number(value || 0).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
|
||||
function dateTime(value) { return value ? String(value).replace("T", " ").slice(0, 19) : "-" }
|
||||
function statusText(status) { return ["草稿", "已确认", "已发放", "已作废"][status] || "未知" }
|
||||
function statusType(status) { return ["info", "warning", "success", "danger"][status] || "info" }
|
||||
function employeeLabel(employee) { return `${employee.name}${employee.department ? `(${employee.department})` : ""}` }
|
||||
function employeeName(id) { return employees.value.find(item => item.id === id)?.name || "-" }
|
||||
function fixedSchemeAmount(item) { return sum(item, ["base_salary", "post_allowance", "performance_salary", "transport_allowance", "meal_allowance", "communication_allowance"]) }
|
||||
|
||||
async function loadEmployees() {
|
||||
const res = await getCompensationEmployees()
|
||||
if (res?.code === 200) employees.value = res.data || []
|
||||
}
|
||||
async function loadSchemes() {
|
||||
const res = await getCompensationSchemes()
|
||||
if (res?.code === 200) schemes.value = res.data || []
|
||||
}
|
||||
async function loadDashboard() {
|
||||
const res = await getCompensationDashboard({ payroll_month: dashboardMonth.value })
|
||||
if (res?.code === 200) dashboard.value = res.data || {}
|
||||
}
|
||||
async function loadPayrolls() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await getPayrollList({ ...filters, page: pagination.page, pageSize: pagination.pageSize })
|
||||
if (res?.code === 200) {
|
||||
payrolls.value = res.data?.list || []
|
||||
pagination.total = res.data?.total || 0
|
||||
}
|
||||
} finally { listLoading.value = false }
|
||||
}
|
||||
function search() { pagination.page = 1; loadPayrolls() }
|
||||
function resetFilters() { Object.assign(filters, { payroll_month: "", keyword: "", status: "" }); search() }
|
||||
function assignForm(target, values) { Object.assign(target, emptyPayroll(), values) }
|
||||
|
||||
function openCreate() { editingID.value = 0; assignForm(payrollForm, emptyPayroll()); selectedSchemeID.value = null; payrollVisible.value = true }
|
||||
async function openEdit(row) {
|
||||
const res = await getPayrollDetail(row.id)
|
||||
if (res?.code !== 200) return ElMessage.error(res?.msg || "获取薪资单失败")
|
||||
editingID.value = row.id
|
||||
assignForm(payrollForm, { ...res.data, items: (res.data.items || []).map(item => ({ ...item, key: crypto.randomUUID?.() || `${Date.now()}-${item.id}` })) })
|
||||
selectedSchemeID.value = res.data.scheme_id || null
|
||||
payrollVisible.value = true
|
||||
}
|
||||
function resetPayrollForm() { payrollFormRef.value?.resetFields() }
|
||||
function onEmployeeChange() { selectedSchemeID.value = null; payrollForm.scheme_id = null }
|
||||
function applyScheme(id) {
|
||||
const scheme = schemes.value.find(item => item.id === id)
|
||||
payrollForm.scheme_id = id || null
|
||||
if (!scheme) return
|
||||
for (const key of ["base_salary", "post_allowance", "performance_salary", "transport_allowance", "meal_allowance", "communication_allowance"]) payrollForm[key] = Number(scheme[key] || 0)
|
||||
payrollForm.social_insurance = Number(scheme.social_insurance_base || 0) * Number(scheme.social_insurance_rate || 0)
|
||||
payrollForm.housing_fund = Number(scheme.housing_fund_base || 0) * Number(scheme.housing_fund_rate || 0)
|
||||
}
|
||||
function addItem() { payrollForm.items.push({ key: crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`, item_name: "", item_type: 1, amount: 0, remark: "", sort_order: payrollForm.items.length }) }
|
||||
async function savePayroll() {
|
||||
await payrollFormRef.value?.validate()
|
||||
saveLoading.value = true
|
||||
try {
|
||||
const payload = { ...payrollForm, items: payrollForm.items.map((item, index) => ({ ...item, sort_order: index })) }
|
||||
const res = editingID.value ? await updatePayroll(editingID.value, payload) : await createPayroll(payload)
|
||||
if (res?.code === 200) { ElMessage.success(editingID.value ? "薪资单已更新" : "薪资单已创建"); payrollVisible.value = false; loadPayrolls(); loadDashboard() } else ElMessage.error(res?.msg || "保存失败")
|
||||
} finally { saveLoading.value = false }
|
||||
}
|
||||
async function openDetail(row) {
|
||||
const res = await getPayrollDetail(row.id)
|
||||
if (res?.code === 200) { detail.value = res.data; detailVisible.value = true } else ElMessage.error(res?.msg || "获取详情失败")
|
||||
}
|
||||
async function changeStatus(row, status) {
|
||||
const action = { 1: "确认", 2: "标记为已发放", 3: "作废" }[status]
|
||||
try { await ElMessageBox.confirm(`确定${action}「${row.employee_name}」的 ${row.payroll_month} 薪资单吗?`, "操作确认", { type: status === 3 ? "warning" : "info" }) } catch { return }
|
||||
const res = await updatePayrollStatus(row.id, status)
|
||||
if (res?.code === 200) { ElMessage.success("操作成功"); loadPayrolls(); loadDashboard() } else ElMessage.error(res?.msg || "操作失败")
|
||||
}
|
||||
async function removePayroll(row) {
|
||||
try { await ElMessageBox.confirm(`确定删除「${row.employee_name}」的薪资单吗?`, "删除确认", { type: "warning" }) } catch { return }
|
||||
const res = await deletePayroll(row.id)
|
||||
if (res?.code === 200) { ElMessage.success("删除成功"); loadPayrolls(); loadDashboard() } else ElMessage.error(res?.msg || "删除失败")
|
||||
}
|
||||
function resetSchemeForm() { Object.assign(schemeForm, emptyScheme()) }
|
||||
function openSchemeCreate() { schemeEditingID.value = 0; resetSchemeForm(); schemeFormVisible.value = true }
|
||||
function openSchemeEdit(row) { schemeEditingID.value = row.id; Object.assign(schemeForm, emptyScheme(), row); schemeFormVisible.value = true }
|
||||
async function saveScheme() {
|
||||
await schemeFormRef.value?.validate()
|
||||
schemeSaving.value = true
|
||||
try {
|
||||
const res = schemeEditingID.value ? await updateCompensationScheme(schemeEditingID.value, schemeForm) : await createCompensationScheme(schemeForm)
|
||||
if (res?.code === 200) { ElMessage.success("方案已保存"); schemeFormVisible.value = false; loadSchemes() } else ElMessage.error(res?.msg || "保存失败")
|
||||
} finally { schemeSaving.value = false }
|
||||
}
|
||||
async function removeScheme(row) {
|
||||
try { await ElMessageBox.confirm(`确定删除薪酬方案「${row.scheme_name}」吗?`, "删除确认", { type: "warning" }) } catch { return }
|
||||
const res = await deleteCompensationScheme(row.id)
|
||||
if (res?.code === 200) { ElMessage.success("删除成功"); loadSchemes() } else ElMessage.error(res?.msg || "删除失败")
|
||||
}
|
||||
|
||||
onMounted(async () => { await Promise.all([loadEmployees(), loadSchemes(), loadDashboard(), loadPayrolls()]) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.compensation-page { padding: 16px 20px 28px; }
|
||||
.page-header, .header-actions, .filter-bar, .dashboard-toolbar, .scheme-toolbar, .section-title, .detail-head, .detail-head > div, .custom-item { display: flex; align-items: center; }
|
||||
.page-header { justify-content: space-between; margin-bottom: 16px; }
|
||||
.page-header h2 { font-size: 20px; margin: 0 0 4px; }
|
||||
.page-header p { margin: 0; color: #909399; font-size: 13px; }
|
||||
.header-actions, .filter-bar { gap: 12px; }
|
||||
.dashboard-toolbar { justify-content: flex-end; gap: 10px; margin: -2px 0 10px; color: #606266; font-size: 13px; }
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { padding: 15px 18px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px; border-top: 3px solid #409eff; }
|
||||
.stat-card.purple { border-top-color: #8b5cf6; }.stat-card.green { border-top-color: #67c23a; }.stat-card.orange { border-top-color: #e6a23c; }
|
||||
.stat-label, .stat-sub { color: #909399; font-size: 13px; }.stat-value { margin: 7px 0 4px; font-size: 25px; font-weight: 600; color: #303133; }.stat-sub { font-size: 12px; }
|
||||
.table-card { padding: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px; }.filter-bar { flex-wrap: wrap; margin-bottom: 14px; }.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.net-money, .addition-text { color: #e6a23c; }.deduction-text { color: #f56c6c; }.more-icon { margin-left: 3px; vertical-align: -2px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }.main-fields { margin-bottom: 4px; }
|
||||
.salary-section { border-top: 1px solid #ebeef5; padding: 14px 0 2px; margin-top: 4px; }.section-title { justify-content: space-between; gap: 8px; margin-bottom: 13px; font-weight: 600; color: #303133; }.dot { width: 8px; height: 8px; border-radius: 50%; }.dot.addition { background: #67c23a; }.dot.deduction { background: #f56c6c; }.section-title:has(.dot) { justify-content: flex-start; }
|
||||
.salary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 20px; }
|
||||
.custom-items { padding-bottom: 10px; }.custom-item { gap: 10px; margin-bottom: 10px; }.custom-item :deep(.el-input) { flex: 1; }
|
||||
.amount-summary { display: grid; grid-template-columns: repeat(3, 1fr); background: #f5f7fa; border-radius: 6px; padding: 13px 18px; text-align: center; }.amount-summary div { border-right: 1px solid #dcdfe6; }.amount-summary div:last-child { border: none; }.amount-summary span { color: #909399; font-size: 13px; margin-right: 8px; }.amount-summary strong { font-size: 17px; }.amount-summary .net strong { color: #409eff; font-size: 20px; }
|
||||
.detail-head { justify-content: space-between; padding-bottom: 14px; border-bottom: 1px solid #ebeef5; }.detail-head > div { gap: 10px; }.detail-head strong { font-size: 18px; }.detail-head span { color: #909399; font-size: 13px; }.detail-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 18px 0; }.detail-summary div { display: flex; flex-direction: column; gap: 5px; }.detail-summary span { font-size: 13px; color: #909399; }.detail-summary strong { font-size: 19px; color: #303133; }.detail-summary .net strong { color: #409eff; }.detail-descriptions { margin-bottom: 16px; }.detail-items h4 { margin: 12px 0 8px; }.detail-remark { margin-top: 14px; padding: 10px; color: #606266; background: #f5f7fa; font-size: 13px; }
|
||||
.scheme-toolbar { justify-content: flex-end; margin-bottom: 12px; }.scheme-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid #ebeef5; }.scheme-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
@media (max-width: 1000px) { .stats-row { grid-template-columns: repeat(2, 1fr); }.salary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 700px) { .page-header { align-items: flex-start; gap: 12px; flex-direction: column; }.stats-row, .form-grid, .salary-grid { grid-template-columns: 1fr; }.custom-item { flex-wrap: wrap; }.amount-summary { grid-template-columns: 1fr; gap: 10px; }.amount-summary div { border: none; }.detail-summary { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -72,44 +72,13 @@
|
||||
<el-descriptions-item label="备注" :span="3">{{ file.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">证照资料</div>
|
||||
<div class="cert-grid">
|
||||
<div v-for="cert in certPhotos" :key="cert.label" class="cert-item">
|
||||
<div
|
||||
v-if="cert.url"
|
||||
class="cert-body"
|
||||
:title="`点击预览${cert.label}`"
|
||||
@click="previewCert(cert)"
|
||||
>
|
||||
<el-image
|
||||
v-if="isImage(cert.url)"
|
||||
:src="cert.url"
|
||||
:preview-src-list="[cert.url]"
|
||||
hide-on-click-modal
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="cert-image"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="cert-loading"><el-icon :size="22"><Loading /></el-icon></div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div v-else class="cert-pdf">
|
||||
<el-icon :size="34"><Document /></el-icon>
|
||||
<span>PDF 附件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="cert-body cert-empty">未上传</div>
|
||||
<div class="cert-label">{{ cert.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 教育经历 / 工作经历 / 合同信息:同构表格,配置驱动 -->
|
||||
<el-tab-pane
|
||||
v-for="conf in recordTabs"
|
||||
:key="conf.type"
|
||||
:label="`${conf.label}(${recordsOf(conf.type).length})`"
|
||||
:label="`${conf.label}(${recordCount(conf.type)})`"
|
||||
:name="conf.name"
|
||||
>
|
||||
<div class="tab-toolbar">
|
||||
@@ -122,7 +91,7 @@
|
||||
新增{{ conf.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="recordsOf(conf.type)" stripe>
|
||||
<el-table :data="recordsForTab(conf.type)" stripe>
|
||||
<el-table-column :label="conf.nameLabel" prop="title" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column :label="conf.subLabel" prop="sub_title" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.sub_title || '-' }}</template>
|
||||
@@ -138,20 +107,47 @@
|
||||
<el-tag :type="contractStatus(row).type" size="small">{{ contractStatus(row).text }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="conf.type === 1" label="学历照片" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.attachment_url"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="previewAttachment(row)"
|
||||
>
|
||||
查看附件
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="描述" prop="description" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.description || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openRecordDialog({ type: conf.type, record: row })"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button>
|
||||
<template v-if="row.is_primary_file">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:loading="certSaving === row.cert_field"
|
||||
@click="removePrimaryCertificate(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openRecordDialog({ type: conf.type, record: row })"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -161,8 +157,23 @@
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 证照附件 -->
|
||||
<el-tab-pane :label="`证照附件(${recordsOf(4).length})`" name="attachment">
|
||||
<el-tab-pane :label="`证照附件(${certificateAttachments.length})`" name="attachment">
|
||||
<div class="tab-toolbar">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="(opt) => handleCertUpload('education_photo', opt)"
|
||||
:before-upload="beforeCertUpload"
|
||||
accept="image/*,.pdf"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:loading="certSaving === 'education_photo'"
|
||||
>
|
||||
{{ file.education_photo ? '替换学历照片' : '上传学历照片' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -172,8 +183,8 @@
|
||||
上传证照
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="recordsOf(4).length" class="attachment-grid">
|
||||
<div v-for="item in recordsOf(4)" :key="item.id" class="attachment-card">
|
||||
<div v-if="certificateAttachments.length" class="attachment-grid">
|
||||
<div v-for="item in certificateAttachments" :key="item.id" class="attachment-card">
|
||||
<div class="attachment-body" @click="previewAttachment(item)">
|
||||
<img
|
||||
v-if="isImage(item.attachment_url)"
|
||||
@@ -190,15 +201,28 @@
|
||||
<div class="attachment-sub" :title="item.sub_title">{{ item.sub_title || ' ' }}</div>
|
||||
<div class="attachment-actions">
|
||||
<el-button link type="primary" size="small" @click="previewAttachment(item)">预览</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openRecordDialog({ type: 4, record: item })"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRecord(item)">删除</el-button>
|
||||
<template v-if="item.is_primary_file">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:loading="certSaving === item.cert_field"
|
||||
@click="removePrimaryCertificate(item)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openRecordDialog({ type: 4, record: item })"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRecord(item)">删除</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,10 +236,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Document, Edit, Loading, Plus, Refresh } from '@element-plus/icons-vue';
|
||||
import { deleteFileRecord, getEmployeeFileDetail } from '@/api/employeeFile';
|
||||
import { Document, Edit, Plus, Refresh } from '@element-plus/icons-vue';
|
||||
import {
|
||||
deleteFileRecord,
|
||||
getEmployeeFileDetail,
|
||||
updateEmployeeFileCertificates,
|
||||
} from '@/api/employeeFile';
|
||||
import { uploadFile } from '@/api/file';
|
||||
import {
|
||||
employeeStatusTagType,
|
||||
employeeStatusText,
|
||||
@@ -228,11 +257,17 @@ import RecordEditDialog from './recordEditDialog.vue';
|
||||
* 档案编辑(基本资料)通过 edit 事件交给父页面打开编辑弹窗。
|
||||
*/
|
||||
|
||||
const emit = defineEmits(['edit']);
|
||||
const emit = defineEmits(['edit', 'refresh']);
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const recordDialogRef = ref();
|
||||
const certSaving = ref('');
|
||||
const certificateForm = reactive({
|
||||
education_photo: '',
|
||||
id_card_front: '',
|
||||
id_card_back: '',
|
||||
});
|
||||
|
||||
const file = ref({});
|
||||
const records = ref([]);
|
||||
@@ -255,31 +290,133 @@ const STATUS_META = {
|
||||
const maritalText = (value) => MARITAL_TEXT[Number(value ?? 0)] || '未知';
|
||||
const statusMeta = (value) => STATUS_META[Number(value ?? 2)] || STATUS_META[2];
|
||||
|
||||
/** 证照资料:学历照片与身份证正反面(主表字段) */
|
||||
const certPhotos = computed(() => [
|
||||
{ label: '学历照片', url: file.value.education_photo || '' },
|
||||
{ label: '身份证正面', url: file.value.id_card_front || '' },
|
||||
{ label: '身份证反面', url: file.value.id_card_back || '' },
|
||||
]);
|
||||
const beforeCertUpload = (uploadedFile) => {
|
||||
const isImageOrPdf =
|
||||
uploadedFile.type.startsWith('image/') || uploadedFile.type === 'application/pdf';
|
||||
const isLt10M = uploadedFile.size / 1024 / 1024 < 10;
|
||||
if (!isImageOrPdf) ElMessage.error('仅支持图片或 PDF 格式');
|
||||
if (!isLt10M) ElMessage.error('文件大小不能超过 10MB');
|
||||
return isImageOrPdf && isLt10M;
|
||||
};
|
||||
|
||||
const previewCert = (cert) => {
|
||||
// PDF 无法用 el-image 预览,新窗口打开;图片走 el-image 内置放大
|
||||
if (cert.url && !isImage(cert.url)) {
|
||||
window.open(cert.url, '_blank');
|
||||
const saveCertificates = async () => {
|
||||
await updateEmployeeFileCertificates(file.value.id, certificateForm);
|
||||
await loadDetail();
|
||||
emit('refresh');
|
||||
};
|
||||
|
||||
const handleCertUpload = async (field, { file: uploadedFile }) => {
|
||||
certSaving.value = field;
|
||||
try {
|
||||
const data = new FormData();
|
||||
data.append('file', uploadedFile);
|
||||
const res = await uploadFile(data);
|
||||
const url = res?.url || res?.data?.url;
|
||||
if (!url) {
|
||||
ElMessage.error(res?.msg || '上传失败');
|
||||
return;
|
||||
}
|
||||
certificateForm[field] = url;
|
||||
await saveCertificates();
|
||||
ElMessage.success('证照已保存');
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '证照保存失败');
|
||||
} finally {
|
||||
certSaving.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeCert = async (field) => {
|
||||
certSaving.value = field;
|
||||
try {
|
||||
certificateForm[field] = '';
|
||||
await saveCertificates();
|
||||
ElMessage.success('证照已移除');
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '证照移除失败');
|
||||
} finally {
|
||||
certSaving.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removePrimaryCertificate = async (item) => {
|
||||
if (!item.cert_field) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除「${item.title}」吗?删除后将同步清空员工基础资料中的对应证照数据。`,
|
||||
'删除确认',
|
||||
{ type: 'warning' },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await removeCert(item.cert_field);
|
||||
};
|
||||
|
||||
const recordsOf = (type) => records.value.filter((item) => Number(item.type) === Number(type));
|
||||
|
||||
/**
|
||||
* 主档案证照字段同步展示在“证照附件”中。
|
||||
* 只读标志用于隐藏子记录的编辑/删除按钮,实际更新请通过证照附件页完成。
|
||||
*/
|
||||
const certificateAttachments = computed(() => {
|
||||
const manualRecords = recordsOf(4);
|
||||
const primaryFiles = [
|
||||
{
|
||||
id: 'primary-education-photo',
|
||||
title: '学历照片',
|
||||
url: file.value.education_photo,
|
||||
cert_field: 'education_photo',
|
||||
},
|
||||
{
|
||||
id: 'primary-id-card-front',
|
||||
title: '身份证正面',
|
||||
url: file.value.id_card_front,
|
||||
cert_field: 'id_card_front',
|
||||
},
|
||||
{
|
||||
id: 'primary-id-card-back',
|
||||
title: '身份证反面',
|
||||
url: file.value.id_card_back,
|
||||
cert_field: 'id_card_back',
|
||||
},
|
||||
]
|
||||
.filter((item) => item.url)
|
||||
.filter((item) => !manualRecords.some((record) => record.attachment_url === item.url))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
type: 4,
|
||||
title: item.title,
|
||||
sub_title: '建档时上传',
|
||||
attachment_url: item.url,
|
||||
is_primary_file: true,
|
||||
cert_field: item.cert_field,
|
||||
}));
|
||||
return [...primaryFiles, ...manualRecords];
|
||||
});
|
||||
|
||||
const recordsForTab = (type) => recordsOf(type);
|
||||
const recordCount = (type) => recordsForTab(type).length;
|
||||
|
||||
/** 后端可能返回 RFC3339 时间戳,经历列表仅展示日期部分。 */
|
||||
const formatDateOnly = (value) => {
|
||||
if (!value) return '';
|
||||
const matched = String(value).match(/^\d{4}-\d{2}-\d{2}/);
|
||||
return matched ? matched[0] : String(value);
|
||||
};
|
||||
|
||||
const dateRangeText = (row) => {
|
||||
if (!row.start_date && !row.end_date) return '-';
|
||||
return `${row.start_date || '?'} ~ ${row.end_date || '至今'}`;
|
||||
const startDate = formatDateOnly(row.start_date);
|
||||
const endDate = formatDateOnly(row.end_date);
|
||||
if (!startDate && !endDate) return '-';
|
||||
return `${startDate || '?'} ~ ${endDate || '至今'}`;
|
||||
};
|
||||
|
||||
/** 合同状态:以结束日期判断履行中 / 已到期 */
|
||||
const contractStatus = (row) => {
|
||||
if (!row.end_date) return { text: '长期', type: 'success' };
|
||||
return row.end_date >= new Date().toISOString().slice(0, 10)
|
||||
const endDate = formatDateOnly(row.end_date);
|
||||
if (!endDate) return { text: '长期', type: 'success' };
|
||||
return endDate >= new Date().toISOString().slice(0, 10)
|
||||
? { text: '履行中', type: 'success' }
|
||||
: { text: '已到期', type: 'warning' };
|
||||
};
|
||||
@@ -294,6 +431,9 @@ const loadDetail = async () => {
|
||||
const res = await getEmployeeFileDetail(id);
|
||||
const data = res?.data || res || {};
|
||||
file.value = data.file || {};
|
||||
certificateForm.education_photo = file.value.education_photo || '';
|
||||
certificateForm.id_card_front = file.value.id_card_front || '';
|
||||
certificateForm.id_card_back = file.value.id_card_back || '';
|
||||
records.value = data.records || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '加载档案详情失败');
|
||||
@@ -406,68 +546,9 @@ defineExpose({ open, reload: loadDetail });
|
||||
.tab-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cert-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cert-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.cert-body {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ebeef5;
|
||||
background: #f5f7fa;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cert-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cert-pdf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cert-empty {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cert-loading {
|
||||
color: #c0c4cc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cert-label {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.attachment-grid {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="档案编号" prop="file_no">
|
||||
<el-input v-model="form.file_no" placeholder="留空自动生成" maxlength="50" />
|
||||
<el-input v-model="form.file_no" placeholder="留空自动生成" disabled maxlength="50" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">个人信息</el-divider>
|
||||
@@ -117,37 +117,6 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">证照资料</el-divider>
|
||||
<el-form-item
|
||||
v-for="cert in certFields"
|
||||
:key="cert.field"
|
||||
:label="cert.label"
|
||||
>
|
||||
<div class="upload-area">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="(opt) => handleCertUpload(cert.field, opt)"
|
||||
:before-upload="beforeCertUpload"
|
||||
accept="image/*,.pdf"
|
||||
>
|
||||
<img v-if="form[cert.field]" :src="form[cert.field]" class="cert-preview" alt="" />
|
||||
<div v-else class="upload-trigger">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>上传{{ cert.label }}</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<el-button
|
||||
v-if="form[cert.field]"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="form[cert.field] = ''"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">紧急联系人</el-divider>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
@@ -184,7 +153,6 @@ import { computed, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { createEmployeeFile, getFiledEmployeeIds, updateEmployeeFile } from '@/api/employeeFile';
|
||||
import { oaOrganizationApi } from '@/api/organization';
|
||||
import { uploadFile } from '@/api/file';
|
||||
|
||||
/**
|
||||
* 建档 / 编辑档案弹窗。
|
||||
@@ -208,20 +176,10 @@ const filedIds = ref([]);
|
||||
|
||||
const politicalOptions = ['群众', '中共党员', '中共预备党员', '共青团员', '民主党派', '无党派人士'];
|
||||
|
||||
/** 证照资料上传项:field 对应 form 字段与后端列名 */
|
||||
const certFields = [
|
||||
{ field: 'education_photo', label: '学历照片' },
|
||||
{ field: 'id_card_front', label: '身份证正面' },
|
||||
{ field: 'id_card_back', label: '身份证反面' },
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
employee_id: null,
|
||||
file_no: '',
|
||||
id_card: '',
|
||||
id_card_front: '',
|
||||
id_card_back: '',
|
||||
education_photo: '',
|
||||
political_status: '',
|
||||
marital_status: 0,
|
||||
native_place: '',
|
||||
@@ -259,9 +217,6 @@ const resetForm = () => {
|
||||
form.employee_id = null;
|
||||
form.file_no = '';
|
||||
form.id_card = '';
|
||||
form.id_card_front = '';
|
||||
form.id_card_back = '';
|
||||
form.education_photo = '';
|
||||
form.political_status = '';
|
||||
form.marital_status = 0;
|
||||
form.native_place = '';
|
||||
@@ -310,11 +265,10 @@ const open = async (file = null) => {
|
||||
editEmployeeLabel.value = file.employee_name
|
||||
? `${file.employee_name}(${file.employee_account || '-'})`
|
||||
: `员工 #${file.employee_id}`;
|
||||
// 编辑时员工归属不可修改,但必须回填校验字段,避免保存时触发“请选择员工”。
|
||||
form.employee_id = file.employee_id ? Number(file.employee_id) : null;
|
||||
form.file_no = file.file_no || '';
|
||||
form.id_card = file.id_card || '';
|
||||
form.id_card_front = file.id_card_front || '';
|
||||
form.id_card_back = file.id_card_back || '';
|
||||
form.education_photo = file.education_photo || '';
|
||||
form.political_status = file.political_status || '';
|
||||
form.marital_status = Number(file.marital_status ?? 0);
|
||||
form.native_place = file.native_place || '';
|
||||
@@ -336,31 +290,6 @@ const open = async (file = null) => {
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const beforeCertUpload = (file) => {
|
||||
const isImageOrPdf = file.type.startsWith('image/') || file.type === 'application/pdf';
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isImageOrPdf) ElMessage.error('仅支持图片或 PDF 格式');
|
||||
if (!isLt10M) ElMessage.error('文件大小不能超过 10MB');
|
||||
return isImageOrPdf && isLt10M;
|
||||
};
|
||||
|
||||
/** 通用证照上传:成功后写回对应表单字段 */
|
||||
const handleCertUpload = async (field, { file }) => {
|
||||
try {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
const res = await uploadFile(data);
|
||||
const url = res?.url || res?.data?.url;
|
||||
if (!url) {
|
||||
ElMessage.error(res?.msg || '上传失败');
|
||||
return;
|
||||
}
|
||||
form[field] = url;
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
|
||||
if (!valid) return;
|
||||
@@ -387,42 +316,3 @@ const handleSave = async () => {
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cert-preview {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dcdfe6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border: 1px dashed #c0ccda;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #8c939d;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #3973ff;
|
||||
color: #3973ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,14 @@
|
||||
<el-input v-model="form.sub_title" :placeholder="`请输入${meta.subLabel}`" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="meta.extraLabel" :label="meta.extraLabel" prop="extra">
|
||||
<el-input
|
||||
v-if="meta.type === 2"
|
||||
v-model="form.extra"
|
||||
placeholder="请输入所在部门"
|
||||
maxlength="100"
|
||||
/>
|
||||
<el-select
|
||||
v-else
|
||||
v-model="form.extra"
|
||||
filterable
|
||||
allow-create
|
||||
@@ -47,30 +54,34 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="meta.type === 4" label="附件" prop="attachment_url">
|
||||
<el-form-item v-if="meta.type === 1 || meta.type === 3 || meta.type === 4" :label="attachmentLabel" prop="attachment_url">
|
||||
<div class="upload-area">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:before-upload="beforeUpload"
|
||||
accept="image/*,.pdf"
|
||||
:accept="uploadAccept"
|
||||
>
|
||||
<img v-if="form.attachment_url" :src="form.attachment_url" class="attachment-preview" alt="证照" />
|
||||
<img
|
||||
v-if="form.attachment_url && isImageFile(form.attachment_url)"
|
||||
:src="form.attachment_url"
|
||||
class="attachment-preview"
|
||||
alt="附件预览"
|
||||
/>
|
||||
<div v-else-if="form.attachment_url" class="uploaded-file">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>已上传{{ attachmentLabel }}</span>
|
||||
</div>
|
||||
<div v-else class="upload-trigger">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>上传证照</span>
|
||||
<span>上传{{ attachmentLabel }}</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<el-button
|
||||
v-if="form.attachment_url"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="form.attachment_url = ''"
|
||||
>
|
||||
移除附件
|
||||
</el-button>
|
||||
<div class="upload-tip">支持图片 / PDF,大小不超过 10MB</div>
|
||||
<div v-if="form.attachment_url" class="attachment-actions">
|
||||
<el-button link type="primary" size="small" @click="openAttachment">查看 / 下载</el-button>
|
||||
<el-button link type="danger" size="small" @click="form.attachment_url = ''">移除附件</el-button>
|
||||
</div>
|
||||
<div class="upload-tip">{{ uploadTip }}</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -85,7 +96,7 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { Document, Plus } from '@element-plus/icons-vue';
|
||||
import { createFileRecord, updateFileRecord } from '@/api/employeeFile';
|
||||
import { uploadFile } from '@/api/file';
|
||||
|
||||
@@ -138,6 +149,21 @@ const rules = {
|
||||
};
|
||||
|
||||
const extraOptions = computed(() => EXTRA_OPTIONS[meta.value.type] || []);
|
||||
const attachmentLabel = computed(() => {
|
||||
if (meta.value.type === 1) return '学历照片';
|
||||
if (meta.value.type === 3) return '电子合同';
|
||||
return '附件';
|
||||
});
|
||||
const uploadAccept = computed(() => (
|
||||
meta.value.type === 3
|
||||
? 'image/*,.pdf,.doc,.docx,.xls,.xlsx'
|
||||
: 'image/*,.pdf'
|
||||
));
|
||||
const uploadTip = computed(() => (
|
||||
meta.value.type === 3
|
||||
? '支持图片、PDF、Word、Excel,大小不超过 20MB'
|
||||
: '支持图片 / PDF,大小不超过 10MB'
|
||||
));
|
||||
|
||||
const resetForm = () => {
|
||||
form.title = '';
|
||||
@@ -149,6 +175,12 @@ const resetForm = () => {
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
/** 兼容后端 time.Time 返回的 RFC3339 时间戳,提交时统一使用 DATE 格式。 */
|
||||
const normalizeDate = (value) => {
|
||||
const matched = String(value || '').match(/^\d{4}-\d{2}-\d{2}/);
|
||||
return matched ? matched[0] : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开弹窗。
|
||||
* @param {object} options
|
||||
@@ -168,9 +200,9 @@ const open = (options = {}) => {
|
||||
form.title = record.title || '';
|
||||
form.sub_title = record.sub_title || '';
|
||||
form.extra = record.extra || '';
|
||||
form.dateRange = record.start_date || record.end_date
|
||||
? [record.start_date || '', record.end_date || '']
|
||||
: null;
|
||||
const startDate = normalizeDate(record.start_date);
|
||||
const endDate = normalizeDate(record.end_date);
|
||||
form.dateRange = startDate || endDate ? [startDate, endDate] : null;
|
||||
form.description = record.description || '';
|
||||
form.attachment_url = record.attachment_url || '';
|
||||
}
|
||||
@@ -178,12 +210,25 @@ const open = (options = {}) => {
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const isImageFile = (url) => /\.(png|jpe?g|gif|webp|bmp|svg)(?:\?.*)?$/i.test(url || '');
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImageOrPdf = file.type.startsWith('image/') || file.type === 'application/pdf';
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isImageOrPdf) ElMessage.error('仅支持图片或 PDF 格式');
|
||||
if (!isLt10M) ElMessage.error('文件大小不能超过 10MB');
|
||||
return isImageOrPdf && isLt10M;
|
||||
const isContract = meta.value.type === 3;
|
||||
const allowedTypes = isContract
|
||||
? ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
|
||||
: ['application/pdf'];
|
||||
const allowed = file.type.startsWith('image/') || allowedTypes.includes(file.type);
|
||||
const limitMB = isContract ? 20 : 10;
|
||||
const withinLimit = file.size / 1024 / 1024 < limitMB;
|
||||
if (!allowed) ElMessage.error(isContract ? '仅支持图片、PDF、Word 或 Excel 格式' : '仅支持图片或 PDF 格式');
|
||||
if (!withinLimit) ElMessage.error(`文件大小不能超过 ${limitMB}MB`);
|
||||
return allowed && withinLimit;
|
||||
};
|
||||
|
||||
const openAttachment = () => {
|
||||
if (form.attachment_url) {
|
||||
window.open(form.attachment_url, '_blank', 'noopener');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async ({ file }) => {
|
||||
@@ -220,8 +265,8 @@ const handleSave = async () => {
|
||||
title: form.title,
|
||||
sub_title: form.sub_title,
|
||||
extra: form.extra,
|
||||
start_date: range[0] || '',
|
||||
end_date: range[1] || '',
|
||||
start_date: normalizeDate(range[0]),
|
||||
end_date: normalizeDate(range[1]),
|
||||
description: form.description,
|
||||
attachment_url: form.attachment_url,
|
||||
sort: 0,
|
||||
@@ -254,13 +299,34 @@ defineExpose({ open, uploading });
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
.attachment-preview,
|
||||
.uploaded-file {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dcdfe6;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.uploaded-file {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: #3973ff;
|
||||
border: 1px solid #d9e4ff;
|
||||
background: #f5f8ff;
|
||||
font-size: 12px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
|
||||
@@ -84,11 +84,19 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right" align="center">
|
||||
<el-table-column label="档案完整度" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="Number(row.completeness || 0)"
|
||||
:stroke-width="10"
|
||||
:color="completenessColor(row.completeness)"
|
||||
:format="(percentage) => `${percentage}%`"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="detailDrawerRef?.open(row)">查看档案</el-button>
|
||||
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -110,19 +118,19 @@
|
||||
</el-card>
|
||||
|
||||
<FileEditDialog ref="editDialogRef" @success="loadAll" />
|
||||
<FileDetailDrawer ref="detailDrawerRef" @edit="(file) => editDialogRef?.open(file)" />
|
||||
<FileDetailDrawer
|
||||
ref="detailDrawerRef"
|
||||
@edit="(file) => editDialogRef?.open(file)"
|
||||
@refresh="loadAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Plus, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
deleteEmployeeFile,
|
||||
getEmployeeFileList,
|
||||
getEmployeeFileStats,
|
||||
} from '@/api/employeeFile';
|
||||
import { getEmployeeFileList, getEmployeeFileStats } from '@/api/employeeFile';
|
||||
import { oaOrganizationApi } from '@/api/organization';
|
||||
import { buildOrgTree, genderText } from '@/views/apps/organization/composables';
|
||||
import FileEditDialog from './components/fileEditDialog.vue';
|
||||
@@ -160,6 +168,13 @@ const STATUS_META = {
|
||||
|
||||
const statusMeta = (value) => STATUS_META[Number(value ?? 2)] || STATUS_META[2];
|
||||
|
||||
const completenessColor = (value) => {
|
||||
const percentage = Number(value || 0);
|
||||
if (percentage >= 100) return '#67c23a';
|
||||
if (percentage >= 60) return '#409eff';
|
||||
return '#e6a23c';
|
||||
};
|
||||
|
||||
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
|
||||
|
||||
const filters = reactive({ keyword: '', org_id: null, status: '' });
|
||||
@@ -219,26 +234,6 @@ const resetFilters = () => {
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
const name = row.employee_name || row.file_no || `#${row.id}`;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除「${name}」的档案吗?档案下的教育经历、合同等记录仍会保留。`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteEmployeeFile(row.id);
|
||||
ElMessage.success('删除成功');
|
||||
loadAll();
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadDepartments();
|
||||
loadAll();
|
||||
|
||||
@@ -50,14 +50,16 @@
|
||||
<div class="form-title">组织信息</div>
|
||||
|
||||
<el-form-item label="隶属单位" prop="affiliate_unit">
|
||||
<el-select v-model="form.affiliate_unit" clearable placeholder="请选择隶属单位" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in companyOptions"
|
||||
:key="item.id"
|
||||
:label="item.org_name"
|
||||
:value="String(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
<el-tree-select
|
||||
v-model="form.affiliate_unit"
|
||||
:data="organizationTree"
|
||||
:props="treeSelectProps"
|
||||
check-strictly
|
||||
clearable
|
||||
node-key="idStr"
|
||||
placeholder="请选择隶属单位"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部门" prop="department">
|
||||
@@ -157,7 +159,7 @@ const isEdit = ref(false);
|
||||
const editingId = ref(0);
|
||||
const formRef = ref();
|
||||
|
||||
const companyOptions = ref([]);
|
||||
const organizationTree = ref([]);
|
||||
const departmentTree = ref([]);
|
||||
const positionOptions = ref([]);
|
||||
|
||||
@@ -231,13 +233,13 @@ const withIdStr = (nodes) =>
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [companies, orgList, positions] = await Promise.all([
|
||||
api.value.getCompanyList().then(unwrap),
|
||||
const [orgList, positions] = await Promise.all([
|
||||
api.value.getOrganizationList().then(unwrap),
|
||||
api.value.getPositionList().then(unwrap),
|
||||
]);
|
||||
companyOptions.value = companies || [];
|
||||
departmentTree.value = withIdStr(buildOrgTree(orgList));
|
||||
const tree = withIdStr(buildOrgTree(orgList));
|
||||
organizationTree.value = tree;
|
||||
departmentTree.value = tree;
|
||||
positionOptions.value = positions || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '加载选项失败');
|
||||
|
||||
@@ -10,6 +10,8 @@ import { erpOrganizationApi, oaOrganizationApi } from '@/api/organization';
|
||||
const API_BY_MODULE = {
|
||||
erp: erpOrganizationApi,
|
||||
oa: oaOrganizationApi,
|
||||
// 基础设置入口复用统一的组织数据接口。
|
||||
basic: erpOrganizationApi,
|
||||
};
|
||||
|
||||
/** 按模块取对应的接口集合,模块名非法时回退到 erp */
|
||||
@@ -21,6 +23,7 @@ export function useOrganizationApi(module) {
|
||||
export const MODULE_LABELS = {
|
||||
erp: { name: '进销存', employeeLabel: '员工', orgLabel: '组织' },
|
||||
oa: { name: '办公自动化', employeeLabel: '人员', orgLabel: '组织' },
|
||||
basic: { name: '基础设置', employeeLabel: '人员', orgLabel: '组织' },
|
||||
};
|
||||
|
||||
export function moduleLabels(module) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<EmployeePage module="basic" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EmployeePage from '@/views/apps/organization/components/EmployeePage.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<OrganizationPage module="basic" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OrganizationPage from '@/views/apps/organization/components/OrganizationPage.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<PositionPage module="basic" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PositionPage from '@/views/apps/organization/components/PositionPage.vue';
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@ package controllers
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -68,15 +69,25 @@ func efIsValidDate(s string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// efNormalizeDate 将 ORM 读出的日期值统一为 "YYYY-MM-DD"。
|
||||
// DSN 开启 parseTime=True 后 DATE 列会被驱动解析为 time.Time,
|
||||
// 赋给 string 字段后是 Go 默认格式,取前 10 位(与 OA 日程一致)。
|
||||
func efNormalizeDate(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 10 {
|
||||
return s[:10]
|
||||
// efNormalizeDate 将可空 DATE 列统一输出为 "YYYY-MM-DD"。
|
||||
func efNormalizeDate(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
return value.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// efDatePointer 将前端可选日期转为可空 DATE 值;空字符串明确保存为 SQL NULL。
|
||||
func efDatePointer(value string) *time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
date, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &date
|
||||
}
|
||||
|
||||
// efQuery 档案查询基座:强制附加租户过滤与未删除条件。
|
||||
@@ -204,6 +215,48 @@ type employeeFileDTO struct {
|
||||
Nation string `json:"nation"`
|
||||
HomeAddress string `json:"home_address"`
|
||||
AccountStatus int8 `json:"account_status"`
|
||||
HireDate string `json:"hire_date"`
|
||||
RegularDate string `json:"regular_date"`
|
||||
LeaveDate string `json:"leave_date"`
|
||||
Completeness int `json:"completeness"`
|
||||
}
|
||||
|
||||
// efFileCompleteness 计算档案资料完整度。仅统计应由档案模块维护的有效字段:
|
||||
// 离职日期仅在离职时要求,转正日期仅在正式员工时要求,备注为可选项不计入。
|
||||
func efFileCompleteness(file models.BackendEmployeeFile) int {
|
||||
items := []bool{
|
||||
strings.TrimSpace(file.IDCard) != "",
|
||||
strings.TrimSpace(file.PoliticalStatus) != "",
|
||||
file.MaritalStatus > 0,
|
||||
strings.TrimSpace(file.NativePlace) != "",
|
||||
strings.TrimSpace(file.HouseholdAddress) != "",
|
||||
strings.TrimSpace(file.CurrentAddress) != "",
|
||||
strings.TrimSpace(file.WorkEmail) != "",
|
||||
file.HireDate != nil,
|
||||
strings.TrimSpace(file.EmergencyContact) != "",
|
||||
strings.TrimSpace(file.EmergencyPhone) != "",
|
||||
strings.TrimSpace(file.EmergencyRelation) != "",
|
||||
strings.TrimSpace(file.EducationPhoto) != "",
|
||||
strings.TrimSpace(file.IDCardFront) != "",
|
||||
strings.TrimSpace(file.IDCardBack) != "",
|
||||
}
|
||||
if file.EmploymentStatus == 2 {
|
||||
items = append(items, file.RegularDate != nil)
|
||||
}
|
||||
if file.EmploymentStatus == 3 {
|
||||
items = append(items, file.LeaveDate != nil)
|
||||
}
|
||||
|
||||
filled := 0
|
||||
for _, item := range items {
|
||||
if item {
|
||||
filled++
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0
|
||||
}
|
||||
return filled * 100 / len(items)
|
||||
}
|
||||
|
||||
// efAssembleDTOList 档案列表批量组装员工展示信息。
|
||||
@@ -230,6 +283,7 @@ func (c *BackendEmployeeFileController) efAssembleDTOList(tid int, files []model
|
||||
|
||||
for _, f := range files {
|
||||
dto := employeeFileDTO{BackendEmployeeFile: f}
|
||||
dto.Completeness = efFileCompleteness(f)
|
||||
dto.HireDate = efNormalizeDate(f.HireDate)
|
||||
dto.RegularDate = efNormalizeDate(f.RegularDate)
|
||||
dto.LeaveDate = efNormalizeDate(f.LeaveDate)
|
||||
@@ -386,11 +440,6 @@ func (c *BackendEmployeeFileController) Detail() {
|
||||
if records == nil {
|
||||
records = []models.BackendEmployeeFileRecord{}
|
||||
}
|
||||
for i := range records {
|
||||
records[i].StartDate = efNormalizeDate(records[i].StartDate)
|
||||
records[i].EndDate = efNormalizeDate(records[i].EndDate)
|
||||
}
|
||||
|
||||
list := c.efAssembleDTOList(tid, []models.BackendEmployeeFile{file})
|
||||
c.efOk(map[string]interface{}{"file": list[0], "records": records})
|
||||
}
|
||||
@@ -507,6 +556,11 @@ func (c *BackendEmployeeFileController) Create() {
|
||||
fileNo = "EF" + time.Now().Format("20060102150405")
|
||||
}
|
||||
|
||||
// 证照资料只能在建档完成后,于档案详情中维护;创建接口不接受附件。
|
||||
payload.IDCardFront = ""
|
||||
payload.IDCardBack = ""
|
||||
payload.EducationPhoto = ""
|
||||
|
||||
now := time.Now()
|
||||
item := &models.BackendEmployeeFile{
|
||||
Tid: tid,
|
||||
@@ -525,9 +579,9 @@ func (c *BackendEmployeeFileController) Create() {
|
||||
EmergencyPhone: payload.EmergencyPhone,
|
||||
EmergencyRelation: payload.EmergencyRelation,
|
||||
WorkEmail: payload.WorkEmail,
|
||||
HireDate: strings.TrimSpace(payload.HireDate),
|
||||
RegularDate: strings.TrimSpace(payload.RegularDate),
|
||||
LeaveDate: strings.TrimSpace(payload.LeaveDate),
|
||||
HireDate: efDatePointer(payload.HireDate),
|
||||
RegularDate: efDatePointer(payload.RegularDate),
|
||||
LeaveDate: efDatePointer(payload.LeaveDate),
|
||||
EmploymentStatus: payload.EmploymentStatus,
|
||||
Remark: payload.Remark,
|
||||
IsDeleted: 0,
|
||||
@@ -535,7 +589,8 @@ func (c *BackendEmployeeFileController) Create() {
|
||||
UpdateTime: &now,
|
||||
}
|
||||
if _, err := models.Orm.Insert(item); err != nil {
|
||||
c.efErr(500, 500, "保存失败")
|
||||
log.Printf("员工档案创建失败: tid=%d employee_id=%d err=%v", tid, payload.EmployeeID, err)
|
||||
c.efErr(500, 500, "保存失败,请检查员工档案表字段及日期数据")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,9 +627,7 @@ func (c *BackendEmployeeFileController) Update() {
|
||||
now := time.Now()
|
||||
item.FileNo = payload.FileNo
|
||||
item.IDCard = payload.IDCard
|
||||
item.IDCardFront = payload.IDCardFront
|
||||
item.IDCardBack = payload.IDCardBack
|
||||
item.EducationPhoto = payload.EducationPhoto
|
||||
// 证照字段由 UpdateCertificates 独立维护,基础资料保存不得覆盖已有附件。
|
||||
item.PoliticalStatus = payload.PoliticalStatus
|
||||
item.MaritalStatus = payload.MaritalStatus
|
||||
item.NativePlace = payload.NativePlace
|
||||
@@ -584,20 +637,81 @@ func (c *BackendEmployeeFileController) Update() {
|
||||
item.EmergencyPhone = payload.EmergencyPhone
|
||||
item.EmergencyRelation = payload.EmergencyRelation
|
||||
item.WorkEmail = payload.WorkEmail
|
||||
item.HireDate = strings.TrimSpace(payload.HireDate)
|
||||
item.RegularDate = strings.TrimSpace(payload.RegularDate)
|
||||
item.LeaveDate = strings.TrimSpace(payload.LeaveDate)
|
||||
item.HireDate = efDatePointer(payload.HireDate)
|
||||
item.RegularDate = efDatePointer(payload.RegularDate)
|
||||
item.LeaveDate = efDatePointer(payload.LeaveDate)
|
||||
item.EmploymentStatus = payload.EmploymentStatus
|
||||
item.Remark = payload.Remark
|
||||
item.UpdateTime = &now
|
||||
|
||||
if _, err := models.Orm.Update(&item,
|
||||
"FileNo", "IDCard", "IDCardFront", "IDCardBack", "EducationPhoto",
|
||||
"PoliticalStatus", "MaritalStatus", "NativePlace",
|
||||
"FileNo", "IDCard", "PoliticalStatus", "MaritalStatus", "NativePlace",
|
||||
"HouseholdAddress", "CurrentAddress", "EmergencyContact", "EmergencyPhone",
|
||||
"EmergencyRelation", "WorkEmail", "HireDate", "RegularDate", "LeaveDate",
|
||||
"EmploymentStatus", "Remark", "UpdateTime"); err != nil {
|
||||
c.efErr(500, 500, "保存失败")
|
||||
log.Printf("员工档案更新失败: tid=%d file_id=%d err=%v", tid, id, err)
|
||||
c.efErr(500, 500, "保存失败,请检查员工档案表字段及日期数据")
|
||||
return
|
||||
}
|
||||
|
||||
c.efOk(item)
|
||||
}
|
||||
|
||||
// employeeFileCertificatesPayload 证照资料单独更新请求体。
|
||||
// 该接口仅在档案详情中使用,避免上传附件时覆盖基础资料。
|
||||
type employeeFileCertificatesPayload struct {
|
||||
IDCardFront string `json:"id_card_front"`
|
||||
IDCardBack string `json:"id_card_back"`
|
||||
EducationPhoto string `json:"education_photo"`
|
||||
}
|
||||
|
||||
// UpdateCertificates POST /backend/oa/employeefile/certificates/:id
|
||||
// 仅更新学历照片、身份证正反面;档案必须已创建,不能通过建档接口上传。
|
||||
func (c *BackendEmployeeFileController) UpdateCertificates() {
|
||||
claims, err := c.efClaims()
|
||||
if err != nil {
|
||||
c.efErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
tid := claims.TenantId
|
||||
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.efErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var payload employeeFileCertificatesPayload
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, &payload) != nil {
|
||||
c.efErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
for label, url := range map[string]string{
|
||||
"身份证正面": payload.IDCardFront,
|
||||
"身份证反面": payload.IDCardBack,
|
||||
"学历照片": payload.EducationPhoto,
|
||||
} {
|
||||
if len(url) > 500 {
|
||||
c.efErr(400, 400, label+"地址过长")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var item models.BackendEmployeeFile
|
||||
if err := c.efQuery(tid).Filter("id", id).One(&item); err != nil {
|
||||
c.efErr(404, 404, "档案不存在")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
item.IDCardFront = strings.TrimSpace(payload.IDCardFront)
|
||||
item.IDCardBack = strings.TrimSpace(payload.IDCardBack)
|
||||
item.EducationPhoto = strings.TrimSpace(payload.EducationPhoto)
|
||||
item.UpdateTime = &now
|
||||
if _, err := models.Orm.Update(&item,
|
||||
"IDCardFront", "IDCardBack", "EducationPhoto", "UpdateTime"); err != nil {
|
||||
c.efErr(500, 500, "证照保存失败")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -743,8 +857,8 @@ func (c *BackendEmployeeFileController) CreateRecord() {
|
||||
Title: payload.Title,
|
||||
SubTitle: payload.SubTitle,
|
||||
Extra: payload.Extra,
|
||||
StartDate: payload.StartDate,
|
||||
EndDate: payload.EndDate,
|
||||
StartDate: efDatePointer(payload.StartDate),
|
||||
EndDate: efDatePointer(payload.EndDate),
|
||||
Description: payload.Description,
|
||||
AttachmentURL: payload.AttachmentURL,
|
||||
Sort: payload.Sort,
|
||||
@@ -795,8 +909,8 @@ func (c *BackendEmployeeFileController) UpdateRecord() {
|
||||
item.Title = payload.Title
|
||||
item.SubTitle = payload.SubTitle
|
||||
item.Extra = payload.Extra
|
||||
item.StartDate = payload.StartDate
|
||||
item.EndDate = payload.EndDate
|
||||
item.StartDate = efDatePointer(payload.StartDate)
|
||||
item.EndDate = efDatePointer(payload.EndDate)
|
||||
item.Description = payload.Description
|
||||
item.AttachmentURL = payload.AttachmentURL
|
||||
item.Sort = payload.Sort
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendOaCompensationController OA 薪酬管理接口。
|
||||
// 数据按租户隔离;工资金额使用薪资单快照,避免组织资料或历史方案变动影响已生成工资。
|
||||
type BackendOaCompensationController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendOaCompensationController) compensationClaims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if auth == "" || len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil || claims.UserType != "backend" {
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (c *BackendOaCompensationController) compensationError(httpStatus int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": httpStatus, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaCompensationController) compensationOK(data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func compensationID(raw string) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
func compensationMoney(v float64) float64 {
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
|
||||
func compensationString(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
var compensationMonthPattern = regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`)
|
||||
|
||||
type compensationItemPayload struct {
|
||||
ID uint64 `json:"id"`
|
||||
ItemName string `json:"item_name"`
|
||||
ItemType int8 `json:"item_type"` // 1 增项;2 扣项
|
||||
Amount float64 `json:"amount"`
|
||||
Remark string `json:"remark"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type compensationSchemePayload struct {
|
||||
EmployeeID uint64 `json:"employee_id"`
|
||||
SchemeName string `json:"scheme_name"`
|
||||
EffectiveDate string `json:"effective_date"`
|
||||
ExpiryDate string `json:"expiry_date"`
|
||||
BaseSalary float64 `json:"base_salary"`
|
||||
PostAllowance float64 `json:"post_allowance"`
|
||||
PerformanceSalary float64 `json:"performance_salary"`
|
||||
TransportAllowance float64 `json:"transport_allowance"`
|
||||
MealAllowance float64 `json:"meal_allowance"`
|
||||
CommunicationAllowance float64 `json:"communication_allowance"`
|
||||
SocialInsuranceBase float64 `json:"social_insurance_base"`
|
||||
HousingFundBase float64 `json:"housing_fund_base"`
|
||||
SocialInsuranceRate float64 `json:"social_insurance_rate"`
|
||||
HousingFundRate float64 `json:"housing_fund_rate"`
|
||||
Status int8 `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type compensationPayrollPayload struct {
|
||||
EmployeeID uint64 `json:"employee_id"`
|
||||
SchemeID *uint64 `json:"scheme_id"`
|
||||
PayrollMonth string `json:"payroll_month"`
|
||||
BaseSalary float64 `json:"base_salary"`
|
||||
PostAllowance float64 `json:"post_allowance"`
|
||||
PerformanceSalary float64 `json:"performance_salary"`
|
||||
TransportAllowance float64 `json:"transport_allowance"`
|
||||
MealAllowance float64 `json:"meal_allowance"`
|
||||
CommunicationAllowance float64 `json:"communication_allowance"`
|
||||
OvertimePay float64 `json:"overtime_pay"`
|
||||
Bonus float64 `json:"bonus"`
|
||||
OtherAddition float64 `json:"other_addition"`
|
||||
LeaveDeduction float64 `json:"leave_deduction"`
|
||||
LateDeduction float64 `json:"late_deduction"`
|
||||
OtherDeduction float64 `json:"other_deduction"`
|
||||
SocialInsurance float64 `json:"social_insurance"`
|
||||
HousingFund float64 `json:"housing_fund"`
|
||||
PersonalIncomeTax float64 `json:"personal_income_tax"`
|
||||
Remark string `json:"remark"`
|
||||
Items []compensationItemPayload `json:"items"`
|
||||
}
|
||||
|
||||
func (c *BackendOaCompensationController) parseBody(target interface{}) bool {
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, target) != nil {
|
||||
c.compensationError(400, "参数格式错误")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validCompensationDate(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
value, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func validateNonNegative(values ...float64) bool {
|
||||
for _, value := range values {
|
||||
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *BackendOaCompensationController) employee(tenantID uint64, employeeID uint64) (*models.BackendEmployee, bool) {
|
||||
var employee models.BackendEmployee
|
||||
err := models.Orm.QueryTable(new(models.BackendEmployee)).
|
||||
Filter("id", employeeID).
|
||||
Filter("tid", tenantID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&employee)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &employee, true
|
||||
}
|
||||
|
||||
func payrollMap(row models.BackendOaPayroll) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": row.ID, "employee_id": row.EmployeeID, "scheme_id": row.SchemeID,
|
||||
"payroll_month": row.PayrollMonth, "employee_name": row.EmployeeName,
|
||||
"department": row.Department, "position": row.Position,
|
||||
"base_salary": row.BaseSalary, "post_allowance": row.PostAllowance,
|
||||
"performance_salary": row.PerformanceSalary, "transport_allowance": row.TransportAllowance,
|
||||
"meal_allowance": row.MealAllowance, "communication_allowance": row.CommunicationAllowance,
|
||||
"overtime_pay": row.OvertimePay, "bonus": row.Bonus, "other_addition": row.OtherAddition,
|
||||
"leave_deduction": row.LeaveDeduction, "late_deduction": row.LateDeduction,
|
||||
"other_deduction": row.OtherDeduction, "social_insurance": row.SocialInsurance,
|
||||
"housing_fund": row.HousingFund, "personal_income_tax": row.PersonalIncomeTax,
|
||||
"gross_salary": row.GrossSalary, "total_deduction": row.TotalDeduction,
|
||||
"net_salary": row.NetSalary, "status": row.Status, "confirmed_at": row.ConfirmedAt,
|
||||
"paid_at": row.PaidAt, "remark": row.Remark, "create_time": row.CreateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BackendOaCompensationController) fillPayroll(row *models.BackendOaPayroll, payload compensationPayrollPayload, employee *models.BackendEmployee) {
|
||||
row.SchemeID = payload.SchemeID
|
||||
row.BaseSalary = compensationMoney(payload.BaseSalary)
|
||||
row.PostAllowance = compensationMoney(payload.PostAllowance)
|
||||
row.PerformanceSalary = compensationMoney(payload.PerformanceSalary)
|
||||
row.TransportAllowance = compensationMoney(payload.TransportAllowance)
|
||||
row.MealAllowance = compensationMoney(payload.MealAllowance)
|
||||
row.CommunicationAllowance = compensationMoney(payload.CommunicationAllowance)
|
||||
row.OvertimePay = compensationMoney(payload.OvertimePay)
|
||||
row.Bonus = compensationMoney(payload.Bonus)
|
||||
row.OtherAddition = compensationMoney(payload.OtherAddition)
|
||||
row.LeaveDeduction = compensationMoney(payload.LeaveDeduction)
|
||||
row.LateDeduction = compensationMoney(payload.LateDeduction)
|
||||
row.OtherDeduction = compensationMoney(payload.OtherDeduction)
|
||||
row.SocialInsurance = compensationMoney(payload.SocialInsurance)
|
||||
row.HousingFund = compensationMoney(payload.HousingFund)
|
||||
row.PersonalIncomeTax = compensationMoney(payload.PersonalIncomeTax)
|
||||
row.Remark = strings.TrimSpace(payload.Remark)
|
||||
row.EmployeeName = employee.Name
|
||||
row.Department = compensationString(employee.Department)
|
||||
row.Position = compensationString(employee.Position)
|
||||
|
||||
customAdd, customDeduct := 0.0, 0.0
|
||||
for _, item := range payload.Items {
|
||||
if item.ItemType == 1 {
|
||||
customAdd += item.Amount
|
||||
} else if item.ItemType == 2 {
|
||||
customDeduct += item.Amount
|
||||
}
|
||||
}
|
||||
row.GrossSalary = compensationMoney(row.BaseSalary + row.PostAllowance + row.PerformanceSalary +
|
||||
row.TransportAllowance + row.MealAllowance + row.CommunicationAllowance + row.OvertimePay +
|
||||
row.Bonus + row.OtherAddition + customAdd)
|
||||
row.TotalDeduction = compensationMoney(row.LeaveDeduction + row.LateDeduction + row.OtherDeduction +
|
||||
row.SocialInsurance + row.HousingFund + row.PersonalIncomeTax + customDeduct)
|
||||
row.NetSalary = compensationMoney(row.GrossSalary - row.TotalDeduction)
|
||||
}
|
||||
|
||||
func (c *BackendOaCompensationController) saveItems(tenantID, payrollID uint64, payloads []compensationItemPayload) error {
|
||||
_, err := models.Orm.QueryTable(new(models.BackendOaPayrollItem)).
|
||||
Filter("tid", tenantID).Filter("payroll_id", payrollID).Filter("is_deleted", 0).
|
||||
Update(orm.Params{"is_deleted": int8(1), "delete_time": time.Now()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for index, payload := range payloads {
|
||||
name := strings.TrimSpace(payload.ItemName)
|
||||
if name == "" || (payload.ItemType != 1 && payload.ItemType != 2) || !validateNonNegative(payload.Amount) {
|
||||
return orm.ErrArgs
|
||||
}
|
||||
item := &models.BackendOaPayrollItem{
|
||||
Tid: tenantID, PayrollID: payrollID, ItemName: name, ItemType: payload.ItemType,
|
||||
Amount: compensationMoney(payload.Amount), Remark: strings.TrimSpace(payload.Remark),
|
||||
SortOrder: index, IsDeleted: 0,
|
||||
}
|
||||
if _, err = models.Orm.Insert(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Employees GET /backend/oa/compensation/employees
|
||||
func (c *BackendOaCompensationController) Employees() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
qs := models.Orm.QueryTable(new(models.BackendEmployee)).Filter("tid", claims.TenantId).Filter("delete_time__isnull", true)
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("name__icontains", keyword)
|
||||
}
|
||||
var employees []models.BackendEmployee
|
||||
_, err = qs.OrderBy("name", "id").All(&employees)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.compensationError(500, "员工查询失败")
|
||||
return
|
||||
}
|
||||
list := make([]map[string]interface{}, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": employee.ID, "name": employee.Name, "account": employee.Account,
|
||||
"department": compensationString(employee.Department), "position": compensationString(employee.Position),
|
||||
})
|
||||
}
|
||||
c.compensationOK(list)
|
||||
}
|
||||
|
||||
// SchemeList GET /backend/oa/compensation/schemes
|
||||
func (c *BackendOaCompensationController) SchemeList() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
employeeID, _ := c.GetInt64("employee_id", 0)
|
||||
qs := models.Orm.QueryTable(new(models.BackendOaCompensationScheme)).Filter("tid", claims.TenantId).Filter("is_deleted", 0)
|
||||
if employeeID > 0 {
|
||||
qs = qs.Filter("employee_id", employeeID)
|
||||
}
|
||||
var rows []models.BackendOaCompensationScheme
|
||||
_, err = qs.OrderBy("-effective_date", "-id").All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.compensationError(500, "查询薪酬方案失败")
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.BackendOaCompensationScheme{}
|
||||
}
|
||||
c.compensationOK(rows)
|
||||
}
|
||||
|
||||
// CreateScheme POST /backend/oa/compensation/schemes
|
||||
func (c *BackendOaCompensationController) CreateScheme() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
var payload compensationSchemePayload
|
||||
if !c.parseBody(&payload) {
|
||||
return
|
||||
}
|
||||
date, ok := validCompensationDate(payload.EffectiveDate)
|
||||
if !ok || payload.EmployeeID == 0 || strings.TrimSpace(payload.SchemeName) == "" {
|
||||
c.compensationError(400, "请填写员工、方案名称和生效日期")
|
||||
return
|
||||
}
|
||||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||||
if !ok || employee == nil {
|
||||
c.compensationError(400, "员工不存在或不属于当前租户")
|
||||
return
|
||||
}
|
||||
if !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance,
|
||||
payload.MealAllowance, payload.CommunicationAllowance, payload.SocialInsuranceBase, payload.HousingFundBase,
|
||||
payload.SocialInsuranceRate, payload.HousingFundRate) || payload.SocialInsuranceRate > 1 || payload.HousingFundRate > 1 {
|
||||
c.compensationError(400, "薪酬金额或比例无效")
|
||||
return
|
||||
}
|
||||
var expiry *time.Time
|
||||
if strings.TrimSpace(payload.ExpiryDate) != "" {
|
||||
value, valid := validCompensationDate(payload.ExpiryDate)
|
||||
if !valid || value.Before(date) {
|
||||
c.compensationError(400, "失效日期无效")
|
||||
return
|
||||
}
|
||||
expiry = &value
|
||||
}
|
||||
status := payload.Status
|
||||
if status != 0 {
|
||||
status = 1
|
||||
}
|
||||
row := &models.BackendOaCompensationScheme{
|
||||
Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, SchemeName: strings.TrimSpace(payload.SchemeName),
|
||||
EffectiveDate: date, ExpiryDate: expiry, BaseSalary: compensationMoney(payload.BaseSalary),
|
||||
PostAllowance: compensationMoney(payload.PostAllowance), PerformanceSalary: compensationMoney(payload.PerformanceSalary),
|
||||
TransportAllowance: compensationMoney(payload.TransportAllowance), MealAllowance: compensationMoney(payload.MealAllowance),
|
||||
CommunicationAllowance: compensationMoney(payload.CommunicationAllowance), SocialInsuranceBase: compensationMoney(payload.SocialInsuranceBase),
|
||||
HousingFundBase: compensationMoney(payload.HousingFundBase), SocialInsuranceRate: payload.SocialInsuranceRate,
|
||||
HousingFundRate: payload.HousingFundRate, Status: status, Remark: strings.TrimSpace(payload.Remark),
|
||||
}
|
||||
if _, err = models.Orm.Insert(row); err != nil {
|
||||
c.compensationError(500, "保存薪酬方案失败")
|
||||
return
|
||||
}
|
||||
c.compensationOK(row)
|
||||
}
|
||||
|
||||
// UpdateScheme POST /backend/oa/compensation/schemes/:id
|
||||
func (c *BackendOaCompensationController) UpdateScheme() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||||
if !valid {
|
||||
c.compensationError(400, "无效方案ID")
|
||||
return
|
||||
}
|
||||
var payload compensationSchemePayload
|
||||
if !c.parseBody(&payload) {
|
||||
return
|
||||
}
|
||||
var row models.BackendOaCompensationScheme
|
||||
if models.Orm.QueryTable(new(models.BackendOaCompensationScheme)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||||
c.compensationError(404, "薪酬方案不存在")
|
||||
return
|
||||
}
|
||||
// 保持更新逻辑与创建一致,先软删除旧值再由统一创建验证的字段约束赋值。
|
||||
date, ok := validCompensationDate(payload.EffectiveDate)
|
||||
if !ok || payload.EmployeeID == 0 || strings.TrimSpace(payload.SchemeName) == "" || !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance, payload.MealAllowance, payload.CommunicationAllowance, payload.SocialInsuranceBase, payload.HousingFundBase, payload.SocialInsuranceRate, payload.HousingFundRate) || payload.SocialInsuranceRate > 1 || payload.HousingFundRate > 1 {
|
||||
c.compensationError(400, "方案内容无效")
|
||||
return
|
||||
}
|
||||
if _, ok = c.employee(uint64(claims.TenantId), payload.EmployeeID); !ok {
|
||||
c.compensationError(400, "员工不存在或不属于当前租户")
|
||||
return
|
||||
}
|
||||
var expiry *time.Time
|
||||
if strings.TrimSpace(payload.ExpiryDate) != "" {
|
||||
value, dateOK := validCompensationDate(payload.ExpiryDate)
|
||||
if !dateOK || value.Before(date) {
|
||||
c.compensationError(400, "失效日期无效")
|
||||
return
|
||||
}
|
||||
expiry = &value
|
||||
}
|
||||
now := time.Now()
|
||||
row.EmployeeID, row.SchemeName, row.EffectiveDate, row.ExpiryDate = payload.EmployeeID, strings.TrimSpace(payload.SchemeName), date, expiry
|
||||
row.BaseSalary, row.PostAllowance, row.PerformanceSalary = compensationMoney(payload.BaseSalary), compensationMoney(payload.PostAllowance), compensationMoney(payload.PerformanceSalary)
|
||||
row.TransportAllowance, row.MealAllowance, row.CommunicationAllowance = compensationMoney(payload.TransportAllowance), compensationMoney(payload.MealAllowance), compensationMoney(payload.CommunicationAllowance)
|
||||
row.SocialInsuranceBase, row.HousingFundBase, row.SocialInsuranceRate, row.HousingFundRate = compensationMoney(payload.SocialInsuranceBase), compensationMoney(payload.HousingFundBase), payload.SocialInsuranceRate, payload.HousingFundRate
|
||||
row.Status, row.Remark, row.UpdateTime = payload.Status, strings.TrimSpace(payload.Remark), &now
|
||||
if row.Status != 0 {
|
||||
row.Status = 1
|
||||
}
|
||||
if _, err = models.Orm.Update(&row); err != nil {
|
||||
c.compensationError(500, "更新薪酬方案失败")
|
||||
return
|
||||
}
|
||||
c.compensationOK(row)
|
||||
}
|
||||
|
||||
// DeleteScheme DELETE /backend/oa/compensation/schemes/:id
|
||||
func (c *BackendOaCompensationController) DeleteScheme() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||||
if !valid {
|
||||
c.compensationError(400, "无效方案ID")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
count, err := models.Orm.QueryTable(new(models.BackendOaCompensationScheme)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).Update(orm.Params{"is_deleted": int8(1), "delete_time": now, "update_time": now})
|
||||
if err != nil || count == 0 {
|
||||
c.compensationError(404, "薪酬方案不存在")
|
||||
return
|
||||
}
|
||||
c.compensationOK(nil)
|
||||
}
|
||||
|
||||
// List GET /backend/oa/compensation/payrolls
|
||||
func (c *BackendOaCompensationController) List() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
month, keyword := strings.TrimSpace(c.GetString("payroll_month")), strings.TrimSpace(c.GetString("keyword"))
|
||||
status := strings.TrimSpace(c.GetString("status"))
|
||||
qs := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("is_deleted", 0)
|
||||
if compensationMonthPattern.MatchString(month) {
|
||||
qs = qs.Filter("payroll_month", month)
|
||||
}
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("employee_name__icontains", keyword)
|
||||
}
|
||||
if status == "0" || status == "1" || status == "2" || status == "3" {
|
||||
value, _ := strconv.Atoi(status)
|
||||
qs = qs.Filter("status", int8(value))
|
||||
}
|
||||
total, _ := qs.Count()
|
||||
var rows []models.BackendOaPayroll
|
||||
_, err = qs.OrderBy("-payroll_month", "employee_name", "-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
log.Printf("薪资单查询失败: tid=%d month=%q keyword=%q status=%q err=%v", claims.TenantId, month, keyword, status, err)
|
||||
c.compensationError(500, "查询薪资单失败,请检查薪酬管理数据表是否已创建")
|
||||
return
|
||||
}
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
list = append(list, payrollMap(row))
|
||||
}
|
||||
c.compensationOK(map[string]interface{}{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// Detail GET /backend/oa/compensation/payrolls/:id
|
||||
func (c *BackendOaCompensationController) Detail() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||||
if !valid {
|
||||
c.compensationError(400, "无效薪资单ID")
|
||||
return
|
||||
}
|
||||
var row models.BackendOaPayroll
|
||||
if models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||||
c.compensationError(404, "薪资单不存在")
|
||||
return
|
||||
}
|
||||
var items []models.BackendOaPayrollItem
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendOaPayrollItem)).Filter("tid", claims.TenantId).Filter("payroll_id", id).Filter("is_deleted", 0).OrderBy("sort_order", "id").All(&items)
|
||||
data := payrollMap(row)
|
||||
data["items"] = items
|
||||
c.compensationOK(data)
|
||||
}
|
||||
|
||||
// Create POST /backend/oa/compensation/payrolls
|
||||
func (c *BackendOaCompensationController) Create() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
var payload compensationPayrollPayload
|
||||
if !c.parseBody(&payload) {
|
||||
return
|
||||
}
|
||||
if payload.EmployeeID == 0 || !compensationMonthPattern.MatchString(strings.TrimSpace(payload.PayrollMonth)) || !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance, payload.MealAllowance, payload.CommunicationAllowance, payload.OvertimePay, payload.Bonus, payload.OtherAddition, payload.LeaveDeduction, payload.LateDeduction, payload.OtherDeduction, payload.SocialInsurance, payload.HousingFund, payload.PersonalIncomeTax) {
|
||||
c.compensationError(400, "员工、工资月份或金额无效")
|
||||
return
|
||||
}
|
||||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||||
if !ok {
|
||||
c.compensationError(400, "员工不存在或不属于当前租户")
|
||||
return
|
||||
}
|
||||
exists := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("employee_id", payload.EmployeeID).Filter("payroll_month", payload.PayrollMonth).Filter("is_deleted", 0).Exist()
|
||||
if exists {
|
||||
c.compensationError(409, "该员工本月薪资单已存在")
|
||||
return
|
||||
}
|
||||
row := &models.BackendOaPayroll{Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, PayrollMonth: strings.TrimSpace(payload.PayrollMonth), Status: 0}
|
||||
c.fillPayroll(row, payload, employee)
|
||||
if _, err = models.Orm.Insert(row); err != nil {
|
||||
c.compensationError(500, "创建薪资单失败")
|
||||
return
|
||||
}
|
||||
if err = c.saveItems(uint64(claims.TenantId), row.ID, payload.Items); err != nil {
|
||||
c.compensationError(400, "自定义薪资项无效")
|
||||
return
|
||||
}
|
||||
c.compensationOK(payrollMap(*row))
|
||||
}
|
||||
|
||||
// Update POST /backend/oa/compensation/payrolls/:id
|
||||
func (c *BackendOaCompensationController) Update() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||||
if !valid {
|
||||
c.compensationError(400, "无效薪资单ID")
|
||||
return
|
||||
}
|
||||
var payload compensationPayrollPayload
|
||||
if !c.parseBody(&payload) {
|
||||
return
|
||||
}
|
||||
var row models.BackendOaPayroll
|
||||
if models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||||
c.compensationError(404, "薪资单不存在")
|
||||
return
|
||||
}
|
||||
if row.Status != 0 {
|
||||
c.compensationError(400, "仅草稿薪资单可以编辑")
|
||||
return
|
||||
}
|
||||
if payload.EmployeeID == 0 || !compensationMonthPattern.MatchString(strings.TrimSpace(payload.PayrollMonth)) || !validateNonNegative(payload.BaseSalary, payload.PostAllowance, payload.PerformanceSalary, payload.TransportAllowance, payload.MealAllowance, payload.CommunicationAllowance, payload.OvertimePay, payload.Bonus, payload.OtherAddition, payload.LeaveDeduction, payload.LateDeduction, payload.OtherDeduction, payload.SocialInsurance, payload.HousingFund, payload.PersonalIncomeTax) {
|
||||
c.compensationError(400, "薪资单内容无效")
|
||||
return
|
||||
}
|
||||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||||
if !ok {
|
||||
c.compensationError(400, "员工不存在")
|
||||
return
|
||||
}
|
||||
exists := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("employee_id", payload.EmployeeID).Filter("payroll_month", payload.PayrollMonth).Filter("is_deleted", 0).Filter("id__ne", id).Exist()
|
||||
if exists {
|
||||
c.compensationError(409, "该员工本月薪资单已存在")
|
||||
return
|
||||
}
|
||||
row.EmployeeID, row.PayrollMonth = payload.EmployeeID, strings.TrimSpace(payload.PayrollMonth)
|
||||
c.fillPayroll(&row, payload, employee)
|
||||
now := time.Now()
|
||||
row.UpdateTime = &now
|
||||
if _, err = models.Orm.Update(&row); err != nil {
|
||||
c.compensationError(500, "更新薪资单失败")
|
||||
return
|
||||
}
|
||||
if err = c.saveItems(uint64(claims.TenantId), row.ID, payload.Items); err != nil {
|
||||
c.compensationError(400, "自定义薪资项无效")
|
||||
return
|
||||
}
|
||||
c.compensationOK(payrollMap(row))
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/oa/compensation/payrolls/:id
|
||||
func (c *BackendOaCompensationController) Delete() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||||
if !valid {
|
||||
c.compensationError(400, "无效薪资单ID")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
count, err := models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("status", 0).Filter("is_deleted", 0).Update(orm.Params{"is_deleted": int8(1), "delete_time": now, "update_time": now})
|
||||
if err != nil || count == 0 {
|
||||
c.compensationError(400, "薪资单不存在或当前状态不可删除")
|
||||
return
|
||||
}
|
||||
c.compensationOK(nil)
|
||||
}
|
||||
|
||||
// UpdateStatus POST /backend/oa/compensation/payrolls/:id/status
|
||||
func (c *BackendOaCompensationController) UpdateStatus() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
id, valid := compensationID(c.Ctx.Input.Param(":id"))
|
||||
if !valid {
|
||||
c.compensationError(400, "无效薪资单ID")
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
if !c.parseBody(&payload) {
|
||||
return
|
||||
}
|
||||
if payload.Status < 0 || payload.Status > 3 {
|
||||
c.compensationError(400, "无效状态")
|
||||
return
|
||||
}
|
||||
var row models.BackendOaPayroll
|
||||
if models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).One(&row) != nil {
|
||||
c.compensationError(404, "薪资单不存在")
|
||||
return
|
||||
}
|
||||
if row.Status == 2 && payload.Status != 2 {
|
||||
c.compensationError(400, "已发放薪资单不可修改状态")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
row.Status, row.UpdateTime = payload.Status, &now
|
||||
if payload.Status == 1 {
|
||||
row.ConfirmedAt = &now
|
||||
}
|
||||
if payload.Status == 2 {
|
||||
row.PaidAt = &now
|
||||
}
|
||||
if _, err = models.Orm.Update(&row, "Status", "ConfirmedAt", "PaidAt", "UpdateTime"); err != nil {
|
||||
c.compensationError(500, "更新状态失败")
|
||||
return
|
||||
}
|
||||
c.compensationOK(payrollMap(row))
|
||||
}
|
||||
|
||||
// Dashboard GET /backend/oa/compensation/dashboard
|
||||
func (c *BackendOaCompensationController) Dashboard() {
|
||||
claims, err := c.compensationClaims()
|
||||
if err != nil {
|
||||
c.compensationError(401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
month := strings.TrimSpace(c.GetString("payroll_month"))
|
||||
if !compensationMonthPattern.MatchString(month) {
|
||||
month = time.Now().Format("2006-01")
|
||||
}
|
||||
var rows []models.BackendOaPayroll
|
||||
_, err = models.Orm.QueryTable(new(models.BackendOaPayroll)).Filter("tid", claims.TenantId).Filter("payroll_month", month).Filter("is_deleted", 0).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
log.Printf("薪酬仪表盘统计失败: tid=%d month=%q err=%v", claims.TenantId, month, err)
|
||||
c.compensationError(500, "统计失败,请检查薪酬管理数据表是否已创建")
|
||||
return
|
||||
}
|
||||
totalNet, totalGross, draft, confirmed, paid := 0.0, 0.0, 0, 0, 0
|
||||
for _, row := range rows {
|
||||
totalNet += row.NetSalary
|
||||
totalGross += row.GrossSalary
|
||||
switch row.Status {
|
||||
case 0:
|
||||
draft++
|
||||
case 1:
|
||||
confirmed++
|
||||
case 2:
|
||||
paid++
|
||||
}
|
||||
}
|
||||
c.compensationOK(map[string]interface{}{"payroll_month": month, "employee_count": len(rows), "total_gross_salary": compensationMoney(totalGross), "total_net_salary": compensationMoney(totalNet), "draft_count": draft, "confirmed_count": confirmed, "paid_count": paid})
|
||||
}
|
||||
@@ -24,9 +24,9 @@ type BackendEmployeeFile struct {
|
||||
EmergencyPhone string `orm:"column(emergency_phone);size(20);default()" json:"emergency_phone"`
|
||||
EmergencyRelation string `orm:"column(emergency_relation);size(30);default()" json:"emergency_relation"`
|
||||
WorkEmail string `orm:"column(work_email);size(100);default()" json:"work_email"`
|
||||
HireDate string `orm:"column(hire_date);type(date);null" json:"hire_date"`
|
||||
RegularDate string `orm:"column(regular_date);type(date);null" json:"regular_date"`
|
||||
LeaveDate string `orm:"column(leave_date);type(date);null" json:"leave_date"`
|
||||
HireDate *time.Time `orm:"column(hire_date);type(date);null" json:"hire_date"`
|
||||
RegularDate *time.Time `orm:"column(regular_date);type(date);null" json:"regular_date"`
|
||||
LeaveDate *time.Time `orm:"column(leave_date);type(date);null" json:"leave_date"`
|
||||
EmploymentStatus int8 `orm:"column(employment_status);default(2)" json:"employment_status"`
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
@@ -51,8 +51,8 @@ type BackendEmployeeFileRecord struct {
|
||||
Title string `orm:"column(title);size(100);default()" json:"title"`
|
||||
SubTitle string `orm:"column(sub_title);size(100);default()" json:"sub_title"`
|
||||
Extra string `orm:"column(extra);size(100);default()" json:"extra"`
|
||||
StartDate string `orm:"column(start_date);type(date);null" json:"start_date"`
|
||||
EndDate string `orm:"column(end_date);type(date);null" json:"end_date"`
|
||||
StartDate *time.Time `orm:"column(start_date);type(date);null" json:"start_date"`
|
||||
EndDate *time.Time `orm:"column(end_date);type(date);null" json:"end_date"`
|
||||
Description string `orm:"column(description);type(text);null" json:"description"`
|
||||
AttachmentURL string `orm:"column(attachment_url);size(500);default()" json:"attachment_url"`
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// BackendOaCompensationScheme 员工薪酬方案表 yz_backend_oa_compensation_schemes。
|
||||
// 一个员工可保留多份历史方案,生效中的方案由 effective_date 与 status 共同决定。
|
||||
type BackendOaCompensationScheme struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
EmployeeID uint64 `orm:"column(employee_id)" json:"employee_id"`
|
||||
SchemeName string `orm:"column(scheme_name);size(100)" json:"scheme_name"`
|
||||
EffectiveDate time.Time `orm:"column(effective_date);type(date)" json:"effective_date"`
|
||||
ExpiryDate *time.Time `orm:"column(expiry_date);null;type(date)" json:"expiry_date"`
|
||||
BaseSalary float64 `orm:"column(base_salary);type(decimal);digits(12);decimals(2);default(0)" json:"base_salary"`
|
||||
PostAllowance float64 `orm:"column(post_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"post_allowance"`
|
||||
PerformanceSalary float64 `orm:"column(performance_salary);type(decimal);digits(12);decimals(2);default(0)" json:"performance_salary"`
|
||||
TransportAllowance float64 `orm:"column(transport_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"transport_allowance"`
|
||||
MealAllowance float64 `orm:"column(meal_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"meal_allowance"`
|
||||
CommunicationAllowance float64 `orm:"column(communication_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"communication_allowance"`
|
||||
SocialInsuranceBase float64 `orm:"column(social_insurance_base);type(decimal);digits(12);decimals(2);default(0)" json:"social_insurance_base"`
|
||||
HousingFundBase float64 `orm:"column(housing_fund_base);type(decimal);digits(12);decimals(2);default(0)" json:"housing_fund_base"`
|
||||
SocialInsuranceRate float64 `orm:"column(social_insurance_rate);type(decimal);digits(6);decimals(4);default(0)" json:"social_insurance_rate"`
|
||||
HousingFundRate float64 `orm:"column(housing_fund_rate);type(decimal);digits(6);decimals(4);default(0)" json:"housing_fund_rate"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *BackendOaCompensationScheme) TableName() string {
|
||||
return "yz_backend_oa_compensation_schemes"
|
||||
}
|
||||
|
||||
// BackendOaPayroll 月度薪资单主表 yz_backend_oa_compensation_payrolls。
|
||||
// EmployeeName / Department / Position 为生成时的快照,避免组织架构变更影响历史工资单。
|
||||
type BackendOaPayroll struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
EmployeeID uint64 `orm:"column(employee_id)" json:"employee_id"`
|
||||
SchemeID *uint64 `orm:"column(scheme_id);null" json:"scheme_id"`
|
||||
PayrollMonth string `orm:"column(payroll_month);size(7)" json:"payroll_month"`
|
||||
EmployeeName string `orm:"column(employee_name);size(50)" json:"employee_name"`
|
||||
Department string `orm:"column(department);size(100)" json:"department"`
|
||||
Position string `orm:"column(position);size(100)" json:"position"`
|
||||
BaseSalary float64 `orm:"column(base_salary);type(decimal);digits(12);decimals(2);default(0)" json:"base_salary"`
|
||||
PostAllowance float64 `orm:"column(post_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"post_allowance"`
|
||||
PerformanceSalary float64 `orm:"column(performance_salary);type(decimal);digits(12);decimals(2);default(0)" json:"performance_salary"`
|
||||
TransportAllowance float64 `orm:"column(transport_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"transport_allowance"`
|
||||
MealAllowance float64 `orm:"column(meal_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"meal_allowance"`
|
||||
CommunicationAllowance float64 `orm:"column(communication_allowance);type(decimal);digits(12);decimals(2);default(0)" json:"communication_allowance"`
|
||||
OvertimePay float64 `orm:"column(overtime_pay);type(decimal);digits(12);decimals(2);default(0)" json:"overtime_pay"`
|
||||
Bonus float64 `orm:"column(bonus);type(decimal);digits(12);decimals(2);default(0)" json:"bonus"`
|
||||
OtherAddition float64 `orm:"column(other_addition);type(decimal);digits(12);decimals(2);default(0)" json:"other_addition"`
|
||||
LeaveDeduction float64 `orm:"column(leave_deduction);type(decimal);digits(12);decimals(2);default(0)" json:"leave_deduction"`
|
||||
LateDeduction float64 `orm:"column(late_deduction);type(decimal);digits(12);decimals(2);default(0)" json:"late_deduction"`
|
||||
OtherDeduction float64 `orm:"column(other_deduction);type(decimal);digits(12);decimals(2);default(0)" json:"other_deduction"`
|
||||
SocialInsurance float64 `orm:"column(social_insurance);type(decimal);digits(12);decimals(2);default(0)" json:"social_insurance"`
|
||||
HousingFund float64 `orm:"column(housing_fund);type(decimal);digits(12);decimals(2);default(0)" json:"housing_fund"`
|
||||
PersonalIncomeTax float64 `orm:"column(personal_income_tax);type(decimal);digits(12);decimals(2);default(0)" json:"personal_income_tax"`
|
||||
GrossSalary float64 `orm:"column(gross_salary);type(decimal);digits(12);decimals(2);default(0)" json:"gross_salary"`
|
||||
TotalDeduction float64 `orm:"column(total_deduction);type(decimal);digits(12);decimals(2);default(0)" json:"total_deduction"`
|
||||
NetSalary float64 `orm:"column(net_salary);type(decimal);digits(12);decimals(2);default(0)" json:"net_salary"`
|
||||
Status int8 `orm:"column(status);default(0)" json:"status"`
|
||||
ConfirmedAt *time.Time `orm:"column(confirmed_at);null;type(datetime)" json:"confirmed_at"`
|
||||
PaidAt *time.Time `orm:"column(paid_at);null;type(datetime)" json:"paid_at"`
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *BackendOaPayroll) TableName() string {
|
||||
return "yz_backend_oa_compensation_payrolls"
|
||||
}
|
||||
|
||||
// BackendOaPayrollItem 薪资单自定义增减项 yz_backend_oa_compensation_payroll_items。
|
||||
type BackendOaPayrollItem struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
PayrollID uint64 `orm:"column(payroll_id)" json:"payroll_id"`
|
||||
ItemName string `orm:"column(item_name);size(100)" json:"item_name"`
|
||||
ItemType int8 `orm:"column(item_type);default(1)" json:"item_type"`
|
||||
Amount float64 `orm:"column(amount);type(decimal);digits(12);decimals(2);default(0)" json:"amount"`
|
||||
Remark string `orm:"column(remark);size(500);null" json:"remark"`
|
||||
SortOrder int `orm:"column(sort_order);default(0)" json:"sort_order"`
|
||||
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);null;type(datetime)" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *BackendOaPayrollItem) TableName() string {
|
||||
return "yz_backend_oa_compensation_payroll_items"
|
||||
}
|
||||
@@ -101,6 +101,9 @@ func Init(_ string) {
|
||||
new(BackendScheduleReminderSendLog),
|
||||
|
||||
new(OaSchedule),
|
||||
new(BackendOaCompensationScheme),
|
||||
new(BackendOaPayroll),
|
||||
new(BackendOaPayrollItem),
|
||||
new(BackendEmployeeFile),
|
||||
new(BackendEmployeeFileRecord),
|
||||
)
|
||||
|
||||
@@ -108,6 +108,7 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/oa/employeefile/detail/:id", &controllers.BackendEmployeeFileController{}, "get:Detail")
|
||||
beego.Router("/backend/oa/employeefile/create", &controllers.BackendEmployeeFileController{}, "post:Create")
|
||||
beego.Router("/backend/oa/employeefile/update/:id", &controllers.BackendEmployeeFileController{}, "post:Update")
|
||||
beego.Router("/backend/oa/employeefile/certificates/:id", &controllers.BackendEmployeeFileController{}, "post:UpdateCertificates")
|
||||
beego.Router("/backend/oa/employeefile/delete/:id", &controllers.BackendEmployeeFileController{}, "delete:Delete")
|
||||
beego.Router("/backend/oa/employeefile/filedEmployees", &controllers.BackendEmployeeFileController{}, "get:FiledEmployees")
|
||||
beego.Router("/backend/oa/employeefile/record/create", &controllers.BackendEmployeeFileController{}, "post:CreateRecord")
|
||||
@@ -240,6 +241,15 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/reminder/batchDelete", &controllers.BackendReminderController{}, "post:BatchDeleteReminder")
|
||||
beego.Router("/backend/reminder/finish/:id", &controllers.BackendReminderController{}, "post:FinishReminder")
|
||||
|
||||
// OA 薪酬管理(薪酬方案 / 月度薪资单)
|
||||
beego.Router("/backend/oa/compensation/employees", &controllers.BackendOaCompensationController{}, "get:Employees")
|
||||
beego.Router("/backend/oa/compensation/dashboard", &controllers.BackendOaCompensationController{}, "get:Dashboard")
|
||||
beego.Router("/backend/oa/compensation/schemes", &controllers.BackendOaCompensationController{}, "get:SchemeList;post:CreateScheme")
|
||||
beego.Router("/backend/oa/compensation/schemes/:id", &controllers.BackendOaCompensationController{}, "post:UpdateScheme;delete:DeleteScheme")
|
||||
beego.Router("/backend/oa/compensation/payrolls", &controllers.BackendOaCompensationController{}, "get:List;post:Create")
|
||||
beego.Router("/backend/oa/compensation/payrolls/:id", &controllers.BackendOaCompensationController{}, "get:Detail;post:Update;delete:Delete")
|
||||
beego.Router("/backend/oa/compensation/payrolls/:id/status", &controllers.BackendOaCompensationController{}, "post:UpdateStatus")
|
||||
|
||||
// OA日程管理(日历待办)
|
||||
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
|
||||
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- 薪酬管理菜单(租户端):挂在“办公自动化”模块目录下。
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 本脚本只创建/修正 yz_system_menu 菜单记录,不涉及薪酬业务数据表。
|
||||
-- 2. 路由由前端根据登录接口返回的菜单数据动态注册;
|
||||
-- 不需要在 backend/src/router/index.js 中增加静态业务路由。
|
||||
-- 3. 执行完成后,请在角色菜单权限关联表中为目标角色授予“薪酬管理”菜单权限,
|
||||
-- 然后退出重登或清理菜单缓存。
|
||||
--
|
||||
-- views: [2] = 租户端
|
||||
-- type: 1 = 目录,2 = 页面
|
||||
-- 脚本可重复执行:通过 path 判重,已有记录会被修正为当前配置。
|
||||
|
||||
-- 确保“办公自动化”父级目录存在。
|
||||
INSERT INTO `yz_system_menu`
|
||||
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT
|
||||
0, '办公自动化', '/apps/oa', '', 'Document', 31, 1, 1, '[2]', 1, '办公自动化模块'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM (SELECT * FROM `yz_system_menu`) AS t
|
||||
WHERE t.`path` = '/apps/oa'
|
||||
);
|
||||
|
||||
SET @oa_pid := (
|
||||
SELECT `id`
|
||||
FROM `yz_system_menu`
|
||||
WHERE `path` = '/apps/oa'
|
||||
ORDER BY `id`
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- 新增“薪酬管理”页面菜单。
|
||||
INSERT INTO `yz_system_menu`
|
||||
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT
|
||||
@oa_pid,
|
||||
'薪酬管理',
|
||||
'/apps/oa/compensation',
|
||||
'/apps/oa/compensation/index.vue',
|
||||
'Money',
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
'[2]',
|
||||
2,
|
||||
'员工薪资档案与月度工资核算'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM (SELECT * FROM `yz_system_menu`) AS t
|
||||
WHERE t.`path` = '/apps/oa/compensation'
|
||||
);
|
||||
|
||||
-- 修正历史或手工新增菜单的动态组件路径及基础属性。
|
||||
UPDATE `yz_system_menu`
|
||||
SET
|
||||
`pid` = @oa_pid,
|
||||
`title` = '薪酬管理',
|
||||
`component_path` = '/apps/oa/compensation/index.vue',
|
||||
`icon` = 'Money',
|
||||
`sort` = 6,
|
||||
`status` = 1,
|
||||
`is_visible` = 1,
|
||||
`views` = '[2]',
|
||||
`type` = 2,
|
||||
`remark` = '员工薪资档案与月度工资核算'
|
||||
WHERE `path` = '/apps/oa/compensation';
|
||||
@@ -1,96 +1,91 @@
|
||||
-- 组织架构菜单(租户端):进销存与办公自动化各自一套入口,指向共用页面组件。
|
||||
--
|
||||
-- 前置:yz_system_menu 中已存在 path 为 /apps/erp 与 /apps/oa 的模块目录菜单。
|
||||
-- 若不存在,脚本会自动创建这两个目录菜单。
|
||||
-- views 字段:[1] 平台端 / [2] 租户端 / [1,2] 双端。组织架构属于租户端,固定为 [2]。
|
||||
-- type 字段:1-目录 2-页面。
|
||||
--
|
||||
-- 脚本可重复执行:按 path 判重,已存在的菜单会更新组件路径与标题。
|
||||
-- 全局基础设置:组织架构、人员管理、职位管理
|
||||
-- 三项功能使用统一组织数据,统一归入全局“基础设置”,不再隶属 OA 或 ERP。
|
||||
-- views:[2] 租户后台;type:1-目录,2-页面。
|
||||
-- 可重复执行。最后会删除 OA / ERP 下的历史重复页面菜单。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 确保模块目录存在
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT 0, '进销存', '/apps/erp', '', 'Goods', 30, 1, 1, '[2]', 1, '进销存模块'
|
||||
-- 1. 确保全局“基础设置”目录存在
|
||||
INSERT INTO `yz_system_menu`
|
||||
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT
|
||||
0, '基础设置', '/basic-settings', '', 'Setting', 20, 1, 1, '[2]', 1, '全局基础数据维护'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp'
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
|
||||
WHERE t.`path` = '/basic-settings'
|
||||
);
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT 0, '办公自动化', '/apps/oa', '', 'Document', 31, 1, 1, '[2]', 1, '办公自动化模块'
|
||||
SET @basic_settings_pid := (
|
||||
SELECT `id` FROM `yz_system_menu`
|
||||
WHERE `path` = '/basic-settings'
|
||||
ORDER BY `id` LIMIT 1
|
||||
);
|
||||
|
||||
-- 2. 创建全局入口
|
||||
INSERT INTO `yz_system_menu`
|
||||
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT
|
||||
@basic_settings_pid, '组织架构', '/basic-settings/organization',
|
||||
'/basicSettings/organization/index.vue', 'OfficeBuilding', 1, 1, 1, '[2]', 2,
|
||||
'全局组织架构维护,与所有业务模块共用'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa'
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
|
||||
WHERE t.`path` = '/basic-settings/organization'
|
||||
);
|
||||
|
||||
SET @erp_pid := (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/apps/erp' ORDER BY `id` LIMIT 1);
|
||||
SET @oa_pid := (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/apps/oa' ORDER BY `id` LIMIT 1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 进销存:组织架构 / 员工管理 / 职位管理
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT @erp_pid, '组织架构', '/apps/erp/organization', '/apps/erp/organization/index.vue', 'OfficeBuilding', 1, 1, 1, '[2]', 2, '与OA共用组织数据'
|
||||
INSERT INTO `yz_system_menu`
|
||||
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT
|
||||
@basic_settings_pid, '人员管理', '/basic-settings/employee',
|
||||
'/basicSettings/employee/index.vue', 'User', 2, 1, 1, '[2]', 2,
|
||||
'全局人员维护,与所有业务模块共用'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp/organization'
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
|
||||
WHERE t.`path` = '/basic-settings/employee'
|
||||
);
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT @erp_pid, '员工管理', '/apps/erp/employee', '/apps/erp/employee/index.vue', 'User', 2, 1, 1, '[2]', 2, '与OA共用员工数据'
|
||||
INSERT INTO `yz_system_menu`
|
||||
(`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT
|
||||
@basic_settings_pid, '职位管理', '/basic-settings/position',
|
||||
'/basicSettings/position/index.vue', 'Postcard', 3, 1, 1, '[2]', 2,
|
||||
'全局职位维护,与所有业务模块共用'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp/employee'
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t
|
||||
WHERE t.`path` = '/basic-settings/position'
|
||||
);
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT @erp_pid, '职位管理', '/apps/erp/position', '/apps/erp/position/index.vue', 'Postcard', 3, 1, 1, '[2]', 2, '与OA共用职位数据'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/erp/position'
|
||||
-- 3. 修正已存在的全局入口
|
||||
UPDATE `yz_system_menu`
|
||||
SET `pid` = @basic_settings_pid, `title` = '组织架构',
|
||||
`component_path` = '/basicSettings/organization/index.vue',
|
||||
`icon` = 'OfficeBuilding', `sort` = 1, `status` = 1, `is_visible` = 1,
|
||||
`views` = '[2]', `type` = 2, `remark` = '全局组织架构维护,与所有业务模块共用'
|
||||
WHERE `path` = '/basic-settings/organization';
|
||||
|
||||
UPDATE `yz_system_menu`
|
||||
SET `pid` = @basic_settings_pid, `title` = '人员管理',
|
||||
`component_path` = '/basicSettings/employee/index.vue',
|
||||
`icon` = 'User', `sort` = 2, `status` = 1, `is_visible` = 1,
|
||||
`views` = '[2]', `type` = 2, `remark` = '全局人员维护,与所有业务模块共用'
|
||||
WHERE `path` = '/basic-settings/employee';
|
||||
|
||||
UPDATE `yz_system_menu`
|
||||
SET `pid` = @basic_settings_pid, `title` = '职位管理',
|
||||
`component_path` = '/basicSettings/position/index.vue',
|
||||
`icon` = 'Postcard', `sort` = 3, `status` = 1, `is_visible` = 1,
|
||||
`views` = '[2]', `type` = 2, `remark` = '全局职位维护,与所有业务模块共用'
|
||||
WHERE `path` = '/basic-settings/position';
|
||||
|
||||
-- 4. 删除历史 OA / ERP 下的重复页面入口,不删除模块目录本身。
|
||||
DELETE FROM `yz_system_menu`
|
||||
WHERE `path` IN (
|
||||
'/apps/erp/organization',
|
||||
'/apps/erp/employee',
|
||||
'/apps/erp/position',
|
||||
'/apps/oa/organization',
|
||||
'/apps/oa/employee',
|
||||
'/apps/oa/position'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 办公自动化:组织架构 / 人员管理 / 职位管理
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT @oa_pid, '组织架构', '/apps/oa/organization', '/apps/oa/organization/index.vue', 'OfficeBuilding', 1, 1, 1, '[2]', 2, '与进销存共用组织数据'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa/organization'
|
||||
);
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT @oa_pid, '人员管理', '/apps/oa/employee', '/apps/oa/employee/index.vue', 'User', 2, 1, 1, '[2]', 2, '与进销存共用员工数据'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa/employee'
|
||||
);
|
||||
|
||||
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `remark`)
|
||||
SELECT @oa_pid, '职位管理', '/apps/oa/position', '/apps/oa/position/index.vue', 'Postcard', 3, 1, 1, '[2]', 2, '与进销存共用职位数据'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT * FROM `yz_system_menu`) AS t WHERE t.`path` = '/apps/oa/position'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. 修正已有菜单的组件路径(历史数据可能指向旧组件)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
UPDATE `yz_system_menu` SET `component_path` = '/apps/erp/organization/index.vue', `type` = 2, `views` = '[2]'
|
||||
WHERE `path` = '/apps/erp/organization';
|
||||
UPDATE `yz_system_menu` SET `component_path` = '/apps/erp/employee/index.vue', `type` = 2, `views` = '[2]'
|
||||
WHERE `path` = '/apps/erp/employee';
|
||||
UPDATE `yz_system_menu` SET `component_path` = '/apps/erp/position/index.vue', `type` = 2, `views` = '[2]'
|
||||
WHERE `path` = '/apps/erp/position';
|
||||
UPDATE `yz_system_menu` SET `component_path` = '/apps/oa/organization/index.vue', `type` = 2, `views` = '[2]'
|
||||
WHERE `path` = '/apps/oa/organization';
|
||||
UPDATE `yz_system_menu` SET `component_path` = '/apps/oa/employee/index.vue', `type` = 2, `views` = '[2]'
|
||||
WHERE `path` = '/apps/oa/employee';
|
||||
UPDATE `yz_system_menu` SET `component_path` = '/apps/oa/position/index.vue', `type` = 2, `views` = '[2]'
|
||||
WHERE `path` = '/apps/oa/position';
|
||||
|
||||
Reference in New Issue
Block a user