feat(employee-hours): sync row copy and time entry improvements

This commit is contained in:
apple 2026-09-29 11:36:04 +08:00
parent eb6fa9d672
commit f6b8065580
7 changed files with 491 additions and 43 deletions

View File

@ -0,0 +1,18 @@
import { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assertEmployeeHoursAccess } from '@/server/services/employee-hours-access.service'
import { employeeHoursRecordService } from '@/server/services/employee-hours-record.service'
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const scope = await assertEmployeeHoursAccess(ctx.user, 'edit')
const body: unknown = await request.json().catch(() => null)
const id = body && typeof body === 'object' && !Array.isArray(body)
&& typeof (body as { id?: unknown }).id === 'string'
? (body as { id: string }).id.trim()
: ''
if (!id) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要复制的员工工时记录', 400, ctx.requestId)
return jsonSuccess({ record: await employeeHoursRecordService.copy(ctx.user, id, scope) }, 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -42,6 +42,11 @@ beforeEach(() => {
totals = { minutes: 0, hours: 0, estimatedScore: 0, approvedScore: 0 }
mockFetch.mockReset().mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === 'DELETE') return reply({ deletedCount: 1 })
if (String(input).endsWith('/records/copy')) {
const { id } = JSON.parse(String(init?.body)) as { id: string }
const source = records.find((item) => item.id === id)!
return reply({ record: { ...source, id: 'hours-copy' } })
}
if (String(input).includes('/records?')) {
const page: EmployeeHoursRecordPage = {
records,
@ -97,12 +102,92 @@ describe('员工工时业务操作列', () => {
}
})
it('点击第二行复制,立即在原行下新增相同内容,并可单独编辑新行', async () => {
const second = { ...record, id: 'hours-2', businessContent: '业务乙', businessNote: '沿用处理说明', attachment: { name: '旧附件.pdf', url: '/api/storage/object?key=old', mimeType: 'application/pdf', size: 100 } }
records = [record, second]
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
config.rules = [{ id: 'rule-2', businessContent: '业务乙', businessCategory: '日常', workFrequency: '每日', initiativeType: '主动', approvedScore: 1, sortOrder: 0 }]
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" accessScope="SELF" />)
await screen.findByText('业务乙')
fireEvent.click(screen.getByRole('button', { name: '复制 花名甲 业务乙' }))
await waitFor(() => expect(screen.getAllByText('业务乙')).toHaveLength(2))
expect(mockFetch).toHaveBeenCalledWith('/api/supply-chain/management/employee-hours/records/copy', expect.objectContaining({ method: 'POST', body: JSON.stringify({ id: 'hours-2' }) }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getAllByText('业务甲')).toHaveLength(1)
const rows = screen.getAllByRole('button', { name: '复制 花名甲 业务乙' }).map((button) => button.closest('tr')!)
expect(rows).toHaveLength(2)
expect(rows[0].nextElementSibling).toBe(rows[1])
expect(rows[1].querySelectorAll('td')).toHaveLength(rows[0].querySelectorAll('td').length)
expect(rows[1].textContent).toBe(rows[0].textContent)
expect(screen.getByText('共 3 条')).toBeInTheDocument()
fireEvent.click(within(rows[1]).getByRole('button', { name: '编辑 花名甲 业务乙' }))
const dialog = screen.getByRole('dialog', { name: '编辑员工工时' })
expect(within(dialog).getByRole('combobox', { name: '业务内容' })).toHaveValue('业务乙')
expect(within(dialog).getByText('业务内容补充说明').parentElement!.querySelector('textarea')).toHaveValue('沿用处理说明')
expect(within(dialog).getByText('旧附件.pdf')).toBeInTheDocument()
expect(dialog.querySelector<HTMLInputElement>('input[type="date"]')).toHaveValue(second.date)
expect(within(dialog).getByLabelText('开始时间日期')).toHaveValue(second.date)
expect(within(dialog).getByRole('textbox', { name: '开始时间' })).toHaveValue('09:00')
})
it('部门账号不能复制他人记录;全公司权限复制时沿用原记录的人员', async () => {
records = [{ ...record, nickname: '花名乙', department: '客服部' }]
employees = [{ id: 'owner-config-2', nickname: '花名乙', department: '客服部' }]
config.rules = [{ id: 'rule-1', businessContent: '业务甲', businessCategory: '日常', workFrequency: '每日', initiativeType: '主动', approvedScore: 1, sortOrder: 0 }]
const { unmount } = render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" accessScope="DEPARTMENT" />)
await screen.findByText('花名乙')
expect(screen.queryByRole('button', { name: '复制 花名乙 业务甲' })).not.toBeInTheDocument()
unmount()
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="管理员" accessScope="ALL" />)
const row = (await screen.findByText('花名乙')).closest('tr')!
fireEvent.click(within(row).getByRole('button', { name: '复制 花名乙 业务甲' }))
await waitFor(() => expect(screen.getAllByText('花名乙')).toHaveLength(2))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('旧业务配置已失效也可直接复制原始快照', async () => {
records = [record]
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
const row = (await screen.findByText('花名甲')).closest('tr')!
fireEvent.click(within(row).getByRole('button', { name: '复制 花名甲 业务甲' }))
await waitFor(() => expect(screen.getAllByText('花名甲')).toHaveLength(2))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('原行在当前页末尾时也立即显示新增副本', async () => {
records = Array.from({ length: 50 }, (_, index) => ({
...record, id: `hours-${index}`, businessContent: `业务-${index}`,
}))
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
await screen.findByText('业务-49')
fireEvent.click(screen.getByRole('button', { name: '复制 花名甲 业务-49' }))
await waitFor(() => expect(screen.getAllByText('业务-49')).toHaveLength(2))
expect(screen.getByText('显示 1 - 51 条,共 51 条')).toBeInTheDocument()
})
it('复制请求失败时不会增加虚假的本地记录', async () => {
records = [record]
const defaultFetch = mockFetch.getMockImplementation()!
mockFetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) =>
String(input).endsWith('/records/copy')
? Promise.resolve({ ok: false, json: async () => ({ success: false, error: { message: '复制失败,请重试' } }) })
: defaultFetch(input, init))
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
await screen.findByText('业务甲')
fireEvent.click(screen.getByRole('button', { name: '复制 花名甲 业务甲' }))
expect(await screen.findByRole('alert')).toHaveTextContent('复制失败,请重试')
expect(screen.getAllByText('业务甲')).toHaveLength(1)
expect(screen.getByText('共 1 条')).toBeInTheDocument()
})
it('只读用户不显示业务操作列', async () => {
records = [record]
render(<EmployeeHoursHoursView canEdit={false} canDownload={false} />)
await screen.findByText('花名甲')
expect(screen.queryByRole('columnheader', { name: /业务操作/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /新增员工工时|编辑 花名甲|删除 花名甲/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /新增员工工时|复制 花名甲|编辑 花名甲|删除 花名甲/ })).not.toBeInTheDocument()
})
it('根据登录账号自动带出花名和人员配置部门', async () => {
@ -115,6 +200,74 @@ describe('员工工时业务操作列', () => {
expect(screen.getByRole('textbox', { name: '部门' })).toHaveValue('运营部')
})
it('开始与结束时间覆盖午休时即时预览扣减后的分钟、小时与预估积分', async () => {
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
await screen.findByText('暂无员工工时记录')
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
fireEvent.change(dialog.querySelector<HTMLInputElement>('input[type="date"]')!, { target: { value: '2026-09-29' } })
const startInput = within(dialog).getByRole('textbox', { name: '开始时间' })
const endInput = within(dialog).getByRole('textbox', { name: '结束时间' })
fireEvent.change(startInput, { target: { value: '900' } })
fireEvent.blur(startInput)
fireEvent.change(endInput, { target: { value: '18:00' } })
expect(startInput).toHaveValue('09:00')
expect(within(dialog).getByLabelText('开始时间日期')).toHaveValue('2026-09-29')
const preview = (label: string) => within(dialog).getByText(label).parentElement!.querySelector('input')
expect(preview('分钟')).toHaveValue('480')
expect(preview('小时')).toHaveValue('8.00')
expect(preview('预估积分')).toHaveValue('4.1600')
})
it('可直接输入 24 小时制时间,并分别设置跨天日期;格式错误时阻止保存', async () => {
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
await screen.findByText('暂无员工工时记录')
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
fireEvent.change(dialog.querySelector<HTMLInputElement>('input[type="date"]')!, { target: { value: '2026-09-29' } })
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
const end = within(dialog).getByRole('textbox', { name: '结束时间' })
fireEvent.change(start, { target: { value: '2330' } })
fireEvent.blur(start)
fireEvent.change(within(dialog).getByLabelText('结束时间日期'), { target: { value: '2026-09-30' } })
fireEvent.change(end, { target: { value: '1:00' } })
fireEvent.blur(end)
expect(start).toHaveValue('23:30')
expect(end).toHaveValue('01:00')
expect(within(dialog).getByText('分钟').parentElement!.querySelector('input')).toHaveValue('90')
fireEvent.change(end, { target: { value: '25:00' } })
expect(end).toHaveAttribute('aria-invalid', 'true')
expect(within(dialog).getByText(/请输入有效时间/)).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: '保存' })).toBeDisabled()
})
it('快捷输入规范化后按原接口格式保存,并在调整工作日期时更新同日时间', async () => {
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
config.rules = [{ id: 'rule-1', businessContent: '业务甲', businessCategory: '日常', workFrequency: '每日', initiativeType: '主动', approvedScore: 1, sortOrder: 0 }]
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
await screen.findByText('暂无员工工时记录')
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
fireEvent.focus(within(dialog).getByRole('combobox', { name: '业务内容' }))
fireEvent.click(within(screen.getByRole('listbox', { name: '业务内容选项' })).getByRole('option'))
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
const end = within(dialog).getByRole('textbox', { name: '结束时间' })
fireEvent.change(start, { target: { value: '930' } })
fireEvent.blur(start)
fireEvent.change(end, { target: { value: '10:30' } })
fireEvent.change(dialog.querySelector<HTMLInputElement>('input[type="date"]')!, { target: { value: '2026-09-29' } })
expect(within(dialog).getByLabelText('开始时间日期')).toHaveValue('2026-09-29')
expect(within(dialog).getByLabelText('结束时间日期')).toHaveValue('2026-09-29')
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }))
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith('/api/supply-chain/management/employee-hours/records', expect.objectContaining({ method: 'POST' })))
const call = mockFetch.mock.calls.find(([url, init]) => url === '/api/supply-chain/management/employee-hours/records' && init?.method === 'POST')!
expect(JSON.parse(call[1].body).record).toMatchObject({
date: '2026-09-29', startTime: '2026-09-29T09:30', endTime: '2026-09-29T10:30',
})
})
it('业务内容下拉框支持向下向右调整尺寸,并完整展示长选项', async () => {
config.rules = [{
id: 'rule-long',
@ -139,6 +292,56 @@ describe('员工工时业务操作列', () => {
expect(within(listbox).getByText(/较长的业务分类说明/)).toHaveClass('whitespace-normal', 'break-words')
})
it('搜索只匹配一个业务内容时,转去填写时间自动确认选项且不会清空', async () => {
config.rules = [
{ id: 'rule-1688', businessContent: '1688异常订单核实处理:无法操作商品映射', businessCategory: '其他日常-异常处理类', workFrequency: '日度', initiativeType: '提前规划', approvedScore: 1, sortOrder: 0 },
{ id: 'rule-other', businessContent: '其他日常事务', businessCategory: '其他日常-其他', workFrequency: '日度', initiativeType: '提前规划', approvedScore: 1, sortOrder: 1 },
]
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
await screen.findByText('暂无员工工时记录')
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
const business = within(dialog).getByRole('combobox', { name: '业务内容' })
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
fireEvent.focus(business)
fireEvent.change(business, { target: { value: '1688异常订单核实处理' } })
expect(within(screen.getByRole('listbox', { name: '业务内容选项' })).getAllByRole('option')).toHaveLength(1)
fireEvent.blur(business, { relatedTarget: start })
fireEvent.focus(start)
fireEvent.change(start, { target: { value: '900' } })
fireEvent.blur(start)
expect(business).toHaveValue('1688异常订单核实处理:无法操作商品映射')
expect(within(dialog).getByText('业务分类').parentElement!.querySelector('input')).toHaveValue('其他日常-异常处理类')
expect(within(dialog).getByText('业务来源').parentElement!.querySelector('input')).toHaveValue('提前规划')
expect(within(dialog).queryByText(/请从下拉列表选择业务内容/)).not.toBeInTheDocument()
})
it('多个业务内容匹配时保留关键词,明确选择后填写日期时间仍保持选项', async () => {
config.rules = [
{ id: 'rule-a', businessContent: '订单核实 A', businessCategory: '日常', workFrequency: '日度', initiativeType: '主动', approvedScore: 1, sortOrder: 0 },
{ id: 'rule-b', businessContent: '订单核实 B', businessCategory: '日常', workFrequency: '日度', initiativeType: '主动', approvedScore: 1, sortOrder: 1 },
]
render(<EmployeeHoursHoursView canEdit canDownload={false} />)
await screen.findByText('暂无员工工时记录')
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
const business = within(dialog).getByRole('combobox', { name: '业务内容' })
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
fireEvent.focus(business)
fireEvent.change(business, { target: { value: '订单核实' } })
fireEvent.blur(business, { relatedTarget: start })
expect(business).toHaveValue('订单核实')
expect(within(dialog).getByRole('status')).toHaveTextContent('找到 2 项,请从下拉列表选择业务内容')
fireEvent.focus(business)
fireEvent.click(within(screen.getByRole('listbox', { name: '业务内容选项' })).getByRole('option', { name: /订单核实 B/ }))
fireEvent.blur(business, { relatedTarget: start })
fireEvent.change(start, { target: { value: '10:00' } })
fireEvent.blur(start)
expect(business).toHaveValue('订单核实 B')
expect(within(dialog).getByText('业务分类').parentElement!.querySelector('input')).toHaveValue('日常')
})
it('将操作列放在最左侧,并固定业务分类之前的四个字段', async () => {
records = [record]
render(<EmployeeHoursHoursView canEdit canDownload={false} />)

View File

@ -6,6 +6,7 @@ import {
BarChart3,
Check,
ChevronDown,
Copy,
Download,
GripHorizontal,
Paperclip,
@ -28,6 +29,7 @@ import type {
EmployeeHoursRanking,
EmployeeHoursRankingMetric,
} from '@/lib/employee-hours'
import { calculateEmployeeHoursDuration } from '@/lib/employee-hours'
import { EmployeeHoursView } from './EmployeeHoursView'
import { EmployeeHoursPermissionButton } from './EmployeeHoursAccessPanels'
@ -102,16 +104,6 @@ function formatDateTime(value: string) {
return match ? `${Number(match[1])}/${Number(match[2])}/${Number(match[3])} ${match[4]}` : value
}
function calculateDuration(startTime: string, endTime: string) {
if (!startTime || !endTime) return { minutes: null, hours: null, estimatedScore: null }
const start = new Date(startTime).getTime()
const end = new Date(endTime).getTime()
const minutes = Math.round((end - start) / 60_000)
if (!Number.isFinite(minutes) || minutes <= 0) return { minutes: null, hours: null, estimatedScore: null }
const hours = Number((minutes / 60).toFixed(2))
return { minutes, hours, estimatedScore: Number((hours * 0.52).toFixed(4)) }
}
function FilterSelect({
label,
value,
@ -166,6 +158,48 @@ function FormField({ label, children }: { label: string; children: React.ReactNo
const INPUT_CLASS = 'mt-1 h-9 w-full rounded-[5px] border border-zinc-300 bg-white px-2 text-sm text-zinc-900 outline-none focus:border-zinc-800 disabled:bg-zinc-50 disabled:text-zinc-500'
const READONLY_CLASS = 'mt-1 h-9 w-full rounded-[5px] border border-zinc-200 bg-zinc-50 px-2 text-sm text-zinc-600 outline-none'
const COMPLETE_DATE_TIME = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d$/
function normalizeClock(value: string) {
const raw = value.trim()
const colon = /^(\d{1,2}):(\d{1,2})$/.exec(raw)
const digits = /^\d{1,4}$/.test(raw) ? raw : ''
if (!colon && !digits) return null
const hour = Number(colon ? colon[1] : digits.length <= 2 ? digits : digits.slice(0, -2))
const minute = Number(colon ? colon[2] : digits.length <= 2 ? 0 : digits.slice(-2))
if (hour > 23 || minute > 59) return null
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
function DateTimeField({ label, defaultDate, value, onChange }: {
label: string
defaultDate: string
value: string
onChange: (value: string) => void
}) {
const [dateOverride, setDateOverride] = useState<string | null>(null)
const [savedDate = '', time = ''] = value.split('T')
const selectedDate = (value ? savedDate : dateOverride) || defaultDate
const invalidTime = time !== '' && !COMPLETE_DATE_TIME.test(`${selectedDate}T${time}`)
return <FormField label={label}>
<div className="mt-1 flex min-w-0 gap-1">
<input type="date" aria-label={`${label}日期`} value={selectedDate} onChange={(event) => {
const nextDate = event.target.value
setDateOverride(nextDate)
onChange(time && nextDate ? `${nextDate}T${time}` : '')
}} className={`${INPUT_CLASS} mt-0 min-w-0 flex-[1.4] px-1.5 text-xs`} />
<input type="text" aria-label={label} inputMode="numeric" autoComplete="off" maxLength={5} placeholder="时:分" value={time} onChange={(event) => {
const nextTime = event.target.value.trim()
onChange(nextTime && selectedDate ? `${selectedDate}T${nextTime}` : '')
}} onBlur={() => {
const normalized = normalizeClock(time)
if (normalized && selectedDate) onChange(`${selectedDate}T${normalized}`)
}} aria-invalid={invalidTime} className={`${INPUT_CLASS} mt-0 min-w-0 flex-1 text-center tabular-nums`} />
</div>
{invalidTime ? <span className="mt-1 block text-[11px] text-amber-700">请输入有效时间,如 09:30(24 小时制)</span> : null}
</FormField>
}
type SearchableSelectOption = {
value: string
@ -215,7 +249,13 @@ function SearchableSelect({
<div
className="relative"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false)
if (event.currentTarget.contains(event.relatedTarget)) return
if (!value && normalizedQuery) {
const exactOptions = filteredOptions.filter((option) => option.label.trim().toLocaleLowerCase('zh-CN') === normalizedQuery)
const match = exactOptions.length === 1 ? exactOptions[0] : filteredOptions.length === 1 ? filteredOptions[0] : null
if (match) choose(match)
}
setOpen(false)
}}
>
<div className="relative mt-1">
@ -226,11 +266,11 @@ function SearchableSelect({
aria-controls={listboxId}
aria-expanded={open}
aria-activedescendant={open && filteredOptions[currentActiveIndex] ? `${listboxId}-${filteredOptions[currentActiveIndex].value}` : undefined}
value={open ? query : selectedLabel}
value={value && !open ? selectedLabel || query : query}
placeholder={placeholder}
autoComplete="off"
onFocus={() => {
setQuery(selectedLabel)
if (value) setQuery(selectedLabel)
setOpen(true)
setActiveIndex(0)
}}
@ -252,6 +292,7 @@ function SearchableSelect({
event.preventDefault()
choose(filteredOptions[currentActiveIndex])
} else if (event.key === 'Escape') {
setQuery(selectedLabel)
setOpen(false)
}
}}
@ -263,7 +304,7 @@ function SearchableSelect({
tabIndex={-1}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setQuery(selectedLabel)
if (value) setQuery(selectedLabel)
setActiveIndex(0)
setOpen((current) => !current)
}}
@ -301,6 +342,7 @@ function SearchableSelect({
{!filteredOptions.length ? <div className="px-3 py-3 text-xs text-zinc-400">{emptyMessage}</div> : null}
</div>
) : null}
{!open && !value && normalizedQuery ? <span className="mt-1 block text-[11px] text-amber-700" role="status">{filteredOptions.length ? `找到 ${filteredOptions.length} 项,请从下拉列表选择业务内容` : emptyMessage}</span> : null}
</div>
)
}
@ -383,7 +425,7 @@ export function EmployeeHoursHoursView({
)
const selectedRule = rules.find((rule) => rule.id === draft.scoreRuleId) ?? null
const matchedEmployee: EmployeeHoursEmployee | undefined = employees.find((employee) => employee.id === draft.employeeId)
const duration = calculateDuration(draft.startTime, draft.endTime)
const duration = calculateEmployeeHoursDuration(draft.startTime, draft.endTime)
const total = recordPage?.total ?? 0
const totals = recordPage?.totals
const summaryValues: Record<string, string> = {
@ -488,13 +530,60 @@ export function EmployeeHoursHoursView({
setNotice('')
}
function openEdit(record: EmployeeHoursRecord) {
const rule = rules.find((value) => value.businessContent === record.businessContent
function matchingRule(record: EmployeeHoursRecord) {
return rules.find((value) => value.businessContent === record.businessContent
&& value.businessCategory === record.businessCategory
&& value.workFrequency === record.workFrequency
&& value.initiativeType === record.businessSource)
const employee = employees.find((value) => value.nickname === record.nickname && value.department === record.department)
}
function matchingEmployee(record: EmployeeHoursRecord) {
return employees.find((value) => value.nickname === record.nickname && value.department === record.department)
?? employees.find((value) => value.nickname === record.nickname)
}
async function copyRecord(record: EmployeeHoursRecord) {
if (!canEdit || saving || (accessScope !== 'ALL' && record.nickname !== currentUserName)) return
setSaving(true)
setError('')
setNotice('')
try {
const { record: copied } = await readResponse<{ record: EmployeeHoursRecord }>(await fetch('/api/supply-chain/management/employee-hours/records/copy', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: record.id }),
}))
setRecordPage((current) => {
if (!current) return current
const sourceIndex = current.records.findIndex((row) => row.id === record.id)
if (sourceIndex < 0) return current
const nextRecords = [...current.records]
nextRecords.splice(sourceIndex + 1, 0, copied)
return {
...current,
// 即使原行是当前页最后一行,也要在它下方立即展示副本。
records: nextRecords,
total: current.total + 1,
totals: {
minutes: current.totals.minutes + copied.minutes,
hours: current.totals.hours + copied.hours,
estimatedScore: current.totals.estimatedScore + copied.estimatedScore,
approvedScore: current.totals.approvedScore + (copied.approvedScore ?? 0),
},
}
})
setNotice('已复制为新记录,可在新行点击“编辑”修改')
if (rankingOpen) await loadRanking(rankingMetric)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '员工工时记录复制失败')
} finally {
setSaving(false)
}
}
function openEdit(record: EmployeeHoursRecord) {
const rule = matchingRule(record)
const employee = matchingEmployee(record)
setEditingId(record.id)
setDraft({
date: record.date,
@ -671,7 +760,7 @@ export function EmployeeHoursHoursView({
<tbody>
{records.map((record) => (
<tr key={record.id} className="group h-10 border-b border-zinc-100 last:border-0 hover:bg-zinc-50/70">
{canEdit ? <td className="sticky left-0 z-10 border-r border-zinc-200 bg-white px-2 text-center shadow-[3px_0_6px_-4px_rgba(0,0,0,0.2)] group-hover:bg-zinc-50">{accessScope === 'ALL' || record.nickname === currentUserName ? <div className="flex items-center justify-center gap-1"><button type="button" aria-label={`编辑 ${record.nickname} ${record.businessContent}`} title="编辑" disabled={saving} onClick={() => openEdit(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"><Pencil size={14} /></button><button type="button" aria-label={`删除 ${record.nickname} ${record.businessContent}`} title="删除" disabled={saving} onClick={() => void deleteRecord(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-red-50 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-40"><Trash2 size={14} /></button></div> : null}</td> : null}
{canEdit ? <td className="sticky left-0 z-10 border-r border-zinc-200 bg-white px-2 text-center shadow-[3px_0_6px_-4px_rgba(0,0,0,0.2)] group-hover:bg-zinc-50">{accessScope === 'ALL' || record.nickname === currentUserName ? <div className="flex items-center justify-center gap-1"><button type="button" aria-label={`复制 ${record.nickname} ${record.businessContent}`} title="复制并新增一行" disabled={saving} onClick={() => void copyRecord(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"><Copy size={14} /></button><button type="button" aria-label={`编辑 ${record.nickname} ${record.businessContent}`} title="编辑" disabled={saving} onClick={() => openEdit(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"><Pencil size={14} /></button><button type="button" aria-label={`删除 ${record.nickname} ${record.businessContent}`} title="删除" disabled={saving} onClick={() => void deleteRecord(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-red-50 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-40"><Trash2 size={14} /></button></div> : null}</td> : null}
{HOURS_COLUMNS.map((column, index) => {
const value = column.key === 'attachment'
? record.attachment?.name ?? ''
@ -697,7 +786,7 @@ export function EmployeeHoursHoursView({
</div>
<footer className="flex shrink-0 items-center justify-between px-1 py-1 text-[11px] text-zinc-400">
<span>显示 {total === 0 ? 0 : (page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} 条,共 {total} 条</span>
<span>显示 {total === 0 ? 0 : (page - 1) * pageSize + 1} - {Math.min((page - 1) * pageSize + records.length, total)} 条,共 {total} 条</span>
<div className="flex items-center gap-1">
<button type="button" aria-label="上一页" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))} className="h-7 rounded-[4px] border border-zinc-200 bg-white px-2 text-zinc-500 disabled:text-zinc-300">‹</button>
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-[4px] bg-zinc-800 px-2 text-white">{page} / {totalPages}</span>
@ -712,24 +801,29 @@ export function EmployeeHoursHoursView({
<section role="dialog" aria-modal="true" aria-labelledby="employee-hours-editor-title" className="max-h-[92vh] w-full max-w-5xl overflow-auto rounded-[9px] bg-white shadow-xl">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-zinc-200 bg-white px-5 py-4"><div><h2 id="employee-hours-editor-title" className="text-base font-semibold text-zinc-900">{editingId ? '编辑员工工时' : '新增员工工时'}</h2><p className="mt-1 text-xs text-zinc-500">分类、频率、来源和核定积分由业务内容配置自动匹配;时长与预估积分自动计算。</p></div><button type="button" aria-label="关闭" onClick={closeEditor} className="inline-flex size-8 items-center justify-center rounded border border-zinc-200 text-zinc-500 hover:bg-zinc-50"><X size={15} /></button></div>
<div className="grid gap-x-4 gap-y-3 p-5 sm:grid-cols-2 lg:grid-cols-4">
<FormField label="日期"><input type="date" value={draft.date} onChange={(event) => setDraft((value) => ({ ...value, date: event.target.value }))} className={INPUT_CLASS} /></FormField>
<FormField label="日期"><input type="date" value={draft.date} onChange={(event) => setDraft((value) => {
const nextDate = event.target.value
const followWorkDate = (time: string) => time.startsWith(`${value.date}T`) && nextDate
? `${nextDate}${time.slice(value.date.length)}` : time
return { ...value, date: nextDate, startTime: followWorkDate(value.startTime), endTime: followWorkDate(value.endTime) }
})} className={INPUT_CLASS} /></FormField>
<FormField label="花名"><input aria-label="花名" readOnly value={draft.nickname || currentUserName} placeholder="根据登录账号自动带出" className={READONLY_CLASS} />{!draft.employeeId ? <span className="mt-1 block text-[11px] text-amber-700">当前登录账号未在人员信息配置中维护花名。</span> : null}</FormField>
<FormField label="业务内容"><SearchableSelect ariaLabel="业务内容" listboxId="employee-hours-business-options" value={draft.scoreRuleId} options={ruleOptions} placeholder="输入业务内容或分类关键词" emptyMessage="未找到匹配业务内容" onChange={(scoreRuleId) => setDraft((value) => ({ ...value, scoreRuleId }))} />{!rules.length ? <span className="mt-1 block text-[11px] text-amber-700">请先进入“业务配置”新增业务内容规则。</span> : null}</FormField>
<FormField label="业务分类"><input readOnly value={selectedRule?.businessCategory ?? ''} className={READONLY_CLASS} /></FormField>
<FormField label="工作频率"><input readOnly value={selectedRule?.workFrequency ?? ''} className={READONLY_CLASS} /></FormField>
<FormField label="业务来源"><input readOnly value={selectedRule?.initiativeType ?? ''} className={READONLY_CLASS} /></FormField>
<FormField label="开始时间"><input type="datetime-local" value={draft.startTime} onChange={(event) => setDraft((value) => ({ ...value, startTime: event.target.value }))} className={INPUT_CLASS} /></FormField>
<FormField label="结束时间"><input type="datetime-local" value={draft.endTime} onChange={(event) => setDraft((value) => ({ ...value, endTime: event.target.value }))} className={INPUT_CLASS} /></FormField>
<FormField label="分钟"><input readOnly value={duration.minutes ?? ''} className={READONLY_CLASS} /></FormField>
<FormField label="小时"><input readOnly value={duration.hours === null ? '' : duration.hours.toFixed(2)} className={READONLY_CLASS} /></FormField>
<FormField label="预估积分"><input readOnly value={duration.estimatedScore === null ? '' : duration.estimatedScore.toFixed(4)} className={READONLY_CLASS} /></FormField>
<DateTimeField label="开始时间" defaultDate={draft.date} value={draft.startTime} onChange={(startTime) => setDraft((value) => ({ ...value, startTime }))} />
<DateTimeField label="结束时间" defaultDate={draft.date} value={draft.endTime} onChange={(endTime) => setDraft((value) => ({ ...value, endTime }))} />
<FormField label="分钟"><input readOnly value={duration?.minutes ?? ''} className={READONLY_CLASS} /></FormField>
<FormField label="小时"><input readOnly value={duration ? duration.hours.toFixed(2) : ''} className={READONLY_CLASS} /></FormField>
<FormField label="预估积分"><input readOnly value={duration ? duration.estimatedScore.toFixed(4) : ''} className={READONLY_CLASS} /></FormField>
<FormField label="核定积分"><input readOnly value={selectedRule?.approvedScore === null || selectedRule?.approvedScore === undefined ? '' : selectedRule.approvedScore} className={READONLY_CLASS} /></FormField>
<FormField label="部门"><input aria-label="部门" readOnly value={matchedEmployee?.department ?? ''} placeholder={draft.employeeId ? '该员工尚未在人员信息配置中维护部门' : '选择花名后自动带出'} className={READONLY_CLASS} /></FormField>
<FormField label="业务内容补充说明"><textarea value={draft.businessNote} onChange={(event) => setDraft((value) => ({ ...value, businessNote: event.target.value }))} rows={3} className="mt-1 min-h-20 w-full resize-y rounded-[5px] border border-zinc-300 bg-white px-2 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-800 lg:col-span-3" /></FormField>
<FormField label="附件"><div className="mt-1 flex min-h-20 flex-col justify-between gap-2 rounded-[5px] border border-dashed border-zinc-300 bg-zinc-50 p-2"><input ref={attachmentRef} type="file" accept="image/*,.pdf,.txt,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void uploadAttachment(file) }} />{draft.attachment ? <a href={draft.attachment.url} target="_blank" rel="noreferrer" title={draft.attachment.name} className="flex min-w-0 items-center gap-1 text-xs text-zinc-700 hover:text-zinc-950"><Paperclip size={13} className="shrink-0" /><span className="truncate">{draft.attachment.name}</span></a> : <span className="text-[11px] text-zinc-400">非必填,可上传文件或图片</span>}<div className="flex gap-1"><button type="button" disabled={uploadingAttachment || saving} onClick={() => attachmentRef.current?.click()} className="inline-flex h-7 items-center gap-1 rounded border border-zinc-300 bg-white px-2 text-[11px] text-zinc-700 hover:bg-zinc-100 disabled:opacity-50"><Paperclip size={12} />{uploadingAttachment ? '上传中…' : draft.attachment ? '更换' : '上传'}</button>{draft.attachment ? <button type="button" disabled={uploadingAttachment || saving} onClick={() => setDraft((value) => ({ ...value, attachment: null }))} className="h-7 rounded border border-red-200 bg-white px-2 text-[11px] text-red-600 hover:bg-red-50 disabled:opacity-50">移除</button> : null}</div></div></FormField>
</div>
{draft.startTime && draft.endTime && !duration.minutes ? <div className="mx-5 mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">结束时间必须晚于开始时间,请检查填写。</div> : null}
<div className="flex justify-between gap-2 border-t border-zinc-200 bg-zinc-50 px-5 py-3"><span className="self-center text-[11px] text-zinc-500">工时小时数保留两位小数;预估积分按小时 × 0.52 自动计算。</span><div className="flex gap-2"><button type="button" disabled={saving || uploadingAttachment} onClick={closeEditor} className="h-8 rounded border border-zinc-300 bg-white px-3 text-xs text-zinc-600 hover:bg-zinc-50 disabled:opacity-50">取消</button><button type="button" disabled={saving || uploadingAttachment || !draft.date || !draft.employeeId || !matchedEmployee?.department || !draft.scoreRuleId || !duration.minutes} onClick={() => void saveRecord()} className="inline-flex h-8 items-center gap-1.5 rounded bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"><Check size={14} />{saving ? '保存中…' : '保存'}</button></div></div>
{COMPLETE_DATE_TIME.test(draft.startTime) && COMPLETE_DATE_TIME.test(draft.endTime) && !duration ? <div className="mx-5 mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">结束时间必须晚于开始时间,请检查填写。</div> : null}
<div className="flex justify-between gap-2 border-t border-zinc-200 bg-zinc-50 px-5 py-3"><span className="self-center text-[11px] text-zinc-500">完整覆盖 12:00–13:00 时,分钟扣 60、小时扣 1;预估积分按扣减后的小时 × 0.52。</span><div className="flex gap-2"><button type="button" disabled={saving || uploadingAttachment} onClick={closeEditor} className="h-8 rounded border border-zinc-300 bg-white px-3 text-xs text-zinc-600 hover:bg-zinc-50 disabled:opacity-50">取消</button><button type="button" disabled={saving || uploadingAttachment || !draft.date || !draft.employeeId || !matchedEmployee?.department || !draft.scoreRuleId || !duration} onClick={() => void saveRecord()} className="inline-flex h-8 items-center gap-1.5 rounded bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"><Check size={14} />{saving ? '保存中…' : '保存'}</button></div></div>
</section>
</div>
) : null}

View File

@ -0,0 +1,21 @@
import { calculateEmployeeHoursDuration } from './employee-hours'
describe('员工工时午休扣减', () => {
it.each([
['2026-09-29T09:00', '2026-09-29T18:00', 540, 480, 8, 4.16, 1],
['2026-09-29T12:00', '2026-09-29T13:00', 60, 0, 0, 0, 1],
['2026-09-29T11:30', '2026-09-29T12:30', 60, 60, 1, 0.52, 0],
['2026-09-29T12:30', '2026-09-29T13:30', 60, 60, 1, 0.52, 0],
['2026-09-29T13:00', '2026-09-29T14:00', 60, 60, 1, 0.52, 0],
['2026-09-29T11:00', '2026-09-29T13:00', 120, 60, 1, 0.52, 1],
['2026-09-29T11:00', '2026-09-30T13:00', 1560, 1440, 24, 12.48, 2],
])('%s 到 %s 的分钟和小时独立扣减', (start, end, rawMinutes, minutes, hours, estimatedScore, lunchDays) => {
expect(calculateEmployeeHoursDuration(start, end)).toEqual({ rawMinutes, minutes, hours, estimatedScore, lunchDays })
})
it('开始时间不早于结束时间或输入非法时不计算', () => {
expect(calculateEmployeeHoursDuration('2026-09-29T13:00', '2026-09-29T12:00')).toBeNull()
expect(calculateEmployeeHoursDuration('2026-09-29T11:00', '2026-09-29T11:00')).toBeNull()
expect(calculateEmployeeHoursDuration('2026-02-30T11:00', '2026-02-30T13:00')).toBeNull()
})
})

View File

@ -145,3 +145,44 @@ export const EMPLOYEE_HOURS_IMPORT_HEADERS = [
'业务来源',
'核定积分',
] as const
const MINUTE_MS = 60_000
const DAY_MS = 24 * 60 * MINUTE_MS
const LUNCH_START_MINUTE = 12 * 60
const LUNCH_DURATION_MS = 60 * MINUTE_MS
const DATE_TIME_INPUT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
function wallClockMilliseconds(value: string) {
const match = DATE_TIME_INPUT.exec(value)
if (!match) return null
const [, year, month, day, hour, minute] = match.map(Number)
const milliseconds = Date.UTC(year, month - 1, day, hour, minute)
const parsed = new Date(milliseconds)
if (
parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1
|| parsed.getUTCDate() !== day || parsed.getUTCHours() !== hour || parsed.getUTCMinutes() !== minute
) return null
return milliseconds
}
/** 页面预览和保存共用的工时口径;分钟、小时按业务要求分别扣减午休。 */
export function calculateEmployeeHoursDuration(startTime: string, endTime: string) {
const start = wallClockMilliseconds(startTime)
const end = wallClockMilliseconds(endTime)
if (start === null || end === null || end <= start) return null
const rawMinutes = Math.round((end - start) / MINUTE_MS)
const startDay = Math.floor(start / DAY_MS) * DAY_MS
const firstLunchStart = startDay + LUNCH_START_MINUTE * MINUTE_MS + (start > startDay + LUNCH_START_MINUTE * MINUTE_MS ? DAY_MS : 0)
// 完整跨越 12:00–13:00 才扣减;跨日记录逐日计算,部分重叠不扣。
const lunchDays = Math.max(0, Math.floor((end - LUNCH_DURATION_MS - firstLunchStart) / DAY_MS) + 1)
const minutes = Math.max(0, rawMinutes - lunchDays * 60)
const hours = Math.max(0, Number((rawMinutes / 60 - lunchDays).toFixed(2)))
return {
rawMinutes,
minutes,
hours,
estimatedScore: Number((hours * 0.52).toFixed(4)),
lunchDays,
}
}

View File

@ -137,6 +137,18 @@ describe('EmployeeHoursRecordService employee mapping', () => {
}))
})
it('覆盖完整午休时创建与导入均扣除 60 分钟并重新计算积分', async () => {
const input = { date: '2026-09-24', employeeId: employee.id, nickname: employee.nickname, scoreRuleId: rule.id, startTime: '2026-09-24T09:00', endTime: '2026-09-24T18:00' }
await employeeHoursRecordService.create({ id: 'user-1', name: '管理员' }, input)
expect(recordRepository.create).toHaveBeenCalledWith(expect.objectContaining({
minutes: 480, hours: new Prisma.Decimal('8.00'), estimatedScore: new Prisma.Decimal('4.1600'),
}))
await employeeHoursRecordService.import({ id: 'user-1', name: '管理员' }, [{ ...input, businessContent: rule.secondary }])
expect(recordRepository.createMany).toHaveBeenCalledWith([expect.objectContaining({
minutes: 480, hours: new Prisma.Decimal('8.00'), estimatedScore: new Prisma.Decimal('4.1600'),
})])
})
it('拒绝不在人员信息配置中的员工 ID', async () => {
await expect(employeeHoursRecordService.create(
{ id: 'user-1', name: '管理员' },
@ -182,6 +194,48 @@ describe('EmployeeHoursRecordService employee mapping', () => {
expect(recordRepository.softDeleteMany).not.toHaveBeenCalled()
})
it('复制完整记录快照与附件,保留目标分支的业务配置且仅记录当前操作人', async () => {
const source = { ...savedRecord, attachment: { name: '证明.pdf', url: '/api/storage/object?key=proof', mimeType: 'application/pdf', size: 128 } }
recordRepository.findById.mockResolvedValue(source)
recordRepository.create.mockResolvedValue({ ...source, id: 'record-copy', createdById: 'user-2', updatedById: 'user-2' })
const result = await employeeHoursRecordService.copy(
{ id: 'user-2', name: '管理员' }, source.id,
{ level: 'ALL', nickname: '管理员', department: '' },
)
expect(recordRepository.create).toHaveBeenCalledWith(expect.objectContaining({
workDate: source.workDate,
nickname: source.nickname,
businessContent: source.businessContent,
businessNote: source.businessNote,
businessCategory: source.businessCategory,
workFrequency: source.workFrequency,
businessSource: source.businessSource,
startTime: source.startTime,
endTime: source.endTime,
minutes: source.minutes,
hours: source.hours,
estimatedScore: source.estimatedScore,
approvedScore: source.approvedScore,
department: source.department,
attachment: source.attachment,
createdById: 'user-2',
updatedById: 'user-2',
}))
expect(result).toMatchObject({ id: 'record-copy', attachment: source.attachment })
expect(ruleRepository.findRuleById).not.toHaveBeenCalled()
})
it('不允许复制不存在的记录或无权编辑的他人记录', async () => {
recordRepository.findById.mockResolvedValueOnce(null).mockResolvedValueOnce(savedRecord)
await expect(employeeHoursRecordService.copy({ id: 'user-2', name: '花名乙' }, 'missing'))
.rejects.toThrow('员工工时记录不存在')
await expect(employeeHoursRecordService.copy(
{ id: 'user-2', name: '花名乙' }, savedRecord.id,
{ level: 'DEPARTMENT', nickname: '花名乙', department: employee.department },
)).rejects.toThrow('无权访问')
expect(recordRepository.create).not.toHaveBeenCalled()
})
it('批量导入时也从人员信息配置解析花名和部门', async () => {
await employeeHoursRecordService.import(
{ id: 'user-1', name: '管理员' },

View File

@ -8,13 +8,13 @@ import type {
EmployeeHoursRanking,
EmployeeHoursRankingMetric,
} from '@/lib/employee-hours'
import { calculateEmployeeHoursDuration } from '@/lib/employee-hours'
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import { employeeHoursConfigRepository } from '@/server/repositories/employee-hours-config.repository'
import { employeeHoursRecordRepository } from '@/server/repositories/employee-hours-record.repository'
import { assertEmployeeHoursRecordAccess, type EmployeeHoursScope } from './employee-hours-access.service'
const ESTIMATED_SCORE_PER_HOUR = 0.52
const DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/
const DATE_TIME_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})[T ](\d{1,2}):(\d{2})$/
@ -64,13 +64,6 @@ function normalizeDateTime(value: unknown) {
return `${yearPart}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
function localDateTimeMilliseconds(value: string) {
const match = DATE_TIME_PATTERN.exec(value)
if (!match) return Number.NaN
const [, year, month, day, hour, minute] = match.map(Number) as unknown as [string, number, number, number, number, number]
return Date.UTC(year, month - 1, day, hour, minute)
}
function toDate(value: Date) {
return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, '0')}-${String(value.getUTCDate()).padStart(2, '0')}`
}
@ -265,12 +258,10 @@ class EmployeeHoursRecordService {
const startTime = normalizeDateTime(input.startTime)
const endTime = normalizeDateTime(input.endTime)
if (!startTime || !endTime) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '开始时间和结束时间格式不正确,请使用 YYYY/M/D HH:mm')
const elapsed = localDateTimeMilliseconds(endTime) - localDateTimeMilliseconds(startTime)
const minutes = Math.round(elapsed / 60_000)
if (!Number.isFinite(elapsed) || minutes <= 0) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '结束时间必须晚于开始时间')
if (minutes > 60 * 24 * 14) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单条工时不能超过14天')
const hours = Number((minutes / 60).toFixed(2))
const estimatedScore = Number((hours * ESTIMATED_SCORE_PER_HOUR).toFixed(4))
const duration = calculateEmployeeHoursDuration(startTime, endTime)
if (!duration) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '结束时间必须晚于开始时间')
if (duration.rawMinutes > 60 * 24 * 14) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单条工时不能超过14天')
const { minutes, hours, estimatedScore } = duration
const approvedScore = scoreRule.unitScore === null ? null : new Prisma.Decimal(scoreRule.unitScore.toString())
return {
workDate: toDbDate(workDateText),
@ -306,6 +297,32 @@ class EmployeeHoursRecordService {
return toRecord(record)
}
async copy(actor: Actor, id: string, scope?: EmployeeHoursScope) {
const existing = await employeeHoursRecordRepository.findById(id)
if (!existing) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '员工工时记录不存在')
if (scope) assertEmployeeHoursRecordAccess(scope, existing, true)
const record = await employeeHoursRecordRepository.create({
workDate: existing.workDate,
nickname: existing.nickname,
businessContent: existing.businessContent,
businessNote: existing.businessNote,
businessCategory: existing.businessCategory,
workFrequency: existing.workFrequency,
businessSource: existing.businessSource,
startTime: existing.startTime,
endTime: existing.endTime,
minutes: existing.minutes,
hours: existing.hours,
estimatedScore: existing.estimatedScore,
approvedScore: existing.approvedScore,
department: existing.department,
attachment: existing.attachment === null ? Prisma.JsonNull : existing.attachment as Prisma.InputJsonValue,
createdById: actor.id,
updatedById: actor.id,
})
return toRecord(record)
}
async update(actor: Actor, id: string, input: WriteInput, scope?: EmployeeHoursScope) {
const existing = await employeeHoursRecordRepository.findById(id)
if (!existing) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '员工工时记录不存在')