feat(pl): 吨千克克和升毫升按固定比例折算用证量。

同质量或体积单位不再要求手填系数,普通 http 页面也能生成幂等操作号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-05 21:14:15 +08:00
1 parent e7f942b399
commit da3ec62719
21 files changed
+277 -43

No files matched your search

@@ -286,6 +286,7 @@
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData } from '@/utils/http/bx'
import { randomOperationId } from '@/utils/random-id'
import {
mapDeliveriesFromApi,
mapLegacyPlanToStops,
@@ -1641,7 +1642,8 @@
try {
const archiveBody = { ...frozenBody, confirmOverage }
const signature = JSON.stringify({ planId: targetId, archiveBody })
if (signature !== quotaArchiveAttempt.signature) quotaArchiveAttempt = { signature, id: crypto.randomUUID() }
if (signature !== quotaArchiveAttempt.signature)
quotaArchiveAttempt = { signature, id: randomOperationId() }
const response = await planApi.archive(
targetId,
{ ...archiveBody, operationId: quotaArchiveAttempt.id },
@@ -663,6 +663,7 @@
} from '@/api/pl'
import { planQuotaApi, type QuotaSourceOption } from '@/api/pl/quota-cert'
import { toxicArrangeBlockMessage } from '@/utils/pl-quota-cert-waiver'
import { randomOperationId } from '@/utils/random-id'
import { arrangeBlockedMessage, isExecutionStarted } from '@/utils/pl-plan-action-guards'
import {
usePlRemoteCarrierOptions,
@@ -2720,7 +2721,7 @@
})
const signature = JSON.stringify({ planId, payload })
if (signature !== arrangeAttempt.signature) arrangeAttempt = { signature, id: crypto.randomUUID() }
if (signature !== arrangeAttempt.signature) arrangeAttempt = { signature, id: randomOperationId() }
try {
await submitPlanArrange(
planId,
@@ -36,6 +36,22 @@ export function sameCertUnit(left?: string | null, right?: string | null) {
return normalizeProductUnitValue(planUnit) === normalizeProductUnitValue(certUnit)
}
const MASS_IN_GRAMS: Record<string, number> = { g: 1, kg: 1000, dun: 1000000 }
const VOLUME_IN_MILLILITRES: Record<string, number> = { hs: 1, sheng: 1000 }
/** 与后端 standard_conversion_ratio 同一口径:吨/千克/克、升/毫升不用手填系数。 */
export function hasStandardUnitConversion(left?: string | null, right?: string | null) {
const planUnit = String(left || '').trim()
const certUnit = String(right || '').trim()
if (!planUnit || !certUnit) return false
const source = normalizeProductUnitValue(planUnit)
const permit = normalizeProductUnitValue(certUnit)
if (!source || !permit || source === permit) return false
return [MASS_IN_GRAMS, VOLUME_IN_MILLILITRES].some(
(table) => source in table && permit in table
)
}
export function coefficientFromQuantities(source: unknown, permit: unknown) {
const sourceQty = Number(source)
const permitQty = Number(permit)
@@ -62,7 +78,13 @@ export function unitConversions(plan: PlanQuota | undefined, sources: QuotaSourc
row.sourceStopProductId === source.sourceStopProductId
)?.certificates || []
return certs
.filter((cert) => cert.quantityUnit && source.unit && !sameCertUnit(source.unit, cert.quantityUnit))
.filter(
(cert) =>
cert.quantityUnit &&
source.unit &&
!sameCertUnit(source.unit, cert.quantityUnit) &&
!hasStandardUnitConversion(source.unit, cert.quantityUnit)
)
.map((cert) => ({
sourceId: `${source.sourceStopType}:${source.sourceStopProductId}`,
source,
@@ -78,6 +78,7 @@ export const DEFAULT_PRODUCT_UNIT_VALUE = 'dun'
/** 展示文案 → 字典值;避免「吨」与合同行 dun 混用导致保存校验失败 */
const PRODUCT_UNIT_LABEL_TO_VALUE: Record<string, string> = {
克: 'g',
千克: 'kg',
吨: 'dun',
升: 'sheng',
+19
View File
@@ -0,0 +1,19 @@
type RandomSource = {
randomUUID?: () => string
getRandomValues?: (bytes: Uint8Array) => Uint8Array
}
/** 幂等操作号。安全上下文用 randomUUID;http 页面没有该接口时改用 getRandomValues。 */
export function randomOperationId(source?: RandomSource): string {
const cryptoSource = source === undefined ? globalThis.crypto : source
if (typeof cryptoSource?.randomUUID === 'function') return cryptoSource.randomUUID()
const bytes = new Uint8Array(16)
if (typeof cryptoSource?.getRandomValues === 'function') cryptoSource.getRandomValues(bytes)
else {
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256)
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
@@ -40,6 +40,7 @@
import { ElMessage } from 'element-plus'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { randomOperationId } from '@/utils/random-id'
import './cert-mobile.scss'
const props = defineProps<{ row?: QuotaCert | null }>()
@@ -57,7 +58,7 @@
watch(
() => props.row?.id,
() => {
opening.operationId = crypto.randomUUID()
opening.operationId = randomOperationId()
opening.usedQty = '0'
opening.singleUseConsumed = false
opening.reason = ''
@@ -55,6 +55,7 @@
} from '@/api/pl/quota-cert'
import { formatCertQty } from '@/views/pl/quota-cert/model'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { randomOperationId } from '@/utils/random-id'
import { useDictStore } from '@/store/modules/dict'
import './cert-mobile.scss'
@@ -131,7 +132,7 @@
return
}
const signature = JSON.stringify({ id: cert.value.id, ...body })
if (signature !== attempt.signature) attempt = { signature, id: crypto.randomUUID() }
if (signature !== attempt.signature) attempt = { signature, id: randomOperationId() }
saving.value = true
try {
await quotaAdjustmentApi.total(props.kind, cert.value.id, { ...body, operationId: attempt.id })
@@ -75,6 +75,7 @@
import { formatCertQty } from './model'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { randomOperationId } from '@/utils/random-id'
function inputQty(value: unknown) {
const text = formatCertQty(value)
return text === '—' ? '' : text
@@ -144,7 +145,7 @@
const id = cert.value.id,
type = kind.value,
signature = JSON.stringify({ id, type, ...body })
if (signature !== attempt.signature) attempt = { signature, id: crypto.randomUUID() }
if (signature !== attempt.signature) attempt = { signature, id: randomOperationId() }
saving.value = true
try {
await quotaAdjustmentApi.total(type, id, { ...body, operationId: attempt.id })
+2 -1
View File
@@ -607,6 +607,7 @@
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { randomOperationId } from '@/utils/random-id'
import { fetchGetMenuCodes } from '@/api/auth'
import {
quotaCertApi,
@@ -1573,7 +1574,7 @@
function openConfirm(row: QuotaCert) {
confirmRow.value = row
Object.assign(opening, {
operationId: crypto.randomUUID(),
operationId: randomOperationId(),
usedQty: '0',
singleUseConsumed: false,
reason: '',