-
diff --git a/src/layout/typeLine.vue b/src/layout/typeLine.vue
index 18b0509..6c1cfc3 100644
--- a/src/layout/typeLine.vue
+++ b/src/layout/typeLine.vue
@@ -14,6 +14,7 @@ import {
} from 'reka-ui'
import { useWindowSize } from '@vueuse/core'
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
+import { readCurrentProjectId } from '@/lib/workspace'
interface TypeLineCategory {
key: string
label: string
@@ -45,6 +46,7 @@ const props = withDefaults(
)
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
+const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`)
const readStoredCategory = (key: string) => {
const sessionValue = sessionStorage.getItem(key)
@@ -60,7 +62,7 @@ const writeStoredCategory = (key: string, value: string) => {
const resolveInitialCategory = () => {
const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
if (!props.persistActiveCategory) return defaultKey
- const savedKey = readStoredCategory(cacheKey.value)
+ const savedKey = readStoredCategory(scopedCacheKey.value)
const validSavedKey = props.categories.some(item => item.key === savedKey)
return validSavedKey ? (savedKey as string) : defaultKey
}
@@ -68,7 +70,7 @@ const resolveInitialCategory = () => {
const activeCategory = ref(resolveInitialCategory())
watch(
- () => [props.categories, props.defaultCategory, cacheKey.value],
+ () => [props.categories, props.defaultCategory, scopedCacheKey.value],
() => {
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
if (isCurrentValid) return
@@ -82,7 +84,7 @@ watch(
const switchCategory = (cat: string) => {
activeCategory.value = cat
if (!props.persistActiveCategory) return
- writeStoredCategory(cacheKey.value, cat)
+ writeStoredCategory(scopedCacheKey.value, cat)
}
const activeComponent = computed(() => {
diff --git a/src/lib/decimal.ts b/src/lib/decimal.ts
index 423458c..d625b46 100644
--- a/src/lib/decimal.ts
+++ b/src/lib/decimal.ts
@@ -8,9 +8,26 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value)
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
-const isFiniteNumber = (value: unknown): value is number =>
+export const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
+export const toFiniteNumberOrNull = (value: unknown): number | null =>
+ isFiniteNumber(value) ? value : null
+
+export const toFiniteNumberOrZero = (value: unknown): number =>
+ toFiniteNumberOrNull(value) ?? 0
+
+export const toFiniteNumber = (value: unknown): number | null => {
+ if (isFiniteNumber(value)) return value
+ if (typeof value === 'string') {
+ const trimmed = value.trim()
+ if (!trimmed) return null
+ const numeric = Number(trimmed)
+ return Number.isFinite(numeric) ? numeric : null
+ }
+ return null
+}
+
const sumFiniteValues = (values: Iterable
) => {
let total = new Decimal(0)
for (const value of values) {
@@ -32,6 +49,12 @@ export const sumByNumber = (list: T[], pick: (item: T) => MaybeNumber) => {
return total.toNumber()
}
+export const sumNullableNumbers = (values: MaybeNumber[]): number | null => {
+ const validValues = values.filter(isFiniteNumber)
+ if (validValues.length === 0) return null
+ return addNumbers(...validValues)
+}
+
export const decimalAggSum = (params: { values?: unknown[] }) => {
const values = params.values || []
let hasFinite = false
diff --git a/src/lib/number.ts b/src/lib/number.ts
index 9da7bab..96594fb 100644
--- a/src/lib/number.ts
+++ b/src/lib/number.ts
@@ -1,10 +1,11 @@
-import { evaluateDecimalExpression, roundTo } from '@/lib/decimal'
+import {
+ evaluateDecimalExpression,
+ isFiniteNumber,
+ roundTo,
+ toFiniteNumberOrNull
+} from '@/lib/decimal'
-export const isFiniteNumber = (value: unknown): value is number =>
- typeof value === 'number' && Number.isFinite(value)
-
-export const toFiniteNumberOrNull = (value: unknown): number | null =>
- isFiniteNumber(value) ? value : null
+export { isFiniteNumber, toFiniteNumberOrNull }
export const parseNumberOrNull = (
value: unknown,
diff --git a/src/lib/pricingHourlyCalc.ts b/src/lib/pricingHourlyCalc.ts
index 4c48d9a..77bf4c8 100644
--- a/src/lib/pricingHourlyCalc.ts
+++ b/src/lib/pricingHourlyCalc.ts
@@ -6,8 +6,7 @@
*/
import { expertList } from '@/sql'
-import { roundTo, toDecimal } from '@/lib/decimal'
-import { toFiniteNumberOrNull } from '@/lib/number'
+import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
/* ----------------------------------------------------------------
diff --git a/src/lib/pricingMethodTotals.ts b/src/lib/pricingMethodTotals.ts
index 17fcb11..108c7dd 100644
--- a/src/lib/pricingMethodTotals.ts
+++ b/src/lib/pricingMethodTotals.ts
@@ -5,8 +5,7 @@ import {
getServiceDictById,
taskList
} from '@/sql'
-import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
-import { toFiniteNumberOrNull } from '@/lib/number'
+import { roundTo, sumByNumber, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
import { getScaleBudgetFee } from '@/lib/pricingScaleFee'
import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail'
import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
diff --git a/src/lib/pricingPersistControl.ts b/src/lib/pricingPersistControl.ts
index dc22b4b..eaf140e 100644
--- a/src/lib/pricingPersistControl.ts
+++ b/src/lib/pricingPersistControl.ts
@@ -5,15 +5,20 @@
* 用于控制计价法组件在清除/重建默认数据时的竞态。
*/
+import { readCurrentProjectId } from '@/lib/workspace'
+
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
+export const buildProjectScopedSessionKey = (prefix: string, dbKey: string) =>
+ `${prefix}${readCurrentProjectId()}:${dbKey}`
+
/**
* 判断当前是否应跳过持久化写入
* 用于防止组件卸载时覆盖刚被清除的数据
*/
export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean => {
- const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}`
+ const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
@@ -40,7 +45,7 @@ export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean
* 读取后立即清除标记(一次性)
*/
export const shouldForceDefaultLoad = (dbKey: string): boolean => {
- const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}`
+ const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
@@ -54,7 +59,7 @@ export const shouldForceDefaultLoad = (dbKey: string): boolean => {
* @param durationMs 有效时长(毫秒),默认 3000ms
*/
export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
- const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}`
+ const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey)
const now = Date.now()
sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`)
}
@@ -65,6 +70,6 @@ export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
* @param durationMs 有效时长(毫秒),默认 3000ms
*/
export const markForceDefaultLoad = (dbKey: string, durationMs = 3000): void => {
- const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}`
+ const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey)
sessionStorage.setItem(storageKey, String(Date.now() + durationMs))
}
diff --git a/src/lib/pricingScaleCalc.ts b/src/lib/pricingScaleCalc.ts
index aba09b5..c9c8994 100644
--- a/src/lib/pricingScaleCalc.ts
+++ b/src/lib/pricingScaleCalc.ts
@@ -6,7 +6,7 @@
*/
import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql'
-import { toFiniteNumberOrNull } from '@/lib/number'
+import { toFiniteNumberOrNull } from '@/lib/decimal'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
import type {
ScaleCalcRow,
diff --git a/src/lib/pricingScaleFee.ts b/src/lib/pricingScaleFee.ts
index b69906e..73c28e4 100644
--- a/src/lib/pricingScaleFee.ts
+++ b/src/lib/pricingScaleFee.ts
@@ -1,6 +1,5 @@
import { getBasicFeeFromScale } from '@/sql'
-import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
-import { toFiniteNumberOrNull } from '@/lib/number'
+import { addNumbers, roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
type ScaleMode = 'cost' | 'area'
diff --git a/src/lib/pricingWorkloadCalc.ts b/src/lib/pricingWorkloadCalc.ts
index 171542c..e821450 100644
--- a/src/lib/pricingWorkloadCalc.ts
+++ b/src/lib/pricingWorkloadCalc.ts
@@ -6,8 +6,7 @@
*/
import { taskList } from '@/sql'
-import { roundTo, toDecimal } from '@/lib/decimal'
-import { toFiniteNumberOrNull } from '@/lib/number'
+import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
import { getDefaultConsultCategoryFactor } from '@/lib/pricingScaleCalc'
import type { WorkloadCalcRow, TaskLite } from '@/types/pricing'
diff --git a/src/lib/projectRegistry.ts b/src/lib/projectRegistry.ts
new file mode 100644
index 0000000..509f6dd
--- /dev/null
+++ b/src/lib/projectRegistry.ts
@@ -0,0 +1,149 @@
+import { QUICK_PROJECT_ID, normalizeProjectId } from '@/lib/workspace'
+
+const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1'
+
+export interface ProjectMeta {
+ id: string
+ name: string
+ createdAt: string
+ updatedAt: string
+ lastOpenedAt: string
+}
+
+type ProjectRegistryPayload = {
+ projects: ProjectMeta[]
+}
+
+const nowIso = () => new Date().toISOString()
+const lastEditedTouchAt = new Map()
+
+const defaultProjects = (): ProjectMeta[] => {
+ return []
+}
+
+const sanitizeProject = (item: Partial | null | undefined): ProjectMeta | null => {
+ if (!item || typeof item !== 'object') return null
+ const id = normalizeProjectId(item.id)
+ const name = String(item.name || '').trim() || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`)
+ const createdAt = typeof item.createdAt === 'string' && item.createdAt ? item.createdAt : nowIso()
+ const updatedAt = typeof item.updatedAt === 'string' && item.updatedAt ? item.updatedAt : createdAt
+ const lastOpenedAt =
+ typeof item.lastOpenedAt === 'string' && item.lastOpenedAt ? item.lastOpenedAt : updatedAt
+ return { id, name, createdAt, updatedAt, lastOpenedAt }
+}
+
+const readPayload = (): ProjectRegistryPayload => {
+ try {
+ const raw = localStorage.getItem(PROJECT_REGISTRY_KEY)
+ if (!raw) return { projects: defaultProjects() }
+ const parsed = JSON.parse(raw) as Partial
+ const projects = Array.isArray(parsed?.projects)
+ ? parsed.projects.map(item => sanitizeProject(item)).filter((item): item is ProjectMeta => Boolean(item))
+ : []
+ if (projects.length === 0) return { projects: defaultProjects() }
+ return { projects }
+ } catch {
+ return { projects: defaultProjects() }
+ }
+}
+
+const writePayload = (payload: ProjectRegistryPayload) => {
+ localStorage.setItem(PROJECT_REGISTRY_KEY, JSON.stringify(payload))
+}
+
+export const listProjects = () => {
+ const payload = readPayload()
+ return payload.projects
+ .filter(item => item.id !== QUICK_PROJECT_ID)
+ .slice()
+ .sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime())
+}
+
+export const upsertProject = (
+ projectIdRaw: string,
+ nameRaw?: string,
+ options?: {
+ touchUpdatedAt?: boolean
+ touchLastOpenedAt?: boolean
+ }
+) => {
+ const id = normalizeProjectId(projectIdRaw)
+ if (id === QUICK_PROJECT_ID) return
+ const name = String(nameRaw || '').trim()
+ const payload = readPayload()
+ const now = nowIso()
+ const touchUpdatedAt = options?.touchUpdatedAt === true
+ const touchLastOpenedAt = options?.touchLastOpenedAt !== false
+ const index = payload.projects.findIndex(item => item.id === id)
+ if (index < 0) {
+ payload.projects.push({
+ id,
+ name: name || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`),
+ createdAt: now,
+ updatedAt: now,
+ lastOpenedAt: now
+ })
+ } else {
+ const current = payload.projects[index]
+ payload.projects[index] = {
+ ...current,
+ name: name || current.name,
+ updatedAt: touchUpdatedAt ? now : current.updatedAt,
+ lastOpenedAt: touchLastOpenedAt ? now : current.lastOpenedAt
+ }
+ }
+ writePayload(payload)
+}
+
+export const touchProjectEdited = (
+ projectIdRaw: string,
+ options?: {
+ throttleMs?: number
+ }
+) => {
+ const id = normalizeProjectId(projectIdRaw)
+ if (id === QUICK_PROJECT_ID) return false
+ const throttleMs = Math.max(0, Number(options?.throttleMs ?? 5000))
+ const nowMs = Date.now()
+ const lastTouch = lastEditedTouchAt.get(id) ?? 0
+ if (throttleMs > 0 && nowMs - lastTouch < throttleMs) return false
+
+ const payload = readPayload()
+ const index = payload.projects.findIndex(item => item.id === id)
+ if (index < 0) return false
+ const now = nowIso()
+ const current = payload.projects[index]
+ payload.projects[index] = {
+ ...current,
+ updatedAt: now,
+ lastOpenedAt: now
+ }
+ writePayload(payload)
+ lastEditedTouchAt.set(id, nowMs)
+ return true
+}
+
+export const createProject = (nameRaw?: string) => {
+ const payload = readPayload()
+ const now = nowIso()
+ let id = ''
+ do {
+ id = normalizeProjectId(`p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
+ } while (payload.projects.some(item => item.id === id))
+ const name = String(nameRaw || '').trim() || `项目-${payload.projects.length + 1}`
+ const project: ProjectMeta = { id, name, createdAt: now, updatedAt: now, lastOpenedAt: now }
+ payload.projects.push(project)
+ writePayload(payload)
+ return project
+}
+
+export const deleteProject = (projectIdRaw: string) => {
+ const id = normalizeProjectId(projectIdRaw)
+ if (id === QUICK_PROJECT_ID) return false
+ const payload = readPayload()
+ const nextProjects = payload.projects.filter(item => item.id !== id)
+ if (nextProjects.length === payload.projects.length) return false
+ payload.projects = nextProjects
+ writePayload(payload)
+ return true
+}
diff --git a/src/lib/projectSessionLock.ts b/src/lib/projectSessionLock.ts
new file mode 100644
index 0000000..4a76810
--- /dev/null
+++ b/src/lib/projectSessionLock.ts
@@ -0,0 +1,159 @@
+const LOCK_TTL_MS = 12_000
+const HEARTBEAT_MS = 4_000
+export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:'
+const CHANNEL_NAME = 'jgjs-project-lock-channel'
+
+type LockPayload = {
+ sessionId: string
+ projectId: string
+ updatedAt: number
+}
+
+const now = () => Date.now()
+const lockKeyOf = (projectId: string) => `${PROJECT_LOCK_KEY_PREFIX}${projectId}`
+
+const parseLockPayload = (raw: string | null): LockPayload | null => {
+ if (!raw) return null
+ try {
+ const data = JSON.parse(raw) as Partial
+ if (!data || typeof data !== 'object') return null
+ if (typeof data.sessionId !== 'string' || typeof data.projectId !== 'string') return null
+ if (typeof data.updatedAt !== 'number' || !Number.isFinite(data.updatedAt)) return null
+ return {
+ sessionId: data.sessionId,
+ projectId: data.projectId,
+ updatedAt: data.updatedAt
+ }
+ } catch {
+ return null
+ }
+}
+
+const isExpired = (payload: LockPayload) => now() - payload.updatedAt > LOCK_TTL_MS
+
+const randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}`
+
+type InitProjectLockParams = {
+ projectId: string
+ onConflict: (conflicted: boolean) => void
+}
+
+export const initProjectSessionLock = (params: InitProjectLockParams) => {
+ const projectId = String(params.projectId || '').trim()
+ const onConflict = params.onConflict
+ const sessionId = randomSessionId()
+ const key = lockKeyOf(projectId)
+ let conflicted = false
+ let heartbeatTimer: ReturnType | null = null
+ let bc: BroadcastChannel | null = null
+
+ const emitConflict = (next: boolean) => {
+ if (conflicted === next) return
+ conflicted = next
+ onConflict(next)
+ }
+
+ const writeHeartbeat = () => {
+ if (conflicted) return
+ const payload: LockPayload = { sessionId, projectId, updatedAt: now() }
+ localStorage.setItem(key, JSON.stringify(payload))
+ bc?.postMessage({ type: 'heartbeat', projectId, sessionId })
+ }
+
+ const clearOwnLock = () => {
+ const current = parseLockPayload(localStorage.getItem(key))
+ if (current?.sessionId === sessionId) {
+ localStorage.removeItem(key)
+ }
+ bc?.postMessage({ type: 'release', projectId, sessionId })
+ }
+
+ const detectConflict = () => {
+ const current = parseLockPayload(localStorage.getItem(key))
+ if (!current || isExpired(current)) {
+ emitConflict(false)
+ writeHeartbeat()
+ return
+ }
+ emitConflict(current.sessionId !== sessionId)
+ }
+
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== key) return
+ detectConflict()
+ }
+
+ const onBeforeUnload = () => {
+ clearOwnLock()
+ }
+
+ try {
+ const existing = parseLockPayload(localStorage.getItem(key))
+ if (existing && !isExpired(existing) && existing.sessionId !== sessionId) {
+ emitConflict(true)
+ } else {
+ writeHeartbeat()
+ }
+ } catch (error) {
+ console.error('init project session lock failed:', error)
+ }
+
+ if (!conflicted) {
+ heartbeatTimer = setInterval(writeHeartbeat, HEARTBEAT_MS)
+ }
+
+ if (typeof BroadcastChannel !== 'undefined') {
+ bc = new BroadcastChannel(CHANNEL_NAME)
+ bc.onmessage = (event: MessageEvent<{ type?: string; projectId?: string; sessionId?: string }>) => {
+ const payload = event.data
+ if (!payload || payload.projectId !== projectId) return
+ if (payload.sessionId === sessionId) return
+ if (payload.type === 'heartbeat') {
+ detectConflict()
+ }
+ }
+ }
+
+ window.addEventListener('storage', onStorage)
+ window.addEventListener('beforeunload', onBeforeUnload)
+
+ return {
+ get conflicted() {
+ return conflicted
+ },
+ release: () => {
+ if (heartbeatTimer) {
+ clearInterval(heartbeatTimer)
+ heartbeatTimer = null
+ }
+ window.removeEventListener('storage', onStorage)
+ window.removeEventListener('beforeunload', onBeforeUnload)
+ clearOwnLock()
+ if (bc) {
+ bc.close()
+ bc = null
+ }
+ }
+ }
+}
+
+export const hasActiveProjectSessionLock = (projectIdRaw: string) => {
+ const projectId = String(projectIdRaw || '').trim()
+ if (!projectId) return false
+ try {
+ const payload = parseLockPayload(localStorage.getItem(lockKeyOf(projectId)))
+ return Boolean(payload && !isExpired(payload))
+ } catch {
+ return false
+ }
+}
+
+export const collectActiveProjectSessionLocks = (projectIds: string[]) => {
+ const result = new Set()
+ for (const projectId of projectIds) {
+ if (hasActiveProjectSessionLock(projectId)) {
+ result.add(String(projectId || '').trim())
+ }
+ }
+ return result
+}
diff --git a/src/lib/reportExportBuilders.ts b/src/lib/reportExportBuilders.ts
index 0f160ac..311123c 100644
--- a/src/lib/reportExportBuilders.ts
+++ b/src/lib/reportExportBuilders.ts
@@ -1,6 +1,7 @@
import { serviceList } from '@/sql'
-import { roundTo } from '@/lib/decimal'
+import { roundTo, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
+export { toFiniteNumber, toFiniteNumberOrZero }
interface ScaleMethodRowLike {
id: string
@@ -168,21 +169,6 @@ interface ExportMethod5DetailLike {
remark: string
}
-export const toFiniteNumber = (value: unknown): number | null => {
- if (typeof value === 'number') {
- return Number.isFinite(value) ? value : null
- }
- if (typeof value === 'string') {
- const trimmed = value.trim()
- if (!trimmed) return null
- const num = Number(trimmed)
- return Number.isFinite(num) ? num : null
- }
- return null
-}
-
-export const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0
-
export const toSafeInteger = (value: unknown): number | null => {
const num = Number(value)
if (!Number.isInteger(num)) return null
@@ -579,17 +565,7 @@ export const buildServiceFee = (
method4: { fee: number } | null
) => {
const subtotal = toFiniteNumber(row?.subtotal)
- if (subtotal != null) return subtotal
-
- const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
- if (methodSum !== 0) return methodSum
-
- return sumNumbers([
- toFiniteNumber(row?.investScale),
- toFiniteNumber(row?.landScale),
- toFiniteNumber(row?.workload),
- toFiniteNumber(row?.hourly)
- ])
+ return subtotal != null ? toMoney(subtotal) : 0
}
export const buildMethod0 = (payload: RateMethodRowLike | null | undefined) => {
@@ -644,8 +620,7 @@ export const buildServiceFinalFee = (
method4: { fee: number } | null
) => {
const finalFee = toFiniteNumber(row?.finalFee)
- if (finalFee != null) return finalFee
+ if (finalFee != null) return toMoney(finalFee)
const subtotal = toFiniteNumber(row?.subtotal)
- if (subtotal != null) return subtotal
- return sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
+ return subtotal != null ? toMoney(subtotal) : 0
}
diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts
index 42d05b5..f83dd65 100644
--- a/src/lib/workspace.ts
+++ b/src/lib/workspace.ts
@@ -6,6 +6,11 @@ export const LEGACY_PROJECT_TAB_ID = 'ProjectCalcView'
export const FIXED_WORKSPACE_TAB_IDS = [PROJECT_TAB_ID, QUICK_TAB_ID] as const
export const WORKSPACE_MODE_STORAGE_KEY = 'jgjs-workspace-mode-v1'
+export const PROJECT_ID_QUERY_KEY = 'projectId'
+export const NEW_PROJECT_QUERY_KEY = 'newProject'
+export const DEFAULT_PROJECT_ID = 'default'
+export const QUICK_PROJECT_ID = 'quick'
+export const PROJECT_DB_NAME_PREFIX = 'DB'
export const QUICK_CONTRACT_ID = 'quick-contract-default'
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
@@ -59,3 +64,55 @@ export const consumePendingHomeImportFile = () => {
pendingHomeImportFile = null
return file
}
+
+export const normalizeProjectId = (value: unknown) => {
+ const raw = String(value || '').trim()
+ if (!raw) return DEFAULT_PROJECT_ID
+ return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || DEFAULT_PROJECT_ID
+}
+
+export const readCurrentProjectId = () => {
+ try {
+ const url = new URL(window.location.href)
+ return normalizeProjectId(url.searchParams.get(PROJECT_ID_QUERY_KEY))
+ } catch {
+ return DEFAULT_PROJECT_ID
+ }
+}
+
+export const writeProjectIdToUrl = (projectIdRaw: string) => {
+ try {
+ const projectId = normalizeProjectId(projectIdRaw)
+ const url = new URL(window.location.href)
+ url.searchParams.set(PROJECT_ID_QUERY_KEY, projectId)
+ window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
+ } catch (error) {
+ console.error('write project id to url failed:', error)
+ }
+}
+
+export const ensureProjectIdInUrl = () => {
+ const id = readCurrentProjectId()
+ writeProjectIdToUrl(id)
+ return id
+}
+
+export const buildProjectUrl = (projectIdRaw: string, options?: { newProject?: boolean }) => {
+ try {
+ const url = new URL(window.location.href)
+ url.searchParams.set(PROJECT_ID_QUERY_KEY, normalizeProjectId(projectIdRaw))
+ if (options?.newProject) {
+ url.searchParams.set(NEW_PROJECT_QUERY_KEY, '1')
+ } else {
+ url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
+ }
+ return `${url.pathname}${url.search}${url.hash}`
+ } catch {
+ return `/?${PROJECT_ID_QUERY_KEY}=${encodeURIComponent(normalizeProjectId(projectIdRaw))}`
+ }
+}
+
+export const getProjectDbName = (projectIdRaw: string) => {
+ const projectId = normalizeProjectId(projectIdRaw)
+ return `${PROJECT_DB_NAME_PREFIX}-${projectId}`
+}
diff --git a/src/lib/xmFactorDefaults.ts b/src/lib/xmFactorDefaults.ts
index e840272..60b3369 100644
--- a/src/lib/xmFactorDefaults.ts
+++ b/src/lib/xmFactorDefaults.ts
@@ -1,5 +1,5 @@
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
-import { toFiniteNumberOrNull } from '@/lib/number'
+import { toFiniteNumberOrNull } from '@/lib/decimal'
import { useKvStore } from '@/pinia/kv'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
diff --git a/src/lib/zxFwPricingSync.ts b/src/lib/zxFwPricingSync.ts
index e0e6006..4a942e2 100644
--- a/src/lib/zxFwPricingSync.ts
+++ b/src/lib/zxFwPricingSync.ts
@@ -1,4 +1,4 @@
-import { roundTo, sumByNumber } from '@/lib/decimal'
+import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals'
import {
isSameNullableNumber,
@@ -13,6 +13,7 @@ import {
parseScopedRowId,
resolveScaleRowMajorDictId as resolveRowMajorDictId
} from '@/lib/pricingScaleLink'
+import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { useKvStore } from '@/pinia/kv'
import { getServiceDictItemById } from '@/sql'
import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing'
@@ -59,6 +60,57 @@ const getScaleMethodTotalBudgetFee = (rows: ScaleDetailRow[]) => {
return roundTo(sumByNumber(rows, row => row.budgetFee ?? null), 2)
}
+type WorkloadDetailRow = {
+ id: string
+ conversion?: number | null
+ workload?: number | null
+ budgetAdoptedUnitPrice?: number | null
+ consultCategoryFactor?: number | null
+ basicFee?: number | null
+ serviceFee?: number | null
+}
+
+const normalizeServiceIdSet = (serviceIds?: Array) =>
+ new Set((serviceIds || []).map(id => String(id || '').trim()).filter(Boolean))
+
+const calcWorkloadBasicFee = (row: WorkloadDetailRow) => {
+ if (String(row.id || '').startsWith('task-none-')) return null
+ const price = row.budgetAdoptedUnitPrice
+ const conversion = row.conversion
+ const workload = row.workload
+ if (
+ typeof price !== 'number' ||
+ !Number.isFinite(price) ||
+ typeof conversion !== 'number' ||
+ !Number.isFinite(conversion) ||
+ typeof workload !== 'number' ||
+ !Number.isFinite(workload)
+ ) {
+ return null
+ }
+ return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
+}
+
+const calcWorkloadServiceFee = (row: WorkloadDetailRow) => {
+ if (String(row.id || '').startsWith('task-none-')) return null
+ const factor = row.consultCategoryFactor
+ const basicFee = calcWorkloadBasicFee(row)
+ if (
+ basicFee == null ||
+ typeof factor !== 'number' ||
+ !Number.isFinite(factor)
+ ) {
+ return null
+ }
+ return roundTo(toDecimal(basicFee).mul(factor), 2)
+}
+
+const getWorkloadMethodTotalServiceFee = (rows: WorkloadDetailRow[]) => {
+ const hasValue = rows.some(row => typeof row.serviceFee === 'number' && Number.isFinite(row.serviceFee))
+ if (!hasValue) return null
+ return roundTo(sumByNumber(rows, row => row.serviceFee ?? null), 2)
+}
+
const matchesChangedScaleRow = (row: ScaleDetailRow, changedRowIds?: Set) => {
if (!changedRowIds || changedRowIds.size === 0) return true
const directRowId = String(row.id || '').trim()
@@ -232,6 +284,233 @@ export const syncContractScaleToPricing = async (
}
}
+const syncScaleMethodFactors = async (params: {
+ contractId: string
+ serviceId: string
+ method: 'investScale' | 'landScale'
+ syncConsultFactor: boolean
+ consultFactor: number | null
+ majorChangedRowIds?: Set
+ majorFactorMap: Map
+}) => {
+ const store = useZxFwPricingStore()
+ const methodState = await store.loadServicePricingMethodState(
+ params.contractId,
+ params.serviceId,
+ params.method
+ )
+ if (!methodState?.detailRows?.length) return 0
+
+ let changed = false
+ let changedRowCount = 0
+ const mode = params.method === 'investScale' ? 'cost' : 'area'
+ const nextRows = methodState.detailRows.map(rawRow => {
+ const row = { ...rawRow }
+ let rowChanged = false
+
+ if (params.syncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) {
+ row.consultCategoryFactor = params.consultFactor
+ rowChanged = true
+ }
+
+ if (params.majorChangedRowIds?.size) {
+ const majorDictId = resolveRowMajorDictId(row)
+ if (params.majorChangedRowIds.has(majorDictId)) {
+ const nextMajorFactor = params.majorFactorMap.get(majorDictId) ?? null
+ if (!isSameNullableNumber(row.majorFactor, nextMajorFactor)) {
+ row.majorFactor = nextMajorFactor
+ rowChanged = true
+ }
+ }
+ }
+
+ if (!rowChanged) return rawRow
+ const recomputed = recomputeScaleDetailRow(row, mode)
+ if (isSameScaleDetailRow(rawRow, recomputed, mode)) return rawRow
+ changed = true
+ changedRowCount += 1
+ return recomputed
+ })
+
+ if (!changed) return 0
+
+ store.setServicePricingMethodState(
+ params.contractId,
+ params.serviceId,
+ params.method,
+ {
+ detailRows: nextRows,
+ projectCount: methodState.projectCount ?? null
+ },
+ { force: true }
+ )
+
+ await syncPricingTotalToZxFw({
+ contractId: params.contractId,
+ serviceId: params.serviceId,
+ field: params.method,
+ value: getScaleMethodTotalBudgetFee(nextRows)
+ })
+ return changedRowCount
+}
+
+const syncWorkloadMethodConsultFactor = async (params: {
+ contractId: string
+ serviceId: string
+ consultFactor: number | null
+}) => {
+ const store = useZxFwPricingStore()
+ const methodState = await store.loadServicePricingMethodState(
+ params.contractId,
+ params.serviceId,
+ 'workload'
+ )
+ if (!methodState?.detailRows?.length) return 0
+
+ let changed = false
+ let changedRowCount = 0
+ const nextRows = methodState.detailRows.map(rawRow => {
+ const row = { ...rawRow }
+ let rowChanged = false
+
+ if (!isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) {
+ row.consultCategoryFactor = params.consultFactor
+ rowChanged = true
+ }
+
+ const nextBasicFee = calcWorkloadBasicFee(row)
+ const nextServiceFee = calcWorkloadServiceFee(row)
+ if (!isSameNullableNumber(row.basicFee, nextBasicFee)) {
+ row.basicFee = nextBasicFee
+ rowChanged = true
+ }
+ if (!isSameNullableNumber(row.serviceFee, nextServiceFee)) {
+ row.serviceFee = nextServiceFee
+ rowChanged = true
+ }
+
+ if (!rowChanged) return rawRow
+ changed = true
+ changedRowCount += 1
+ return row
+ })
+
+ if (!changed) return 0
+
+ store.setServicePricingMethodState(
+ params.contractId,
+ params.serviceId,
+ 'workload',
+ {
+ detailRows: nextRows,
+ projectCount: methodState.projectCount ?? null
+ },
+ { force: true }
+ )
+
+ await syncPricingTotalToZxFw({
+ contractId: params.contractId,
+ serviceId: params.serviceId,
+ field: 'workload',
+ value: getWorkloadMethodTotalServiceFee(nextRows)
+ })
+ return changedRowCount
+}
+
+export interface ContractFactorSyncResult {
+ updatedServiceCount: number
+ updatedMethodCount: number
+ updatedRowCount: number
+}
+
+export const syncContractFactorsToPricing = async (
+ contractId: string,
+ options?: {
+ consultChangedServiceIds?: string[]
+ majorChangedRowIds?: string[]
+ }
+): Promise => {
+ const store = useZxFwPricingStore()
+ await store.loadContract(contractId)
+ const currentState = store.getContractState(contractId)
+ const selectedIds = Array.from(new Set((currentState?.selectedIds || []).map(id => String(id || '').trim()).filter(Boolean)))
+ if (selectedIds.length === 0) {
+ return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 }
+ }
+
+ const consultChangedServiceIdSet = normalizeServiceIdSet(options?.consultChangedServiceIds)
+ const majorChangedRowIdSet = normalizeChangedScaleRowIds(options?.majorChangedRowIds)
+ if (consultChangedServiceIdSet.size === 0 && majorChangedRowIdSet.size === 0) {
+ return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 }
+ }
+
+ const [consultFactorMap, majorFactorMap] = await Promise.all([
+ loadConsultCategoryFactorMap(`ht-consult-category-factor-v1-${contractId}`),
+ loadMajorFactorMap(`ht-major-factor-v1-${contractId}`)
+ ])
+
+ let updatedMethodCount = 0
+ let updatedRowCount = 0
+ const updatedServiceIdSet = new Set()
+
+ for (const serviceId of selectedIds) {
+ const syncConsultFactor = consultChangedServiceIdSet.has(serviceId)
+ const syncMajorFactor = majorChangedRowIdSet.size > 0
+ if (!syncConsultFactor && !syncMajorFactor) continue
+
+ const consultFactor = consultFactorMap.get(serviceId) ?? null
+
+ const investChangedCount = await syncScaleMethodFactors({
+ contractId,
+ serviceId,
+ method: 'investScale',
+ syncConsultFactor,
+ consultFactor,
+ majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined,
+ majorFactorMap
+ })
+ if (investChangedCount > 0) {
+ updatedServiceIdSet.add(serviceId)
+ updatedMethodCount += 1
+ updatedRowCount += investChangedCount
+ }
+
+ const landChangedCount = await syncScaleMethodFactors({
+ contractId,
+ serviceId,
+ method: 'landScale',
+ syncConsultFactor,
+ consultFactor,
+ majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined,
+ majorFactorMap
+ })
+ if (landChangedCount > 0) {
+ updatedServiceIdSet.add(serviceId)
+ updatedMethodCount += 1
+ updatedRowCount += landChangedCount
+ }
+
+ if (syncConsultFactor) {
+ const workloadChangedCount = await syncWorkloadMethodConsultFactor({
+ contractId,
+ serviceId,
+ consultFactor
+ })
+ if (workloadChangedCount > 0) {
+ updatedServiceIdSet.add(serviceId)
+ updatedMethodCount += 1
+ updatedRowCount += workloadChangedCount
+ }
+ }
+ }
+
+ return {
+ updatedServiceCount: updatedServiceIdSet.size,
+ updatedMethodCount,
+ updatedRowCount
+ }
+}
+
export const syncPricingTotalToZxFw = async (params: {
contractId: string
serviceId: string | number
diff --git a/src/main.ts b/src/main.ts
index 4a7a284..c62766f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -23,6 +23,8 @@ import piniaPersistedstate from '@/pinia/Plugin/indexdb'
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
+import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace'
+import { listProjects } from '@/lib/projectRegistry'
LicenseManager.setLicenseKey(
'[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b'
@@ -47,9 +49,28 @@ const AG_GRID_MODULES = [
LocaleModule,ValidationModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule ,ServerSideRowModelApiModule
]
+const pickBootstrapProjectId = () => {
+ try {
+ const url = new URL(window.location.href)
+ const explicit = String(url.searchParams.get('projectId') || '').trim()
+ if (explicit) return ensureProjectIdInUrl()
+ const projects = listProjects()
+ if (projects.length > 0) {
+ const lastEdited = projects[0]
+ url.searchParams.set('projectId', lastEdited.id)
+ window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
+ }
+ return ensureProjectIdInUrl()
+ } catch {
+ return ensureProjectIdInUrl()
+ }
+}
+
const pinia = createPinia()
+const currentProjectId = pickBootstrapProjectId()
pinia.use(
piniaPersistedstate({
+ name: getProjectDbName(currentProjectId),
storeName: 'pinia',
mode: 'multiple'
})
diff --git a/src/pinia/Plugin/indexdb.ts b/src/pinia/Plugin/indexdb.ts
index 6f9ee02..abdc946 100644
--- a/src/pinia/Plugin/indexdb.ts
+++ b/src/pinia/Plugin/indexdb.ts
@@ -1,5 +1,7 @@
import type { PiniaPluginContext } from 'pinia'
import localforage from 'localforage'
+import { touchProjectEdited } from '@/lib/projectRegistry'
+import { QUICK_PROJECT_ID, readCurrentProjectId } from '@/lib/workspace'
export type PersistOption = boolean | {
key?: string
@@ -84,6 +86,7 @@ export default (config?: PiniaStorageConfig) => {
if (!persistOptions) return
const storeId = context.store.$id
+ const shouldTouchProjectEditedAt = storeId !== 'tabs'
const baseStoreName = forageConfig.storeName || baseConfig.storeName || 'pinia-storage'
const resolvedStoreName = mode === 'multiple' ? `${baseStoreName}-${storeId}` : baseStoreName
const key = persistOptions.key || (mode === 'single' ? `${baseStoreName}-${storeId}` : resolvedStoreName)
@@ -96,6 +99,12 @@ export default (config?: PiniaStorageConfig) => {
const clonedState = JSON.parse(JSON.stringify(state))
return lf.setItem(key, trimStringValuesDeep(clonedState))
}
+ const markProjectEdited = () => {
+ if (!shouldTouchProjectEditedAt) return
+ const currentProjectId = readCurrentProjectId()
+ if (!currentProjectId || currentProjectId === QUICK_PROJECT_ID) return
+ touchProjectEdited(currentProjectId)
+ }
const storeExt = context.store as typeof context.store & PersistStoreExt
storeExt.$persistNow = async () => {
@@ -105,6 +114,7 @@ export default (config?: PiniaStorageConfig) => {
}
try {
await writeState(context.store.$state)
+ markProjectEdited()
} catch (error) {
console.error('pinia persist failed:', error)
}
@@ -124,12 +134,17 @@ export default (config?: PiniaStorageConfig) => {
context.store.$subscribe(
(_mutation, state) => {
- if (!hydrating) userMutatedBeforeHydrate = true
+ if (hydrating) return
+ userMutatedBeforeHydrate = true
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
- void writeState(state).catch(error => {
- console.error('pinia persist failed:', error)
- })
+ void writeState(state)
+ .then(() => {
+ markProjectEdited()
+ })
+ .catch(error => {
+ console.error('pinia persist failed:', error)
+ })
}, Math.max(0, persistDebounce))
},
{ detached: true }
diff --git a/src/pinia/zxFwPricing.ts b/src/pinia/zxFwPricing.ts
index dbe2368..d11bd78 100644
--- a/src/pinia/zxFwPricing.ts
+++ b/src/pinia/zxFwPricing.ts
@@ -1,7 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
-import { addNumbers } from '@/lib/decimal'
-import { toFiniteNumberOrNull } from '@/lib/number'
+import {
+ addNumbers,
+ roundTo,
+ sumNullableNumbers,
+ toFiniteNumberOrNull,
+ toFiniteNumberOrZero
+} from '@/lib/decimal'
import { waitForHydration } from '@/pinia/Plugin/indexdb'
import {
parseHtFeeMainStorageKey,
@@ -68,17 +73,9 @@ const toKey = (contractId: string | number) => String(contractId || '').trim()
const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim()
const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) =>
`${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}`
-const round3 = (value: number) => Number(value.toFixed(3))
-const isFiniteNumberValue = (value: unknown): value is number =>
- typeof value === 'number' && Number.isFinite(value)
-const sumNullableNumbers = (values: Array): number | null => {
- const validValues = values.filter(isFiniteNumberValue)
- if (validValues.length === 0) return null
- return addNumbers(...validValues)
-}
const round3Nullable = (value: number | null | undefined) => {
const numeric = toFiniteNumberOrNull(value)
- return numeric == null ? null : round3(numeric)
+ return numeric == null ? null : roundTo(numeric, 3)
}
const normalizeProcessValue = (value: unknown, rowId: string) => {
if (rowId === FIXED_ROW_ID) return null
@@ -89,6 +86,37 @@ const cloneAny = (value: T): T => {
if (value == null) return value
return JSON.parse(JSON.stringify(value)) as T
}
+interface HtFeeMainDetailRowLike {
+ id?: unknown
+ rateFee?: unknown
+ hourlyFee?: unknown
+ quantityUnitPriceFee?: unknown
+ subtotal?: unknown
+ [key: string]: unknown
+}
+interface HtRateMethodStateLike {
+ rate?: unknown
+ budgetFee?: unknown
+ [key: string]: unknown
+}
+interface HtHourlyMethodRowLike {
+ adoptedBudgetUnitPrice?: unknown
+ personnelCount?: unknown
+ workdayCount?: unknown
+ serviceBudget?: unknown
+}
+interface HtHourlyMethodStateLike {
+ detailRows?: HtHourlyMethodRowLike[]
+}
+interface HtQuantityMethodRowLike {
+ id?: unknown
+ budgetFee?: unknown
+ quantity?: unknown
+ unitPrice?: unknown
+}
+interface HtQuantityMethodStateLike {
+ detailRows?: HtQuantityMethodRowLike[]
+}
const normalizeRows = (rows: unknown): ZxFwDetailRow[] =>
(Array.isArray(rows) ? rows : []).map(item => {
@@ -422,6 +450,157 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
const loadHtFeeMethodState = htFeeStore.loadHtFeeMethodState
const removeHtFeeMethodState = htFeeStore.removeHtFeeMethodState
+ const sumHourlyMethodFee = (state: HtHourlyMethodStateLike | null): number | null => {
+ const rows = Array.isArray(state?.detailRows)
+ ? state.detailRows.filter(row => toFiniteNumberOrNull(row?.serviceBudget) != null)
+ : []
+ if (rows.length === 0) return null
+
+ let total = 0
+ let hasValid = false
+ for (const row of rows) {
+ const serviceBudget = toFiniteNumberOrNull(row?.serviceBudget)
+ if (serviceBudget != null) {
+ total += serviceBudget
+ hasValid = true
+ continue
+ }
+ const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
+ const personnel = toFiniteNumberOrNull(row?.personnelCount)
+ const workday = toFiniteNumberOrNull(row?.workdayCount)
+ if (adopted == null || personnel == null || workday == null) continue
+ total += adopted * personnel * workday
+ hasValid = true
+ }
+ return hasValid ? roundTo(total, 3) : null
+ }
+
+ const sumQuantityMethodFee = (state: HtQuantityMethodStateLike | null): number | null => {
+ const rows = Array.isArray(state?.detailRows)
+ ? state.detailRows.filter(row => toFiniteNumberOrNull(row?.budgetFee) != null)
+ : []
+ if (rows.length === 0) return null
+
+ let total = 0
+ let hasValid = false
+ for (const row of rows) {
+ if (String(row?.id || '') === 'fee-subtotal-fixed') continue
+ const budget = toFiniteNumberOrNull(row?.budgetFee)
+ if (budget != null) {
+ total += budget
+ hasValid = true
+ continue
+ }
+ const quantity = toFiniteNumberOrNull(row?.quantity)
+ const unitPrice = toFiniteNumberOrNull(row?.unitPrice)
+ if (quantity == null || unitPrice == null) continue
+ total += quantity * unitPrice
+ hasValid = true
+ }
+ return hasValid ? roundTo(total, 3) : null
+ }
+
+ const getHtRowSubtotal = (row: HtFeeMainDetailRowLike | null | undefined): number | null => {
+ if (!row) return null
+ const values = [
+ toFiniteNumberOrNull(row.rateFee),
+ toFiniteNumberOrNull(row.hourlyFee),
+ toFiniteNumberOrNull(row.quantityUnitPriceFee)
+ ]
+ if (!values.some(value => value != null)) return null
+ let sum = 0
+ for (const value of values) {
+ sum += toFiniteNumberOrZero(value)
+ }
+ return roundTo(sum, 3)
+ }
+
+ const sumMainStateSubtotal = (rows: HtFeeMainDetailRowLike[]): number | null => {
+ if (!Array.isArray(rows) || rows.length === 0) return null
+ const subtotalValues = rows.map(row => getHtRowSubtotal(row)).filter(value => value != null) as number[]
+ if (subtotalValues.length === 0) return null
+ return roundTo(subtotalValues.reduce((sum, value) => sum + value, 0), 3)
+ }
+
+ const syncHtMainByBase = async (
+ mainStorageKey: string,
+ baseValue: number | null
+ ): Promise => {
+ const mainState = await loadHtFeeMainState(mainStorageKey)
+ const sourceRows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
+ if (sourceRows.length === 0) return null
+
+ const nextRows = await Promise.all(
+ sourceRows.map(async sourceRow => {
+ const rowId = String(sourceRow?.id || '').trim()
+ if (!rowId) return sourceRow
+
+ const [rateState, hourlyState, quantityState] = await Promise.all([
+ loadHtFeeMethodState(mainStorageKey, rowId, 'rate-fee'),
+ loadHtFeeMethodState(mainStorageKey, rowId, 'hourly-fee'),
+ loadHtFeeMethodState(mainStorageKey, rowId, 'quantity-unit-price-fee')
+ ])
+
+ const rateValue = toFiniteNumberOrNull(rateState?.rate)
+ const nextRateFee = baseValue != null && rateValue != null ? roundTo(baseValue * rateValue / 100, 2) : null
+ if (rateState) {
+ const currentRateFee = toFiniteNumberOrNull(rateState.budgetFee)
+ if (currentRateFee !== nextRateFee) {
+ setHtFeeMethodState(
+ mainStorageKey,
+ rowId,
+ 'rate-fee',
+ {
+ ...rateState,
+ budgetFee: nextRateFee
+ },
+ { force: true }
+ )
+ }
+ }
+
+ const nextHourlyFee = sumHourlyMethodFee(hourlyState)
+ const nextQuantityFee = sumQuantityMethodFee(quantityState)
+ const nextRow = {
+ ...sourceRow,
+ rateFee: nextRateFee,
+ hourlyFee: nextHourlyFee,
+ quantityUnitPriceFee: nextQuantityFee
+ }
+ return {
+ ...nextRow,
+ subtotal: getHtRowSubtotal(nextRow)
+ }
+ })
+ )
+
+ const prevSnapshot = toKeySnapshot(sourceRows)
+ const nextSnapshot = toKeySnapshot(nextRows)
+ if (prevSnapshot !== nextSnapshot) {
+ setHtFeeMainState(mainStorageKey, { detailRows: nextRows }, { force: true })
+ return sumMainStateSubtotal(nextRows)
+ }
+ return sumMainStateSubtotal(sourceRows)
+ }
+
+ const syncHtExtraFeeByContractBase = async (contractId: string) => {
+ if (!contractId) return
+ try {
+ const serviceBase = getBaseSubtotal(contractId)
+ const additionalKey = `htExtraFee-${contractId}-additional-work`
+ const reserveKey = `htExtraFee-${contractId}-reserve`
+ const additionalTotal = await syncHtMainByBase(additionalKey, serviceBase)
+
+ const hasReserveBase = serviceBase != null || additionalTotal != null
+ const reserveBase = hasReserveBase
+ ? roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalTotal), 3)
+ : null
+ await syncHtMainByBase(reserveKey, reserveBase)
+ } catch (error) {
+ console.error('syncHtExtraFeeByContractBase failed:', error)
+ }
+ }
+
const getKeyState = (keyRaw: string | number): T | null => {
const key = toKey(keyRaw)
if (!key) return null
@@ -675,7 +854,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
if (isSameState(current, nextState)) return false
contracts.value[contractId] = nextState
contractLoaded.value[contractId] = true
- const targetRow = nextState.detailRows.find(row => String(row.id || '') === targetServiceId)
+ await syncHtExtraFeeByContractBase(contractId)
return true
}
@@ -687,7 +866,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
if (!state?.detailRows?.length) return null
const fixedRow = state.detailRows.find(row => String(row.id || '') === FIXED_ROW_ID)
const fixedFinalFee = toFiniteNumberOrNull(fixedRow?.finalFee)
- if (fixedFinalFee != null) return round3(fixedFinalFee)
+ if (fixedFinalFee != null) return roundTo(fixedFinalFee, 3)
let hasValid = false
const sum = state.detailRows.reduce((acc, row) => {
@@ -696,7 +875,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
if (fee != null) hasValid = true
return fee == null ? acc : acc + fee
}, 0)
- return hasValid ? round3(sum) : null
+ return hasValid ? roundTo(sum, 3) : null
}
// 清理合同维度的内存态、版本号以及该合同下所有相关持久化键。
diff --git a/src/sql.ts b/src/sql.ts
index c08ce2f..d09985b 100644
--- a/src/sql.ts
+++ b/src/sql.ts
@@ -1,5 +1,5 @@
// @ts-nocheck
-import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
+import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import ExcelJS from "ExcelJS";
// 统一数字千分位格式化,默认保留 2 位小数。
@@ -7,10 +7,7 @@ const numberFormatter = (value: unknown, fractionDigits = 2) =>
formatThousands(value, fractionDigits)
// 将任意输入安全转为有限数字;无效值统一按 0 处理。
-const toFiniteNumber = (value: unknown) => {
- const num = Number(value)
- return Number.isFinite(num) ? num : 0
-}
+const toFiniteNumber = toFiniteNumberOrZero
// 兼容导出 tasks 对象结构:[{ text: [] }, { serviceid, text: [] }]
const normalizeTaskTexts = (tasks: unknown): string[] => {
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 635753e..cb781bc 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ht/ht.vue","./src/components/ht/htadditionalworkfee.vue","./src/components/ht/htbaseinfo.vue","./src/components/ht/htconsultcategoryfactor.vue","./src/components/ht/htcontractsummary.vue","./src/components/ht/htfeeratemethodform.vue","./src/components/ht/htmajorfactor.vue","./src/components/ht/htreservefee.vue","./src/components/ht/htcard.vue","./src/components/ht/htinfo.vue","./src/components/ht/zxfw.vue","./src/components/pricing/hourlypricingpane.vue","./src/components/pricing/investmentscalepricingpane.vue","./src/components/pricing/landscalepricingpane.vue","./src/components/pricing/workloadpricingpane.vue","./src/components/shared/hourlyfeegrid.vue","./src/components/shared/htfeegrid.vue","./src/components/shared/htfeemethodgrid.vue","./src/components/shared/methodunavailablenotice.vue","./src/components/shared/servicecheckboxselector.vue","./src/components/shared/workcontentgrid.vue","./src/components/shared/xmfactorgrid.vue","./src/components/shared/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/homeentryview.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/quickcalcworkbenchview.vue","./src/components/views/zxfwview.vue","./src/components/xm/xmconsultcategoryfactor.vue","./src/components/xm/xmmajorfactor.vue","./src/components/xm/info.vue","./src/components/xm/xmcard.vue","./src/components/xm/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/features/ht/contracts.ts","./src/features/ht/importexport.ts","./src/features/ht/types.ts","./src/features/tab/importexport.ts","./src/features/tab/types.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/features/ht/components/ht.vue","./src/features/ht/components/htadditionalworkfee.vue","./src/features/ht/components/htbaseinfo.vue","./src/features/ht/components/htconsultcategoryfactor.vue","./src/features/ht/components/htcontractsummary.vue","./src/features/ht/components/htfeeratemethodform.vue","./src/features/ht/components/htmajorfactor.vue","./src/features/ht/components/htreservefee.vue","./src/features/ht/components/htcard.vue","./src/features/ht/components/htinfo.vue","./src/features/ht/components/zxfw.vue","./src/features/pricing/components/hourlypricingpane.vue","./src/features/pricing/components/investmentscalepricingpane.vue","./src/features/pricing/components/landscalepricingpane.vue","./src/features/pricing/components/workloadpricingpane.vue","./src/features/shared/components/hourlyfeegrid.vue","./src/features/shared/components/htfeegrid.vue","./src/features/shared/components/htfeemethodgrid.vue","./src/features/shared/components/methodunavailablenotice.vue","./src/features/shared/components/servicecheckboxselector.vue","./src/features/shared/components/workcontentgrid.vue","./src/features/shared/components/xmfactorgrid.vue","./src/features/shared/components/xmcommonaggrid.vue","./src/features/workbench/components/homeentryview.vue","./src/features/workbench/components/htfeemethodtypelineview.vue","./src/features/workbench/components/quickcalcworkbenchview.vue","./src/features/workbench/components/zxfwview.vue","./src/features/xm/components/xmconsultcategoryfactor.vue","./src/features/xm/components/xmmajorfactor.vue","./src/features/xm/components/info.vue","./src/features/xm/components/xmcard.vue","./src/features/xm/components/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
\ No newline at end of file