This commit is contained in:
wintsa 2026-03-27 15:55:53 +08:00
parent fd1b782803
commit 2a224c74ff
16 changed files with 526 additions and 157 deletions

BIN
release/JGJS2026-dist.zip Normal file

Binary file not shown.

View File

@ -54,6 +54,7 @@ import {
SERVICE_KEY_PREFIX, SERVICE_KEY_PREFIX,
type ContractSegmentPackage type ContractSegmentPackage
} from '@/lib/contractSegment' } from '@/lib/contractSegment'
import { buildDefaultProjectScaleState } from '@/lib/projectWorkspace'
import { getIndustryDisplayName, industryTypeList } from '@/sql' import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal' import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat' import { formatThousands } from '@/lib/numberFormat'
@ -517,15 +518,21 @@ const saveContracts = async () => {
} }
const initializeContractScaleData = async (contractId: string) => { const initializeContractScaleData = async (contractId: string) => {
const source = await kvStore.getItem<XmScaleState>(PROJECT_SCALE_KEY) const [source, projectInfo] = await Promise.all([
const payload: XmScaleState = { kvStore.getItem<XmScaleState>(PROJECT_SCALE_KEY),
detailRows: Array.isArray(source?.detailRows) ? cloneJson(source.detailRows) : [], kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
roughCalcEnabled: Boolean(source?.roughCalcEnabled), ])
totalAmount: const industry = typeof projectInfo?.projectIndustry === 'string' ? projectInfo.projectIndustry.trim() : ''
typeof source?.totalAmount === 'number' && Number.isFinite(source.totalAmount) const payload: XmScaleState = industry
? source.totalAmount ? buildDefaultProjectScaleState(industry, source)
: null : {
} detailRows: Array.isArray(source?.detailRows) ? cloneJson(source.detailRows) : [],
roughCalcEnabled: Boolean(source?.roughCalcEnabled),
totalAmount:
typeof source?.totalAmount === 'number' && Number.isFinite(source.totalAmount)
? source.totalAmount
: null
}
await kvStore.setItem(`${CONTRACT_KEY_PREFIX}${contractId}`, payload) await kvStore.setItem(`${CONTRACT_KEY_PREFIX}${contractId}`, payload)
} }

View File

@ -250,7 +250,7 @@ const totalRow = computed<SummaryRow>(() => {
landScale: sumField(row => row.landScale), landScale: sumField(row => row.landScale),
workload: sumField(row => row.workload), workload: sumField(row => row.workload),
hourly: sumField(row => row.hourly), hourly: sumField(row => row.hourly),
subtotal: sumField(row => row.subtotal), subtotal: null,
finalFee: sumField(row => row.finalFee) finalFee: sumField(row => row.finalFee)
} }
}) })
@ -328,7 +328,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
}, },
colSpan: params => { colSpan: params => {
if (!params.data) return 1 if (!params.data) return 1
if (params.data.rowType === 'total') return 4 if (params.data.rowType === 'total') return 5
if (params.data.rowType === 'additional' || params.data.rowType === 'reserve') return 5 if (params.data.rowType === 'additional' || params.data.rowType === 'reserve') return 5
return 1 return 1
}, },

View File

