完成大部分

This commit is contained in:
zwgczx committed 2026-02-28 16:34:36 +08:00
1 parent e97707ac59
commit 13b03e016e
21 files changed
+1628 -275

No files matched your search

+15 -4
View File
@@ -5,8 +5,8 @@
} from "ag-grid-community"
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.5, // 更细的边框,减少视觉干扰
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
width: 0.3, // 更细的边框,减少视觉干扰
color: "#d3d3d3" // 浅灰色边框,清新不刺眼
};
// 简洁清新风格的主题配置
@@ -32,10 +32,15 @@ export const myTheme = themeQuartz.withParams({
export const gridOptions: GridOptions<any> = {
treeData: true,
animateRows: true,
tooltipShowMode: 'whenTruncated',
suppressAggFuncInHeader: true,
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
autoSizeStrategy: {
type: 'fitGridWidth',
defaultMinWidth: 100,
},
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,
@@ -43,6 +48,12 @@ export const gridOptions: GridOptions<any> = {
defaultColDef: {
resizable: true,
sortable: false,
filter: false
filter: false,
wrapHeaderText: true,
autoHeaderHeight: true
},
defaultColGroupDef: {
wrapHeaderText: true,
autoHeaderHeight: true
}
}
}
+19
View File
@@ -0,0 +1,19 @@
export const formatThousands = (value: unknown, fractionDigits = 2) => {
if (value === '' || value == null) return ''
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return ''
return numericValue.toLocaleString('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
})
}
export const formatThousandsFlexible = (value: unknown, maxFractionDigits = 20) => {
if (value === '' || value == null) return ''
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return ''
return numericValue.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits
})
}
+317
View File
@@ -0,0 +1,317 @@
import localforage from 'localforage'
import { expertList, getBasicFeeFromScale, majorList, serviceList, taskList } from '@/sql'
import { addNumbers, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
interface StoredDetailRowsState<T = any> {
detailRows?: T[]
}
type MaybeNumber = number | null | undefined
interface ScaleRow {
id: string
amount: number | null
landArea: number | null
consultCategoryFactor: number | null
majorFactor: number | null
}
interface WorkloadRow {
id: string
conversion: number | null
workload: number | null
budgetAdoptedUnitPrice: number | null
consultCategoryFactor: number | null
}
interface HourlyRow {
id: string
adoptedBudgetUnitPrice: number | null
personnelCount: number | null
workdayCount: number | null
}
interface MajorLite {
code: string
defCoe: number | null
}
interface ServiceLite {
defCoe: number | null
}
interface TaskLite {
serviceID: number
conversion: number | null
defPrice: number | null
}
interface ExpertLite {
defPrice: number | null
manageCoe: number | null
}
export interface PricingMethodTotals {
investScale: number | null
landScale: number | null
workload: number | null
hourly: number | null
}
const toFiniteNumberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null
const hasOwn = (obj: unknown, key: string) =>
Object.prototype.hasOwnProperty.call(obj || {}, key)
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
const service = (serviceList as Record<string, ServiceLite | undefined>)[String(serviceId)]
return toFiniteNumberOrNull(service?.defCoe)
}
const getDefaultMajorFactorById = (id: string) => {
const major = (majorList as Record<string, MajorLite | undefined>)[id]
return toFiniteNumberOrNull(major?.defCoe)
}
const getMajorLeafIds = () =>
Object.entries(majorList as Record<string, MajorLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter(([, item]) => Boolean(item?.code && item.code.includes('-')))
.map(([id]) => id)
const buildDefaultScaleRows = (serviceId: string | number): ScaleRow[] => {
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
return getMajorLeafIds().map(id => ({
id,
amount: null,
landArea: null,
consultCategoryFactor: defaultConsultCategoryFactor,
majorFactor: getDefaultMajorFactorById(id)
}))
}
const mergeScaleRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
): ScaleRow[] => {
const dbValueMap = new Map<string, Partial<ScaleRow> & Pick<ScaleRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
return buildDefaultScaleRows(serviceId).map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
const hasConsultCategoryFactor = hasOwn(fromDb, 'consultCategoryFactor')
const hasMajorFactor = hasOwn(fromDb, 'majorFactor')
return {
...row,
amount: toFiniteNumberOrNull(fromDb.amount),
landArea: toFiniteNumberOrNull(fromDb.landArea),
consultCategoryFactor:
toFiniteNumberOrNull(fromDb.consultCategoryFactor) ??
(hasConsultCategoryFactor ? null : defaultConsultCategoryFactor),
majorFactor:
toFiniteNumberOrNull(fromDb.majorFactor) ??
(hasMajorFactor ? null : getDefaultMajorFactorById(row.id))
}
})
}
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) => {
const result = getBasicFeeFromScale(amount, 'cost')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) => {
const result = getBasicFeeFromScale(landArea, 'area')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getInvestmentBudgetFee = (row: ScaleRow) => {
const benchmarkBudget = getBenchmarkBudgetByAmount(row.amount)
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
}
const getLandBudgetFee = (row: ScaleRow) => {
const benchmarkBudget = getBenchmarkBudgetByLandArea(row.landArea)
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
}
const getTaskEntriesByServiceId = (serviceId: string | number) =>
Object.entries(taskList as Record<string, TaskLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.filter(([, task]) => Number(task.serviceID) === Number(serviceId))
const buildDefaultWorkloadRows = (serviceId: string | number): WorkloadRow[] => {
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
return getTaskEntriesByServiceId(serviceId).map(([taskId, task], order) => ({
id: `task-${taskId}-${order}`,
conversion: toFiniteNumberOrNull(task.conversion),
workload: null,
budgetAdoptedUnitPrice: toFiniteNumberOrNull(task.defPrice),
consultCategoryFactor: defaultConsultCategoryFactor
}))
}
const mergeWorkloadRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>> | undefined
): WorkloadRow[] => {
const dbValueMap = new Map<string, Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
return buildDefaultWorkloadRows(serviceId).map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
workload: toFiniteNumberOrNull(fromDb.workload),
budgetAdoptedUnitPrice: toFiniteNumberOrNull(fromDb.budgetAdoptedUnitPrice),
consultCategoryFactor: toFiniteNumberOrNull(fromDb.consultCategoryFactor)
}
})
}
const calcWorkloadServiceFee = (row: WorkloadRow) => {
if (
row.budgetAdoptedUnitPrice == null ||
row.conversion == null ||
row.workload == null ||
row.consultCategoryFactor == null
) {
return null
}
return roundTo(
toDecimal(row.budgetAdoptedUnitPrice).mul(row.conversion).mul(row.workload).mul(row.consultCategoryFactor),
2
)
}
const getExpertEntries = () =>
Object.entries(expertList as Record<string, ExpertLite>).sort((a, b) => Number(a[0]) - Number(b[0]))
const getDefaultHourlyAdoptedPrice = (expert: ExpertLite) => {
if (expert.defPrice == null || expert.manageCoe == null) return null
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
const buildDefaultHourlyRows = (): HourlyRow[] =>
getExpertEntries().map(([expertId, expert]) => ({
id: `expert-${expertId}`,
adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert),
personnelCount: null,
workdayCount: null
}))
const mergeHourlyRows = (
rowsFromDb: Array<Partial<HourlyRow> & Pick<HourlyRow, 'id'>> | undefined
): HourlyRow[] => {
const dbValueMap = new Map<string, Partial<HourlyRow> & Pick<HourlyRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
return buildDefaultHourlyRows().map(row => {
const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice),
personnelCount: toFiniteNumberOrNull(fromDb.personnelCount),
workdayCount: toFiniteNumberOrNull(fromDb.workdayCount)
}
})
}
const calcHourlyServiceBudget = (row: HourlyRow) => {
if (row.adoptedBudgetUnitPrice == null || row.personnelCount == null || row.workdayCount == null) return null
return roundTo(toDecimal(row.adoptedBudgetUnitPrice).mul(row.personnelCount).mul(row.workdayCount), 2)
}
export const getPricingMethodTotalsForService = async (params: {
contractId: string
serviceId: string | number
}): Promise<PricingMethodTotals> => {
const serviceId = String(params.serviceId)
const htDbKey = `ht-info-v3-${params.contractId}`
const investDbKey = `tzGMF-${params.contractId}-${serviceId}`
const landDbKey = `ydGMF-${params.contractId}-${serviceId}`
const workloadDbKey = `gzlF-${params.contractId}-${serviceId}`
const hourlyDbKey = `hourlyPricing-${params.contractId}-${serviceId}`
const [investData, landData, workloadData, hourlyData, htData] = await Promise.all([
localforage.getItem<StoredDetailRowsState>(investDbKey),
localforage.getItem<StoredDetailRowsState>(landDbKey),
localforage.getItem<StoredDetailRowsState>(workloadDbKey),
localforage.getItem<StoredDetailRowsState>(hourlyDbKey),
localforage.getItem<StoredDetailRowsState>(htDbKey)
])
const investRows =
investData?.detailRows != null
? mergeScaleRows(serviceId, investData.detailRows as any)
: htData?.detailRows != null
? mergeScaleRows(serviceId, htData.detailRows as any)
: buildDefaultScaleRows(serviceId)
const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row))
const landRows =
landData?.detailRows != null
? mergeScaleRows(serviceId, landData.detailRows as any)
: htData?.detailRows != null
? mergeScaleRows(serviceId, htData.detailRows as any)
: buildDefaultScaleRows(serviceId)
const landScale = sumByNumber(landRows, row => getLandBudgetFee(row))
const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId)
const workload =
defaultWorkloadRows.length === 0
? null
: sumByNumber(
workloadData?.detailRows != null
? mergeWorkloadRows(serviceId, workloadData.detailRows as any)
: defaultWorkloadRows,
row => calcWorkloadServiceFee(row)
)
const hourlyRows =
hourlyData?.detailRows != null
? mergeHourlyRows(hourlyData.detailRows as any)
: buildDefaultHourlyRows()
const hourly = sumByNumber(hourlyRows, row => calcHourlyServiceBudget(row))
return {
investScale,
landScale,
workload,
hourly
}
}
export const getPricingMethodTotalsForServices = async (params: {
contractId: string
serviceIds: Array<string | number>
}) => {
const result = new Map<string, PricingMethodTotals>()
await Promise.all(
params.serviceIds.map(async serviceId => {
const totals = await getPricingMethodTotalsForService({
contractId: params.contractId,
serviceId
})
result.set(String(serviceId), totals)
})
)
return result
}
+39
View File
@@ -0,0 +1,39 @@
import localforage from 'localforage'
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
type XmFactorRow = {
id: string
standardFactor: number | null
budgetValue: number | null
}
type XmFactorState = {
detailRows?: XmFactorRow[]
}
const toFiniteNumberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null
const resolveFactorValue = (row: Partial<XmFactorRow> | undefined): number | null => {
if (!row) return null
const budgetValue = toFiniteNumberOrNull(row.budgetValue)
if (budgetValue != null) return budgetValue
return toFiniteNumberOrNull(row.standardFactor)
}
const loadFactorMap = async (storageKey: string): Promise<Map<string, number | null>> => {
const data = await localforage.getItem<XmFactorState>(storageKey)
const map = new Map<string, number | null>()
for (const row of data?.detailRows || []) {
if (!row?.id) continue
map.set(String(row.id), resolveFactorValue(row))
}
return map
}
export const loadConsultCategoryFactorMap = async () => loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY)
export const loadMajorFactorMap = async () => loadFactorMap(MAJOR_FACTOR_KEY)
+99
View File
@@ -0,0 +1,99 @@
const ZW_VERSION = 1
const KEY_SEED = 'JGJS2026::ZW::ARCHIVE::V1::DO_NOT_TAMPER'
const MAGIC_BYTES = new Uint8Array([0x4a, 0x47, 0x4a, 0x53, 0x5a, 0x57]) // JGJSZW
export const ZW_FILE_EXTENSION = '.zw'
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let cachedKeyPromise: Promise<CryptoKey> | null = null
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer =>
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
const getArchiveKey = async (): Promise<CryptoKey> => {
if (!cachedKeyPromise) {
cachedKeyPromise = (async () => {
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(KEY_SEED))
return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
})()
}
return cachedKeyPromise
}
export const encodeZwArchive = async (payload: unknown): Promise<Uint8Array> => {
const key = await getArchiveKey()
const iv = crypto.getRandomValues(new Uint8Array(12))
const ivBuffer = toArrayBuffer(iv)
const plainText = encoder.encode(JSON.stringify(payload))
const cipherBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: ivBuffer },
key,
toArrayBuffer(plainText)
)
const cipher = new Uint8Array(cipherBuffer)
const result = new Uint8Array(MAGIC_BYTES.length + 1 + 1 + iv.length + cipher.length)
let offset = 0
result.set(MAGIC_BYTES, offset)
offset += MAGIC_BYTES.length
result[offset] = ZW_VERSION
offset += 1
result[offset] = iv.length
offset += 1
result.set(iv, offset)
offset += iv.length
result.set(cipher, offset)
return result
}
export const decodeZwArchive = async <T>(raw: ArrayBuffer | Uint8Array): Promise<T> => {
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const minLength = MAGIC_BYTES.length + 1 + 1 + 1 + 16
if (bytes.length < minLength) {
throw new Error('INVALID_ZW_PAYLOAD')
}
let offset = 0
for (let i = 0; i < MAGIC_BYTES.length; i += 1) {
if (bytes[offset + i] !== MAGIC_BYTES[i]) {
throw new Error('INVALID_ZW_HEADER')
}
}
offset += MAGIC_BYTES.length
const version = bytes[offset]
offset += 1
if (version !== ZW_VERSION) {
throw new Error('INVALID_ZW_VERSION')
}
const ivLength = bytes[offset]
offset += 1
if (ivLength <= 0 || bytes.length <= offset + ivLength) {
throw new Error('INVALID_ZW_PAYLOAD')
}
const iv = bytes.slice(offset, offset + ivLength)
offset += ivLength
const cipher = bytes.slice(offset)
if (cipher.length < 16) {
throw new Error('INVALID_ZW_PAYLOAD')
}
const key = await getArchiveKey()
const ivBuffer = toArrayBuffer(iv)
const cipherBuffer = toArrayBuffer(cipher)
let plainBuffer: ArrayBuffer
try {
plainBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, cipherBuffer)
} catch {
throw new Error('INVALID_ZW_TAMPERED')
}
try {
return JSON.parse(decoder.decode(plainBuffer)) as T
} catch {
throw new Error('INVALID_ZW_CONTENT')
}
}
+56
View File
@@ -0,0 +1,56 @@
import localforage from 'localforage'
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 const ZXFW_RELOAD_SERVICE_KEY = 'zxfw-main'
const toFiniteNumberOrNull = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? value : null
export const syncPricingTotalToZxFw = async (params: {
contractId: string
serviceId: string | number
field: ZxFwPricingField
value: number | null | undefined
}) => {
const dbKey = `zxFW-${params.contractId}`
const data = await localforage.getItem<ZxFwState>(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
}