fix: 统一计划产品计量单位并兼容历史吨单位,避免保存时关联校验失败。

This commit is contained in:
wuzilong committed 2026-10-29 16:16:25 +08:00
1 parent 75d7da3d3d
commit 6f4d4d2c59
10 files changed
+16078 -36329

No files matched your search

@@ -4,11 +4,25 @@ from fastapi import HTTPException
# Packaging is editable independently on each stop and does not change its linked product.
LINK_IDENTITY_FIELDS = ('product_id', 'model', 'unit')
# CRM dict value + historical Chinese label; empty also means ton in plan forms.
_TON_UNIT_ALIASES = frozenset({'dun', '吨', ''})
def _key(row, name):
return str(getattr(row, name, None) or '').strip()
def _identity_value(row, name):
value = _key(row, name)
if name == 'unit' and value in _TON_UNIT_ALIASES:
return 'dun'
return value
def _identity_mismatch(left, right):
return any(_identity_value(left, field) != _identity_value(right, field) for field in LINK_IDENTITY_FIELDS)
def validate_mobile_product_links(products, stop_products, *, creating=False, allow_unlinked_draft=False):
products, stops = list(products or []), list(stop_products or [])
if not any(_key(p, 'client_key') for p in products) and not any(_key(p, 'plan_product_client_key') or getattr(p, 'unlinked_draft', False) for p in stops):
@@ -37,7 +51,7 @@ def validate_mobile_product_links(products, stop_products, *, creating=False, al
explicit = _key(stop, 'plan_product_id')
if explicit and explicit != _key(product, 'id'):
raise HTTPException(422, '段产品关联ID与客户端关联键矛盾')
if any(_key(stop, field) != _key(product, field) for field in LINK_IDENTITY_FIELDS):
if _identity_mismatch(stop, product):
raise HTTPException(422, '段产品与所关联计划产品不一致')
@@ -59,7 +73,7 @@ def resolve_mobile_product_link(product, plan_products):
explicit = _key(product, 'plan_product_id')
if explicit and explicit != str(row.id):
raise HTTPException(422, '段产品关联ID与客户端关联键矛盾')
if any(_key(product, field) != _key(row, field) for field in LINK_IDENTITY_FIELDS):
if _identity_mismatch(product, row):
raise HTTPException(422, '段产品与所关联计划产品不一致')
return row.id
File diff suppressed because it is too large. Load diff
+15094 -14944
View File
File diff suppressed because it is too large. Load diff
@@ -92,6 +92,19 @@ def test_mobile_request_rejects_incompatible_model_unit(field):
assert error.value.status_code==422
def test_ton_unit_label_and_dict_value_are_compatible():
"""装货添加产品可能带「吨」,合同行带 dun;二者视为同一单位。"""
m = service()
product = Row(client_key='a', id='pp', product_id='p', model='食品级', unit='吨')
stop = Row(
plan_product_client_key='a', plan_product_id='pp', product_id='p', model='食品级', unit='dun'
)
m.validate_mobile_product_links([product], [stop])
row = Row(id='pp', product_id='p', model='食品级', unit='吨')
m.bind_mobile_product_key(row, product)
assert m.resolve_mobile_product_link(stop, [row]) == 'pp'
def test_new_mobile_plan_cannot_claim_existing_product_id():
with pytest.raises(Exception) as error:
service().validate_mobile_product_links([Row(client_key='a',id='fake')],[],creating=True)
+4 -20372
View File
File diff suppressed because it is too large. Load diff
@@ -731,6 +731,7 @@
prepareLineAliasAndBrandForSubmit,
resolveLineProductName
} from '@/utils/crm-product-brand'
import { normalizeProductUnitValue } from '@/utils/crm-product-display'
import { formatPlVehiclePlateOptionLabel } from '@/utils/pl-display'
import {
buildMissingHazardCertReadyMessage,
@@ -2208,7 +2209,7 @@
productName: String(product.productName || ''),
model: product.model as string | undefined,
categoryName: product.categoryName as string | undefined,
unit: String(product.unit || '吨'),
unit: normalizeProductUnitValue(product.unit),
quantity: 1,
actualQty: 0,
packageSpec: resolveDefaultPackageSpec(productId),
@@ -2316,7 +2317,7 @@
productNo: String(line.productNo || productId),
productName: String(line.productName || ''),
model: (line.model as string) || undefined,
unit: String(line.unit || '吨'),
unit: normalizeProductUnitValue(line.unit),
quantity: 1,
actualQty: 0,
packageSpec,
@@ -2388,7 +2389,7 @@
brandName: item.brandName || undefined,
model: item.model,
categoryName: item.categoryName,
unit: item.unit || '吨',
unit: normalizeProductUnitValue(item.unit),
quantity: Number(item.quantity || item.inboundEstimateQty || item.outboundEstimateQty || 0),
actualQty: 0,
packageSpec: item.packageSpec,
@@ -2432,7 +2433,7 @@
brandId: item.brandMode === 'required' ? item.brandId || undefined : undefined,
brandName: item.brandMode === 'required' ? item.brandName || undefined : undefined,
model: item.model,
unit: item.unit,
unit: normalizeProductUnitValue(item.unit),
categoryName: item.categoryName,
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
@@ -8,6 +8,7 @@ import {
initLineBrandMetaFromProduct,
type BrandOption
} from '@/utils/crm-product-brand'
import { normalizeProductUnitValue } from '@/utils/crm-product-display'
export interface PlanFormProductRow {
id?: string
@@ -87,7 +88,7 @@ export function mapContractProductToPlanRow(item: ContractProductForm, index: nu
brandName: item.brandName || undefined,
model: item.model || item.productModel,
categoryName: item.categoryName,
unit: item.unit || '吨',
unit: normalizeProductUnitValue(item.unit),
quantity: Number(item.quantity || 0),
actualQty: 0,
packageSpec: packageSpecs.length === 1 ? packageSpecs[0] : undefined,
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_PRODUCT_UNIT_VALUE,
normalizeProductUnitValue
} from './crm-product-display'
describe('normalizeProductUnitValue', () => {
it('defaults empty unit to dict value dun', () => {
expect(normalizeProductUnitValue('')).toBe(DEFAULT_PRODUCT_UNIT_VALUE)
expect(normalizeProductUnitValue(null)).toBe('dun')
expect(normalizeProductUnitValue(undefined)).toBe('dun')
})
it('maps historical Chinese labels to dict values', () => {
expect(normalizeProductUnitValue('吨')).toBe('dun')
expect(normalizeProductUnitValue('千克')).toBe('kg')
})
it('keeps existing dict values', () => {
expect(normalizeProductUnitValue('dun')).toBe('dun')
expect(normalizeProductUnitValue('kg')).toBe('kg')
})
})
@@ -70,6 +70,33 @@ export function getProductUnitLabel(
/** CRM 计量单位「吨」的字典值及历史直存文案 */
export const TON_PRODUCT_UNIT_VALUES = new Set(['dun', '吨'])
/** 计划/合同提交用的默认单位(字典值,不是展示文案) */
export const DEFAULT_PRODUCT_UNIT_VALUE = 'dun'
/** 展示文案 → 字典值;避免「吨」与合同行 dun 混用导致保存校验失败 */
const PRODUCT_UNIT_LABEL_TO_VALUE: Record<string, string> = {
千克: 'kg',
吨: 'dun',
升: 'sheng',
瓶: 'ping',
袋: 'dai',
桶: 'tong',
毫升: 'hs',
套: 'set',
个: 'piece',
台: 'unit'
}
/**
* 表单写入 / 提交用:统一为 crm_product_unit 字典值。
* 空值默认 dun;历史直存「吨」等中文标签映射为对应 value。
*/
export function normalizeProductUnitValue(unit: string | null | undefined): string {
const raw = String(unit || '').trim()
if (!raw) return DEFAULT_PRODUCT_UNIT_VALUE
return PRODUCT_UNIT_LABEL_TO_VALUE[raw] || raw
}
/** 地磅过磅单位为吨;空单位按业务默认视为吨 */
export function isTonProductUnit(unit: string | null | undefined): boolean {
const code = (unit || '').trim()
+7 -6
View File
@@ -2,6 +2,7 @@ import type { Plan, PlanContractLink, PlanDelivery, PlanLoad, PlanStopProduct }
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import type { BrandOption } from '@/utils/crm-product-brand'
import { formatBrandConstraintLabel } from '@/utils/crm-product-brand'
import { normalizeProductUnitValue } from '@/utils/crm-product-display'
import {
isPlDibangOptionalVehiclePlate,
isPlInternalCustomerName
@@ -193,7 +194,7 @@ export function createStopProductsFromPlanProducts(
brandName: item.brandName,
brandOptions: item.brandOptions ? [...item.brandOptions] : undefined,
model: item.model,
unit: item.unit || '吨',
unit: normalizeProductUnitValue(item.unit),
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
planQty: Number(item[qtyField] ?? item.quantity ?? 0),
@@ -399,7 +400,7 @@ export function enrichStopProductsDisplayFromPlan(
brandName: match.brandName ?? row.brandName,
brandOptions: match.brandOptions?.length ? [...match.brandOptions] : row.brandOptions,
model: row.model || match.model,
unit: row.unit || match.unit || '吨',
unit: normalizeProductUnitValue(row.unit || match.unit),
packageSpec: row.packageSpec || match.packageSpec,
deliveryMethod: row.deliveryMethod || match.deliveryMethod,
planQty,
@@ -461,7 +462,7 @@ function mapStopProductFromApi(row: PlanStopProduct): PlanStopProductFormRow {
productNo: row.productNo,
productName: row.productName,
model: row.model,
unit: row.unit || '吨',
unit: normalizeProductUnitValue(row.unit),
packageSpec: row.packageSpec,
deliveryMethod: row.deliveryMethod,
contractId: row.contractId,
@@ -595,7 +596,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
productNo: item.productNo || '',
productName: item.productName || '',
model: item.model,
unit: item.unit || '吨',
unit: normalizeProductUnitValue(item.unit),
quantity: Number(item.quantity ?? item.actualQty ?? 0),
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
@@ -754,7 +755,7 @@ export function mapLoadProductsToPlanFormRows(
brandName: row.brandName,
brandOptions: row.brandOptions ? [...row.brandOptions] : undefined,
model: row.model,
unit: row.unit || '吨',
unit: normalizeProductUnitValue(row.unit),
quantity: Number(row.planQty ?? 0),
actualQty: 0,
packageSpec: row.packageSpec,
@@ -863,7 +864,7 @@ export function buildStopProductPayload(row: PlanStopProductFormRow) {
productNo: row.productNo,
productName: row.productName,
model: row.model,
unit: row.unit,
unit: normalizeProductUnitValue(row.unit),
packageSpec: row.packageSpec,
deliveryMethod: row.deliveryMethod,
contractId: row.contractId || undefined,