提交
This commit is contained in:
1 parent
80138c6388
commit
693e8a678e
682 files changed
+211375
-5306
No files matched your search
@@ -17,7 +17,12 @@ import { formatPersonDisplay } from '@/utils/oa-person-value'
|
||||
import { formatPostDisplay } from '@/utils/oa-post-value'
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import { formatReceiveAccountDisplay } from '@/utils/oa-receive-account-value'
|
||||
import {
|
||||
formatFormValueDisplay,
|
||||
formatRegionLikeDisplay
|
||||
} from '@/utils/oa-form-value-display'
|
||||
import { toRmbUpper } from '@/utils/rmb-upper'
|
||||
import { formatOaUploadPathsForPrint } from '@/utils/oa-upload-file-type'
|
||||
|
||||
export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
|
||||
|
||||
@@ -29,19 +34,24 @@ export type OaPrintFieldRow = {
|
||||
label: string
|
||||
/** 普通文本(可含换行) */
|
||||
text?: string
|
||||
/** 附件 / 链接列表 */
|
||||
/** 附件文件名 / 链接列表(上传字段仅文件名) */
|
||||
urls?: string[]
|
||||
/** 关联审批单多行块(优先于 text) */
|
||||
blocks?: RelatedPrintBlock[]
|
||||
}
|
||||
|
||||
export type OaPrintPersonLine = {
|
||||
left: string
|
||||
right: string
|
||||
}
|
||||
|
||||
export type OaPrintFlowItem = {
|
||||
left: string
|
||||
right: string
|
||||
/** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
|
||||
comment?: string
|
||||
/** 或签/会签按人纯文本行 */
|
||||
personLines?: string[]
|
||||
/** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */
|
||||
personLines?: OaPrintPersonLine[]
|
||||
}
|
||||
|
||||
export type OaPrintModel = {
|
||||
@@ -172,25 +182,7 @@ function extractUrls(value: unknown): string[] {
|
||||
}
|
||||
|
||||
function formatScalar(value: unknown, sep = '、'): string {
|
||||
if (value == null || value === '') return '-'
|
||||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') return value.trim() || '-'
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return '-'
|
||||
return value
|
||||
.map((v) => formatScalar(v))
|
||||
.filter((s) => s && s !== '-')
|
||||
.join(sep)
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
return formatFormValueDisplay(value, sep)
|
||||
}
|
||||
|
||||
/** 日期/时间区间(FormCreate:日期区间、时间区间等)打印用 ~ 连接起止 */
|
||||
@@ -322,14 +314,26 @@ async function walkRules(
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === 'region' || type === 'cascader') {
|
||||
const text = formatRegionLikeDisplay(value)
|
||||
out.push({ label, text: text || '-' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === 'tripItinerary') {
|
||||
out.push({ label, text: formatTripItineraryForPrint(value) })
|
||||
continue
|
||||
}
|
||||
|
||||
if (UPLOAD_TYPES.has(type) || String(rule.name || '').toLowerCase().includes('upload')) {
|
||||
const urls = extractUrls(value)
|
||||
out.push({ label, urls: urls.length ? urls : [], text: urls.length ? undefined : '-' })
|
||||
const dragTag = String(rule._fc_drag_tag || '').trim()
|
||||
if (
|
||||
UPLOAD_TYPES.has(type) ||
|
||||
UPLOAD_TYPES.has(dragTag) ||
|
||||
String(rule.name || '').toLowerCase().includes('upload') ||
|
||||
dragTag.toLowerCase().includes('upload')
|
||||
) {
|
||||
const names = formatOaUploadPathsForPrint(value)
|
||||
out.push({ label, urls: names.length ? names : [], text: names.length ? undefined : '-' })
|
||||
continue
|
||||
}
|
||||
// 字符串里像 URL 列表的也拆开展示
|
||||
@@ -349,21 +353,40 @@ async function walkRules(
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePlainObject(raw: unknown): Record<string, any> {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, any>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
if (typeof raw === 'object') return raw as Record<string, any>
|
||||
return {}
|
||||
}
|
||||
|
||||
async function buildFormRows(detail: any): Promise<OaPrintFieldRow[]> {
|
||||
const schema = detail?.formSchemaJson
|
||||
const formData = (detail?.formDataJson || {}) as Record<string, any>
|
||||
const schema = ensurePlainObject(detail?.formSchemaJson)
|
||||
const formData = ensurePlainObject(detail?.formDataJson)
|
||||
const rule = Array.isArray(schema?.rule)
|
||||
? schema.rule
|
||||
: Array.isArray(schema)
|
||||
? schema
|
||||
: Array.isArray(detail?.formSchemaJson)
|
||||
? detail.formSchemaJson
|
||||
: []
|
||||
const out: OaPrintFieldRow[] = []
|
||||
await walkRules(rule, formData, out)
|
||||
if (out.length) return out
|
||||
// schema 缺失:兜底平铺 formData
|
||||
// schema 缺失:兜底平铺 formData(附件路径收成文件名)
|
||||
for (const [k, v] of Object.entries(formData)) {
|
||||
const names = formatOaUploadPathsForPrint(v)
|
||||
if (names.length && extractUrls(v).every((u) => /\/minio\//i.test(u) || /\.[a-z0-9]{2,5}(\?|$)/i.test(u))) {
|
||||
out.push({ label: k, urls: names })
|
||||
continue
|
||||
}
|
||||
const urls = extractUrls(v).filter(isHttpUrl)
|
||||
if (urls.length) out.push({ label: k, urls })
|
||||
if (urls.length) out.push({ label: k, urls: formatOaUploadPathsForPrint(urls) })
|
||||
else out.push({ label: k, text: formatScalar(v) })
|
||||
}
|
||||
return out
|
||||
@@ -380,10 +403,13 @@ function peopleDisplayNames(p: any): string[] {
|
||||
return name ? [name] : []
|
||||
}
|
||||
|
||||
function progressPersonLinesFromItem(p: any): string[] {
|
||||
function progressPersonLinesFromItem(p: any): OaPrintPersonLine[] {
|
||||
const people = Array.isArray(p?.people) ? p.people : []
|
||||
if (!people.length) return []
|
||||
return formatProgressPersonLines(people).map((x) => x.text)
|
||||
return formatProgressPersonLines(people).map((x) => ({
|
||||
left: x.body || x.text,
|
||||
right: x.timestamp || ''
|
||||
}))
|
||||
}
|
||||
|
||||
function isCcProgressItem(p: any): boolean {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
formatFormValueDisplay,
|
||||
formatRegionLikeDisplay,
|
||||
formatUnknownObjectDisplay
|
||||
} from './oa-form-value-display'
|
||||
|
||||
describe('oa-form-value-display', () => {
|
||||
it('formats region path array and object', () => {
|
||||
assert.equal(
|
||||
formatRegionLikeDisplay(['安徽省', '合肥市', '蜀山区']),
|
||||
'安徽省 / 合肥市 / 蜀山区'
|
||||
)
|
||||
assert.equal(
|
||||
formatRegionLikeDisplay({
|
||||
province: '安徽省',
|
||||
city: '合肥市',
|
||||
district: '蜀山区'
|
||||
}),
|
||||
'安徽省 / 合肥市 / 蜀山区'
|
||||
)
|
||||
})
|
||||
|
||||
it('never JSON.stringifies unknown objects', () => {
|
||||
const text = formatFormValueDisplay({
|
||||
foo: 1,
|
||||
bar: 'baz',
|
||||
id: 'hidden',
|
||||
durationManual: true
|
||||
})
|
||||
assert.equal(text.includes('{'), false)
|
||||
assert.equal(text.includes('hidden'), false)
|
||||
assert.ok(text.includes('baz'))
|
||||
})
|
||||
|
||||
it('formats trip-like object without dumping keys', () => {
|
||||
const text = formatUnknownObjectDisplay({
|
||||
transport: '汽车',
|
||||
fromCity: '合肥',
|
||||
toCity: '上海',
|
||||
durationManual: false
|
||||
})
|
||||
assert.ok(text.includes('合肥 → 上海'))
|
||||
assert.equal(text.includes('durationManual'), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* OA 表单值可读化(打印 / 摘要兜底)
|
||||
* 禁止把对象直接 JSON.stringify 到界面。
|
||||
*/
|
||||
|
||||
export function formatRegionLikeDisplay(value: unknown): string {
|
||||
if (value == null || value === '') return ''
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((x) => {
|
||||
if (x == null || x === '') return ''
|
||||
if (typeof x === 'object') {
|
||||
const o = x as Record<string, unknown>
|
||||
return String(o.label || o.name || o.text || o.value || '').trim()
|
||||
}
|
||||
return String(x).trim()
|
||||
})
|
||||
.filter(Boolean)
|
||||
return parts.join(' / ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const o = value as Record<string, unknown>
|
||||
const path = [
|
||||
String(o.province || o.provinceName || o.province_name || '').trim(),
|
||||
String(o.city || o.cityName || o.city_name || '').trim(),
|
||||
String(
|
||||
o.district || o.districtName || o.area || o.areaName || o.district_name || ''
|
||||
).trim()
|
||||
].filter(Boolean)
|
||||
if (path.length) return path.join(' / ')
|
||||
return formatUnknownObjectDisplay(o)
|
||||
}
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
export function formatUnknownObjectDisplay(raw: Record<string, unknown>): string {
|
||||
const named = String(raw.label || raw.name || raw.text || raw.value || '').trim()
|
||||
if (named) return named
|
||||
|
||||
if (raw.accountType || raw.accountNo || raw.accountName) {
|
||||
const parts = [
|
||||
String(raw.accountType || '').trim(),
|
||||
String(raw.accountName || '').trim(),
|
||||
String(raw.accountNo || '').trim()
|
||||
].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
if (raw.fromCity || raw.toCity || raw.transport) {
|
||||
const fromCity = String(raw.fromCity || '').trim() || '-'
|
||||
const toCity = String(raw.toCity || '').trim() || '-'
|
||||
const transport = String(raw.transport || '').trim()
|
||||
const tripType = String(raw.tripType || '').trim()
|
||||
const bits = [
|
||||
transport,
|
||||
tripType,
|
||||
`${fromCity} → ${toCity}`
|
||||
].filter(Boolean)
|
||||
return bits.join(' · ')
|
||||
}
|
||||
|
||||
const skip = new Set(['id', 'uuid', 'key', 'durationManual', 'manual'])
|
||||
const parts: string[] = []
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (skip.has(k) || k.startsWith('__') || /id$/i.test(k)) continue
|
||||
if (typeof v === 'boolean' || v == null || v === '') continue
|
||||
if (typeof v === 'string' || typeof v === 'number') {
|
||||
parts.push(String(v).trim())
|
||||
}
|
||||
}
|
||||
return parts.filter(Boolean).join('、')
|
||||
}
|
||||
|
||||
/** 标量/数组/对象统一可读化;对象绝不 JSON.stringify */
|
||||
export function formatFormValueDisplay(value: unknown, sep = '、'): string {
|
||||
if (value == null || value === '') return '-'
|
||||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') return value.trim() || '-'
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return '-'
|
||||
const parts = value
|
||||
.map((v) => formatFormValueDisplay(v, sep))
|
||||
.filter((s) => s && s !== '-')
|
||||
return parts.length ? parts.join(sep) : '-'
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = formatUnknownObjectDisplay(value as Record<string, unknown>)
|
||||
return text || '-'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
calcOvertimeHours,
|
||||
DEFAULT_OVERTIME_WINDOWS,
|
||||
type RestDayResolver
|
||||
} from './oa-overtime-hours'
|
||||
|
||||
/** 固定:2026-08-07 周五工作日,08-08 周六休息日 */
|
||||
const restMap: Record<string, boolean> = {
|
||||
'2026-08-07': false,
|
||||
'2026-08-08': true,
|
||||
'2026-08-09': true
|
||||
}
|
||||
function isRestDay(ymd: string): boolean {
|
||||
if (ymd in restMap) return restMap[ymd]
|
||||
const d = new Date(ymd + 'T00:00:00')
|
||||
const w = d.getDay()
|
||||
return w === 0 || w === 6
|
||||
}
|
||||
|
||||
describe('calcOvertimeHours', () => {
|
||||
const w = DEFAULT_OVERTIME_WINDOWS
|
||||
|
||||
it('weekday 18:00-20:00 => 2.0', () => {
|
||||
assert.equal(
|
||||
calcOvertimeHours('2026-08-07 18:00', '2026-08-07 20:00', w, isRestDay),
|
||||
2.0
|
||||
)
|
||||
})
|
||||
|
||||
it('weekday 14:00-18:00 => 0.5', () => {
|
||||
assert.equal(
|
||||
calcOvertimeHours('2026-08-07 14:00', '2026-08-07 18:00', w, isRestDay),
|
||||
0.5
|
||||
)
|
||||
})
|
||||
|
||||
it('weekday 17:30 to next 08:30 => 15.0', () => {
|
||||
assert.equal(
|
||||
calcOvertimeHours('2026-08-07 17:30', '2026-08-08 08:30', w, isRestDay),
|
||||
15.0
|
||||
)
|
||||
})
|
||||
|
||||
it('Saturday 09:00-15:00 => 4.0', () => {
|
||||
assert.equal(
|
||||
calcOvertimeHours('2026-08-08 09:00', '2026-08-08 15:00', w, isRestDay),
|
||||
4.0
|
||||
)
|
||||
})
|
||||
|
||||
it('Fri 18:00 - Sat 12:00 => 17.5', () => {
|
||||
assert.equal(
|
||||
calcOvertimeHours('2026-08-07 18:00', '2026-08-08 12:00', w, isRestDay),
|
||||
17.5
|
||||
)
|
||||
})
|
||||
|
||||
it('end <= start => null', () => {
|
||||
assert.equal(
|
||||
calcOvertimeHours('2026-08-07 20:00', '2026-08-07 18:00', w, isRestDay),
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
/** 加班套件:工作日/休息日窗口重叠小时数(与 backend oa_overtime_hours 对齐) */
|
||||
|
||||
export type OvertimeWindows = {
|
||||
workdayOvertimeStart: string
|
||||
workdayOvertimeEnd: string
|
||||
restBreakStart: string
|
||||
restBreakEnd: string
|
||||
}
|
||||
|
||||
export const DEFAULT_OVERTIME_WINDOWS: OvertimeWindows = {
|
||||
workdayOvertimeStart: '17:30',
|
||||
workdayOvertimeEnd: '08:30',
|
||||
restBreakStart: '11:30',
|
||||
restBreakEnd: '13:30'
|
||||
}
|
||||
|
||||
export type RestDayResolver = (ymd: string) => boolean
|
||||
|
||||
const DT_RE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/
|
||||
|
||||
type Interval = [number, number]
|
||||
|
||||
function parseHm(hm: string): number | null {
|
||||
const m = String(hm || '')
|
||||
.trim()
|
||||
.match(/^(\d{1,2}):(\d{2})$/)
|
||||
if (!m) return null
|
||||
const h = Number(m[1])
|
||||
const min = Number(m[2])
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null
|
||||
return h * 60 + min
|
||||
}
|
||||
|
||||
function parseDt(s: string): Date | null {
|
||||
const m = String(s || '')
|
||||
.trim()
|
||||
.match(DT_RE)
|
||||
if (!m) return null
|
||||
const d = new Date(
|
||||
Number(m[1]),
|
||||
Number(m[2]) - 1,
|
||||
Number(m[3]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
0,
|
||||
0
|
||||
)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function formatYmd(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const da = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${mo}-${da}`
|
||||
}
|
||||
|
||||
function dayStartMs(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||
}
|
||||
|
||||
function dayAtMinutes(d: Date, minutes: number): number {
|
||||
return dayStartMs(d) + minutes * 60000
|
||||
}
|
||||
|
||||
function mergeIntervals(intervals: Interval[]): Interval[] {
|
||||
if (intervals.length === 0) return []
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
|
||||
const merged: Interval[] = [[sorted[0][0], sorted[0][1]]]
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const last = merged[merged.length - 1]
|
||||
const [s, e] = sorted[i]
|
||||
if (s <= last[1]) {
|
||||
last[1] = Math.max(last[1], e)
|
||||
} else {
|
||||
merged.push([s, e])
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function intersectInterval(a: Interval, b: Interval): Interval | null {
|
||||
const lo = Math.max(a[0], b[0])
|
||||
const hi = Math.min(a[1], b[1])
|
||||
return hi > lo ? [lo, hi] : null
|
||||
}
|
||||
|
||||
function subtractInterval(base: Interval, sub: Interval): Interval[] {
|
||||
const [bs, be] = base
|
||||
const [ss, se] = sub
|
||||
if (se <= bs || ss >= be) return [base]
|
||||
const out: Interval[] = []
|
||||
if (ss > bs) out.push([bs, ss])
|
||||
if (se < be) out.push([se, be])
|
||||
return out
|
||||
}
|
||||
|
||||
function subtractIntervals(base: Interval, subs: Interval[]): Interval[] {
|
||||
let result: Interval[] = [base]
|
||||
for (const sub of subs) {
|
||||
const next: Interval[] = []
|
||||
for (const r of result) {
|
||||
next.push(...subtractInterval(r, sub))
|
||||
}
|
||||
result = next
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function intervalMinutes(intervals: Interval[]): number {
|
||||
return intervals.reduce((sum, [a, b]) => sum + (b - a) / 60000, 0)
|
||||
}
|
||||
|
||||
function buildWorkdayWindows(
|
||||
anchorDay: Date,
|
||||
windows: OvertimeWindows
|
||||
): Interval | null {
|
||||
const startMin = parseHm(windows.workdayOvertimeStart)
|
||||
const endMin = parseHm(windows.workdayOvertimeEnd)
|
||||
if (startMin == null || endMin == null) return null
|
||||
|
||||
const anchorStart = dayAtMinutes(anchorDay, startMin)
|
||||
const nextDay = new Date(anchorDay)
|
||||
nextDay.setDate(nextDay.getDate() + 1)
|
||||
const anchorEnd = dayAtMinutes(nextDay, endMin)
|
||||
if (anchorEnd <= anchorStart) return null
|
||||
return [anchorStart, anchorEnd]
|
||||
}
|
||||
|
||||
function buildRestDaySegments(day: Date, windows: OvertimeWindows): Interval[] {
|
||||
const breakStart = parseHm(windows.restBreakStart)
|
||||
const breakEnd = parseHm(windows.restBreakEnd)
|
||||
if (breakStart == null || breakEnd == null) return []
|
||||
|
||||
const day0 = dayStartMs(day)
|
||||
const nextDay = new Date(day)
|
||||
nextDay.setDate(nextDay.getDate() + 1)
|
||||
const dayEnd = dayStartMs(nextDay)
|
||||
|
||||
const segments: Interval[] = []
|
||||
if (breakStart > 0) segments.push([day0, dayAtMinutes(day, breakStart)])
|
||||
if (breakEnd < 24 * 60) segments.push([dayAtMinutes(day, breakEnd), dayEnd])
|
||||
return segments.filter(([a, b]) => b > a)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算加班时长(小时,1 位小数)。
|
||||
* end <= start 或非法输入返回 null。
|
||||
*/
|
||||
export function calcOvertimeHours(
|
||||
startStr: string,
|
||||
endStr: string,
|
||||
windows: OvertimeWindows = DEFAULT_OVERTIME_WINDOWS,
|
||||
isRestDay: RestDayResolver
|
||||
): number | null {
|
||||
const start = parseDt(startStr)
|
||||
const end = parseDt(endStr)
|
||||
if (!start || !end || end.getTime() <= start.getTime()) return null
|
||||
|
||||
const query: Interval = [start.getTime(), end.getTime()]
|
||||
const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
|
||||
const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
|
||||
|
||||
const workdayIntervals: Interval[] = []
|
||||
const anchor = new Date(startDay)
|
||||
anchor.setDate(anchor.getDate() - 1)
|
||||
const lastAnchor = new Date(endDay)
|
||||
while (anchor.getTime() <= lastAnchor.getTime()) {
|
||||
const ymd = formatYmd(anchor)
|
||||
if (!isRestDay(ymd)) {
|
||||
const win = buildWorkdayWindows(anchor, windows)
|
||||
if (win) {
|
||||
const hit = intersectInterval(win, query)
|
||||
if (hit) workdayIntervals.push(hit)
|
||||
}
|
||||
}
|
||||
anchor.setDate(anchor.getDate() + 1)
|
||||
}
|
||||
|
||||
const workdayMerged = mergeIntervals(workdayIntervals)
|
||||
|
||||
const restIntervals: Interval[] = []
|
||||
const day = new Date(startDay)
|
||||
while (day.getTime() <= endDay.getTime()) {
|
||||
const ymd = formatYmd(day)
|
||||
if (isRestDay(ymd)) {
|
||||
for (const seg of buildRestDaySegments(day, windows)) {
|
||||
const hit = intersectInterval(seg, query)
|
||||
if (hit) {
|
||||
const remaining = subtractIntervals(hit, workdayMerged)
|
||||
restIntervals.push(...remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
day.setDate(day.getDate() + 1)
|
||||
}
|
||||
|
||||
const totalMinutes = intervalMinutes(workdayMerged) + intervalMinutes(restIntervals)
|
||||
return Math.round((totalMinutes / 60) * 10) / 10
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DEFAULT_OVERTIME_WINDOWS } from '@/utils/oa-overtime-hours'
|
||||
|
||||
/** FormCreate Designer DragRule:加班套件 */
|
||||
|
||||
let _uidSeq = 0
|
||||
@@ -66,7 +68,7 @@ function buildOvertimeKitChildren() {
|
||||
type: 'inputNumber',
|
||||
field: durationField,
|
||||
title: '时长(h)',
|
||||
info: '请手动填写加班时长',
|
||||
info: '可根据起止时间自动计算,支持手改',
|
||||
$required: true,
|
||||
props: {
|
||||
_fc_kit_field: 'overtimeDuration',
|
||||
@@ -97,6 +99,7 @@ export const overtimeKitDragRule = {
|
||||
return {
|
||||
type: 'overtimeKit',
|
||||
props: {
|
||||
...DEFAULT_OVERTIME_WINDOWS,
|
||||
personField: built.personField,
|
||||
startField: built.startField,
|
||||
endField: built.endField,
|
||||
@@ -106,6 +109,35 @@ export const overtimeKitDragRule = {
|
||||
}
|
||||
},
|
||||
props() {
|
||||
return []
|
||||
return [
|
||||
{
|
||||
type: 'input',
|
||||
field: 'workdayOvertimeStart',
|
||||
title: '工作日加班起',
|
||||
value: DEFAULT_OVERTIME_WINDOWS.workdayOvertimeStart,
|
||||
props: { placeholder: 'HH:mm' }
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'workdayOvertimeEnd',
|
||||
title: '工作日加班止(次日)',
|
||||
value: DEFAULT_OVERTIME_WINDOWS.workdayOvertimeEnd,
|
||||
props: { placeholder: 'HH:mm' }
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'restBreakStart',
|
||||
title: '休息日午休起',
|
||||
value: DEFAULT_OVERTIME_WINDOWS.restBreakStart,
|
||||
props: { placeholder: 'HH:mm' }
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'restBreakEnd',
|
||||
title: '休息日午休止',
|
||||
value: DEFAULT_OVERTIME_WINDOWS.restBreakEnd,
|
||||
props: { placeholder: 'HH:mm' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
/** 加班套件运行时:时长仅手动填写,补齐数字框左对齐;加班人默认当前登录人 */
|
||||
/** 加班套件运行时:时长可按起止自动计算,支持手改;加班人默认当前登录人 */
|
||||
|
||||
import {
|
||||
buildCurrentOwnerPerson,
|
||||
type OwnerUserSnapshot
|
||||
} from '@/utils/oa-owner-field-defaults'
|
||||
import {
|
||||
calcOvertimeHours,
|
||||
DEFAULT_OVERTIME_WINDOWS,
|
||||
type OvertimeWindows,
|
||||
type RestDayResolver
|
||||
} from '@/utils/oa-overtime-hours'
|
||||
|
||||
export const OVERTIME_MANUAL_PREFIX = '__manual_'
|
||||
|
||||
function asRules(schema: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === 'object') as any
|
||||
@@ -29,16 +37,22 @@ function walk(rules: Record<string, unknown>[], out: Record<string, unknown>[])
|
||||
|
||||
export type OvertimeKitBinding = {
|
||||
personField?: string
|
||||
durationField?: string
|
||||
startField: string
|
||||
endField: string
|
||||
durationField: string
|
||||
windows: OvertimeWindows
|
||||
}
|
||||
|
||||
function resolveBinding(kit: Record<string, unknown>): OvertimeKitBinding {
|
||||
function resolveBinding(kit: Record<string, unknown>): OvertimeKitBinding | null {
|
||||
const props = (kit.props && typeof kit.props === 'object' ? kit.props : {}) as Record<
|
||||
string,
|
||||
any
|
||||
>
|
||||
let personField = props.personField ? String(props.personField) : undefined
|
||||
let startField = props.startField ? String(props.startField) : undefined
|
||||
let endField = props.endField ? String(props.endField) : undefined
|
||||
let durationField = props.durationField ? String(props.durationField) : undefined
|
||||
|
||||
const children = (
|
||||
Array.isArray(kit.children)
|
||||
? kit.children
|
||||
@@ -46,19 +60,66 @@ function resolveBinding(kit: Record<string, unknown>): OvertimeKitBinding {
|
||||
? props.rule
|
||||
: []
|
||||
) as Record<string, any>[]
|
||||
|
||||
for (const c of children) {
|
||||
if (!c?.field) continue
|
||||
const sem = String(c.props?._fc_kit_field || '')
|
||||
if (sem === 'overtimePerson' && !personField) personField = String(c.field)
|
||||
if (sem === 'overtimeStart' && !startField) startField = String(c.field)
|
||||
if (sem === 'overtimeEnd' && !endField) endField = String(c.field)
|
||||
if (sem === 'overtimeDuration' && !durationField) durationField = String(c.field)
|
||||
}
|
||||
return { personField, durationField }
|
||||
|
||||
if (!startField || !endField || !durationField) {
|
||||
const dateFields: string[] = []
|
||||
let dur: string | undefined
|
||||
for (const c of children) {
|
||||
if (!c?.field) continue
|
||||
const t = String(c.type || '')
|
||||
if (t === 'datePicker' || t === 'date' || t === 'timePicker' || t === 'time') {
|
||||
dateFields.push(String(c.field))
|
||||
} else if (
|
||||
(t === 'inputNumber' || t === 'number') &&
|
||||
(String(c.title || '').includes('时长') || String(c.props?._fc_kit_field || '') === 'overtimeDuration')
|
||||
) {
|
||||
dur = String(c.field)
|
||||
}
|
||||
}
|
||||
startField = startField || dateFields[0]
|
||||
endField = endField || dateFields[1]
|
||||
durationField = durationField || dur
|
||||
}
|
||||
|
||||
if (!startField || !endField || !durationField) return null
|
||||
|
||||
return {
|
||||
personField,
|
||||
startField,
|
||||
endField,
|
||||
durationField,
|
||||
windows: {
|
||||
workdayOvertimeStart: String(
|
||||
props.workdayOvertimeStart || DEFAULT_OVERTIME_WINDOWS.workdayOvertimeStart
|
||||
),
|
||||
workdayOvertimeEnd: String(
|
||||
props.workdayOvertimeEnd || DEFAULT_OVERTIME_WINDOWS.workdayOvertimeEnd
|
||||
),
|
||||
restBreakStart: String(props.restBreakStart || DEFAULT_OVERTIME_WINDOWS.restBreakStart),
|
||||
restBreakEnd: String(props.restBreakEnd || DEFAULT_OVERTIME_WINDOWS.restBreakEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listOvertimeKitBindings(schema: unknown): OvertimeKitBinding[] {
|
||||
const flat: Record<string, unknown>[] = []
|
||||
walk(asRules(schema), flat)
|
||||
return flat.filter((r) => String(r.type || '') === 'overtimeKit').map(resolveBinding)
|
||||
const out: OvertimeKitBinding[] = []
|
||||
for (const r of flat) {
|
||||
if (String(r.type || '') !== 'overtimeKit') continue
|
||||
const b = resolveBinding(r)
|
||||
if (b) out.push(b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isEmptyPersonValue(raw: unknown): boolean {
|
||||
@@ -91,28 +152,57 @@ export function applyOvertimeKitDefaults(
|
||||
return data
|
||||
}
|
||||
|
||||
function resolveDurationField(kit: Record<string, unknown>): string | undefined {
|
||||
const binding = resolveBinding(kit)
|
||||
if (binding.durationField) return binding.durationField
|
||||
const props = (kit.props && typeof kit.props === 'object' ? kit.props : {}) as Record<
|
||||
string,
|
||||
any
|
||||
>
|
||||
const children = (
|
||||
Array.isArray(kit.children)
|
||||
? kit.children
|
||||
: Array.isArray(props.rule)
|
||||
? props.rule
|
||||
: []
|
||||
) as Record<string, any>[]
|
||||
for (const c of children) {
|
||||
if (!c?.field) continue
|
||||
const t = String(c.type || '')
|
||||
if ((t === 'inputNumber' || t === 'number') && String(c.title || '').includes('时长')) {
|
||||
return String(c.field)
|
||||
/** 未手改时写回时长;返回是否有变更 */
|
||||
export function applyOvertimeKitDurationsToFormData(
|
||||
schema: unknown,
|
||||
formData: Record<string, unknown>,
|
||||
isRestDay: RestDayResolver
|
||||
): { data: Record<string, unknown>; changed: boolean } {
|
||||
const data = { ...formData }
|
||||
let changed = false
|
||||
for (const kit of listOvertimeKitBindings(schema)) {
|
||||
const manualKey = OVERTIME_MANUAL_PREFIX + kit.durationField
|
||||
if (data[manualKey]) continue
|
||||
const hours = calcOvertimeHours(
|
||||
String(data[kit.startField] || ''),
|
||||
String(data[kit.endField] || ''),
|
||||
kit.windows,
|
||||
isRestDay
|
||||
)
|
||||
if (hours == null) continue
|
||||
if (data[kit.durationField] !== hours) {
|
||||
data[kit.durationField] = hours
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
return { data, changed }
|
||||
}
|
||||
|
||||
export function markOvertimeDurationManual(
|
||||
formData: Record<string, unknown>,
|
||||
durationField: string,
|
||||
manual = true
|
||||
) {
|
||||
const data = { ...formData }
|
||||
const key = OVERTIME_MANUAL_PREFIX + durationField
|
||||
if (manual) data[key] = true
|
||||
else delete data[key]
|
||||
return data
|
||||
}
|
||||
|
||||
export function isOvertimeDurationField(schema: unknown, field: string): boolean {
|
||||
return listOvertimeKitBindings(schema).some((k) => k.durationField === field)
|
||||
}
|
||||
|
||||
export function findOvertimeKitByStartOrEnd(
|
||||
schema: unknown,
|
||||
field: string
|
||||
): OvertimeKitBinding | null {
|
||||
return (
|
||||
listOvertimeKitBindings(schema).find(
|
||||
(k) => k.startField === field || k.endField === field
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,14 +214,14 @@ export function ensureOvertimeKitRuleProps(rules: unknown): any[] {
|
||||
if (!raw || typeof raw !== 'object') return raw
|
||||
const rule: any = { ...(raw as object) }
|
||||
if (String(rule.type || '') === 'overtimeKit') {
|
||||
const durationField = resolveDurationField(rule)
|
||||
const binding = resolveBinding(rule)
|
||||
const patchChildren = (list: any[]): any[] =>
|
||||
list.map((c) => {
|
||||
if (!c || typeof c !== 'object') return c
|
||||
const child: any = { ...c }
|
||||
const t = String(child.type || '')
|
||||
const isDuration =
|
||||
(durationField && child.field === durationField) ||
|
||||
(binding && child.field === binding.durationField) ||
|
||||
((t === 'inputNumber' || t === 'number') &&
|
||||
String(child.title || '').includes('时长'))
|
||||
if (isDuration) {
|
||||
|
||||
@@ -19,8 +19,13 @@ export type ProgressPersonInput = {
|
||||
export type PersonLineTone = 'ok' | 'pending' | 'danger' | 'mute' | ''
|
||||
|
||||
export type ProgressPersonLine = {
|
||||
/** 完整一行(含时间),详情页等纯文本场景用 */
|
||||
text: string
|
||||
tone: PersonLineTone
|
||||
/** 不含时间的主体,打印右对齐时间用 */
|
||||
body: string
|
||||
/** 审批时间(可空) */
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const ACTED_STATUS = new Set(['approved', 'rejected', 'returned', 'completed', 'transferred'])
|
||||
@@ -52,6 +57,21 @@ function toneForStatus(status: string): PersonLineTone {
|
||||
return ''
|
||||
}
|
||||
|
||||
function pushLine(
|
||||
out: ProgressPersonLine[],
|
||||
body: string,
|
||||
timestamp: string,
|
||||
tone: PersonLineTone
|
||||
) {
|
||||
const ts = String(timestamp || '').trim()
|
||||
out.push({
|
||||
body,
|
||||
timestamp: ts,
|
||||
text: ts ? `${body} ${ts}` : body,
|
||||
tone
|
||||
})
|
||||
}
|
||||
|
||||
/** 将 progress.people 格式化为「姓名 意见/状态 时间」纯文本行 */
|
||||
export function formatProgressPersonLines(
|
||||
people: ProgressPersonInput[] | null | undefined
|
||||
@@ -67,7 +87,7 @@ export function formatProgressPersonLines(
|
||||
const tone = toneForStatus(status)
|
||||
|
||||
if (status === 'done') {
|
||||
out.push({ text: `${name} 无需处理`, tone: 'mute' })
|
||||
pushLine(out, `${name} 无需处理`, '', 'mute')
|
||||
continue
|
||||
}
|
||||
if (status === 'transferred') {
|
||||
@@ -77,34 +97,25 @@ export function formatProgressPersonLines(
|
||||
? `转交给${target} ${comment}`
|
||||
: `转交给${target}`
|
||||
: comment || statusFallbackLabel('transferred')
|
||||
out.push({
|
||||
text: ts ? `${name} ${mid} ${ts}` : `${name} ${mid}`,
|
||||
tone: 'mute'
|
||||
})
|
||||
pushLine(out, `${name} ${mid}`, ts, 'mute')
|
||||
continue
|
||||
}
|
||||
if (status === 'pending' || status === 'running' || status === 'not_reached' || !status) {
|
||||
// 无 status 时:若有意见/时间仍按已处理拼;否则待处理
|
||||
if (!status && (comment || ts)) {
|
||||
const mid = comment || statusFallbackLabel('approved')
|
||||
out.push({
|
||||
text: ts ? `${name} ${mid} ${ts}` : `${name} ${mid}`,
|
||||
tone: 'ok'
|
||||
})
|
||||
pushLine(out, `${name} ${mid}`, ts, 'ok')
|
||||
continue
|
||||
}
|
||||
out.push({ text: `${name} 待处理`, tone: 'pending' })
|
||||
pushLine(out, `${name} 待处理`, '', 'pending')
|
||||
continue
|
||||
}
|
||||
if (ACTED_STATUS.has(status) || comment || ts) {
|
||||
const mid = comment || statusFallbackLabel(status)
|
||||
out.push({
|
||||
text: ts ? `${name} ${mid} ${ts}` : `${name} ${mid}`,
|
||||
tone: tone || 'ok'
|
||||
})
|
||||
pushLine(out, `${name} ${mid}`, ts, tone || 'ok')
|
||||
continue
|
||||
}
|
||||
out.push({ text: `${name} 待处理`, tone: 'pending' })
|
||||
pushLine(out, `${name} 待处理`, '', 'pending')
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -50,3 +50,58 @@ export function getOaUploadDisplayName(file: OaUploadFileLike | null | undefined
|
||||
return '未命名文件'
|
||||
}
|
||||
}
|
||||
|
||||
/** 打印 PDF:上传字段值 → 仅附件文件名列表(不含 MinIO/目录路径) */
|
||||
export function formatOaUploadPathsForPrint(value: unknown): string[] {
|
||||
if (value == null || value === '') return []
|
||||
if (Array.isArray(value)) {
|
||||
const out: string[] = []
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
const parts = item
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
for (const p of parts) {
|
||||
out.push(getOaUploadDisplayName({ url: p }))
|
||||
}
|
||||
} else if (item && typeof item === 'object') {
|
||||
out.push(getOaUploadDisplayName(item as OaUploadFileLike))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((p) => getOaUploadDisplayName({ url: p }))
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return [getOaUploadDisplayName(value as OaUploadFileLike)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** 是否像对象存储 / 附件地址(打印展示时应收成文件名) */
|
||||
export function isOaStoredFileRef(text: string): boolean {
|
||||
const s = String(text || '').trim()
|
||||
if (!s) return false
|
||||
if (/\/minio\//i.test(s)) return true
|
||||
if (/^https?:\/\//i.test(s) && /\.[a-z0-9]{2,5}(\?|$)/i.test(s)) return true
|
||||
if (s.startsWith('/') && /\.[a-z0-9]{2,5}(\?|$)/i.test(s)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印单元格文案:若整段为附件路径(可多条,顿号/逗号分隔),仅保留文件名。
|
||||
* 普通业务文案原样返回,避免误伤「省 / 市 / 区」等路径。
|
||||
*/
|
||||
export function formatOaPrintAttachmentText(text: unknown): string {
|
||||
const s = String(text ?? '').trim()
|
||||
if (!s || s === '-') return s
|
||||
const parts = s.split(/[、,]/).map((x) => x.trim()).filter(Boolean)
|
||||
if (!parts.length || !parts.every(isOaStoredFileRef)) return s
|
||||
return parts.map((p) => getOaUploadDisplayName({ url: p })).join('、')
|
||||
}
|
||||
Reference in new issue
Block a user