Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0baa6880f7 | ||
|
|
dd4fdf1e6e | ||
|
|
92a8b3f6da | ||
|
|
522ef36e7a | ||
|
|
bb75f98582 | ||
|
|
bf89a1f301 | ||
|
|
391e5f66f5 | ||
|
|
522b4be705 |
No files matched your search
@@ -115,4 +115,5 @@ api_key
|
||||
|
||||
# personal-dev:begin
|
||||
/.local/agent/
|
||||
/.local/secrets/
|
||||
# personal-dev:end
|
||||
@@ -4,7 +4,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.doclib_upload_route import DoclibRoute
|
||||
from app.schemas.doclib import DeleteFilesBody, FolderCreateBody, FolderUpdateBody, ShareBody
|
||||
from app.schemas.doclib import (
|
||||
BatchShareBody,
|
||||
DeleteFilesBody,
|
||||
FolderCreateBody,
|
||||
FolderUpdateBody,
|
||||
ShareBody,
|
||||
)
|
||||
from app.services.doclib_service import DoclibService
|
||||
from app.services.doclib_storage import download_document, upload_document
|
||||
|
||||
@@ -87,6 +93,11 @@ def delete_document_files(body: DeleteFilesBody, svc: DoclibService = Depends(se
|
||||
return success_response()
|
||||
|
||||
|
||||
@router.post('/web/doclib/files/batch-shares')
|
||||
def batch_share_documents(body: BatchShareBody, svc: DoclibService = Depends(service)):
|
||||
return success_response(svc.set_shares_batch(body.ids, body.userIds))
|
||||
|
||||
|
||||
@router.get('/web/doclib/files/{file_id}')
|
||||
def document_detail(file_id: str, svc: DoclibService = Depends(service)):
|
||||
return success_response(svc.get_file(file_id))
|
||||
|
||||
@@ -536,6 +536,7 @@ class PlPlanLoad(Base):
|
||||
notes = Column(String(500), nullable=True, comment="注意事项")
|
||||
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
||||
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
|
||||
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
@@ -599,6 +600,7 @@ class PlPlanDelivery(Base):
|
||||
notes = Column(String(500), nullable=True, comment="注意事项")
|
||||
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
||||
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
|
||||
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
|
||||
@@ -28,5 +28,10 @@ class ShareBody(_Body):
|
||||
userIds: List[str] = Field(max_length=500)
|
||||
|
||||
|
||||
class BatchShareBody(_Body):
|
||||
ids: List[str] = Field(min_length=1, max_length=100)
|
||||
userIds: List[str] = Field(max_length=500)
|
||||
|
||||
|
||||
class DeleteFilesBody(_Body):
|
||||
ids: List[str] = Field(min_length=1, max_length=100)
|
||||
@@ -182,6 +182,7 @@ class PlanLoadBody(BaseModel):
|
||||
dibang_direction: Optional[str] = Field(
|
||||
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
|
||||
)
|
||||
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
products: list[PlanStopProductBody] = Field(default_factory=list, description="装货段产品明细")
|
||||
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="装货段合同列表")
|
||||
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
|
||||
@@ -203,6 +204,7 @@ class PlanDeliveryBody(BaseModel):
|
||||
dibang_direction: Optional[str] = Field(
|
||||
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
|
||||
)
|
||||
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
products: list[PlanStopProductBody] = Field(default_factory=list, description="送货段产品明细")
|
||||
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="送货段合同列表")
|
||||
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
|
||||
|
||||
@@ -20,6 +20,7 @@ class UsageSource(InternalModel):
|
||||
related_stop_products: List[str] = Field(default_factory=list)
|
||||
fingerprint: str
|
||||
snapshot: dict = Field(default_factory=dict)
|
||||
requires_transport: bool = True
|
||||
|
||||
@property
|
||||
def source_id(self):
|
||||
|
||||
@@ -228,23 +228,38 @@ class DoclibService:
|
||||
self.db.commit()
|
||||
return result
|
||||
|
||||
def set_shares(self, file_id, user_ids):
|
||||
def _apply_shares(self, file_id, user_ids):
|
||||
row = self._file(file_id, manage=True, lock=True)
|
||||
requested = set(user_ids) - {row.create_by}
|
||||
valid = {u.id for u in self._base(SysUser).filter(SysUser.id.in_(requested), SysUser.status == '1').all()}
|
||||
if requested != valid:
|
||||
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
||||
if requested:
|
||||
valid = {u.id for u in self._base(SysUser).filter(
|
||||
SysUser.id.in_(requested), SysUser.status == '1').all()}
|
||||
if requested != valid:
|
||||
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
||||
history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
|
||||
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id).populate_existing().with_for_update().all()}
|
||||
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id
|
||||
).populate_existing().with_for_update().all()}
|
||||
for uid, share in history.items():
|
||||
share.del_flag = 0 if uid in requested else 1
|
||||
share.update_by = self.uid
|
||||
for uid in requested - set(history):
|
||||
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
|
||||
row.update_by, row.update_time = self.uid, datetime.now()
|
||||
return row
|
||||
|
||||
def set_shares(self, file_id, user_ids):
|
||||
row = self._apply_shares(file_id, user_ids)
|
||||
self.db.commit()
|
||||
return self._serialize_files([row])[0]
|
||||
|
||||
def set_shares_batch(self, ids, user_ids):
|
||||
file_ids = sorted({str(i).strip() for i in ids if str(i).strip()})
|
||||
if not file_ids:
|
||||
raise HTTPException(400, '请选择要设置分享的文档')
|
||||
rows = [self._apply_shares(fid, user_ids) for fid in file_ids]
|
||||
self.db.commit()
|
||||
return self._serialize_files(rows)
|
||||
|
||||
def delete_files(self, ids):
|
||||
rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))]
|
||||
for row in rows:
|
||||
|
||||
@@ -1152,6 +1152,7 @@ def _resolve_or_create_plan_load(
|
||||
existing.notes = load.notes
|
||||
existing.generate_dibang = generate_dibang
|
||||
existing.dibang_direction = dibang_direction
|
||||
existing.same_city = 1 if int(load.same_city or 0) == 1 else 0
|
||||
existing.remark = load.remark
|
||||
existing.del_flag = 0
|
||||
existing.update_by = user_id
|
||||
@@ -1173,6 +1174,7 @@ def _resolve_or_create_plan_load(
|
||||
notes=load.notes,
|
||||
generate_dibang=generate_dibang,
|
||||
dibang_direction=dibang_direction,
|
||||
same_city=1 if int(load.same_city or 0) == 1 else 0,
|
||||
tenant_no=tenant_no,
|
||||
remark=load.remark,
|
||||
create_by=user_id,
|
||||
@@ -1439,6 +1441,7 @@ def replace_plan_stops(
|
||||
stop.seq = body.seq or 1
|
||||
stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields(
|
||||
stop.customer_name, kind, body.generate_dibang, body.dibang_direction)
|
||||
stop.same_city = 1 if int(getattr(body, "same_city", 0) or 0) == 1 else 0
|
||||
stop.del_flag, stop.update_by, stop.update_time = 0, user_id, now
|
||||
model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct
|
||||
for idx, (row, product, linked) in enumerate(product_jobs or []):
|
||||
|
||||
@@ -37,6 +37,7 @@ class SourceDemand:
|
||||
product_id: str
|
||||
quantity: Decimal
|
||||
unit: str
|
||||
requires_transport: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -231,6 +232,8 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
|
||||
key = (row.permit_kind, row.source_id, row.permit_id)
|
||||
s = sources.get(row.source_id)
|
||||
p = groups.get(row.permit_kind, {}).get(row.permit_id)
|
||||
if s is not None and not s.requires_transport and row.permit_kind == 'transport':
|
||||
raise AllocationInputError('同市来源不接受运输证分配')
|
||||
sq, pq = _quantity(row.source_quantity), _quantity(row.permit_quantity)
|
||||
if key in manual or s is None or p is None or s.product_id not in p.product_ids:
|
||||
raise AllocationInputError('人工分配标识重复或不适用')
|
||||
@@ -249,12 +252,19 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
|
||||
|
||||
routes = []
|
||||
for sid, s in sorted(sources.items()):
|
||||
for gid, g in sorted(groups['purchase'].items()):
|
||||
for tid, t in sorted(groups['transport'].items()):
|
||||
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t):
|
||||
routes.append((sid, gid, tid))
|
||||
if len(routes) > limits.max_variables:
|
||||
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
|
||||
if s.requires_transport:
|
||||
pairs = (
|
||||
(gid, tid)
|
||||
for gid, g in sorted(groups['purchase'].items())
|
||||
for tid, t in sorted(groups['transport'].items())
|
||||
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t)
|
||||
)
|
||||
else:
|
||||
pairs = ((gid, None) for gid, g in sorted(groups['purchase'].items()) if eligible('purchase', s, g))
|
||||
for gid, tid in pairs:
|
||||
routes.append((sid, gid, tid))
|
||||
if len(routes) > limits.max_variables:
|
||||
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
|
||||
a, b = [], []
|
||||
|
||||
def constrain(coefficients, bound, equal=False):
|
||||
@@ -405,6 +415,7 @@ def validate_allocation_pairing(
|
||||
if len(demands) > limits.max_sources or len(transports) > limits.max_permits:
|
||||
raise AllocationIncomplete('匹配校验未完成:输入规模超限')
|
||||
sources = _unique(demands, 'source_id')
|
||||
purchase_only = {sid for sid, row in sources.items() if not row.requires_transport}
|
||||
transport_map = _unique(transports, 'permit_id')
|
||||
for s in sources.values():
|
||||
_quantity(s.quantity)
|
||||
@@ -434,13 +445,16 @@ def validate_allocation_pairing(
|
||||
groups[kind][key] = row
|
||||
for sid, s in sources.items():
|
||||
total = sum(F(r.source_quantity) for (source_id, _), r in groups[kind].items() if source_id == sid)
|
||||
if total != F(s.quantity):
|
||||
expected = F(0) if kind == 'transport' and sid in purchase_only else F(s.quantity)
|
||||
if total != expected:
|
||||
raise AllocationPairingError('分配未覆盖实际来源量,需要人工调整;保留原占用')
|
||||
purchase_ids = {pid for _, pid in groups['purchase']}
|
||||
if len(purchase_ids) + len(transport_map) > limits.max_permits:
|
||||
raise AllocationIncomplete('匹配校验未完成:证件数量超限')
|
||||
routes = []
|
||||
for sid, gid in sorted(groups['purchase']):
|
||||
if sid in purchase_only:
|
||||
continue
|
||||
for source_id, tid in sorted(groups['transport']):
|
||||
if sid == source_id and gid in transport_map[tid].purchase_permit_ids:
|
||||
routes.append((sid, gid, tid))
|
||||
@@ -450,9 +464,13 @@ def validate_allocation_pairing(
|
||||
for kind, rows in groups.items():
|
||||
position = 1 if kind == 'purchase' else 2
|
||||
for (sid, pid), row in sorted(rows.items()):
|
||||
if sid in purchase_only:
|
||||
continue
|
||||
v = [int(r[0] == sid and r[position] == pid) for r in routes]
|
||||
a.extend([v, [-x for x in v]])
|
||||
b.extend([F(row.source_quantity), -F(row.source_quantity)])
|
||||
if not a:
|
||||
return
|
||||
try:
|
||||
_solve(a, b, [0] * len(routes), budget)
|
||||
except InsufficientQuota as exc:
|
||||
|
||||
@@ -21,7 +21,13 @@ def allocation_diagnostics(demands, purchases, transports):
|
||||
grouped.append((code, source.unit))
|
||||
for kind, permits in [('purchase', purchases), ('transport', transports)]:
|
||||
for code, unit in grouped:
|
||||
sources = [s for s in demands if canonical_quantity_unit(s.unit) == code]
|
||||
sources = [
|
||||
s for s in demands
|
||||
if canonical_quantity_unit(s.unit) == code
|
||||
and (kind == 'purchase' or getattr(s, 'requires_transport', True))
|
||||
]
|
||||
if not sources:
|
||||
continue
|
||||
products = {s.product_id for s in sources}
|
||||
eligible = [p for p in permits if p.product_ids & products]
|
||||
demand = sum((s.quantity for s in sources), Decimal(0))
|
||||
@@ -67,6 +73,8 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos):
|
||||
notices = []
|
||||
seen = set()
|
||||
for source in demands:
|
||||
if not getattr(source, 'requires_transport', True):
|
||||
continue
|
||||
buys = [p for p in purchases if source.product_id in p.product_ids and same_quantity_unit(p.unit, source.unit)]
|
||||
moves = [t for t in transports if source.product_id in t.product_ids and same_quantity_unit(t.unit, source.unit)]
|
||||
if not buys or not moves:
|
||||
@@ -93,16 +101,17 @@ def foreign_unit_message(demands, purchases, transports, cert_nos):
|
||||
for source in demands:
|
||||
buys = [p for p in purchases if source.product_id in p.product_ids]
|
||||
moves = [t for t in transports if source.product_id in t.product_ids]
|
||||
needs_transport = getattr(source, 'requires_transport', True)
|
||||
same_buys = [p for p in buys if _directly_usable(p, source.unit)]
|
||||
same_moves = [t for t in moves if _directly_usable(t, source.unit)]
|
||||
if same_buys and same_moves:
|
||||
if same_buys and (same_moves or not needs_transport):
|
||||
continue
|
||||
parts = []
|
||||
if not same_buys:
|
||||
text = _cert_unit_text('purchase', [p for p in buys if not _directly_usable(p, source.unit)], cert_nos)
|
||||
if text:
|
||||
parts.append(f'购买证 {text}')
|
||||
if not same_moves:
|
||||
if needs_transport and not same_moves:
|
||||
text = _cert_unit_text('transport', [t for t in moves if not _directly_usable(t, source.unit)], cert_nos)
|
||||
if text:
|
||||
parts.append(f'运输证 {text}')
|
||||
|
||||
@@ -35,8 +35,9 @@ def plan_fingerprint(sources):
|
||||
|
||||
|
||||
def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labels):
|
||||
"""按合同产品的可用绑定选出购买证、运输证。缺任一证时拒绝安排。"""
|
||||
purchase_ids, transport_ids, missing = [], [], []
|
||||
"""按合同产品的可用绑定选出购买证、运输证。同市段只要求购买证。"""
|
||||
purchase_ids, transport_ids = [], []
|
||||
missing_both, missing_purchase = [], []
|
||||
for source in sources:
|
||||
found = {'purchase': [], 'transport': []}
|
||||
for binding in bindings_by_contract.get(source.contract_id) or []:
|
||||
@@ -52,13 +53,22 @@ def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labe
|
||||
if source.contract_product_id not in product_ids or kind not in found or not cert_id:
|
||||
continue
|
||||
found[kind].append(cert_id)
|
||||
if not found['purchase'] or not found['transport']:
|
||||
missing.append(contract_labels.get(source.contract_id) or '未编号合同')
|
||||
needs_transport = getattr(source, 'requires_transport', True)
|
||||
label = contract_labels.get(source.contract_id) or '未编号合同'
|
||||
if not found['purchase'] or (needs_transport and not found['transport']):
|
||||
(missing_both if needs_transport else missing_purchase).append(label)
|
||||
purchase_ids.extend(found['purchase'])
|
||||
transport_ids.extend(found['transport'])
|
||||
if missing:
|
||||
names = '、'.join(dict.fromkeys(missing))
|
||||
raise HTTPException(409, f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
|
||||
if needs_transport:
|
||||
transport_ids.extend(found['transport'])
|
||||
messages = []
|
||||
if missing_both:
|
||||
names = '、'.join(dict.fromkeys(missing_both))
|
||||
messages.append(f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
|
||||
if missing_purchase:
|
||||
names = '、'.join(dict.fromkeys(missing_purchase))
|
||||
messages.append(f'合同{names}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输')
|
||||
if messages:
|
||||
raise HTTPException(409, ';'.join(messages))
|
||||
|
||||
def unique(values):
|
||||
return list(dict.fromkeys(values))
|
||||
@@ -279,6 +289,7 @@ def get_plan_quota(db, user, plan_id):
|
||||
product = products[row.plan_product_id]
|
||||
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
|
||||
item = {'sourceStopId': stop.id, 'stopId': stop.id,
|
||||
'sameCity': int(getattr(stop, 'same_city', 0) or 0) == 1,
|
||||
'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
|
||||
'productId': row.product_id, 'productNo': row.product_no,
|
||||
'sourceStopType': side, 'sourceStopProductId': row.id,
|
||||
|
||||
@@ -156,8 +156,10 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
|
||||
for stop_side, value in [(side, row)] + [(other_side, r) for r in related]:
|
||||
parent = stops[stop_side][getattr(value, SIDE[stop_side][2])]
|
||||
stop_facts.append({key: getattr(parent, key, None) for key in ('id', 'supplier_id', 'supplier_name', 'customer_id', 'customer_name', 'address')})
|
||||
same_city = int(getattr(stop, 'same_city', 0) or 0) == 1
|
||||
snapshot = {'source': _facts(row), 'related': [_facts(r) for r in related], 'stops': stop_facts,
|
||||
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type}
|
||||
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type,
|
||||
'sameCity': 1 if same_city else 0}
|
||||
if measurement_version == 'per_stop_v2':
|
||||
snapshot['measurementVersion'] = measurement_version
|
||||
if preview_vehicle is not None:
|
||||
@@ -168,7 +170,7 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
|
||||
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
|
||||
contract_product_id=row.contract_product_id, product_id=row.product_id,
|
||||
quantity=row.plan_qty, unit=row.unit, related_stop_products=[r.id for r in related],
|
||||
fingerprint=fingerprint, snapshot=snapshot)
|
||||
fingerprint=fingerprint, snapshot=snapshot, requires_transport=not same_city)
|
||||
sources.append(source)
|
||||
if actual:
|
||||
quantity = getattr(row, actual_field)
|
||||
|
||||
@@ -197,6 +197,7 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
product=query(CrmContractProduct,id=s.contract_product_id,contract_id=s.contract_id,del_flag=0).first()
|
||||
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
|
||||
scopes={}; candidates={'purchase':[],'transport':[]}
|
||||
skipped_for_vehicle=False
|
||||
for k in sorted(selected):
|
||||
kind,pid=k; c=certs[k]
|
||||
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
|
||||
@@ -206,7 +207,10 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
if not c.valid_from <= r.on_date <= c.valid_to: raise ValueError('证件不在有效日期内')
|
||||
policy=usage_policy(c.valid_use_text)
|
||||
if policy=='needs_confirmation': raise ValueError('有效次数待确认')
|
||||
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): raise ValueError('车号不在运输证范围')
|
||||
# 合同上常有多张运输证。车号对不上的证不参与本次分配,只要还有一张覆盖该主车号即可安排。
|
||||
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
|
||||
skipped_for_vehicle=True
|
||||
continue
|
||||
if policy=='once':
|
||||
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
|
||||
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
|
||||
@@ -220,11 +224,13 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
refs=frozenset(x.purchase_cert_id for x in query(m.PlTransportCertPurchaseRef,transport_cert_id=pid,del_flag=0).all() if x.purchase_cert_id) if kind=='transport' else frozenset()
|
||||
own=usages[k].reserved_qty if k in usages else ZERO
|
||||
candidates[kind].append(PermitCandidate(pid,c.total_qty-c.used_qty-c.reserved_qty+own,c.quantity_unit,c.valid_to,frozenset(eligible),refs))
|
||||
if skipped_for_vehicle and not candidates['transport']:
|
||||
raise ValueError('车号不在运输证范围')
|
||||
return sources,scopes,candidates
|
||||
|
||||
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
|
||||
sources,scopes,candidates=_candidate_context(db,user,r,plan,certs,usages,items,selected,preview,lock_preview)
|
||||
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit) for s in sources.values()]
|
||||
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit,s.requires_transport) for s in sources.values()]
|
||||
try:
|
||||
allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations))
|
||||
except InsufficientQuota as error:
|
||||
@@ -244,6 +250,15 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
|
||||
# 零量也占用一次资格;纯计算器省略零流,账本补存一对真实适用证件。
|
||||
for sid,s in sorted(sources.items()):
|
||||
if s.quantity != 0: continue
|
||||
if not s.requires_transport:
|
||||
buys=[p for p in candidates['purchase'] if sid in p.product_ids and (
|
||||
same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None
|
||||
or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))]
|
||||
if not buys:
|
||||
raise ValueError('零量同市来源仍须有适用购买证')
|
||||
chosen=min(buys, key=lambda item: (item.expires_on, item.permit_id))
|
||||
rows.append(Allocation('purchase', sid, chosen.permit_id, ZERO, ZERO, s.unit, chosen.unit, 1))
|
||||
continue
|
||||
pairs=[(p,t) for p in candidates['purchase'] for t in candidates['transport']
|
||||
if sid in p.product_ids and sid in t.product_ids and p.permit_id in t.purchase_permit_ids
|
||||
and (same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))
|
||||
@@ -430,12 +445,13 @@ def _settle(db,user,r,certs,usages,items,plan):
|
||||
if any(u.usage_status!='settled' if adjusting else u.usage_status not in ('reserved','in_transit','exception') for k,u in usages.items() if k in active):
|
||||
raise ValueError('用证状态不允许当前结转动作')
|
||||
groups={'purchase':[],'transport':[]}; item_map={}; demands={}
|
||||
transport_required={s.source_id: s.requires_transport for s in r.sources}
|
||||
for k,rows in active.items():
|
||||
for i in rows:
|
||||
sid=_key(i)
|
||||
a=Allocation(k[0],sid,k[1],i.allocated_source_qty,i.planned_cert_qty,i.source_unit,usages[k].quantity_unit,i.allocation_seq)
|
||||
groups[k[0]].append(a); item_map[(k[0],sid,k[1])]=i
|
||||
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit)
|
||||
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit,transport_required.get(sid, True))
|
||||
if not r.manual_actuals:
|
||||
groups, generated, conversion_error = _prepare_saved_conversion(groups, r.actual_by_source, certs)
|
||||
if conversion_error:
|
||||
|
||||
@@ -3,8 +3,8 @@ from decimal import Decimal as D
|
||||
import pytest
|
||||
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, AllocationLimits, AllocationInputError, AllocationIncomplete, InsufficientQuota, SupplementalAllocationRequired, allocate_plan, settle_allocations)
|
||||
|
||||
def source(id='s', product='A', quantity='30', unit='t'):
|
||||
return SourceDemand(id, product, D(quantity), unit)
|
||||
def source(id='s', product='A', quantity='30', unit='t', requires_transport=True):
|
||||
return SourceDemand(id, product, D(quantity), unit, requires_transport)
|
||||
|
||||
def permit(id, quantity='30', products=('A',), refs=(), unit='t', expiry=1):
|
||||
return PermitCandidate(id, D(quantity), unit, date(2027, 1, expiry), frozenset(products), frozenset(refs))
|
||||
@@ -153,6 +153,40 @@ def test_zero_demand_and_empty_plan():
|
||||
assert allocate_plan([source(quantity='0')], [], []).transport_allocations == ()
|
||||
|
||||
|
||||
def test_same_city_demand_uses_purchase_certificate_only():
|
||||
result = allocate_plan([source('city', quantity='10', requires_transport=False)], [permit('g', '10')], [])
|
||||
assert quantities(result.purchase_allocations) == [('g', D(10), D(10))]
|
||||
assert result.transport_allocations == ()
|
||||
|
||||
|
||||
def test_same_city_shares_purchase_balance_and_skips_transport():
|
||||
city = source('city', 'A', '10', requires_transport=False)
|
||||
cross = source('cross', 'B', '10')
|
||||
result = allocate_plan(
|
||||
[city, cross],
|
||||
[permit('g', '30', ('A', 'B'))],
|
||||
[permit('t', '10', ('A', 'B'), ('g',))],
|
||||
)
|
||||
assert sum(row.source_quantity for row in result.purchase_allocations) == D(20)
|
||||
assert {(row.source_id, row.permit_id) for row in result.transport_allocations} == {('cross', 't')}
|
||||
with pytest.raises(InsufficientQuota):
|
||||
allocate_plan(
|
||||
[source('city', 'A', '10', requires_transport=False), source('cross', 'A', '10')],
|
||||
[permit('g', '15')],
|
||||
[permit('t', '10', refs=('g',))],
|
||||
)
|
||||
|
||||
|
||||
def test_same_city_rejects_a_transport_manual_row():
|
||||
with pytest.raises(AllocationInputError, match='同市'):
|
||||
allocate_plan(
|
||||
[source(requires_transport=False)],
|
||||
[permit('g', '30')],
|
||||
[permit('t', '30', refs=('g',))],
|
||||
manual_allocations=[ManualAllocation('transport', 's', 't', D(30), D(30))],
|
||||
)
|
||||
|
||||
|
||||
def test_one_purchase_and_two_transport_certificates():
|
||||
result = allocate_plan(
|
||||
[source()], [permit('g', '30')],
|
||||
@@ -208,6 +242,13 @@ def test_settlement_pairing_29_consistent_passes():
|
||||
assert validate_allocation_pairing([source(quantity='29')], actual_rows(plan.purchase_allocations), actual_rows(plan.transport_allocations), transports) is None
|
||||
|
||||
|
||||
def test_same_city_settlement_does_not_require_transport_pairing():
|
||||
from app.services.pl_quota_allocation import Allocation, validate_allocation_pairing
|
||||
demand = source('city', quantity='10', requires_transport=False)
|
||||
purchase = Allocation('purchase', 'city', 'g', D(10), D(10), 't', 't', 1)
|
||||
assert validate_allocation_pairing([demand], [purchase], [], []) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('manual_values', [(8,10,0),(18,0,0)])
|
||||
def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values):
|
||||
from app.services.pl_quota_allocation import Allocation
|
||||
|
||||
@@ -35,6 +35,17 @@ def test_sufficient_totals_do_not_claim_joint_feasibility():
|
||||
assert all('引用' in row['reason'] for row in rows)
|
||||
|
||||
|
||||
def test_same_city_shortfall_does_not_demand_a_transport_certificate():
|
||||
from app.services.pl_quota_diagnostics import allocation_diagnostics, insufficient_quantity_message
|
||||
rows = allocation_diagnostics(
|
||||
[SourceDemand('a', 'a', D('30'), '吨', False)],
|
||||
[permit('p', '10', ['a'])],
|
||||
[],
|
||||
)
|
||||
assert [row['certType'] for row in rows] == ['purchase']
|
||||
assert insufficient_quantity_message(rows) == '购买证剩余10.000吨,本次需要30.000吨。证件余量不足,不能安排运输,请先调整到有余量后再安排'
|
||||
|
||||
|
||||
def test_missing_purchase_link_names_both_certificates():
|
||||
from app.services.pl_quota_diagnostics import missing_purchase_link_message
|
||||
source = SourceDemand('a', 'a', D('30'), '吨')
|
||||
|
||||
@@ -69,6 +69,21 @@ def test_arrange_uses_active_contract_cert_bindings_and_names_the_gap():
|
||||
)
|
||||
|
||||
|
||||
def test_same_city_stop_binds_purchase_certificate_only():
|
||||
from app.services.pl_quota_planning import classify_contract_cert_bindings
|
||||
source = SimpleNamespace(contract_id='c1', contract_product_id='line-1', requires_transport=False)
|
||||
purchase = {
|
||||
'certType': 'purchase', 'certId': 'buy-1', 'linkStatus': 'active',
|
||||
'products': [{'contractProductId': 'line-1', 'scopeStatus': 'active'}],
|
||||
}
|
||||
labels = {'c1': 'HT-1'}
|
||||
assert classify_contract_cert_bindings([source], {'c1': [purchase]}, labels) == (['buy-1'], [])
|
||||
with pytest.raises(HTTPException) as error:
|
||||
classify_contract_cert_bindings([source], {'c1': []}, labels)
|
||||
assert error.value.status_code == 409
|
||||
assert error.value.detail == '合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输'
|
||||
|
||||
|
||||
def test_supplement_is_explicit_and_archive_public_schema_preserves_intent():
|
||||
from app.schemas.pl.plan import PlanArchiveBody
|
||||
from app.schemas.pl.quota_planning import PlanQuotaOperation
|
||||
|
||||
@@ -245,7 +245,25 @@ def test_opening_consumed_and_expiry_vehicle_block(case):
|
||||
p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush()
|
||||
with pytest.raises(ValueError): run(c)
|
||||
p.valid_to=date(2099,12,31); c.db.flush()
|
||||
with pytest.raises(ValueError): run(c,vehicle_no='unknown')
|
||||
with pytest.raises(ValueError, match='车号不在运输证范围'): run(c,vehicle_no='unknown')
|
||||
|
||||
|
||||
def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
|
||||
"""合同上多张运输证时,只用登记了本次主车号的证,不因其他证没有该车号而拒绝安排。"""
|
||||
c = case
|
||||
_second_pair(c)
|
||||
c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 100
|
||||
c.db.get(c.m.PlTransportCert, 't').total_qty = 100
|
||||
c.db.get(c.m.PlTransportCert, 't2').vehicle_numbers_json = ['OTHER']
|
||||
c.db.flush()
|
||||
run(c, purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
|
||||
assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == 29
|
||||
assert c.db.get(c.m.PlTransportCert, 't2').reserved_qty == 0
|
||||
assert c.db.get(c.m.PlPurchaseCert, 'p2').reserved_qty == 0
|
||||
transport_usages = c.db.query(c.m.PlPlanCertUsage).filter_by(cert_type='transport', del_flag=0).all()
|
||||
assert [u.cert_id for u in transport_usages if u.usage_status == 'reserved'] == ['t']
|
||||
with pytest.raises(ValueError, match='车号不在运输证范围'):
|
||||
run(c, operation_id='none', vehicle_no='NONE', purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
|
||||
|
||||
def test_no_cert_operation_is_still_idempotent(case):
|
||||
c=case
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 装货段、送货段增加「是否同市」。同市的易制毒段只校验、扣减购买证。
|
||||
-- MySQL 5.7。可重复执行。历史数据默认 0,仍按购买证和运输证同时校验。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
SET @schema_name = DATABASE();
|
||||
|
||||
SET @load_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_load' AND COLUMN_NAME = 'same_city'
|
||||
);
|
||||
SET @load_sql = IF(
|
||||
@load_exists = 0,
|
||||
'ALTER TABLE pl_plan_load ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE load_stmt FROM @load_sql;
|
||||
EXECUTE load_stmt;
|
||||
DEALLOCATE PREPARE load_stmt;
|
||||
|
||||
SET @delivery_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_delivery' AND COLUMN_NAME = 'same_city'
|
||||
);
|
||||
SET @delivery_sql = IF(
|
||||
@delivery_exists = 0,
|
||||
'ALTER TABLE pl_plan_delivery ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE delivery_stmt FROM @delivery_sql;
|
||||
EXECUTE delivery_stmt;
|
||||
DEALLOCATE PREPARE delivery_stmt;
|
||||
@@ -26711,6 +26711,7 @@ CREATE TABLE `pl_plan_delivery` (
|
||||
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
||||
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
||||
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
|
||||
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
@@ -26794,6 +26795,7 @@ CREATE TABLE `pl_plan_load` (
|
||||
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
||||
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
||||
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
|
||||
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# 文档库多选批量设置分享 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 Web 文档库 `/#/doclib/enter` 支持多选文档后一次设置分享人员(整份覆盖、交集预选、整批事务)。
|
||||
|
||||
**Architecture:** 后端新增 `POST /web/doclib/files/batch-shares`,从现有 `set_shares` 抽出无 commit 的 `_apply_shares`,批量一次 commit;前端放宽 `actionsFor.share`、扩展 `ShareDialog` 为多文档并用 `shareUserIntersection` 预选,统一调用批量 API。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / TypeScript / Element Plus;`node:test` + `tsx`(web-admin)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md`(工单 #25)
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `backend/app/schemas/doclib.py` | 新增 `BatchShareBody` |
|
||||
| `backend/app/services/doclib_service.py` | `_apply_shares`、`set_shares` 复用、`set_shares_batch` |
|
||||
| `backend/app/api/doclib.py` | 注册 `POST .../batch-shares` |
|
||||
| `web-admin/src/api/doclib.ts` | `saveDoclibBatchShares` |
|
||||
| `web-admin/src/views/doclib/enter/model.ts` | `actionsFor.share` 多选 + `shareUserIntersection` |
|
||||
| `web-admin/src/views/doclib/enter/model.test.ts` | 前端纯函数回归 |
|
||||
| `web-admin/src/views/doclib/enter/api.test.ts` | 批量 API 适配器断言 |
|
||||
| `web-admin/src/views/doclib/enter/ShareDialog.vue` | 多文档摘要、交集预选、批量保存 |
|
||||
| `web-admin/src/views/doclib/enter/index.vue` | 工具栏传入全部 `selected` |
|
||||
|
||||
说明:仓库内暂无 backend doclib pytest;本计划以服务层行为对照现有 `set_shares`/`delete_files` 实现,并用前端单测锁定契约。若执行时已有可用测试 DB,可追加服务层用例,但不阻塞前端交付。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 前端纯函数 — 多选可分享 + 分享人交集
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/doclib/enter/model.ts`
|
||||
- Modify: `web-admin/src/views/doclib/enter/model.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(更新 actionsFor + 新增交集)**
|
||||
|
||||
在 `model.test.ts` 将首个测试改为多选可分享语义,并新增交集测试:
|
||||
|
||||
```ts
|
||||
test('可管理文档可多选分享,混合选择不能分享', () => {
|
||||
assert.deepEqual(actionsFor([{ canManage: false }]), {
|
||||
share: false,
|
||||
download: true,
|
||||
delete: false
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: false }]), {
|
||||
share: false,
|
||||
download: true,
|
||||
delete: false
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }]), {
|
||||
share: true,
|
||||
download: true,
|
||||
delete: true
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: true }]), {
|
||||
share: true,
|
||||
download: true,
|
||||
delete: true
|
||||
})
|
||||
assert.deepEqual(actionsFor([]), { share: false, download: false, delete: false })
|
||||
})
|
||||
|
||||
test('分享人初始勾选取所选文档交集', async () => {
|
||||
const { shareUserIntersection } = await import('./model')
|
||||
const docs = [
|
||||
{ shares: [{ id: 'a' }, { id: 'b' }] },
|
||||
{ shares: [{ id: 'b' }, { id: 'c' }] }
|
||||
]
|
||||
assert.deepEqual(shareUserIntersection(docs), ['b'])
|
||||
assert.deepEqual(shareUserIntersection([{ shares: [{ id: 'a' }] }, { shares: [] }]), [])
|
||||
assert.deepEqual(shareUserIntersection([]), [])
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run(在 `web-admin/`,使用本仓 `node_modules`):
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL(`share` 仍要求 `length === 1`,且无 `shareUserIntersection`)
|
||||
|
||||
- [ ] **Step 3: 最小实现**
|
||||
|
||||
`model.ts`:
|
||||
|
||||
```ts
|
||||
export function actionsFor(rows: Array<{ canManage: boolean }>) {
|
||||
return {
|
||||
share: rows.length > 0 && rows.every((row) => row.canManage),
|
||||
download: rows.length > 0,
|
||||
delete: rows.length > 0 && rows.every((row) => row.canManage)
|
||||
}
|
||||
}
|
||||
|
||||
export function shareUserIntersection(
|
||||
documents: Array<{ shares?: Array<{ id: string }> | null }>
|
||||
): string[] {
|
||||
if (!documents.length) return []
|
||||
let ids = new Set(
|
||||
(documents[0].shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
|
||||
)
|
||||
for (const doc of documents.slice(1)) {
|
||||
const next = new Set(
|
||||
(doc.shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
|
||||
)
|
||||
ids = new Set([...ids].filter((id) => next.has(id)))
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/doclib/enter/model.ts web-admin/src/views/doclib/enter/model.test.ts
|
||||
git commit -m "feat(doclib): 允许多选可管理文档分享并计算分享人交集。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 前端 API — batch-shares 适配器
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/doclib.ts`
|
||||
- Modify: `web-admin/src/views/doclib/enter/api.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
在 `api.test.ts` 追加:
|
||||
|
||||
```ts
|
||||
test('批量分享提交文档 id 列表与人员 id', async () => {
|
||||
const { api, calls } = apiAdapter()
|
||||
await api.saveDoclibBatchShares(['id/a', 'id/b'], ['u1', 'u2'])
|
||||
assert.equal(calls[0].method, 'post')
|
||||
assert.equal(calls[0].config.url, '/web/doclib/files/batch-shares')
|
||||
assert.deepEqual(calls[0].config.data, { ids: ['id/a', 'id/b'], userIds: ['u1', 'u2'] })
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL(无 `saveDoclibBatchShares`)
|
||||
|
||||
- [ ] **Step 3: 最小实现**
|
||||
|
||||
在 `doclib.ts` 的 `saveDoclibShares` 旁增加:
|
||||
|
||||
```ts
|
||||
export const saveDoclibBatchShares = (ids: string[], userIds: string[]) =>
|
||||
request.post<DoclibDocument[]>({
|
||||
url: `${base}/files/batch-shares`,
|
||||
data: { ids, userIds }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/doclib.ts web-admin/src/views/doclib/enter/api.test.ts
|
||||
git commit -m "feat(doclib): 增加批量设置分享 API 客户端。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 — BatchShareBody + set_shares_batch
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/doclib.py`
|
||||
- Modify: `backend/app/services/doclib_service.py`
|
||||
- Modify: `backend/app/api/doclib.py`
|
||||
|
||||
- [ ] **Step 1: 增加 schema**
|
||||
|
||||
在 `ShareBody` 后增加:
|
||||
|
||||
```python
|
||||
class BatchShareBody(_Body):
|
||||
ids: List[str] = Field(min_length=1, max_length=100)
|
||||
userIds: List[str] = Field(max_length=500)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重构服务层**
|
||||
|
||||
将 `set_shares` 拆为 `_apply_shares`(不 commit)与包装方法;新增 `set_shares_batch`:
|
||||
|
||||
```python
|
||||
def _apply_shares(self, file_id, user_ids):
|
||||
row = self._file(file_id, manage=True, lock=True)
|
||||
requested = set(user_ids) - {row.create_by}
|
||||
if requested:
|
||||
valid = {u.id for u in self._base(SysUser).filter(
|
||||
SysUser.id.in_(requested), SysUser.status == '1').all()}
|
||||
if requested != valid:
|
||||
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
||||
else:
|
||||
valid = set()
|
||||
history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
|
||||
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id
|
||||
).populate_existing().with_for_update().all()}
|
||||
for uid, share in history.items():
|
||||
share.del_flag = 0 if uid in requested else 1
|
||||
share.update_by = self.uid
|
||||
for uid in requested - set(history):
|
||||
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
|
||||
row.update_by, row.update_time = self.uid, datetime.now()
|
||||
return row
|
||||
|
||||
def set_shares(self, file_id, user_ids):
|
||||
row = self._apply_shares(file_id, user_ids)
|
||||
self.db.commit()
|
||||
return self._serialize_files([row])[0]
|
||||
|
||||
def set_shares_batch(self, ids, user_ids):
|
||||
file_ids = sorted({str(i).strip() for i in ids if str(i).strip()})
|
||||
if not file_ids:
|
||||
raise HTTPException(400, '请选择要设置分享的文档')
|
||||
rows = [self._apply_shares(fid, user_ids) for fid in file_ids]
|
||||
self.db.commit()
|
||||
return self._serialize_files(rows)
|
||||
```
|
||||
|
||||
注意:空 `requested` 时跳过用户表查询(避免 `IN ()`),与「清空分享」语义一致。原 `set_shares` 在 `requested` 为空时 `SysUser.id.in_(set())` 行为依赖方言,重构时一并修掉。
|
||||
|
||||
- [ ] **Step 3: 注册路由**
|
||||
|
||||
`doclib.py` 增加 import `BatchShareBody`,并在 `batch-delete` 旁:
|
||||
|
||||
```python
|
||||
@router.post('/web/doclib/files/batch-shares')
|
||||
def batch_share_documents(body: BatchShareBody, svc: DoclibService = Depends(service)):
|
||||
return success_response(svc.set_shares_batch(body.ids, body.userIds))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 静态核对**
|
||||
|
||||
确认:
|
||||
- 单文档 `PUT .../shares` 仍指向 `set_shares`
|
||||
- 批量路径仅一次 `commit`
|
||||
- `_apply_shares` 任一步抛错时不调用 `commit`
|
||||
|
||||
可选:在已激活 venv 下对改动文件做语法检查
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -c "from app.schemas.doclib import BatchShareBody; from app.services.doclib_service import DoclibService; print('ok')"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/doclib.py backend/app/services/doclib_service.py backend/app/api/doclib.py
|
||||
git commit -m "feat(doclib): 增加批量设置分享接口并复用单文档写入逻辑。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ShareDialog + 列表页接入
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/doclib/enter/ShareDialog.vue`
|
||||
- Modify: `web-admin/src/views/doclib/enter/index.vue`
|
||||
|
||||
- [ ] **Step 1: 改 ShareDialog props 与保存**
|
||||
|
||||
- Props:`documents: DoclibDocument[]`(由页面传入;可去掉单 `document` 或保留 computed 取 `documents[0]` 兼容内部逻辑)。
|
||||
- 打开时:`userIds = shareUserIntersection(documents)`;摘要展示名称列表(超过 5 个截断并显示「等 N 个」)。
|
||||
- `options` 排除所有 `documents` 的 `ownerId`。
|
||||
- `save`:若无文档或存在 `!canManage` 则 return;调用 `saveDoclibBatchShares(ids, userIds)`;`emit('saved', updated)`(`updated` 为数组时由父组件刷新)。
|
||||
|
||||
示意:
|
||||
|
||||
```ts
|
||||
const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>()
|
||||
// watch open:
|
||||
userIds.value = shareUserIntersection(props.documents)
|
||||
// save:
|
||||
const ids = props.documents.map((d) => d.id)
|
||||
const updated = await saveDoclibBatchShares(ids, userIds.value)
|
||||
emit('saved', updated)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 index.vue**
|
||||
|
||||
- `shareDocuments` 替代 `shareDocument`(`ref<DoclibDocument[]>`)。
|
||||
- `openShare(rows: DoclibDocument | DoclibDocument[])` 规范化为数组。
|
||||
- 工具栏:`@click="openShare(selected)"`。
|
||||
- 详情:`@click="openShare(detail)"`。
|
||||
- `<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />`
|
||||
- `shareSaved`:若返回数组则按 id 更新详情;始终 `refresh()`。
|
||||
|
||||
- [ ] **Step 3: 跑前端相关测试**
|
||||
|
||||
```bash
|
||||
cd web-admin
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/doclib/enter/ShareDialog.vue web-admin/src/views/doclib/enter/index.vue
|
||||
git commit -m "feat(doclib): 文档库支持多选文档后批量设置分享。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 回归核对与交付准备
|
||||
|
||||
- [ ] **Step 1: 再跑前端测试套件**
|
||||
|
||||
```bash
|
||||
cd web-admin && ./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 对照验收清单(手工或浏览器)**
|
||||
|
||||
1. 多选可管理 → 可开分享;混入不可管理 → 按钮禁用
|
||||
2. 初始勾选为交集
|
||||
3. 保存后各文档分享人一致
|
||||
4. 详情单文档分享仍可用
|
||||
|
||||
- [ ] **Step 3: 更新工单 #25 评论**:分支、提交、测试命令与结果、未跑项(真机)
|
||||
|
||||
- [ ] **Step 4: 按用户授权 push 任务分支**(`push_task_branch` 已开;`create_pr` 未开则不建 PR)
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage self-check
|
||||
|
||||
| Spec 要求 | Task |
|
||||
| --- | --- |
|
||||
| `actionsFor` 多选可管理可分享 | Task 1 |
|
||||
| 交集预选 | Task 1 + 4 |
|
||||
| `batch-shares` API + 事务 | Task 3 |
|
||||
| `_apply_shares` 复用单路径 | Task 3 |
|
||||
| 前端批量客户端 | Task 2 |
|
||||
| ShareDialog / 工具栏 | Task 4 |
|
||||
| 单测 | Task 1–2、5 |
|
||||
| 整份覆盖 / 空列表收回 | Task 3–4(沿用 `_apply_shares`) |
|
||||
| 非目标:小程序 / 只追加 / 部分成功 | 无任务(刻意不做) |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
无 TBD /「类似 Task N」/ 空测试步骤。
|
||||
@@ -0,0 +1,115 @@
|
||||
# 文档库多选批量设置分享(工单 #25)
|
||||
|
||||
工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/25
|
||||
|
||||
## 背景与问题
|
||||
|
||||
Web 文档库管理(`/#/doclib/enter`)工具栏「设置分享」当前仅在恰好选中 1 份且可管理的文档时可用(`actionsFor.share`),弹窗与接口均为单文档。用户需要多选文档后统一设置分享人员。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| 保存语义 | 整份覆盖:弹窗所选 `userIds` 写入每一份所选文档,替换原分享人;空列表表示全部收回 |
|
||||
| 弹窗初始勾选 | 所选文档现有分享人的交集;无共同人员则为空 |
|
||||
| 修改端 | Web 管理端 + 后端批量 API |
|
||||
| 失败策略 | 整批事务:全部成功或全部回滚 |
|
||||
| 实现方案 | 新增 `batch-shares` + 扩展现有 `ShareDialog`(方案 A) |
|
||||
|
||||
## 范围
|
||||
|
||||
**包含**
|
||||
|
||||
- `web-admin`:`doclib/enter` 列表工具栏、`ShareDialog`、`actionsFor`、API 封装与单测
|
||||
- `backend`:批量分享路由、schema、服务层事务与单文件写入逻辑复用
|
||||
|
||||
**不包含**
|
||||
|
||||
- 小程序 / 移动 H5
|
||||
- 「只追加不收回」、部分成功返回清单
|
||||
- 文件夹级分享
|
||||
|
||||
## 后端设计
|
||||
|
||||
### 接口
|
||||
|
||||
`POST /web/doclib/files/batch-shares`
|
||||
|
||||
请求体(`extra=forbid`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["file-id-1", "file-id-2"],
|
||||
"userIds": ["user-id-a", "user-id-b"]
|
||||
}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `ids`:`min_length=1`,`max_length=100`(对齐 `DeleteFilesBody`)
|
||||
- `userIds`:`max_length=500`(对齐 `ShareBody`)
|
||||
- 去重后的 `ids` 均须当前用户可管理;任一份不可管理则 4xx,整批不写
|
||||
- 人员须同租户且启用;自动排除各文档上传人(与现 `set_shares` 一致:`requested - {create_by}`)
|
||||
- 非法 / 停用 / 跨租户人员 → 400,整批回滚
|
||||
|
||||
响应:成功后返回更新后的文档列表(字段与现有单文档分享成功返回一致的序列化)。
|
||||
|
||||
### 事务与复用
|
||||
|
||||
- 现有 `DoclibService.set_shares` 在单文件路径末尾 `commit`。批量路径须在**全部文件写入完成前不 commit**。
|
||||
- 抽取内部方法(名称示例:`_apply_shares(file_id, user_ids)`)完成单文件差量更新(含 lock);`set_shares` = `_apply_shares` + `commit` + 返回序列化;`set_shares_batch` = 校验全部可管理 → 校验人员 → 对每个 id 调用 `_apply_shares` → 一次 `commit` → 返回列表。
|
||||
- 任一步抛错依赖会话回滚,不留下部分文档已改的状态。
|
||||
|
||||
路由注册位置:与现有 `PUT /web/doclib/files/{file_id}/shares`、`POST .../batch-delete` 同模块(`backend/app/api/doclib.py`)。
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 工具栏与权限
|
||||
|
||||
- `actionsFor.share`:`rows.length > 0 && rows.every((row) => row.canManage)`(与删除多选门槛一致,取消「仅 1 条」限制)。
|
||||
- 工具栏「设置分享」对 `selected` 全量打开弹窗,不再只取 `selected[0]`。
|
||||
- 详情抽屉单文档「设置分享」仍传入单元素数组或单文档,行为不变。
|
||||
|
||||
### ShareDialog
|
||||
|
||||
- Props 改为支持多文档(例如 `documents: DoclibDocument[]`);打开时:
|
||||
- 摘要列出所选文档名称(数量较多时可截断并注明总数)
|
||||
- `userIds` 初始 = 分享人 id **交集**
|
||||
- 候选人员:交集中的人员 ∪ 远程搜索结果;排除各文档上传人(多文档时排除所有相关 `ownerId`)
|
||||
- 保存:统一调用批量接口 `saveDoclibBatchShares(ids, userIds)`(单文档也走批量,少分支);成功提示后关闭并刷新列表 / 更新详情。
|
||||
|
||||
### 纯函数(便于单测)
|
||||
|
||||
- `shareUserIntersection(documents): string[]` — 交集;空文档列表返回 `[]`
|
||||
- 可选:`canShareSelection(rows)` 与 `actionsFor` 共用语义
|
||||
|
||||
### API
|
||||
|
||||
在 `web-admin/src/api/doclib.ts` 增加:
|
||||
|
||||
`saveDoclibBatchShares(ids: string[], userIds: string[])` → `POST .../files/batch-shares`
|
||||
|
||||
单文档 `saveDoclibShares` 可保留供兼容/测试,页面路径以批量为准。
|
||||
|
||||
## UI 规范
|
||||
|
||||
- 沿用现有 `doclib-dialog dialog-form-layout`、Fresh Mint、P12 类弹窗;不新造布局与主色。
|
||||
- 按钮图标规则与现页一致(Close / Check 等已有用法)。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 多选可管理文档后可打开「设置分享」;含不可管理项时按钮禁用。
|
||||
2. 打开时勾选为交集;无共同分享人时为空。
|
||||
3. 保存后所选文档分享人员均等于弹窗所选集合(含清空)。
|
||||
4. 故意混入无权限文档(若后端被直接调用)→ 整批不改。
|
||||
5. 详情页单文档分享仍可用。
|
||||
6. 单测覆盖交集函数与 `actionsFor.share`;后端/服务层覆盖批量成功与权限失败回滚(按项目既有测试方式)。
|
||||
|
||||
## 风险
|
||||
|
||||
- `set_shares` 重构时须保证单文档路径回归(commit 时机、上传人排除、软删恢复历史 share 行)。
|
||||
- 批量上限 100 与删除一致;超大选区依赖现有表格多选,不另做分片 UI。
|
||||
|
||||
## 状态
|
||||
|
||||
设计已获用户确认(会话中「按照推荐」方案 A,「OK,请继续」批准落盘)。待用户审阅本文件后进入实现计划。
|
||||
@@ -3,6 +3,9 @@ import test from 'node:test'
|
||||
import {
|
||||
buyPartyName,
|
||||
certFaceFields,
|
||||
certKindLabel,
|
||||
certKindTagType,
|
||||
compareCertUploadTime,
|
||||
expiryWarning,
|
||||
salePartyName
|
||||
} from '../src/views/pl/quota-cert/cert-list-face'
|
||||
@@ -49,3 +52,19 @@ test('only certificates expiring within seven days show a red warning label', ()
|
||||
assert.equal(expiryWarning('2026-11-12', today), null)
|
||||
assert.equal(expiryWarning('', today), null)
|
||||
})
|
||||
|
||||
test('merged certificate lists follow upload time, then id, descending', () => {
|
||||
assert.equal(certKindLabel('transport'), '运输证')
|
||||
assert.equal(certKindLabel('purchase'), '购买证')
|
||||
assert.equal(certKindTagType('transport'), 'warning')
|
||||
assert.equal(certKindTagType('purchase'), 'success')
|
||||
const rows = [
|
||||
{ id: 'b', createTime: '2026-11-01 10:00:00' },
|
||||
{ id: 'a', createTime: '2026-11-05 10:00:00' },
|
||||
{ id: 'c', createTime: '2026-11-05 10:00:00' }
|
||||
].sort(compareCertUploadTime)
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.id),
|
||||
['c', 'a', 'b']
|
||||
)
|
||||
})
|
||||
@@ -67,6 +67,11 @@ export const saveDoclibShares = (id: string, userIds: string[]) =>
|
||||
url: `${base}/files/${encodeURIComponent(id)}/shares`,
|
||||
data: { userIds }
|
||||
})
|
||||
export const saveDoclibBatchShares = (ids: string[], userIds: string[]) =>
|
||||
request.post<DoclibDocument[]>({
|
||||
url: `${base}/files/batch-shares`,
|
||||
data: { ids, userIds }
|
||||
})
|
||||
export const deleteDoclibFiles = (ids: string[]) =>
|
||||
request.post({ url: `${base}/files/batch-delete`, data: { ids } })
|
||||
export const downloadDoclibFile = (id: string) =>
|
||||
|
||||
@@ -480,6 +480,8 @@ export interface PlanLoad {
|
||||
notes?: string
|
||||
generateDibang?: number
|
||||
dibangDirection?: string
|
||||
/** 1 时本段易制毒只校验购买证 */
|
||||
sameCity?: number
|
||||
remark?: string
|
||||
products?: PlanStopProduct[]
|
||||
contracts?: PlanContractLink[]
|
||||
@@ -502,6 +504,8 @@ export interface PlanDelivery {
|
||||
notes?: string
|
||||
generateDibang?: number
|
||||
dibangDirection?: string
|
||||
/** 1 时本段易制毒只校验购买证 */
|
||||
sameCity?: number
|
||||
remark?: string
|
||||
deliveryMethod?: string
|
||||
products?: PlanStopProduct[]
|
||||
|
||||
@@ -285,6 +285,8 @@ export interface QuotaSourceOption {
|
||||
contractId: string
|
||||
contractProductId: string
|
||||
partyName?: string
|
||||
/** 本段勾选同市时只要求购买证 */
|
||||
sameCity?: boolean
|
||||
}
|
||||
export interface QuotaSourcePair {
|
||||
sourceStopType: 'load' | 'delivery'
|
||||
|
||||
@@ -162,6 +162,15 @@
|
||||
@update:model-value="patchDelivery(index, { notes: $event })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否同市" class="form-col-span-2">
|
||||
<el-checkbox
|
||||
:model-value="delivery.sameCity === 1"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(checked) => patchDelivery(index, { sameCity: checked ? 1 : 0 })"
|
||||
>
|
||||
同市只需购买证
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="地磅单" class="form-col-span-2">
|
||||
<div class="pl-plan-dibang-row">
|
||||
<el-checkbox
|
||||
|
||||
@@ -714,6 +714,7 @@
|
||||
mergePlanProductsFromLoadMirror,
|
||||
shouldRetainPlanProductsWhileLoadEmpty,
|
||||
resolveStopDibangDefaults,
|
||||
toxicStopCertBindingMessage,
|
||||
sumStopProductsPlanQty,
|
||||
type PlanDeliveryForm,
|
||||
type PlanLoadForm,
|
||||
@@ -2619,19 +2620,7 @@
|
||||
}
|
||||
|
||||
function toxicContractCertBindingMessage(rows: QuotaSourceOption[]) {
|
||||
const missing = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
if (isPlInternalCustomerName(row.partyName)) continue
|
||||
const certificates = row.certificates || []
|
||||
const ready =
|
||||
certificates.some((cert) => cert.certType === 'purchase') &&
|
||||
certificates.some((cert) => cert.certType === 'transport')
|
||||
if (ready) continue
|
||||
const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId
|
||||
missing.set(key, row.contractNo || '未编号合同')
|
||||
}
|
||||
if (!missing.size) return ''
|
||||
return `合同${[...missing.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
|
||||
return toxicStopCertBindingMessage(rows)
|
||||
}
|
||||
|
||||
async function performArrangeAfterSave(
|
||||
|
||||
@@ -110,6 +110,15 @@
|
||||
@update:model-value="patch({ notes: $event })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否同市" class="form-col-span-2">
|
||||
<el-checkbox
|
||||
:model-value="modelValue.sameCity === 1"
|
||||
:disabled="readonly"
|
||||
@update:model-value="patch({ sameCity: $event ? 1 : 0 })"
|
||||
>
|
||||
同市只需购买证
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="地磅单" class="form-col-span-2">
|
||||
<div class="pl-plan-dibang-row">
|
||||
<el-checkbox
|
||||
|
||||
@@ -78,7 +78,15 @@
|
||||
<section v-if="faceTabs.length" class="quota-face">
|
||||
<h3 class="dialog-section-title">关联证件证面</h3>
|
||||
<el-tabs v-model="faceTab" class="quota-face-tabs">
|
||||
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :label="tab.label" :name="tab.key">
|
||||
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :name="tab.key">
|
||||
<template #label>
|
||||
<span class="quota-kind-line">
|
||||
<el-tag size="small" :type="certKindTagType(tab.kind)">{{
|
||||
certKindLabel(tab.kind)
|
||||
}}</el-tag>
|
||||
<span>{{ tab.certNo }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<p v-if="!tab.cert" class="quota-note">没有读到这张证的证面档案。</p>
|
||||
<template v-else>
|
||||
<section
|
||||
@@ -209,12 +217,9 @@
|
||||
<div class="conversion-grid">
|
||||
<section v-for="row in group.rows" :key="conversionKey(row)" class="conversion-card">
|
||||
<div class="conversion-card-head">
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="row.cert.certType === 'purchase' ? 'success' : 'primary'"
|
||||
>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }}</el-tag
|
||||
>
|
||||
<el-tag size="small" :type="certKindTagType(row.cert.certType)">{{
|
||||
certKindLabel(row.cert.certType)
|
||||
}}</el-tag>
|
||||
<strong>{{ row.cert.certNo }}</strong>
|
||||
</div>
|
||||
<label>换算系数</label>
|
||||
@@ -286,7 +291,12 @@
|
||||
>
|
||||
</div>
|
||||
<div v-for="item in selected" :key="item.kind + item.cert.id" class="view-section">
|
||||
<h4>{{ item.kind === 'purchase' ? '购买证' : '运输证' }} · {{ item.cert.certNo }}</h4>
|
||||
<h4 class="quota-kind-line">
|
||||
<el-tag size="small" :type="certKindTagType(item.kind)">{{
|
||||
certKindLabel(item.kind)
|
||||
}}</el-tag>
|
||||
<span>{{ item.cert.certNo }}</span>
|
||||
</h4>
|
||||
<p
|
||||
>{{ item.cert.buyerName || item.cert.shipperName }} →
|
||||
{{ item.cert.sellerName || item.cert.consigneeName }};有效期
|
||||
@@ -393,10 +403,12 @@
|
||||
:key="row.certType + row.unit"
|
||||
class="view-section"
|
||||
>
|
||||
<strong
|
||||
>{{ row.certType === 'purchase' ? '购买证' : '运输证' }} · 需求
|
||||
{{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong
|
||||
>
|
||||
<div class="quota-kind-line">
|
||||
<el-tag size="small" :type="certKindTagType(row.certType)">{{
|
||||
certKindLabel(row.certType)
|
||||
}}</el-tag>
|
||||
<strong>需求 {{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong>
|
||||
</div>
|
||||
<p v-if="row.availableUpperBound !== null">
|
||||
适用余额至多 {{ qtyText(row.availableUpperBound) }}
|
||||
{{ unitText(row.unit) }};缺口至少 {{ qtyText(row.gapLowerBound) }}
|
||||
@@ -408,7 +420,13 @@
|
||||
}}</p>
|
||||
</div>
|
||||
<el-table :data="allocations" stripe
|
||||
><el-table-column prop="kind" label="证种" min-width="90" /><el-table-column
|
||||
><el-table-column label="证种" min-width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag size="small" :type="row.kind === '运输证' ? 'warning' : 'success'">{{
|
||||
row.kind
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
><el-table-column
|
||||
prop="certNo"
|
||||
label="证号"
|
||||
min-width="160" /><el-table-column
|
||||
@@ -493,6 +511,7 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
@@ -631,7 +650,7 @@
|
||||
kind,
|
||||
cert,
|
||||
certNo: number,
|
||||
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
|
||||
label: `${certKindLabel(kind)} ${number}`
|
||||
})
|
||||
}
|
||||
for (const row of plan.value?.stopProducts || []) {
|
||||
@@ -1059,6 +1078,14 @@
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.quota-kind-line {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quota-dialog :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
canManualToggleStopDibang,
|
||||
resolveStopDibangDefaults
|
||||
resolveStopDibangDefaults,
|
||||
toxicStopCertBindingMessage
|
||||
} from './pl-plan-stop-form'
|
||||
import { isPlDibangOptionalVehiclePlate } from './pl-internal-customer'
|
||||
|
||||
@@ -23,6 +24,28 @@ describe('isPlDibangOptionalVehiclePlate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('toxicStopCertBindingMessage', () => {
|
||||
it('same-city stop only requires the purchase certificate', () => {
|
||||
assert.equal(
|
||||
toxicStopCertBindingMessage([
|
||||
{
|
||||
contractId: 'c1',
|
||||
contractNo: 'HT-1',
|
||||
sameCity: 1,
|
||||
certificates: [{ certType: 'purchase' }]
|
||||
}
|
||||
]),
|
||||
''
|
||||
)
|
||||
assert.equal(
|
||||
toxicStopCertBindingMessage([
|
||||
{ contractId: 'c1', contractNo: 'HT-1', sameCity: true, certificates: [] }
|
||||
]),
|
||||
'合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveStopDibangDefaults + canManualToggleStopDibang', () => {
|
||||
it('internal + normal vehicle: auto-check, not manual', () => {
|
||||
assert.deepEqual(resolveStopDibangDefaults(INTERNAL, 'load', '皖A11111'), {
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface PlanLoadForm {
|
||||
notes: string
|
||||
generateDibang: number
|
||||
dibangDirection: string
|
||||
/** 1 时本段易制毒只校验购买证 */
|
||||
sameCity: number
|
||||
contracts: PlanContractLink[]
|
||||
products: PlanStopProductFormRow[]
|
||||
}
|
||||
@@ -119,6 +121,8 @@ export interface PlanDeliveryForm {
|
||||
notes: string
|
||||
generateDibang: number
|
||||
dibangDirection: string
|
||||
/** 1 时本段易制毒只校验购买证 */
|
||||
sameCity: number
|
||||
contracts: PlanContractLink[]
|
||||
products: PlanStopProductFormRow[]
|
||||
}
|
||||
@@ -146,6 +150,49 @@ export function resolveStopDibangDefaults(
|
||||
return { generateDibang: 1, dibangDirection: 'inbound' }
|
||||
}
|
||||
|
||||
/** 安排前检查易制毒段的合同证件。同市段只要求购买证。 */
|
||||
export function toxicStopCertBindingMessage(
|
||||
rows: Array<{
|
||||
partyName?: string | null
|
||||
sameCity?: boolean | number | null
|
||||
certificates?: { certType?: string | null }[] | null
|
||||
contractId?: string | null
|
||||
contractNo?: string | null
|
||||
productName?: string | null
|
||||
sourceStopProductId?: string | null
|
||||
}>
|
||||
): string {
|
||||
const missingBoth = new Map<string, string>()
|
||||
const missingPurchase = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
if (isPlInternalCustomerName(row.partyName)) continue
|
||||
const certificates = row.certificates || []
|
||||
const hasPurchase = certificates.some((cert) => cert.certType === 'purchase')
|
||||
const hasTransport = certificates.some((cert) => cert.certType === 'transport')
|
||||
const sameCity = row.sameCity === true || Number(row.sameCity) === 1
|
||||
const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId || ''
|
||||
const label = row.contractNo || '未编号合同'
|
||||
if (sameCity) {
|
||||
if (!hasPurchase) missingPurchase.set(key, label)
|
||||
continue
|
||||
}
|
||||
if (hasPurchase && hasTransport) continue
|
||||
missingBoth.set(key, label)
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (missingBoth.size) {
|
||||
parts.push(
|
||||
`合同${[...missingBoth.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
|
||||
)
|
||||
}
|
||||
if (missingPurchase.size) {
|
||||
parts.push(
|
||||
`合同${[...missingPurchase.values()].join('、')}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输`
|
||||
)
|
||||
}
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
/** 仅「内部客户 + 地磅可选车」允许用户手动勾选地磅单 */
|
||||
export function canManualToggleStopDibang(
|
||||
customerName?: string | null,
|
||||
@@ -433,6 +480,7 @@ export function createEmptyLoadForm(): PlanLoadForm {
|
||||
notes: '',
|
||||
generateDibang: 0,
|
||||
dibangDirection: 'none',
|
||||
sameCity: 0,
|
||||
contracts: [],
|
||||
products: []
|
||||
}
|
||||
@@ -451,6 +499,7 @@ export function createEmptyDeliveryForm(seq = 1): PlanDeliveryForm {
|
||||
notes: '',
|
||||
generateDibang: 0,
|
||||
dibangDirection: 'none',
|
||||
sameCity: 0,
|
||||
contracts: [],
|
||||
products: []
|
||||
}
|
||||
@@ -540,6 +589,7 @@ export function mapLoadFromApi(
|
||||
notes: load.notes || '',
|
||||
generateDibang: dibang.generateDibang,
|
||||
dibangDirection: dibang.dibangDirection,
|
||||
sameCity: Number(load.sameCity) === 1 ? 1 : 0,
|
||||
contracts: (load.contracts || []).map((item) => ({
|
||||
contractId: String(item.contractId || ''),
|
||||
contractNo: item.contractNo
|
||||
@@ -574,6 +624,7 @@ export function mapDeliveriesFromApi(
|
||||
notes: row.notes || '',
|
||||
generateDibang: dibang.generateDibang,
|
||||
dibangDirection: dibang.dibangDirection,
|
||||
sameCity: Number(row.sameCity) === 1 ? 1 : 0,
|
||||
contracts: (row.contracts || []).map((item) => ({
|
||||
contractId: String(item.contractId || ''),
|
||||
contractNo: item.contractNo
|
||||
@@ -617,6 +668,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
|
||||
notes: plan.pickupNotes || '',
|
||||
generateDibang: loadDefaults.generateDibang,
|
||||
dibangDirection: loadDefaults.dibangDirection,
|
||||
sameCity: 0,
|
||||
contracts: (plan.supplierContracts || []).map((item) => ({
|
||||
contractId: String(item.contractId || ''),
|
||||
contractNo: item.contractNo
|
||||
@@ -636,6 +688,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
|
||||
notes: plan.destinationNotes || '',
|
||||
generateDibang: deliveryDefaults.generateDibang,
|
||||
dibangDirection: deliveryDefaults.dibangDirection,
|
||||
sameCity: 0,
|
||||
contracts: (plan.demanderContracts || []).map((item) => ({
|
||||
contractId: String(item.contractId || ''),
|
||||
contractNo: item.contractNo
|
||||
@@ -900,6 +953,7 @@ export function buildLoadPayload(load: PlanLoadForm) {
|
||||
notes: load.notes || undefined,
|
||||
generateDibang: load.generateDibang,
|
||||
dibangDirection: load.dibangDirection,
|
||||
sameCity: load.sameCity === 1 ? 1 : 0,
|
||||
contracts: load.contracts,
|
||||
products: load.products.map(buildStopProductPayload)
|
||||
}
|
||||
@@ -919,6 +973,7 @@ export function buildDeliveriesPayload(deliveries: PlanDeliveryForm[]) {
|
||||
notes: row.notes || undefined,
|
||||
generateDibang: row.generateDibang,
|
||||
dibangDirection: row.dibangDirection,
|
||||
sameCity: row.sameCity === 1 ? 1 : 0,
|
||||
contracts: row.contracts,
|
||||
products: row.products.map(buildStopProductPayload)
|
||||
}))
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
:close-on-press-escape="!saving"
|
||||
:show-close="!saving"
|
||||
>
|
||||
<p class="doclib-summary">{{ document?.name }}</p>
|
||||
<p class="doclib-summary">{{ summaryText }}</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item>
|
||||
<template #label
|
||||
>分享给<span class="form-label-tip">可多选,取消选择即收回访问权限</span></template
|
||||
>分享给<span class="form-label-tip"
|
||||
>可多选,取消选择即收回访问权限;保存后对所选文档整份覆盖</span
|
||||
></template
|
||||
>
|
||||
<el-select
|
||||
v-model="userIds"
|
||||
@@ -56,15 +58,15 @@
|
||||
import { Close, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchDoclibUsers,
|
||||
saveDoclibShares,
|
||||
saveDoclibBatchShares,
|
||||
type DoclibDocument,
|
||||
type DoclibPerson
|
||||
} from '@/api/doclib'
|
||||
import { createLatestRequest } from './model'
|
||||
const props = defineProps<{ modelValue: boolean; document: DoclibDocument | null }>()
|
||||
import { createLatestRequest, shareUserIntersection } from './model'
|
||||
const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: [document: DoclibDocument]
|
||||
saved: [documents: DoclibDocument[]]
|
||||
}>()
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -77,17 +79,31 @@
|
||||
const userIds = ref<string[]>([])
|
||||
const people = ref<DoclibPerson[]>([])
|
||||
const requests = createLatestRequest()
|
||||
const ownerIds = computed(
|
||||
() => new Set(props.documents.map((doc) => doc.ownerId).filter(Boolean))
|
||||
)
|
||||
const summaryText = computed(() => {
|
||||
const names = props.documents.map((doc) => doc.name).filter(Boolean)
|
||||
if (!names.length) return '未选择文档'
|
||||
if (names.length === 1) return names[0]
|
||||
const shown = names.slice(0, 5)
|
||||
const more = names.length > 5 ? `等 ${names.length} 个` : ''
|
||||
return `已选 ${names.length} 个文档:${shown.join('、')}${more ? ` ${more}` : ''}`
|
||||
})
|
||||
const options = computed(() => {
|
||||
const candidates = new Map((props.document?.shares || []).map((person) => [person.id, person]))
|
||||
const candidates = new Map<string, DoclibPerson>()
|
||||
for (const doc of props.documents) {
|
||||
for (const person of doc.shares || []) candidates.set(person.id, person)
|
||||
}
|
||||
people.value.forEach((person) => candidates.set(person.id, person))
|
||||
return [...candidates.values()].filter((person) => person.id !== props.document?.ownerId)
|
||||
return [...candidates.values()].filter((person) => !ownerIds.value.has(person.id))
|
||||
})
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
requests.invalidate()
|
||||
if (!value) return
|
||||
userIds.value = (props.document?.shares || []).map((person) => person.id)
|
||||
userIds.value = shareUserIntersection(props.documents)
|
||||
people.value = []
|
||||
void searchUsers('')
|
||||
}
|
||||
@@ -109,12 +125,17 @@
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!props.document?.canManage || saving.value) return
|
||||
if (!props.documents.length || props.documents.some((doc) => !doc.canManage) || saving.value) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await saveDoclibShares(props.document.id, userIds.value)
|
||||
const updated = await saveDoclibBatchShares(
|
||||
props.documents.map((doc) => doc.id),
|
||||
userIds.value
|
||||
)
|
||||
ElMessage.success('分享设置已保存')
|
||||
emit('saved', updated)
|
||||
emit('saved', updated || [])
|
||||
emit('update:modelValue', false)
|
||||
} catch {
|
||||
/* 请求层统一展示错误 */
|
||||
|
||||
@@ -44,3 +44,11 @@ test('专用上传请求提交原文件及目标目录,清空分享提交空
|
||||
assert.equal(calls[1].method, 'put')
|
||||
assert.deepEqual(calls[1].config.data, { userIds: [] })
|
||||
})
|
||||
|
||||
test('批量分享提交文档 id 列表与人员 id', async () => {
|
||||
const { api, calls } = apiAdapter()
|
||||
await api.saveDoclibBatchShares(['id/a', 'id/b'], ['u1', 'u2'])
|
||||
assert.equal(calls[0].method, 'post')
|
||||
assert.equal(calls[0].config.url, '/web/doclib/files/batch-shares')
|
||||
assert.deepEqual(calls[0].config.data, { ids: ['id/a', 'id/b'], userIds: ['u1', 'u2'] })
|
||||
})
|
||||
@@ -95,6 +95,12 @@ await context.route('**/api/**', async (route) => {
|
||||
return ok({ list, total: list.length })
|
||||
}
|
||||
if (path === '/web/doclib/users') return ok([{ id: 'other', name: '同事', deptName: '生产部' }])
|
||||
if (path === '/web/doclib/files/batch-shares' && req.method() === 'POST') {
|
||||
const body = req.postDataJSON()
|
||||
sharedUsers = body.userIds
|
||||
file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' }))
|
||||
return ok([file])
|
||||
}
|
||||
if (path.endsWith('/shares')) {
|
||||
sharedUsers = req.postDataJSON().userIds
|
||||
file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' }))
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
>
|
||||
<el-button
|
||||
:disabled="!actions.share || loading || busy"
|
||||
@click="openShare(selected[0])"
|
||||
@click="openShare(selected)"
|
||||
><el-icon><Share /></el-icon>设置分享</el-button
|
||||
>
|
||||
<el-button
|
||||
@@ -195,7 +195,7 @@
|
||||
@download="download([$event])"
|
||||
@view="openDetail"
|
||||
/>
|
||||
<ShareDialog v-model="shareVisible" :document="shareDocument" @saved="shareSaved" />
|
||||
<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />
|
||||
<el-dialog
|
||||
v-model="folderVisible"
|
||||
:title="renameMode ? '重命名文件夹' : '新建文件夹'"
|
||||
@@ -489,14 +489,22 @@
|
||||
}
|
||||
}
|
||||
const shareVisible = ref(false)
|
||||
const shareDocument = ref<DoclibDocument | null>(null)
|
||||
function openShare(row: DoclibDocument) {
|
||||
if (!row?.canManage) return
|
||||
shareDocument.value = row
|
||||
const shareDocuments = ref<DoclibDocument[]>([])
|
||||
function openShare(rows: DoclibDocument | DoclibDocument[] | null | undefined) {
|
||||
const list = (Array.isArray(rows) ? rows : rows ? [rows] : []).filter(
|
||||
(row): row is DoclibDocument => !!row?.id && row.canManage
|
||||
)
|
||||
if (!list.length) return
|
||||
shareDocuments.value = list
|
||||
shareVisible.value = true
|
||||
}
|
||||
function shareSaved(row: DoclibDocument) {
|
||||
if (detail.value?.id === row.id) detail.value = row
|
||||
function shareSaved(rows: DoclibDocument[]) {
|
||||
const updated = Array.isArray(rows) ? rows : []
|
||||
const current = detail.value
|
||||
if (current) {
|
||||
const match = updated.find((row) => row.id === current.id)
|
||||
if (match) detail.value = match
|
||||
}
|
||||
void refresh()
|
||||
}
|
||||
const detailVisible = ref(false)
|
||||
|
||||
@@ -14,7 +14,7 @@ const node = (id: string, scope: 'mine' | 'shared', children: any[] = []) => ({
|
||||
children
|
||||
})
|
||||
|
||||
test('分享接收人只能下载,混合选择不能批量删除', () => {
|
||||
test('可管理文档可多选分享,混合选择不能分享', () => {
|
||||
assert.deepEqual(actionsFor([{ canManage: false }]), {
|
||||
share: false,
|
||||
download: true,
|
||||
@@ -25,10 +25,30 @@ test('分享接收人只能下载,混合选择不能批量删除', () => {
|
||||
download: true,
|
||||
delete: false
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }]), { share: true, download: true, delete: true })
|
||||
assert.deepEqual(actionsFor([{ canManage: true }]), {
|
||||
share: true,
|
||||
download: true,
|
||||
delete: true
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: true }]), {
|
||||
share: true,
|
||||
download: true,
|
||||
delete: true
|
||||
})
|
||||
assert.deepEqual(actionsFor([]), { share: false, download: false, delete: false })
|
||||
})
|
||||
|
||||
test('分享人初始勾选取所选文档交集', async () => {
|
||||
const { shareUserIntersection } = await import('./model')
|
||||
const docs = [
|
||||
{ shares: [{ id: 'a' }, { id: 'b' }] },
|
||||
{ shares: [{ id: 'b' }, { id: 'c' }] }
|
||||
]
|
||||
assert.deepEqual(shareUserIntersection(docs), ['b'])
|
||||
assert.deepEqual(shareUserIntersection([{ shares: [{ id: 'a' }] }, { shares: [] }]), [])
|
||||
assert.deepEqual(shareUserIntersection([]), [])
|
||||
})
|
||||
|
||||
test('同一个目录在不同范围有独立树节点,已删除目录不再命中', () => {
|
||||
const mine = node('root', 'mine', [node('child', 'mine')])
|
||||
const shared = node('root', 'shared')
|
||||
|
||||
@@ -2,12 +2,29 @@ import type { DoclibFolder } from '@/api/doclib'
|
||||
|
||||
export function actionsFor(rows: Array<{ canManage: boolean }>) {
|
||||
return {
|
||||
share: rows.length === 1 && rows[0].canManage,
|
||||
share: rows.length > 0 && rows.every((row) => row.canManage),
|
||||
download: rows.length > 0,
|
||||
delete: rows.length > 0 && rows.every((row) => row.canManage)
|
||||
}
|
||||
}
|
||||
|
||||
/** 多选打开分享弹窗时,初始勾选为各文档现有分享人的交集。 */
|
||||
export function shareUserIntersection(
|
||||
documents: Array<{ shares?: Array<{ id: string }> | null }>
|
||||
): string[] {
|
||||
if (!documents.length) return []
|
||||
let ids = new Set(
|
||||
(documents[0].shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
|
||||
)
|
||||
for (const doc of documents.slice(1)) {
|
||||
const next = new Set(
|
||||
(doc.shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
|
||||
)
|
||||
ids = new Set([...ids].filter((id) => next.has(id)))
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
export function findFolder(nodes: DoclibFolder[], id: string): DoclibFolder | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
<div class="mp-wv-card__no">{{ row.certNo }}</div>
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="row.certType === 'transport' ? 'status-tag--warning' : 'status-tag--primary'"
|
||||
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</span
|
||||
:class="`status-tag--${certKindMobileTone(row.certType)}`"
|
||||
>{{ certKindLabel(row.certType) }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="status-tag" :class="`status-tag--${statusTag(row.status)}`">{{
|
||||
@@ -109,7 +109,14 @@
|
||||
import CertConfirmSheet from './CertConfirmSheet.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
import { buyPartyName, expiryWarning, salePartyName } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import {
|
||||
buyPartyName,
|
||||
certKindLabel,
|
||||
certKindMobileTone,
|
||||
compareCertUploadTime,
|
||||
expiryWarning,
|
||||
salePartyName
|
||||
} from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { certStatusLabels } from './face-model'
|
||||
@@ -209,9 +216,11 @@
|
||||
const kinds: CertType[] = props.kind ? [props.kind as CertType] : ['purchase', 'transport']
|
||||
const lists = await Promise.all(kinds.map((item) => quotaCertApi.list(item, params)))
|
||||
if (current !== epoch) return
|
||||
rows.value = kinds.flatMap((item, index) =>
|
||||
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
|
||||
)
|
||||
rows.value = kinds
|
||||
.flatMap((item, index) =>
|
||||
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
|
||||
)
|
||||
.sort(compareCertUploadTime)
|
||||
} catch {
|
||||
if (current === epoch) error.value = '证件加载失败,请重试'
|
||||
} finally {
|
||||
|
||||
@@ -41,7 +41,14 @@
|
||||
计划数量 {{ qtyText(group.source.quantity) }} {{ unitText(group.source.unit) }}
|
||||
</p>
|
||||
<div v-for="row in group.rows" :key="conversionKey(row)" class="mp-cert-factor">
|
||||
<strong>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }} {{ row.cert.certNo }}</strong>
|
||||
<strong class="mp-cert-kind-heading">
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(row.cert.certType)}`"
|
||||
>{{ certKindLabel(row.cert.certType) }}</span
|
||||
>
|
||||
<span>{{ row.cert.certNo }}</span>
|
||||
</strong>
|
||||
<van-field
|
||||
v-model="coefficients[conversionKey(row)]"
|
||||
label="换算系数"
|
||||
@@ -91,7 +98,14 @@
|
||||
<section v-if="isPerStop && faceCards.length" class="mp-wv-section">
|
||||
<h3>关联证件证面</h3>
|
||||
<article v-for="face in faceCards" :id="faceDomId(face.key)" :key="face.key" class="mp-cert-face-card">
|
||||
<h4>{{ face.label }}</h4>
|
||||
<h4 class="mp-cert-kind-heading">
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(face.kind)}`"
|
||||
>{{ certKindLabel(face.kind) }}</span
|
||||
>
|
||||
<span>{{ face.certNo }}</span>
|
||||
</h4>
|
||||
<p v-if="!face.cert" class="plan-empty-hint">没有读到这张证的证面档案。</p>
|
||||
<template v-else>
|
||||
<div v-for="(group, groupIndex) in groupsOf(face.cert, face.kind)" :key="groupIndex">
|
||||
@@ -158,6 +172,7 @@
|
||||
sourceLabel,
|
||||
unitConversions
|
||||
} from '@/components/business/pl-plan-quota-dialog/stop-model'
|
||||
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
@@ -190,7 +205,13 @@
|
||||
const sourceOptions = computed(() => plan.value?.sourceOptions || [])
|
||||
const faceCards = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
const tabs: { key: string; label: string; kind: CertType; cert: QuotaCert | null }[] = []
|
||||
const tabs: {
|
||||
key: string
|
||||
label: string
|
||||
kind: CertType
|
||||
certNo: string
|
||||
cert: QuotaCert | null
|
||||
}[] = []
|
||||
const push = (kind: string, id: string, certNo: string) => {
|
||||
if ((kind !== 'purchase' && kind !== 'transport') || !id || seen.has(`${kind}:${id}`)) return
|
||||
seen.add(`${kind}:${id}`)
|
||||
@@ -200,7 +221,8 @@
|
||||
key: `${kind}:${id}`,
|
||||
kind,
|
||||
cert,
|
||||
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
|
||||
certNo: number,
|
||||
label: `${certKindLabel(kind)} ${number}`
|
||||
})
|
||||
}
|
||||
for (const row of plan.value?.stopProducts || []) {
|
||||
|
||||
@@ -60,7 +60,14 @@
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else class="mp-cert-link-note">{{ currentLabel }}</p>
|
||||
<p v-else class="mp-cert-kind-heading">
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(certType)}`"
|
||||
>{{ certKindLabel(certType) }}</span
|
||||
>
|
||||
<span v-if="certNo">{{ certNo }}</span>
|
||||
</p>
|
||||
|
||||
<template v-if="certId">
|
||||
<h3>适用产品</h3>
|
||||
@@ -139,6 +146,7 @@
|
||||
type QuotaCert,
|
||||
type ScopeProduct
|
||||
} from '@/api/pl/quota-cert'
|
||||
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { bindingPayload, replaceCertProducts } from '@/views/pl/quota-cert/model'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
@@ -178,9 +186,6 @@
|
||||
let epoch = 0
|
||||
let searchTimer = 0
|
||||
|
||||
const currentLabel = computed(() =>
|
||||
[certType.value === 'purchase' ? '购买证' : '运输证', certNo.value].filter(Boolean).join(' ')
|
||||
)
|
||||
const pickerRows = computed(() => {
|
||||
const used = new Set(draft.value.map((product) => product.contractProductId))
|
||||
return lines.value.filter((row) => row.id && !used.has(row.id))
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
@click="open(row)"
|
||||
>
|
||||
<div class="mp-wv-card__head">
|
||||
<div>
|
||||
<div class="mp-cert-card__main">
|
||||
<div class="mp-wv-card__no">{{ row.certNo }}</div>
|
||||
<div class="mp-wv-card__sub">{{ row.certType === 'transport' ? '运输证' : '购买证' }}</div>
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(row.certType)}`"
|
||||
>{{ certKindLabel(row.certType) }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="status-tag" :class="`status-tag--${row.status === 'active' ? 'success' : 'info'}`">
|
||||
{{ certListStatus(row.status) }}
|
||||
@@ -61,7 +65,11 @@
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { certListStatus } from './usage-model'
|
||||
import { expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import {
|
||||
certKindLabel,
|
||||
certKindMobileTone,
|
||||
expiryWarning
|
||||
} from '@/views/pl/quota-cert/cert-list-face'
|
||||
|
||||
const props = defineProps<{ keyword: string; kind: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -37,6 +37,24 @@
|
||||
min-height: calc(100dvh - 132px);
|
||||
}
|
||||
|
||||
.mp-cert-kind-tag {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mp-cert-kind-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mp-plan-cert-list .mp-wv-flat-card {
|
||||
.mp-cert-card__main {
|
||||
display: flex;
|
||||
@@ -46,10 +64,7 @@
|
||||
}
|
||||
|
||||
.mp-cert-kind-tag {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mp-wv-card__no {
|
||||
|
||||
@@ -48,7 +48,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mp-cert-tags">
|
||||
<span class="status-tag status-tag--info">{{ cert.certType === 'purchase' ? '购买证' : '运输证' }}</span>
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(cert.certType)}`"
|
||||
>{{ certKindLabel(cert.certType) }}</span
|
||||
>
|
||||
<span class="status-tag" :class="`status-tag--${cert.linkStatus === 'active' ? 'success' : 'info'}`">
|
||||
{{ linkLabel(cert.linkStatus) }}
|
||||
</span>
|
||||
@@ -145,7 +149,14 @@
|
||||
<div class="mp-wv-card__kv">
|
||||
<div class="mp-wv-card__kv-row"><span>规格</span><span>{{ row.model }}</span></div>
|
||||
<div class="mp-wv-card__kv-row"><span>产品单位</span><span>{{ unit(row.unit) || '—' }}</span></div>
|
||||
<div class="mp-wv-card__kv-row"><span>证件类型</span><span>{{ row.certKind }}</span></div>
|
||||
<div class="mp-wv-card__kv-row">
|
||||
<span>证件类型</span>
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(row.certType)}`"
|
||||
>{{ row.certKind }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mp-wv-card__kv-row"><span>证件编号</span><span>{{ row.certNo }}</span></div>
|
||||
<div class="mp-wv-card__kv-row"><span>证面单位</span><span>{{ unit(row.certUnit) || '—' }}</span></div>
|
||||
<div class="mp-wv-card__kv-row"><span>产品预占</span><span>{{ qty(row.reservedQty) }}</span></div>
|
||||
@@ -188,7 +199,11 @@
|
||||
type StatisticsProduct
|
||||
} from '@/api/pl/quota-cert'
|
||||
import { bindingPayload, formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
import { certFaceFields } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import {
|
||||
certFaceFields,
|
||||
certKindLabel,
|
||||
certKindMobileTone
|
||||
} from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
|
||||
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
|
||||
import CertLinkSheet from './CertLinkSheet.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContractStatistics } from '@/api/pl/quota-cert'
|
||||
import { certFaceFields, expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { certFaceFields, certKindLabel, expiryWarning } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { statusLabel } from '@/views/pl/quota-statistics/labels'
|
||||
|
||||
export function certDetailStatus(status?: string | null) {
|
||||
@@ -19,7 +19,8 @@ export function contractProductRows(detail: ContractStatistics) {
|
||||
productName: product.productName || '—',
|
||||
model: product.model || '—',
|
||||
unit: product.unit,
|
||||
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
|
||||
certType: cert.certType,
|
||||
certKind: certKindLabel(cert.certType),
|
||||
certNo: cert.certNo || '—',
|
||||
certUnit: cert.quantityUnit,
|
||||
reservedQty: product.reservedQty,
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
|
||||
<template v-else-if="detail">
|
||||
<section class="mp-wv-section">
|
||||
<h3>{{ detail.certType === 'transport' ? '运输证' : '购买证' }} {{ detail.certNo }}</h3>
|
||||
<h3 class="mp-cert-kind-heading">
|
||||
<span
|
||||
class="status-tag mp-cert-kind-tag"
|
||||
:class="`status-tag--${certKindMobileTone(detail.certType)}`"
|
||||
>{{ certKindLabel(detail.certType) }}</span
|
||||
>
|
||||
<span>{{ detail.certNo }}</span>
|
||||
</h3>
|
||||
<div class="detail-field-grid">
|
||||
<div class="mp-wv-kv"><span>证面品名</span><span>{{ detail.productNameOnCert || '—' }}</span></div>
|
||||
<div class="mp-wv-kv">
|
||||
@@ -101,6 +108,7 @@
|
||||
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
||||
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
||||
import { quotaStatisticsApi, type CertificateStatistics, type CertType } from '@/api/pl/quota-cert'
|
||||
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
|
||||
@@ -116,6 +116,13 @@
|
||||
:readonly="c.props.readonly"
|
||||
@update:model-value="c.patchDelivery(index, { notes: $event })"
|
||||
/>
|
||||
<van-cell class="mp-stop-toggle" title="是否同市" label="同市只需购买证"
|
||||
><template #right-icon
|
||||
><van-switch
|
||||
:model-value="delivery.sameCity === 1"
|
||||
:disabled="c.props.readonly"
|
||||
@update:model-value="c.patchDelivery(index, { sameCity: $event ? 1 : 0 })" /></template
|
||||
></van-cell>
|
||||
<van-cell
|
||||
class="mp-stop-toggle"
|
||||
title="生成地磅单"
|
||||
|
||||
@@ -61,6 +61,13 @@
|
||||
:readonly="c.props.readonly"
|
||||
@update:model-value="c.patch({ notes: $event })"
|
||||
/>
|
||||
<van-cell class="mp-stop-toggle" title="是否同市" label="同市只需购买证"
|
||||
><template #right-icon
|
||||
><van-switch
|
||||
:model-value="c.props.modelValue.sameCity === 1"
|
||||
:disabled="c.props.readonly"
|
||||
@update:model-value="c.patch({ sameCity: $event ? 1 : 0 })" /></template
|
||||
></van-cell>
|
||||
<van-cell class="mp-stop-toggle" title="生成地磅单" :label="c.dibangDirectionLabel"
|
||||
><template #right-icon
|
||||
><van-switch
|
||||
|
||||
@@ -34,6 +34,28 @@ export function certFaceFields(row: CertListFace) {
|
||||
]
|
||||
}
|
||||
|
||||
export function certKindLabel(certType?: string | null) {
|
||||
return certType === 'transport' ? '运输证' : '购买证'
|
||||
}
|
||||
|
||||
export function certKindTagType(certType?: string | null) {
|
||||
return certType === 'transport' ? 'warning' : 'success'
|
||||
}
|
||||
|
||||
export function certKindMobileTone(certType?: string | null) {
|
||||
return certType === 'transport' ? 'warning' : 'primary'
|
||||
}
|
||||
|
||||
export function compareCertUploadTime<T extends { createTime?: string | null; id?: string | null }>(
|
||||
a: T,
|
||||
b: T
|
||||
) {
|
||||
return (
|
||||
String(b.createTime || '').localeCompare(String(a.createTime || '')) ||
|
||||
String(b.id || '').localeCompare(String(a.id || ''))
|
||||
)
|
||||
}
|
||||
|
||||
export function expiryWarning(validTo?: string | null, today = new Date()) {
|
||||
const target = calendarDate(validTo)
|
||||
if (!target) return null
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
><el-tag
|
||||
v-else-if="column.prop === 'certType'"
|
||||
size="small"
|
||||
:type="row.certType === 'transport' ? 'warning' : 'success'"
|
||||
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</el-tag
|
||||
:type="certKindTagType(row.certType)"
|
||||
>{{ certKindLabel(row.certType) }}</el-tag
|
||||
><el-tag
|
||||
v-else-if="column.prop === 'status'"
|
||||
size="small"
|
||||
@@ -629,7 +629,14 @@
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import CertContractBinder from './CertContractBinder.vue'
|
||||
import { bindingsFromPurchaseCerts, matchPurchaseCert } from './contract-binder-rules'
|
||||
import { buyPartyName, expiryWarning, salePartyName } from './cert-list-face'
|
||||
import {
|
||||
buyPartyName,
|
||||
certKindLabel,
|
||||
certKindTagType,
|
||||
compareCertUploadTime,
|
||||
expiryWarning,
|
||||
salePartyName
|
||||
} from './cert-list-face'
|
||||
import ContractScopeDialog from './ContractScopeDialog.vue'
|
||||
import SystemViewDialog, {
|
||||
type ViewField,
|
||||
@@ -1089,11 +1096,7 @@
|
||||
rows.value = [
|
||||
...purchases.map((row) => presentCert(row, 'purchase')),
|
||||
...transports.map((row) => presentCert(row, 'transport'))
|
||||
].sort(
|
||||
(a, b) =>
|
||||
String(b.createTime || '').localeCompare(String(a.createTime || '')) ||
|
||||
String(b.id).localeCompare(String(a.id))
|
||||
)
|
||||
].sort(compareCertUploadTime)
|
||||
} finally {
|
||||
if (current === listEpoch && !disposed) loading.value = false
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p v-else class="quota-contract-cert-dialog__current">{{ currentCertLabel }}</p>
|
||||
<p v-else class="quota-contract-cert-dialog__current">
|
||||
<el-tag size="small" :type="certKindTagType(certType)">{{ certKindLabel(certType) }}</el-tag>
|
||||
<span v-if="certNo">{{ certNo }}</span>
|
||||
</p>
|
||||
<CertContractBinder
|
||||
v-if="ready && certId && contractId"
|
||||
:key="binderKey"
|
||||
@@ -68,6 +71,7 @@
|
||||
type ScopeProduct
|
||||
} from '@/api/pl/quota-cert'
|
||||
import CertContractBinder from '../quota-cert/CertContractBinder.vue'
|
||||
import { certKindLabel, certKindTagType } from '../quota-cert/cert-list-face'
|
||||
import { bindingPayload, replaceCertProducts } from '../quota-cert/model'
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
@@ -91,10 +95,6 @@
|
||||
() =>
|
||||
`${mode.value}|${certType.value}|${certId.value}|${savedProducts.value.map((row) => row.contractProductId).join(',')}`
|
||||
)
|
||||
const currentCertLabel = computed(() =>
|
||||
[certType.value === 'purchase' ? '购买证' : '运输证', certNo.value].filter(Boolean).join(' ')
|
||||
)
|
||||
|
||||
function certOptionLabel(item: QuotaCert) {
|
||||
const name = [item.certNo, item.productNameOnCert].filter(Boolean).join(' · ')
|
||||
if (item.status === 'void') return `${name}(已作废)`
|
||||
@@ -209,6 +209,10 @@
|
||||
|
||||
<style scoped>
|
||||
.quota-contract-cert-dialog__current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
v-for="cert in contract?.certificates || []"
|
||||
:key="certKey(cert)"
|
||||
:name="certKey(cert)"
|
||||
:label="certTabLabel(cert)"
|
||||
>
|
||||
<template #label>
|
||||
<span class="quota-cert-tab-label">
|
||||
<el-tag size="small" :type="certKindTagType(cert.certType)">{{
|
||||
certKindLabel(cert.certType)
|
||||
}}</el-tag>
|
||||
<span>{{ cert.certNo }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<section class="quota-cert-summary">
|
||||
<header class="quota-cert-summary__head">
|
||||
<strong>{{ cert.certNo }}</strong>
|
||||
@@ -16,8 +23,8 @@
|
||||
有效期:{{ cert.validFrom || '—' }} 至 {{ cert.validTo || '—' }}
|
||||
</span>
|
||||
<div class="quota-cert-summary__tags">
|
||||
<el-tag size="small">{{
|
||||
cert.certType === 'purchase' ? '购买证' : '运输证'
|
||||
<el-tag size="small" :type="certKindTagType(cert.certType)">{{
|
||||
certKindLabel(cert.certType)
|
||||
}}</el-tag>
|
||||
<el-tag size="small" :type="cert.linkStatus === 'active' ? 'success' : 'info'">{{
|
||||
linkLabel(cert.linkStatus)
|
||||
@@ -90,7 +97,13 @@
|
||||
<el-table-column prop="productName" label="产品" min-width="160" />
|
||||
<el-table-column prop="model" label="规格" min-width="120" />
|
||||
<el-table-column prop="unit" label="产品单位" min-width="100" />
|
||||
<el-table-column prop="certKind" label="证件类型" min-width="100" />
|
||||
<el-table-column label="证件类型" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="certKindTagType(row.certType)">{{
|
||||
row.certKind
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="certNo" label="证件编号" min-width="160" />
|
||||
<el-table-column prop="certUnit" label="证面单位" min-width="100" />
|
||||
<el-table-column label="产品预占" min-width="120"
|
||||
@@ -230,7 +243,12 @@
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { formatCertQty } from '../quota-cert/model'
|
||||
import { certFaceFields, expiryWarning } from '../quota-cert/cert-list-face'
|
||||
import {
|
||||
certFaceFields,
|
||||
certKindLabel,
|
||||
certKindTagType,
|
||||
expiryWarning
|
||||
} from '../quota-cert/cert-list-face'
|
||||
import StatisticsProducts from './StatisticsProducts.vue'
|
||||
import { statusLabel } from './labels'
|
||||
const props = withDefaults(
|
||||
@@ -252,10 +270,6 @@
|
||||
function certKey(cert: { certType: string; certId: string }) {
|
||||
return `${cert.certType}:${cert.certId}`
|
||||
}
|
||||
function certTabLabel(cert: ContractStatistics['certificates'][number]) {
|
||||
const kind = cert.certType === 'purchase' ? '购买证' : '运输证'
|
||||
return `${kind} ${cert.certNo || ''}`.trim()
|
||||
}
|
||||
watch(
|
||||
() =>
|
||||
props.mode === 'contract'
|
||||
@@ -282,7 +296,8 @@
|
||||
productName: product.productName || '—',
|
||||
model: product.model || '—',
|
||||
unit: unitLabel(product.unit) || '—',
|
||||
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
|
||||
certType: cert.certType,
|
||||
certKind: certKindLabel(cert.certType),
|
||||
certNo: cert.certNo || '—',
|
||||
certUnit: unitLabel(cert.quantityUnit) || '—',
|
||||
reservedQty: product.reservedQty,
|
||||
@@ -475,4 +490,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quota-cert-tab-label {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -144,9 +144,11 @@
|
||||
/> </div
|
||||
></template>
|
||||
<template #default="{ row }"
|
||||
><span v-if="column.prop === 'certType'">{{
|
||||
row.certType === 'transport' ? '运输证' : '购买证'
|
||||
}}</span
|
||||
><el-tag
|
||||
v-if="column.prop === 'certType'"
|
||||
size="small"
|
||||
:type="certKindTagType(row.certType)"
|
||||
>{{ certKindLabel(row.certType) }}</el-tag
|
||||
><el-tag
|
||||
v-else-if="column.prop === 'status'"
|
||||
size="small"
|
||||
@@ -205,7 +207,7 @@
|
||||
import StatisticsDetail from './StatisticsDetail.vue'
|
||||
import { createStatisticsLoader } from './loader'
|
||||
import { statusLabel, contractTypeLabel } from './labels'
|
||||
import { expiryWarning } from '../quota-cert/cert-list-face'
|
||||
import { certKindLabel, certKindTagType, expiryWarning } from '../quota-cert/cert-list-face'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: 'contract' | 'certificate'
|
||||
|
||||
Reference in new issue
Block a user