Compare commits

...
Author SHA1 Message Date
wuzilongandCursor 0baa6880f7 feat(doclib): 文档库支持多选文档后批量设置分享。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:43:16 +08:00
wuzilongandCursor dd4fdf1e6e feat(doclib): 增加批量设置分享接口并复用单文档写入逻辑。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:42:34 +08:00
wuzilongandCursor 92a8b3f6da feat(doclib): 增加批量设置分享 API 客户端。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:42:06 +08:00
wuzilongandCursor 522ef36e7a feat(doclib): 允许多选可管理文档分享并计算分享人交集。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:41:48 +08:00
wuzilongandCursor bb75f98582 docs: 增加文档库多选批量分享实现计划(#25)。
按 TDD 拆分前后端任务,覆盖交集预选、batch-shares 事务与页面接入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:39:49 +08:00
wuzilongandCursor bf89a1f301 docs: 记录文档库多选批量分享设计(#25)。
明确整份覆盖、交集预选、batch-shares 事务与 Web 弹窗扩展范围,供实现对照。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:36:51 +08:00
wuzilong 391e5f66f5 merge: 合入合同改号同步运输计划快照修复(#23)。 2026-11-12 16:34:58 +08:00
wuzilongandCursor 522b4be705 feat(pl): 装送货段增加同市标记,并同步额度证件与计划表单。
同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:34:58 +08:00
52 changed files with 1210 additions and 136 deletions

No files matched your search

+1
View File
@@ -115,4 +115,5 @@ api_key
# personal-dev:begin
/.local/agent/
/.local/secrets/
# personal-dev:end
+12 -1
View File
@@ -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))
+2
View File
@@ -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="备注")
+5
View File
@@ -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)
+2
View File
@@ -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="备注")
+1
View File
@@ -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):
+20 -5
View File
@@ -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 []):
+25 -7
View File
@@ -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:
+12 -3
View File
@@ -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}')
+19 -8
View File
@@ -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,
+4 -2
View File
@@ -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)
+19 -3
View File
@@ -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:
+43 -2
View File
@@ -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'), '吨')
+15
View File
@@ -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
+19 -1
View File
@@ -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;
+2
View File
@@ -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,请继续」批准落盘)。待用户审阅本文件后进入实现计划。
+19
View File
@@ -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']
)
})
+5
View File
@@ -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) =>
+4
View File
@@ -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[]
+2
View File
@@ -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'), {
+55
View File
@@ -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: '生产部' }))
+16 -8
View File
@@ -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)
+22 -2
View File
@@ -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')
+18 -1
View File
@@ -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
+11 -8
View File
@@ -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'