更新若干事项
This commit is contained in:
1 parent
68cdaf38fa
commit
9898962836
30 files changed
+7715
No files matched your search
Vendored
+2
@@ -31,6 +31,7 @@ declare module 'vue' {
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
@@ -79,6 +80,7 @@ declare module 'vue' {
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 工作排班:班次 / 排班方案 / 排班看板 / 临时调配
|
||||
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
|
||||
|
||||
/* ------------------------------- 班次 ------------------------------- */
|
||||
|
||||
export function getShiftList() {
|
||||
return request({ url: '/backend/oa/scheduling/shifts', method: 'get' })
|
||||
}
|
||||
|
||||
export function createShift(data) {
|
||||
return request({ url: '/backend/oa/scheduling/shifts', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateShift(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function deleteShift(id) {
|
||||
return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
/* ------------------------------- 员工 ------------------------------- */
|
||||
|
||||
// 排班可选员工:org_id 为空表示全部,include_child=1 含下级组织
|
||||
export function getSchedulingEmployees(params) {
|
||||
return request({ url: '/backend/oa/scheduling/employees', method: 'get', params })
|
||||
}
|
||||
|
||||
/* ------------------------------- 看板 ------------------------------- */
|
||||
|
||||
export function getScheduleBoard(params) {
|
||||
return request({ url: '/backend/oa/scheduling/board', method: 'get', params })
|
||||
}
|
||||
|
||||
/* ----------------------------- 排班模板 ----------------------------- */
|
||||
|
||||
// 平台预置 + 自定义模板(只读);租户调用后规则会复制到自己的方案
|
||||
export function getSchedTemplates(params) {
|
||||
return request({ url: '/backend/oa/scheduling/templates', method: 'get', params })
|
||||
}
|
||||
|
||||
// 调用模板:自动匹配 / 创建租户自己的班次,返回可直接用于方案的 pattern
|
||||
export function applySchedTemplate(templateId) {
|
||||
return request({ url: '/backend/oa/scheduling/templates/apply', method: 'post', data: { template_id: templateId } })
|
||||
}
|
||||
|
||||
/* ------------------------------- 方案 ------------------------------- */
|
||||
|
||||
export function getSchedulePlans(params) {
|
||||
return request({ url: '/backend/oa/scheduling/plans', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getSchedulePlanDetail(id) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/detail/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
// 预览方案覆盖人数与生成条数,不写入数据
|
||||
export function previewSchedulePlan(data) {
|
||||
return request({ url: '/backend/oa/scheduling/plans/preview', method: 'post', data })
|
||||
}
|
||||
|
||||
export function createSchedulePlan(data) {
|
||||
return request({ url: '/backend/oa/scheduling/plans/create', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateSchedulePlan(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/update/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
// overwrite=true 覆盖同一员工同一天已有排班,默认保留
|
||||
export function publishSchedulePlan(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/publish/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
// delete_entries=0 时仅删除方案,保留已生成的排班
|
||||
export function deleteSchedulePlan(id, params) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/delete/${id}`, method: 'delete', params })
|
||||
}
|
||||
|
||||
/* ----------------------------- 明细与调配 ----------------------------- */
|
||||
|
||||
// 临时排班:employee_ids + start_date/end_date + weekdays + shift_id
|
||||
// shift_id=0 表示清除这些日期的排班
|
||||
export function saveScheduleEntries(data) {
|
||||
return request({ url: '/backend/oa/scheduling/entries/save', method: 'post', data })
|
||||
}
|
||||
|
||||
export function swapScheduleEntry(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/entries/swap/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function clearScheduleEntries(data) {
|
||||
return request({ url: '/backend/oa/scheduling/entries/clear', method: 'post', data })
|
||||
}
|
||||
|
||||
export function getScheduleChanges(params) {
|
||||
return request({ url: '/backend/oa/scheduling/changes', method: 'get', params })
|
||||
}
|
||||
@@ -41,6 +41,13 @@ const staticMainChildren = [
|
||||
component: () => import("@/views/apps/oa/approvalcenter/settings.vue"),
|
||||
meta: { requiresAuth: true, title: "审批设置", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA 工作排班(静态注册兜底:三级菜单在动态路由重建前/菜单缓存未刷新时直链会落到 404)
|
||||
{
|
||||
path: "/apps/oa/scheduling/board",
|
||||
name: "OaSchedulingBoard",
|
||||
component: () => import("@/views/apps/oa/scheduling/index.vue"),
|
||||
meta: { requiresAuth: true, title: "工作排班", modulePath: "/apps/oa" }
|
||||
},
|
||||
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
|
||||
// 统一结构:index 为列表页,type 为分类页。
|
||||
{
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="临时排班 / 调配"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<el-alert type="info" :closable="false" show-icon class="entry-tip">
|
||||
针对单个员工或一批员工做临时安排;同一员工同一天已有排班时会被覆盖,并在变更记录中留痕。
|
||||
</el-alert>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px" class="entry-form">
|
||||
<el-form-item label="员工范围">
|
||||
<div class="scope-row">
|
||||
<el-tree-select
|
||||
v-model="filterOrgID"
|
||||
:data="orgTree"
|
||||
:props="{ label: 'org_name', value: 'id', children: 'children' }"
|
||||
node-key="id"
|
||||
check-strictly
|
||||
filterable
|
||||
clearable
|
||||
placeholder="按公司/部门/小组筛选员工(可选)"
|
||||
style="flex: 1"
|
||||
@change="loadEmployees"
|
||||
/>
|
||||
<el-button link type="primary" @click="selectAll">全选</el-button>
|
||||
<el-button link @click="clearSelected">清空</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="员工" prop="employee_ids">
|
||||
<el-select
|
||||
v-model="form.employee_ids"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="可选择一个或多个员工"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="employee in employees" :key="employee.id" :label="employee.label" :value="employee.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-grid">
|
||||
<el-form-item label="日期范围" prop="date_range">
|
||||
<el-date-picker
|
||||
v-model="form.date_range"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:clearable="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="生效星期">
|
||||
<el-checkbox-group v-model="form.weekdays">
|
||||
<el-checkbox v-for="day in WEEKDAYS" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="班次" prop="shift_id">
|
||||
<el-select v-model="form.shift_id" placeholder="选择班次" style="width: 100%">
|
||||
<el-option v-for="shift in shifts" :key="shift.id" :label="`${shift.name}(${shift.work_start}-${shift.work_end})`" :value="shift.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注 / 原因">
|
||||
<el-input v-model="form.note" maxlength="255" placeholder="如:项目上线临时换班" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="danger" plain :loading="clearing" @click="submitClear">清除这些日期排班</el-button>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitSave">保存排班</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { oaOrganizationApi } from '@/api/organization'
|
||||
import { buildOrgTree } from '@/views/apps/organization/composables'
|
||||
import { getSchedulingEmployees, getShiftList, saveScheduleEntries } from '@/api/oaScheduling'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
presetEmployeeIds: { type: Array, default: () => [] },
|
||||
presetDate: { type: String, default: '' }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'changed'])
|
||||
|
||||
const WEEKDAYS = [
|
||||
{ value: 1, label: '一' },
|
||||
{ value: 2, label: '二' },
|
||||
{ value: 3, label: '三' },
|
||||
{ value: 4, label: '四' },
|
||||
{ value: 5, label: '五' },
|
||||
{ value: 6, label: '六' },
|
||||
{ value: 7, label: '日' }
|
||||
]
|
||||
|
||||
function unwrap(res) {
|
||||
if (res && typeof res === 'object' && 'code' in res) {
|
||||
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
|
||||
return res.data
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const clearing = ref(false)
|
||||
const employees = ref([])
|
||||
const shifts = ref([])
|
||||
const orgTree = ref([])
|
||||
const filterOrgID = ref(null)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
employee_ids: [],
|
||||
date_range: [],
|
||||
weekdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
shift_id: null,
|
||||
note: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
employee_ids: [{ required: true, message: '请选择员工', trigger: 'change' }],
|
||||
date_range: [{ required: true, message: '请选择日期范围', trigger: 'change' }],
|
||||
shift_id: [{ required: true, message: '请选择班次', trigger: 'change' }]
|
||||
}
|
||||
|
||||
async function handleOpen() {
|
||||
form.employee_ids = [...(props.presetEmployeeIds || [])]
|
||||
form.date_range = props.presetDate ? [props.presetDate, props.presetDate] : []
|
||||
form.weekdays = [1, 2, 3, 4, 5, 6, 7]
|
||||
form.note = ''
|
||||
form.shift_id = null
|
||||
filterOrgID.value = null
|
||||
await Promise.all([loadOrgs(), loadEmployees(), loadShifts()])
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const data = unwrap(await oaOrganizationApi.getOrganizationList())
|
||||
orgTree.value = buildOrgTree(Array.isArray(data) ? data : data?.list || [])
|
||||
} catch {
|
||||
orgTree.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 按当前组织筛选候选项;已选中的员工不受影响
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const data = unwrap(await getSchedulingEmployees({
|
||||
org_id: filterOrgID.value || undefined,
|
||||
include_child: 1
|
||||
})) || {}
|
||||
employees.value = (data.list || []).map((item) => ({
|
||||
...item,
|
||||
label: `${item.name}${item.department_name ? `(${item.department_name})` : ''}`
|
||||
}))
|
||||
} catch {
|
||||
employees.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
const merged = new Set(form.employee_ids)
|
||||
employees.value.forEach((item) => merged.add(item.id))
|
||||
form.employee_ids = [...merged]
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
form.employee_ids = []
|
||||
}
|
||||
|
||||
async function loadShifts() {
|
||||
try {
|
||||
const data = unwrap(await getShiftList()) || {}
|
||||
shifts.value = (data.list || []).filter((item) => item.status === 1)
|
||||
if (!form.shift_id) {
|
||||
form.shift_id = data.default_id || shifts.value[0]?.id || null
|
||||
}
|
||||
} catch {
|
||||
shifts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
employee_ids: form.employee_ids,
|
||||
start_date: form.date_range?.[0],
|
||||
end_date: form.date_range?.[1],
|
||||
weekdays: form.weekdays,
|
||||
note: form.note
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSave() {
|
||||
if (!formRef.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const stats = unwrap(await saveScheduleEntries({ ...buildPayload(), shift_id: form.shift_id })) || {}
|
||||
ElMessage.success(
|
||||
`排班完成:新增 ${stats.created || 0} 条,调整 ${stats.updated || 0} 条,跳过 ${stats.skipped || 0} 条`
|
||||
)
|
||||
emit('update:modelValue', false)
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '保存排班失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitClear() {
|
||||
if (!formRef.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认清除所选员工在这些日期的排班?', '清除排班', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
clearing.value = true
|
||||
try {
|
||||
const stats = unwrap(await saveScheduleEntries({ ...buildPayload(), shift_id: 0 })) || {}
|
||||
ElMessage.success(`已清除 ${stats.cleared || 0} 条排班`)
|
||||
emit('update:modelValue', false)
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '清除失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.entry-tip {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.entry-form {
|
||||
padding-right: 8px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.scope-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,821 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="排班方案"
|
||||
width="980px"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<!-- 列表视图 -->
|
||||
<template v-if="!formVisible">
|
||||
<div class="plan-toolbar">
|
||||
<div class="plan-tip">方案按公司、部门(小组)或指定员工下发排班,发布后可在看板逐日调整。</div>
|
||||
<div class="plan-toolbar-actions">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索方案名称"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadPlans"
|
||||
/>
|
||||
<el-button size="small" @click="loadPlans">查询</el-button>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openCreate">新建方案</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="plans" v-loading="loading" size="small" max-height="430">
|
||||
<el-table-column prop="plan_name" label="方案名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.plan_type === 2 ? 'warning' : ''" size="small">
|
||||
{{ row.plan_type === 2 ? "临时" : "常规" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="org_name" label="作用范围" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="周期" width="210">
|
||||
<template #default="{ row }">{{ row.start_date }} ~ {{ row.end_date }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="周期规则" width="150">
|
||||
<template #default="{ row }">{{ ruleText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模板 / 班次" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.align_mode" size="small" type="warning">
|
||||
{{ row.template_name || '模板规则' }}
|
||||
</el-tag>
|
||||
<span v-else>
|
||||
<span class="shift-dot" :style="{ background: row.color }" />{{ row.shift_name }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.publish_status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.publish_status === 1 ? "已发布" : "未发布" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="publishPlan(row, false)">发布</el-button>
|
||||
<el-dropdown trigger="click" @command="(command) => handlePublishCommand(row, command)">
|
||||
<el-button link type="primary">更多<el-icon class="ml2"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="overwrite">覆盖发布</el-dropdown-item>
|
||||
<el-dropdown-item command="edit">编辑方案</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>删除方案</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无排班方案" :image-size="60" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadPlans"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 新建 / 编辑表单 -->
|
||||
<template v-else>
|
||||
<div class="plan-form-head">
|
||||
<span class="form-title">{{ editingID ? "编辑排班方案" : "新建排班方案" }}</span>
|
||||
<el-button link @click="formVisible = false">返回方案列表</el-button>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="方案名称" prop="plan_name">
|
||||
<el-input v-model="form.plan_name" maxlength="100" placeholder="如:技术部 11 月早班" />
|
||||
</el-form-item>
|
||||
<el-form-item label="方案类型">
|
||||
<el-radio-group v-model="form.plan_type">
|
||||
<el-radio :value="1">常规排班</el-radio>
|
||||
<el-radio :value="2">临时方案</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排班方式">
|
||||
<el-radio-group v-model="form.rule_mode" @change="handleRuleModeChange">
|
||||
<el-radio value="template">使用排班模板</el-radio>
|
||||
<el-radio value="weekday">按星期排班</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式:套用平台模板的周期规则,规则会复制到本方案,可继续微调 -->
|
||||
<template v-if="form.rule_mode === 'template'">
|
||||
<div class="form-grid template-row">
|
||||
<el-form-item label="排班模板" prop="template_id">
|
||||
<el-select
|
||||
v-model="form.template_id"
|
||||
placeholder="选择模板"
|
||||
style="width: 100%"
|
||||
@change="handleTemplateChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:value="tpl.id"
|
||||
:label="`${tpl.name}(${tpl.template_type_text})`"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板规则">
|
||||
<span class="template-summary">
|
||||
{{ currentTemplate?.summary || '选择模板后自动带入规则' }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-if="form.pattern.length"
|
||||
:data="form.pattern"
|
||||
size="small"
|
||||
border
|
||||
max-height="220"
|
||||
class="pattern-table"
|
||||
>
|
||||
<el-table-column label="周期" width="110">
|
||||
<template #default="{ $index }">{{ patternLabel($index) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上班 / 休息" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-radio-group v-model="row.day_type" size="small">
|
||||
<el-radio-button :value="1">上班</el-radio-button>
|
||||
<el-radio-button :value="2">休息</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班次" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="row.day_type === 1"
|
||||
v-model="row.shift_id"
|
||||
size="small"
|
||||
placeholder="选择班次"
|
||||
style="width: 100%"
|
||||
@change="applyShiftToPattern(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="shift in shifts"
|
||||
:key="shift.id"
|
||||
:value="shift.id"
|
||||
:label="`${shift.name}(${shift.work_start}-${shift.work_end})`"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-else class="rest-text">休息</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工作时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.day_type === 1">{{ row.work_start }} - {{ row.work_end }}</span>
|
||||
<span v-else class="rest-text">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="form.pattern.length" class="pattern-tip">
|
||||
模板规则已复制到本方案,上面的调整只影响当前方案,不会修改平台模板
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 按星期模式:一人一天一个固定班次 -->
|
||||
<el-form-item v-else label="班次" prop="shift_id">
|
||||
<el-select v-model="form.shift_id" placeholder="选择班次" style="width: 100%">
|
||||
<el-option v-for="shift in shifts" :key="shift.id" :label="`${shift.name}(${shift.work_start}-${shift.work_end})`" :value="shift.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排班范围">
|
||||
<el-radio-group v-model="form.scope_type">
|
||||
<el-radio-button :value="1">整个公司</el-radio-button>
|
||||
<el-radio-button :value="2">部门 / 小组</el-radio-button>
|
||||
<el-radio-button :value="3">指定员工</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="form.scope_type === 2" class="form-grid scope-row">
|
||||
<el-form-item label="选择组织" prop="org_id">
|
||||
<el-tree-select
|
||||
v-model="form.org_id"
|
||||
:data="orgTree"
|
||||
:props="{ label: 'org_name', value: 'id', children: 'children' }"
|
||||
node-key="id"
|
||||
check-strictly
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择公司、部门或小组"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="包含下级">
|
||||
<el-switch v-model="form.include_child" active-text="含全部下级组织" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item v-if="form.scope_type === 3" label="选择员工" prop="employee_ids">
|
||||
<el-select
|
||||
v-model="form.employee_ids"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="可选择一个或多个员工"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:label="employee.label"
|
||||
:value="employee.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-grid">
|
||||
<el-form-item label="日期范围" prop="date_range">
|
||||
<el-date-picker
|
||||
v-model="form.date_range"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:clearable="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.rule_mode === 'weekday'" label="生效星期">
|
||||
<el-checkbox-group v-model="form.weekdays">
|
||||
<el-checkbox v-for="day in WEEKDAYS" :key="day.value" :value="day.value">{{ day.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.note" maxlength="500" placeholder="选填,会带到生成的排班备注里" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="preview" class="preview-box">
|
||||
<el-alert v-if="!preview.employee_count" type="warning" :closable="false" show-icon
|
||||
title="该范围内没有可排班的员工,请检查所选组织或员工状态" />
|
||||
<el-alert v-else type="success" :closable="false" show-icon>
|
||||
<template #title>
|
||||
将覆盖 <strong>{{ preview.employee_count }}</strong> 名员工 ×
|
||||
<strong>{{ preview.workday_count }}</strong> 个工作日,共
|
||||
<strong>{{ preview.entry_count }}</strong> 条排班
|
||||
</template>
|
||||
</el-alert>
|
||||
<div v-if="preview.employee_list?.length" class="preview-employees">
|
||||
<el-tag v-for="item in preview.employee_list.slice(0, 30)" :key="item.id" size="small" type="info">
|
||||
{{ item.name }}<span v-if="item.department_name"> · {{ item.department_name }}</span>
|
||||
</el-tag>
|
||||
<span v-if="preview.employee_list.length > 30" class="more-tip">
|
||||
等 {{ preview.employee_list.length }} 人
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button :loading="previewing" @click="runPreview">预览覆盖范围</el-button>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="savePlan">保存方案</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ArrowDown, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { oaOrganizationApi } from '@/api/organization'
|
||||
import { buildOrgTree } from '@/views/apps/organization/composables'
|
||||
import { getSchedulingEmployees, getShiftList } from '@/api/oaScheduling'
|
||||
import {
|
||||
applySchedTemplate,
|
||||
createSchedulePlan,
|
||||
deleteSchedulePlan,
|
||||
getSchedulePlanDetail,
|
||||
getSchedTemplates,
|
||||
getSchedulePlans,
|
||||
previewSchedulePlan,
|
||||
publishSchedulePlan,
|
||||
updateSchedulePlan
|
||||
} from '@/api/oaScheduling'
|
||||
|
||||
defineProps({ modelValue: Boolean })
|
||||
const emit = defineEmits(['update:modelValue', 'changed'])
|
||||
|
||||
const WEEKDAYS = [
|
||||
{ value: 1, label: '周一' },
|
||||
{ value: 2, label: '周二' },
|
||||
{ value: 3, label: '周三' },
|
||||
{ value: 4, label: '周四' },
|
||||
{ value: 5, label: '周五' },
|
||||
{ value: 6, label: '周六' },
|
||||
{ value: 7, label: '周日' }
|
||||
]
|
||||
|
||||
function unwrap(res) {
|
||||
if (res && typeof res === 'object' && 'code' in res) {
|
||||
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
|
||||
return res.data
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const previewing = ref(false)
|
||||
const plans = ref([])
|
||||
const shifts = ref([])
|
||||
const templates = ref([])
|
||||
const currentTemplate = ref(null)
|
||||
const employees = ref([])
|
||||
const orgTree = ref([])
|
||||
const keyword = ref('')
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const formVisible = ref(false)
|
||||
const editingID = ref(0)
|
||||
const preview = ref(null)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
plan_name: '',
|
||||
plan_type: 1,
|
||||
rule_mode: 'weekday',
|
||||
shift_id: null,
|
||||
template_id: null,
|
||||
template_code: '',
|
||||
template_name: '',
|
||||
align_mode: 0,
|
||||
cycle_days: 0,
|
||||
week_offset: 0,
|
||||
month_rest_days: 0,
|
||||
month_rest_weekday: 0,
|
||||
pattern: [],
|
||||
scope_type: 2,
|
||||
org_id: null,
|
||||
include_child: true,
|
||||
employee_ids: [],
|
||||
date_range: [],
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
note: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
plan_name: [{ required: true, message: '请输入方案名称', trigger: 'blur' }],
|
||||
org_id: [{ required: true, message: '请选择组织', trigger: 'change' }],
|
||||
employee_ids: [{ required: true, message: '请选择员工', trigger: 'change' }],
|
||||
date_range: [{ required: true, message: '请选择日期范围', trigger: 'change' }]
|
||||
}
|
||||
|
||||
async function handleOpen() {
|
||||
formVisible.value = false
|
||||
preview.value = null
|
||||
await Promise.all([loadPlans(), loadShifts(), loadTemplates(), loadEmployees(), loadOrgs()])
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = unwrap(await getSchedulePlans({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
keyword: keyword.value || undefined
|
||||
})) || {}
|
||||
plans.value = data.list || []
|
||||
pagination.total = data.total || 0
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '加载方案失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShifts() {
|
||||
try {
|
||||
const data = unwrap(await getShiftList()) || {}
|
||||
shifts.value = (data.list || []).filter((item) => item.status === 1)
|
||||
} catch {
|
||||
shifts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const data = unwrap(await getSchedTemplates({})) || {}
|
||||
templates.value = data.list || []
|
||||
} catch {
|
||||
templates.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const data = unwrap(await getSchedulingEmployees({})) || {}
|
||||
employees.value = (data.list || []).map((item) => ({
|
||||
...item,
|
||||
label: `${item.name}${item.department_name ? `(${item.department_name})` : ''}`
|
||||
}))
|
||||
} catch {
|
||||
employees.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const data = unwrap(await oaOrganizationApi.getOrganizationList())
|
||||
orgTree.value = buildOrgTree(Array.isArray(data) ? data : data?.list || [])
|
||||
} catch {
|
||||
orgTree.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingID.value = 0
|
||||
preview.value = null
|
||||
currentTemplate.value = null
|
||||
Object.assign(form, {
|
||||
plan_name: '',
|
||||
plan_type: 1,
|
||||
rule_mode: 'weekday',
|
||||
shift_id: shifts.value.find((item) => item.is_default === 1)?.id || shifts.value[0]?.id || null,
|
||||
template_id: null,
|
||||
template_code: '',
|
||||
template_name: '',
|
||||
align_mode: 0,
|
||||
cycle_days: 0,
|
||||
week_offset: 0,
|
||||
month_rest_days: 0,
|
||||
month_rest_weekday: 0,
|
||||
pattern: [],
|
||||
scope_type: 2,
|
||||
org_id: null,
|
||||
include_child: true,
|
||||
employee_ids: [],
|
||||
date_range: [],
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
note: ''
|
||||
})
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function openEdit(row) {
|
||||
try {
|
||||
const detail = unwrap(await getSchedulePlanDetail(row.id)) || {}
|
||||
editingID.value = detail.id
|
||||
preview.value = null
|
||||
const isTemplate = Number(detail.align_mode) > 0 && Array.isArray(detail.pattern) && detail.pattern.length > 0
|
||||
Object.assign(form, {
|
||||
plan_name: detail.plan_name,
|
||||
plan_type: detail.plan_type,
|
||||
rule_mode: isTemplate ? 'template' : 'weekday',
|
||||
shift_id: detail.shift_id,
|
||||
template_id: detail.template_id || null,
|
||||
template_code: detail.template_code || '',
|
||||
template_name: detail.template_name || '',
|
||||
align_mode: detail.align_mode || 0,
|
||||
cycle_days: detail.cycle_days || 0,
|
||||
week_offset: detail.week_offset || 0,
|
||||
month_rest_days: detail.month_rest_days || 0,
|
||||
month_rest_weekday: detail.month_rest_weekday || 0,
|
||||
pattern: isTemplate ? detail.pattern.map((item) => ({ ...item })) : [],
|
||||
scope_type: detail.scope_type,
|
||||
org_id: detail.org_id || null,
|
||||
include_child: detail.include_child === 1,
|
||||
employee_ids: detail.employee_ids || [],
|
||||
date_range: [detail.start_date, detail.end_date],
|
||||
weekdays: detail.weekdays || [],
|
||||
note: detail.note || ''
|
||||
})
|
||||
currentTemplate.value = isTemplate
|
||||
? {
|
||||
id: detail.template_id,
|
||||
name: detail.template_name,
|
||||
summary: '当前方案沿用调用时的模板规则(已复制到本方案,可继续调整)'
|
||||
}
|
||||
: null
|
||||
formVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '加载方案失败')
|
||||
}
|
||||
}
|
||||
|
||||
const weekdayLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
function patternLabel(index) {
|
||||
if (form.align_mode === 3) return '上班日'
|
||||
if (form.align_mode === 2) return `第 ${index + 1} 天`
|
||||
const week = Math.floor(index / 7) + 1
|
||||
const name = weekdayLabels[index % 7]
|
||||
return form.cycle_days > 7 ? `第${week}周 ${name}` : name
|
||||
}
|
||||
|
||||
function handleRuleModeChange(mode) {
|
||||
preview.value = null
|
||||
if (mode === 'weekday' && !form.shift_id) {
|
||||
form.shift_id = shifts.value.find((item) => item.is_default === 1)?.id || shifts.value[0]?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
// 选择模板:调用后端把模板规则复制成方案 pattern,同时自动匹配 / 创建班次
|
||||
async function handleTemplateChange(templateId) {
|
||||
preview.value = null
|
||||
if (!templateId) {
|
||||
form.pattern = []
|
||||
currentTemplate.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = unwrap(await applySchedTemplate(templateId)) || {}
|
||||
const info = data.template || templates.value.find((item) => item.id === templateId) || {}
|
||||
form.template_code = info.code || ''
|
||||
form.template_name = info.name || ''
|
||||
form.align_mode = info.align_mode || 0
|
||||
form.cycle_days = info.cycle_days || 0
|
||||
form.week_offset = info.week_offset || 0
|
||||
form.month_rest_days = info.month_rest_days || 0
|
||||
form.month_rest_weekday = info.month_rest_weekday || 0
|
||||
form.pattern = (data.pattern || data.days || []).map((item) => ({ ...item }))
|
||||
currentTemplate.value = { ...info, summary: info.summary || '' }
|
||||
if (data.created_shift) {
|
||||
ElMessage.success(`已套用模板,并自动创建 ${data.created_shift} 个班次`)
|
||||
await loadShifts()
|
||||
} else {
|
||||
ElMessage.success('已套用模板规则')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '套用模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 微调某天的班次:把班次快照带进 pattern,保存后与模板无关
|
||||
function applyShiftToPattern(row) {
|
||||
const shift = shifts.value.find((item) => item.id === row.shift_id)
|
||||
if (!shift) return
|
||||
row.shift_name = shift.name
|
||||
row.short_name = shift.short_name
|
||||
row.color = shift.color
|
||||
row.work_start = shift.work_start
|
||||
row.work_end = shift.work_end
|
||||
row.rest_start = shift.rest_start || ''
|
||||
row.rest_end = shift.rest_end || ''
|
||||
row.work_hours = shift.work_hours
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const templateMode = form.rule_mode === 'template'
|
||||
return {
|
||||
plan_name: form.plan_name,
|
||||
plan_type: form.plan_type,
|
||||
shift_id: templateMode ? 0 : form.shift_id,
|
||||
template_id: templateMode ? form.template_id : 0,
|
||||
template_code: templateMode ? form.template_code : '',
|
||||
template_name: templateMode ? form.template_name : '',
|
||||
align_mode: templateMode ? form.align_mode : 0,
|
||||
cycle_days: templateMode ? form.cycle_days : 0,
|
||||
week_offset: templateMode ? form.week_offset : 0,
|
||||
month_rest_days: templateMode ? form.month_rest_days : 0,
|
||||
month_rest_weekday: templateMode ? form.month_rest_weekday : 0,
|
||||
pattern: templateMode ? form.pattern : [],
|
||||
scope_type: form.scope_type,
|
||||
org_id: form.scope_type === 2 ? form.org_id : 0,
|
||||
include_child: form.include_child,
|
||||
employee_ids: form.scope_type === 3 ? form.employee_ids : [],
|
||||
start_date: form.date_range?.[0],
|
||||
end_date: form.date_range?.[1],
|
||||
weekdays: form.weekdays,
|
||||
note: form.note
|
||||
}
|
||||
}
|
||||
|
||||
async function validateForm() {
|
||||
if (!formRef.value) return false
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return false
|
||||
if (form.rule_mode === 'template') {
|
||||
if (!form.template_id || form.pattern.length === 0) {
|
||||
ElMessage.warning('请选择排班模板')
|
||||
return false
|
||||
}
|
||||
if (!form.pattern.some((item) => item.day_type === 1)) {
|
||||
ElMessage.warning('模板规则至少需要一天上班')
|
||||
return false
|
||||
}
|
||||
} else if (!form.shift_id) {
|
||||
ElMessage.warning('请选择班次')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
const valid = await validateForm()
|
||||
if (!valid) return
|
||||
previewing.value = true
|
||||
try {
|
||||
preview.value = unwrap(await previewSchedulePlan(buildPayload())) || {}
|
||||
} catch (error) {
|
||||
preview.value = null
|
||||
ElMessage.error(error.message || '预览失败')
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlan() {
|
||||
const valid = await validateForm()
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingID.value) {
|
||||
unwrap(await updateSchedulePlan(editingID.value, buildPayload()))
|
||||
ElMessage.success('方案已更新,重新发布后新规则生效')
|
||||
} else {
|
||||
unwrap(await createSchedulePlan(buildPayload()))
|
||||
ElMessage.success('方案已保存,发布后生效')
|
||||
}
|
||||
formVisible.value = false
|
||||
await loadPlans()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '保存方案失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishCommand(row, command) {
|
||||
if (command === 'edit') return openEdit(row)
|
||||
if (command === 'delete') return removePlan(row)
|
||||
if (command === 'overwrite') return publishPlan(row, true)
|
||||
}
|
||||
|
||||
async function publishPlan(row, overwrite) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
overwrite
|
||||
? `将用方案「${row.plan_name}」覆盖范围内员工同一天的已有排班,是否继续?`
|
||||
: `将按方案「${row.plan_name}」生成排班,已有排班的日期会被保留(不覆盖)。`,
|
||||
overwrite ? '覆盖发布' : '发布排班',
|
||||
{ type: overwrite ? 'warning' : 'info', confirmButtonText: '发布' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = unwrap(await publishSchedulePlan(row.id, { overwrite })) || {}
|
||||
const stats = data.publish || {}
|
||||
ElMessage.success(
|
||||
`发布完成:新增 ${stats.created || 0} 条,覆盖 ${stats.updated || 0} 条,跳过 ${stats.skipped || 0} 条`
|
||||
)
|
||||
await loadPlans()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '发布失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlan(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除方案「${row.plan_name}」会同时清除它生成且尚未被调整的排班,是否继续?`,
|
||||
'删除方案',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
unwrap(await deleteSchedulePlan(row.id))
|
||||
ElMessage.success('方案已删除')
|
||||
await loadPlans()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function weekdayText(list) {
|
||||
if (!Array.isArray(list) || !list.length) return '每天'
|
||||
if (list.length === 7) return '每天'
|
||||
return list.map((value) => WEEKDAYS.find((day) => day.value === value)?.label || value).join(' ')
|
||||
}
|
||||
|
||||
function ruleText(row) {
|
||||
if (row.align_mode === 3) return `每月休 ${row.month_rest_days || 0} 天`
|
||||
if (row.align_mode === 2) return `每 ${row.cycle_days || 0} 天循环`
|
||||
if (row.align_mode === 1) {
|
||||
const weeks = Math.max(1, Math.round((row.cycle_days || 7) / 7))
|
||||
return weeks > 1 ? `${weeks} 周循环` : '每周循环'
|
||||
}
|
||||
return weekdayText(row.weekdays)
|
||||
}
|
||||
|
||||
defineExpose({ loadPlans })
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.plan-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.plan-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.plan-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.shift-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.ml2 {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.plan-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
.scope-row {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
.preview-box {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.preview-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.more-tip {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.template-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.template-summary {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.pattern-table {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.pattern-tip {
|
||||
margin-top: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rest-text {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="班次管理"
|
||||
width="900px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="loadShifts"
|
||||
>
|
||||
<div class="shift-toolbar">
|
||||
<div class="shift-tip">班次用于定义上下班时间与休息时段,可被排班方案和临时调配复用。</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增班次</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="shifts" v-loading="loading" size="small" max-height="320">
|
||||
<el-table-column label="班次" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="shift-dot" :style="{ background: row.color }" />
|
||||
<span>{{ row.name }}</span>
|
||||
<el-tag v-if="row.is_default === 1" size="small" type="success" class="ml6">默认</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="short_name" label="简称" width="90" />
|
||||
<el-table-column label="上班时间" width="150">
|
||||
<template #default="{ row }">{{ row.work_start }} - {{ row.work_end }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="休息时段" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.rest_start ? `${row.rest_start} - ${row.rest_end}` : "无" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工时" width="80">
|
||||
<template #default="{ row }">{{ row.work_hours }}h</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeShift(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="还没有班次,请先新增一个" :image-size="60" /></template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="formVisible" class="shift-form">
|
||||
<div class="form-title">{{ editingID ? "编辑班次" : "新增班次" }}</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="班次名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:早班 / 晚班 / 行政班" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="简称">
|
||||
<el-input v-model="form.short_name" placeholder="看板色块上的短文本" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上班时间" prop="work_start">
|
||||
<el-time-picker v-model="form.work_start" value-format="HH:mm" format="HH:mm" placeholder="上班" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="下班时间" prop="work_end">
|
||||
<el-time-picker v-model="form.work_end" value-format="HH:mm" format="HH:mm" placeholder="下班" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="休息开始">
|
||||
<el-time-picker v-model="form.rest_start" value-format="HH:mm" format="HH:mm" placeholder="选填" clearable style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="休息结束">
|
||||
<el-time-picker v-model="form.rest_end" value-format="HH:mm" format="HH:mm" placeholder="选填" clearable style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标识颜色">
|
||||
<el-color-picker v-model="form.color" :predefine="COLORS" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认班次">
|
||||
<el-switch v-model="form.is_default" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="span-2">
|
||||
<el-input v-model="form.remark" maxlength="255" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<span class="hours-tip">自动计算工时:{{ computedHours }} 小时</span>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveShift">保存班次</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createShift, deleteShift, getShiftList, updateShift } from '@/api/oaScheduling'
|
||||
|
||||
defineProps({ modelValue: Boolean })
|
||||
const emit = defineEmits(['update:modelValue', 'changed'])
|
||||
|
||||
const COLORS = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#9254DE', '#13C2C2']
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const shifts = ref([])
|
||||
const formVisible = ref(false)
|
||||
const editingID = ref(0)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
short_name: '',
|
||||
color: '#409EFF',
|
||||
work_start: '09:00',
|
||||
work_end: '18:00',
|
||||
rest_start: '12:00',
|
||||
rest_end: '13:00',
|
||||
is_default: false,
|
||||
status: 1,
|
||||
sort: 0,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入班次名称', trigger: 'blur' }],
|
||||
work_start: [{ required: true, message: '请选择上班时间', trigger: 'change' }],
|
||||
work_end: [{ required: true, message: '请选择下班时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function unwrap(res) {
|
||||
if (res && typeof res === 'object' && 'code' in res) {
|
||||
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
|
||||
return res.data
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const computedHours = computed(() => {
|
||||
const toMinutes = (value) => {
|
||||
if (!value) return null
|
||||
const [hour, minute] = String(value).split(':').map(Number)
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute)) return null
|
||||
return hour * 60 + minute
|
||||
}
|
||||
let start = toMinutes(form.work_start)
|
||||
let end = toMinutes(form.work_end)
|
||||
if (start === null || end === null) return 0
|
||||
if (end <= start) end += 24 * 60
|
||||
let span = end - start
|
||||
let restStart = toMinutes(form.rest_start)
|
||||
let restEnd = toMinutes(form.rest_end)
|
||||
if (restStart !== null && restEnd !== null) {
|
||||
if (restEnd <= restStart) restEnd += 24 * 60
|
||||
const overlap = Math.max(0, Math.min(restEnd, end) - Math.max(restStart, start))
|
||||
span -= overlap
|
||||
}
|
||||
return Math.max(0, Math.round((span / 60) * 100) / 100)
|
||||
})
|
||||
|
||||
async function loadShifts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = unwrap(await getShiftList()) || {}
|
||||
shifts.value = data.list || []
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '加载班次失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingID.value = 0
|
||||
Object.assign(form, {
|
||||
name: '', short_name: '', color: '#409EFF',
|
||||
work_start: '09:00', work_end: '18:00', rest_start: '12:00', rest_end: '13:00',
|
||||
is_default: false, status: 1, sort: shifts.value.length, remark: ''
|
||||
})
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingID.value = row.id
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
short_name: row.short_name || '',
|
||||
color: row.color || '#409EFF',
|
||||
work_start: row.work_start,
|
||||
work_end: row.work_end,
|
||||
rest_start: row.rest_start || '',
|
||||
rest_end: row.rest_end || '',
|
||||
is_default: row.is_default === 1,
|
||||
status: row.status,
|
||||
sort: row.sort || 0,
|
||||
remark: row.remark || ''
|
||||
})
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function saveShift() {
|
||||
if (!formRef.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
if (form.rest_start && !form.rest_end) {
|
||||
ElMessage.warning('请选择休息结束时间')
|
||||
return
|
||||
}
|
||||
if (!form.rest_start && form.rest_end) {
|
||||
ElMessage.warning('请选择休息开始时间')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form, work_hours: computedHours.value }
|
||||
if (editingID.value) {
|
||||
unwrap(await updateShift(editingID.value, payload))
|
||||
ElMessage.success('班次已更新')
|
||||
} else {
|
||||
unwrap(await createShift(payload))
|
||||
ElMessage.success('班次已创建')
|
||||
}
|
||||
formVisible.value = false
|
||||
await loadShifts()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '保存班次失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeShift(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除班次「${row.name}」?已被排班使用的班次不能删除。`, '删除班次', {
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
unwrap(await deleteShift(row.id))
|
||||
ElMessage.success('班次已删除')
|
||||
await loadShifts()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ loadShifts })
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.shift-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.shift-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shift-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.ml6 {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.shift-form {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
.form-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
.span-2 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.hours-tip {
|
||||
margin-right: auto;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="entry ? '调整排班' : '补排班次'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="swap-head">
|
||||
<div class="swap-person">
|
||||
<strong>{{ employeeName }}</strong>
|
||||
<span>{{ dateText }}</span>
|
||||
</div>
|
||||
<div class="swap-current">
|
||||
<template v-if="entry">
|
||||
<span class="shift-dot" :style="{ background: entry.color }" />
|
||||
<span>{{ entry.shift_name }}</span>
|
||||
<span class="time">{{ entry.work_start }}-{{ entry.work_end }}</span>
|
||||
</template>
|
||||
<span v-else class="empty-tip">当天未排班</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="82px">
|
||||
<el-form-item :label="entry ? '调换班次' : '选择班次'" prop="shift_id">
|
||||
<el-select v-model="form.shift_id" placeholder="选择新班次" style="width: 100%">
|
||||
<el-option
|
||||
v-for="shift in options"
|
||||
:key="shift.id"
|
||||
:label="`${shift.name}(${shift.work_start}-${shift.work_end})`"
|
||||
:value="shift.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因">
|
||||
<el-input v-model="form.reason" maxlength="255" placeholder="如:临时换班 / 请假顶班" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="entry" type="danger" plain :loading="clearing" @click="submitClear">清除排班</el-button>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitSave">{{ entry ? "保存调班" : "保存排班" }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { clearScheduleEntries, getShiftList, saveScheduleEntries, swapScheduleEntry } from '@/api/oaScheduling'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
entry: { type: Object, default: null },
|
||||
employeeId: { type: Number, default: 0 },
|
||||
employeeName: { type: String, default: '' },
|
||||
scheduleDate: { type: String, default: '' }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'changed'])
|
||||
|
||||
function unwrap(res) {
|
||||
if (res && typeof res === 'object' && 'code' in res) {
|
||||
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
|
||||
return res.data
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const clearing = ref(false)
|
||||
const shifts = ref([])
|
||||
const formRef = ref(null)
|
||||
const form = reactive({ shift_id: null, reason: '' })
|
||||
const rules = {
|
||||
shift_id: [{ required: true, message: '请选择班次', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const dateText = computed(() => {
|
||||
if (!props.scheduleDate) return ''
|
||||
const parsed = new Date(`${props.scheduleDate}T00:00:00`)
|
||||
const labels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return `${props.scheduleDate} ${labels[parsed.getDay()] || ''}`
|
||||
})
|
||||
|
||||
// 调班时把自己排除,避免选到当前班次
|
||||
const options = computed(() => shifts.value.filter((item) => item.id !== props.entry?.shift_id))
|
||||
|
||||
async function handleOpen() {
|
||||
form.reason = ''
|
||||
form.shift_id = null
|
||||
try {
|
||||
const data = unwrap(await getShiftList()) || {}
|
||||
shifts.value = (data.list || []).filter((item) => item.status === 1)
|
||||
if (!props.entry) {
|
||||
form.shift_id = data.default_id || shifts.value[0]?.id || null
|
||||
}
|
||||
} catch {
|
||||
shifts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSave() {
|
||||
if (!formRef.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.entry) {
|
||||
unwrap(await swapScheduleEntry(props.entry.id, { shift_id: form.shift_id, reason: form.reason }))
|
||||
ElMessage.success('调班已保存')
|
||||
} else {
|
||||
unwrap(await saveScheduleEntries({
|
||||
employee_ids: [props.employeeId],
|
||||
start_date: props.scheduleDate,
|
||||
end_date: props.scheduleDate,
|
||||
weekdays: [],
|
||||
shift_id: form.shift_id,
|
||||
note: form.reason
|
||||
}))
|
||||
ElMessage.success('排班已保存')
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitClear() {
|
||||
if (!props.entry) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认清除这条排班?', '清除排班', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
clearing.value = true
|
||||
try {
|
||||
unwrap(await clearScheduleEntries({ entry_ids: [props.entry.id], reason: form.reason }))
|
||||
ElMessage.success('排班已清除')
|
||||
emit('update:modelValue', false)
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '清除失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.swap-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
background: #f7f9fc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.swap-person {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
span {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.swap-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shift-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.time {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-tip {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,900 @@
|
||||
<template>
|
||||
<div class="scheduling-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>工作排班</h2>
|
||||
<p>按公司、部门或小组批量排班,支持单人或多人临时调配,逐日可调</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Clock" @click="openChanges">变更记录</el-button>
|
||||
<el-button :icon="Setting" @click="shiftVisible = true">班次管理</el-button>
|
||||
<el-button :icon="Tickets" @click="planVisible = true">排班方案</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openTempEntry">临时排班</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">覆盖员工</div>
|
||||
<div class="stat-value">{{ summary.employee_count || 0 }}</div>
|
||||
<div class="stat-sub">当前筛选范围内员工</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-label">排班人次</div>
|
||||
<div class="stat-value">{{ summary.entry_count || 0 }}</div>
|
||||
<div class="stat-sub">{{ rangeText }}</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="stat-label">临时调配</div>
|
||||
<div class="stat-value">{{ summary.temp_count || 0 }}</div>
|
||||
<div class="stat-sub">非方案生成、手工调整的班次</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="stat-label">今日在岗</div>
|
||||
<div class="stat-value">{{ summary.today_on_duty || 0 }}</div>
|
||||
<div class="stat-sub">未排班员工 {{ summary.unassigned || 0 }} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="filter-bar">
|
||||
<el-radio-group v-model="viewMode" @change="handleViewModeChange">
|
||||
<el-radio-button value="week">按周</el-radio-button>
|
||||
<el-radio-button value="month">按月</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button-group class="week-switch">
|
||||
<el-button :icon="ArrowLeft" @click="shiftRange(-1)" />
|
||||
<el-button @click="goCurrentRange">{{ isMonth ? "本月" : "本周" }}</el-button>
|
||||
<el-button :icon="ArrowRight" @click="shiftRange(1)" />
|
||||
</el-button-group>
|
||||
<el-date-picker
|
||||
v-model="anchorDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:clearable="false"
|
||||
placeholder="选择日期"
|
||||
style="width: 150px"
|
||||
@change="loadBoard"
|
||||
/>
|
||||
<el-tree-select
|
||||
v-model="filters.org_id"
|
||||
:data="orgTree"
|
||||
:props="{ label: 'org_name', value: 'id', children: 'children' }"
|
||||
node-key="id"
|
||||
check-strictly
|
||||
filterable
|
||||
clearable
|
||||
placeholder="全部组织"
|
||||
style="width: 200px"
|
||||
@change="loadBoard"
|
||||
/>
|
||||
<el-checkbox v-model="filters.include_child" @change="loadBoard">含下级组织</el-checkbox>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索员工姓名 / 账号"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
style="width: 190px"
|
||||
/>
|
||||
<el-checkbox v-model="filters.only_scheduled">只看有排班</el-checkbox>
|
||||
<el-button :icon="Refresh" @click="loadBoard">刷新</el-button>
|
||||
<div class="legend">
|
||||
<span v-for="shift in shifts" :key="shift.id" class="legend-item">
|
||||
<span class="legend-dot" :style="{ background: shift.color }" />{{ shift.short_name || shift.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board-wrap" :class="{ 'is-month': isMonth }" v-loading="loading">
|
||||
<table v-if="displayEmployees.length" class="board-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky-col">员工</th>
|
||||
<th
|
||||
v-for="day in dateCells"
|
||||
:key="day.date"
|
||||
:class="{
|
||||
'is-today': day.is_today,
|
||||
'is-holiday': holidayOf(day.date)?.type === 1,
|
||||
'is-makeup': holidayOf(day.date)?.type === 0
|
||||
}"
|
||||
>
|
||||
<div class="date-main">{{ day.date.slice(5) }}</div>
|
||||
<div class="date-sub">{{ weekdayLabel(day.weekday) }}</div>
|
||||
<div
|
||||
v-if="holidayOf(day.date)"
|
||||
class="date-holiday"
|
||||
:title="holidayOf(day.date).name"
|
||||
>
|
||||
<span
|
||||
class="holiday-tag"
|
||||
:class="holidayOf(day.date).type === 1 ? 'is-off' : 'is-work'"
|
||||
>
|
||||
{{ holidayOf(day.date).type === 1 ? "休" : "班" }}
|
||||
</span>
|
||||
<span v-if="!isMonth" class="holiday-name">{{ holidayOf(day.date).name }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="employee in displayEmployees" :key="employee.id">
|
||||
<td class="sticky-col employee-cell">
|
||||
<div class="emp-name">{{ employee.name }}</div>
|
||||
<div class="emp-meta">{{ employee.org_name || "未分配部门" }}</div>
|
||||
</td>
|
||||
<td
|
||||
v-for="day in dateCells"
|
||||
:key="day.date"
|
||||
class="shift-cell"
|
||||
:class="{ 'is-today': day.is_today }"
|
||||
@click="openCell(employee, day.date)"
|
||||
>
|
||||
<div
|
||||
v-if="employee.days[day.date]"
|
||||
class="shift-block"
|
||||
:style="shiftStyle(employee.days[day.date])"
|
||||
:title="cellTitle(employee.days[day.date])"
|
||||
>
|
||||
<span v-if="employee.days[day.date].source === 2" class="temp-flag">临</span>
|
||||
<span class="shift-title">{{ employee.days[day.date].short_name || employee.days[day.date].shift_name }}</span>
|
||||
<span v-if="!isMonth" class="shift-time">{{ employee.days[day.date].work_start }}-{{ employee.days[day.date].work_end }}</span>
|
||||
</div>
|
||||
<div v-else class="empty-cell">+</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<el-empty
|
||||
v-else
|
||||
:description="loading ? '加载中…' : '当前范围内没有员工或排班,请调整筛选或先创建排班方案'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="truncated" class="truncated-tip">
|
||||
员工较多,仅展示前 500 人,请通过组织筛选缩小范围。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<shift-dialog v-model="shiftVisible" @changed="loadBoard" />
|
||||
<plan-dialog v-model="planVisible" @changed="loadBoard" />
|
||||
<entry-dialog
|
||||
v-model="entryVisible"
|
||||
:preset-employee-ids="entryPreset.employeeIds"
|
||||
:preset-date="entryPreset.date"
|
||||
@changed="handleEntryChanged"
|
||||
/>
|
||||
<swap-dialog
|
||||
v-model="swapVisible"
|
||||
:entry="selectedCell.entry"
|
||||
:employee-id="selectedCell.employeeId"
|
||||
:employee-name="selectedCell.employeeName"
|
||||
:schedule-date="selectedCell.scheduleDate"
|
||||
@changed="handleEntryChanged"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="changesVisible" title="排班变更记录" size="620px" @open="loadChanges">
|
||||
<el-table :data="changes" v-loading="changesLoading" size="small">
|
||||
<el-table-column prop="scheduleDateText" label="日期" width="100" />
|
||||
<el-table-column prop="employee_name" label="员工" width="90" />
|
||||
<el-table-column label="变更" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.change_type === 2" class="change-clear">清除 {{ row.old_shift_name }}</span>
|
||||
<span v-else-if="row.change_type === 3" class="change-new">新增 {{ row.new_shift_name }}</span>
|
||||
<span v-else>
|
||||
{{ row.old_shift_name || "未排班" }}
|
||||
<el-icon class="change-arrow"><Right /></el-icon>
|
||||
{{ row.new_shift_name }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="operator_name" label="操作人" width="90" />
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">{{ (row.create_time || "").replace("T", " ").slice(0, 16) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="本周暂无调配记录" :image-size="55" /></template>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ArrowLeft, ArrowRight, Clock, Plus, Refresh, Right, Search, Setting, Tickets } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { oaOrganizationApi } from '@/api/organization'
|
||||
import { buildOrgTree } from '@/views/apps/organization/composables'
|
||||
import { getScheduleBoard, getScheduleChanges, getShiftList } from '@/api/oaScheduling'
|
||||
import { getScheduleHolidays } from '@/api/oaSchedule'
|
||||
import ShiftDialog from './components/shiftDialog.vue'
|
||||
import PlanDialog from './components/planDialog.vue'
|
||||
import EntryDialog from './components/entryDialog.vue'
|
||||
import SwapDialog from './components/swapDialog.vue'
|
||||
|
||||
const WEEK_LABELS = { 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日' }
|
||||
|
||||
function unwrap(res) {
|
||||
if (res && typeof res === 'object' && 'code' in res) {
|
||||
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
|
||||
return res.data
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function mondayOf(value) {
|
||||
const date = new Date(`${value}T00:00:00`)
|
||||
const weekday = date.getDay() || 7
|
||||
date.setDate(date.getDate() - (weekday - 1))
|
||||
return date
|
||||
}
|
||||
|
||||
const anchorDate = ref(formatDate(new Date()))
|
||||
// 默认按月看(一月一屏);可切回按周
|
||||
const viewMode = ref('month')
|
||||
const isMonth = computed(() => viewMode.value === 'month')
|
||||
const loading = ref(false)
|
||||
const truncated = ref(false)
|
||||
const board = ref({ employees: [], dates: [], summary: {}, shifts: [] })
|
||||
const shifts = ref([])
|
||||
const orgTree = ref([])
|
||||
const filters = reactive({ org_id: null, include_child: true, keyword: '', only_scheduled: false })
|
||||
|
||||
const shiftVisible = ref(false)
|
||||
const planVisible = ref(false)
|
||||
const entryVisible = ref(false)
|
||||
const swapVisible = ref(false)
|
||||
const changesVisible = ref(false)
|
||||
const changes = ref([])
|
||||
const changesLoading = ref(false)
|
||||
const entryPreset = reactive({ employeeIds: [], date: '' })
|
||||
const selectedCell = reactive({ entry: null, employeeId: 0, employeeName: '', scheduleDate: '' })
|
||||
|
||||
const summary = computed(() => board.value.summary || {})
|
||||
const dateCells = computed(() => board.value.dates || [])
|
||||
const rangeText = computed(() => {
|
||||
if (!dateCells.value.length) return ''
|
||||
return `${dateCells.value[0].date} ~ ${dateCells.value[dateCells.value.length - 1].date}`
|
||||
})
|
||||
const displayEmployees = computed(() => {
|
||||
const keyword = filters.keyword.trim()
|
||||
return (board.value.employees || [])
|
||||
.filter((row) => !keyword || row.name.includes(keyword) || (row.account || '').includes(keyword))
|
||||
.filter((row) => !filters.only_scheduled || Object.keys(row.days || {}).length > 0)
|
||||
})
|
||||
|
||||
function weekdayLabel(value) {
|
||||
return WEEK_LABELS[value] || ''
|
||||
}
|
||||
|
||||
function cellTitle(entry) {
|
||||
const parts = [entry.shift_name || '排班']
|
||||
if (entry.work_start || entry.work_end) parts.push(`${entry.work_start}-${entry.work_end}`)
|
||||
if (entry.source === 2) parts.push('临时调配')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function shiftStyle(entry) {
|
||||
return {
|
||||
borderLeftColor: entry.color || '#409EFF',
|
||||
background: hexToRgba(entry.color || '#409EFF', 0.1),
|
||||
color: '#303133'
|
||||
}
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
const value = String(hex || '').replace('#', '')
|
||||
if (value.length !== 6) return `rgba(64, 158, 255, ${alpha})`
|
||||
const r = parseInt(value.slice(0, 2), 16)
|
||||
const g = parseInt(value.slice(2, 4), 16)
|
||||
const b = parseInt(value.slice(4, 6), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
async function loadOrgTree() {
|
||||
try {
|
||||
const data = unwrap(await oaOrganizationApi.getOrganizationList())
|
||||
orgTree.value = buildOrgTree(Array.isArray(data) ? data : data?.list || [])
|
||||
} catch {
|
||||
orgTree.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShifts() {
|
||||
try {
|
||||
const data = unwrap(await getShiftList()) || {}
|
||||
shifts.value = (data.list || []).filter((item) => item.status === 1)
|
||||
} catch {
|
||||
shifts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 中国法定节假日 / 调休 ----------
|
||||
// 复用工作日程的节假日接口:type 1=法定放假(休),0=调休上班(班)。
|
||||
// 整年拉取并缓存,翻月/翻周命中缓存时不再请求。
|
||||
const holidayMap = ref({})
|
||||
const loadedHolidayYears = new Set()
|
||||
const loadingHolidayYears = new Set()
|
||||
|
||||
async function loadHolidays(start, end) {
|
||||
const years = [start.slice(0, 4), end.slice(0, 4)].filter((y, i, arr) => arr.indexOf(y) === i)
|
||||
const pending = years.filter((y) => !loadedHolidayYears.has(y) && !loadingHolidayYears.has(y))
|
||||
if (!pending.length) return
|
||||
pending.forEach((y) => loadingHolidayYears.add(y))
|
||||
try {
|
||||
const results = await Promise.all(pending.map((y) => getScheduleHolidays({ year: y })))
|
||||
const map = { ...holidayMap.value }
|
||||
const okYears = []
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const res = results[i]
|
||||
if (res?.code !== 200) continue
|
||||
okYears.push(pending[i])
|
||||
for (const item of res.data?.list || []) {
|
||||
map[item.date] = { name: item.name, type: item.type, wage: item.wage }
|
||||
}
|
||||
}
|
||||
if (okYears.length) {
|
||||
holidayMap.value = map
|
||||
okYears.forEach((y) => loadedHolidayYears.add(y))
|
||||
}
|
||||
} catch {
|
||||
// 节假日属辅助信息,接口失败时静默降级,看板照常可用
|
||||
} finally {
|
||||
pending.forEach((y) => loadingHolidayYears.delete(y))
|
||||
}
|
||||
}
|
||||
|
||||
function holidayOf(date) {
|
||||
return holidayMap.value[date] || null
|
||||
}
|
||||
|
||||
async function loadBoard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const range = currentRange()
|
||||
const data = unwrap(await getScheduleBoard({
|
||||
start_date: range.start,
|
||||
end_date: range.end,
|
||||
org_id: filters.org_id || undefined,
|
||||
include_child: filters.include_child ? 1 : 0,
|
||||
only_scheduled: filters.only_scheduled ? 1 : 0
|
||||
})) || {}
|
||||
board.value = {
|
||||
employees: data.employees || [],
|
||||
dates: data.dates || [],
|
||||
summary: data.summary || {},
|
||||
shifts: data.shifts || []
|
||||
}
|
||||
truncated.value = Boolean(data.truncated)
|
||||
// 节假日不阻塞表格渲染,拿到后补上「休 / 班」角标
|
||||
loadHolidays(range.start, range.end)
|
||||
if (data.shifts?.length) {
|
||||
shifts.value = data.shifts.filter((item) => item.status === 1)
|
||||
}
|
||||
if (changesVisible.value) loadChanges()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '加载排班失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 当前视图的日期区间:按月=自然月 1 号至月末,按周=周一至周日
|
||||
function currentRange() {
|
||||
if (isMonth.value) {
|
||||
const date = new Date(`${anchorDate.value}T00:00:00`)
|
||||
const first = new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
const last = new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||
return { start: formatDate(first), end: formatDate(last) }
|
||||
}
|
||||
const monday = mondayOf(anchorDate.value)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
return { start: formatDate(monday), end: formatDate(sunday) }
|
||||
}
|
||||
|
||||
function shiftRange(offset) {
|
||||
const date = new Date(`${anchorDate.value}T00:00:00`)
|
||||
if (isMonth.value) {
|
||||
// 以当月 1 号为基准换月,避免 31 号在短月份溢出到下下个月
|
||||
date.setDate(1)
|
||||
date.setMonth(date.getMonth() + offset)
|
||||
} else {
|
||||
date.setDate(date.getDate() + offset * 7)
|
||||
}
|
||||
anchorDate.value = formatDate(date)
|
||||
loadBoard()
|
||||
}
|
||||
|
||||
function goCurrentRange() {
|
||||
anchorDate.value = formatDate(new Date())
|
||||
loadBoard()
|
||||
}
|
||||
|
||||
function handleViewModeChange() {
|
||||
loadBoard()
|
||||
}
|
||||
|
||||
function openCell(employee, date) {
|
||||
const entry = employee.days?.[date] || null
|
||||
Object.assign(selectedCell, {
|
||||
entry,
|
||||
employeeId: employee.id,
|
||||
employeeName: employee.name,
|
||||
scheduleDate: date
|
||||
})
|
||||
swapVisible.value = true
|
||||
}
|
||||
|
||||
function openTempEntry() {
|
||||
entryPreset.employeeIds = []
|
||||
entryPreset.date = ''
|
||||
entryVisible.value = true
|
||||
}
|
||||
|
||||
function handleEntryChanged() {
|
||||
loadBoard()
|
||||
}
|
||||
|
||||
async function openChanges() {
|
||||
changesVisible.value = true
|
||||
await loadChanges()
|
||||
}
|
||||
|
||||
async function loadChanges() {
|
||||
changesLoading.value = true
|
||||
try {
|
||||
const range = currentRange()
|
||||
const data = unwrap(await getScheduleChanges({
|
||||
start_date: range.start,
|
||||
end_date: range.end
|
||||
})) || {}
|
||||
changes.value = (data.list || []).map((row) => ({
|
||||
...row,
|
||||
scheduleDateText: (row.schedule_date || '').slice(0, 10)
|
||||
}))
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '加载变更记录失败')
|
||||
} finally {
|
||||
changesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadOrgTree(), loadShifts()])
|
||||
await loadBoard()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.scheduling-page {
|
||||
padding: 16px 20px 28px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.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-top: 3px solid #409eff;
|
||||
border-radius: 8px;
|
||||
|
||||
&.purple {
|
||||
border-top-color: #8b5cf6;
|
||||
}
|
||||
&.green {
|
||||
border-top-color: #67c23a;
|
||||
}
|
||||
&.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.board-wrap {
|
||||
overflow: auto;
|
||||
max-height: 640px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.board-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
min-width: 900px;
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
border-right: 1px solid #f2f3f5;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
}
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #f5f7fa;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
thead th.is-today {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.date-main {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
.date-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
// 法定假日表头:浅红底 + 「休」角标;调休上班日标「班」
|
||||
thead th.is-holiday:not(.is-today) {
|
||||
background: #fffafa;
|
||||
}
|
||||
.date-holiday {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.holiday-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.is-off {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
border: 1px solid #fde2e2;
|
||||
}
|
||||
&.is-work {
|
||||
background: #f4f4f5;
|
||||
color: #909399;
|
||||
border: 1px solid #e9e9eb;
|
||||
}
|
||||
}
|
||||
.holiday-name {
|
||||
font-size: 11px;
|
||||
color: #f56c6c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-right: 1px solid #ebeef5;
|
||||
}
|
||||
thead .sticky-col {
|
||||
z-index: 3;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.employee-cell {
|
||||
.emp-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.emp-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
.shift-cell {
|
||||
width: 118px;
|
||||
min-width: 118px;
|
||||
height: 56px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
|
||||
&.is-today {
|
||||
background: #f7fbff;
|
||||
}
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
.shift-block {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 5px 7px;
|
||||
border-left: 3px solid #409eff;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
min-height: 38px;
|
||||
}
|
||||
.shift-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shift-time {
|
||||
font-size: 11px;
|
||||
color: #606266;
|
||||
}
|
||||
.temp-flag {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
color: #e6a23c;
|
||||
background: #fdf6ec;
|
||||
}
|
||||
.empty-cell {
|
||||
color: #dcdfe6;
|
||||
font-size: 16px;
|
||||
line-height: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
// 月视图:整月同屏,列宽收紧,只显示班次简称(完整信息看悬浮提示)
|
||||
.board-wrap.is-month {
|
||||
.board-table {
|
||||
min-width: 0;
|
||||
|
||||
thead th {
|
||||
padding: 6px 2px;
|
||||
}
|
||||
.sticky-col {
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.shift-cell {
|
||||
width: 54px;
|
||||
min-width: 54px;
|
||||
height: 42px;
|
||||
padding: 2px;
|
||||
}
|
||||
.shift-block {
|
||||
gap: 1px;
|
||||
padding: 3px 4px;
|
||||
min-height: 30px;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
.shift-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
.date-main {
|
||||
font-size: 12px;
|
||||
}
|
||||
.date-sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
.date-holiday {
|
||||
gap: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.holiday-tag {
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 2px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.temp-flag {
|
||||
top: 1px;
|
||||
right: 2px;
|
||||
font-size: 9px;
|
||||
line-height: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.empty-cell {
|
||||
font-size: 13px;
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.truncated-tip {
|
||||
margin-top: 10px;
|
||||
color: #e6a23c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.change-arrow {
|
||||
margin: 0 4px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
.change-clear {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.change-new {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.legend {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 暗色主题适配 =====
|
||||
html.dark & {
|
||||
.page-header p {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--el-bg-color);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
|
||||
.stat-label,
|
||||
.stat-sub {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.stat-value {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
.table-card {
|
||||
background: var(--el-bg-color);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
.board-wrap {
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
.board-table {
|
||||
th,
|
||||
td {
|
||||
border-bottom-color: var(--el-border-color-lighter);
|
||||
border-right-color: var(--el-border-color-lighter);
|
||||
}
|
||||
thead th {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
thead th.is-today {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
thead th.is-holiday:not(.is-today) {
|
||||
background: rgba(245, 108, 108, 0.08);
|
||||
}
|
||||
.holiday-tag {
|
||||
&.is-off {
|
||||
background: rgba(245, 108, 108, 0.16);
|
||||
color: #f89898;
|
||||
border-color: rgba(245, 108, 108, 0.3);
|
||||
}
|
||||
&.is-work {
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
.holiday-name {
|
||||
color: #f89898;
|
||||
}
|
||||
.sticky-col {
|
||||
background: var(--el-bg-color);
|
||||
border-right-color: var(--el-border-color-lighter);
|
||||
}
|
||||
thead .sticky-col {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.employee-cell {
|
||||
.emp-name {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.emp-meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
.shift-cell {
|
||||
&.is-today {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
.shift-time {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
.legend {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,540 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/identity"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendOaSchedulingController OA 工作排班(班次 / 排班方案 / 排班看板 / 临时调配)。
|
||||
//
|
||||
// 排班的作用范围直接复用租户的组织架构(yz_backend_organization):
|
||||
// 公司、部门、小组是同一棵树上的不同层级,选中某个节点并勾选“含下级”
|
||||
// 即可把排班下发给该节点下的全部员工;也可以跳过组织,直接挑选
|
||||
// 一个或多个员工做临时安排。所有数据按 JWT 中的 tid 做租户隔离。
|
||||
type BackendOaSchedulingController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证与响应
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *BackendOaSchedulingController) scClaims() (*jwtutil.Claims, bool) {
|
||||
claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
|
||||
if err != nil || claims.TenantId <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// scOperator 返回当前操作人信息:tid、uid、用户名。
|
||||
func (c *BackendOaSchedulingController) scOperator() (uint64, uint64, string, bool) {
|
||||
claims, ok := c.scClaims()
|
||||
if !ok {
|
||||
c.scFail(401, 401, "未登录或登录已过期")
|
||||
return 0, 0, "", false
|
||||
}
|
||||
return uint64(claims.TenantId), uint64(claims.UserID), claims.Username, true
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scOK(data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scFail(httpStatus, bizCode int, msg string) {
|
||||
if httpStatus > 0 {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scBody() map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if len(c.Ctx.Input.RequestBody) == 0 {
|
||||
return body
|
||||
}
|
||||
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &body)
|
||||
return body
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scPathID(param string) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(param)), 10, 64)
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 参数解析辅助
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func scStringValue(body map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
raw, ok := body[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case float64:
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scIntValue(body map[string]interface{}, keys ...string) (int, bool) {
|
||||
raw := scStringValue(body, keys...)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func scUintValue(body map[string]interface{}, keys ...string) uint64 {
|
||||
raw := scStringValue(body, keys...)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// scUintSlice 解析 [] 形式的 ID 列表,兼容数字与字符串两种元素。
|
||||
func scUintSlice(body map[string]interface{}, key string) []uint64 {
|
||||
raw, ok := body[key]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint64, 0, len(list))
|
||||
seen := map[uint64]bool{}
|
||||
for _, item := range list {
|
||||
var value uint64
|
||||
switch v := item.(type) {
|
||||
case float64:
|
||||
value = uint64(v)
|
||||
case string:
|
||||
value, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64)
|
||||
}
|
||||
if value > 0 && !seen[value] {
|
||||
seen[value] = true
|
||||
ids = append(ids, value)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func scBoolValue(body map[string]interface{}, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
raw, ok := body[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case float64:
|
||||
return value != 0
|
||||
case string:
|
||||
return value == "1" || strings.EqualFold(value, "true")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scWeekdaySet 解析星期筛选,1=周一 ... 7=周日。
|
||||
// 支持 [1,3,5] 数组与 "1,3,5" 字符串;空值表示每天。
|
||||
func scWeekdaySet(body map[string]interface{}) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
raw, ok := body["weekdays"]
|
||||
if !ok || raw == nil {
|
||||
return result
|
||||
}
|
||||
add := func(value int) {
|
||||
if value >= 1 && value <= 7 {
|
||||
result[value] = true
|
||||
}
|
||||
}
|
||||
switch list := raw.(type) {
|
||||
case []interface{}:
|
||||
for _, item := range list {
|
||||
switch v := item.(type) {
|
||||
case float64:
|
||||
add(int(v))
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
add(n)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
for _, part := range strings.Split(list, ",") {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(part))
|
||||
add(n)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// scParseDate 解析 "YYYY-MM-DD" 并返回时间对象。
|
||||
func scParseDate(value string) (time.Time, bool) {
|
||||
if !isValidOaDate(value) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// scDateSpan 展开 [start, end] 之间的全部日期字符串;区间上限 92 天,
|
||||
// 避免一次请求把看板拖垮。
|
||||
func scDateSpan(start, end string) ([]string, string) {
|
||||
startDate, ok := scParseDate(start)
|
||||
if !ok {
|
||||
return nil, "开始日期格式错误"
|
||||
}
|
||||
endDate, ok := scParseDate(end)
|
||||
if !ok {
|
||||
return nil, "结束日期格式错误"
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return nil, "结束日期不能早于开始日期"
|
||||
}
|
||||
if endDate.Sub(startDate) > 92*24*time.Hour {
|
||||
return nil, "日期区间不能超过 92 天"
|
||||
}
|
||||
dates := make([]string, 0, 32)
|
||||
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
|
||||
dates = append(dates, cursor.Format("2006-01-02"))
|
||||
}
|
||||
return dates, ""
|
||||
}
|
||||
|
||||
// scMinutes 把 "HH:mm" 转为当日分钟数。
|
||||
func scMinutes(value string) (int, bool) {
|
||||
if value == "" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := time.ParseInLocation("15:04", value, time.Local)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed.Hour()*60 + parsed.Minute(), true
|
||||
}
|
||||
|
||||
// scCalcWorkHours 计算班次工时(小时):上下班跨度扣除休息时段。
|
||||
// 下班时间早于上班时间视为跨天班次,自动加 24 小时。
|
||||
func scCalcWorkHours(workStart, workEnd, restStart, restEnd string) float64 {
|
||||
start, okStart := scMinutes(workStart)
|
||||
end, okEnd := scMinutes(workEnd)
|
||||
if !okStart || !okEnd {
|
||||
return 0
|
||||
}
|
||||
if end <= start {
|
||||
end += 24 * 60
|
||||
}
|
||||
span := end - start
|
||||
if restStart != "" && restEnd != "" {
|
||||
restFrom, okFrom := scMinutes(restStart)
|
||||
restTo, okTo := scMinutes(restEnd)
|
||||
if okFrom && okTo {
|
||||
if restTo <= restFrom {
|
||||
restTo += 24 * 60
|
||||
}
|
||||
if restTo > start && restFrom < end {
|
||||
overlap := restTo - restFrom
|
||||
if restFrom < start {
|
||||
overlap -= start - restFrom
|
||||
}
|
||||
if restTo > end {
|
||||
overlap -= restTo - end
|
||||
}
|
||||
if overlap > 0 {
|
||||
span -= overlap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if span < 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(span) / 60
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 班次管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *BackendOaSchedulingController) shiftQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.OaShift)).
|
||||
Filter("tid", tid).
|
||||
Filter("is_deleted", 0)
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) shiftByID(tid, id uint64) (*models.OaShift, bool) {
|
||||
var row models.OaShift
|
||||
if err := c.shiftQuery(tid).Filter("id", id).One(&row); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &row, true
|
||||
}
|
||||
|
||||
// ShiftList 班次列表。
|
||||
// GET /backend/oa/scheduling/shifts
|
||||
func (c *BackendOaSchedulingController) ShiftList() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var rows []models.OaShift
|
||||
if _, err := c.shiftQuery(tid).OrderBy("sort", "id").All(&rows); err != nil {
|
||||
c.scFail(500, 500, "查询班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.OaShift{}
|
||||
}
|
||||
|
||||
var defaultID uint64
|
||||
for _, row := range rows {
|
||||
if row.IsDefault == 1 {
|
||||
defaultID = row.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{
|
||||
"list": rows,
|
||||
"default_id": defaultID,
|
||||
"total": len(rows),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateShift 新增班次。
|
||||
// POST /backend/oa/scheduling/shifts
|
||||
func (c *BackendOaSchedulingController) CreateShift() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
|
||||
row, msg := c.buildShiftFromBody(body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
row.Tid = int(tid)
|
||||
|
||||
if row.IsDefault == 1 {
|
||||
c.clearDefaultShift(tid, 0)
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
c.scFail(500, 500, "新增班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.scOK(row)
|
||||
}
|
||||
|
||||
// UpdateShift 更新班次。
|
||||
// POST /backend/oa/scheduling/shifts/:id
|
||||
func (c *BackendOaSchedulingController) UpdateShift() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的班次ID")
|
||||
return
|
||||
}
|
||||
existing, found := c.shiftByID(tid, id)
|
||||
if !found {
|
||||
c.scFail(404, 404, "班次不存在")
|
||||
return
|
||||
}
|
||||
|
||||
body := c.scBody()
|
||||
row, msg := c.buildShiftFromBody(body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
|
||||
existing.Name = row.Name
|
||||
existing.ShortName = row.ShortName
|
||||
existing.Color = row.Color
|
||||
existing.WorkStart = row.WorkStart
|
||||
existing.WorkEnd = row.WorkEnd
|
||||
existing.RestStart = row.RestStart
|
||||
existing.RestEnd = row.RestEnd
|
||||
existing.WorkHours = row.WorkHours
|
||||
existing.IsDefault = row.IsDefault
|
||||
existing.Status = row.Status
|
||||
existing.Sort = row.Sort
|
||||
existing.Remark = row.Remark
|
||||
|
||||
if existing.IsDefault == 1 {
|
||||
c.clearDefaultShift(tid, existing.ID)
|
||||
}
|
||||
if _, err := models.Orm.Update(existing); err != nil {
|
||||
c.scFail(500, 500, "更新班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 班次名称/时间变化时同步已排班的快照,避免看板显示旧时间。
|
||||
c.syncShiftSnapshot(tid, existing)
|
||||
|
||||
c.scOK(existing)
|
||||
}
|
||||
|
||||
// DeleteShift 删除班次(软删除)。已被排班使用时拒绝删除,建议改为停用。
|
||||
// DELETE /backend/oa/scheduling/shifts/:id
|
||||
func (c *BackendOaSchedulingController) DeleteShift() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的班次ID")
|
||||
return
|
||||
}
|
||||
row, found := c.shiftByID(tid, id)
|
||||
if !found {
|
||||
c.scFail(404, 404, "班次不存在")
|
||||
return
|
||||
}
|
||||
|
||||
used, _ := models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||
Filter("tid", tid).
|
||||
Filter("shift_id", id).
|
||||
Filter("is_deleted", 0).
|
||||
Count()
|
||||
if used > 0 {
|
||||
c.scFail(400, 400, "该班次已被排班使用,无法删除,请改为停用")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
row.IsDeleted = 1
|
||||
row.DeleteTime = &now
|
||||
if _, err := models.Orm.Update(row, "is_deleted", "delete_time"); err != nil {
|
||||
c.scFail(500, 500, "删除班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scOK(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// buildShiftFromBody 校验并构造班次对象;返回非空 msg 表示参数错误。
|
||||
func (c *BackendOaSchedulingController) buildShiftFromBody(body map[string]interface{}) (*models.OaShift, string) {
|
||||
name := scStringValue(body, "name")
|
||||
if name == "" {
|
||||
return nil, "班次名称不能为空"
|
||||
}
|
||||
if len([]rune(name)) > 50 {
|
||||
return nil, "班次名称不能超过 50 个字符"
|
||||
}
|
||||
|
||||
workStart := scStringValue(body, "work_start")
|
||||
workEnd := scStringValue(body, "work_end")
|
||||
if !isValidOaTime(workStart) || !isValidOaTime(workEnd) {
|
||||
return nil, "上下班时间格式应为 HH:mm"
|
||||
}
|
||||
if workStart == workEnd {
|
||||
return nil, "上下班时间不能相同"
|
||||
}
|
||||
|
||||
restStart := scStringValue(body, "rest_start")
|
||||
restEnd := scStringValue(body, "rest_end")
|
||||
if (restStart == "") != (restEnd == "") {
|
||||
return nil, "休息开始与结束时间需要同时填写"
|
||||
}
|
||||
if restStart != "" && (!isValidOaTime(restStart) || !isValidOaTime(restEnd)) {
|
||||
return nil, "休息时间格式应为 HH:mm"
|
||||
}
|
||||
|
||||
shortName := scStringValue(body, "short_name")
|
||||
if shortName == "" {
|
||||
shortName = name
|
||||
}
|
||||
color := scStringValue(body, "color")
|
||||
if color == "" {
|
||||
color = "#409EFF"
|
||||
}
|
||||
status := int8(1)
|
||||
if value, has := scIntValue(body, "status"); has {
|
||||
status = int8(value)
|
||||
}
|
||||
sortValue, _ := scIntValue(body, "sort")
|
||||
isDefault := int8(0)
|
||||
if scBoolValue(body, "is_default") {
|
||||
isDefault = 1
|
||||
}
|
||||
remark := scStringValue(body, "remark")
|
||||
row := &models.OaShift{
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
Color: color,
|
||||
WorkStart: workStart,
|
||||
WorkEnd: workEnd,
|
||||
RestStart: restStart,
|
||||
RestEnd: restEnd,
|
||||
WorkHours: scCalcWorkHours(workStart, workEnd, restStart, restEnd),
|
||||
IsDefault: isDefault,
|
||||
Status: status,
|
||||
Sort: sortValue,
|
||||
}
|
||||
if remark != "" {
|
||||
row.Remark = &remark
|
||||
}
|
||||
return row, ""
|
||||
}
|
||||
|
||||
// clearDefaultShift 把同租户下其它班次的默认标记清掉,保证只有一个默认班次。
|
||||
func (c *BackendOaSchedulingController) clearDefaultShift(tid, excludeID uint64) {
|
||||
qs := c.shiftQuery(tid).Filter("is_default", 1)
|
||||
if excludeID > 0 {
|
||||
qs = qs.Exclude("id", excludeID)
|
||||
}
|
||||
var rows []models.OaShift
|
||||
if _, err := qs.All(&rows); err != nil {
|
||||
return
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].IsDefault = 0
|
||||
_, _ = models.Orm.Update(&rows[i], "is_default")
|
||||
}
|
||||
}
|
||||
|
||||
// syncShiftSnapshot 班次变更后,把新快照写回已生成的排班明细。
|
||||
func (c *BackendOaSchedulingController) syncShiftSnapshot(tid uint64, shift *models.OaShift) {
|
||||
_, _ = models.Orm.Raw(
|
||||
"UPDATE yz_backend_oa_schedule_entry SET shift_name=?, short_name=?, color=?, work_start=?, work_end=? "+
|
||||
"WHERE tid=? AND shift_id=? AND is_deleted=0",
|
||||
shift.Name, shift.ShortName, shift.Color, shift.WorkStart, shift.WorkEnd, tid, shift.ID,
|
||||
).Exec()
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
const scBoardMaxDays = 62
|
||||
|
||||
// scEntryView 看板单元格里的排班信息。
|
||||
type scEntryView struct {
|
||||
ID uint64 `json:"id"`
|
||||
PlanID uint64 `json:"plan_id"`
|
||||
ShiftID uint64 `json:"shift_id"`
|
||||
ShiftName string `json:"shift_name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Color string `json:"color"`
|
||||
WorkStart string `json:"work_start"`
|
||||
WorkEnd string `json:"work_end"`
|
||||
Source int8 `json:"source"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type scBoardRow struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Account string `json:"account"`
|
||||
OrgID uint64 `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
Position string `json:"position"`
|
||||
Days map[string]scEntryView `json:"days"`
|
||||
}
|
||||
|
||||
type scDateCell struct {
|
||||
Date string `json:"date"`
|
||||
Weekday int `json:"weekday"`
|
||||
IsToday bool `json:"is_today"`
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scEntryQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||
Filter("tid", tid).
|
||||
Filter("is_deleted", 0)
|
||||
}
|
||||
|
||||
// Board 排班看板:按员工 × 日期返回区间内的排班矩阵。
|
||||
// GET /backend/oa/scheduling/board?start_date=&end_date=&org_id=&include_child=1&keyword=
|
||||
func (c *BackendOaSchedulingController) Board() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate := strings.TrimSpace(c.GetString("start_date"))
|
||||
endDate := strings.TrimSpace(c.GetString("end_date"))
|
||||
if startDate == "" || endDate == "" {
|
||||
// 默认展示本周(周一至周日)
|
||||
now := time.Now()
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
monday := now.AddDate(0, 0, -(weekday - 1))
|
||||
startDate = monday.Format("2006-01-02")
|
||||
endDate = monday.AddDate(0, 0, 6).Format("2006-01-02")
|
||||
}
|
||||
dates, msg := scDateSpan(startDate, endDate)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
if len(dates) > scBoardMaxDays {
|
||||
c.scFail(400, 400, fmt.Sprintf("看板一次最多展示 %d 天", scBoardMaxDays))
|
||||
return
|
||||
}
|
||||
|
||||
orgID := uint64(0)
|
||||
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||
if parsed, err := strconvParseUint(raw); err == nil {
|
||||
orgID = parsed
|
||||
}
|
||||
}
|
||||
includeChild := c.GetString("include_child") != "0"
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
onlyScheduled := c.GetString("only_scheduled") == "1"
|
||||
shiftFilter := uint64(0)
|
||||
if raw := strings.TrimSpace(c.GetString("shift_id")); raw != "" {
|
||||
if parsed, err := strconvParseUint(raw); err == nil {
|
||||
shiftFilter = parsed
|
||||
}
|
||||
}
|
||||
|
||||
employees, err := c.scEmployeesByOrg(tid, orgID, includeChild)
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "查询员工失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
index := c.scLoadOrgIndex(tid)
|
||||
rows := make([]scBoardRow, 0, len(employees))
|
||||
employeeIDs := make([]uint64, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
if keyword != "" && !strings.Contains(employee.Name, keyword) && !strings.Contains(employee.Account, keyword) {
|
||||
continue
|
||||
}
|
||||
orgID := scEmployeeDepartmentID(employee)
|
||||
position := ""
|
||||
if employee.Position != nil {
|
||||
position = *employee.Position
|
||||
}
|
||||
rows = append(rows, scBoardRow{
|
||||
ID: employee.ID,
|
||||
Name: employee.Name,
|
||||
Account: employee.Account,
|
||||
OrgID: orgID,
|
||||
OrgName: index.name(orgID),
|
||||
Position: position,
|
||||
Days: map[string]scEntryView{},
|
||||
})
|
||||
employeeIDs = append(employeeIDs, uint64(employee.ID))
|
||||
}
|
||||
|
||||
truncated := false
|
||||
const boardMaxEmployees = 500
|
||||
if len(rows) > boardMaxEmployees {
|
||||
rows = rows[:boardMaxEmployees]
|
||||
employeeIDs = employeeIDs[:boardMaxEmployees]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if len(employeeIDs) > 0 {
|
||||
var entries []models.OaScheduleEntry
|
||||
_, err = c.scEntryQuery(tid).
|
||||
Filter("employee_id__in", employeeIDs).
|
||||
Filter("schedule_date__gte", dates[0]).
|
||||
Filter("schedule_date__lte", dates[len(dates)-1]).
|
||||
All(&entries)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rowIndex := map[uint]*scBoardRow{}
|
||||
for i := range rows {
|
||||
rowIndex[rows[i].ID] = &rows[i]
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if shiftFilter > 0 && entry.ShiftID != shiftFilter {
|
||||
continue
|
||||
}
|
||||
row, hit := rowIndex[entry.EmployeeID]
|
||||
if !hit {
|
||||
continue
|
||||
}
|
||||
row.Days[normalizeOaDate(entry.ScheduleDate)] = scEntryView{
|
||||
ID: entry.ID,
|
||||
PlanID: entry.PlanID,
|
||||
ShiftID: entry.ShiftID,
|
||||
ShiftName: entry.ShiftName,
|
||||
ShortName: entry.ShortName,
|
||||
Color: entry.Color,
|
||||
WorkStart: entry.WorkStart,
|
||||
WorkEnd: entry.WorkEnd,
|
||||
Source: entry.Source,
|
||||
Note: entry.Note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if onlyScheduled {
|
||||
filtered := rows[:0]
|
||||
for _, row := range rows {
|
||||
if len(row.Days) > 0 {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
rows = filtered
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
dateCells := make([]scDateCell, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
parsed, _ := scParseDate(date)
|
||||
weekday := int(parsed.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
dateCells = append(dateCells, scDateCell{Date: date, Weekday: weekday, IsToday: date == today})
|
||||
}
|
||||
|
||||
// 汇总:排班人次、临时调配数、未排班人数、今日在岗、班次分布
|
||||
entryCount, tempCount, unassigned := 0, 0, 0
|
||||
todayOnDuty := 0
|
||||
shiftCounter := map[string]int{}
|
||||
shiftColor := map[string]string{}
|
||||
for _, row := range rows {
|
||||
if len(row.Days) == 0 {
|
||||
unassigned++
|
||||
}
|
||||
for date, entry := range row.Days {
|
||||
entryCount++
|
||||
if entry.Source == models.OaEntrySourceTemp {
|
||||
tempCount++
|
||||
}
|
||||
if date == today {
|
||||
todayOnDuty++
|
||||
}
|
||||
name := entry.ShiftName
|
||||
if name == "" {
|
||||
name = "未命名班次"
|
||||
}
|
||||
shiftCounter[name]++
|
||||
shiftColor[name] = entry.Color
|
||||
}
|
||||
}
|
||||
shiftDist := make([]map[string]interface{}, 0, len(shiftCounter))
|
||||
for name, count := range shiftCounter {
|
||||
shiftDist = append(shiftDist, map[string]interface{}{
|
||||
"name": name,
|
||||
"count": count,
|
||||
"color": shiftColor[name],
|
||||
})
|
||||
}
|
||||
sort.Slice(shiftDist, func(i, j int) bool {
|
||||
return shiftDist[i]["count"].(int) > shiftDist[j]["count"].(int)
|
||||
})
|
||||
|
||||
var shifts []models.OaShift
|
||||
_, _ = c.shiftQuery(tid).Filter("status", 1).OrderBy("sort", "id").All(&shifts)
|
||||
if shifts == nil {
|
||||
shifts = []models.OaShift{}
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{
|
||||
"start_date": startDate,
|
||||
"end_date": endDate,
|
||||
"dates": dateCells,
|
||||
"employees": rows,
|
||||
"shifts": shifts,
|
||||
"truncated": truncated,
|
||||
"summary": map[string]interface{}{
|
||||
"employee_count": len(rows),
|
||||
"entry_count": entryCount,
|
||||
"temp_count": tempCount,
|
||||
"unassigned": unassigned,
|
||||
"today_on_duty": todayOnDuty,
|
||||
"shift_dist": shiftDist,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func strconvParseUint(value string) (uint64, error) {
|
||||
return strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||||
}
|
||||
|
||||
// scEntryTargets 解析临时排班/批量清除的目标员工与日期。
|
||||
func (c *BackendOaSchedulingController) scEntryTargets(tid uint64, body map[string]interface{}) ([]models.BackendEmployee, []string, string) {
|
||||
employeeIDs := scUintSlice(body, "employee_ids")
|
||||
if len(employeeIDs) == 0 {
|
||||
return nil, nil, "请选择员工"
|
||||
}
|
||||
if len(employeeIDs) > scPlanMaxEmployees {
|
||||
return nil, nil, fmt.Sprintf("单次最多操作 %d 名员工", scPlanMaxEmployees)
|
||||
}
|
||||
|
||||
startDate := scStringValue(body, "start_date", "schedule_date", "date")
|
||||
endDate := scStringValue(body, "end_date")
|
||||
if endDate == "" {
|
||||
endDate = startDate
|
||||
}
|
||||
dates, msg := scDateSpan(startDate, endDate)
|
||||
if msg != "" {
|
||||
return nil, nil, msg
|
||||
}
|
||||
workdays := scWorkdays(dates, scWeekdaySet(body))
|
||||
if len(workdays) == 0 {
|
||||
return nil, nil, "所选日期区间内没有符合条件的日期"
|
||||
}
|
||||
|
||||
var employees []models.BackendEmployee
|
||||
_, err := c.scBaseEmployeeQuery(tid).Filter("id__in", employeeIDs).All(&employees)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, nil, "查询员工失败: " + err.Error()
|
||||
}
|
||||
if len(employees) == 0 {
|
||||
return nil, nil, "员工不存在或已离职"
|
||||
}
|
||||
if len(employees)*len(workdays) > scPlanMaxEntries {
|
||||
return nil, nil, fmt.Sprintf("本次将产生 %d 条排班,超过单次 %d 条上限", len(employees)*len(workdays), scPlanMaxEntries)
|
||||
}
|
||||
return employees, workdays, ""
|
||||
}
|
||||
|
||||
// SaveEntries 临时排班:给一个或多个员工在指定日期/星期安排班次。
|
||||
// shift_id=0 时表示清除这些日期的排班。
|
||||
// POST /backend/oa/scheduling/entries/save
|
||||
func (c *BackendOaSchedulingController) SaveEntries() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
employees, workdays, msg := c.scEntryTargets(tid, body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
|
||||
shiftID := scUintValue(body, "shift_id")
|
||||
var shift *models.OaShift
|
||||
if shiftID > 0 {
|
||||
loaded, found := c.scLoadShift(tid, shiftID)
|
||||
if !found {
|
||||
c.scFail(400, 400, "班次不存在或已停用")
|
||||
return
|
||||
}
|
||||
shift = loaded
|
||||
}
|
||||
|
||||
note := scStringValue(body, "note")
|
||||
overwrite := true
|
||||
if _, has := body["overwrite"]; has {
|
||||
overwrite = scBoolValue(body, "overwrite")
|
||||
}
|
||||
|
||||
empIDs := make([]uint64, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
empIDs = append(empIDs, uint64(employee.ID))
|
||||
}
|
||||
var existing []models.OaScheduleEntry
|
||||
_, err := c.scEntryQuery(tid).
|
||||
Filter("employee_id__in", empIDs).
|
||||
Filter("schedule_date__gte", workdays[0]).
|
||||
Filter("schedule_date__lte", workdays[len(workdays)-1]).
|
||||
All(&existing)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询已有排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
existingMap := map[string]*models.OaScheduleEntry{}
|
||||
for i := range existing {
|
||||
key := fmt.Sprintf("%d|%s", existing[i].EmployeeID, normalizeOaDate(existing[i].ScheduleDate))
|
||||
existingMap[key] = &existing[i]
|
||||
}
|
||||
|
||||
index := c.scLoadOrgIndex(tid)
|
||||
stats := map[string]int{"created": 0, "updated": 0, "cleared": 0, "skipped": 0}
|
||||
now := time.Now()
|
||||
|
||||
for _, employee := range employees {
|
||||
orgID := scEmployeeDepartmentID(employee)
|
||||
position := ""
|
||||
if employee.Position != nil {
|
||||
position = *employee.Position
|
||||
}
|
||||
for _, date := range workdays {
|
||||
key := fmt.Sprintf("%d|%s", employee.ID, date)
|
||||
row := existingMap[key]
|
||||
|
||||
// shift_id=0:清除排班
|
||||
if shift == nil {
|
||||
if row == nil {
|
||||
stats["skipped"]++
|
||||
continue
|
||||
}
|
||||
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
||||
row.IsDeleted = row.ID
|
||||
row.DeleteTime = &now
|
||||
if _, updateErr := models.Orm.Update(row, "is_deleted", "delete_time"); updateErr != nil {
|
||||
c.scFail(500, 500, "清除排班失败: "+updateErr.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, date,
|
||||
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", note, uid, username)
|
||||
delete(existingMap, key)
|
||||
stats["cleared"]++
|
||||
continue
|
||||
}
|
||||
|
||||
if row != nil {
|
||||
if !overwrite {
|
||||
stats["skipped"]++
|
||||
continue
|
||||
}
|
||||
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
||||
row.ShiftID = shift.ID
|
||||
row.ShiftName = shift.Name
|
||||
row.ShortName = shift.ShortName
|
||||
row.Color = shift.Color
|
||||
row.WorkStart = shift.WorkStart
|
||||
row.WorkEnd = shift.WorkEnd
|
||||
row.Source = models.OaEntrySourceTemp
|
||||
row.Note = note
|
||||
row.CreatedBy = uid
|
||||
row.CreatedByName = username
|
||||
if _, updateErr := models.Orm.Update(row); updateErr != nil {
|
||||
c.scFail(500, 500, "调整排班失败: "+updateErr.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, date,
|
||||
models.OaChangeShift, oldShiftID, oldShiftName, shift.ID, shift.Name, note, uid, username)
|
||||
stats["updated"]++
|
||||
continue
|
||||
}
|
||||
|
||||
entry := &models.OaScheduleEntry{
|
||||
Tid: int(tid),
|
||||
EmployeeID: employee.ID,
|
||||
EmployeeName: employee.Name,
|
||||
EmployeeAccount: employee.Account,
|
||||
OrgID: orgID,
|
||||
OrgName: index.name(orgID),
|
||||
Position: position,
|
||||
ScheduleDate: date,
|
||||
ShiftID: shift.ID,
|
||||
ShiftName: shift.Name,
|
||||
ShortName: shift.ShortName,
|
||||
Color: shift.Color,
|
||||
WorkStart: shift.WorkStart,
|
||||
WorkEnd: shift.WorkEnd,
|
||||
Source: models.OaEntrySourceTemp,
|
||||
Note: note,
|
||||
CreatedBy: uid,
|
||||
CreatedByName: username,
|
||||
}
|
||||
if _, insertErr := models.Orm.Insert(entry); insertErr != nil {
|
||||
c.scFail(500, 500, "新增排班失败: "+insertErr.Error())
|
||||
return
|
||||
}
|
||||
existingMap[key] = entry
|
||||
c.scWriteChange(models.Orm, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, date,
|
||||
models.OaChangeManual, 0, "", shift.ID, shift.Name, note, uid, username)
|
||||
stats["created"]++
|
||||
}
|
||||
}
|
||||
|
||||
stats["entry_count"] = stats["created"] + stats["updated"] + stats["cleared"]
|
||||
c.scOK(stats)
|
||||
}
|
||||
|
||||
// SwapEntry 单条调班:把某个员工某天的班次换成另一个班次。
|
||||
// POST /backend/oa/scheduling/entries/swap/:id
|
||||
func (c *BackendOaSchedulingController) SwapEntry() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的排班ID")
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
shiftID := scUintValue(body, "shift_id")
|
||||
if shiftID == 0 {
|
||||
c.scFail(400, 400, "请选择调换后的班次")
|
||||
return
|
||||
}
|
||||
shift, found := c.scLoadShift(tid, shiftID)
|
||||
if !found {
|
||||
c.scFail(400, 400, "班次不存在或已停用")
|
||||
return
|
||||
}
|
||||
|
||||
var entry models.OaScheduleEntry
|
||||
if err := c.scEntryQuery(tid).Filter("id", id).One(&entry); err != nil {
|
||||
c.scFail(404, 404, "排班记录不存在")
|
||||
return
|
||||
}
|
||||
if entry.ShiftID == shift.ID {
|
||||
c.scFail(400, 400, "该排班已经是目标班次")
|
||||
return
|
||||
}
|
||||
|
||||
reason := scStringValue(body, "reason")
|
||||
oldShiftID, oldShiftName := entry.ShiftID, entry.ShiftName
|
||||
entry.ShiftID = shift.ID
|
||||
entry.ShiftName = shift.Name
|
||||
entry.ShortName = shift.ShortName
|
||||
entry.Color = shift.Color
|
||||
entry.WorkStart = shift.WorkStart
|
||||
entry.WorkEnd = shift.WorkEnd
|
||||
entry.Source = models.OaEntrySourceTemp
|
||||
if reason != "" {
|
||||
entry.Note = reason
|
||||
}
|
||||
if _, err := models.Orm.Update(&entry); err != nil {
|
||||
c.scFail(500, 500, "调班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(models.Orm, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, normalizeOaDate(entry.ScheduleDate),
|
||||
models.OaChangeShift, oldShiftID, oldShiftName, shift.ID, shift.Name, reason, uid, username)
|
||||
|
||||
c.scOK(map[string]interface{}{"entry": entry, "old_shift_name": oldShiftName})
|
||||
}
|
||||
|
||||
// ClearEntries 批量清除排班:按明细 ID,或按员工 + 日期区间。
|
||||
// POST /backend/oa/scheduling/entries/clear
|
||||
func (c *BackendOaSchedulingController) ClearEntries() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
entryIDs := scUintSlice(body, "entry_ids")
|
||||
|
||||
var entries []models.OaScheduleEntry
|
||||
if len(entryIDs) > 0 {
|
||||
_, err := c.scEntryQuery(tid).Filter("id__in", entryIDs).All(&entries)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
employees, workdays, msg := c.scEntryTargets(tid, body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
empIDs := make([]uint64, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
empIDs = append(empIDs, uint64(employee.ID))
|
||||
}
|
||||
_, err := c.scEntryQuery(tid).
|
||||
Filter("employee_id__in", empIDs).
|
||||
Filter("schedule_date__in", workdays).
|
||||
All(&entries)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
c.scOK(map[string]interface{}{"cleared": 0})
|
||||
return
|
||||
}
|
||||
if len(entries) > scPlanMaxEntries {
|
||||
c.scFail(400, 400, fmt.Sprintf("单次最多清除 %d 条排班", scPlanMaxEntries))
|
||||
return
|
||||
}
|
||||
|
||||
reason := scStringValue(body, "reason")
|
||||
now := time.Now()
|
||||
tx, err := orm.NewOrm().Begin()
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "清除排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
for i := range entries {
|
||||
entry := &entries[i]
|
||||
oldShiftID, oldShiftName := entry.ShiftID, entry.ShiftName
|
||||
entry.IsDeleted = entry.ID
|
||||
entry.DeleteTime = &now
|
||||
if _, err := tx.Update(entry, "is_deleted", "delete_time"); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.scFail(500, 500, "清除排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(tx, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, normalizeOaDate(entry.ScheduleDate),
|
||||
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", reason, uid, username)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.scFail(500, 500, "清除排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{"cleared": len(entries)})
|
||||
}
|
||||
|
||||
// ChangeList 排班变更记录(最近 100 条),用于追溯临时调配。
|
||||
// GET /backend/oa/scheduling/changes?start_date=&end_date=&employee_id=
|
||||
func (c *BackendOaSchedulingController) ChangeList() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.OaScheduleChange)).Filter("tid", tid)
|
||||
if start := strings.TrimSpace(c.GetString("start_date")); isValidOaDate(start) {
|
||||
qs = qs.Filter("schedule_date__gte", start)
|
||||
}
|
||||
if end := strings.TrimSpace(c.GetString("end_date")); isValidOaDate(end) {
|
||||
qs = qs.Filter("schedule_date__lte", end)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.GetString("employee_id")); raw != "" {
|
||||
if employeeID, err := strconvParseUint(raw); err == nil {
|
||||
qs = qs.Filter("employee_id", employeeID)
|
||||
}
|
||||
}
|
||||
var rows []models.OaScheduleChange
|
||||
_, err := qs.OrderBy("-id").Limit(100).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询变更记录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.OaScheduleChange{}
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].ScheduleDate = normalizeOaDate(rows[i].ScheduleDate)
|
||||
}
|
||||
c.scOK(map[string]interface{}{"list": rows})
|
||||
}
|
||||
|
||||
// ScheduleEmployeeOptions 排班可选员工(供“指定员工”范围选择)。
|
||||
// GET /backend/oa/scheduling/employees?org_id=&include_child=1&keyword=
|
||||
func (c *BackendOaSchedulingController) ScheduleEmployeeOptions() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orgID := uint64(0)
|
||||
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||
if parsed, err := strconvParseUint(raw); err == nil {
|
||||
orgID = parsed
|
||||
}
|
||||
}
|
||||
includeChild := c.GetString("include_child") != "0"
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
|
||||
employees, err := c.scEmployeesByOrg(tid, orgID, includeChild)
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "查询员工失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list := c.scEmployeeDTOs(tid, employees)
|
||||
if keyword != "" {
|
||||
filtered := make([]schedulingEmployee, 0, len(list))
|
||||
for _, item := range list {
|
||||
if strings.Contains(item.Name, keyword) || strings.Contains(item.Account, keyword) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
if list == nil {
|
||||
list = []schedulingEmployee{}
|
||||
}
|
||||
c.scOK(map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// scPatternDay 排班方案周期规则的每日快照。
|
||||
//
|
||||
// AlignMode=1/2/3 时,Plan.Pattern 保存该结构的 JSON 数组:模板的规则在
|
||||
// 「调用模板」时复制进方案,之后平台模板怎么改都不影响已保存的方案。
|
||||
// 发布方案时按自然周 / 开始日滚动 / 自然月推算每天命中哪一天规则。
|
||||
type scPatternDay struct {
|
||||
DayIndex int `json:"day_index"`
|
||||
DayType int8 `json:"day_type"`
|
||||
ShiftID uint64 `json:"shift_id"`
|
||||
ShiftName string `json:"shift_name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Color string `json:"color"`
|
||||
WorkStart string `json:"work_start"`
|
||||
WorkEnd string `json:"work_end"`
|
||||
RestStart string `json:"rest_start"`
|
||||
RestEnd string `json:"rest_end"`
|
||||
WorkHours float64 `json:"work_hours"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// scPatternFromBody 解析前端提交的 pattern;兼容数组与 JSON 字符串两种形式。
|
||||
func scPatternFromBody(body map[string]interface{}) []scPatternDay {
|
||||
raw, ok := body["pattern"]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
list := []scPatternDay{}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &list); err != nil {
|
||||
return nil
|
||||
}
|
||||
case []interface{}:
|
||||
buf, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(buf, &list); err != nil {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func scPatternToJSON(days []scPatternDay) string {
|
||||
if len(days) == 0 {
|
||||
return ""
|
||||
}
|
||||
buf, err := json.Marshal(days)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func scPatternFromJSON(raw string) []scPatternDay {
|
||||
days := []scPatternDay{}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return days
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &days); err != nil {
|
||||
return []scPatternDay{}
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// scValidatePattern 校验周期规则,返回补齐默认值并按 day_index 排序后的规则。
|
||||
func scValidatePattern(days []scPatternDay, alignMode int8, cycleDays int) ([]scPatternDay, string) {
|
||||
if alignMode < models.SchedTplAlignWeek || alignMode > models.SchedTplAlignMonth {
|
||||
return nil, "排班方式不合法"
|
||||
}
|
||||
if len(days) == 0 {
|
||||
return nil, "缺少排班规则,请重新选择模板或班次"
|
||||
}
|
||||
if alignMode == models.SchedTplAlignMonth {
|
||||
// 月休按自然月计算,周期天数无意义
|
||||
cycleDays = 0
|
||||
} else {
|
||||
if cycleDays < 2 || cycleDays > 31 {
|
||||
return nil, "周期天数需在 2~31 天之间"
|
||||
}
|
||||
if alignMode == models.SchedTplAlignWeek && cycleDays%7 != 0 {
|
||||
return nil, "按自然周排班时,周期天数必须是 7 的倍数(7 / 14 / 21 / 28)"
|
||||
}
|
||||
if len(days) != cycleDays {
|
||||
return nil, fmt.Sprintf("周期为 %d 天,需要配置 %d 天的规则", cycleDays, cycleDays)
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[int]bool{}
|
||||
workCount := 0
|
||||
result := make([]scPatternDay, 0, len(days))
|
||||
for _, item := range days {
|
||||
index := item.DayIndex
|
||||
if alignMode == models.SchedTplAlignMonth {
|
||||
index = len(result)
|
||||
}
|
||||
if index < 0 || (alignMode != models.SchedTplAlignMonth && index >= cycleDays) {
|
||||
return nil, "规则的天序号不合法"
|
||||
}
|
||||
if seen[index] {
|
||||
return nil, "规则的天序号重复"
|
||||
}
|
||||
seen[index] = true
|
||||
if item.DayType != models.SchedTplDayWork && item.DayType != models.SchedTplDayRest {
|
||||
return nil, "规则类型不合法(1=上班 2=休息)"
|
||||
}
|
||||
|
||||
row := scPatternDay{DayIndex: index, DayType: item.DayType, Remark: strings.TrimSpace(item.Remark)}
|
||||
if item.DayType == models.SchedTplDayWork {
|
||||
workCount++
|
||||
row.ShiftID = item.ShiftID
|
||||
row.ShiftName = strings.TrimSpace(item.ShiftName)
|
||||
row.ShortName = strings.TrimSpace(item.ShortName)
|
||||
row.Color = strings.TrimSpace(item.Color)
|
||||
row.WorkStart = strings.TrimSpace(item.WorkStart)
|
||||
row.WorkEnd = strings.TrimSpace(item.WorkEnd)
|
||||
row.RestStart = strings.TrimSpace(item.RestStart)
|
||||
row.RestEnd = strings.TrimSpace(item.RestEnd)
|
||||
row.WorkHours = item.WorkHours
|
||||
if row.ShiftName == "" {
|
||||
return nil, fmt.Sprintf("第 %d 天选择上班,班次不能为空", index+1)
|
||||
}
|
||||
if row.ShortName == "" {
|
||||
row.ShortName = row.ShiftName
|
||||
}
|
||||
if row.Color == "" {
|
||||
row.Color = "#409EFF"
|
||||
}
|
||||
if !isValidOaTime(row.WorkStart) || !isValidOaTime(row.WorkEnd) {
|
||||
return nil, fmt.Sprintf("第 %d 天的上下班时间格式应为 HH:mm", index+1)
|
||||
}
|
||||
if row.WorkStart == row.WorkEnd {
|
||||
return nil, fmt.Sprintf("第 %d 天的上下班时间不能相同", index+1)
|
||||
}
|
||||
if (row.RestStart == "") != (row.RestEnd == "") {
|
||||
return nil, fmt.Sprintf("第 %d 天的休息开始与结束时间需要同时填写", index+1)
|
||||
}
|
||||
if row.RestStart != "" && (!isValidOaTime(row.RestStart) || !isValidOaTime(row.RestEnd)) {
|
||||
return nil, fmt.Sprintf("第 %d 天的休息时间格式应为 HH:mm", index+1)
|
||||
}
|
||||
if row.WorkHours <= 0 {
|
||||
row.WorkHours = scCalcWorkHours(row.WorkStart, row.WorkEnd, row.RestStart, row.RestEnd)
|
||||
}
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if workCount == 0 {
|
||||
return nil, "排班规则至少需要一天上班"
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].DayIndex < result[j].DayIndex })
|
||||
return result, ""
|
||||
}
|
||||
|
||||
// scPrimaryPatternShift 取规则里第一个上班日,用于方案列表展示班次名 / 颜色。
|
||||
func scPrimaryPatternShift(days []scPatternDay) (scPatternDay, bool) {
|
||||
for _, item := range days {
|
||||
if item.DayType == models.SchedTplDayWork {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return scPatternDay{}, false
|
||||
}
|
||||
|
||||
// scPlanPatternDays 取方案中保存的周期规则。
|
||||
func scPlanPatternDays(plan *models.OaSchedulePlan) []scPatternDay {
|
||||
return scPatternFromJSON(plan.Pattern)
|
||||
}
|
||||
|
||||
// scPlanWorkRules 计算方案在日期区间内「需要上班」的日期与对应的班次规则。
|
||||
//
|
||||
// fallback 是按星期模式(AlignMode=0)使用的固定班次;模板模式下可为 nil。
|
||||
// 返回的 map 以日期字符串为键,workdays 为升序的上班日期列表。
|
||||
func scPlanWorkRules(plan *models.OaSchedulePlan, fallback *models.OaShift, dates []string) (map[string]scPatternDay, []string, string) {
|
||||
pattern := scPlanPatternDays(plan)
|
||||
if len(pattern) == 0 && fallback == nil {
|
||||
return nil, nil, "排班规则为空,请重新选择模板或班次"
|
||||
}
|
||||
start, ok := scParseDate(normalizeOaDate(plan.StartDate))
|
||||
if !ok {
|
||||
return nil, nil, "开始日期格式错误"
|
||||
}
|
||||
|
||||
rules := make(map[int]scPatternDay, len(pattern))
|
||||
for _, item := range pattern {
|
||||
rules[item.DayIndex] = item
|
||||
}
|
||||
weekdays := map[int]bool{}
|
||||
if strings.TrimSpace(plan.Weekdays) != "" {
|
||||
list := []int{}
|
||||
if err := json.Unmarshal([]byte(plan.Weekdays), &list); err == nil {
|
||||
for _, day := range list {
|
||||
weekdays[day] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make(map[string]scPatternDay, len(dates))
|
||||
workdays := make([]string, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
day, valid := scParseDate(date)
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
rule, work := scRuleForDate(plan, rules, fallback, start, day, weekdays)
|
||||
if !work {
|
||||
continue
|
||||
}
|
||||
result[date] = rule
|
||||
workdays = append(workdays, date)
|
||||
}
|
||||
return result, workdays, ""
|
||||
}
|
||||
|
||||
// scRuleForDate 推算某一天命中的规则;第二个返回值表示当天是否上班。
|
||||
func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fallback *models.OaShift,
|
||||
start, day time.Time, weekdays map[int]bool) (scPatternDay, bool) {
|
||||
switch plan.AlignMode {
|
||||
case models.SchedTplAlignWeek:
|
||||
weeks := plan.CycleDays / 7
|
||||
if weeks < 1 {
|
||||
weeks = 1
|
||||
}
|
||||
startMonday := start.AddDate(0, 0, -scWeekdayOffset(start))
|
||||
curMonday := day.AddDate(0, 0, -scWeekdayOffset(day))
|
||||
diffWeeks := int(curMonday.Sub(startMonday).Hours()/24) / 7
|
||||
weekIndex := scMod(diffWeeks+int(plan.WeekOffset), weeks)
|
||||
return scRuleOfDay(rules, weekIndex*7+scWeekdayOffset(day))
|
||||
|
||||
case models.SchedTplAlignRoll:
|
||||
cycle := plan.CycleDays
|
||||
if cycle < 1 {
|
||||
cycle = 1
|
||||
}
|
||||
diffDays := int(day.Sub(start).Hours() / 24)
|
||||
return scRuleOfDay(rules, scMod(diffDays, cycle))
|
||||
|
||||
case models.SchedTplAlignMonth:
|
||||
if scMonthRestDay(plan, day) {
|
||||
return scPatternDay{}, false
|
||||
}
|
||||
index := 0
|
||||
if rule, ok := rules[0]; ok && rule.DayType != models.SchedTplDayWork {
|
||||
for _, item := range rules {
|
||||
if item.DayType == models.SchedTplDayWork {
|
||||
index = item.DayIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return scRuleOfDay(rules, index)
|
||||
}
|
||||
|
||||
// 按星期模式(AlignMode=0):weekdays 为空表示每天都上班
|
||||
weekday := int(day.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
if len(weekdays) > 0 && !weekdays[weekday] {
|
||||
return scPatternDay{}, false
|
||||
}
|
||||
if fallback == nil {
|
||||
return scPatternDay{}, false
|
||||
}
|
||||
return scPatternDay{
|
||||
DayType: models.SchedTplDayWork,
|
||||
ShiftID: fallback.ID,
|
||||
ShiftName: fallback.Name,
|
||||
ShortName: fallback.ShortName,
|
||||
Color: fallback.Color,
|
||||
WorkStart: fallback.WorkStart,
|
||||
WorkEnd: fallback.WorkEnd,
|
||||
RestStart: fallback.RestStart,
|
||||
RestEnd: fallback.RestEnd,
|
||||
WorkHours: fallback.WorkHours,
|
||||
}, true
|
||||
}
|
||||
|
||||
func scRuleOfDay(rules map[int]scPatternDay, index int) (scPatternDay, bool) {
|
||||
rule, ok := rules[index]
|
||||
if !ok || rule.DayType != models.SchedTplDayWork {
|
||||
return scPatternDay{}, false
|
||||
}
|
||||
return rule, true
|
||||
}
|
||||
|
||||
// scWeekdayOffset 返回相对本周周一的天数(周一=0 ... 周日=6)。
|
||||
func scWeekdayOffset(day time.Time) int {
|
||||
return (int(day.Weekday()) + 6) % 7
|
||||
}
|
||||
|
||||
func scMod(value, base int) int {
|
||||
if base <= 0 {
|
||||
return 0
|
||||
}
|
||||
result := value % base
|
||||
if result < 0 {
|
||||
result += base
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// scMonthRestDay 判断自然月排班中某天是否休息。
|
||||
//
|
||||
// 指定休息周几时,先取当月该周几的前 N 天;不足 N 天时从月末向前补齐。
|
||||
// 未指定周几(0)时直接取月末最后 N 天。
|
||||
func scMonthRestDay(plan *models.OaSchedulePlan, day time.Time) bool {
|
||||
restDays := int(plan.MonthRestDays)
|
||||
if restDays <= 0 {
|
||||
return false
|
||||
}
|
||||
year, month := day.Year(), day.Month()
|
||||
first := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
|
||||
daysInMonth := first.AddDate(0, 1, -1).Day()
|
||||
if restDays >= daysInMonth {
|
||||
return true
|
||||
}
|
||||
|
||||
restSet := map[int]bool{}
|
||||
weekday := int(plan.MonthRestWeekday)
|
||||
if weekday >= 1 && weekday <= 7 {
|
||||
for d := 1; d <= daysInMonth && len(restSet) < restDays; d++ {
|
||||
w := int(time.Date(year, month, d, 0, 0, 0, 0, time.Local).Weekday())
|
||||
if w == 0 {
|
||||
w = 7
|
||||
}
|
||||
if w == weekday {
|
||||
restSet[d] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for d := daysInMonth; d >= 1 && len(restSet) < restDays; d-- {
|
||||
restSet[d] = true
|
||||
}
|
||||
return restSet[day.Day()]
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// scPlanMaxEmployees / scPlanMaxEntries 限制单次方案规模,避免误选整个公司
|
||||
// 再叠加长周期导致一次写入数万条明细。
|
||||
const (
|
||||
scPlanMaxEmployees = 500
|
||||
scPlanMaxEntries = 10000
|
||||
)
|
||||
|
||||
// scPlanInput 排班方案的入参(新建 / 编辑 / 预览共用)。
|
||||
//
|
||||
// 两种排班方式:
|
||||
// - 按星期:Weekdays + ShiftID,每天固定一个班次;
|
||||
// - 按模板:Pattern + AlignMode,规则来自平台模板(可在租户端微调),
|
||||
// 此时 ShiftID 允许为 0。
|
||||
type scPlanInput struct {
|
||||
PlanName string
|
||||
PlanType int8
|
||||
ScopeType int8
|
||||
OrgID uint64
|
||||
IncludeChild bool
|
||||
EmployeeIDs []uint64
|
||||
StartDate string
|
||||
EndDate string
|
||||
Weekdays map[int]bool
|
||||
ShiftID uint64
|
||||
Note string
|
||||
TemplateID uint64
|
||||
TemplateCode string
|
||||
TemplateName string
|
||||
AlignMode int8
|
||||
CycleDays int
|
||||
WeekOffset int8
|
||||
MonthRestDays int8
|
||||
MonthRestWeekday int8
|
||||
Pattern []scPatternDay
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scPlanInputFromBody(body map[string]interface{}, requireName bool) (*scPlanInput, string) {
|
||||
input := &scPlanInput{
|
||||
PlanName: scStringValue(body, "plan_name"),
|
||||
ScopeType: models.OaScopeOrg,
|
||||
IncludeChild: true,
|
||||
StartDate: scStringValue(body, "start_date"),
|
||||
EndDate: scStringValue(body, "end_date"),
|
||||
Weekdays: scWeekdaySet(body),
|
||||
Note: scStringValue(body, "note"),
|
||||
ShiftID: scUintValue(body, "shift_id"),
|
||||
EmployeeIDs: scUintSlice(body, "employee_ids"),
|
||||
TemplateID: scUintValue(body, "template_id"),
|
||||
TemplateCode: scStringValue(body, "template_code"),
|
||||
TemplateName: scStringValue(body, "template_name"),
|
||||
Pattern: scPatternFromBody(body),
|
||||
}
|
||||
if requireName && input.PlanName == "" {
|
||||
return nil, "方案名称不能为空"
|
||||
}
|
||||
if len([]rune(input.PlanName)) > 100 {
|
||||
return nil, "方案名称不能超过 100 个字符"
|
||||
}
|
||||
|
||||
if value, ok := scIntValue(body, "plan_type"); ok {
|
||||
input.PlanType = int8(value)
|
||||
} else {
|
||||
input.PlanType = models.OaPlanTypeRegular
|
||||
}
|
||||
if input.PlanType != models.OaPlanTypeRegular && input.PlanType != models.OaPlanTypeTemp {
|
||||
return nil, "方案类型不合法"
|
||||
}
|
||||
|
||||
if value, ok := scIntValue(body, "scope_type"); ok {
|
||||
input.ScopeType = int8(value)
|
||||
}
|
||||
if input.ScopeType < models.OaScopeCompany || input.ScopeType > models.OaScopeEmployees {
|
||||
return nil, "排班范围不合法"
|
||||
}
|
||||
input.OrgID = scUintValue(body, "org_id")
|
||||
if input.ScopeType == models.OaScopeOrg && input.OrgID == 0 {
|
||||
return nil, "请选择要排班的部门或小组"
|
||||
}
|
||||
if input.ScopeType == models.OaScopeEmployees && len(input.EmployeeIDs) == 0 {
|
||||
return nil, "请选择需要排班的员工"
|
||||
}
|
||||
if _, has := body["include_child"]; has {
|
||||
input.IncludeChild = scBoolValue(body, "include_child")
|
||||
}
|
||||
|
||||
if _, msg := scDateSpan(input.StartDate, input.EndDate); msg != "" {
|
||||
return nil, msg
|
||||
}
|
||||
|
||||
if len(input.Pattern) > 0 {
|
||||
// 模板模式:校验周期规则,班次信息随规则一起保存
|
||||
if value, ok := scIntValue(body, "align_mode"); ok {
|
||||
input.AlignMode = int8(value)
|
||||
}
|
||||
if value, ok := scIntValue(body, "cycle_days"); ok {
|
||||
input.CycleDays = value
|
||||
}
|
||||
if value, ok := scIntValue(body, "week_offset"); ok {
|
||||
input.WeekOffset = int8(value)
|
||||
}
|
||||
if value, ok := scIntValue(body, "month_rest_days"); ok {
|
||||
input.MonthRestDays = int8(value)
|
||||
}
|
||||
if value, ok := scIntValue(body, "month_rest_weekday"); ok {
|
||||
input.MonthRestWeekday = int8(value)
|
||||
}
|
||||
days, msg := scValidatePattern(input.Pattern, input.AlignMode, input.CycleDays)
|
||||
if msg != "" {
|
||||
return nil, msg
|
||||
}
|
||||
input.Pattern = days
|
||||
if input.TemplateName == "" {
|
||||
input.TemplateName = "自定义规则"
|
||||
}
|
||||
} else {
|
||||
if input.ShiftID == 0 {
|
||||
return nil, "请选择班次"
|
||||
}
|
||||
input.AlignMode = 0
|
||||
}
|
||||
|
||||
if len(input.EmployeeIDs) > scPlanMaxEmployees {
|
||||
return nil, fmt.Sprintf("单次最多为 %d 名员工排班,请缩小范围", scPlanMaxEmployees)
|
||||
}
|
||||
return input, ""
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scLoadShift(tid, id uint64) (*models.OaShift, bool) {
|
||||
shift, ok := c.shiftByID(tid, id)
|
||||
if !ok || shift.Status != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return shift, true
|
||||
}
|
||||
|
||||
func scWeekdayList(set map[int]bool) []int {
|
||||
list := make([]int, 0, 7)
|
||||
for day := 1; day <= 7; day++ {
|
||||
if set[day] {
|
||||
list = append(list, day)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// scPlanView 把方案转成前端友好的结构:employee_ids / weekdays 直接给数组,
|
||||
// 避免前端再解析 JSON 字符串。
|
||||
func scPlanView(plan *models.OaSchedulePlan) map[string]interface{} {
|
||||
employeeIDs := []uint64{}
|
||||
if strings.TrimSpace(plan.EmployeeIDs) != "" {
|
||||
_ = json.Unmarshal([]byte(plan.EmployeeIDs), &employeeIDs)
|
||||
}
|
||||
weekdays := []int{}
|
||||
if strings.TrimSpace(plan.Weekdays) != "" {
|
||||
_ = json.Unmarshal([]byte(plan.Weekdays), &weekdays)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": plan.ID,
|
||||
"plan_name": plan.PlanName,
|
||||
"plan_type": plan.PlanType,
|
||||
"scope_type": plan.ScopeType,
|
||||
"org_id": plan.OrgID,
|
||||
"org_name": plan.OrgName,
|
||||
"include_child": plan.IncludeChild,
|
||||
"employee_ids": employeeIDs,
|
||||
"employee_names": plan.EmployeeNames,
|
||||
"start_date": normalizeOaDate(plan.StartDate),
|
||||
"end_date": normalizeOaDate(plan.EndDate),
|
||||
"weekdays": weekdays,
|
||||
"shift_id": plan.ShiftID,
|
||||
"shift_name": plan.ShiftName,
|
||||
"color": plan.Color,
|
||||
"template_id": plan.TemplateID,
|
||||
"template_code": plan.TemplateCode,
|
||||
"template_name": plan.TemplateName,
|
||||
"align_mode": plan.AlignMode,
|
||||
"cycle_days": plan.CycleDays,
|
||||
"week_offset": plan.WeekOffset,
|
||||
"month_rest_days": plan.MonthRestDays,
|
||||
"month_rest_weekday": plan.MonthRestWeekday,
|
||||
"pattern": scPlanPatternDays(plan),
|
||||
"note": plan.Note,
|
||||
"status": plan.Status,
|
||||
"publish_status": plan.PublishStatus,
|
||||
"entry_count": plan.EntryCount,
|
||||
"created_by": plan.CreatedBy,
|
||||
"created_by_name": plan.CreatedByName,
|
||||
"create_time": plan.CreateTime.Format("2006-01-02 15:04:05"),
|
||||
"update_time": plan.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scPlanQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.OaSchedulePlan)).
|
||||
Filter("tid", tid).
|
||||
Filter("is_deleted", 0)
|
||||
}
|
||||
|
||||
// PlanList 排班方案列表。
|
||||
// GET /backend/oa/scheduling/plans
|
||||
func (c *BackendOaSchedulingController) PlanList() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("page_size", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
qs := c.scPlanQuery(tid)
|
||||
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
|
||||
qs = qs.Filter("plan_name__icontains", keyword)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.GetString("status")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
qs = qs.Filter("status", value)
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(c.GetString("plan_type")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
qs = qs.Filter("plan_type", value)
|
||||
}
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var rows []models.OaSchedulePlan
|
||||
_, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班方案失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for i := range rows {
|
||||
list = append(list, scPlanView(&rows[i]))
|
||||
}
|
||||
c.scOK(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
// PlanDetail 排班方案详情。
|
||||
// GET /backend/oa/scheduling/plans/detail/:id
|
||||
func (c *BackendOaSchedulingController) PlanDetail() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的方案ID")
|
||||
return
|
||||
}
|
||||
var plan models.OaSchedulePlan
|
||||
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
||||
c.scFail(404, 404, "排班方案不存在")
|
||||
return
|
||||
}
|
||||
c.scOK(scPlanView(&plan))
|
||||
}
|
||||
|
||||
// PlanPreview 预览方案覆盖的员工与班次数,不写入任何数据。
|
||||
// POST /backend/oa/scheduling/plans/preview
|
||||
func (c *BackendOaSchedulingController) PlanPreview() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
input, msg := c.scPlanInputFromBody(body, false)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
|
||||
var shift *models.OaShift
|
||||
if len(input.Pattern) == 0 {
|
||||
loaded, found := c.scLoadShift(tid, input.ShiftID)
|
||||
if !found {
|
||||
c.scFail(400, 400, "班次不存在或已停用")
|
||||
return
|
||||
}
|
||||
shift = loaded
|
||||
}
|
||||
|
||||
employees, err := c.scResolveEmployees(tid, input.ScopeType, input.OrgID, input.IncludeChild, input.EmployeeIDs)
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "展开排班范围失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(employees) == 0 {
|
||||
c.scOK(map[string]interface{}{
|
||||
"employee_count": 0,
|
||||
"employee_list": []schedulingEmployee{},
|
||||
"dates": []string{},
|
||||
"entry_count": 0,
|
||||
"rest_count": 0,
|
||||
"shift": shift,
|
||||
"scope_name": c.scScopeName(tid, input),
|
||||
"template_name": input.TemplateName,
|
||||
"pattern": input.Pattern,
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(employees) > scPlanMaxEmployees {
|
||||
c.scFail(400, 400, fmt.Sprintf("范围覆盖 %d 名员工,超过单次 %d 人上限,请缩小范围", len(employees), scPlanMaxEmployees))
|
||||
return
|
||||
}
|
||||
|
||||
dates, msg := scDateSpan(input.StartDate, input.EndDate)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
previewPlan := &models.OaSchedulePlan{
|
||||
StartDate: input.StartDate,
|
||||
EndDate: input.EndDate,
|
||||
Weekdays: scWeekdaysJSON(input.Weekdays),
|
||||
AlignMode: input.AlignMode,
|
||||
CycleDays: input.CycleDays,
|
||||
WeekOffset: input.WeekOffset,
|
||||
MonthRestDays: input.MonthRestDays,
|
||||
MonthRestWeekday: input.MonthRestWeekday,
|
||||
Pattern: scPatternToJSON(input.Pattern),
|
||||
}
|
||||
_, workdays, msg := scPlanWorkRules(previewPlan, shift, dates)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
if len(workdays) == 0 {
|
||||
c.scFail(400, 400, "所选日期区间内没有需要上班的日期")
|
||||
return
|
||||
}
|
||||
entryCount := len(employees) * len(workdays)
|
||||
if entryCount > scPlanMaxEntries {
|
||||
c.scFail(400, 400, fmt.Sprintf("本次将生成 %d 条排班,超过单次 %d 条上限,请缩短日期或缩小范围", entryCount, scPlanMaxEntries))
|
||||
return
|
||||
}
|
||||
|
||||
list := c.scEmployeeDTOs(tid, employees)
|
||||
c.scOK(map[string]interface{}{
|
||||
"employee_count": len(employees),
|
||||
"employee_list": list,
|
||||
"dates": workdays,
|
||||
"workday_count": len(workdays),
|
||||
"rest_count": len(dates) - len(workdays),
|
||||
"entry_count": entryCount,
|
||||
"shift": shift,
|
||||
"scope_name": c.scScopeName(tid, input),
|
||||
"template_name": input.TemplateName,
|
||||
"pattern": input.Pattern,
|
||||
})
|
||||
}
|
||||
|
||||
// scWeekdaysJSON 把星期集合转成与方案存储一致的 JSON 数组。
|
||||
func scWeekdaysJSON(set map[int]bool) string {
|
||||
list := scWeekdayList(set)
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
buf, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// scScopeName 给方案生成可读的范围描述。
|
||||
func (c *BackendOaSchedulingController) scScopeName(tid uint64, input *scPlanInput) string {
|
||||
switch input.ScopeType {
|
||||
case models.OaScopeCompany:
|
||||
return "全公司"
|
||||
case models.OaScopeEmployees:
|
||||
employees, err := c.scEmployeesByIDs(tid, input.EmployeeIDs)
|
||||
if err != nil || len(employees) == 0 {
|
||||
return fmt.Sprintf("指定员工(%d 人)", len(input.EmployeeIDs))
|
||||
}
|
||||
names := make([]string, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
names = append(names, employee.Name)
|
||||
}
|
||||
if len(names) > 3 {
|
||||
return fmt.Sprintf("%s 等 %d 人", strings.Join(names[:3], "、"), len(names))
|
||||
}
|
||||
return strings.Join(names, "、")
|
||||
default:
|
||||
name := c.scLoadOrgIndex(tid).name(input.OrgID)
|
||||
if name == "" {
|
||||
name = "部门"
|
||||
}
|
||||
if input.IncludeChild {
|
||||
return name + "(含下级)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// PlanCreate 新建排班方案。
|
||||
// POST /backend/oa/scheduling/plans/create
|
||||
func (c *BackendOaSchedulingController) PlanCreate() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
input, msg := c.scPlanInputFromBody(body, true)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
|
||||
var shift *models.OaShift
|
||||
if len(input.Pattern) == 0 {
|
||||
loaded, found := c.scLoadShift(tid, input.ShiftID)
|
||||
if !found {
|
||||
c.scFail(400, 400, "班次不存在或已停用")
|
||||
return
|
||||
}
|
||||
shift = loaded
|
||||
}
|
||||
|
||||
weekdays := scWeekdayList(input.Weekdays)
|
||||
weekdayJSON, _ := json.Marshal(weekdays)
|
||||
employeeJSON, _ := json.Marshal(input.EmployeeIDs)
|
||||
|
||||
plan := &models.OaSchedulePlan{
|
||||
Tid: int(tid),
|
||||
PlanName: input.PlanName,
|
||||
PlanType: input.PlanType,
|
||||
ScopeType: input.ScopeType,
|
||||
OrgID: input.OrgID,
|
||||
OrgName: c.scScopeName(tid, input),
|
||||
IncludeChild: scBoolToInt8(input.IncludeChild),
|
||||
EmployeeIDs: string(employeeJSON),
|
||||
EmployeeNames: c.scEmployeeNames(tid, input.EmployeeIDs),
|
||||
StartDate: input.StartDate,
|
||||
EndDate: input.EndDate,
|
||||
Weekdays: string(weekdayJSON),
|
||||
Note: input.Note,
|
||||
Status: 1,
|
||||
PublishStatus: 0,
|
||||
CreatedBy: uid,
|
||||
CreatedByName: username,
|
||||
}
|
||||
scApplyPlanRules(plan, input, shift)
|
||||
if _, err := models.Orm.Insert(plan); err != nil {
|
||||
c.scFail(500, 500, "保存排班方案失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// publish_now=true 时创建后立即下发,减少“建完再点发布”的一步操作。
|
||||
if scBoolValue(body, "publish_now") {
|
||||
stats, publishErr := c.scPublishPlan(tid, uid, username, plan, true)
|
||||
if publishErr != nil {
|
||||
c.scFail(500, 500, "方案已保存,但发布失败: "+publishErr.Error())
|
||||
return
|
||||
}
|
||||
c.scOK(map[string]interface{}{"plan": scPlanView(plan), "publish": stats})
|
||||
return
|
||||
}
|
||||
c.scOK(map[string]interface{}{"plan": scPlanView(plan)})
|
||||
}
|
||||
|
||||
func scBoolToInt8(value bool) int8 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// scApplyPlanRules 把入参的排班规则写入方案:
|
||||
// - 带 pattern:保存模板快照与周期参数,方案的展示班次取第一个上班日;
|
||||
// - 不带 pattern:回到按星期模式,清空模板字段。
|
||||
func scApplyPlanRules(plan *models.OaSchedulePlan, input *scPlanInput, shift *models.OaShift) {
|
||||
if len(input.Pattern) > 0 {
|
||||
plan.TemplateID = input.TemplateID
|
||||
plan.TemplateCode = input.TemplateCode
|
||||
plan.TemplateName = input.TemplateName
|
||||
plan.AlignMode = input.AlignMode
|
||||
plan.CycleDays = input.CycleDays
|
||||
if plan.AlignMode == models.SchedTplAlignMonth {
|
||||
plan.CycleDays = 0
|
||||
}
|
||||
plan.WeekOffset = input.WeekOffset
|
||||
plan.MonthRestDays = input.MonthRestDays
|
||||
plan.MonthRestWeekday = input.MonthRestWeekday
|
||||
plan.Pattern = scPatternToJSON(input.Pattern)
|
||||
if primary, ok := scPrimaryPatternShift(input.Pattern); ok {
|
||||
plan.ShiftID = primary.ShiftID
|
||||
plan.ShiftName = primary.ShiftName
|
||||
plan.Color = primary.Color
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
plan.TemplateID = 0
|
||||
plan.TemplateCode = ""
|
||||
plan.TemplateName = ""
|
||||
plan.AlignMode = 0
|
||||
plan.CycleDays = 0
|
||||
plan.WeekOffset = 0
|
||||
plan.MonthRestDays = 0
|
||||
plan.MonthRestWeekday = 0
|
||||
plan.Pattern = ""
|
||||
if shift != nil {
|
||||
plan.ShiftID = shift.ID
|
||||
plan.ShiftName = shift.Name
|
||||
plan.Color = shift.Color
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scEmployeeNames(tid uint64, ids []uint64) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
employees, err := c.scEmployeesByIDs(tid, ids)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
names = append(names, employee.Name)
|
||||
}
|
||||
return strings.Join(names, "、")
|
||||
}
|
||||
|
||||
// PlanUpdate 更新排班方案。已发布的方案会回到“未发布”状态,
|
||||
// 需要重新发布后新规则才会写入明细(已有明细不会被自动清除)。
|
||||
// POST /backend/oa/scheduling/plans/update/:id
|
||||
func (c *BackendOaSchedulingController) PlanUpdate() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的方案ID")
|
||||
return
|
||||
}
|
||||
var plan models.OaSchedulePlan
|
||||
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
||||
c.scFail(404, 404, "排班方案不存在")
|
||||
return
|
||||
}
|
||||
|
||||
body := c.scBody()
|
||||
input, msg := c.scPlanInputFromBody(body, true)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
|
||||
var shift *models.OaShift
|
||||
if len(input.Pattern) == 0 {
|
||||
loaded, found := c.scLoadShift(tid, input.ShiftID)
|
||||
if !found {
|
||||
c.scFail(400, 400, "班次不存在或已停用")
|
||||
return
|
||||
}
|
||||
shift = loaded
|
||||
}
|
||||
|
||||
weekdays := scWeekdayList(input.Weekdays)
|
||||
weekdayJSON, _ := json.Marshal(weekdays)
|
||||
employeeJSON, _ := json.Marshal(input.EmployeeIDs)
|
||||
|
||||
plan.PlanName = input.PlanName
|
||||
plan.PlanType = input.PlanType
|
||||
plan.ScopeType = input.ScopeType
|
||||
plan.OrgID = input.OrgID
|
||||
plan.OrgName = c.scScopeName(tid, input)
|
||||
plan.IncludeChild = scBoolToInt8(input.IncludeChild)
|
||||
plan.EmployeeIDs = string(employeeJSON)
|
||||
plan.EmployeeNames = c.scEmployeeNames(tid, input.EmployeeIDs)
|
||||
plan.StartDate = input.StartDate
|
||||
plan.EndDate = input.EndDate
|
||||
plan.Weekdays = string(weekdayJSON)
|
||||
plan.Note = input.Note
|
||||
plan.Status = 1
|
||||
plan.PublishStatus = 0
|
||||
scApplyPlanRules(&plan, input, shift)
|
||||
|
||||
if _, err := models.Orm.Update(&plan); err != nil {
|
||||
c.scFail(500, 500, "更新排班方案失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scOK(map[string]interface{}{"plan": scPlanView(&plan)})
|
||||
}
|
||||
|
||||
// PlanPublish 发布方案:把规则展开成每人每天的排班明细。
|
||||
// POST /backend/oa/scheduling/plans/publish/:id
|
||||
// body: { overwrite: true } 覆盖同一员工同一天已有排班,默认保留原排班。
|
||||
func (c *BackendOaSchedulingController) PlanPublish() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的方案ID")
|
||||
return
|
||||
}
|
||||
var plan models.OaSchedulePlan
|
||||
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
||||
c.scFail(404, 404, "排班方案不存在")
|
||||
return
|
||||
}
|
||||
if plan.Status != 1 {
|
||||
c.scFail(400, 400, "方案已停用,请先启用")
|
||||
return
|
||||
}
|
||||
|
||||
body := c.scBody()
|
||||
stats, err := c.scPublishPlan(tid, uid, username, &plan, scBoolValue(body, "overwrite"))
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "发布排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scOK(map[string]interface{}{"plan": scPlanView(&plan), "publish": stats})
|
||||
}
|
||||
|
||||
// scPublishPlan 方案展开与写入。同一员工同一天只保留一条有效明细:
|
||||
// - 不存在 -> 新增
|
||||
// - 已存在且 overwrite=false -> 跳过(保留手工调整结果)
|
||||
// - 已存在且 overwrite=true -> 覆盖为新班次并记录变更
|
||||
func (c *BackendOaSchedulingController) scPublishPlan(tid, uid uint64, username string, plan *models.OaSchedulePlan, overwrite bool) (map[string]int, error) {
|
||||
// 模板模式(AlignMode=1/2/3)由 pattern 携带每天班次;按星期模式回退到方案绑定的班次
|
||||
var fallback *models.OaShift
|
||||
if plan.AlignMode == 0 {
|
||||
loaded, found := c.scLoadShift(tid, plan.ShiftID)
|
||||
if !found {
|
||||
return nil, errors.New("方案绑定的班次不存在或已停用")
|
||||
}
|
||||
fallback = loaded
|
||||
}
|
||||
|
||||
employeeIDs := []uint64{}
|
||||
if strings.TrimSpace(plan.EmployeeIDs) != "" {
|
||||
_ = json.Unmarshal([]byte(plan.EmployeeIDs), &employeeIDs)
|
||||
}
|
||||
employees, err := c.scResolveEmployees(tid, plan.ScopeType, plan.OrgID, plan.IncludeChild == 1, employeeIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(employees) == 0 {
|
||||
return nil, errors.New("方案范围内没有可排班的员工")
|
||||
}
|
||||
if len(employees) > scPlanMaxEmployees {
|
||||
return nil, fmt.Errorf("范围覆盖 %d 名员工,超过单次 %d 人上限", len(employees), scPlanMaxEmployees)
|
||||
}
|
||||
|
||||
dates, msg := scDateSpan(normalizeOaDate(plan.StartDate), normalizeOaDate(plan.EndDate))
|
||||
if msg != "" {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
rules, workdays, msg := scPlanWorkRules(plan, fallback, dates)
|
||||
if msg != "" {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
if len(workdays) == 0 {
|
||||
return nil, errors.New("方案日期区间内没有需要上班的日期")
|
||||
}
|
||||
if len(employees)*len(workdays) > scPlanMaxEntries {
|
||||
return nil, fmt.Errorf("本次将生成 %d 条排班,超过单次 %d 条上限", len(employees)*len(workdays), scPlanMaxEntries)
|
||||
}
|
||||
|
||||
index := c.scLoadOrgIndex(tid)
|
||||
empIDs := make([]uint64, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
empIDs = append(empIDs, uint64(employee.ID))
|
||||
}
|
||||
|
||||
var existing []models.OaScheduleEntry
|
||||
_, err = models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||
Filter("tid", tid).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("employee_id__in", empIDs).
|
||||
Filter("schedule_date__gte", workdays[0]).
|
||||
Filter("schedule_date__lte", workdays[len(workdays)-1]).
|
||||
All(&existing)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
existingMap := map[string]*models.OaScheduleEntry{}
|
||||
for i := range existing {
|
||||
key := fmt.Sprintf("%d|%s", existing[i].EmployeeID, normalizeOaDate(existing[i].ScheduleDate))
|
||||
existingMap[key] = &existing[i]
|
||||
}
|
||||
|
||||
stats := map[string]int{"created": 0, "updated": 0, "skipped": 0}
|
||||
tx, err := orm.NewOrm().Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, employee := range employees {
|
||||
orgID := scEmployeeDepartmentID(employee)
|
||||
position := ""
|
||||
if employee.Position != nil {
|
||||
position = *employee.Position
|
||||
}
|
||||
for _, date := range workdays {
|
||||
rule := rules[date]
|
||||
key := fmt.Sprintf("%d|%s", employee.ID, date)
|
||||
if row, hit := existingMap[key]; hit {
|
||||
if !overwrite {
|
||||
stats["skipped"]++
|
||||
continue
|
||||
}
|
||||
if row.ShiftID == rule.ShiftID && row.ShiftName == rule.ShiftName && row.Source == models.OaEntrySourcePlan && row.PlanID == plan.ID {
|
||||
stats["skipped"]++
|
||||
continue
|
||||
}
|
||||
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
||||
row.PlanID = plan.ID
|
||||
row.ShiftID = rule.ShiftID
|
||||
row.ShiftName = rule.ShiftName
|
||||
row.ShortName = rule.ShortName
|
||||
row.Color = rule.Color
|
||||
row.WorkStart = rule.WorkStart
|
||||
row.WorkEnd = rule.WorkEnd
|
||||
row.Source = models.OaEntrySourcePlan
|
||||
if _, updateErr := tx.Update(row); updateErr != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, updateErr
|
||||
}
|
||||
c.scWriteChange(tx, tid, row.ID, row.EmployeeID, row.EmployeeName, date,
|
||||
models.OaChangePublish, oldShiftID, oldShiftName, rule.ShiftID, rule.ShiftName, "方案重新发布覆盖", uid, username)
|
||||
stats["updated"]++
|
||||
continue
|
||||
}
|
||||
|
||||
entry := &models.OaScheduleEntry{
|
||||
Tid: int(tid),
|
||||
PlanID: plan.ID,
|
||||
EmployeeID: employee.ID,
|
||||
EmployeeName: employee.Name,
|
||||
EmployeeAccount: employee.Account,
|
||||
OrgID: orgID,
|
||||
OrgName: index.name(orgID),
|
||||
Position: position,
|
||||
ScheduleDate: date,
|
||||
ShiftID: rule.ShiftID,
|
||||
ShiftName: rule.ShiftName,
|
||||
ShortName: rule.ShortName,
|
||||
Color: rule.Color,
|
||||
WorkStart: rule.WorkStart,
|
||||
WorkEnd: rule.WorkEnd,
|
||||
Source: models.OaEntrySourcePlan,
|
||||
Note: plan.Note,
|
||||
CreatedBy: uid,
|
||||
CreatedByName: username,
|
||||
}
|
||||
if _, insertErr := tx.Insert(entry); insertErr != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, insertErr
|
||||
}
|
||||
existingMap[key] = entry
|
||||
stats["created"]++
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
plan.PublishStatus = 1
|
||||
plan.EntryCount = stats["created"] + stats["updated"]
|
||||
plan.UpdateTime = &now
|
||||
if _, err := tx.Update(plan, "publish_status", "entry_count", "update_time"); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats["employee_count"] = len(employees)
|
||||
stats["workday_count"] = len(workdays)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// scChangeInserter 抽取 Insert,使普通 Ormer 与事务 TxOrmer 都能写变更记录。
|
||||
type scChangeInserter interface {
|
||||
Insert(interface{}) (int64, error)
|
||||
}
|
||||
|
||||
// scWriteChange 写一条排班变更记录;失败不阻断主流程。
|
||||
func (c *BackendOaSchedulingController) scWriteChange(tx scChangeInserter, tid uint64, entryID uint64, employeeID uint, employeeName, date string,
|
||||
changeType int8, oldShiftID uint64, oldShiftName string, newShiftID uint64, newShiftName, reason string, operatorID uint64, operatorName string) {
|
||||
if tx == nil || (oldShiftID == newShiftID && oldShiftName == newShiftName) {
|
||||
return
|
||||
}
|
||||
_, _ = tx.Insert(&models.OaScheduleChange{
|
||||
Tid: int(tid),
|
||||
EntryID: entryID,
|
||||
EmployeeID: employeeID,
|
||||
EmployeeName: employeeName,
|
||||
ScheduleDate: date,
|
||||
ChangeType: changeType,
|
||||
OldShiftID: oldShiftID,
|
||||
OldShiftName: oldShiftName,
|
||||
NewShiftID: newShiftID,
|
||||
NewShiftName: newShiftName,
|
||||
Reason: reason,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: operatorName,
|
||||
})
|
||||
}
|
||||
|
||||
// PlanDelete 删除排班方案;默认同时清除该方案生成且仍未被调整的排班明细
|
||||
// (source=1)。被临时调配过的明细 source 已变为 2,会保留下来,
|
||||
// 避免把人工调整过的排班一起删掉。
|
||||
// DELETE /backend/oa/scheduling/plans/delete/:id?delete_entries=1
|
||||
func (c *BackendOaSchedulingController) PlanDelete() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的方案ID")
|
||||
return
|
||||
}
|
||||
var plan models.OaSchedulePlan
|
||||
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
||||
c.scFail(404, 404, "排班方案不存在")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
plan.IsDeleted = 1
|
||||
plan.DeleteTime = &now
|
||||
if _, err := models.Orm.Update(&plan, "is_deleted", "delete_time"); err != nil {
|
||||
c.scFail(500, 500, "删除方案失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
removed := int64(0)
|
||||
deleteEntries := c.GetString("delete_entries") != "0"
|
||||
if deleteEntries {
|
||||
result, err := models.Orm.Raw(
|
||||
"UPDATE yz_backend_oa_schedule_entry SET is_deleted=id, delete_time=? "+
|
||||
"WHERE tid=? AND plan_id=? AND source=1 AND is_deleted=0", now, tid, plan.ID,
|
||||
).Exec()
|
||||
if err == nil {
|
||||
removed, _ = result.RowsAffected()
|
||||
}
|
||||
}
|
||||
c.scOK(map[string]interface{}{"id": plan.ID, "deleted_entries": removed})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// schedulingEmployee 排班看板/方案预览里的员工视图。
|
||||
// Department 保留组织 ID 字符串,DepartmentName 供前端直接展示。
|
||||
type schedulingEmployee struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Account string `json:"account"`
|
||||
Department string `json:"department"`
|
||||
DepartmentName string `json:"department_name"`
|
||||
Position string `json:"position"`
|
||||
Phone string `json:"phone"`
|
||||
AccountStatus int8 `json:"account_status"`
|
||||
}
|
||||
|
||||
// scOrgIndex 租户组织架构的内存索引,用于把“部门/小组”展开成部门子树,
|
||||
// 以及给员工补齐部门名称。一次请求只加载一次,避免 N+1 查询。
|
||||
type scOrgIndex struct {
|
||||
rows map[uint64]models.BackendOrganization
|
||||
children map[uint64][]uint64
|
||||
names map[uint64]string
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scLoadOrgIndex(tid uint64) *scOrgIndex {
|
||||
index := &scOrgIndex{
|
||||
rows: map[uint64]models.BackendOrganization{},
|
||||
children: map[uint64][]uint64{},
|
||||
names: map[uint64]string{},
|
||||
}
|
||||
var rows []models.BackendOrganization
|
||||
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
return index
|
||||
}
|
||||
for _, row := range rows {
|
||||
index.rows[row.ID] = row
|
||||
index.names[row.ID] = row.OrgName
|
||||
index.children[row.ParentID] = append(index.children[row.ParentID], row.ID)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// subtreeIDs 返回节点自身及其全部下级组织 ID。
|
||||
func (index *scOrgIndex) subtreeIDs(root uint64) map[uint64]bool {
|
||||
result := map[uint64]bool{}
|
||||
if root == 0 {
|
||||
return result
|
||||
}
|
||||
queue := []uint64{root}
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
if result[current] {
|
||||
continue
|
||||
}
|
||||
result[current] = true
|
||||
queue = append(queue, index.children[current]...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (index *scOrgIndex) name(id uint64) string {
|
||||
if name, ok := index.names[id]; ok {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scBaseEmployeeQuery 排班可选员工:未删除、非离职。
|
||||
func (c *BackendOaSchedulingController) scBaseEmployeeQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.BackendEmployee)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
Exclude("account_status", 2)
|
||||
}
|
||||
|
||||
func scEmployeeDepartmentID(row models.BackendEmployee) uint64 {
|
||||
if row.Department == nil {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(*row.Department), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scEmployeeDTOs(tid uint64, rows []models.BackendEmployee) []schedulingEmployee {
|
||||
index := c.scLoadOrgIndex(tid)
|
||||
result := make([]schedulingEmployee, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
orgID := scEmployeeDepartmentID(row)
|
||||
position := ""
|
||||
if row.Position != nil {
|
||||
position = *row.Position
|
||||
}
|
||||
phone := ""
|
||||
if row.Phone != nil {
|
||||
phone = *row.Phone
|
||||
}
|
||||
result = append(result, schedulingEmployee{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Account: row.Account,
|
||||
Department: strconv.FormatUint(orgID, 10),
|
||||
DepartmentName: index.name(orgID),
|
||||
Position: position,
|
||||
Phone: phone,
|
||||
AccountStatus: row.AccountStatus,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// scEmployeesByOrg 按组织节点取员工;includeChild=true 时包含全部下级组织,
|
||||
// 这样选中“公司/部门/小组”任意层级都能覆盖到该范围内的所有人。
|
||||
func (c *BackendOaSchedulingController) scEmployeesByOrg(tid, orgID uint64, includeChild bool) ([]models.BackendEmployee, error) {
|
||||
qs := c.scBaseEmployeeQuery(tid)
|
||||
if orgID > 0 {
|
||||
allowed := map[uint64]bool{orgID: true}
|
||||
if includeChild {
|
||||
allowed = c.scLoadOrgIndex(tid).subtreeIDs(orgID)
|
||||
}
|
||||
values := make([]string, 0, len(allowed))
|
||||
for id := range allowed {
|
||||
values = append(values, strconv.FormatUint(id, 10))
|
||||
}
|
||||
qs = qs.Filter("department__in", values)
|
||||
}
|
||||
var rows []models.BackendEmployee
|
||||
_, err := qs.OrderBy("department", "id").All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// scEmployeesByIDs 按员工 ID 取员工,保持传入顺序由前端自行处理。
|
||||
func (c *BackendOaSchedulingController) scEmployeesByIDs(tid uint64, ids []uint64) ([]models.BackendEmployee, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []models.BackendEmployee
|
||||
_, err := c.scBaseEmployeeQuery(tid).Filter("id__in", ids).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// scResolveEmployees 把方案范围(公司 / 部门小组 / 指定员工)展开成员工列表。
|
||||
func (c *BackendOaSchedulingController) scResolveEmployees(tid uint64, scopeType int8, orgID uint64, includeChild bool, employeeIDs []uint64) ([]models.BackendEmployee, error) {
|
||||
switch scopeType {
|
||||
case models.OaScopeEmployees:
|
||||
return c.scEmployeesByIDs(tid, employeeIDs)
|
||||
case models.OaScopeCompany:
|
||||
return c.scEmployeesByOrg(tid, 0, false)
|
||||
default:
|
||||
return c.scEmployeesByOrg(tid, orgID, includeChild)
|
||||
}
|
||||
}
|
||||
|
||||
// scWorkdays 从日期区间中筛出命中的星期(1=周一 ... 7=周日)。
|
||||
// 未选择任何星期表示每天。
|
||||
func scWorkdays(dates []string, weekdays map[int]bool) []string {
|
||||
if len(weekdays) == 0 {
|
||||
return dates
|
||||
}
|
||||
result := make([]string, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
parsed, ok := scParseDate(date)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
weekday := int(parsed.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
if weekdays[weekday] {
|
||||
result = append(result, date)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// 租户端排班模板:只读调用,不允许修改平台模板。
|
||||
//
|
||||
// 调用模板时会把模板的每日规则转成方案周期的 pattern 快照,并把模板里的
|
||||
// 班次按「名称 + 上下班时间」匹配到租户自己的班次;匹配不到则自动创建,
|
||||
// 这样租户拿到方案后就能像普通方案一样自由修改。
|
||||
|
||||
func schedTplTypeLabel(value int8) string {
|
||||
return schedTplTypeText(value)
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) schedTplQuery() orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.PlatformSchedTpl)).
|
||||
Filter("delete_time__isnull", true)
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) schedTplDays(templateID uint64) []models.PlatformSchedTplDay {
|
||||
rows := []models.PlatformSchedTplDay{}
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformSchedTplDay)).
|
||||
Filter("template_id", templateID).
|
||||
OrderBy("day_index").All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return []models.PlatformSchedTplDay{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) schedTplByID(id uint64) (*models.PlatformSchedTpl, bool) {
|
||||
var row models.PlatformSchedTpl
|
||||
if err := c.schedTplQuery().Filter("id", id).One(&row); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &row, true
|
||||
}
|
||||
|
||||
func schedTplView(row *models.PlatformSchedTpl, days []models.PlatformSchedTplDay) map[string]interface{} {
|
||||
view := map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"code": row.Code,
|
||||
"name": row.Name,
|
||||
"template_type": row.TemplateType,
|
||||
"template_type_text": schedTplTypeLabel(row.TemplateType),
|
||||
"align_mode": row.AlignMode,
|
||||
"cycle_days": row.CycleDays,
|
||||
"week_offset": row.WeekOffset,
|
||||
"month_rest_days": row.MonthRestDays,
|
||||
"month_rest_weekday": row.MonthRestWeekday,
|
||||
"summary": row.Summary,
|
||||
}
|
||||
if days != nil {
|
||||
view["days"] = days
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// TemplateList 租户端可调用的排班模板(只返回启用的模板)。
|
||||
// GET /backend/oa/scheduling/templates?keyword=
|
||||
func (c *BackendOaSchedulingController) TemplateList() {
|
||||
if _, _, _, ok := c.scOperator(); !ok {
|
||||
return
|
||||
}
|
||||
qs := c.schedTplQuery().Filter("status", 1)
|
||||
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
|
||||
qs = qs.Filter("name__icontains", keyword)
|
||||
}
|
||||
|
||||
var rows []models.PlatformSchedTpl
|
||||
if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班模板失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for i := range rows {
|
||||
list = append(list, schedTplView(&rows[i], c.schedTplDays(rows[i].ID)))
|
||||
}
|
||||
c.scOK(map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
// TemplateApply 调用模板:返回可直接用于方案的周期规则,并把模板班次
|
||||
// 匹配到租户班次(不存在时自动创建)。
|
||||
// POST /backend/oa/scheduling/templates/apply body: {"template_id": 1}
|
||||
func (c *BackendOaSchedulingController) TemplateApply() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id := scUintValue(c.scBody(), "template_id")
|
||||
if id == 0 {
|
||||
if raw := strings.TrimSpace(c.GetString("template_id")); raw != "" {
|
||||
id, _ = strconv.ParseUint(raw, 10, 64)
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
c.scFail(400, 400, "请选择排班模板")
|
||||
return
|
||||
}
|
||||
tpl, found := c.schedTplByID(id)
|
||||
if !found {
|
||||
c.scFail(404, 404, "排班模板不存在")
|
||||
return
|
||||
}
|
||||
if tpl.Status != 1 {
|
||||
c.scFail(400, 400, "该模板已停用")
|
||||
return
|
||||
}
|
||||
|
||||
var shifts []models.OaShift
|
||||
if _, err := c.shiftQuery(tid).OrderBy("sort", "id").All(&shifts); err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
shiftMap := make(map[string]*models.OaShift, len(shifts))
|
||||
maxSort := 0
|
||||
for i := range shifts {
|
||||
shift := &shifts[i]
|
||||
shiftMap[schedShiftKey(shift.Name, shift.WorkStart, shift.WorkEnd)] = shift
|
||||
if shift.Sort > maxSort {
|
||||
maxSort = shift.Sort
|
||||
}
|
||||
}
|
||||
|
||||
patterns := []scPatternDay{}
|
||||
createdShifts := 0
|
||||
matchedShifts := 0
|
||||
for _, day := range c.schedTplDays(tpl.ID) {
|
||||
if day.DayType != models.SchedTplDayWork {
|
||||
patterns = append(patterns, scPatternDay{DayIndex: day.DayIndex, DayType: models.SchedTplDayRest})
|
||||
continue
|
||||
}
|
||||
key := schedShiftKey(day.ShiftName, day.WorkStart, day.WorkEnd)
|
||||
shift, hit := shiftMap[key]
|
||||
if hit {
|
||||
matchedShifts++
|
||||
} else {
|
||||
maxSort += 10
|
||||
shift = &models.OaShift{
|
||||
Tid: int(tid),
|
||||
Name: day.ShiftName,
|
||||
ShortName: day.ShiftShort,
|
||||
Color: day.Color,
|
||||
WorkStart: day.WorkStart,
|
||||
WorkEnd: day.WorkEnd,
|
||||
RestStart: day.RestStart,
|
||||
RestEnd: day.RestEnd,
|
||||
WorkHours: day.WorkHours,
|
||||
IsDefault: 0,
|
||||
Status: 1,
|
||||
Sort: maxSort,
|
||||
IsDeleted: 0,
|
||||
}
|
||||
if shift.ShortName == "" {
|
||||
shift.ShortName = shift.Name
|
||||
}
|
||||
if shift.Color == "" {
|
||||
shift.Color = "#409EFF"
|
||||
}
|
||||
if _, err := models.Orm.Insert(shift); err != nil {
|
||||
c.scFail(500, 500, "创建班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
shiftMap[key] = shift
|
||||
createdShifts++
|
||||
}
|
||||
patterns = append(patterns, scPatternDay{
|
||||
DayIndex: day.DayIndex,
|
||||
DayType: models.SchedTplDayWork,
|
||||
ShiftID: shift.ID,
|
||||
ShiftName: shift.Name,
|
||||
ShortName: shift.ShortName,
|
||||
Color: shift.Color,
|
||||
WorkStart: shift.WorkStart,
|
||||
WorkEnd: shift.WorkEnd,
|
||||
RestStart: shift.RestStart,
|
||||
RestEnd: shift.RestEnd,
|
||||
WorkHours: shift.WorkHours,
|
||||
})
|
||||
}
|
||||
if len(patterns) == 0 {
|
||||
c.scFail(400, 400, "该模板没有配置排班规则")
|
||||
return
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{
|
||||
"template": schedTplView(tpl, nil),
|
||||
"pattern": patterns,
|
||||
"days": patterns,
|
||||
"created_shift": createdShifts,
|
||||
"matched_shift": matchedShifts,
|
||||
})
|
||||
}
|
||||
|
||||
func schedShiftKey(name, workStart, workEnd string) string {
|
||||
return strings.TrimSpace(name) + "|" + strings.TrimSpace(workStart) + "|" + strings.TrimSpace(workEnd)
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/identity"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// PlatformSchedTemplateController 平台端排班模板管理(全局模板,只有平台可维护)。
|
||||
//
|
||||
// 表:yz_platform_sched_tpl / yz_platform_sched_tpl_day
|
||||
// 建表与预置数据:docs/sql/create_platform_sched_template.sql
|
||||
//
|
||||
// 规则:
|
||||
// - 系统预置模板(is_system=1)由平台锁定:禁止编辑、删除、停用;
|
||||
// - 平台可新建「自定义模板」,并编辑 / 停用 / 删除自定义模板;
|
||||
// - 租户端只读取启用模板(/backend/oa/scheduling/templates),
|
||||
// 调用后把规则快照进自己的排班方案,模板改动不影响已保存方案。
|
||||
type PlatformSchedTemplateController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *PlatformSchedTemplateController) tplClaims() (*jwtutil.Claims, error) {
|
||||
return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndPlatform)
|
||||
}
|
||||
|
||||
func (c *PlatformSchedTemplateController) tplErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *PlatformSchedTemplateController) tplOK(data interface{}, msg string) {
|
||||
if msg == "" {
|
||||
msg = "success"
|
||||
}
|
||||
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||||
if data != nil {
|
||||
resp["data"] = data
|
||||
}
|
||||
c.Data["json"] = resp
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 参数结构
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type schedTplDayPayload struct {
|
||||
DayIndex int `json:"day_index"`
|
||||
DayType int8 `json:"day_type"`
|
||||
ShiftName string `json:"shift_name"`
|
||||
ShiftShort string `json:"shift_short"`
|
||||
Color string `json:"color"`
|
||||
WorkStart string `json:"work_start"`
|
||||
WorkEnd string `json:"work_end"`
|
||||
RestStart string `json:"rest_start"`
|
||||
RestEnd string `json:"rest_end"`
|
||||
WorkHours float64 `json:"work_hours"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type schedTplPayload struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
TemplateType int8 `json:"template_type"`
|
||||
AlignMode int8 `json:"align_mode"`
|
||||
CycleDays int `json:"cycle_days"`
|
||||
WeekOffset int8 `json:"week_offset"`
|
||||
MonthRestDays int8 `json:"month_rest_days"`
|
||||
MonthRestWeekday int8 `json:"month_rest_weekday"`
|
||||
Summary string `json:"summary"`
|
||||
Status int8 `json:"status"`
|
||||
Sort int `json:"sort"`
|
||||
Days []schedTplDayPayload `json:"days"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 列表 / 详情
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *PlatformSchedTemplateController) tplQuery() orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.PlatformSchedTpl)).Filter("delete_time__isnull", true)
|
||||
}
|
||||
|
||||
func (c *PlatformSchedTemplateController) tplByID(id uint64) (*models.PlatformSchedTpl, bool) {
|
||||
var row models.PlatformSchedTpl
|
||||
if err := c.tplQuery().Filter("id", id).One(&row); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &row, true
|
||||
}
|
||||
|
||||
func (c *PlatformSchedTemplateController) tplDays(templateID uint64) []models.PlatformSchedTplDay {
|
||||
rows := []models.PlatformSchedTplDay{}
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformSchedTplDay)).
|
||||
Filter("template_id", templateID).
|
||||
OrderBy("day_index").All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return []models.PlatformSchedTplDay{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (c *PlatformSchedTemplateController) tplView(row *models.PlatformSchedTpl, withDays bool) map[string]interface{} {
|
||||
view := map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"code": row.Code,
|
||||
"name": row.Name,
|
||||
"template_type": row.TemplateType,
|
||||
"template_type_text": schedTplTypeText(row.TemplateType),
|
||||
"align_mode": row.AlignMode,
|
||||
"cycle_days": row.CycleDays,
|
||||
"week_offset": row.WeekOffset,
|
||||
"month_rest_days": row.MonthRestDays,
|
||||
"month_rest_weekday": row.MonthRestWeekday,
|
||||
"summary": row.Summary,
|
||||
"is_system": row.IsSystem,
|
||||
"editable": row.IsSystem != 1,
|
||||
"status": row.Status,
|
||||
"sort": row.Sort,
|
||||
"creater": row.Creater,
|
||||
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if row.UpdateTime != nil {
|
||||
view["update_time"] = row.UpdateTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if withDays {
|
||||
view["days"] = c.tplDays(row.ID)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func schedTplTypeText(value int8) string {
|
||||
switch value {
|
||||
case models.SchedTplTypeDoubleWeekend:
|
||||
return "双休"
|
||||
case models.SchedTplTypeSingleWeekend:
|
||||
return "单休"
|
||||
case models.SchedTplTypeBigSmallWeek:
|
||||
return "大小周"
|
||||
case models.SchedTplTypeMonthRest:
|
||||
return "月休"
|
||||
case models.SchedTplTypeThreeShift:
|
||||
return "三班倒"
|
||||
case models.SchedTplTypeWorkRestOne:
|
||||
return "做一休一"
|
||||
default:
|
||||
return "自定义"
|
||||
}
|
||||
}
|
||||
|
||||
// List 模板列表。
|
||||
// GET /platform/schedTemplates?page=1&pageSize=20&keyword=&status=&template_type=
|
||||
func (c *PlatformSchedTemplateController) List() {
|
||||
if _, err := c.tplClaims(); err != nil {
|
||||
c.tplErr(401, 401, "未登录或登录已过期")
|
||||
return
|
||||
}
|
||||
page, pageSize, offset := dictPageSize(&c.Controller)
|
||||
|
||||
qs := c.tplQuery()
|
||||
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
|
||||
qs = qs.Filter("name__icontains", keyword)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.GetString("status")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
qs = qs.Filter("status", value)
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(c.GetString("template_type")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
qs = qs.Filter("template_type", value)
|
||||
}
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var rows []models.PlatformSchedTpl
|
||||
if _, err := qs.OrderBy("sort", "id").Limit(pageSize).Offset(offset).All(&rows); err != nil && err != orm.ErrNoRows {
|
||||
c.tplErr(500, 500, "查询模板失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for i := range rows {
|
||||
list = append(list, c.tplView(&rows[i], false))
|
||||
}
|
||||
c.tplOK(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
}, "")
|
||||
}
|
||||
|
||||
// Detail 模板详情(含周期每日规则)。
|
||||
// GET /platform/schedTemplates/:id
|
||||
func (c *PlatformSchedTemplateController) Detail() {
|
||||
if _, err := c.tplClaims(); err != nil {
|
||||
c.tplErr(401, 401, "未登录或登录已过期")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.tplErr(400, 400, "无效的模板ID")
|
||||
return
|
||||
}
|
||||
row, found := c.tplByID(id)
|
||||
if !found {
|
||||
c.tplErr(404, 404, "模板不存在")
|
||||
return
|
||||
}
|
||||
c.tplOK(c.tplView(row, true), "")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 新建 / 编辑 / 删除 / 启停
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *PlatformSchedTemplateController) parsePayload() (*schedTplPayload, bool) {
|
||||
var payload schedTplPayload
|
||||
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
|
||||
c.tplErr(400, 400, "参数错误")
|
||||
return nil, false
|
||||
}
|
||||
return &payload, true
|
||||
}
|
||||
|
||||
// buildDays 校验并构造周期每日规则;返回非空 msg 表示参数错误。
|
||||
func buildSchedTplDays(payload *schedTplPayload) ([]models.PlatformSchedTplDay, string) {
|
||||
if payload.AlignMode < models.SchedTplAlignWeek || payload.AlignMode > models.SchedTplAlignMonth {
|
||||
return nil, "排班方式不合法"
|
||||
}
|
||||
if len(payload.Days) == 0 {
|
||||
return nil, "请至少配置一天的规则"
|
||||
}
|
||||
|
||||
cycle := payload.CycleDays
|
||||
if payload.AlignMode == models.SchedTplAlignMonth {
|
||||
// 月休按自然月计算,周期天数无意义
|
||||
cycle = 0
|
||||
if payload.MonthRestDays < 1 || payload.MonthRestDays > 15 {
|
||||
return nil, "月休天数需在 1~15 天之间"
|
||||
}
|
||||
if payload.MonthRestWeekday < 0 || payload.MonthRestWeekday > 7 {
|
||||
return nil, "月休休息周几不合法"
|
||||
}
|
||||
} else {
|
||||
if cycle < 2 || cycle > 31 {
|
||||
return nil, "周期天数需在 2~31 天之间"
|
||||
}
|
||||
if payload.AlignMode == models.SchedTplAlignWeek && cycle%7 != 0 {
|
||||
return nil, "按自然周排班时,周期天数必须是 7 的倍数(7 / 14 / 21 / 28)"
|
||||
}
|
||||
if len(payload.Days) != cycle {
|
||||
return nil, fmt.Sprintf("周期为 %d 天,需要配置 %d 天的规则", cycle, cycle)
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[int]bool{}
|
||||
workCount := 0
|
||||
days := make([]models.PlatformSchedTplDay, 0, len(payload.Days))
|
||||
for _, item := range payload.Days {
|
||||
index := item.DayIndex
|
||||
if payload.AlignMode == models.SchedTplAlignMonth {
|
||||
index = len(days)
|
||||
}
|
||||
if index < 0 || (payload.AlignMode != models.SchedTplAlignMonth && index >= cycle) {
|
||||
return nil, "每日规则的天序号不合法"
|
||||
}
|
||||
if seen[index] {
|
||||
return nil, "每日规则的天序号重复"
|
||||
}
|
||||
seen[index] = true
|
||||
|
||||
dayType := item.DayType
|
||||
if dayType != models.SchedTplDayWork && dayType != models.SchedTplDayRest {
|
||||
return nil, "每日规则类型不合法(1=上班 2=休息)"
|
||||
}
|
||||
row := models.PlatformSchedTplDay{
|
||||
DayIndex: index,
|
||||
DayType: dayType,
|
||||
Remark: strings.TrimSpace(item.Remark),
|
||||
}
|
||||
if dayType == models.SchedTplDayWork {
|
||||
workCount++
|
||||
row.ShiftName = strings.TrimSpace(item.ShiftName)
|
||||
if row.ShiftName == "" {
|
||||
return nil, fmt.Sprintf("第 %d 天选择上班,需填写班次名称", index+1)
|
||||
}
|
||||
if len([]rune(row.ShiftName)) > 50 {
|
||||
return nil, "班次名称不能超过 50 个字符"
|
||||
}
|
||||
row.WorkStart = strings.TrimSpace(item.WorkStart)
|
||||
row.WorkEnd = strings.TrimSpace(item.WorkEnd)
|
||||
if !isValidOaTime(row.WorkStart) || !isValidOaTime(row.WorkEnd) {
|
||||
return nil, fmt.Sprintf("第 %d 天的上下班时间格式应为 HH:mm", index+1)
|
||||
}
|
||||
if row.WorkStart == row.WorkEnd {
|
||||
return nil, fmt.Sprintf("第 %d 天的上下班时间不能相同", index+1)
|
||||
}
|
||||
row.RestStart = strings.TrimSpace(item.RestStart)
|
||||
row.RestEnd = strings.TrimSpace(item.RestEnd)
|
||||
if (row.RestStart == "") != (row.RestEnd == "") {
|
||||
return nil, fmt.Sprintf("第 %d 天的休息开始与结束时间需要同时填写", index+1)
|
||||
}
|
||||
if row.RestStart != "" && (!isValidOaTime(row.RestStart) || !isValidOaTime(row.RestEnd)) {
|
||||
return nil, fmt.Sprintf("第 %d 天的休息时间格式应为 HH:mm", index+1)
|
||||
}
|
||||
row.ShiftShort = strings.TrimSpace(item.ShiftShort)
|
||||
if row.ShiftShort == "" {
|
||||
row.ShiftShort = row.ShiftName
|
||||
}
|
||||
row.Color = strings.TrimSpace(item.Color)
|
||||
if row.Color == "" {
|
||||
row.Color = "#409EFF"
|
||||
}
|
||||
row.WorkHours = scCalcWorkHours(row.WorkStart, row.WorkEnd, row.RestStart, row.RestEnd)
|
||||
}
|
||||
days = append(days, row)
|
||||
}
|
||||
|
||||
if workCount == 0 {
|
||||
return nil, "排班模板至少需要一天上班"
|
||||
}
|
||||
// 按天序号排序,避免前端乱序提交
|
||||
for i := 0; i < len(days); i++ {
|
||||
for j := i + 1; j < len(days); j++ {
|
||||
if days[j].DayIndex < days[i].DayIndex {
|
||||
days[i], days[j] = days[j], days[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return days, ""
|
||||
}
|
||||
|
||||
// autoSummary 生成规则摘要(前端未填写时使用)。
|
||||
func autoSummary(payload *schedTplPayload, days []models.PlatformSchedTplDay) string {
|
||||
work, rest := 0, 0
|
||||
shiftNames := []string{}
|
||||
seenShift := map[string]bool{}
|
||||
for _, day := range days {
|
||||
if day.DayType == models.SchedTplDayWork {
|
||||
work++
|
||||
if !seenShift[day.ShiftName] {
|
||||
seenShift[day.ShiftName] = true
|
||||
shiftNames = append(shiftNames, fmt.Sprintf("%s %s-%s", day.ShiftName, day.WorkStart, day.WorkEnd))
|
||||
}
|
||||
} else {
|
||||
rest++
|
||||
}
|
||||
}
|
||||
switch payload.AlignMode {
|
||||
case models.SchedTplAlignWeek:
|
||||
weeks := payload.CycleDays / 7
|
||||
if weeks <= 1 {
|
||||
return fmt.Sprintf("每周上班 %d 天、休息 %d 天;%s", work, rest, strings.Join(shiftNames, "、"))
|
||||
}
|
||||
return fmt.Sprintf("%d 周循环,共上班 %d 天、休息 %d 天;%s", weeks, work, rest, strings.Join(shiftNames, "、"))
|
||||
case models.SchedTplAlignMonth:
|
||||
return fmt.Sprintf("每月固定休 %d 天;上班日 %s", payload.MonthRestDays, strings.Join(shiftNames, "、"))
|
||||
default:
|
||||
return fmt.Sprintf("每 %d 天循环,上班 %d 天、休息 %d 天;%s", payload.CycleDays, work, rest, strings.Join(shiftNames, "、"))
|
||||
}
|
||||
}
|
||||
|
||||
// Create 新建自定义模板。
|
||||
// POST /platform/schedTemplates
|
||||
func (c *PlatformSchedTemplateController) Create() {
|
||||
claims, err := c.tplClaims()
|
||||
if err != nil {
|
||||
c.tplErr(401, 401, "未登录或登录已过期")
|
||||
return
|
||||
}
|
||||
payload, ok := c.parsePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
payload.Name = strings.TrimSpace(payload.Name)
|
||||
if payload.Name == "" {
|
||||
c.tplErr(400, 400, "模板名称不能为空")
|
||||
return
|
||||
}
|
||||
if len([]rune(payload.Name)) > 50 {
|
||||
c.tplErr(400, 400, "模板名称不能超过 50 个字符")
|
||||
return
|
||||
}
|
||||
if payload.TemplateType <= 0 || (payload.TemplateType > 6 && payload.TemplateType != models.SchedTplTypeCustom) {
|
||||
payload.TemplateType = models.SchedTplTypeCustom
|
||||
}
|
||||
days, msg := buildSchedTplDays(payload)
|
||||
if msg != "" {
|
||||
c.tplErr(400, 400, msg)
|
||||
return
|
||||
}
|
||||
if payload.Status != 0 {
|
||||
payload.Status = 1
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(payload.Code)
|
||||
if code == "" {
|
||||
code = fmt.Sprintf("custom_%d", time.Now().UnixNano()/1e6)
|
||||
}
|
||||
exist, _ := c.tplQuery().Filter("code", code).Count()
|
||||
if exist > 0 {
|
||||
c.tplErr(400, 400, "模板编码已存在")
|
||||
return
|
||||
}
|
||||
summary := strings.TrimSpace(payload.Summary)
|
||||
if summary == "" {
|
||||
summary = autoSummary(payload, days)
|
||||
}
|
||||
|
||||
row := &models.PlatformSchedTpl{
|
||||
Code: code,
|
||||
Name: payload.Name,
|
||||
TemplateType: payload.TemplateType,
|
||||
AlignMode: payload.AlignMode,
|
||||
CycleDays: payload.CycleDays,
|
||||
WeekOffset: payload.WeekOffset,
|
||||
MonthRestDays: payload.MonthRestDays,
|
||||
MonthRestWeekday: payload.MonthRestWeekday,
|
||||
Summary: summary,
|
||||
IsSystem: 0,
|
||||
Status: payload.Status,
|
||||
Sort: payload.Sort,
|
||||
Creater: claims.Username,
|
||||
}
|
||||
if row.AlignMode == models.SchedTplAlignMonth {
|
||||
row.CycleDays = 0
|
||||
}
|
||||
|
||||
tx, beginErr := orm.NewOrm().Begin()
|
||||
if beginErr != nil {
|
||||
c.tplErr(500, 500, "保存模板失败: "+beginErr.Error())
|
||||
return
|
||||
}
|
||||
id, insertErr := tx.Insert(row)
|
||||
if insertErr != nil {
|
||||
_ = tx.Rollback()
|
||||
c.tplErr(500, 500, "保存模板失败: "+insertErr.Error())
|
||||
return
|
||||
}
|
||||
for i := range days {
|
||||
days[i].TemplateID = row.ID
|
||||
if _, err := tx.Insert(&days[i]); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.tplErr(500, 500, "保存模板规则失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.tplErr(500, 500, "保存模板失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = id
|
||||
|
||||
c.tplOK(c.tplView(row, true), "模板已创建")
|
||||
}
|
||||
|
||||
// Update 编辑自定义模板;系统预置模板只读。
|
||||
// POST /platform/schedTemplates/:id
|
||||
func (c *PlatformSchedTemplateController) Update() {
|
||||
if _, err := c.tplClaims(); err != nil {
|
||||
c.tplErr(401, 401, "未登录或登录已过期")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.tplErr(400, 400, "无效的模板ID")
|
||||
return
|
||||
}
|
||||
row, found := c.tplByID(id)
|
||||
if !found {
|
||||
c.tplErr(404, 404, "模板不存在")
|
||||
return
|
||||
}
|
||||
if row.IsSystem == 1 {
|
||||
c.tplErr(400, 400, "系统预置模板只读,不能编辑")
|
||||
return
|
||||
}
|
||||
|
||||
payload, ok := c.parsePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
payload.Name = strings.TrimSpace(payload.Name)
|
||||
if payload.Name == "" {
|
||||
c.tplErr(400, 400, "模板名称不能为空")
|
||||
return
|
||||
}
|
||||
if len([]rune(payload.Name)) > 50 {
|
||||
c.tplErr(400, 400, "模板名称不能超过 50 个字符")
|
||||
return
|
||||
}
|
||||
if payload.TemplateType <= 0 || (payload.TemplateType > 6 && payload.TemplateType != models.SchedTplTypeCustom) {
|
||||
payload.TemplateType = models.SchedTplTypeCustom
|
||||
}
|
||||
days, msg := buildSchedTplDays(payload)
|
||||
if msg != "" {
|
||||
c.tplErr(400, 400, msg)
|
||||
return
|
||||
}
|
||||
if payload.Status != 0 {
|
||||
payload.Status = 1
|
||||
}
|
||||
summary := strings.TrimSpace(payload.Summary)
|
||||
if summary == "" {
|
||||
summary = autoSummary(payload, days)
|
||||
}
|
||||
|
||||
row.Name = payload.Name
|
||||
row.TemplateType = payload.TemplateType
|
||||
row.AlignMode = payload.AlignMode
|
||||
row.CycleDays = payload.CycleDays
|
||||
if row.AlignMode == models.SchedTplAlignMonth {
|
||||
row.CycleDays = 0
|
||||
}
|
||||
row.WeekOffset = payload.WeekOffset
|
||||
row.MonthRestDays = payload.MonthRestDays
|
||||
row.MonthRestWeekday = payload.MonthRestWeekday
|
||||
row.Summary = summary
|
||||
row.Status = payload.Status
|
||||
row.Sort = payload.Sort
|
||||
|
||||
tx, beginErr := orm.NewOrm().Begin()
|
||||
if beginErr != nil {
|
||||
c.tplErr(500, 500, "保存模板失败: "+beginErr.Error())
|
||||
return
|
||||
}
|
||||
if _, err := tx.Update(row); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.tplErr(500, 500, "保存模板失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 规则整体替换:先清空旧规则再写入新规则,避免残留天数
|
||||
if _, err := tx.QueryTable(new(models.PlatformSchedTplDay)).Filter("template_id", row.ID).Delete(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.tplErr(500, 500, "保存模板规则失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
for i := range days {
|
||||
days[i].TemplateID = row.ID
|
||||
if _, err := tx.Insert(&days[i]); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.tplErr(500, 500, "保存模板规则失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.tplErr(500, 500, "保存模板失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.tplOK(c.tplView(row, true), "模板已更新")
|
||||
}
|
||||
|
||||
// Delete 删除自定义模板(软删除);系统预置模板禁止删除。
|
||||
// DELETE /platform/schedTemplates/:id
|
||||
func (c *PlatformSchedTemplateController) Delete() {
|
||||
if _, err := c.tplClaims(); err != nil {
|
||||
c.tplErr(401, 401, "未登录或登录已过期")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.tplErr(400, 400, "无效的模板ID")
|
||||
return
|
||||
}
|
||||
row, found := c.tplByID(id)
|
||||
if !found {
|
||||
c.tplErr(404, 404, "模板不存在")
|
||||
return
|
||||
}
|
||||
if row.IsSystem == 1 {
|
||||
c.tplErr(400, 400, "系统预置模板不能删除")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
row.DeleteTime = &now
|
||||
if _, err := models.Orm.Update(row, "delete_time"); err != nil {
|
||||
c.tplErr(500, 500, "删除模板失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.tplOK(map[string]interface{}{"id": row.ID}, "模板已删除")
|
||||
}
|
||||
|
||||
// ToggleStatus 启用 / 停用自定义模板;系统预置模板不可停用。
|
||||
// POST /platform/schedTemplates/:id/status
|
||||
func (c *PlatformSchedTemplateController) ToggleStatus() {
|
||||
if _, err := c.tplClaims(); err != nil {
|
||||
c.tplErr(401, 401, "未登录或登录已过期")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.tplErr(400, 400, "无效的模板ID")
|
||||
return
|
||||
}
|
||||
row, found := c.tplByID(id)
|
||||
if !found {
|
||||
c.tplErr(404, 404, "模板不存在")
|
||||
return
|
||||
}
|
||||
if row.IsSystem == 1 {
|
||||
c.tplErr(400, 400, "系统预置模板固定启用,不能停用")
|
||||
return
|
||||
}
|
||||
status, _ := c.GetInt("status", -1)
|
||||
if status != 0 && status != 1 {
|
||||
var body struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &body)
|
||||
if body.Status != 0 && body.Status != 1 {
|
||||
c.tplErr(400, 400, "状态只能是 0 或 1")
|
||||
return
|
||||
}
|
||||
status = int(body.Status)
|
||||
}
|
||||
row.Status = int8(status)
|
||||
if _, err := models.Orm.Update(row, "status"); err != nil {
|
||||
c.tplErr(500, 500, "更新状态失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.tplOK(map[string]interface{}{"id": row.ID, "status": row.Status}, "状态已更新")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
-- ============================================================
|
||||
-- OA 工作排班:排班方案支持「平台模板 + 周期规则」
|
||||
--
|
||||
-- 适用:已执行过 create_oa_scheduling.sql 的环境(表已存在)。
|
||||
-- 执行:在业务库(go-platform)下整段执行即可。
|
||||
--
|
||||
-- 幂等:逐列判断 information_schema,只添加缺失的列;
|
||||
-- 9 列全部存在时自动跳过一次无害查询,不会报 1060。
|
||||
--
|
||||
-- 字段说明:
|
||||
-- template_id / template_code / template_name
|
||||
-- 方案来源模板的快照。租户调用模板后,模板规则会复制进本方案,
|
||||
-- 之后模板调整或停用都不影响已保存方案(platform 模板只读,租户可改自己的)。
|
||||
-- align_mode
|
||||
-- 0 = 未使用模板(沿用 weekdays + shift_id 的按星期排班)
|
||||
-- 1 = 按自然周(双休 / 单休 / 大小周)
|
||||
-- 2 = 从 start_date 起滚动(三班倒 / 做一休一)
|
||||
-- 3 = 按自然月(月休)
|
||||
-- cycle_days 周期天数(align_mode=1/2 时等于 pattern 长度)
|
||||
-- week_offset 大小周起始周偏移:0=开始日期所在周为周期第 1 周,1=第 2 周
|
||||
-- month_rest_days 月休:每月休息天数
|
||||
-- month_rest_weekday 月休固定休息周几(1-7),0 表示不固定
|
||||
-- pattern 周期规则 JSON 快照,元素形如
|
||||
-- {"day_index":0,"day_type":1,"shift_id":12,"shift_name":"白班",
|
||||
-- "short_name":"白","color":"#409EFF","work_start":"09:00","work_end":"18:00"}
|
||||
-- ============================================================
|
||||
|
||||
-- 执行前自查:existing_cols = 9 表示字段已齐全,执行到下面会自动跳过
|
||||
SELECT COUNT(*) AS existing_cols
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'yz_backend_oa_schedule_plan'
|
||||
AND COLUMN_NAME IN ('template_id','template_code','template_name','align_mode','cycle_days','week_offset','month_rest_days','month_rest_weekday','pattern');
|
||||
|
||||
SET @sql := CONCAT('ALTER TABLE `yz_backend_oa_schedule_plan` ',
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_id') = 0,
|
||||
'ADD COLUMN `template_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''来源模板ID(0=未使用模板)'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_code') = 0,
|
||||
'ADD COLUMN `template_code` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''来源模板编码快照'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_name') = 0,
|
||||
'ADD COLUMN `template_name` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''来源模板名称快照'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'align_mode') = 0,
|
||||
'ADD COLUMN `align_mode` TINYINT NOT NULL DEFAULT 0 COMMENT ''0不使用模板 1按自然周 2按开始日滚动 3按自然月'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'cycle_days') = 0,
|
||||
'ADD COLUMN `cycle_days` SMALLINT NOT NULL DEFAULT 0 COMMENT ''周期天数'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'week_offset') = 0,
|
||||
'ADD COLUMN `week_offset` TINYINT NOT NULL DEFAULT 0 COMMENT ''大小周起始周偏移'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'month_rest_days') = 0,
|
||||
'ADD COLUMN `month_rest_days` TINYINT NOT NULL DEFAULT 0 COMMENT ''月休天数'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'month_rest_weekday') = 0,
|
||||
'ADD COLUMN `month_rest_weekday` TINYINT NOT NULL DEFAULT 0 COMMENT ''月休固定周几(1-7,0不固定)'', ', ''),
|
||||
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'pattern') = 0,
|
||||
'ADD COLUMN `pattern` TEXT NULL COMMENT ''周期规则JSON快照'', ', '')
|
||||
);
|
||||
|
||||
-- 去掉结尾多余的 ", "(两个字符),否则 ALTER 语句会因尾逗号报 1064
|
||||
SET @sql := IF(RIGHT(@sql, 2) = ', ', LEFT(@sql, CHAR_LENGTH(@sql) - 2), @sql);
|
||||
-- 没有任何缺失列时,@sql 只剩表名,替换为一条无害语句
|
||||
SET @sql := IF(RIGHT(@sql, 6) = 'TABLE ', 'SELECT 1 AS skipped_all_columns_exist', @sql);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 核对结果:应返回 9 行
|
||||
SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'yz_backend_oa_schedule_plan'
|
||||
AND COLUMN_NAME IN ('template_id','template_code','template_name','align_mode','cycle_days','week_offset','month_rest_days','month_rest_weekday','pattern')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
@@ -0,0 +1,151 @@
|
||||
-- =============================================================
|
||||
-- OA 工作排班 —— 表结构
|
||||
--
|
||||
-- 对应代码:
|
||||
-- models/oa_scheduling.go
|
||||
-- controllers/backend_oa_scheduling.go (班次管理)
|
||||
-- controllers/backend_oa_scheduling_scope.go (组织/员工范围展开)
|
||||
-- controllers/backend_oa_scheduling_plan.go (排班方案与发布)
|
||||
-- controllers/backend_oa_scheduling_board.go (看板、临时调配、变更记录)
|
||||
--
|
||||
-- 执行顺序:
|
||||
-- 1) 本脚本:创建 4 张排班表
|
||||
-- 2) oa_scheduling_menus.sql:注册「OA → 排班考勤 → 工作排班」菜单并授权角色
|
||||
--
|
||||
-- 设计要点:
|
||||
-- 1. 排班对象直接复用租户组织架构(yz_backend_organization)与员工表
|
||||
-- (yz_backend_employee),不另建“小组”表:公司、部门、小组都是组织
|
||||
-- 树上的节点,方案通过 scope_type + org_id + include_child 决定范围。
|
||||
-- 2. 方案(plan)只保存规则;发布后展开成明细(entry),一人一天一条。
|
||||
-- 看板与临时调配只操作明细,方案可重复发布(可选覆盖)。
|
||||
-- 3. 明细软删除:is_deleted=0 表示有效,删除时写入记录 ID,
|
||||
-- 使唯一键 (tid, employee_id, schedule_date, is_deleted) 在删除后
|
||||
-- 同一天重新排班时不会与历史记录冲突。
|
||||
-- 4. 变更留痕(change)只记录人、日期与新旧班次,方便追溯临时调配。
|
||||
-- =============================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------------- 1. 班次定义 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_shift` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称,如早班/晚班',
|
||||
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '看板色块上的简称',
|
||||
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '标识颜色',
|
||||
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间 HH:mm',
|
||||
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间 HH:mm(早于上班时间视为跨天)',
|
||||
`rest_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '休息开始 HH:mm,空表示无',
|
||||
`rest_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '休息结束 HH:mm',
|
||||
`work_hours` DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '工时(已扣除休息)',
|
||||
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '1=默认班次',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
|
||||
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`remark` VARCHAR(255) DEFAULT NULL,
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_status` (`tid`, `status`),
|
||||
KEY `idx_tid_deleted` (`tid`, `is_deleted`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班班次';
|
||||
|
||||
-- ---------------- 2. 排班方案 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_plan` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`plan_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '方案名称',
|
||||
`plan_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=常规排班 2=临时方案',
|
||||
`scope_type` TINYINT NOT NULL DEFAULT 2 COMMENT '1=整个公司 2=部门/小组 3=指定员工',
|
||||
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '范围组织ID(yz_backend_organization.id)',
|
||||
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '范围名称快照',
|
||||
`include_child` TINYINT NOT NULL DEFAULT 1 COMMENT '1=包含全部下级组织',
|
||||
`employee_ids` TEXT COMMENT '指定员工ID JSON 数组:[1,2,3]',
|
||||
`employee_names` TEXT COMMENT '指定员工姓名快照',
|
||||
`start_date` DATE NOT NULL COMMENT '开始日期',
|
||||
`end_date` DATE NOT NULL COMMENT '结束日期',
|
||||
`weekdays` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生效星期 JSON:[1,2,3,4,5];空=每天',
|
||||
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID',
|
||||
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
|
||||
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
|
||||
`note` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
|
||||
`publish_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未发布 1=已发布',
|
||||
`entry_count` INT NOT NULL DEFAULT 0 COMMENT '最近一次发布生成/覆盖的条数',
|
||||
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人 uid',
|
||||
`created_by_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人名称快照',
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_deleted` (`tid`, `is_deleted`),
|
||||
KEY `idx_tid_scope` (`tid`, `scope_type`, `org_id`),
|
||||
KEY `idx_tid_dates` (`tid`, `start_date`, `end_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班方案';
|
||||
|
||||
-- ---------------- 3. 排班明细 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_entry` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`plan_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '来源方案ID,0=纯手工排班',
|
||||
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID(yz_backend_employee.id)',
|
||||
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||
`employee_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工账号快照',
|
||||
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属组织ID',
|
||||
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '所属组织名称快照',
|
||||
`position` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '职位快照',
|
||||
`schedule_date` DATE NOT NULL COMMENT '排班日期',
|
||||
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID',
|
||||
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
|
||||
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '班次简称快照',
|
||||
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
|
||||
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间快照',
|
||||
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间快照',
|
||||
`source` TINYINT NOT NULL DEFAULT 1 COMMENT '1=方案生成 2=临时调配',
|
||||
`note` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注/调配原因',
|
||||
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人 uid',
|
||||
`created_by_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照',
|
||||
`is_deleted` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除标记:0=有效;删除时写入记录ID',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tid_emp_date` (`tid`, `employee_id`, `schedule_date`, `is_deleted`),
|
||||
KEY `idx_tid_date` (`tid`, `schedule_date`),
|
||||
KEY `idx_tid_plan` (`tid`, `plan_id`),
|
||||
KEY `idx_tid_emp` (`tid`, `employee_id`),
|
||||
KEY `idx_tid_org_date` (`tid`, `org_id`, `schedule_date`),
|
||||
KEY `idx_tid_shift` (`tid`, `shift_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班明细';
|
||||
|
||||
-- ---------------- 4. 排班变更记录 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_change` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`entry_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '排班明细ID',
|
||||
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID',
|
||||
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||
`schedule_date` DATE NOT NULL COMMENT '排班日期',
|
||||
`change_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=调班 2=清除 3=手工新增 4=方案覆盖',
|
||||
`old_shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '原班次ID',
|
||||
`old_shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '原班次名称',
|
||||
`new_shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '新班次ID',
|
||||
`new_shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '新班次名称',
|
||||
`reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '调整原因',
|
||||
`operator_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人 uid',
|
||||
`operator_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_date` (`tid`, `schedule_date`),
|
||||
KEY `idx_tid_emp` (`tid`, `employee_id`),
|
||||
KEY `idx_tid_entry` (`tid`, `entry_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班变更记录';
|
||||
|
||||
-- ---------------- 校验 ----------------
|
||||
SELECT TABLE_NAME, TABLE_COMMENT
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME LIKE 'yz_backend_oa_schedule_%'
|
||||
OR TABLE_NAME = 'yz_backend_oa_shift';
|
||||
@@ -0,0 +1,219 @@
|
||||
-- ============================================================
|
||||
-- 平台端排班模板(全局预制模板 + 自定义模板)
|
||||
--
|
||||
-- 执行顺序:
|
||||
-- 1) 本脚本:创建 2 张模板表并写入 6 个系统预置模板
|
||||
-- 2) alter_oa_schedule_plan_template.sql:租户排班方案增加模板/周期字段
|
||||
-- 3) platform_sched_template_menu.sql:注册平台端「排班模板」菜单
|
||||
-- 4) create_oa_scheduling.sql / oa_scheduling_menus.sql(若尚未执行)
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 模板是全局数据(不按租户隔离),只有平台端可维护;
|
||||
-- 系统预置模板(is_system=1)由平台锁定:不可编辑、不可删除、不可停用。
|
||||
-- 2. 租户端只能「调用」模板:调用时把模板规则快照到自己的排班方案
|
||||
-- (yz_backend_oa_schedule_plan.pattern),之后在租户端随意修改,
|
||||
-- 模板本身的后续调整不会再影响已保存的方案。
|
||||
-- 3. align_mode:1=按自然周(双休/单休/大小周) 2=从开始日滚动(三班倒/做一休一)
|
||||
-- 3=按自然月(月休,按月计算休息日)
|
||||
-- 4. day_index 表示周期内第几天,从 0 开始;按自然周时 0=周一、6=周日。
|
||||
-- 5. 本脚本不使用会话变量 / PREPARE,可整段执行也可逐条执行,重复执行安全。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `yz_platform_sched_tpl` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`code` varchar(50) NOT NULL DEFAULT '' COMMENT '模板编码(系统模板固定)',
|
||||
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '模板名称',
|
||||
`template_type` tinyint NOT NULL DEFAULT 9 COMMENT '1双休 2单休 3大小周 4月休 5三班倒 6做一休一 9自定义',
|
||||
`align_mode` tinyint NOT NULL DEFAULT 1 COMMENT '1按自然周 2按开始日滚动 3按自然月',
|
||||
`cycle_days` smallint NOT NULL DEFAULT 7 COMMENT '周期天数(按自然月时为0)',
|
||||
`week_offset` tinyint NOT NULL DEFAULT 0 COMMENT '大小周起始周偏移:0=开始周为第1周 1=第2周',
|
||||
`month_rest_days` tinyint NOT NULL DEFAULT 0 COMMENT '月休:每月休息天数',
|
||||
`month_rest_weekday` tinyint NOT NULL DEFAULT 0 COMMENT '月休固定休息周几(1-7,0=不固定)',
|
||||
`summary` varchar(255) NOT NULL DEFAULT '' COMMENT '规则摘要(列表展示)',
|
||||
`is_system` tinyint NOT NULL DEFAULT 0 COMMENT '1=系统预置(只读,禁止编辑/删除)',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
|
||||
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`creater` varchar(50) NOT NULL DEFAULT '' COMMENT '创建人',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_code` (`code`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台端排班模板(全局)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `yz_platform_sched_tpl_day` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`template_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '模板ID',
|
||||
`day_index` smallint NOT NULL DEFAULT 0 COMMENT '周期内第几天,从0开始',
|
||||
`day_type` tinyint NOT NULL DEFAULT 1 COMMENT '1上班 2休息',
|
||||
`shift_name` varchar(50) NOT NULL DEFAULT '' COMMENT '班次名称',
|
||||
`shift_short` varchar(20) NOT NULL DEFAULT '' COMMENT '班次简称',
|
||||
`color` varchar(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色',
|
||||
`work_start` varchar(8) NOT NULL DEFAULT '' COMMENT '上班时间 HH:mm',
|
||||
`work_end` varchar(8) NOT NULL DEFAULT '' COMMENT '下班时间 HH:mm',
|
||||
`rest_start` varchar(8) NOT NULL DEFAULT '' COMMENT '休息开始 HH:mm',
|
||||
`rest_end` varchar(8) NOT NULL DEFAULT '' COMMENT '休息结束 HH:mm',
|
||||
`work_hours` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '工时(小时)',
|
||||
`remark` varchar(100) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tpl_day` (`template_id`,`day_index`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台端排班模板-周期每日规则';
|
||||
|
||||
-- ============================================================
|
||||
-- 系统预置模板(6 个)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 双休:周一至周五上班,周六日休息
|
||||
INSERT INTO yz_platform_sched_tpl
|
||||
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
|
||||
SELECT 'double_weekend', '双休', 1, 1, 7, 0, 0, 0, '周一至周五上班 09:00-18:00,周六日休息', 1, 1, 10, 'system'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'double_weekend' AND delete_time IS NULL) t);
|
||||
|
||||
INSERT INTO yz_platform_sched_tpl_day
|
||||
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
|
||||
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
|
||||
FROM (
|
||||
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
|
||||
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 5, 2, '', '', '', '', '', '', '', 0.00
|
||||
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
|
||||
) d
|
||||
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'double_weekend' AND delete_time IS NULL LIMIT 1) tpl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
|
||||
WHERE x.template_id = tpl.id AND x.day_index = d.idx
|
||||
);
|
||||
|
||||
-- 2. 单休:周一至周六上班,周日休息
|
||||
INSERT INTO yz_platform_sched_tpl
|
||||
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
|
||||
SELECT 'single_weekend', '单休', 2, 1, 7, 0, 0, 0, '周一至周六上班 09:00-18:00,周日休息', 1, 1, 20, 'system'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'single_weekend' AND delete_time IS NULL) t);
|
||||
|
||||
INSERT INTO yz_platform_sched_tpl_day
|
||||
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
|
||||
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
|
||||
FROM (
|
||||
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
|
||||
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 5, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
|
||||
) d
|
||||
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'single_weekend' AND delete_time IS NULL LIMIT 1) tpl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
|
||||
WHERE x.template_id = tpl.id AND x.day_index = d.idx
|
||||
);
|
||||
|
||||
-- 3. 大小周:第 1 周双休,第 2 周单休(周六上班),14 天一个周期
|
||||
INSERT INTO yz_platform_sched_tpl
|
||||
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
|
||||
SELECT 'big_small_week', '大小周', 3, 1, 14, 0, 0, 0, '第 1 周双休、第 2 周单休(周六上班),14 天循环', 1, 1, 30, 'system'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'big_small_week' AND delete_time IS NULL) t);
|
||||
|
||||
INSERT INTO yz_platform_sched_tpl_day
|
||||
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
|
||||
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
|
||||
FROM (
|
||||
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
|
||||
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 5, 2, '', '', '', '', '', '', '', 0.00
|
||||
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
|
||||
UNION ALL SELECT 7, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 8, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 9, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 10, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 11, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 12, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
|
||||
UNION ALL SELECT 13, 2, '', '', '', '', '', '', '', 0.00
|
||||
) d
|
||||
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'big_small_week' AND delete_time IS NULL LIMIT 1) tpl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
|
||||
WHERE x.template_id = tpl.id AND x.day_index = d.idx
|
||||
);
|
||||
|
||||
-- 4. 月休 4 天:每月固定休 4 天,优先安排在周日(周末不足时从月末补齐)
|
||||
INSERT INTO yz_platform_sched_tpl
|
||||
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
|
||||
SELECT 'month_4_rest', '月休 4 天', 4, 3, 0, 0, 4, 7, '每月固定休 4 天,优先安排周日,遇 5 个周日只休前 4 个', 1, 1, 40, 'system'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'month_4_rest' AND delete_time IS NULL) t);
|
||||
|
||||
INSERT INTO yz_platform_sched_tpl_day
|
||||
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
|
||||
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
|
||||
FROM (
|
||||
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
|
||||
) d
|
||||
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'month_4_rest' AND delete_time IS NULL LIMIT 1) tpl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
|
||||
WHERE x.template_id = tpl.id AND x.day_index = d.idx
|
||||
);
|
||||
|
||||
-- 5. 三班倒:早班 -> 中班 -> 夜班,各 1 天循环
|
||||
INSERT INTO yz_platform_sched_tpl
|
||||
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
|
||||
SELECT 'three_shift', '三班倒', 5, 2, 3, 0, 0, 0, '早班 08:00-16:00 → 中班 16:00-24:00 → 夜班 00:00-08:00,3 天循环', 1, 1, 50, 'system'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'three_shift' AND delete_time IS NULL) t);
|
||||
|
||||
INSERT INTO yz_platform_sched_tpl_day
|
||||
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
|
||||
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
|
||||
FROM (
|
||||
SELECT 0 AS idx, 1 AS day_type, '早班' AS shift_name, '早' AS shift_short, '#67C23A' AS color, '08:00' AS work_start, '16:00' AS work_end, '' AS rest_start, '' AS rest_end, 8.00 AS work_hours
|
||||
UNION ALL SELECT 1, 1, '中班', '中', '#E6A23C', '16:00', '24:00', '', '', 8.00
|
||||
UNION ALL SELECT 2, 1, '夜班', '夜', '#909399', '00:00', '08:00', '', '', 8.00
|
||||
) d
|
||||
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'three_shift' AND delete_time IS NULL LIMIT 1) tpl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
|
||||
WHERE x.template_id = tpl.id AND x.day_index = d.idx
|
||||
);
|
||||
|
||||
-- 6. 做一休一:上 1 天休 1 天
|
||||
INSERT INTO yz_platform_sched_tpl
|
||||
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
|
||||
SELECT 'work_one_rest_one', '做一休一', 6, 2, 2, 0, 0, 0, '上 1 天休 1 天循环(09:00-18:00)', 1, 1, 60, 'system'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'work_one_rest_one' AND delete_time IS NULL) t);
|
||||
|
||||
INSERT INTO yz_platform_sched_tpl_day
|
||||
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
|
||||
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
|
||||
FROM (
|
||||
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
|
||||
UNION ALL SELECT 1, 2, '', '', '', '', '', '', '', 0.00
|
||||
) d
|
||||
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'work_one_rest_one' AND delete_time IS NULL LIMIT 1) tpl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
|
||||
WHERE x.template_id = tpl.id AND x.day_index = d.idx
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 结果校验:6 行,day_count 分别为 7 / 7 / 14 / 1 / 3 / 2
|
||||
-- ============================================================
|
||||
SELECT t.id, t.code, t.name, t.template_type, t.align_mode, t.cycle_days,
|
||||
t.month_rest_days, t.month_rest_weekday, t.is_system, t.status, t.sort,
|
||||
COUNT(d.id) AS day_count
|
||||
FROM yz_platform_sched_tpl t
|
||||
LEFT JOIN yz_platform_sched_tpl_day d ON d.template_id = t.id
|
||||
WHERE t.delete_time IS NULL
|
||||
GROUP BY t.id
|
||||
ORDER BY t.sort;
|
||||
@@ -0,0 +1,94 @@
|
||||
-- ============================================================
|
||||
-- OA 工作排班 —— 菜单配置(yz_system_menu)
|
||||
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_scheduling_menus.sql
|
||||
--
|
||||
-- 挂载位置:OA 应用根(path = '/apps/oa')
|
||||
-- OA
|
||||
-- └─ 排班考勤(目录,/apps/oa/scheduling)
|
||||
-- └─ 工作排班(页面,/apps/oa/scheduling/board)
|
||||
--
|
||||
-- 对应页面:backend/src/views/apps/oa/scheduling/index.vue
|
||||
-- 同一页面内完成:班次管理、排班方案(公司/部门/小组/指定员工)、
|
||||
-- 周看板、单个或批量员工的临时调配、变更记录查看。
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 排班看板是 Web 管理端功能,platforms 显式为 ["web"],
|
||||
-- 移动端菜单不出现(后续若开放员工查看排班,再补 APP 端页面)。
|
||||
-- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
|
||||
-- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
|
||||
-- 因此本脚本同时把两个菜单 ID 追加到角色 rights。
|
||||
-- 目录与页面都必须授权,缺一不可。
|
||||
-- ============================================================
|
||||
|
||||
SET @oa_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 排班考勤(目录) ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||
SELECT @oa_id, '排班考勤', '/apps/oa/scheduling', NULL,
|
||||
'Calendar', 88, 1, 1, '[2]', '["web"]', 1, 'system',
|
||||
'按公司、部门、小组或指定员工制定排班方案,支持临时调配'
|
||||
FROM DUAL
|
||||
WHERE @oa_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL) x);
|
||||
|
||||
SET @scheduling_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 工作排班(页面) ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||
SELECT @scheduling_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
|
||||
'Clock', 1, 1, 1, '[2]', '["web"]', 2, 'system',
|
||||
'排班看板:按周查看排班,支持方案发布、单条调班与批量临时排班'
|
||||
FROM DUAL
|
||||
WHERE @scheduling_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL) x);
|
||||
|
||||
SET @board_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
|
||||
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
|
||||
|
||||
UPDATE yz_system_admin_role
|
||||
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @scheduling_id) AS CHAR)
|
||||
WHERE cid = 2
|
||||
AND rights IS NOT NULL
|
||||
AND TRIM(rights) <> ''
|
||||
AND JSON_VALID(rights)
|
||||
AND @scheduling_id IS NOT NULL
|
||||
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON));
|
||||
|
||||
UPDATE yz_system_admin_role
|
||||
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
|
||||
WHERE cid = 2
|
||||
AND rights IS NOT NULL
|
||||
AND TRIM(rights) <> ''
|
||||
AND JSON_VALID(rights)
|
||||
AND @board_id IS NOT NULL
|
||||
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
|
||||
|
||||
-- ---------- 结果校验 ----------
|
||||
SELECT id, pid, title, path, component_path, platforms, type, sort
|
||||
FROM yz_system_menu
|
||||
WHERE delete_time IS NULL AND path LIKE '/apps/oa/scheduling%'
|
||||
ORDER BY pid, sort;
|
||||
|
||||
SELECT id, name, tenant_id,
|
||||
JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON)) AS has_dir,
|
||||
JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON)) AS has_board
|
||||
FROM yz_system_admin_role
|
||||
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
|
||||
ORDER BY tenant_id, id;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- ============================================================
|
||||
-- 排班模板 —— 平台端菜单(yz_system_menu)
|
||||
--
|
||||
-- 挂载位置:平台端「基础设置」(path = /basicSettings)下,与「字典管理」并列。
|
||||
-- 基础设置
|
||||
-- └─ 排班模板(页面,/basicSettings/schedTemplate)
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. views = [1]:只在平台端出现;租户端通过 /backend/oa/scheduling/templates
|
||||
-- 读取模板(不在租户菜单里暴露模板管理入口)。
|
||||
-- 2. 平台端菜单接口(/platform/menu/:id)按 views 过滤,不按角色 rights 过滤,
|
||||
-- 因此本脚本无需追加角色授权。
|
||||
-- 3. 幂等:按 path 判重,重复执行不会产生重复菜单。
|
||||
-- 4. 本脚本不使用会话变量 / PREPARE,可整段执行也可逐条执行。
|
||||
-- ============================================================
|
||||
|
||||
-- 1) 有「基础设置」目录时,挂到该目录下
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT
|
||||
(SELECT id FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL ORDER BY id LIMIT 1) b),
|
||||
'排班模板', '/basicSettings/schedTemplate', '/system/schedTemplate/index.vue',
|
||||
'Calendar', 90, 1, 1, '[1]', 2, 'system',
|
||||
'全局排班模板:预置双休/单休/大小周/月休/三班倒/做一休一,租户调用后存入自己的方案'
|
||||
FROM DUAL
|
||||
WHERE EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL) b)
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/schedTemplate' AND delete_time IS NULL) e);
|
||||
|
||||
-- 2) 未找到「基础设置」目录时,退化为平台端顶级菜单(pid=0)
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT 0, '排班模板', '/basicSettings/schedTemplate', '/system/schedTemplate/index.vue',
|
||||
'Calendar', 92, 1, 1, '[1]', 2, 'system',
|
||||
'全局排班模板:预置双休/单休/大小周/月休/三班倒/做一休一,租户调用后存入自己的方案'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL) b)
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/schedTemplate' AND delete_time IS NULL) e);
|
||||
|
||||
-- 结果校验:应返回 1 行
|
||||
SELECT id, pid, title, path, component_path, views, type, is_visible, status
|
||||
FROM yz_system_menu
|
||||
WHERE delete_time IS NULL AND path = '/basicSettings/schedTemplate';
|
||||
@@ -190,6 +190,14 @@ func Init(_ string) {
|
||||
new(BackendScheduleReminderSendLog),
|
||||
|
||||
new(OaSchedule),
|
||||
// 平台端排班模板(docs/sql/create_platform_sched_template.sql)
|
||||
new(PlatformSchedTpl),
|
||||
new(PlatformSchedTplDay),
|
||||
// 工作排班(docs/sql/create_oa_scheduling.sql)
|
||||
new(OaShift),
|
||||
new(OaSchedulePlan),
|
||||
new(OaScheduleEntry),
|
||||
new(OaScheduleChange),
|
||||
new(OaHoliday),
|
||||
new(OaNotice),
|
||||
// 工作日报(docs/sql/create_oa_dailyreport.sql)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// OaShift OA 工作排班-班次定义表 yz_backend_oa_shift
|
||||
//
|
||||
// 班次是排班的最小单位,描述一段可被复用的工作时间(如早班 09:00-18:00)。
|
||||
// 字段中的时间均为 "HH:mm" 字符串,休息时段可为空表示不区分午休。
|
||||
type OaShift struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid int `orm:"column(tid)" json:"tid"`
|
||||
Name string `orm:"column(name);size(50)" json:"name"`
|
||||
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
|
||||
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
|
||||
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
|
||||
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
|
||||
RestStart string `orm:"column(rest_start);size(8);default()" json:"rest_start"`
|
||||
RestEnd string `orm:"column(rest_end);size(8);default()" json:"rest_end"`
|
||||
WorkHours float64 `orm:"column(work_hours);type(decimal);digits(5);decimals(2);default(0)" json:"work_hours"`
|
||||
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||
Remark *string `orm:"column(remark);size(255);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);auto_now;type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *OaShift) TableName() string {
|
||||
return "yz_backend_oa_shift"
|
||||
}
|
||||
|
||||
// 排班方案类型(plan_type)
|
||||
const (
|
||||
OaPlanTypeRegular int8 = 1 // 常规排班:按组织范围周期性下发
|
||||
OaPlanTypeTemp int8 = 2 // 临时方案:针对单个/多个员工的短期安排
|
||||
)
|
||||
|
||||
// 排班作用范围(scope_type)
|
||||
const (
|
||||
OaScopeCompany int8 = 1 // 整个公司
|
||||
OaScopeOrg int8 = 2 // 部门 / 小组(可含下级组织)
|
||||
OaScopeEmployees int8 = 3 // 指定员工(一人或多人)
|
||||
)
|
||||
|
||||
// OaSchedulePlan 排班方案 yz_backend_oa_schedule_plan
|
||||
//
|
||||
// 方案只描述“谁、在什么时间、上什么班”的规则,发布后由后端展开成
|
||||
// yz_backend_oa_schedule_entry 明细(每人每天一条),这样看板查询与
|
||||
// 临时调配都只操作明细表,方案本身可以随时停用而不影响已下发的排班。
|
||||
//
|
||||
// 两种排班方式:
|
||||
// - 按星期(AlignMode=0):Weekdays + ShiftID,一人一天一个固定班次;
|
||||
// - 按模板(AlignMode=1/2/3):Pattern 存周期的每日规则快照,
|
||||
// 发布时按自然周 / 开始日滚动 / 自然月推算每天是否上班、上哪个班次。
|
||||
// 模板来自平台端(yz_platform_sched_tpl),调用后即固化到本方案,
|
||||
// 之后模板调整与方案无关。
|
||||
type OaSchedulePlan struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid int `orm:"column(tid)" json:"tid"`
|
||||
PlanName string `orm:"column(plan_name);size(100)" json:"plan_name"`
|
||||
PlanType int8 `orm:"column(plan_type);default(1)" json:"plan_type"`
|
||||
ScopeType int8 `orm:"column(scope_type);default(2)" json:"scope_type"`
|
||||
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
|
||||
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
|
||||
IncludeChild int8 `orm:"column(include_child);default(1)" json:"include_child"`
|
||||
EmployeeIDs string `orm:"column(employee_ids);type(text);null" json:"employee_ids"`
|
||||
EmployeeNames string `orm:"column(employee_names);type(text);null" json:"employee_names"`
|
||||
StartDate string `orm:"column(start_date);type(date)" json:"start_date"`
|
||||
EndDate string `orm:"column(end_date);type(date)" json:"end_date"`
|
||||
Weekdays string `orm:"column(weekdays);size(32);default()" json:"weekdays"`
|
||||
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
|
||||
// 模板快照与周期规则(平台模板调用后固化到方案,见 models/sched_template.go)
|
||||
TemplateID uint64 `orm:"column(template_id);default(0)" json:"template_id"`
|
||||
TemplateCode string `orm:"column(template_code);size(50);default()" json:"template_code"`
|
||||
TemplateName string `orm:"column(template_name);size(50);default()" json:"template_name"`
|
||||
AlignMode int8 `orm:"column(align_mode);default(0)" json:"align_mode"`
|
||||
CycleDays int `orm:"column(cycle_days);default(0)" json:"cycle_days"`
|
||||
WeekOffset int8 `orm:"column(week_offset);default(0)" json:"week_offset"`
|
||||
MonthRestDays int8 `orm:"column(month_rest_days);default(0)" json:"month_rest_days"`
|
||||
MonthRestWeekday int8 `orm:"column(month_rest_weekday);default(0)" json:"month_rest_weekday"`
|
||||
Pattern string `orm:"column(pattern);type(text);null" json:"pattern"`
|
||||
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
|
||||
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
|
||||
Note string `orm:"column(note);size(500);default()" json:"note"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||
PublishStatus int8 `orm:"column(publish_status);default(0)" json:"publish_status"`
|
||||
EntryCount int `orm:"column(entry_count);default(0)" json:"entry_count"`
|
||||
CreatedBy uint64 `orm:"column(created_by);default(0)" json:"created_by"`
|
||||
CreatedByName string `orm:"column(created_by_name);size(64);default()" json:"created_by_name"`
|
||||
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);auto_now;type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *OaSchedulePlan) TableName() string {
|
||||
return "yz_backend_oa_schedule_plan"
|
||||
}
|
||||
|
||||
// 排班明细来源(source)
|
||||
const (
|
||||
OaEntrySourcePlan int8 = 1 // 由排班方案发布生成
|
||||
OaEntrySourceTemp int8 = 2 // 临时调配 / 单人多人手工安排
|
||||
)
|
||||
|
||||
// OaScheduleEntry 排班明细 yz_backend_oa_schedule_entry
|
||||
//
|
||||
// 一人一天一条有效记录:唯一键 (tid, employee_id, schedule_date, is_deleted)。
|
||||
// 删除时把 is_deleted 写成记录 ID,既保留历史痕迹,又避免同一天重新排班
|
||||
// 与软删记录冲突(与 OA 工作日报的处理方式一致)。
|
||||
type OaScheduleEntry struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid int `orm:"column(tid)" json:"tid"`
|
||||
PlanID uint64 `orm:"column(plan_id);default(0)" json:"plan_id"`
|
||||
EmployeeID uint `orm:"column(employee_id)" json:"employee_id"`
|
||||
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
|
||||
EmployeeAccount string `orm:"column(employee_account);size(50);default()" json:"employee_account"`
|
||||
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
|
||||
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
|
||||
Position string `orm:"column(position);size(50);default()" json:"position"`
|
||||
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
|
||||
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
|
||||
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
|
||||
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
|
||||
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
|
||||
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
|
||||
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
|
||||
Source int8 `orm:"column(source);default(1)" json:"source"`
|
||||
Note string `orm:"column(note);size(255);default()" json:"note"`
|
||||
CreatedBy uint64 `orm:"column(created_by);default(0)" json:"created_by"`
|
||||
CreatedByName string `orm:"column(created_by_name);size(64);default()" json:"created_by_name"`
|
||||
IsDeleted uint64 `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);auto_now;type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *OaScheduleEntry) TableName() string {
|
||||
return "yz_backend_oa_schedule_entry"
|
||||
}
|
||||
|
||||
// 排班变更类型(change_type)
|
||||
const (
|
||||
OaChangeShift int8 = 1 // 调班:换班次
|
||||
OaChangeClear int8 = 2 // 清除排班
|
||||
OaChangeManual int8 = 3 // 手工新增排班
|
||||
OaChangePublish int8 = 4 // 方案发布覆盖
|
||||
)
|
||||
|
||||
// OaScheduleChange 排班变更留痕 yz_backend_oa_schedule_change
|
||||
//
|
||||
// 只记录“人 + 日期 + 原班次 -> 新班次”的关键信息,用于回答
|
||||
// “这天谁被临时调班了、为什么调”,不做完整审计日志。
|
||||
type OaScheduleChange struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid int `orm:"column(tid)" json:"tid"`
|
||||
EntryID uint64 `orm:"column(entry_id);default(0)" json:"entry_id"`
|
||||
EmployeeID uint `orm:"column(employee_id)" json:"employee_id"`
|
||||
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
|
||||
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
|
||||
ChangeType int8 `orm:"column(change_type);default(1)" json:"change_type"`
|
||||
OldShiftID uint64 `orm:"column(old_shift_id);default(0)" json:"old_shift_id"`
|
||||
OldShiftName string `orm:"column(old_shift_name);size(50);default()" json:"old_shift_name"`
|
||||
NewShiftID uint64 `orm:"column(new_shift_id);default(0)" json:"new_shift_id"`
|
||||
NewShiftName string `orm:"column(new_shift_name);size(50);default()" json:"new_shift_name"`
|
||||
Reason string `orm:"column(reason);size(255);default()" json:"reason"`
|
||||
OperatorID uint64 `orm:"column(operator_id);default(0)" json:"operator_id"`
|
||||
OperatorName string `orm:"column(operator_name);size(64);default()" json:"operator_name"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
}
|
||||
|
||||
func (m *OaScheduleChange) TableName() string {
|
||||
return "yz_backend_oa_schedule_change"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// 排班模板对齐方式(align_mode)
|
||||
//
|
||||
// 1 按自然周:双休 / 单休 / 大小周,day_index 0=周一 ... 6=周日
|
||||
// 2 按开始日滚动:三班倒 / 做一休一,从方案开始日期起按周期顺延
|
||||
// 3 按自然月:月休,按月计算休息日
|
||||
const (
|
||||
SchedTplAlignWeek int8 = 1
|
||||
SchedTplAlignRoll int8 = 2
|
||||
SchedTplAlignMonth int8 = 3
|
||||
)
|
||||
|
||||
// 排班模板每天类型(day_type)
|
||||
const (
|
||||
SchedTplDayWork int8 = 1 // 上班
|
||||
SchedTplDayRest int8 = 2 // 休息
|
||||
)
|
||||
|
||||
// 排班模板类型(template_type)
|
||||
const (
|
||||
SchedTplTypeDoubleWeekend int8 = 1 // 双休
|
||||
SchedTplTypeSingleWeekend int8 = 2 // 单休
|
||||
SchedTplTypeBigSmallWeek int8 = 3 // 大小周
|
||||
SchedTplTypeMonthRest int8 = 4 // 月休
|
||||
SchedTplTypeThreeShift int8 = 5 // 三班倒
|
||||
SchedTplTypeWorkRestOne int8 = 6 // 做一休一
|
||||
SchedTplTypeCustom int8 = 9 // 自定义
|
||||
)
|
||||
|
||||
// PlatformSchedTpl 平台端排班模板 yz_platform_sched_tpl
|
||||
//
|
||||
// 模板是全局数据(不按租户隔离),只有平台端可维护;系统预置模板
|
||||
// (is_system=1)由平台锁定,禁止编辑 / 删除 / 停用。租户端调用模板时
|
||||
// 会把规则快照进自己的排班方案(OaSchedulePlan.Pattern),之后互不影响。
|
||||
type PlatformSchedTpl struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Code string `orm:"column(code);size(50);default()" json:"code"`
|
||||
Name string `orm:"column(name);size(50)" json:"name"`
|
||||
TemplateType int8 `orm:"column(template_type);default(9)" json:"template_type"`
|
||||
AlignMode int8 `orm:"column(align_mode);default(1)" json:"align_mode"`
|
||||
CycleDays int `orm:"column(cycle_days);default(7)" json:"cycle_days"`
|
||||
WeekOffset int8 `orm:"column(week_offset);default(0)" json:"week_offset"`
|
||||
MonthRestDays int8 `orm:"column(month_rest_days);default(0)" json:"month_rest_days"`
|
||||
MonthRestWeekday int8 `orm:"column(month_rest_weekday);default(0)" json:"month_rest_weekday"`
|
||||
Summary string `orm:"column(summary);size(255);default()" json:"summary"`
|
||||
IsSystem int8 `orm:"column(is_system);default(0)" json:"is_system"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||
Creater string `orm:"column(creater);size(50);default()" json:"creater"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *PlatformSchedTpl) TableName() string {
|
||||
return "yz_platform_sched_tpl"
|
||||
}
|
||||
|
||||
// PlatformSchedTplDay 平台端排班模板的周期每日规则 yz_platform_sched_tpl_day
|
||||
//
|
||||
// day_index 为周期内第几天(从 0 开始)。day_type=2 表示休息,
|
||||
// 此时班次字段为空;day_type=1 时填写班次名称与上下班时间。
|
||||
type PlatformSchedTplDay struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TemplateID uint64 `orm:"column(template_id);default(0)" json:"template_id"`
|
||||
DayIndex int `orm:"column(day_index);default(0)" json:"day_index"`
|
||||
DayType int8 `orm:"column(day_type);default(1)" json:"day_type"`
|
||||
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
|
||||
ShiftShort string `orm:"column(shift_short);size(20);default()" json:"shift_short"`
|
||||
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
|
||||
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
|
||||
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
|
||||
RestStart string `orm:"column(rest_start);size(8);default()" json:"rest_start"`
|
||||
RestEnd string `orm:"column(rest_end);size(8);default()" json:"rest_end"`
|
||||
WorkHours float64 `orm:"column(work_hours);type(decimal);digits(5);decimals(2);default(0)" json:"work_hours"`
|
||||
Remark string `orm:"column(remark);size(100);default()" json:"remark"`
|
||||
}
|
||||
|
||||
func (m *PlatformSchedTplDay) TableName() string {
|
||||
return "yz_platform_sched_tpl_day"
|
||||
}
|
||||
@@ -334,6 +334,25 @@ func RegisterAuthRoutes() {
|
||||
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/scheduling/shifts", &controllers.BackendOaSchedulingController{}, "get:ShiftList;post:CreateShift")
|
||||
beego.Router("/backend/oa/scheduling/shifts/:id", &controllers.BackendOaSchedulingController{}, "post:UpdateShift;delete:DeleteShift")
|
||||
beego.Router("/backend/oa/scheduling/employees", &controllers.BackendOaSchedulingController{}, "get:ScheduleEmployeeOptions")
|
||||
beego.Router("/backend/oa/scheduling/board", &controllers.BackendOaSchedulingController{}, "get:Board")
|
||||
beego.Router("/backend/oa/scheduling/plans", &controllers.BackendOaSchedulingController{}, "get:PlanList")
|
||||
beego.Router("/backend/oa/scheduling/templates", &controllers.BackendOaSchedulingController{}, "get:TemplateList")
|
||||
beego.Router("/backend/oa/scheduling/templates/apply", &controllers.BackendOaSchedulingController{}, "post:TemplateApply")
|
||||
beego.Router("/backend/oa/scheduling/plans/create", &controllers.BackendOaSchedulingController{}, "post:PlanCreate")
|
||||
beego.Router("/backend/oa/scheduling/plans/preview", &controllers.BackendOaSchedulingController{}, "post:PlanPreview")
|
||||
beego.Router("/backend/oa/scheduling/plans/detail/:id", &controllers.BackendOaSchedulingController{}, "get:PlanDetail")
|
||||
beego.Router("/backend/oa/scheduling/plans/update/:id", &controllers.BackendOaSchedulingController{}, "post:PlanUpdate")
|
||||
beego.Router("/backend/oa/scheduling/plans/publish/:id", &controllers.BackendOaSchedulingController{}, "post:PlanPublish")
|
||||
beego.Router("/backend/oa/scheduling/plans/delete/:id", &controllers.BackendOaSchedulingController{}, "delete:PlanDelete")
|
||||
beego.Router("/backend/oa/scheduling/entries/save", &controllers.BackendOaSchedulingController{}, "post:SaveEntries")
|
||||
beego.Router("/backend/oa/scheduling/entries/swap/:id", &controllers.BackendOaSchedulingController{}, "post:SwapEntry")
|
||||
beego.Router("/backend/oa/scheduling/entries/clear", &controllers.BackendOaSchedulingController{}, "post:ClearEntries")
|
||||
beego.Router("/backend/oa/scheduling/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
|
||||
|
||||
// OA日程管理(日历待办)
|
||||
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
|
||||
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
|
||||
|
||||
@@ -404,6 +404,12 @@ func Register() {
|
||||
beego.Router("/platform/loginBrand", &controllers.PlatformWebsiteSettingController{}, "get:LoginBrandInfo")
|
||||
beego.Router("/platform/saveLoginBrand", &controllers.PlatformWebsiteSettingController{}, "post:SaveLoginBrand")
|
||||
|
||||
// 平台端排班模板(全局模板;系统预置只读,只有平台可维护)
|
||||
// 注意:带 /status 的固定路径先于 /:id 注册
|
||||
beego.Router("/platform/schedTemplates", &controllers.PlatformSchedTemplateController{}, "get:List;post:Create")
|
||||
beego.Router("/platform/schedTemplates/:id/status", &controllers.PlatformSchedTemplateController{}, "post:ToggleStatus")
|
||||
beego.Router("/platform/schedTemplates/:id", &controllers.PlatformSchedTemplateController{}, "get:Detail;post:Update;delete:Delete")
|
||||
|
||||
// ===== 平台官网管理(docs/sql/create_platform_website.sql)=====
|
||||
// 文章管理 yz_platform_website_article
|
||||
beego.Router("/platform/website/article/list", &controllers.PlatformWebsiteArticleController{}, "get:List")
|
||||
|
||||
Vendored
+1
@@ -28,6 +28,7 @@ declare module 'vue' {
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 平台端排班模板管理(全局模板,只有平台可维护)
|
||||
// 系统预置模板只读:后端会拒绝对 is_system=1 的编辑 / 删除 / 停用
|
||||
const PREFIX = "/platform/schedTemplates";
|
||||
|
||||
export function getSchedTemplateList(params) {
|
||||
return request({
|
||||
url: PREFIX,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSchedTemplateDetail(id) {
|
||||
return request({
|
||||
url: `${PREFIX}/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function createSchedTemplate(data) {
|
||||
return request({
|
||||
url: PREFIX,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSchedTemplate(id, data) {
|
||||
return request({
|
||||
url: `${PREFIX}/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSchedTemplate(id) {
|
||||
return request({
|
||||
url: `${PREFIX}/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleSchedTemplateStatus(id, status) {
|
||||
return request({
|
||||
url: `${PREFIX}/${id}/status`,
|
||||
method: "post",
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
@@ -73,6 +73,13 @@ const staticMainChildren = [
|
||||
component: () => import("@/views/template/tags.vue"),
|
||||
meta: { requiresAuth: true, title: "标签调用说明" }
|
||||
},
|
||||
// 排班模板(全局模板管理,只有平台可维护)
|
||||
{
|
||||
path: "/basicSettings/schedTemplate",
|
||||
name: "PlatformSchedTemplate",
|
||||
component: () => import("@/views/system/schedTemplate/index.vue"),
|
||||
meta: { requiresAuth: true, title: "排班模板" }
|
||||
},
|
||||
// 兼容拼写错误的路径重定向
|
||||
{
|
||||
path: "/apps/erp/dashborad",
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="960px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<el-form :model="form" label-width="110px" :disabled="readonly">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模板名称" required>
|
||||
<el-input v-model="form.name" maxlength="50" placeholder="如:项目部大小周" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模板类型">
|
||||
<el-select v-model="form.template_type" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:label="item.label"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排班方式" required>
|
||||
<el-radio-group v-model="form.align_mode">
|
||||
<el-radio :value="1">按自然周</el-radio>
|
||||
<el-radio :value="2">按开始日滚动</el-radio>
|
||||
<el-radio :value="3">按自然月</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
|
||||
<span class="tip">数字越小越靠前</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col v-if="form.align_mode !== 3" :span="12">
|
||||
<el-form-item label="周期天数" required>
|
||||
<el-select v-if="form.align_mode === 1" v-model="form.cycle_days" style="width: 100%">
|
||||
<el-option :value="7" label="1 周(7 天)" />
|
||||
<el-option :value="14" label="2 周(14 天,大小周常用)" />
|
||||
<el-option :value="21" label="3 周(21 天)" />
|
||||
<el-option :value="28" label="4 周(28 天)" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="form.cycle_days"
|
||||
:min="2"
|
||||
:max="31"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.align_mode === 1" :span="12">
|
||||
<el-form-item label="起始周">
|
||||
<el-select v-model="form.week_offset" style="width: 100%">
|
||||
<el-option :value="0" label="方案开始周按第 1 周算" />
|
||||
<el-option v-if="form.cycle_days > 7" :value="1" label="方案开始周按第 2 周算" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.align_mode === 3" :span="12">
|
||||
<el-form-item label="每月休息天数" required>
|
||||
<el-input-number
|
||||
v-model="form.month_rest_days"
|
||||
:min="1"
|
||||
:max="15"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="tip">天</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.align_mode === 3" :span="12">
|
||||
<el-form-item label="休息日安排">
|
||||
<el-select v-model="form.month_rest_weekday" style="width: 100%">
|
||||
<el-option :value="0" label="不固定(从月末往前排)" />
|
||||
<el-option v-for="item in weekdayOptions" :key="item.value" :value="item.value" :label="`优先休${item.label}`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="规则摘要">
|
||||
<el-input
|
||||
v-model="form.summary"
|
||||
maxlength="255"
|
||||
placeholder="留空由系统自动生成,用于租户端列表展示"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="rules-head">
|
||||
<div class="label">
|
||||
{{ form.align_mode === 3 ? "上班日规则" : "周期每日规则" }}
|
||||
</div>
|
||||
<div class="hint" v-if="form.align_mode === 1">
|
||||
序号按自然周固定:周一 → 周日;多周循环时第 2 周从第 8 天开始
|
||||
</div>
|
||||
<div class="hint" v-else-if="form.align_mode === 2">
|
||||
从方案开始日期起,按顺序循环使用下面 {{ form.cycle_days }} 天的规则
|
||||
</div>
|
||||
<div class="hint" v-else>
|
||||
除每月固定休息日外,其余日期都按下面这一天的班次上班
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="form.days" border size="small" :disabled="readonly">
|
||||
<el-table-column label="天数" width="120">
|
||||
<template #default="{ row, $index }">{{ dayLabel($index, row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-radio-group v-model="row.day_type" size="small" :disabled="readonly">
|
||||
<el-radio-button :value="1">上班</el-radio-button>
|
||||
<el-radio-button :value="2">休息</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班次名称" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.shift_name"
|
||||
size="small"
|
||||
maxlength="50"
|
||||
:disabled="readonly || row.day_type === 2"
|
||||
placeholder="如:白班"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="简称" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.shift_short"
|
||||
size="small"
|
||||
maxlength="20"
|
||||
:disabled="readonly || row.day_type === 2"
|
||||
placeholder="白"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上班时间" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.work_start"
|
||||
size="small"
|
||||
placeholder="09:00"
|
||||
:disabled="readonly || row.day_type === 2"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下班时间" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.work_end"
|
||||
size="small"
|
||||
placeholder="18:00"
|
||||
:disabled="readonly || row.day_type === 2"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="休息开始" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.rest_start"
|
||||
size="small"
|
||||
placeholder="12:00"
|
||||
:disabled="readonly || row.day_type === 2"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="休息结束" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.rest_end"
|
||||
size="small"
|
||||
placeholder="13:30"
|
||||
:disabled="readonly || row.day_type === 2"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="颜色" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-color-picker
|
||||
v-model="row.color"
|
||||
size="small"
|
||||
:disabled="readonly || row.day_type === 2"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ readonly ? "关闭" : "取消" }}</el-button>
|
||||
<el-button v-if="!readonly" type="primary" :loading="submitting" @click="submit">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from "vue";
|
||||
import {
|
||||
getSchedTemplateDetail,
|
||||
createSchedTemplate,
|
||||
updateSchedTemplate,
|
||||
} from "@/api/schedTemplate";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
templateId: { type: Number, default: 0 },
|
||||
readonly: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 1, label: "双休" },
|
||||
{ value: 2, label: "单休" },
|
||||
{ value: 3, label: "大小周" },
|
||||
{ value: 4, label: "月休" },
|
||||
{ value: 5, label: "三班倒" },
|
||||
{ value: 6, label: "做一休一" },
|
||||
{ value: 9, label: "自定义" },
|
||||
];
|
||||
const weekdayOptions = [
|
||||
{ value: 1, label: "周一" },
|
||||
{ value: 2, label: "周二" },
|
||||
{ value: 3, label: "周三" },
|
||||
{ value: 4, label: "周四" },
|
||||
{ value: 5, label: "周五" },
|
||||
{ value: 6, label: "周六" },
|
||||
{ value: 7, label: "周日" },
|
||||
];
|
||||
const weekdayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
// 回填详情时避免 watch 把已加载的规则重新重建
|
||||
let skipSync = false;
|
||||
|
||||
function blankDay(index) {
|
||||
return {
|
||||
day_index: index,
|
||||
day_type: 1,
|
||||
shift_name: "白班",
|
||||
shift_short: "白",
|
||||
color: "#409EFF",
|
||||
work_start: "09:00",
|
||||
work_end: "18:00",
|
||||
rest_start: "12:00",
|
||||
rest_end: "13:30",
|
||||
work_hours: 7.5,
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function restDay(index) {
|
||||
return {
|
||||
day_index: index,
|
||||
day_type: 2,
|
||||
shift_name: "",
|
||||
shift_short: "",
|
||||
color: "",
|
||||
work_start: "",
|
||||
work_end: "",
|
||||
rest_start: "",
|
||||
rest_end: "",
|
||||
work_hours: 0,
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultForm() {
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
days.push(i >= 5 ? restDay(i) : blankDay(i));
|
||||
}
|
||||
return {
|
||||
id: 0,
|
||||
name: "",
|
||||
code: "",
|
||||
template_type: 9,
|
||||
align_mode: 1,
|
||||
cycle_days: 7,
|
||||
week_offset: 0,
|
||||
month_rest_days: 4,
|
||||
month_rest_weekday: 7,
|
||||
summary: "",
|
||||
status: 1,
|
||||
sort: 0,
|
||||
days,
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.readonly) return "查看排班模板";
|
||||
return props.templateId ? "编辑排班模板" : "新建排班模板";
|
||||
});
|
||||
|
||||
function dayLabel(index, row) {
|
||||
if (form.align_mode === 3) return "上班日";
|
||||
if (form.align_mode === 2) return `第 ${index + 1} 天`;
|
||||
const week = Math.floor(index / 7) + 1;
|
||||
const name = weekdayNames[index % 7] || row.day_index;
|
||||
return form.cycle_days > 7 ? `第 ${week} 周 ${name}` : name;
|
||||
}
|
||||
|
||||
// 周期天数或排班方式变化时,保留已填规则并补齐/裁剪天数
|
||||
function syncDays() {
|
||||
const cycle = form.align_mode === 3 ? 1 : Number(form.cycle_days) || 7;
|
||||
const next = [];
|
||||
for (let i = 0; i < cycle; i += 1) {
|
||||
const current = form.days[i];
|
||||
if (current) {
|
||||
next.push({ ...current, day_index: i });
|
||||
} else {
|
||||
next.push(blankDay(i));
|
||||
}
|
||||
}
|
||||
form.days = next;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.cycle_days,
|
||||
() => {
|
||||
if (!props.readonly && !skipSync) syncDays();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.align_mode,
|
||||
(value) => {
|
||||
if (props.readonly || skipSync) return;
|
||||
if (value === 3) {
|
||||
form.month_rest_days = form.month_rest_days || 4;
|
||||
} else if (value === 1 && form.cycle_days % 7 !== 0) {
|
||||
form.cycle_days = 7;
|
||||
}
|
||||
syncDays();
|
||||
}
|
||||
);
|
||||
|
||||
// 月休的休息日安排由后端按月推算,这里只要保证至少有一天的上班规则
|
||||
watch(
|
||||
() => form.modelValue,
|
||||
(open) => {
|
||||
if (open) loadDetail();
|
||||
}
|
||||
);
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.templateId) {
|
||||
skipSync = true;
|
||||
Object.assign(form, defaultForm());
|
||||
await nextTick();
|
||||
skipSync = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSchedTemplateDetail(props.templateId);
|
||||
const data = res?.data || {};
|
||||
skipSync = true;
|
||||
Object.assign(form, {
|
||||
id: data.id || 0,
|
||||
name: data.name || "",
|
||||
code: data.code || "",
|
||||
template_type: data.template_type || 9,
|
||||
align_mode: data.align_mode || 1,
|
||||
cycle_days: data.cycle_days || 7,
|
||||
week_offset: data.week_offset || 0,
|
||||
month_rest_days: data.month_rest_days || 0,
|
||||
month_rest_weekday: data.month_rest_weekday || 0,
|
||||
summary: data.summary || "",
|
||||
status: data.status === 0 ? 0 : 1,
|
||||
sort: data.sort || 0,
|
||||
});
|
||||
form.days = (data.days || []).map((item) => ({
|
||||
day_index: item.day_index,
|
||||
day_type: item.day_type,
|
||||
shift_name: item.shift_name || "",
|
||||
shift_short: item.shift_short || "",
|
||||
color: item.color || "#409EFF",
|
||||
work_start: item.work_start || "",
|
||||
work_end: item.work_end || "",
|
||||
rest_start: item.rest_start || "",
|
||||
rest_end: item.rest_end || "",
|
||||
work_hours: item.work_hours || 0,
|
||||
remark: item.remark || "",
|
||||
}));
|
||||
if (form.days.length === 0) syncDays();
|
||||
await nextTick();
|
||||
skipSync = false;
|
||||
} catch (e) {
|
||||
skipSync = false;
|
||||
ElMessage.error(e?.message || "加载模板详情失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validate() {
|
||||
if (!form.name.trim()) return "请填写模板名称";
|
||||
if (form.align_mode !== 3) {
|
||||
if (!form.cycle_days || form.cycle_days < 2) return "周期天数至少 2 天";
|
||||
if (form.align_mode === 1 && form.cycle_days % 7 !== 0) {
|
||||
return "按自然周排班时,周期天数必须是 7 的倍数";
|
||||
}
|
||||
if (form.days.length !== form.cycle_days) return "每日规则数量与周期天数不一致";
|
||||
} else if (!form.month_rest_days || form.month_rest_days < 1) {
|
||||
return "请填写每月休息天数";
|
||||
}
|
||||
const workDays = form.days.filter((item) => item.day_type === 1);
|
||||
if (workDays.length === 0) return "至少需要一天上班";
|
||||
for (const item of workDays) {
|
||||
if (!String(item.shift_name || "").trim()) return "上班日需要填写班次名称";
|
||||
if (!/^\d{1,2}:\d{2}$/.test(item.work_start) || !/^\d{1,2}:\d{2}$/.test(item.work_end)) {
|
||||
return "上下班时间格式应为 HH:mm";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const message = validate();
|
||||
if (message) {
|
||||
ElMessage.warning(message);
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code || "",
|
||||
template_type: form.template_type,
|
||||
align_mode: form.align_mode,
|
||||
cycle_days: form.align_mode === 3 ? 0 : form.cycle_days,
|
||||
week_offset: form.week_offset,
|
||||
month_rest_days: form.align_mode === 3 ? form.month_rest_days : 0,
|
||||
month_rest_weekday: form.align_mode === 3 ? form.month_rest_weekday : 0,
|
||||
summary: form.summary,
|
||||
status: form.status,
|
||||
sort: form.sort,
|
||||
days: form.days.map((item, index) => ({
|
||||
day_index: index,
|
||||
day_type: item.day_type,
|
||||
shift_name: item.day_type === 1 ? item.shift_name : "",
|
||||
shift_short: item.day_type === 1 ? item.shift_short : "",
|
||||
color: item.day_type === 1 ? item.color || "#409EFF" : "",
|
||||
work_start: item.day_type === 1 ? item.work_start : "",
|
||||
work_end: item.day_type === 1 ? item.work_end : "",
|
||||
rest_start: item.day_type === 1 ? item.rest_start : "",
|
||||
rest_end: item.day_type === 1 ? item.rest_end : "",
|
||||
work_hours: item.day_type === 1 ? item.work_hours : 0,
|
||||
remark: item.remark || "",
|
||||
})),
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (props.templateId) {
|
||||
await updateSchedTemplate(props.templateId, payload);
|
||||
ElMessage.success("模板已更新");
|
||||
} else {
|
||||
await createSchedTemplate(payload);
|
||||
ElMessage.success("模板已创建");
|
||||
}
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
skipSync = true;
|
||||
Object.assign(form, defaultForm());
|
||||
nextTick(() => {
|
||||
skipSync = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rules-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.rules-head .label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rules-head .hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tip {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="sched-template-page">
|
||||
<div class="page-header">
|
||||
<div class="title">排班模板</div>
|
||||
<div class="actions">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索模板名称"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="loadList"
|
||||
@clear="loadList"
|
||||
/>
|
||||
<el-button @click="loadList">查询</el-button>
|
||||
<el-button type="primary" @click="openCreate">新建模板</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="系统预置模板全局生效、只读不可改;租户只能调用,调用后规则会复制到租户自己的方案里,可自由调整"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border>
|
||||
<el-table-column prop="sort" label="排序" width="70" align="center" />
|
||||
<el-table-column label="模板名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.name }}</span>
|
||||
<el-tag v-if="row.is_system === 1" size="small" type="warning" style="margin-left: 6px">
|
||||
预置
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="template_type_text" label="类型" width="100" />
|
||||
<el-table-column label="排班方式" width="190">
|
||||
<template #default="{ row }">{{ alignText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="summary" label="规则摘要" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
||||
{{ row.status === 1 ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creater" label="创建人" width="100" />
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openView(row)">查看</el-button>
|
||||
<el-button v-if="row.editable" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="row.editable"
|
||||
link
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
@click="toggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? "停用" : "启用" }}
|
||||
</el-button>
|
||||
<el-button v-if="row.editable" link type="danger" @click="removeRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadList"
|
||||
@size-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TemplateEdit
|
||||
v-model="dialogVisible"
|
||||
:template-id="currentId"
|
||||
:readonly="readonly"
|
||||
@saved="loadList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import TemplateEdit from "./components/TemplateEdit.vue";
|
||||
import {
|
||||
getSchedTemplateList,
|
||||
deleteSchedTemplate,
|
||||
toggleSchedTemplateStatus,
|
||||
} from "@/api/schedTemplate";
|
||||
|
||||
const keyword = ref("");
|
||||
const list = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const currentId = ref(0);
|
||||
const readonly = ref(false);
|
||||
|
||||
function alignText(row) {
|
||||
if (row.align_mode === 3) {
|
||||
return `按自然月 · 每月休 ${row.month_rest_days || 0} 天`;
|
||||
}
|
||||
if (row.align_mode === 2) {
|
||||
return `每 ${row.cycle_days || 0} 天滚动循环`;
|
||||
}
|
||||
const weeks = Math.max(1, Math.round((row.cycle_days || 7) / 7));
|
||||
return weeks > 1 ? `按自然周 · ${weeks} 周循环` : "按自然周";
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSchedTemplateList({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value,
|
||||
});
|
||||
const data = res?.data || {};
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "加载排班模板失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentId.value = 0;
|
||||
readonly.value = false;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
currentId.value = row.id;
|
||||
readonly.value = false;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openView(row) {
|
||||
currentId.value = row.id;
|
||||
readonly.value = true;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const next = row.status === 1 ? 0 : 1;
|
||||
try {
|
||||
await toggleSchedTemplateStatus(row.id, next);
|
||||
ElMessage.success(next === 1 ? "模板已启用" : "模板已停用");
|
||||
loadList();
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "更新状态失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除模板「${row.name}」?删除后租户将无法再调用该模板,已生成的方案不受影响。`,
|
||||
"删除确认",
|
||||
{ type: "warning" }
|
||||
);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteSchedTemplate(row.id);
|
||||
ElMessage.success("模板已删除");
|
||||
loadList();
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sched-template-page {
|
||||
padding: 16px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.page-header .title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.page-header .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user