diff --git a/src/components/common/HourlyFeeGrid.vue b/src/components/common/HourlyFeeGrid.vue index 1d98696..251f3f3 100644 --- a/src/components/common/HourlyFeeGrid.vue +++ b/src/components/common/HourlyFeeGrid.vue @@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { formatThousandsFlexible } from '@/lib/numberFormat' import { parseNumberOrNull } from '@/lib/number' -import { syncPricingTotalToZxFw, type ZxFwPricingField, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' +import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync' import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' @@ -402,7 +402,7 @@ const saveToIndexedDB = async () => { field: props.syncField, value: totalServiceBudget.value }) - if (synced) pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY) + if (!synced) return } if (props.syncMainStorageKey && props.syncRowId) { htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId) diff --git a/src/components/common/HtFeeMethodGrid.vue b/src/components/common/HtFeeMethodGrid.vue index 5b4b2d4..03e6419 100644 --- a/src/components/common/HtFeeMethodGrid.vue +++ b/src/components/common/HtFeeMethodGrid.vue @@ -11,8 +11,7 @@ import { Pencil, Eraser } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { useTabStore } from '@/pinia/tab' import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload' -import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' -import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' +import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { AlertDialogAction, AlertDialogCancel, @@ -65,15 +64,6 @@ interface MethodQuantityState { detailRows?: MethodQuantityRowLike[] } -interface ZxFwRowLike { - id?: unknown - subtotal?: unknown -} - -interface ZxFwStateLike { - detailRows?: ZxFwRowLike[] -} - interface LegacyFeeRow { id?: string feeItem?: string @@ -91,7 +81,7 @@ const props = defineProps<{ }>() const tabStore = useTabStore() const htFeeMethodReloadStore = useHtFeeMethodReloadStore() -const pricingPaneReloadStore = usePricingPaneReloadStore() +const zxFwPricingStore = useZxFwPricingStore() const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` const createDefaultRow = (name = ''): FeeMethodRow => ({ @@ -121,17 +111,9 @@ const loadContractServiceFeeBase = async (): Promise => { const contractId = String(props.contractId || '').trim() if (!contractId) return null try { - const data = await localforage.getItem(`zxFW-${contractId}`) - const rows = Array.isArray(data?.detailRows) ? data.detailRows : [] - const fixedRow = rows.find(row => String(row?.id || '') === 'fixed-budget-c') - const fixedSubtotal = toFiniteUnknown(fixedRow?.subtotal) - if (fixedSubtotal != null) return round3(fixedSubtotal) - const sum = rows.reduce((acc, row) => { - if (String(row?.id || '') === 'fixed-budget-c') return acc - const subtotal = toFiniteUnknown(row?.subtotal) - return subtotal == null ? acc : acc + subtotal - }, 0) - return round3(sum) + await zxFwPricingStore.loadContract(contractId) + const base = zxFwPricingStore.getBaseSubtotal(contractId) + return base == null ? null : round3(base) } catch (error) { console.error('loadContractServiceFeeBase failed:', error) return null @@ -610,12 +592,13 @@ watch( ) watch( - () => pricingPaneReloadStore.persistedSeq, + () => { + const contractId = String(props.contractId || '').trim() + if (!contractId) return 0 + return zxFwPricingStore.contractVersions[contractId] || 0 + }, (nextVersion, prevVersion) => { if (nextVersion === prevVersion || nextVersion === 0) return - const contractId = String(props.contractId || '').trim() - if (!contractId) return - if (!matchPricingPaneReload(pricingPaneReloadStore.lastPersistedEvent, contractId, ZXFW_RELOAD_SERVICE_KEY)) return void loadFromIndexedDB() } ) diff --git a/src/components/views/HtFeeRateMethodForm.vue b/src/components/views/HtFeeRateMethodForm.vue index bb37f68..a163309 100644 --- a/src/components/views/HtFeeRateMethodForm.vue +++ b/src/components/views/HtFeeRateMethodForm.vue @@ -3,18 +3,8 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v import localforage from 'localforage' import { parseNumberOrNull } from '@/lib/number' import { formatThousandsFlexible } from '@/lib/numberFormat' -import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' -import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload' - -interface ZxFwRowLike { - id?: unknown - subtotal?: unknown -} - -interface ZxFwStateLike { - detailRows?: ZxFwRowLike[] -} +import { useZxFwPricingStore } from '@/pinia/zxFwPricing' interface RateMethodState { rate: number | null @@ -28,18 +18,18 @@ const props = defineProps<{ syncMainStorageKey?: string syncRowId?: string }>() -const pricingPaneReloadStore = usePricingPaneReloadStore() const htFeeMethodReloadStore = useHtFeeMethodReloadStore() +const zxFwPricingStore = useZxFwPricingStore() const base = ref(null) const rate = ref(null) const remark = ref('') const rateInput = ref('') - -const toFinite = (value: unknown): number | null => { - const numeric = Number(value) - return Number.isFinite(numeric) ? numeric : null -} +const contractVersion = computed(() => { + const contractId = String(props.contractId || '').trim() + if (!contractId) return 0 + return zxFwPricingStore.contractVersions[contractId] || 0 +}) const round3 = (value: number) => Number(value.toFixed(3)) const budgetFee = computed(() => { @@ -51,27 +41,15 @@ const formatAmount = (value: number | null) => value == null ? '' : formatThousandsFlexible(value, 3) const loadBase = async () => { - const contractId = String(props.contractId || '').trim() if (!contractId) { base.value = null return } try { - const data = await localforage.getItem(`zxFW-${contractId}`) - const rows = Array.isArray(data?.detailRows) ? data.detailRows : [] - const fixedRow = rows.find(row => String(row?.id || '') === 'fixed-budget-c') - const fixedSubtotal = toFinite(fixedRow?.subtotal) - if (fixedSubtotal != null) { - base.value = round3(fixedSubtotal) - return - } - const sum = rows.reduce((acc, row) => { - if (String(row?.id || '') === 'fixed-budget-c') return acc - const subtotal = toFinite(row?.subtotal) - return subtotal == null ? acc : acc + subtotal - }, 0) - base.value = round3(sum) + await zxFwPricingStore.loadContract(contractId) + const nextBase = zxFwPricingStore.getBaseSubtotal(contractId) + base.value = nextBase == null ? null : round3(nextBase) } catch (error) { console.error('load rate base failed:', error) base.value = null @@ -127,12 +105,9 @@ watch( ) watch( - () => pricingPaneReloadStore.persistedSeq, + () => contractVersion.value, (nextVersion, prevVersion) => { if (nextVersion === prevVersion || nextVersion === 0) return - const contractId = String(props.contractId || '').trim() - if (!contractId) return - if (!matchPricingPaneReload(pricingPaneReloadStore.lastPersistedEvent, contractId, ZXFW_RELOAD_SERVICE_KEY)) return void loadBase() } ) diff --git a/src/components/views/pricingView/InvestmentScalePricingPane.vue b/src/components/views/pricingView/InvestmentScalePricingPane.vue index f952aa2..a1d1d54 100644 --- a/src/components/views/pricingView/InvestmentScalePricingPane.vue +++ b/src/components/views/pricingView/InvestmentScalePricingPane.vue @@ -7,7 +7,7 @@ import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorI import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { formatThousandsFlexible } from '@/lib/numberFormat' -import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' +import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync' import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' import { parseNumberOrNull } from '@/lib/number' @@ -1026,9 +1026,7 @@ const saveToIndexedDB = async () => { field: 'investScale', value: totalBudgetFee.value }) - if (synced) { - pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY) - } + if (!synced) return } catch (error) { console.error('saveToIndexedDB failed:', error) } diff --git a/src/components/views/pricingView/LandScalePricingPane.vue b/src/components/views/pricingView/LandScalePricingPane.vue index a9dfbaf..def59ae 100644 --- a/src/components/views/pricingView/LandScalePricingPane.vue +++ b/src/components/views/pricingView/LandScalePricingPane.vue @@ -7,7 +7,7 @@ import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorI import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { formatThousandsFlexible } from '@/lib/numberFormat' -import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' +import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync' import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' import { parseNumberOrNull } from '@/lib/number' @@ -879,9 +879,7 @@ const saveToIndexedDB = async () => { field: 'landScale', value: totalBudgetFee.value }) - if (synced) { - pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY) - } + if (!synced) return } catch (error) { console.error('saveToIndexedDB failed:', error) } diff --git a/src/components/views/pricingView/WorkloadPricingPane.vue b/src/components/views/pricingView/WorkloadPricingPane.vue index f870c02..b5fb693 100644 --- a/src/components/views/pricingView/WorkloadPricingPane.vue +++ b/src/components/views/pricingView/WorkloadPricingPane.vue @@ -8,7 +8,7 @@ import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat' import { parseNumberOrNull } from '@/lib/number' -import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' +import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync' import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults' import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue' @@ -432,9 +432,7 @@ const saveToIndexedDB = async () => { field: 'workload', value: totalServiceFee.value }) - if (synced) { - pricingPaneReloadStore.emit(props.contractId, ZXFW_RELOAD_SERVICE_KEY) - } + if (!synced) return } catch (error) { console.error('saveToIndexedDB failed:', error) } diff --git a/src/components/views/zxFw.vue b/src/components/views/zxFw.vue index 485465b..8182b3b 100644 --- a/src/components/views/zxFw.vue +++ b/src/components/views/zxFw.vue @@ -16,8 +16,7 @@ import { getPricingMethodTotalsForServices, type PricingMethodTotals } from '@/lib/pricingMethodTotals' -import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' -import { matchPricingPaneReload, usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' +import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { Pencil, Eraser, Trash2 } from 'lucide-vue-next' import { AlertDialogAction, @@ -34,6 +33,7 @@ import { Button } from '@/components/ui/button' import { TooltipProvider } from '@/components/ui/tooltip' import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql' import { useTabStore } from '@/pinia/tab' +import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue' interface ServiceItem { @@ -78,14 +78,16 @@ const props = defineProps<{ }>() const tabStore = useTabStore() const pricingPaneReloadStore = usePricingPaneReloadStore() -const DB_KEY = computed(() => `zxFW-${props.contractId}`) +const zxFwPricingStore = useZxFwPricingStore() const PROJECT_INFO_KEY = 'xm-base-info-v1' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_CLEAR_SKIP_TTL_MS = 5000 const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const const projectIndustry = ref('') -const reloadSignal = ref(0) +const syncingFromStore = ref(false) +const localSavedVersion = ref(0) +const zxFwStoreVersion = computed(() => zxFwPricingStore.contractVersions[props.contractId] || 0) type ServiceListItem = { code?: string @@ -959,15 +961,18 @@ const saveToIndexedDB = async () => { selectedIds: [...selectedIds.value], detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())) } - await localforage.setItem(DB_KEY.value, payload) - pricingPaneReloadStore.emitPersisted(props.contractId, ZXFW_RELOAD_SERVICE_KEY) + await zxFwPricingStore.setContractState(props.contractId, payload) + localSavedVersion.value = zxFwPricingStore.contractVersions[props.contractId] || 0 } catch (error) { console.error('saveToIndexedDB failed:', error) } } const loadFromIndexedDB = async () => { + if (syncingFromStore.value) return + syncingFromStore.value = true try { - const data = await localforage.getItem(DB_KEY.value) + await zxFwPricingStore.loadContract(props.contractId) + const data = zxFwPricingStore.getContractState(props.contractId) if (!data) { selectedIds.value = [] detailRows.value = [] @@ -1002,6 +1007,8 @@ const loadFromIndexedDB = async () => { console.error('loadFromIndexedDB failed:', error) selectedIds.value = [] detailRows.value = [] + } finally { + syncingFromStore.value = false } } @@ -1017,22 +1024,11 @@ const loadProjectIndustry = async () => { } watch( - () => pricingPaneReloadStore.seq, + () => zxFwStoreVersion.value, (nextVersion, prevVersion) => { if (nextVersion === prevVersion || nextVersion === 0) return - if (!matchPricingPaneReload(pricingPaneReloadStore.lastEvent, props.contractId, ZXFW_RELOAD_SERVICE_KEY)) return - reloadSignal.value += 1 - } -) - -watch( - () => reloadSignal.value, - (nextVersion, prevVersion) => { - if (nextVersion === prevVersion || nextVersion === 0) return - void (async () => { - await loadFromIndexedDB() - await saveToIndexedDB() - })() + if (nextVersion === localSavedVersion.value) return + void loadFromIndexedDB() } ) diff --git a/src/lib/zxFwPricingSync.ts b/src/lib/zxFwPricingSync.ts index 0d76f26..07ee726 100644 --- a/src/lib/zxFwPricingSync.ts +++ b/src/lib/zxFwPricingSync.ts @@ -1,21 +1,6 @@ -import localforage from 'localforage' -import { toFiniteNumberOrNull } from '@/lib/number' +import { useZxFwPricingStore, type ZxFwPricingField } from '@/pinia/zxFwPricing' -export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly' - -interface ZxFwDetailRow { - id: string - investScale: number | null - landScale: number | null - workload: number | null - hourly: number | null -} - -interface ZxFwState { - selectedIds?: string[] - selectedCodes?: string[] - detailRows: ZxFwDetailRow[] -} +export type { ZxFwPricingField } from '@/pinia/zxFwPricing' export const ZXFW_RELOAD_SERVICE_KEY = 'zxfw-main' @@ -25,30 +10,6 @@ export const syncPricingTotalToZxFw = async (params: { field: ZxFwPricingField value: number | null | undefined }) => { - const dbKey = `zxFW-${params.contractId}` - const data = await localforage.getItem(dbKey) - if (!data?.detailRows?.length) return false - - const targetServiceId = String(params.serviceId) - const nextValue = toFiniteNumberOrNull(params.value) - let changed = false - - const nextRows = data.detailRows.map(row => { - if (String(row.id) !== targetServiceId) return row - const currentValue = toFiniteNumberOrNull(row[params.field]) - if (currentValue === nextValue) return row - changed = true - return { - ...row, - [params.field]: nextValue - } - }) - - if (!changed) return false - - await localforage.setItem(dbKey, { - ...data, - detailRows: nextRows - }) - return true + const store = useZxFwPricingStore() + return store.updatePricingField(params) } diff --git a/src/pinia/zxFwPricing.ts b/src/pinia/zxFwPricing.ts new file mode 100644 index 0000000..d8abaa9 --- /dev/null +++ b/src/pinia/zxFwPricing.ts @@ -0,0 +1,235 @@ +import localforage from 'localforage' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { addNumbers } from '@/lib/decimal' +import { toFiniteNumberOrNull } from '@/lib/number' + +export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly' + +export interface ZxFwDetailRow { + id: string + code?: string + name?: string + investScale: number | null + landScale: number | null + workload: number | null + hourly: number | null + subtotal?: number | null + actions?: unknown +} + +export interface ZxFwState { + selectedIds?: string[] + selectedCodes?: string[] + detailRows: ZxFwDetailRow[] +} + +const FIXED_ROW_ID = 'fixed-budget-c' + +const toKey = (contractId: string | number) => String(contractId || '').trim() +const dbKeyOf = (contractId: string) => `zxFW-${contractId}` +const round3 = (value: number) => Number(value.toFixed(3)) +const toNumberOrZero = (value: unknown) => { + const numeric = Number(value) + return Number.isFinite(numeric) ? numeric : 0 +} + +const normalizeRows = (rows: unknown): ZxFwDetailRow[] => + (Array.isArray(rows) ? rows : []).map(item => { + const row = item as Partial + return { + id: String(row.id || ''), + code: typeof row.code === 'string' ? row.code : '', + name: typeof row.name === 'string' ? row.name : '', + investScale: toFiniteNumberOrNull(row.investScale), + landScale: toFiniteNumberOrNull(row.landScale), + workload: toFiniteNumberOrNull(row.workload), + hourly: toFiniteNumberOrNull(row.hourly), + subtotal: toFiniteNumberOrNull(row.subtotal), + actions: row.actions + } + }).filter(row => row.id) + +const applyRowSubtotals = (rows: ZxFwDetailRow[]): ZxFwDetailRow[] => { + const normalized = rows.map(row => ({ ...row })) + const nonFixedRows = normalized.filter(row => row.id !== FIXED_ROW_ID) + const totalInvestScale = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.investScale)), 0) + const totalLandScale = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.landScale)), 0) + const totalWorkload = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.workload)), 0) + const totalHourly = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.hourly)), 0) + const fixedSubtotal = addNumbers(totalInvestScale, totalLandScale, totalWorkload, totalHourly) + + return normalized.map(row => { + if (row.id === FIXED_ROW_ID) { + return { + ...row, + investScale: round3(totalInvestScale), + landScale: round3(totalLandScale), + workload: round3(totalWorkload), + hourly: round3(totalHourly), + subtotal: round3(fixedSubtotal) + } + } + const subtotal = addNumbers( + toNumberOrZero(row.investScale), + toNumberOrZero(row.landScale), + toNumberOrZero(row.workload), + toNumberOrZero(row.hourly) + ) + return { + ...row, + subtotal: round3(subtotal) + } + }) +} + +const normalizeState = (state: ZxFwState | null | undefined): ZxFwState => ({ + selectedIds: Array.isArray(state?.selectedIds) + ? state.selectedIds.map(id => String(id || '')).filter(Boolean) + : [], + selectedCodes: Array.isArray(state?.selectedCodes) + ? state.selectedCodes.map(code => String(code || '')).filter(Boolean) + : [], + detailRows: applyRowSubtotals(normalizeRows(state?.detailRows)) +}) + +const cloneState = (state: ZxFwState): ZxFwState => ({ + selectedIds: [...(state.selectedIds || [])], + selectedCodes: [...(state.selectedCodes || [])], + detailRows: state.detailRows.map(row => ({ ...row })) +}) + +const loadTasks = new Map>() +const persistQueues = new Map>() + +export const useZxFwPricingStore = defineStore('zxFwPricing', () => { + const contracts = ref>({}) + const contractVersions = ref>({}) + + const touchVersion = (contractId: string) => { + contractVersions.value[contractId] = (contractVersions.value[contractId] || 0) + 1 + } + + const queuePersist = async (contractId: string) => { + const prev = persistQueues.get(contractId) || Promise.resolve() + const next = prev + .then(async () => { + const current = contracts.value[contractId] + if (!current) return + await localforage.setItem(dbKeyOf(contractId), cloneState(current)) + }) + .catch(error => { + console.error('zxFwPricing persist failed:', error) + }) + persistQueues.set(contractId, next) + await next + } + + const getContractState = (contractIdRaw: string | number) => { + const contractId = toKey(contractIdRaw) + if (!contractId) return null + const data = contracts.value[contractId] + return data ? cloneState(data) : null + } + + const loadContract = async (contractIdRaw: string | number, force = false) => { + const contractId = toKey(contractIdRaw) + if (!contractId) return null + if (!force && contracts.value[contractId]) return getContractState(contractId) + if (!force && loadTasks.has(contractId)) return loadTasks.get(contractId) as Promise + + const task = (async () => { + const raw = await localforage.getItem(dbKeyOf(contractId)) + const normalized = normalizeState(raw) + contracts.value[contractId] = normalized + touchVersion(contractId) + return cloneState(normalized) + })() + loadTasks.set(contractId, task) + + try { + return await task + } finally { + loadTasks.delete(contractId) + } + } + + const setContractState = async (contractIdRaw: string | number, state: ZxFwState) => { + const contractId = toKey(contractIdRaw) + if (!contractId) return + contracts.value[contractId] = normalizeState(state) + touchVersion(contractId) + await queuePersist(contractId) + } + + const updatePricingField = async (params: { + contractId: string + serviceId: string | number + field: ZxFwPricingField + value: number | null | undefined + }) => { + const contractId = toKey(params.contractId) + if (!contractId) return false + + if (!contracts.value[contractId]) { + await loadContract(contractId) + } + + const current = contracts.value[contractId] + if (!current?.detailRows?.length) return false + + const targetServiceId = String(params.serviceId || '').trim() + if (!targetServiceId) return false + + const nextValue = toFiniteNumberOrNull(params.value) + let changed = false + const nextRows = current.detailRows.map(row => { + if (String(row.id || '') !== targetServiceId) return row + const oldValue = toFiniteNumberOrNull(row[params.field]) + if (oldValue === nextValue) return row + changed = true + return { + ...row, + [params.field]: nextValue + } + }) + + if (!changed) return false + + contracts.value[contractId] = normalizeState({ + ...current, + detailRows: nextRows + }) + touchVersion(contractId) + await queuePersist(contractId) + return true + } + + const getBaseSubtotal = (contractIdRaw: string | number): number | null => { + const contractId = toKey(contractIdRaw) + if (!contractId) return null + const state = contracts.value[contractId] + if (!state?.detailRows?.length) return null + + const fixedRow = state.detailRows.find(row => String(row.id || '') === FIXED_ROW_ID) + const fixedSubtotal = toFiniteNumberOrNull(fixedRow?.subtotal) + if (fixedSubtotal != null) return round3(fixedSubtotal) + + const sum = state.detailRows.reduce((acc, row) => { + if (String(row.id || '') === FIXED_ROW_ID) return acc + const subtotal = toFiniteNumberOrNull(row.subtotal) + return subtotal == null ? acc : acc + subtotal + }, 0) + return round3(sum) + } + + return { + contracts, + contractVersions, + getContractState, + loadContract, + setContractState, + updatePricingField, + getBaseSubtotal + } +})