@ -63,7 +63,9 @@ import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
import { import {
buildContractScaleIdMap, buildContractScaleIdMap,
buildContractScaleMap, buildContractScaleMap,
buildContractScaleProjectTotals,
getContractScaleRowByMajor, getContractScaleRowByMajor,
getContractScaleProjectTotalsByRow,
makeProjectMajorKey, makeProjectMajorKey,
normalizeChangedScaleRowIds, normalizeChangedScaleRowIds,
parseProjectIndexFromPathKey, parseProjectIndexFromPathKey,
@ -328,8 +330,15 @@ const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRo
}) })
} }
const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) => const calcOnlyCostScaleAmountFromRows = (
sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null)) rows?: Array<{ amount?: unknown; isGroupRow?: unknown }>,
totalAmount?: number | null
) => {
if (typeof totalAmount === 'number' && Number.isFinite(totalAmount)) return totalAmount
const summaryRow = (rows || []).find(row => row?.isGroupRow === true)
if (typeof summaryRow?.amount === 'number' && Number.isFinite(summaryRow.amount)) return summaryRow.amount
return sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null))
}
const getOnlyCostScaleMajorEntry = () => { const getOnlyCostScaleMajorEntry = () => {
const industryId = String(activeIndustryCode.value || '').trim() const industryId = String(activeIndustryCode.value || '').trim()
@ -405,10 +414,22 @@ const buildOnlyCostScaleRow = (
const buildOnlyCostScaleRows = ( const buildOnlyCostScaleRows = (
rowsFromDb?: Array<Partial<DetailRow> & Pick<DetailRow, 'id'>>, rowsFromDb?: Array<Partial<DetailRow> & Pick<DetailRow, 'id'>>,
options?: { projectCount?: number; cloneFromProjectOne?: boolean } options?: {
projectCount?: number
cloneFromProjectOne?: boolean
totalAmount?: number | null
preferSummaryAmountWhenSingleRow?: boolean
}
): DetailRow[] => { ): DetailRow[] => {
const targetProjectCount = normalizeProjectCount(options?.projectCount ?? getTargetProjectCount()) const targetProjectCount = normalizeProjectCount(options?.projectCount ?? getTargetProjectCount())
const onlyCostMajorId = getOnlyCostScaleMajorEntry()?.id || ONLY_COST_SCALE_ROW_ID const onlyCostMajorId = getOnlyCostScaleMajorEntry()?.id || ONLY_COST_SCALE_ROW_ID
const singleRowSummaryAmount =
options?.preferSummaryAmountWhenSingleRow
? calcOnlyCostScaleAmountFromRows(
rowsFromDb as Array<{ amount?: unknown; isGroupRow?: unknown }>,
options.totalAmount
)
: null
const dbValueMap = new Map<string, Partial<DetailRow> & Pick<DetailRow, 'id'>>() const dbValueMap = new Map<string, Partial<DetailRow> & Pick<DetailRow, 'id'>>()
for (const row of rowsFromDb || []) { for (const row of rowsFromDb || []) {
const projectIndex = resolveRowProjectIndex(row) const projectIndex = resolveRowProjectIndex(row)
@ -428,11 +449,11 @@ const buildOnlyCostScaleRows = (
(options?.cloneFromProjectOne && projectIndex > 1 ? dbValueMap.get(firstProjectKey) : undefined) (options?.cloneFromProjectOne && projectIndex > 1 ? dbValueMap.get(firstProjectKey) : undefined)
const fallbackAmount = const fallbackAmount =
options?.cloneFromProjectOne && projectIndex > 1 && fromDb == null options?.cloneFromProjectOne && projectIndex > 1 && fromDb == null
? calcOnlyCostScaleAmountFromRows(rowsFromDb) ? calcOnlyCostScaleAmountFromRows(rowsFromDb as Array<{ amount?: unknown; isGroupRow?: unknown }>, options.totalAmount)
: null : null
result.push( result.push(
buildOnlyCostScaleRow( buildOnlyCostScaleRow(
typeof fromDb?.amount === 'number' ? fromDb.amount : fallbackAmount, singleRowSummaryAmount ?? (typeof fromDb?.amount === 'number' ? fromDb.amount : fallbackAmount),
projectIndex, projectIndex,
fromDb fromDb
) )
@ -608,22 +629,24 @@ const getBudgetFeeSplit = (
) => getScaleBudgetFeeSplitByRow(row, 'cost') ) => getScaleBudgetFeeSplitByRow(row, 'cost')
const restoreAmountColumnDefaults = async () => { const restoreAmountColumnDefaults = async () => {
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await kvStore.getItem<{ detailRows: SourceRow[]; totalAmount?: number | null }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : [] const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows) const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows) const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount)
const useSummaryAmount = detailRows.value.length === 1 || isOnlyCostScaleService.value
const onlyCostScaleFallbackAmount = isOnlyCostScaleService.value const onlyCostScaleFallbackAmount = isOnlyCostScaleService.value
? calcOnlyCostScaleAmountFromRows(sourceRows as Array<{ amount?: unknown }>) ? calcOnlyCostScaleAmountFromRows(sourceRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, htData?.totalAmount)
: null : null
await restoreScaleColumnDefaults({ await restoreScaleColumnDefaults({
gridApi: gridApi.value, gridApi: gridApi.value,
rows: detailRows.value, rows: detailRows.value,
getCurrentValue: row => row.amount, getCurrentValue: row => row.amount,
getNextValue: row => { getNextValue: row => {
if (!row.hasCost) return null
if (useSummaryAmount) return getContractScaleProjectTotalsByRow(row, projectTotals).amount
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap) const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap)
return row.hasCost return typeof sourceRow?.amount === 'number' ? sourceRow.amount : onlyCostScaleFallbackAmount
? (typeof sourceRow?.amount === 'number' ? sourceRow.amount : onlyCostScaleFallbackAmount)
: null
}, },
isSameValue: isSameNullableNumber, isSameValue: isSameNullableNumber,
applyValue: (row, nextValue) => { applyValue: (row, nextValue) => {
@ -812,24 +835,28 @@ const syncLinkedFieldsFromContractAndFactors = async () => {
const syncLinkedScaleValuesFromContract = async (changedRowIds?: string[]) => { const syncLinkedScaleValuesFromContract = async (changedRowIds?: string[]) => {
if (detailRows.value.length === 0) return if (detailRows.value.length === 0) return
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await kvStore.getItem<{ detailRows: SourceRow[]; totalAmount?: number | null }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : [] const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows) const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows) const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount)
const useSummaryAmount = detailRows.value.length === 1 || isOnlyCostScaleService.value
const onlyCostScaleFallbackAmount = isOnlyCostScaleService.value const onlyCostScaleFallbackAmount = isOnlyCostScaleService.value
? calcOnlyCostScaleAmountFromRows(sourceRows as Array<{ amount?: unknown }>) ? calcOnlyCostScaleAmountFromRows(sourceRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, htData?.totalAmount)
: null : null
const changedRowIdSet = changedRowIds?.length ? normalizeChangedScaleRowIds(changedRowIds) : null const changedRowIdSet = changedRowIds?.length ? normalizeChangedScaleRowIds(changedRowIds) : null
let changed = false let changed = false
for (const row of detailRows.value) { for (const row of detailRows.value) {
if (changedRowIdSet) { if (changedRowIdSet && !useSummaryAmount) {
const rowMajorId = resolveRowMajorDictId(row) const rowMajorId = resolveRowMajorDictId(row)
if (!changedRowIdSet.has(rowMajorId)) continue if (!changedRowIdSet.has(rowMajorId)) continue
} }
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap) const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap)
const nextAmount = row.hasCost const nextAmount = row.hasCost
? (typeof sourceRow?.amount === 'number' ? sourceRow.amount : onlyCostScaleFallbackAmount) ? (useSummaryAmount
? getContractScaleProjectTotalsByRow(row, projectTotals).amount
: (typeof sourceRow?.amount === 'number' ? sourceRow.amount : onlyCostScaleFallbackAmount))
: null : null
if (isSameNullableNumber(row.amount, nextAmount)) continue if (isSameNullableNumber(row.amount, nextAmount)) continue
row.amount = nextAmount row.amount = nextAmount
@ -866,12 +893,21 @@ const buildRowsFromImportDefaultSource = async (
): Promise<DetailRow[]> => { ): Promise<DetailRow[]> => {
// 使 // 使
await loadFactorDefaults() await loadFactorDefaults()
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await kvStore.getItem<{ detailRows: SourceRow[]; totalAmount?: number | null }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0 const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
if (isOnlyCostScaleService.value) { if (isOnlyCostScaleService.value) {
return hasContractRows return hasContractRows
? buildOnlyCostScaleRows(htData!.detailRows as any, { projectCount: targetProjectCount, cloneFromProjectOne: true }) ? buildOnlyCostScaleRows(htData!.detailRows as any, {
: buildOnlyCostScaleRows(undefined, { projectCount: targetProjectCount }) projectCount: targetProjectCount,
cloneFromProjectOne: true,
totalAmount: htData?.totalAmount ?? null,
preferSummaryAmountWhenSingleRow: true
})
: buildOnlyCostScaleRows(undefined, {
projectCount: targetProjectCount,
totalAmount: htData?.totalAmount ?? null,
preferSummaryAmountWhenSingleRow: true
})
} }
return hasContractRows return hasContractRows
? mergeWithDictRows(htData!.detailRows, { ? mergeWithDictRows(htData!.detailRows, {

View File

@ -63,7 +63,9 @@ import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
import { import {
buildContractScaleIdMap, buildContractScaleIdMap,
buildContractScaleMap, buildContractScaleMap,
buildContractScaleProjectTotals,
getContractScaleRowByMajor, getContractScaleRowByMajor,
getContractScaleProjectTotalsByRow,
makeProjectMajorKey, makeProjectMajorKey,
normalizeChangedScaleRowIds, normalizeChangedScaleRowIds,
parseProjectIndexFromPathKey, parseProjectIndexFromPathKey,
@ -487,17 +489,21 @@ const formatEditableFlexibleNumber = (params: any) =>
}) })
const restoreLandAreaColumnDefaults = async () => { const restoreLandAreaColumnDefaults = async () => {
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await kvStore.getItem<{ detailRows: SourceRow[]; totalAmount?: number | null }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : [] const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows) const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows) const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount)
const useSummaryLandArea = detailRows.value.length === 1
await restoreScaleColumnDefaults({ await restoreScaleColumnDefaults({
gridApi: gridApi.value, gridApi: gridApi.value,
rows: detailRows.value, rows: detailRows.value,
getCurrentValue: row => row.landArea, getCurrentValue: row => row.landArea,
getNextValue: row => { getNextValue: row => {
if (!row.hasArea) return null
if (useSummaryLandArea) return getContractScaleProjectTotalsByRow(row, projectTotals).landArea
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap) const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap)
return row.hasArea ? (typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null) : null return typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null
}, },
isSameValue: isSameNullableNumber, isSameValue: isSameNullableNumber,
applyValue: (row, nextValue) => { applyValue: (row, nextValue) => {
@ -690,20 +696,26 @@ const syncLinkedFieldsFromContractAndFactors = async () => {
const syncLinkedScaleValuesFromContract = async (changedRowIds?: string[]) => { const syncLinkedScaleValuesFromContract = async (changedRowIds?: string[]) => {
if (detailRows.value.length === 0) return if (detailRows.value.length === 0) return
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await kvStore.getItem<{ detailRows: SourceRow[]; totalAmount?: number | null }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : [] const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows) const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows) const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount)
const useSummaryLandArea = detailRows.value.length === 1
const changedRowIdSet = changedRowIds?.length ? normalizeChangedScaleRowIds(changedRowIds) : null const changedRowIdSet = changedRowIds?.length ? normalizeChangedScaleRowIds(changedRowIds) : null
let changed = false let changed = false
for (const row of detailRows.value) { for (const row of detailRows.value) {
if (changedRowIdSet) { if (changedRowIdSet && !useSummaryLandArea) {
const rowMajorId = resolveRowMajorDictId(row) const rowMajorId = resolveRowMajorDictId(row)
if (!changedRowIdSet.has(rowMajorId)) continue if (!changedRowIdSet.has(rowMajorId)) continue
} }
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap) const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap)
const nextLandArea = row.hasArea ? (typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null) : null const nextLandArea = row.hasArea
? (useSummaryLandArea
? getContractScaleProjectTotalsByRow(row, projectTotals).landArea
: (typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null))
: null
if (isSameNullableNumber(row.landArea, nextLandArea)) continue if (isSameNullableNumber(row.landArea, nextLandArea)) continue
row.landArea = nextLandArea row.landArea = nextLandArea
changed = true changed = true

View File

@ -77,11 +77,21 @@ const numberFormatter = (params: { value?: unknown }) =>
? formatThousandsFlexible(params.value, 3) ? formatThousandsFlexible(params.value, 3)
: '' : ''
const scaleValueColumnField = computed<keyof ScaleDetailRow>(() =>
isInvestmentFormula.value ? 'amount' : 'landArea'
)
const scaleValueColumnHeader = computed(() =>
isInvestmentFormula.value
? t('pricingScale.columns.investAmount')
: t('pricingScale.columns.landArea')
)
const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() => const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() =>
withReadonlyAutoHeight<ScaleDetailRow>([ withReadonlyAutoHeight<ScaleDetailRow>([
{ {
headerName: t('zxFwView.formulaColumns.amount'), headerName: scaleValueColumnHeader.value,
field: 'budgetFee', field: scaleValueColumnField.value,
minWidth: 130, minWidth: 130,
flex: 1, flex: 1,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -92,7 +102,7 @@ const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() =>
}, },
{ {
headerName: t('pricingScale.columns.basicWork'), headerName: t('pricingScale.columns.basicWork'),
field: 'budgetFeeBasic', field: 'benchmarkBudgetBasic',
minWidth: 130, minWidth: 130,
flex: 1, flex: 1,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -109,7 +119,7 @@ const columnDefs = computed<ColDef<ScaleDetailRow>[]>(() =>
}, },
{ {
headerName: t('pricingScale.columns.optionalWork'), headerName: t('pricingScale.columns.optionalWork'),
field: 'budgetFeeOptional', field: 'benchmarkBudgetOptional',
minWidth: 130, minWidth: 130,
flex: 1, flex: 1,
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',

View File

@ -141,7 +141,6 @@ export interface FactorRowLike {
} }
export interface ExportScaleRow { export interface ExportScaleRow {
majorid: number
major: number major: number
cost: number | null cost: number | null
area: number | null area: number | null

View File

@ -14,7 +14,7 @@ import {
X X
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { getIndustryDisplayName, industryTypeList } from '@/sql' import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace' import { initializeProjectFactorStates, initializeProjectScaleState } from '@/lib/projectWorkspace'
import { import {
SelectContent, SelectContent,
SelectIcon, SelectIcon,
@ -76,6 +76,7 @@ interface ProjectInfoState {
const PROJECT_INFO_KEY = 'xm-base-info-v1' const PROJECT_INFO_KEY = 'xm-base-info-v1'
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1' const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1' const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
const PROJECT_SCALE_KEY = 'xm-info-v3'
const getActiveProjectId = () => readCurrentProjectId() const getActiveProjectId = () => readCurrentProjectId()
const tabStore = useTabStore() const tabStore = useTabStore()
@ -277,6 +278,7 @@ const confirmProjectCalc = async () => {
PROJECT_CONSULT_CATEGORY_FACTOR_KEY, PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
PROJECT_MAJOR_FACTOR_KEY PROJECT_MAJOR_FACTOR_KEY
) )
await initializeProjectScaleState(kvAdapter, industry, PROJECT_SCALE_KEY)
writeWorkspaceMode('project') writeWorkspaceMode('project')
window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false }) window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false })
} finally { } finally {

View File

@ -443,45 +443,45 @@ watch(canUseLandScale, enabled => {
<div class="quick-calc-toolbar"> <div class="quick-calc-toolbar">
<label class="quick-calc-toolbar__field quick-calc-toolbar__field--cards"> <section class="quick-calc-form-section quick-calc-toolbar__section">
<span class="quick-calc-field__label">{{ t('quickCalc.fields.industry') }}</span> <div class="quick-calc-field quick-calc-field--wide">
<div class="quick-calc-industry-grid" role="radiogroup" :aria-label="t('quickCalc.fields.industry')"> <span class="quick-calc-field__label">{{ t('quickCalc.fields.industry') }}</span>
<label <div class="quick-calc-industry-grid" role="radiogroup" :aria-label="t('quickCalc.fields.industry')">
v-for="item in industryTypeList" <label
:key="`quick-workbench-${item.id}`" v-for="item in industryTypeList"
class="quick-calc-industry-card" :key="`quick-workbench-${item.id}`"
:class="{ 'is-selected': isIndustrySelected(item.id) }" class="quick-calc-industry-card"
:aria-checked="isIndustrySelected(item.id)"
role="radio"
tabindex="0"
@click.prevent="handleIndustrySelect(item.id)"
@keydown.enter.prevent="handleIndustrySelect(item.id)"
@keydown.space.prevent="handleIndustrySelect(item.id)"
>
<input
:checked="isIndustrySelected(item.id)"
type="radio"
name="quick-calc-industry-choice"
class="quick-calc-option__input"
tabindex="-1"
>
<span
class="quick-calc-industry-card__icon"
:class="{ 'is-selected': isIndustrySelected(item.id) }" :class="{ 'is-selected': isIndustrySelected(item.id) }"
:aria-checked="isIndustrySelected(item.id)"
role="radio"
tabindex="0"
@click.prevent="handleIndustrySelect(item.id)"
@keydown.enter.prevent="handleIndustrySelect(item.id)"
@keydown.space.prevent="handleIndustrySelect(item.id)"
> >
<CircleDot v-if="isIndustrySelected(item.id)" class="h-3.5 w-3.5" /> <input
<Circle v-else class="h-3.5 w-3.5" /> :checked="isIndustrySelected(item.id)"
</span> type="radio"
<span class="quick-calc-industry-card__text"> name="quick-calc-industry-choice"
{{ getIndustryDisplayName(item.id, locale) }} class="quick-calc-option__input"
</span> tabindex="-1"
</label> >
<span
class="quick-calc-industry-card__icon"
:class="{ 'is-selected': isIndustrySelected(item.id) }"
>
<CircleDot v-if="isIndustrySelected(item.id)" class="h-3.5 w-3.5" />
<Circle v-else class="h-3.5 w-3.5" />
</span>
<span class="quick-calc-industry-card__text">
{{ getIndustryDisplayName(item.id, locale) }}
</span>
</label>
</div>
</div> </div>
</label>
<span class="quick-calc-toolbar__meta">
{{ industrySaving ? t('quickCalc.saving') : industryLabel }} </section>
</span>
</div> </div>
<div v-if="!hasSelectedIndustry" class="quick-calc-empty-state"> <div v-if="!hasSelectedIndustry" class="quick-calc-empty-state">
@ -724,25 +724,18 @@ watch(canUseLandScale, enabled => {
.quick-calc-toolbar { .quick-calc-toolbar {
display: flex; display: flex;
align-items: end; align-items: stretch;
justify-content: space-between;
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
border-bottom: 1px solid var(--qc-border); border-bottom: 1px solid var(--qc-border);
background: color-mix(in srgb, var(--qc-surface-muted) 72%, transparent); background: color-mix(in srgb, var(--qc-surface-muted) 72%, transparent);
} }
.quick-calc-toolbar__field { .quick-calc-toolbar__section {
display: grid;
gap: 8px;
min-width: min(280px, 100%); min-width: min(280px, 100%);
flex: 1; flex: 1;
} }
.quick-calc-toolbar__field--cards {
align-content: start;
}
.quick-calc-industry-grid { .quick-calc-industry-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
@ -805,10 +798,8 @@ watch(canUseLandScale, enabled => {
} }
.quick-calc-toolbar__meta { .quick-calc-toolbar__meta {
flex-shrink: 0; justify-content: flex-end;
font-size: 12px;
color: var(--qc-muted); color: var(--qc-muted);
text-align: right;
} }
.quick-calc-empty-state { .quick-calc-empty-state {
@ -1383,7 +1374,7 @@ watch(canUseLandScale, enabled => {
} }
.quick-calc-toolbar__meta { .quick-calc-toolbar__meta {
text-align: left; justify-content: flex-start;
} }
.quick-calc-layout { .quick-calc-layout {

View File

@ -149,7 +149,7 @@ import {
getServiceDictItemById, getServiceDictItemById,
industryTypeList industryTypeList
} from '@/sql' } from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace' import { initializeProjectFactorStates, initializeProjectScaleState } from '@/lib/projectWorkspace'
import { createProjectKvAdapter } from '@/lib/projectKvStore' import { createProjectKvAdapter } from '@/lib/projectKvStore'
import { I18N_LOCALE_KEY, type AppLocale } from '@/i18n' import { I18N_LOCALE_KEY, type AppLocale } from '@/i18n'
const { t, locale } = useI18n() const { t, locale } = useI18n()
@ -539,6 +539,7 @@ const createProjectAndOpen = async () => {
CONSULT_CATEGORY_FACTOR_DB_KEY, CONSULT_CATEGORY_FACTOR_DB_KEY,
MAJOR_FACTOR_DB_KEY MAJOR_FACTOR_DB_KEY
) )
await initializeProjectScaleState(kvAdapter, industry, LEGACY_PROJECT_DB_KEY)
void refreshProjectList() void refreshProjectList()
const href = buildProjectUrl(project.id) const href = buildProjectUrl(project.id)
window.open(href, '_blank', 'noopener') window.open(href, '_blank', 'noopener')
@ -992,6 +993,30 @@ const loadFactorRowsState = async (storageKey: string) => {
} }
} }
const resolveExportFactorRows = (
primary: Awaited<ReturnType<typeof loadFactorRowsState>>,
fallback?: Awaited<ReturnType<typeof loadFactorRowsState>>
) => {
const primaryRows = Array.isArray(primary?.resolved?.detailRows) ? primary.resolved.detailRows : []
if (primaryRows.length > 0) return primaryRows
return Array.isArray(fallback?.resolved?.detailRows) ? fallback.resolved.detailRows : []
}
const resolveExportScaleState = <
TState extends { detailRows?: unknown[]; roughCalcEnabled?: unknown; totalAmount?: unknown } | null | undefined
>(
primary: TState,
fallback?: TState
) => {
const primaryRows = Array.isArray(primary?.detailRows) ? primary.detailRows : []
const primaryHasState =
primaryRows.length > 0
|| primary?.roughCalcEnabled === true
|| (typeof primary?.totalAmount === 'number' && Number.isFinite(primary.totalAmount))
if (primaryHasState) return primary
return fallback ?? primary ?? null
}
const createRichTextCode = (...parts: string[]): unknown => ({ const createRichTextCode = (...parts: string[]): unknown => ({
richText: parts richText: parts
.map(item => String(item || '').trim()) .map(item => String(item || '').trim())
@ -1171,8 +1196,8 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumNumbers(projectScale.map(item => item.cost)) const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumNumbers(projectScale.map(item => item.cost))
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorState.resolved?.detailRows) const projectServiceCoes = buildProjectServiceCoes(resolveExportFactorRows(consultCategoryFactorState))
const projectMajorCoes = buildProjectMajorCoes(majorFactorState.resolved?.detailRows) const projectMajorCoes = buildProjectMajorCoes(resolveExportFactorRows(majorFactorState))
const projectName = isNonEmptyString(projectInfo.projectName) const projectName = isNonEmptyString(projectInfo.projectName)
? projectInfo.projectName.trim() ? projectInfo.projectName.trim()
: t('tab.messages.defaultProjectName') : t('tab.messages.defaultProjectName')
@ -1369,10 +1394,15 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const addtionalFee = toMoney(addtional ? addtional.fee : 0) const addtionalFee = toMoney(addtional ? addtional.fee : 0)
const reserveFee = toMoney(reserve ? reserve.fee : 0) const reserveFee = toMoney(reserve ? reserve.fee : 0)
const contractFee = toMoney(addNumbers(serviceFee, addtionalFee, reserveFee)) const contractFee = toMoney(addNumbers(serviceFee, addtionalFee, reserveFee))
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows) const contractScaleState = resolveExportScaleState(htInfoRaw, projectScaleSource)
const contractScale = contractScaleState?.roughCalcEnabled ? [] : toExportScaleRows(contractScaleState?.detailRows)
const contractServiceCoesRaw = buildProjectServiceCoes(htConsultCategoryFactorState.resolved?.detailRows) const contractServiceCoesRaw = buildProjectServiceCoes(
const contractMajorCoesRaw = buildProjectMajorCoes(htMajorFactorState.resolved?.detailRows) resolveExportFactorRows(htConsultCategoryFactorState, consultCategoryFactorState)
)
const contractMajorCoesRaw = buildProjectMajorCoes(
resolveExportFactorRows(htMajorFactorState, majorFactorState)
)
console.log('[export][contract factor rows]', { console.log('[export][contract factor rows]', {
contractId, contractId,
consultFactorPinia: htConsultCategoryFactorState.piniaData, consultFactorPinia: htConsultCategoryFactorState.piniaData,

View File

@ -37,7 +37,6 @@ export class AgGridResetHeader implements IHeaderComp {
eButton.type = 'button' eButton.type = 'button'
eButton.textContent = '↻' eButton.textContent = '↻'
const fallbackResetTitle = i18n.global.t('agGrid.resetDefault') const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
eButton.title = params.resetTitle || fallbackResetTitle
eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle) eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
eButton.style.display = 'inline-flex' eButton.style.display = 'inline-flex'
eButton.style.alignItems = 'center' eButton.style.alignItems = 'center'
@ -70,7 +69,6 @@ export class AgGridResetHeader implements IHeaderComp {
this.params = params this.params = params
this.eLabel.textContent = params.displayName || params.column?.getColDef().headerName || '' this.eLabel.textContent = params.displayName || params.column?.getColDef().headerName || ''
const fallbackResetTitle = i18n.global.t('agGrid.resetDefault') const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
this.eButton.title = params.resetTitle || fallbackResetTitle
this.eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle) this.eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
this.eButton.style.visibility = params.onReset ? 'visible' : 'hidden' this.eButton.style.visibility = params.onReset ? 'visible' : 'hidden'
return true return true

View File

@ -13,6 +13,7 @@ import { useKvStore } from '@/pinia/kv'
interface StoredDetailRowsState<T = any> { interface StoredDetailRowsState<T = any> {
detailRows?: T[] detailRows?: T[]
totalAmount?: number | null
} }
interface StoredFactorState { interface StoredFactorState {
@ -36,6 +37,16 @@ const sumByNumberNullable = <T>(list: T[], pick: (item: T) => MaybeNumber): numb
return hasValid ? total : null return hasValid ? total : null
} }
const getOnlyCostScaleSummaryAmount = (
rows?: Array<{ amount?: unknown; isGroupRow?: unknown }>,
totalAmount?: unknown
) => {
if (typeof totalAmount === 'number' && Number.isFinite(totalAmount)) return totalAmount
const summaryRow = (rows || []).find(row => row?.isGroupRow === true)
if (typeof summaryRow?.amount === 'number' && Number.isFinite(summaryRow.amount)) return summaryRow.amount
return null
}
interface ScaleRow { interface ScaleRow {
id: string id: string
amount: number | null amount: number | null
@ -388,9 +399,11 @@ const getOnlyCostScaleBudgetFee = (
rowsFromDb: Array<Record<string, unknown>> | undefined, rowsFromDb: Array<Record<string, unknown>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>, consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>, majorFactorMap?: Map<string, number | null>,
industryId?: string | null industryId?: string | null,
totalAmount?: number | null
) => { ) => {
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const rawRows = rowsFromDb || []
const sourceRows = stripGroupScaleRows(rowsFromDb) const sourceRows = stripGroupScaleRows(rowsFromDb)
const defaultConsultCategoryFactor = const defaultConsultCategoryFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
@ -421,12 +434,10 @@ const getOnlyCostScaleBudgetFee = (
}) })
} }
const totalAmount = sumByNumberNullable(sourceRows, row => const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount)
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null if (resolvedTotalAmount == null) return null
)
if (totalAmount == null) return null
const onlyRow = const onlyRow =
sourceRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) || rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) || sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) ||
sourceRows[0] sourceRows[0]
const consultCategoryFactor = getRowNumberOrFallback(onlyRow, 'consultCategoryFactor', defaultConsultCategoryFactor) const consultCategoryFactor = getRowNumberOrFallback(onlyRow, 'consultCategoryFactor', defaultConsultCategoryFactor)
@ -434,7 +445,7 @@ const getOnlyCostScaleBudgetFee = (
const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1) const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1)
const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100) const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100)
return getScaleBudgetFeeByRow({ return getScaleBudgetFeeByRow({
amount: totalAmount, amount: resolvedTotalAmount,
benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true, benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true, benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true,
majorFactor, majorFactor,
@ -449,16 +460,16 @@ const buildOnlyCostScaleDetailRows = (
rowsFromDb: Array<Record<string, unknown>> | undefined, rowsFromDb: Array<Record<string, unknown>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>, consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>, majorFactorMap?: Map<string, number | null>,
industryId?: string | null industryId?: string | null,
totalAmount?: number | null
) => { ) => {
const rawRows = rowsFromDb || []
const sourceRows = stripGroupScaleRows(rowsFromDb) const sourceRows = stripGroupScaleRows(rowsFromDb)
const totalAmount = sumByNumberNullable(sourceRows, row => const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount)
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null
)
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID
const onlyRow = const onlyRow =
sourceRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) || rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
sourceRows.find(row => String(row?.id || '') === onlyCostRowId) sourceRows.find(row => String(row?.id || '') === onlyCostRowId)
const consultCategoryFactor = getRowNumberOrFallback( const consultCategoryFactor = getRowNumberOrFallback(
onlyRow, onlyRow,
@ -478,7 +489,7 @@ const buildOnlyCostScaleDetailRows = (
return [ return [
{ {
id: onlyCostRowId, id: onlyCostRowId,
amount: totalAmount, amount: resolvedTotalAmount,
landArea: null, landArea: null,
consultCategoryFactor, consultCategoryFactor,
majorFactor, majorFactor,
@ -735,7 +746,8 @@ const buildDefaultPricingMethodDetailRows = (
context.htData?.detailRows as Array<Record<string, unknown>> | undefined, context.htData?.detailRows as Array<Record<string, unknown>> | undefined,
context.consultCategoryFactorMap, context.consultCategoryFactorMap,
context.majorFactorMap, context.majorFactorMap,
context.industryId context.industryId,
context.htData?.totalAmount ?? null
) )
: scaleRows.filter(row => { : scaleRows.filter(row => {
if (!isCostMajorById(row.id)) return false if (!isCostMajorById(row.id)) return false
@ -846,7 +858,8 @@ export const getPricingMethodTotalsForService = async (params: {
(htData?.detailRows as Array<Record<string, unknown>> | undefined), (htData?.detailRows as Array<Record<string, unknown>> | undefined),
consultCategoryFactorMap, consultCategoryFactorMap,
majorFactorMap, majorFactorMap,
industryId industryId,
htData?.totalAmount ?? null
) )
: (() => { : (() => {
const investRows = scopedInvestRows || resolveScaleRows( const investRows = scopedInvestRows || resolveScaleRows(

View File

@ -9,6 +9,12 @@ type ScaleLinkRow = {
path?: unknown path?: unknown
} }
type ScaleValueLinkRow = ScaleLinkRow & {
amount?: unknown
landArea?: unknown
isGroupRow?: unknown
}
const normalizeProjectCount = (value: unknown) => { const normalizeProjectCount = (value: unknown) => {
const parsed = Number(value) const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1 if (!Number.isFinite(parsed)) return 1
@ -84,6 +90,55 @@ export const buildContractScaleIdMap = <TRow extends ScaleLinkRow>(rows: TRow[]
return map return map
} }
export const buildContractScaleProjectTotals = <TRow extends ScaleValueLinkRow>(
rows: TRow[] | undefined,
totalAmount?: unknown
) => {
const map = new Map<number, { amount: number | null; landArea: number | null }>()
const normalizedTotalAmount =
typeof totalAmount === 'number' && Number.isFinite(totalAmount)
? totalAmount
: null
if (normalizedTotalAmount != null) {
map.set(1, {
amount: normalizedTotalAmount,
landArea: null
})
}
for (const row of rows || []) {
if (row?.isGroupRow !== true) continue
const projectIndex = resolveScaleRowProjectIndex(row)
const current = map.get(projectIndex) || { amount: null, landArea: null }
const nextAmount =
current.amount != null
? current.amount
: (typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null)
const nextLandArea =
current.landArea != null
? current.landArea
: (typeof row?.landArea === 'number' && Number.isFinite(row.landArea) ? row.landArea : null)
map.set(projectIndex, {
amount: nextAmount,
landArea: nextLandArea
})
}
return map
}
export const getContractScaleProjectTotalsByRow = (
row: ScaleLinkRow | undefined,
totalsMap: Map<number, { amount: number | null; landArea: number | null }>
) => {
const projectIndex = resolveScaleRowProjectIndex(row)
return totalsMap.get(projectIndex)
|| (projectIndex > 1 ? totalsMap.get(1) : undefined)
|| { amount: null, landArea: null }
}
export const getContractScaleRowByMajor = <TRow extends ScaleLinkRow>( export const getContractScaleRowByMajor = <TRow extends ScaleLinkRow>(
row: ScaleLinkRow, row: ScaleLinkRow,
map: Map<string, TRow>, map: Map<string, TRow>,

View File

@ -15,6 +15,8 @@ type DictItemLite = {
name?: string name?: string
defCoe?: number | null defCoe?: number | null
notshowByzxflxs?: boolean notshowByzxflxs?: boolean
hasCost?: boolean | null
hasArea?: boolean | null
} }
type FactorPersistRow = { type FactorPersistRow = {
@ -31,6 +33,48 @@ type FactorPersistState = {
detailRows: FactorPersistRow[] detailRows: FactorPersistRow[]
} }
type ScalePersistRow = {
id: string
groupCode: string
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
path: string[]
hide?: boolean
isGroupRow?: boolean
}
type ScalePersistState = {
detailRows: ScalePersistRow[]
totalAmount: number | null
roughCalcEnabled: boolean
}
type ScalePersistStateSource = {
detailRows?: unknown[]
totalAmount?: unknown
roughCalcEnabled?: unknown
}
type ScaleDictLeaf = {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
type ScaleDictGroup = {
id: string
code: string
name: string
children: ScaleDictLeaf[]
}
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => { const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
const parts = code.split('-').filter(Boolean) const parts = code.split('-').filter(Boolean)
if (!parts.length) return [selfId] if (!parts.length) return [selfId]
@ -80,6 +124,151 @@ const buildFactorRowsFromEntries = (entries: Array<{ id: string; item: DictItemL
.filter((item): item is FactorPersistRow => Boolean(item)) .filter((item): item is FactorPersistRow => Boolean(item))
} }
const buildScaleGroupsFromEntries = (entries: Array<{ id: string; item: DictItemLite }>) => {
const groupMap = new Map<string, ScaleDictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(entries.map(entry => [String(entry.item?.code || '').trim(), entry]))
for (const entry of entries) {
const code = String(entry.item?.code || '').trim()
const name = String(entry.item?.name || '').trim()
if (!code || !name) continue
if (!code.includes('-')) {
if (!groupMap.has(code)) groupOrder.push(code)
groupMap.set(code, {
id: entry.id,
code,
name,
children: []
})
continue
}
const parentCode = code.split('-')[0]
if (!groupMap.has(parentCode)) {
const parent = codeLookup.get(parentCode)
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: String(parent?.item?.name || parentCode).trim(),
children: []
})
}
groupMap.get(parentCode)?.children.push({
id: entry.id,
code,
name,
hasCost: entry.item?.hasCost !== false,
hasArea: entry.item?.hasArea !== false
})
}
return groupOrder
.map(code => groupMap.get(code))
.filter((item): item is ScaleDictGroup => Boolean(item))
}
const buildScaleLeafRows = (groups: ScaleDictGroup[]): ScalePersistRow[] => (
groups.flatMap(group =>
group.children.map(child => ({
id: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
landArea: null,
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
}))
)
)
const mergeScaleLeafRows = (defaultRows: ScalePersistRow[], rowsFromDb: ScalePersistRow[] | undefined) => {
const dbValueMap = new Map<string, ScalePersistRow>()
for (const row of rowsFromDb || []) {
if (!row || row.isGroupRow === true) continue
dbValueMap.set(String(row.id || ''), row)
}
return defaultRows.map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
hide: fromDb.hide,
amount: row.hasCost && typeof fromDb.amount === 'number' && Number.isFinite(fromDb.amount) ? fromDb.amount : null,
landArea: row.hasArea && typeof fromDb.landArea === 'number' && Number.isFinite(fromDb.landArea) ? fromDb.landArea : null
}
})
}
const buildScaleGroupRows = (groups: ScaleDictGroup[], rows: ScalePersistRow[]): ScalePersistRow[] => {
const rowById = new Map(rows.map(row => [String(row.id || ''), row] as const))
return groups.map(group => {
let amountTotal = 0
let hasAmount = false
let landAreaTotal = 0
let hasLandArea = false
for (const child of group.children) {
const leaf = rowById.get(String(child.id || ''))
const amount = leaf?.amount
if (typeof amount === 'number' && Number.isFinite(amount)) {
amountTotal += amount
hasAmount = true
}
const landArea = leaf?.landArea
if (typeof landArea === 'number' && Number.isFinite(landArea)) {
landAreaTotal += landArea
hasLandArea = true
}
}
return {
id: group.id,
groupCode: group.code,
groupName: group.name,
majorCode: group.code,
majorName: group.name,
hasCost: true,
hasArea: true,
amount: hasAmount ? amountTotal : null,
landArea: hasLandArea ? landAreaTotal : null,
path: [`${group.code} ${group.name}`],
hide: false,
isGroupRow: true
}
})
}
export const buildDefaultProjectScaleState = (industry: string, source?: ScalePersistStateSource | null): ScalePersistState => {
const majorEntries = getMajorDictEntries()
.map(({ id, item }) => ({ id, item: item as DictItemLite }))
.filter(({ id, item }) => item.notshowByzxflxs !== true && isMajorIdInIndustryScope(id, industry))
const groups = buildScaleGroupsFromEntries(majorEntries)
const defaultLeafRows = buildScaleLeafRows(groups)
const detailRows = mergeScaleLeafRows(
defaultLeafRows,
Array.isArray(source?.detailRows) ? source.detailRows as ScalePersistRow[] : undefined
)
return {
detailRows: [...detailRows, ...buildScaleGroupRows(groups, detailRows)],
totalAmount:
typeof source?.totalAmount === 'number' && Number.isFinite(source.totalAmount)
? source.totalAmount
: null,
roughCalcEnabled: source?.roughCalcEnabled === true
}
}
export const initializeProjectFactorStates = async ( export const initializeProjectFactorStates = async (
kvStore: KvStoreLike, kvStore: KvStoreLike,
industry: string, industry: string,
@ -110,3 +299,11 @@ export const initializeProjectFactorStates = async (
kvStore.setItem(majorFactorKey, majorPayload) kvStore.setItem(majorFactorKey, majorPayload)
]) ])
} }
export const initializeProjectScaleState = async (
kvStore: KvStoreLike,
industry: string,
projectScaleKey: string
) => {
await kvStore.setItem(projectScaleKey, buildDefaultProjectScaleState(industry))
}

View File

@ -6,6 +6,9 @@ export { toFiniteNumber, toFiniteNumberOrZero }
interface ScaleMethodRowLike { interface ScaleMethodRowLike {
id: string id: string
majorDictId?: unknown majorDictId?: unknown
hasCost?: unknown
hasArea?: unknown
isGroupRow?: unknown
amount: number | null amount: number | null
landArea: number | null landArea: number | null
benchmarkBudgetBasicChecked?: unknown benchmarkBudgetBasicChecked?: unknown
@ -33,12 +36,16 @@ interface WorkContentRowLike {
interface FactorRowLike { interface FactorRowLike {
id: string id: string
standardFactor?: unknown
budgetValue?: unknown budgetValue?: unknown
remark?: unknown remark?: unknown
} }
interface ScaleRowLike { interface ScaleRowLike {
id: string id: string
hasCost?: unknown
hasArea?: unknown
isGroupRow?: unknown
amount: number | null amount: number | null
landArea: number | null landArea: number | null
} }
@ -89,7 +96,6 @@ interface QuantityMethodRowLike {
} }
interface ExportScaleRowLike { interface ExportScaleRowLike {
majorid: number
major: number major: number
cost: number | null cost: number | null
area: number | null area: number | null
@ -249,6 +255,7 @@ const dedupeScaleMethodRows = (rows: ScaleMethodRowLike[] | undefined): ScaleMet
if (!Array.isArray(rows) || rows.length === 0) return [] if (!Array.isArray(rows) || rows.length === 0) return []
const deduped = new Map<string, ScaleMethodRowLike>() const deduped = new Map<string, ScaleMethodRowLike>()
for (const row of rows) { for (const row of rows) {
if (row?.isGroupRow === true) continue
const major = toScaleMajorId(row) const major = toScaleMajorId(row)
if (major == null) continue if (major == null) continue
const proNum = toScaleProNum(row) const proNum = toScaleProNum(row)
@ -258,6 +265,20 @@ const dedupeScaleMethodRows = (rows: ScaleMethodRowLike[] | undefined): ScaleMet
return Array.from(deduped.values()) return Array.from(deduped.values())
} }
const isScaleLeafRow = (row: { isGroupRow?: unknown; hasCost?: unknown; hasArea?: unknown } | null | undefined) => {
if (!row || row.isGroupRow === true) return false
if (row.hasCost === true || row.hasArea === true) return true
return row.hasCost == null && row.hasArea == null
}
const isExportableScaleMethodRow = (
row: ScaleMethodRowLike | null | undefined,
mode: 'cost' | 'area'
) => {
if (!isScaleLeafRow(row)) return false
return mode === 'cost' ? row?.hasCost === true : row?.hasArea === true
}
export const normalizeTaskText = (value: unknown): string => String(value || '').trim() export const normalizeTaskText = (value: unknown): string => String(value || '').trim()
export const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null => export const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null =>
@ -361,11 +382,11 @@ export const buildProjectServiceCoes = (rows: FactorRowLike[] | undefined) => {
return rows return rows
.map(row => { .map(row => {
const serviceid = toSafeInteger(row.id) const serviceid = toSafeInteger(row.id)
if (serviceid == null || row.budgetValue == null) return null const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor)
const coe = toFiniteNumber(row.budgetValue) if (serviceid == null || coe == null) return null
return { return {
serviceid, serviceid,
coe: coe ?? 0, coe,
remark: typeof row.remark === 'string' ? row.remark : '' remark: typeof row.remark === 'string' ? row.remark : ''
} }
}) })
@ -377,11 +398,11 @@ export const buildProjectMajorCoes = (rows: FactorRowLike[] | undefined) => {
return rows return rows
.map(row => { .map(row => {
const majorid = toSafeInteger(row.id) const majorid = toSafeInteger(row.id)
if (majorid == null || row.budgetValue == null) return null const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor)
const coe = toFiniteNumber(row.budgetValue) if (majorid == null || coe == null) return null
return { return {
majorid, majorid,
coe: coe ?? 0, coe,
remark: typeof row.remark === 'string' ? row.remark : '' remark: typeof row.remark === 'string' ? row.remark : ''
} }
}) })
@ -391,11 +412,11 @@ export const buildProjectMajorCoes = (rows: FactorRowLike[] | undefined) => {
export const toExportScaleRows = (rows: ScaleRowLike[] | undefined) => { export const toExportScaleRows = (rows: ScaleRowLike[] | undefined) => {
if (!Array.isArray(rows)) return [] if (!Array.isArray(rows)) return []
return rows return rows
.filter(row => isScaleLeafRow(row))
.map(row => { .map(row => {
const majorid = toSafeInteger(row.id) const majorid = toSafeInteger(row.id)
if (majorid == null || (row.amount == null && row.landArea == null)) return null if (majorid == null || (row.amount == null && row.landArea == null)) return null
return { return {
majorid,
major: majorid, major: majorid,
cost: row.amount, cost: row.amount,
area: row.landArea area: row.landArea
@ -411,6 +432,7 @@ export const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined) => {
const proSet = new Set<number>() const proSet = new Set<number>()
const det = normalizedRows const det = normalizedRows
.map(row => { .map(row => {
if (!isExportableScaleMethodRow(row, 'cost')) return null
const major = toScaleMajorId(row) const major = toScaleMajorId(row)
if (major == null) return null if (major == null) return null
const proNum = toScaleProNum(row) const proNum = toScaleProNum(row)
@ -461,6 +483,7 @@ export const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined) => {
const proSet = new Set<number>() const proSet = new Set<number>()
const det = normalizedRows const det = normalizedRows
.map(row => { .map(row => {
if (!isExportableScaleMethodRow(row, 'area')) return null
const major = toScaleMajorId(row) const major = toScaleMajorId(row)
if (major == null) return null if (major == null) return null
const proNum = toScaleProNum(row) const proNum = toScaleProNum(row)

View File

@ -1,5 +1,6 @@
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals' import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals'
import { getServiceDictItemById } from '@/sql'
import { import {
isSameNullableNumber, isSameNullableNumber,
isSameScaleDetailRow, isSameScaleDetailRow,
@ -8,14 +9,14 @@ import {
import { import {
buildContractScaleIdMap, buildContractScaleIdMap,
buildContractScaleMap, buildContractScaleMap,
buildContractScaleProjectTotals,
getContractScaleRowByMajor, getContractScaleRowByMajor,
getContractScaleProjectTotalsByRow,
normalizeChangedScaleRowIds, normalizeChangedScaleRowIds,
parseScopedRowId,
resolveScaleRowMajorDictId as resolveRowMajorDictId resolveScaleRowMajorDictId as resolveRowMajorDictId
} from '@/lib/pricingScaleLink' } from '@/lib/pricingScaleLink'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
import { getServiceDictItemById } from '@/sql'
import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing' import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing'
export type { ZxFwPricingField } from '@/pinia/zxFwPricing' export type { ZxFwPricingField } from '@/pinia/zxFwPricing'
@ -23,11 +24,6 @@ export type { ZxFwPricingField } from '@/pinia/zxFwPricing'
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
type ServiceLite = {
mutiple?: boolean | null
onlyCostScale?: boolean | null
}
type ScaleDetailRow = { type ScaleDetailRow = {
id: string id: string
projectIndex?: number projectIndex?: number
@ -51,9 +47,6 @@ type ScaleDetailRow = {
path?: string[] path?: string[]
} }
const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) =>
sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null))
const getScaleMethodTotalBudgetFee = (rows: ScaleDetailRow[]) => { const getScaleMethodTotalBudgetFee = (rows: ScaleDetailRow[]) => {
const hasValue = rows.some(row => typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) const hasValue = rows.some(row => typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee))
if (!hasValue) return null if (!hasValue) return null
@ -70,6 +63,10 @@ type WorkloadDetailRow = {
serviceFee?: number | null serviceFee?: number | null
} }
type ServiceLite = {
onlyCostScale?: boolean | null
}
const normalizeServiceIdSet = (serviceIds?: Array<string | number>) => const normalizeServiceIdSet = (serviceIds?: Array<string | number>) =>
new Set((serviceIds || []).map(id => String(id || '').trim()).filter(Boolean)) new Set((serviceIds || []).map(id => String(id || '').trim()).filter(Boolean))
@ -111,12 +108,18 @@ const getWorkloadMethodTotalServiceFee = (rows: WorkloadDetailRow[]) => {
return roundTo(sumByNumber(rows, row => row.serviceFee ?? null), 2) return roundTo(sumByNumber(rows, row => row.serviceFee ?? null), 2)
} }
const matchesChangedScaleRow = (row: ScaleDetailRow, changedRowIds?: Set<string>) => { const isOnlyCostScaleService = (serviceId: string) => {
const service = getServiceDictItemById(serviceId) as ServiceLite | undefined
return service?.onlyCostScale === true
}
const matchesChangedScaleRow = (
row: ScaleDetailRow,
changedRowIds?: Set<string>,
options?: { bypassFilter?: boolean }
) => {
if (options?.bypassFilter) return true
if (!changedRowIds || changedRowIds.size === 0) return true if (!changedRowIds || changedRowIds.size === 0) return true
const directRowId = String(row.id || '').trim()
if (directRowId && changedRowIds.has(directRowId)) return true
const parsedMajorId = parseScopedRowId(row.id).majorDictId
if (parsedMajorId && changedRowIds.has(parsedMajorId)) return true
const majorDictId = resolveRowMajorDictId(row) const majorDictId = resolveRowMajorDictId(row)
return Boolean(majorDictId && changedRowIds.has(majorDictId)) return Boolean(majorDictId && changedRowIds.has(majorDictId))
} }
@ -127,9 +130,8 @@ const syncScaleMethodRows = async (params: {
method: ServicePricingMethod method: ServicePricingMethod
sourceRowMap: Map<string, ScaleDetailRow> sourceRowMap: Map<string, ScaleDetailRow>
sourceRowIdMap: Map<string, ScaleDetailRow> sourceRowIdMap: Map<string, ScaleDetailRow>
projectTotals: Map<number, { amount: number | null; landArea: number | null }>
changedRowIds?: Set<string> changedRowIds?: Set<string>
onlyCostScaleFallbackAmount?: number | null
isOnlyCostScaleService?: boolean
}) => { }) => {
const store = useZxFwPricingStore() const store = useZxFwPricingStore()
const methodState = await store.loadServicePricingMethodState<ScaleDetailRow>( const methodState = await store.loadServicePricingMethodState<ScaleDetailRow>(
@ -141,30 +143,25 @@ const syncScaleMethodRows = async (params: {
let changed = false let changed = false
let changedRowCount = 0 let changedRowCount = 0
const useSummaryScaleValues =
methodState.detailRows.length === 1 ||
(params.method === 'investScale' && isOnlyCostScaleService(params.serviceId))
const nextRows = methodState.detailRows.map(rawRow => { const nextRows = methodState.detailRows.map(rawRow => {
const mode = params.method === 'investScale' ? 'cost' : 'area' const mode = params.method === 'investScale' ? 'cost' : 'area'
if (!matchesChangedScaleRow(rawRow, params.changedRowIds)) return rawRow if (!matchesChangedScaleRow(rawRow, params.changedRowIds, { bypassFilter: useSummaryScaleValues })) return rawRow
const row = { ...rawRow } const row = { ...rawRow }
const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap, params.sourceRowIdMap) const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap, params.sourceRowIdMap)
const projectTotals = getContractScaleProjectTotalsByRow(row, params.projectTotals)
if (mode === 'cost') { if (mode === 'cost') {
const nextAmount = params.isOnlyCostScaleService const nextAmount = useSummaryScaleValues
? ( ? projectTotals.amount
typeof sourceRow?.amount === 'number' : (typeof sourceRow?.amount === 'number' ? sourceRow.amount : null)
? sourceRow.amount
: (params.onlyCostScaleFallbackAmount ?? null)
)
: (
typeof sourceRow?.amount === 'number'
? sourceRow.amount
: null
)
row.amount = isSameNullableNumber(row.amount, nextAmount) ? row.amount : nextAmount row.amount = isSameNullableNumber(row.amount, nextAmount) ? row.amount : nextAmount
} else { } else {
const nextLandArea = const nextLandArea = useSummaryScaleValues
typeof sourceRow?.landArea === 'number' ? projectTotals.landArea
? sourceRow.landArea : (typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null)
: null
row.landArea = isSameNullableNumber(row.landArea, nextLandArea) ? row.landArea : nextLandArea row.landArea = isSameNullableNumber(row.landArea, nextLandArea) ? row.landArea : nextLandArea
} }
@ -236,26 +233,24 @@ export const syncContractScaleToPricing = async (
options: PRICING_TOTALS_OPTIONS options: PRICING_TOTALS_OPTIONS
}) })
const htData = await kvStore.getItem<{ detailRows?: ScaleDetailRow[] }>(`ht-info-v3-${contractId}`) const htData = await kvStore.getItem<{ detailRows?: ScaleDetailRow[]; totalAmount?: number | null }>(`ht-info-v3-${contractId}`)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : [] const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows) const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows) const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const onlyCostScaleFallbackAmount = calcOnlyCostScaleAmountFromRows(sourceRows) const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount)
const updatedServiceIdSet = new Set<string>() const updatedServiceIdSet = new Set<string>()
let updatedMethodCount = 0 let updatedMethodCount = 0
let updatedRowCount = 0 let updatedRowCount = 0
for (const serviceId of selectedIds) { for (const serviceId of selectedIds) {
const service = getServiceDictItemById(serviceId) as ServiceLite | undefined
const investChangedCount = await syncScaleMethodRows({ const investChangedCount = await syncScaleMethodRows({
contractId, contractId,
serviceId, serviceId,
method: 'investScale', method: 'investScale',
sourceRowMap, sourceRowMap,
sourceRowIdMap, sourceRowIdMap,
changedRowIds: changedRowIdSet, projectTotals,
onlyCostScaleFallbackAmount, changedRowIds: changedRowIdSet
isOnlyCostScaleService: service?.onlyCostScale === true
}) })
if (investChangedCount > 0) { if (investChangedCount > 0) {
updatedServiceIdSet.add(serviceId) updatedServiceIdSet.add(serviceId)
@ -268,6 +263,7 @@ export const syncContractScaleToPricing = async (
method: 'landScale', method: 'landScale',
sourceRowMap, sourceRowMap,
sourceRowIdMap, sourceRowIdMap,
projectTotals,
changedRowIds: changedRowIdSet changedRowIds: changedRowIdSet
}) })
if (landChangedCount > 0) { if (landChangedCount > 0) {