fix(pl): 修复客户品类证件额度选证与回显问题。

优先消耗有到期日的证件,补齐品类产品选择与运输证适用品类回显,避免新规则安排与建档展示失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-15 07:13:42 +08:00
1 parent b0c1bca683
commit e8cda33a3c
10 files changed
+330 -44

No files matched your search

@@ -56,17 +56,45 @@ def get_category(db: Session, user, category_id: str, *, lock=False):
.order_by(PlCertCategoryProduct.create_time.asc())
.all()
)
names = _product_labels(db, user.tenant_no, [link.product_id for link in links])
products = [
{
'id': link.id,
'productId': link.product_id,
'scopeStatus': link.scope_status,
**names.get(link.product_id, {}),
}
for link in links
]
return _category_dict(row, products)
def _product_labels(db: Session, tenant_no: str, product_ids):
"""批量带出 CRM 产品编号/名称/型号,供品类编辑回显。"""
ids = [pid for pid in dict.fromkeys(product_ids or []) if pid]
if not ids:
return {}
from app.models.crm_models import CrmProduct
rows = (
db.query(CrmProduct)
.filter(
CrmProduct.tenant_no == tenant_no,
CrmProduct.del_flag == 0,
CrmProduct.id.in_(ids),
)
.all()
)
return {
row.id: {
'productNo': row.product_no,
'productName': row.product_name,
'model': row.model,
}
for row in rows
}
def save_category(db: Session, user, data: dict, category_id=None):
"""data: categoryName, categoryCode?, status?, remark?, products?
products 遗漏则不改关联;显式 [] 清空(软删);带 id 就地改。
@@ -90,6 +90,7 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
'产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
)
# MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5)
candidates = (
db.query(PlPurchaseCert)
.filter(
@@ -99,7 +100,11 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
PlPurchaseCert.customer_id == customer_id,
PlPurchaseCert.category_id.in_(categories),
)
.order_by(PlPurchaseCert.valid_to.asc(), PlPurchaseCert.id.asc())
.order_by(
PlPurchaseCert.valid_to.is_(None).asc(),
PlPurchaseCert.valid_to.asc(),
PlPurchaseCert.id.asc(),
)
.all()
)
@@ -150,7 +155,11 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
PlTransportCert.customer_id == customer_id,
PlTransportCert.id.in_(transport_cert_ids),
)
.order_by(PlTransportCert.valid_to.asc(), PlTransportCert.id.asc())
.order_by(
PlTransportCert.valid_to.is_(None).asc(),
PlTransportCert.valid_to.asc(),
PlTransportCert.id.asc(),
)
.all()
)
for transport in transports:
@@ -2,9 +2,8 @@
"""为客户+品类证件额度联调造数(测试库)。
造:客户、报价(accepted)、合同(executing)+ 合同产品、
2 张购买证(10吨早到期 / 100吨晚到期)、2 张运输证(引用对应购买证)、期初 opening。
不造:品类↔系统产品关联(由用户在界面配置)。
2 张购买证(10吨早到期 / 100吨晚到期)、2 张运输证(引用对应购买证)、期初 opening、
品类↔种子产品关联(否则新规则选证会因「产品未配置证用品类」失败)。
用法:
cd backend && source venv/bin/activate
@@ -118,6 +117,7 @@ def main() -> int:
print(f'category_id={category_id}')
print(f'vehicle={VEHICLE}')
print(f'purchase early 10 / late 100 ; transport pair each')
ids['category_product'] = uid('cat-prod')
# cleanup previous seed with same ids
cleanup_tables = [
@@ -125,6 +125,7 @@ def main() -> int:
('pl_transport_cert_purchase_ref', 'id', [ids['ref_early'], ids['ref_late']]),
('pl_transport_cert', 'id', [ids['transport_early'], ids['transport_late']]),
('pl_purchase_cert', 'id', [ids['purchase_early'], ids['purchase_late']]),
('pl_cert_category_product', 'id', [ids['category_product']]),
('crm_contract_product', 'id', [ids['contract_product']]),
('crm_contract', 'id', [ids['contract']]),
('crm_quote_product', 'id', [ids['quote_product']]),
@@ -204,6 +205,18 @@ def main() -> int:
)
print('+ contract', contract_no, 'product_row', ids['contract_product'])
# 品类 ↔ 种子产品(选证按产品反查品类;缺关联则安排必败)
cur.execute(
"""
INSERT INTO pl_cert_category_product
(id, category_id, product_id, scope_status,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,%s,'active',%s,'',%s,%s,%s,0)
""",
(ids['category_product'], category_id, PRODUCT_ID, TENANT, OWNER_USER_ID, now, now),
)
print('+ category_product', PRODUCT_ID, '->', category_id)
def insert_purchase(pid, cert_no, total, valid_to, face='98% 工业硫酸'):
cur.execute(
"""
@@ -238,6 +238,84 @@ def test_legacy_waive_still_skips_settle(monkeypatch):
assert result.get('waived') is True
def test_null_valid_to_sorted_after_dated_certs():
"""无到期日的证不能排在有到期日之前(MySQL NULL ASC 默认会错序)。"""
from app.services import pl_quota_customer_select as sel
from app.schemas.pl.quota_usage import UsageSource
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
source = UsageSource(
source_stop_type='delivery',
source_stop_id='s1',
source_stop_product_id='sp1',
plan_product_id='pp1',
contract_id='c1',
contract_product_id='cp1',
product_id='prod1',
quantity=Decimal('10'),
unit='吨',
fingerprint='z' * 64,
snapshot={'customerId': 'cust1'},
requires_transport=False,
)
unbounded = SimpleNamespace(
id='buy-null', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=None,
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
dated = SimpleNamespace(
id='buy-dated', customer_id='cust1', category_id='cat1', status='active',
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1),
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
quantity_unit='吨', opening_single_use_consumed=0,
)
class PurchaseQuery:
def __init__(self):
self._order = None
def filter(self, *a, **k):
return self
def order_by(self, *exprs):
self._order = exprs
return self
def all(self):
# 应先按 valid_to IS NULL 升序(非空在前),再按 valid_to ASC
assert self._order is not None and len(self._order) >= 2
return [dated, unbounded]
class LogQuery:
def filter_by(self, **k):
return self
def first(self):
return SimpleNamespace(id='opening')
db = MagicMock()
def q_side(model):
if model is PlPurchaseCert:
return PurchaseQuery()
if model is PlCertQtyLog:
return LogQuery()
return MagicMock()
db.query.side_effect = q_side
orig = sel.category_ids_for_product
sel.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
try:
purchase_ids, _, manuals = sel.select_whole_cover_certs(
db, SimpleNamespace(tenant_no='t'), [source], vehicle_no='皖A1', on_date=date(2026, 11, 14),
)
finally:
sel.category_ids_for_product = orig
assert purchase_ids == ['buy-dated']
assert manuals[0].permit_id == 'buy-dated'
def test_same_city_source_skips_transport_requirement():
"""同市来源 requires_transport=False,整笔选证可不带运输证。"""
from app.services import pl_quota_customer_select as sel
+8 -1
View File
@@ -428,7 +428,14 @@ export interface CertCategory {
categoryName: string
categoryCode?: string | null
status: string
products?: Array<{ id?: string; productId: string; scopeStatus?: string }>
products?: Array<{
id?: string
productId: string
scopeStatus?: string
productNo?: string | null
productName?: string | null
model?: string | null
}>
}
export const certQuotaRuleApi = {
@@ -1606,7 +1606,7 @@
message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善')
if (title !== '用证没有完成' || pendingFulfill) {
const guide = pendingFulfill
? `${html}<p class="pl-arrange-block-text">新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 待完善并关闭」上传证件并执行完善并关闭后再归档。</p>`
? `${html}<p class="pl-arrange-block-text">新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 已标记待完善证书」上传证件并执行完善并关闭后再归档。</p>`
: html
await ElMessageBox.alert(guide, title, {
type: 'warning',
@@ -1618,7 +1618,7 @@
return undefined
}
// 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「待完善并关闭」。</p>`
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「证书齐备 → 已标记待完善证书」。</p>`
try {
await ElMessageBox.confirm(readyHtml, title, {
type: 'warning',
@@ -74,7 +74,7 @@
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 待完善并关闭」挂证
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
+151 -22
View File
@@ -43,8 +43,12 @@
</el-table-column>
<el-table-column label="操作" min-width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
<el-button link type="primary" @click="openEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click="remove(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</ListTable>
@@ -55,9 +59,10 @@
v-model="visible"
class="dialog-form-layout"
:title="editingId ? '编辑证用品类' : '新增证用品类'"
width="640px"
width="720px"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<div class="form-grid-2">
@@ -74,13 +79,41 @@
</el-select>
</el-form-item>
</div>
<el-form-item label="关联系统产品(产品 ID,逗号分隔,保存后按差量写入)">
<el-input
v-model="productIdsText"
type="textarea"
:rows="3"
placeholder="例如:uuid1,uuid2(上线后可改产品选择器)"
/>
<el-form-item label="关联系统产品">
<div class="cert-category-products">
<div class="cert-category-products__toolbar">
<span class="selected-hint">已选 {{ linkedProducts.length }} 项</span>
<el-button type="primary" @click="productSelectorVisible = true">
<el-icon><Plus /></el-icon>添加产品
</el-button>
</div>
<el-table
v-if="linkedProducts.length"
:data="linkedProducts"
stripe
class="list-table"
row-key="productId"
max-height="280"
>
<el-table-column prop="productNo" label="产品编号" min-width="120">
<template #default="{ row }">{{ row.productNo || '—' }}</template>
</el-table-column>
<el-table-column prop="productName" label="产品名称" min-width="160">
<template #default="{ row }">{{ row.productName || row.productId }}</template>
</el-table-column>
<el-table-column prop="model" label="规格型号" min-width="120">
<template #default="{ row }">{{ row.model || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" @click="removeLinked($index)">
<el-icon><Delete /></el-icon>移除
</el-button>
</template>
</el-table-column>
</el-table>
<p v-else class="form-tip">暂未关联系统产品</p>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -92,13 +125,30 @@
</el-button>
</template>
</el-dialog>
<ProductSelector
:visible="productSelectorVisible"
@update:visible="productSelectorVisible = $event"
@confirm="onProductsConfirm"
/>
</div>
</template>
<script setup lang="ts">
import { certCategoryApi, type CertCategory } from '@/api/pl/quota-cert'
import { fetchProductDetail } from '@/api/crm/product'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
import ProductSelector from '@/components/business/product-selector/index.vue'
import { Check, Close, Delete, Edit, Plus, Refresh } from '@element-plus/icons-vue'
type LinkedProduct = {
id?: string
productId: string
productNo?: string | null
productName?: string | null
model?: string | null
scopeStatus: 'active' | 'inactive'
}
const keyword = ref('')
const loading = ref(false)
@@ -107,7 +157,8 @@
const visible = ref(false)
const editingId = ref('')
const form = reactive({ categoryName: '', categoryCode: '', status: '1' })
const productIdsText = ref('')
const linkedProducts = ref<LinkedProduct[]>([])
const productSelectorVisible = ref(false)
async function load() {
loading.value = true
@@ -124,36 +175,93 @@
void load()
}
async function enrichMissingNames(items: LinkedProduct[]) {
const missing = items.filter((item) => item.productId && !item.productName)
if (!missing.length) return items
await Promise.all(
missing.map(async (item) => {
try {
const res = (await fetchProductDetail({ id: item.productId })) as Record<string, any>
const data = res?.data || res
item.productNo = data?.productNo || item.productNo
item.productName = data?.productName || item.productName
item.model = data?.model || item.model
} catch {
/* 保留 productId 回显 */
}
})
)
return items
}
async function openEdit(row?: CertCategory) {
editingId.value = row?.id || ''
form.categoryName = row?.categoryName || ''
form.categoryCode = row?.categoryCode || ''
form.status = row?.status || '1'
productIdsText.value = ''
linkedProducts.value = []
if (row?.id) {
const detail = await certCategoryApi.get(row.id)
productIdsText.value = (detail.products || [])
const products = (detail.products || [])
.filter((p) => p.scopeStatus !== 'inactive')
.map((p) => p.productId)
.join(',')
.map((p) => ({
id: p.id,
productId: p.productId,
productNo: p.productNo,
productName: p.productName,
model: p.model,
scopeStatus: 'active' as const
}))
linkedProducts.value = await enrichMissingNames(products)
}
visible.value = true
}
function onProductsConfirm(products: Array<Record<string, any>>) {
const existing = new Map(linkedProducts.value.map((item) => [item.productId, item]))
let added = 0
let skipped = 0
for (const product of products || []) {
const productId = String(product.id || '').trim()
if (!productId) continue
if (existing.has(productId)) {
skipped += 1
continue
}
const row: LinkedProduct = {
productId,
productNo: product.productNo || null,
productName: product.productName || null,
model: product.model || null,
scopeStatus: 'active'
}
existing.set(productId, row)
linkedProducts.value.push(row)
added += 1
}
productSelectorVisible.value = false
if (added) ElMessage.success(`已添加 ${added} 个产品`)
else if (skipped) ElMessage.warning('所选产品均已关联')
}
function removeLinked(index: number) {
linkedProducts.value.splice(index, 1)
}
async function save() {
if (!form.categoryName.trim()) {
ElMessage.warning('请填写品类名称')
return
}
const productIds = productIdsText.value
.split(/[,,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
const body = {
categoryName: form.categoryName.trim(),
categoryCode: form.categoryCode || null,
status: form.status,
products: productIds.map((productId) => ({ productId, scopeStatus: 'active' }))
products: linkedProducts.value.map((item) => ({
...(item.id ? { id: item.id } : {}),
productId: item.productId,
scopeStatus: item.scopeStatus || 'active'
}))
}
saving.value = true
try {
@@ -171,7 +279,9 @@
async function remove(row: CertCategory) {
try {
await ElMessageBox.confirm(`确认删除品类「${row.categoryName}」?`, '删除', { type: 'warning' })
await ElMessageBox.confirm(`确认删除品类「${row.categoryName}」?`, '删除', {
type: 'warning'
})
} catch {
return
}
@@ -188,3 +298,22 @@
void load()
})
</script>
<style scoped lang="scss">
.cert-category-products {
width: 100%;
}
.cert-category-products__toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.selected-hint {
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>
+25 -9
View File
@@ -2,12 +2,12 @@
<div class="page-container">
<section class="panel">
<div class="panel-top"><h2 class="title-bar-heading">证书齐备</h2></div>
<el-tabs v-model="listMode" class="cert-ready-tabs" @tab-change="onModeChange">
<el-tab-pane label="待标记齐备" name="mark" />
<el-tab-pane label="已标记待完善证书" name="fulfill" />
</el-tabs>
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-radio-group v-model="listMode" class="mr-3" @change="onModeChange">
<el-radio-button value="mark">标记齐备</el-radio-button>
<el-radio-button value="fulfill">待完善并关闭</el-radio-button>
</el-radio-group>
<ListQuickSearchInput
v-model="keyword"
placeholder="计划编号"
@@ -117,9 +117,9 @@
:loading="markingId === row.id"
@click="mark(row)"
>
<el-icon><CircleCheck /></el-icon>证书齐备
<el-icon><CircleCheck /></el-icon>标记齐备
</el-button>
<span v-else>已确认</span>
<span v-else>已标记</span>
</template>
</template>
</el-table-column>
@@ -284,6 +284,7 @@
function onModeChange() {
page.value = 1
selectedIds.value = []
void load()
}
@@ -296,9 +297,9 @@
async function mark(row: CertReadyPlan) {
try {
await ElMessageBox.confirm(
'确认后可先安排运输;新规则下归档前仍须在「待完善并关闭」中挂上购买证/运输证。确认人和时间会记在本行。',
'证书齐备',
{ type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' }
'确认后可先安排运输;新规则下归档前仍须到「已标记待完善证书」页上传证件并执行完善并关闭。确认人和时间会记在本行。',
'标记齐备',
{ type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '取消' }
)
} catch {
return
@@ -365,3 +366,18 @@
void load()
})
</script>
<style scoped lang="scss">
.cert-ready-tabs {
margin: 0 0 4px;
padding: 0 24px;
:deep(> .el-tabs__header) {
margin-bottom: 0;
}
:deep(> .el-tabs__content) {
display: none;
}
}
</style>
+10 -4
View File
@@ -99,8 +99,8 @@
><el-icon><Check /></el-icon
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
>
<el-button size="small" @click="showContracts(row)"
><el-icon><Connection /></el-icon>关联合同</el-button
<el-button size="small" @click="view(row)"
><el-icon><View /></el-icon>查看</el-button
>
</template></el-table-column
>
@@ -649,7 +649,7 @@
import QuotaHistoryDialog from './QuotaHistoryDialog.vue'
const historyDialog = ref<InstanceType<typeof QuotaHistoryDialog>>()
import { Check, Close, Connection, Delete, Document, Edit, Plus } from '@element-plus/icons-vue'
import { Check, Close, Delete, Document, Edit, Plus, View } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
@@ -1283,6 +1283,10 @@
function applyCertForm(targetKind: CertType, cert: QuotaCert) {
const value = {
...certPayload(targetKind, cert),
// 运输证适用品类为只读展示字段,不进保存 payload
...(targetKind === 'transport'
? { derivedCategoryIds: [...(cert.derivedCategoryIds || [])] }
: {}),
contractBindings: (cert.contractBindings || []).map((binding) => ({
...binding,
products: binding.products.map((product) => ({ ...product }))
@@ -1325,6 +1329,9 @@
form.value = row
? {
...certPayload(targetKind, row),
...(targetKind === 'transport'
? { derivedCategoryIds: [...(row.derivedCategoryIds || [])] }
: {}),
totalQty: inputQty(row.totalQty),
contractBindings: [],
bindingsEditable: row.bindingsEditable !== false,
@@ -1703,7 +1710,6 @@
linkedContracts.value = links
detailVisible.value = true
}
const showContracts = view
async function refreshDetails() {
if (detail.value && detailVisible.value) await view(detail.value)
}