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
|
# personal-dev:begin
|
||||||
/.local/agent/
|
/.local/agent/
|
||||||
|
/.local/secrets/
|
||||||
# personal-dev:end
|
# personal-dev:end
|
||||||
@@ -4,7 +4,13 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.doclib_upload_route import DoclibRoute
|
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_service import DoclibService
|
||||||
from app.services.doclib_storage import download_document, upload_document
|
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()
|
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}')
|
@router.get('/web/doclib/files/{file_id}')
|
||||||
def document_detail(file_id: str, svc: DoclibService = Depends(service)):
|
def document_detail(file_id: str, svc: DoclibService = Depends(service)):
|
||||||
return success_response(svc.get_file(file_id))
|
return success_response(svc.get_file(file_id))
|
||||||
|
|||||||
@@ -536,6 +536,7 @@ class PlPlanLoad(Base):
|
|||||||
notes = Column(String(500), nullable=True, comment="注意事项")
|
notes = Column(String(500), nullable=True, comment="注意事项")
|
||||||
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
||||||
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
|
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="租户编号")
|
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||||
remark = Column(String(512), nullable=True, comment="备注")
|
remark = Column(String(512), nullable=True, comment="备注")
|
||||||
@@ -599,6 +600,7 @@ class PlPlanDelivery(Base):
|
|||||||
notes = Column(String(500), nullable=True, comment="注意事项")
|
notes = Column(String(500), nullable=True, comment="注意事项")
|
||||||
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
||||||
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
|
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="租户编号")
|
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||||
remark = Column(String(512), nullable=True, comment="备注")
|
remark = Column(String(512), nullable=True, comment="备注")
|
||||||
|
|||||||
@@ -28,5 +28,10 @@ class ShareBody(_Body):
|
|||||||
userIds: List[str] = Field(max_length=500)
|
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):
|
class DeleteFilesBody(_Body):
|
||||||
ids: List[str] = Field(min_length=1, max_length=100)
|
ids: List[str] = Field(min_length=1, max_length=100)
|
||||||
@@ -182,6 +182,7 @@ class PlanLoadBody(BaseModel):
|
|||||||
dibang_direction: Optional[str] = Field(
|
dibang_direction: Optional[str] = Field(
|
||||||
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
|
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="装货段产品明细")
|
products: list[PlanStopProductBody] = Field(default_factory=list, description="装货段产品明细")
|
||||||
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="装货段合同列表")
|
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="装货段合同列表")
|
||||||
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
|
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
|
||||||
@@ -203,6 +204,7 @@ class PlanDeliveryBody(BaseModel):
|
|||||||
dibang_direction: Optional[str] = Field(
|
dibang_direction: Optional[str] = Field(
|
||||||
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
|
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="送货段产品明细")
|
products: list[PlanStopProductBody] = Field(default_factory=list, description="送货段产品明细")
|
||||||
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="送货段合同列表")
|
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="送货段合同列表")
|
||||||
remark: Optional[str] = Field(default=None, max_length=512, 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)
|
related_stop_products: List[str] = Field(default_factory=list)
|
||||||
fingerprint: str
|
fingerprint: str
|
||||||
snapshot: dict = Field(default_factory=dict)
|
snapshot: dict = Field(default_factory=dict)
|
||||||
|
requires_transport: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_id(self):
|
def source_id(self):
|
||||||
|
|||||||
@@ -228,23 +228,38 @@ class DoclibService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return result
|
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)
|
row = self._file(file_id, manage=True, lock=True)
|
||||||
requested = set(user_ids) - {row.create_by}
|
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:
|
||||||
if requested != valid:
|
valid = {u.id for u in self._base(SysUser).filter(
|
||||||
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
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(
|
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():
|
for uid, share in history.items():
|
||||||
share.del_flag = 0 if uid in requested else 1
|
share.del_flag = 0 if uid in requested else 1
|
||||||
share.update_by = self.uid
|
share.update_by = self.uid
|
||||||
for uid in requested - set(history):
|
for uid in requested - set(history):
|
||||||
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
|
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
|
||||||
row.update_by, row.update_time = self.uid, datetime.now()
|
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()
|
self.db.commit()
|
||||||
return self._serialize_files([row])[0]
|
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):
|
def delete_files(self, ids):
|
||||||
rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))]
|
rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))]
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -1152,6 +1152,7 @@ def _resolve_or_create_plan_load(
|
|||||||
existing.notes = load.notes
|
existing.notes = load.notes
|
||||||
existing.generate_dibang = generate_dibang
|
existing.generate_dibang = generate_dibang
|
||||||
existing.dibang_direction = dibang_direction
|
existing.dibang_direction = dibang_direction
|
||||||
|
existing.same_city = 1 if int(load.same_city or 0) == 1 else 0
|
||||||
existing.remark = load.remark
|
existing.remark = load.remark
|
||||||
existing.del_flag = 0
|
existing.del_flag = 0
|
||||||
existing.update_by = user_id
|
existing.update_by = user_id
|
||||||
@@ -1173,6 +1174,7 @@ def _resolve_or_create_plan_load(
|
|||||||
notes=load.notes,
|
notes=load.notes,
|
||||||
generate_dibang=generate_dibang,
|
generate_dibang=generate_dibang,
|
||||||
dibang_direction=dibang_direction,
|
dibang_direction=dibang_direction,
|
||||||
|
same_city=1 if int(load.same_city or 0) == 1 else 0,
|
||||||
tenant_no=tenant_no,
|
tenant_no=tenant_no,
|
||||||
remark=load.remark,
|
remark=load.remark,
|
||||||
create_by=user_id,
|
create_by=user_id,
|
||||||
@@ -1439,6 +1441,7 @@ def replace_plan_stops(
|
|||||||
stop.seq = body.seq or 1
|
stop.seq = body.seq or 1
|
||||||
stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields(
|
stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields(
|
||||||
stop.customer_name, kind, body.generate_dibang, body.dibang_direction)
|
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
|
stop.del_flag, stop.update_by, stop.update_time = 0, user_id, now
|
||||||
model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct
|
model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct
|
||||||
for idx, (row, product, linked) in enumerate(product_jobs or []):
|
for idx, (row, product, linked) in enumerate(product_jobs or []):
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class SourceDemand:
|
|||||||
product_id: str
|
product_id: str
|
||||||
quantity: Decimal
|
quantity: Decimal
|
||||||
unit: str
|
unit: str
|
||||||
|
requires_transport: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=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)
|
key = (row.permit_kind, row.source_id, row.permit_id)
|
||||||
s = sources.get(row.source_id)
|
s = sources.get(row.source_id)
|
||||||
p = groups.get(row.permit_kind, {}).get(row.permit_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)
|
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:
|
if key in manual or s is None or p is None or s.product_id not in p.product_ids:
|
||||||
raise AllocationInputError('人工分配标识重复或不适用')
|
raise AllocationInputError('人工分配标识重复或不适用')
|
||||||
@@ -249,12 +252,19 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
|
|||||||
|
|
||||||
routes = []
|
routes = []
|
||||||
for sid, s in sorted(sources.items()):
|
for sid, s in sorted(sources.items()):
|
||||||
for gid, g in sorted(groups['purchase'].items()):
|
if s.requires_transport:
|
||||||
for tid, t in sorted(groups['transport'].items()):
|
pairs = (
|
||||||
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t):
|
(gid, tid)
|
||||||
routes.append((sid, gid, tid))
|
for gid, g in sorted(groups['purchase'].items())
|
||||||
if len(routes) > limits.max_variables:
|
for tid, t in sorted(groups['transport'].items())
|
||||||
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
|
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 = [], []
|
a, b = [], []
|
||||||
|
|
||||||
def constrain(coefficients, bound, equal=False):
|
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:
|
if len(demands) > limits.max_sources or len(transports) > limits.max_permits:
|
||||||
raise AllocationIncomplete('匹配校验未完成:输入规模超限')
|
raise AllocationIncomplete('匹配校验未完成:输入规模超限')
|
||||||
sources = _unique(demands, 'source_id')
|
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')
|
transport_map = _unique(transports, 'permit_id')
|
||||||
for s in sources.values():
|
for s in sources.values():
|
||||||
_quantity(s.quantity)
|
_quantity(s.quantity)
|
||||||
@@ -434,13 +445,16 @@ def validate_allocation_pairing(
|
|||||||
groups[kind][key] = row
|
groups[kind][key] = row
|
||||||
for sid, s in sources.items():
|
for sid, s in sources.items():
|
||||||
total = sum(F(r.source_quantity) for (source_id, _), r in groups[kind].items() if source_id == sid)
|
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('分配未覆盖实际来源量,需要人工调整;保留原占用')
|
raise AllocationPairingError('分配未覆盖实际来源量,需要人工调整;保留原占用')
|
||||||
purchase_ids = {pid for _, pid in groups['purchase']}
|
purchase_ids = {pid for _, pid in groups['purchase']}
|
||||||
if len(purchase_ids) + len(transport_map) > limits.max_permits:
|
if len(purchase_ids) + len(transport_map) > limits.max_permits:
|
||||||
raise AllocationIncomplete('匹配校验未完成:证件数量超限')
|
raise AllocationIncomplete('匹配校验未完成:证件数量超限')
|
||||||
routes = []
|
routes = []
|
||||||
for sid, gid in sorted(groups['purchase']):
|
for sid, gid in sorted(groups['purchase']):
|
||||||
|
if sid in purchase_only:
|
||||||
|
continue
|
||||||
for source_id, tid in sorted(groups['transport']):
|
for source_id, tid in sorted(groups['transport']):
|
||||||
if sid == source_id and gid in transport_map[tid].purchase_permit_ids:
|
if sid == source_id and gid in transport_map[tid].purchase_permit_ids:
|
||||||
routes.append((sid, gid, tid))
|
routes.append((sid, gid, tid))
|
||||||
@@ -450,9 +464,13 @@ def validate_allocation_pairing(
|
|||||||
for kind, rows in groups.items():
|
for kind, rows in groups.items():
|
||||||
position = 1 if kind == 'purchase' else 2
|
position = 1 if kind == 'purchase' else 2
|
||||||
for (sid, pid), row in sorted(rows.items()):
|
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]
|
v = [int(r[0] == sid and r[position] == pid) for r in routes]
|
||||||
a.extend([v, [-x for x in v]])
|
a.extend([v, [-x for x in v]])
|
||||||
b.extend([F(row.source_quantity), -F(row.source_quantity)])
|
b.extend([F(row.source_quantity), -F(row.source_quantity)])
|
||||||
|
if not a:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
_solve(a, b, [0] * len(routes), budget)
|
_solve(a, b, [0] * len(routes), budget)
|
||||||
except InsufficientQuota as exc:
|
except InsufficientQuota as exc:
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ def allocation_diagnostics(demands, purchases, transports):
|
|||||||
grouped.append((code, source.unit))
|
grouped.append((code, source.unit))
|
||||||
for kind, permits in [('purchase', purchases), ('transport', transports)]:
|
for kind, permits in [('purchase', purchases), ('transport', transports)]:
|
||||||
for code, unit in grouped:
|
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}
|
products = {s.product_id for s in sources}
|
||||||
eligible = [p for p in permits if p.product_ids & products]
|
eligible = [p for p in permits if p.product_ids & products]
|
||||||
demand = sum((s.quantity for s in sources), Decimal(0))
|
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 = []
|
notices = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for source in demands:
|
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)]
|
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)]
|
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:
|
if not buys or not moves:
|
||||||
@@ -93,16 +101,17 @@ def foreign_unit_message(demands, purchases, transports, cert_nos):
|
|||||||
for source in demands:
|
for source in demands:
|
||||||
buys = [p for p in purchases if source.product_id in p.product_ids]
|
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]
|
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_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)]
|
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
|
continue
|
||||||
parts = []
|
parts = []
|
||||||
if not same_buys:
|
if not same_buys:
|
||||||
text = _cert_unit_text('purchase', [p for p in buys if not _directly_usable(p, source.unit)], cert_nos)
|
text = _cert_unit_text('purchase', [p for p in buys if not _directly_usable(p, source.unit)], cert_nos)
|
||||||
if text:
|
if text:
|
||||||
parts.append(f'购买证 {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)
|
text = _cert_unit_text('transport', [t for t in moves if not _directly_usable(t, source.unit)], cert_nos)
|
||||||
if text:
|
if text:
|
||||||
parts.append(f'运输证 {text}')
|
parts.append(f'运输证 {text}')
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ def plan_fingerprint(sources):
|
|||||||
|
|
||||||
|
|
||||||
def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labels):
|
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:
|
for source in sources:
|
||||||
found = {'purchase': [], 'transport': []}
|
found = {'purchase': [], 'transport': []}
|
||||||
for binding in bindings_by_contract.get(source.contract_id) or []:
|
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:
|
if source.contract_product_id not in product_ids or kind not in found or not cert_id:
|
||||||
continue
|
continue
|
||||||
found[kind].append(cert_id)
|
found[kind].append(cert_id)
|
||||||
if not found['purchase'] or not found['transport']:
|
needs_transport = getattr(source, 'requires_transport', True)
|
||||||
missing.append(contract_labels.get(source.contract_id) or '未编号合同')
|
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'])
|
purchase_ids.extend(found['purchase'])
|
||||||
transport_ids.extend(found['transport'])
|
if needs_transport:
|
||||||
if missing:
|
transport_ids.extend(found['transport'])
|
||||||
names = '、'.join(dict.fromkeys(missing))
|
messages = []
|
||||||
raise HTTPException(409, f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
|
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):
|
def unique(values):
|
||||||
return list(dict.fromkeys(values))
|
return list(dict.fromkeys(values))
|
||||||
@@ -279,6 +289,7 @@ def get_plan_quota(db, user, plan_id):
|
|||||||
product = products[row.plan_product_id]
|
product = products[row.plan_product_id]
|
||||||
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
|
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
|
||||||
item = {'sourceStopId': stop.id, 'stopId': stop.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,
|
'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
|
||||||
'productId': row.product_id, 'productNo': row.product_no,
|
'productId': row.product_id, 'productNo': row.product_no,
|
||||||
'sourceStopType': side, 'sourceStopProductId': row.id,
|
'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]:
|
for stop_side, value in [(side, row)] + [(other_side, r) for r in related]:
|
||||||
parent = stops[stop_side][getattr(value, SIDE[stop_side][2])]
|
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')})
|
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,
|
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':
|
if measurement_version == 'per_stop_v2':
|
||||||
snapshot['measurementVersion'] = measurement_version
|
snapshot['measurementVersion'] = measurement_version
|
||||||
if preview_vehicle is not None:
|
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,
|
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
|
||||||
contract_product_id=row.contract_product_id, product_id=row.product_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],
|
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)
|
sources.append(source)
|
||||||
if actual:
|
if actual:
|
||||||
quantity = getattr(row, actual_field)
|
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()
|
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('合同产品归属不符')
|
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
|
||||||
scopes={}; candidates={'purchase':[],'transport':[]}
|
scopes={}; candidates={'purchase':[],'transport':[]}
|
||||||
|
skipped_for_vehicle=False
|
||||||
for k in sorted(selected):
|
for k in sorted(selected):
|
||||||
kind,pid=k; c=certs[k]
|
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('证件尚未确认期初')
|
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('证件不在有效日期内')
|
if not c.valid_from <= r.on_date <= c.valid_to: raise ValueError('证件不在有效日期内')
|
||||||
policy=usage_policy(c.valid_use_text)
|
policy=usage_policy(c.valid_use_text)
|
||||||
if policy=='needs_confirmation': raise ValueError('有效次数待确认')
|
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':
|
if policy=='once':
|
||||||
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
|
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):
|
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()
|
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
|
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))
|
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
|
return sources,scopes,candidates
|
||||||
|
|
||||||
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
|
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)
|
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:
|
try:
|
||||||
allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations))
|
allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations))
|
||||||
except InsufficientQuota as error:
|
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()):
|
for sid,s in sorted(sources.items()):
|
||||||
if s.quantity != 0: continue
|
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']
|
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
|
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))
|
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):
|
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('用证状态不允许当前结转动作')
|
raise ValueError('用证状态不允许当前结转动作')
|
||||||
groups={'purchase':[],'transport':[]}; item_map={}; demands={}
|
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 k,rows in active.items():
|
||||||
for i in rows:
|
for i in rows:
|
||||||
sid=_key(i)
|
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)
|
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
|
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:
|
if not r.manual_actuals:
|
||||||
groups, generated, conversion_error = _prepare_saved_conversion(groups, r.actual_by_source, certs)
|
groups, generated, conversion_error = _prepare_saved_conversion(groups, r.actual_by_source, certs)
|
||||||
if conversion_error:
|
if conversion_error:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from decimal import Decimal as D
|
|||||||
import pytest
|
import pytest
|
||||||
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, AllocationLimits, AllocationInputError, AllocationIncomplete, InsufficientQuota, SupplementalAllocationRequired, allocate_plan, settle_allocations)
|
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'):
|
def source(id='s', product='A', quantity='30', unit='t', requires_transport=True):
|
||||||
return SourceDemand(id, product, D(quantity), unit)
|
return SourceDemand(id, product, D(quantity), unit, requires_transport)
|
||||||
|
|
||||||
def permit(id, quantity='30', products=('A',), refs=(), unit='t', expiry=1):
|
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))
|
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 == ()
|
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():
|
def test_one_purchase_and_two_transport_certificates():
|
||||||
result = allocate_plan(
|
result = allocate_plan(
|
||||||
[source()], [permit('g', '30')],
|
[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
|
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)])
|
@pytest.mark.parametrize('manual_values', [(8,10,0),(18,0,0)])
|
||||||
def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values):
|
def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values):
|
||||||
from app.services.pl_quota_allocation import Allocation
|
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)
|
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():
|
def test_missing_purchase_link_names_both_certificates():
|
||||||
from app.services.pl_quota_diagnostics import missing_purchase_link_message
|
from app.services.pl_quota_diagnostics import missing_purchase_link_message
|
||||||
source = SourceDemand('a', 'a', D('30'), '吨')
|
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():
|
def test_supplement_is_explicit_and_archive_public_schema_preserves_intent():
|
||||||
from app.schemas.pl.plan import PlanArchiveBody
|
from app.schemas.pl.plan import PlanArchiveBody
|
||||||
from app.schemas.pl.quota_planning import PlanQuotaOperation
|
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()
|
p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush()
|
||||||
with pytest.raises(ValueError): run(c)
|
with pytest.raises(ValueError): run(c)
|
||||||
p.valid_to=date(2099,12,31); c.db.flush()
|
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):
|
def test_no_cert_operation_is_still_idempotent(case):
|
||||||
c=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 '注意事项',
|
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
||||||
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
||||||
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
|
`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 '租户编号',
|
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||||
@@ -26794,6 +26795,7 @@ CREATE TABLE `pl_plan_load` (
|
|||||||
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
||||||
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
||||||
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
|
`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 '租户编号',
|
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||||
`remark` varchar(512) 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 {
|
import {
|
||||||
buyPartyName,
|
buyPartyName,
|
||||||
certFaceFields,
|
certFaceFields,
|
||||||
|
certKindLabel,
|
||||||
|
certKindTagType,
|
||||||
|
compareCertUploadTime,
|
||||||
expiryWarning,
|
expiryWarning,
|
||||||
salePartyName
|
salePartyName
|
||||||
} from '../src/views/pl/quota-cert/cert-list-face'
|
} 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('2026-11-12', today), null)
|
||||||
assert.equal(expiryWarning('', 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`,
|
url: `${base}/files/${encodeURIComponent(id)}/shares`,
|
||||||
data: { userIds }
|
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[]) =>
|
export const deleteDoclibFiles = (ids: string[]) =>
|
||||||
request.post({ url: `${base}/files/batch-delete`, data: { ids } })
|
request.post({ url: `${base}/files/batch-delete`, data: { ids } })
|
||||||
export const downloadDoclibFile = (id: string) =>
|
export const downloadDoclibFile = (id: string) =>
|
||||||
|
|||||||
@@ -480,6 +480,8 @@ export interface PlanLoad {
|
|||||||
notes?: string
|
notes?: string
|
||||||
generateDibang?: number
|
generateDibang?: number
|
||||||
dibangDirection?: string
|
dibangDirection?: string
|
||||||
|
/** 1 时本段易制毒只校验购买证 */
|
||||||
|
sameCity?: number
|
||||||
remark?: string
|
remark?: string
|
||||||
products?: PlanStopProduct[]
|
products?: PlanStopProduct[]
|
||||||
contracts?: PlanContractLink[]
|
contracts?: PlanContractLink[]
|
||||||
@@ -502,6 +504,8 @@ export interface PlanDelivery {
|
|||||||
notes?: string
|
notes?: string
|
||||||
generateDibang?: number
|
generateDibang?: number
|
||||||
dibangDirection?: string
|
dibangDirection?: string
|
||||||
|
/** 1 时本段易制毒只校验购买证 */
|
||||||
|
sameCity?: number
|
||||||
remark?: string
|
remark?: string
|
||||||
deliveryMethod?: string
|
deliveryMethod?: string
|
||||||
products?: PlanStopProduct[]
|
products?: PlanStopProduct[]
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ export interface QuotaSourceOption {
|
|||||||
contractId: string
|
contractId: string
|
||||||
contractProductId: string
|
contractProductId: string
|
||||||
partyName?: string
|
partyName?: string
|
||||||
|
/** 本段勾选同市时只要求购买证 */
|
||||||
|
sameCity?: boolean
|
||||||
}
|
}
|
||||||
export interface QuotaSourcePair {
|
export interface QuotaSourcePair {
|
||||||
sourceStopType: 'load' | 'delivery'
|
sourceStopType: 'load' | 'delivery'
|
||||||
|
|||||||
@@ -162,6 +162,15 @@
|
|||||||
@update:model-value="patchDelivery(index, { notes: $event })"
|
@update:model-value="patchDelivery(index, { notes: $event })"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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">
|
<el-form-item label="地磅单" class="form-col-span-2">
|
||||||
<div class="pl-plan-dibang-row">
|
<div class="pl-plan-dibang-row">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
|
|||||||
@@ -714,6 +714,7 @@
|
|||||||
mergePlanProductsFromLoadMirror,
|
mergePlanProductsFromLoadMirror,
|
||||||
shouldRetainPlanProductsWhileLoadEmpty,
|
shouldRetainPlanProductsWhileLoadEmpty,
|
||||||
resolveStopDibangDefaults,
|
resolveStopDibangDefaults,
|
||||||
|
toxicStopCertBindingMessage,
|
||||||
sumStopProductsPlanQty,
|
sumStopProductsPlanQty,
|
||||||
type PlanDeliveryForm,
|
type PlanDeliveryForm,
|
||||||
type PlanLoadForm,
|
type PlanLoadForm,
|
||||||
@@ -2619,19 +2620,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toxicContractCertBindingMessage(rows: QuotaSourceOption[]) {
|
function toxicContractCertBindingMessage(rows: QuotaSourceOption[]) {
|
||||||
const missing = new Map<string, string>()
|
return toxicStopCertBindingMessage(rows)
|
||||||
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('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performArrangeAfterSave(
|
async function performArrangeAfterSave(
|
||||||
|
|||||||
@@ -110,6 +110,15 @@
|
|||||||
@update:model-value="patch({ notes: $event })"
|
@update:model-value="patch({ notes: $event })"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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">
|
<el-form-item label="地磅单" class="form-col-span-2">
|
||||||
<div class="pl-plan-dibang-row">
|
<div class="pl-plan-dibang-row">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
|
|||||||
@@ -78,7 +78,15 @@
|
|||||||
<section v-if="faceTabs.length" class="quota-face">
|
<section v-if="faceTabs.length" class="quota-face">
|
||||||
<h3 class="dialog-section-title">关联证件证面</h3>
|
<h3 class="dialog-section-title">关联证件证面</h3>
|
||||||
<el-tabs v-model="faceTab" class="quota-face-tabs">
|
<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>
|
<p v-if="!tab.cert" class="quota-note">没有读到这张证的证面档案。</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section
|
<section
|
||||||
@@ -209,12 +217,9 @@
|
|||||||
<div class="conversion-grid">
|
<div class="conversion-grid">
|
||||||
<section v-for="row in group.rows" :key="conversionKey(row)" class="conversion-card">
|
<section v-for="row in group.rows" :key="conversionKey(row)" class="conversion-card">
|
||||||
<div class="conversion-card-head">
|
<div class="conversion-card-head">
|
||||||
<el-tag
|
<el-tag size="small" :type="certKindTagType(row.cert.certType)">{{
|
||||||
size="small"
|
certKindLabel(row.cert.certType)
|
||||||
effect="plain"
|
}}</el-tag>
|
||||||
:type="row.cert.certType === 'purchase' ? 'success' : 'primary'"
|
|
||||||
>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }}</el-tag
|
|
||||||
>
|
|
||||||
<strong>{{ row.cert.certNo }}</strong>
|
<strong>{{ row.cert.certNo }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<label>换算系数</label>
|
<label>换算系数</label>
|
||||||
@@ -286,7 +291,12 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="item in selected" :key="item.kind + item.cert.id" class="view-section">
|
<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
|
<p
|
||||||
>{{ item.cert.buyerName || item.cert.shipperName }} →
|
>{{ item.cert.buyerName || item.cert.shipperName }} →
|
||||||
{{ item.cert.sellerName || item.cert.consigneeName }};有效期
|
{{ item.cert.sellerName || item.cert.consigneeName }};有效期
|
||||||
@@ -393,10 +403,12 @@
|
|||||||
:key="row.certType + row.unit"
|
:key="row.certType + row.unit"
|
||||||
class="view-section"
|
class="view-section"
|
||||||
>
|
>
|
||||||
<strong
|
<div class="quota-kind-line">
|
||||||
>{{ row.certType === 'purchase' ? '购买证' : '运输证' }} · 需求
|
<el-tag size="small" :type="certKindTagType(row.certType)">{{
|
||||||
{{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong
|
certKindLabel(row.certType)
|
||||||
>
|
}}</el-tag>
|
||||||
|
<strong>需求 {{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong>
|
||||||
|
</div>
|
||||||
<p v-if="row.availableUpperBound !== null">
|
<p v-if="row.availableUpperBound !== null">
|
||||||
适用余额至多 {{ qtyText(row.availableUpperBound) }}
|
适用余额至多 {{ qtyText(row.availableUpperBound) }}
|
||||||
{{ unitText(row.unit) }};缺口至少 {{ qtyText(row.gapLowerBound) }}
|
{{ unitText(row.unit) }};缺口至少 {{ qtyText(row.gapLowerBound) }}
|
||||||
@@ -408,7 +420,13 @@
|
|||||||
}}</p>
|
}}</p>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="allocations" stripe
|
<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"
|
prop="certNo"
|
||||||
label="证号"
|
label="证号"
|
||||||
min-width="160" /><el-table-column
|
min-width="160" /><el-table-column
|
||||||
@@ -493,6 +511,7 @@
|
|||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { useDictStore } from '@/store/modules/dict'
|
import { useDictStore } from '@/store/modules/dict'
|
||||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
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 ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
|
||||||
import type { ViewField } from '@/components/business/system-view-dialog/types'
|
import type { ViewField } from '@/components/business/system-view-dialog/types'
|
||||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||||
@@ -631,7 +650,7 @@
|
|||||||
kind,
|
kind,
|
||||||
cert,
|
cert,
|
||||||
certNo: number,
|
certNo: number,
|
||||||
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
|
label: `${certKindLabel(kind)} ${number}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const row of plan.value?.stopProducts || []) {
|
for (const row of plan.value?.stopProducts || []) {
|
||||||
@@ -1059,6 +1078,14 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<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) {
|
.quota-dialog :deep(.el-select) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
|
|||||||
import { describe, it } from 'node:test'
|
import { describe, it } from 'node:test'
|
||||||
import {
|
import {
|
||||||
canManualToggleStopDibang,
|
canManualToggleStopDibang,
|
||||||
resolveStopDibangDefaults
|
resolveStopDibangDefaults,
|
||||||
|
toxicStopCertBindingMessage
|
||||||
} from './pl-plan-stop-form'
|
} from './pl-plan-stop-form'
|
||||||
import { isPlDibangOptionalVehiclePlate } from './pl-internal-customer'
|
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', () => {
|
describe('resolveStopDibangDefaults + canManualToggleStopDibang', () => {
|
||||||
it('internal + normal vehicle: auto-check, not manual', () => {
|
it('internal + normal vehicle: auto-check, not manual', () => {
|
||||||
assert.deepEqual(resolveStopDibangDefaults(INTERNAL, 'load', '皖A11111'), {
|
assert.deepEqual(resolveStopDibangDefaults(INTERNAL, 'load', '皖A11111'), {
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface PlanLoadForm {
|
|||||||
notes: string
|
notes: string
|
||||||
generateDibang: number
|
generateDibang: number
|
||||||
dibangDirection: string
|
dibangDirection: string
|
||||||
|
/** 1 时本段易制毒只校验购买证 */
|
||||||
|
sameCity: number
|
||||||
contracts: PlanContractLink[]
|
contracts: PlanContractLink[]
|
||||||
products: PlanStopProductFormRow[]
|
products: PlanStopProductFormRow[]
|
||||||
}
|
}
|
||||||
@@ -119,6 +121,8 @@ export interface PlanDeliveryForm {
|
|||||||
notes: string
|
notes: string
|
||||||
generateDibang: number
|
generateDibang: number
|
||||||
dibangDirection: string
|
dibangDirection: string
|
||||||
|
/** 1 时本段易制毒只校验购买证 */
|
||||||
|
sameCity: number
|
||||||
contracts: PlanContractLink[]
|
contracts: PlanContractLink[]
|
||||||
products: PlanStopProductFormRow[]
|
products: PlanStopProductFormRow[]
|
||||||
}
|
}
|
||||||
@@ -146,6 +150,49 @@ export function resolveStopDibangDefaults(
|
|||||||
return { generateDibang: 1, dibangDirection: 'inbound' }
|
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(
|
export function canManualToggleStopDibang(
|
||||||
customerName?: string | null,
|
customerName?: string | null,
|
||||||
@@ -433,6 +480,7 @@ export function createEmptyLoadForm(): PlanLoadForm {
|
|||||||
notes: '',
|
notes: '',
|
||||||
generateDibang: 0,
|
generateDibang: 0,
|
||||||
dibangDirection: 'none',
|
dibangDirection: 'none',
|
||||||
|
sameCity: 0,
|
||||||
contracts: [],
|
contracts: [],
|
||||||
products: []
|
products: []
|
||||||
}
|
}
|
||||||
@@ -451,6 +499,7 @@ export function createEmptyDeliveryForm(seq = 1): PlanDeliveryForm {
|
|||||||
notes: '',
|
notes: '',
|
||||||
generateDibang: 0,
|
generateDibang: 0,
|
||||||
dibangDirection: 'none',
|
dibangDirection: 'none',
|
||||||
|
sameCity: 0,
|
||||||
contracts: [],
|
contracts: [],
|
||||||
products: []
|
products: []
|
||||||
}
|
}
|
||||||
@@ -540,6 +589,7 @@ export function mapLoadFromApi(
|
|||||||
notes: load.notes || '',
|
notes: load.notes || '',
|
||||||
generateDibang: dibang.generateDibang,
|
generateDibang: dibang.generateDibang,
|
||||||
dibangDirection: dibang.dibangDirection,
|
dibangDirection: dibang.dibangDirection,
|
||||||
|
sameCity: Number(load.sameCity) === 1 ? 1 : 0,
|
||||||
contracts: (load.contracts || []).map((item) => ({
|
contracts: (load.contracts || []).map((item) => ({
|
||||||
contractId: String(item.contractId || ''),
|
contractId: String(item.contractId || ''),
|
||||||
contractNo: item.contractNo
|
contractNo: item.contractNo
|
||||||
@@ -574,6 +624,7 @@ export function mapDeliveriesFromApi(
|
|||||||
notes: row.notes || '',
|
notes: row.notes || '',
|
||||||
generateDibang: dibang.generateDibang,
|
generateDibang: dibang.generateDibang,
|
||||||
dibangDirection: dibang.dibangDirection,
|
dibangDirection: dibang.dibangDirection,
|
||||||
|
sameCity: Number(row.sameCity) === 1 ? 1 : 0,
|
||||||
contracts: (row.contracts || []).map((item) => ({
|
contracts: (row.contracts || []).map((item) => ({
|
||||||
contractId: String(item.contractId || ''),
|
contractId: String(item.contractId || ''),
|
||||||
contractNo: item.contractNo
|
contractNo: item.contractNo
|
||||||
@@ -617,6 +668,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
|
|||||||
notes: plan.pickupNotes || '',
|
notes: plan.pickupNotes || '',
|
||||||
generateDibang: loadDefaults.generateDibang,
|
generateDibang: loadDefaults.generateDibang,
|
||||||
dibangDirection: loadDefaults.dibangDirection,
|
dibangDirection: loadDefaults.dibangDirection,
|
||||||
|
sameCity: 0,
|
||||||
contracts: (plan.supplierContracts || []).map((item) => ({
|
contracts: (plan.supplierContracts || []).map((item) => ({
|
||||||
contractId: String(item.contractId || ''),
|
contractId: String(item.contractId || ''),
|
||||||
contractNo: item.contractNo
|
contractNo: item.contractNo
|
||||||
@@ -636,6 +688,7 @@ export function mapLegacyPlanToStops(plan: Plan): {
|
|||||||
notes: plan.destinationNotes || '',
|
notes: plan.destinationNotes || '',
|
||||||
generateDibang: deliveryDefaults.generateDibang,
|
generateDibang: deliveryDefaults.generateDibang,
|
||||||
dibangDirection: deliveryDefaults.dibangDirection,
|
dibangDirection: deliveryDefaults.dibangDirection,
|
||||||
|
sameCity: 0,
|
||||||
contracts: (plan.demanderContracts || []).map((item) => ({
|
contracts: (plan.demanderContracts || []).map((item) => ({
|
||||||
contractId: String(item.contractId || ''),
|
contractId: String(item.contractId || ''),
|
||||||
contractNo: item.contractNo
|
contractNo: item.contractNo
|
||||||
@@ -900,6 +953,7 @@ export function buildLoadPayload(load: PlanLoadForm) {
|
|||||||
notes: load.notes || undefined,
|
notes: load.notes || undefined,
|
||||||
generateDibang: load.generateDibang,
|
generateDibang: load.generateDibang,
|
||||||
dibangDirection: load.dibangDirection,
|
dibangDirection: load.dibangDirection,
|
||||||
|
sameCity: load.sameCity === 1 ? 1 : 0,
|
||||||
contracts: load.contracts,
|
contracts: load.contracts,
|
||||||
products: load.products.map(buildStopProductPayload)
|
products: load.products.map(buildStopProductPayload)
|
||||||
}
|
}
|
||||||
@@ -919,6 +973,7 @@ export function buildDeliveriesPayload(deliveries: PlanDeliveryForm[]) {
|
|||||||
notes: row.notes || undefined,
|
notes: row.notes || undefined,
|
||||||
generateDibang: row.generateDibang,
|
generateDibang: row.generateDibang,
|
||||||
dibangDirection: row.dibangDirection,
|
dibangDirection: row.dibangDirection,
|
||||||
|
sameCity: row.sameCity === 1 ? 1 : 0,
|
||||||
contracts: row.contracts,
|
contracts: row.contracts,
|
||||||
products: row.products.map(buildStopProductPayload)
|
products: row.products.map(buildStopProductPayload)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
:close-on-press-escape="!saving"
|
:close-on-press-escape="!saving"
|
||||||
:show-close="!saving"
|
:show-close="!saving"
|
||||||
>
|
>
|
||||||
<p class="doclib-summary">{{ document?.name }}</p>
|
<p class="doclib-summary">{{ summaryText }}</p>
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<template #label
|
<template #label
|
||||||
>分享给<span class="form-label-tip">可多选,取消选择即收回访问权限</span></template
|
>分享给<span class="form-label-tip"
|
||||||
|
>可多选,取消选择即收回访问权限;保存后对所选文档整份覆盖</span
|
||||||
|
></template
|
||||||
>
|
>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="userIds"
|
v-model="userIds"
|
||||||
@@ -56,15 +58,15 @@
|
|||||||
import { Close, Check } from '@element-plus/icons-vue'
|
import { Close, Check } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
fetchDoclibUsers,
|
fetchDoclibUsers,
|
||||||
saveDoclibShares,
|
saveDoclibBatchShares,
|
||||||
type DoclibDocument,
|
type DoclibDocument,
|
||||||
type DoclibPerson
|
type DoclibPerson
|
||||||
} from '@/api/doclib'
|
} from '@/api/doclib'
|
||||||
import { createLatestRequest } from './model'
|
import { createLatestRequest, shareUserIntersection } from './model'
|
||||||
const props = defineProps<{ modelValue: boolean; document: DoclibDocument | null }>()
|
const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
saved: [document: DoclibDocument]
|
saved: [documents: DoclibDocument[]]
|
||||||
}>()
|
}>()
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
@@ -77,17 +79,31 @@
|
|||||||
const userIds = ref<string[]>([])
|
const userIds = ref<string[]>([])
|
||||||
const people = ref<DoclibPerson[]>([])
|
const people = ref<DoclibPerson[]>([])
|
||||||
const requests = createLatestRequest()
|
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 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))
|
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(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(value) => {
|
(value) => {
|
||||||
requests.invalidate()
|
requests.invalidate()
|
||||||
if (!value) return
|
if (!value) return
|
||||||
userIds.value = (props.document?.shares || []).map((person) => person.id)
|
userIds.value = shareUserIntersection(props.documents)
|
||||||
people.value = []
|
people.value = []
|
||||||
void searchUsers('')
|
void searchUsers('')
|
||||||
}
|
}
|
||||||
@@ -109,12 +125,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function save() {
|
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
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const updated = await saveDoclibShares(props.document.id, userIds.value)
|
const updated = await saveDoclibBatchShares(
|
||||||
|
props.documents.map((doc) => doc.id),
|
||||||
|
userIds.value
|
||||||
|
)
|
||||||
ElMessage.success('分享设置已保存')
|
ElMessage.success('分享设置已保存')
|
||||||
emit('saved', updated)
|
emit('saved', updated || [])
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
} catch {
|
} catch {
|
||||||
/* 请求层统一展示错误 */
|
/* 请求层统一展示错误 */
|
||||||
|
|||||||
@@ -44,3 +44,11 @@ test('专用上传请求提交原文件及目标目录,清空分享提交空
|
|||||||
assert.equal(calls[1].method, 'put')
|
assert.equal(calls[1].method, 'put')
|
||||||
assert.deepEqual(calls[1].config.data, { userIds: [] })
|
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 })
|
return ok({ list, total: list.length })
|
||||||
}
|
}
|
||||||
if (path === '/web/doclib/users') return ok([{ id: 'other', name: '同事', deptName: '生产部' }])
|
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')) {
|
if (path.endsWith('/shares')) {
|
||||||
sharedUsers = req.postDataJSON().userIds
|
sharedUsers = req.postDataJSON().userIds
|
||||||
file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' }))
|
file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' }))
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
:disabled="!actions.share || loading || busy"
|
:disabled="!actions.share || loading || busy"
|
||||||
@click="openShare(selected[0])"
|
@click="openShare(selected)"
|
||||||
><el-icon><Share /></el-icon>设置分享</el-button
|
><el-icon><Share /></el-icon>设置分享</el-button
|
||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
@download="download([$event])"
|
@download="download([$event])"
|
||||||
@view="openDetail"
|
@view="openDetail"
|
||||||
/>
|
/>
|
||||||
<ShareDialog v-model="shareVisible" :document="shareDocument" @saved="shareSaved" />
|
<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="folderVisible"
|
v-model="folderVisible"
|
||||||
:title="renameMode ? '重命名文件夹' : '新建文件夹'"
|
:title="renameMode ? '重命名文件夹' : '新建文件夹'"
|
||||||
@@ -489,14 +489,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const shareVisible = ref(false)
|
const shareVisible = ref(false)
|
||||||
const shareDocument = ref<DoclibDocument | null>(null)
|
const shareDocuments = ref<DoclibDocument[]>([])
|
||||||
function openShare(row: DoclibDocument) {
|
function openShare(rows: DoclibDocument | DoclibDocument[] | null | undefined) {
|
||||||
if (!row?.canManage) return
|
const list = (Array.isArray(rows) ? rows : rows ? [rows] : []).filter(
|
||||||
shareDocument.value = row
|
(row): row is DoclibDocument => !!row?.id && row.canManage
|
||||||
|
)
|
||||||
|
if (!list.length) return
|
||||||
|
shareDocuments.value = list
|
||||||
shareVisible.value = true
|
shareVisible.value = true
|
||||||
}
|
}
|
||||||
function shareSaved(row: DoclibDocument) {
|
function shareSaved(rows: DoclibDocument[]) {
|
||||||
if (detail.value?.id === row.id) detail.value = row
|
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()
|
void refresh()
|
||||||
}
|
}
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const node = (id: string, scope: 'mine' | 'shared', children: any[] = []) => ({
|
|||||||
children
|
children
|
||||||
})
|
})
|
||||||
|
|
||||||
test('分享接收人只能下载,混合选择不能批量删除', () => {
|
test('可管理文档可多选分享,混合选择不能分享', () => {
|
||||||
assert.deepEqual(actionsFor([{ canManage: false }]), {
|
assert.deepEqual(actionsFor([{ canManage: false }]), {
|
||||||
share: false,
|
share: false,
|
||||||
download: true,
|
download: true,
|
||||||
@@ -25,10 +25,30 @@ test('分享接收人只能下载,混合选择不能批量删除', () => {
|
|||||||
download: true,
|
download: true,
|
||||||
delete: false
|
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 })
|
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('同一个目录在不同范围有独立树节点,已删除目录不再命中', () => {
|
test('同一个目录在不同范围有独立树节点,已删除目录不再命中', () => {
|
||||||
const mine = node('root', 'mine', [node('child', 'mine')])
|
const mine = node('root', 'mine', [node('child', 'mine')])
|
||||||
const shared = node('root', 'shared')
|
const shared = node('root', 'shared')
|
||||||
|
|||||||
@@ -2,12 +2,29 @@ import type { DoclibFolder } from '@/api/doclib'
|
|||||||
|
|
||||||
export function actionsFor(rows: Array<{ canManage: boolean }>) {
|
export function actionsFor(rows: Array<{ canManage: boolean }>) {
|
||||||
return {
|
return {
|
||||||
share: rows.length === 1 && rows[0].canManage,
|
share: rows.length > 0 && rows.every((row) => row.canManage),
|
||||||
download: rows.length > 0,
|
download: rows.length > 0,
|
||||||
delete: rows.length > 0 && rows.every((row) => row.canManage)
|
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 {
|
export function findFolder(nodes: DoclibFolder[], id: string): DoclibFolder | undefined {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.id === id) return node
|
if (node.id === id) return node
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
<div class="mp-wv-card__no">{{ row.certNo }}</div>
|
<div class="mp-wv-card__no">{{ row.certNo }}</div>
|
||||||
<span
|
<span
|
||||||
class="status-tag mp-cert-kind-tag"
|
class="status-tag mp-cert-kind-tag"
|
||||||
:class="row.certType === 'transport' ? 'status-tag--warning' : 'status-tag--primary'"
|
:class="`status-tag--${certKindMobileTone(row.certType)}`"
|
||||||
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</span
|
>{{ certKindLabel(row.certType) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-tag" :class="`status-tag--${statusTag(row.status)}`">{{
|
<span class="status-tag" :class="`status-tag--${statusTag(row.status)}`">{{
|
||||||
@@ -109,7 +109,14 @@
|
|||||||
import CertConfirmSheet from './CertConfirmSheet.vue'
|
import CertConfirmSheet from './CertConfirmSheet.vue'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import { formatCertQty } from '@/views/pl/quota-cert/model'
|
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 { useDictStore } from '@/store/modules/dict'
|
||||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||||
import { certStatusLabels } from './face-model'
|
import { certStatusLabels } from './face-model'
|
||||||
@@ -209,9 +216,11 @@
|
|||||||
const kinds: CertType[] = props.kind ? [props.kind as CertType] : ['purchase', 'transport']
|
const kinds: CertType[] = props.kind ? [props.kind as CertType] : ['purchase', 'transport']
|
||||||
const lists = await Promise.all(kinds.map((item) => quotaCertApi.list(item, params)))
|
const lists = await Promise.all(kinds.map((item) => quotaCertApi.list(item, params)))
|
||||||
if (current !== epoch) return
|
if (current !== epoch) return
|
||||||
rows.value = kinds.flatMap((item, index) =>
|
rows.value = kinds
|
||||||
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
|
.flatMap((item, index) =>
|
||||||
)
|
lists[index].map((row) => ({ ...row, certType: row.certType || item }))
|
||||||
|
)
|
||||||
|
.sort(compareCertUploadTime)
|
||||||
} catch {
|
} catch {
|
||||||
if (current === epoch) error.value = '证件加载失败,请重试'
|
if (current === epoch) error.value = '证件加载失败,请重试'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -41,7 +41,14 @@
|
|||||||
计划数量 {{ qtyText(group.source.quantity) }} {{ unitText(group.source.unit) }}
|
计划数量 {{ qtyText(group.source.quantity) }} {{ unitText(group.source.unit) }}
|
||||||
</p>
|
</p>
|
||||||
<div v-for="row in group.rows" :key="conversionKey(row)" class="mp-cert-factor">
|
<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
|
<van-field
|
||||||
v-model="coefficients[conversionKey(row)]"
|
v-model="coefficients[conversionKey(row)]"
|
||||||
label="换算系数"
|
label="换算系数"
|
||||||
@@ -91,7 +98,14 @@
|
|||||||
<section v-if="isPerStop && faceCards.length" class="mp-wv-section">
|
<section v-if="isPerStop && faceCards.length" class="mp-wv-section">
|
||||||
<h3>关联证件证面</h3>
|
<h3>关联证件证面</h3>
|
||||||
<article v-for="face in faceCards" :id="faceDomId(face.key)" :key="face.key" class="mp-cert-face-card">
|
<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>
|
<p v-if="!face.cert" class="plan-empty-hint">没有读到这张证的证面档案。</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="(group, groupIndex) in groupsOf(face.cert, face.kind)" :key="groupIndex">
|
<div v-for="(group, groupIndex) in groupsOf(face.cert, face.kind)" :key="groupIndex">
|
||||||
@@ -158,6 +172,7 @@
|
|||||||
sourceLabel,
|
sourceLabel,
|
||||||
unitConversions
|
unitConversions
|
||||||
} from '@/components/business/pl-plan-quota-dialog/stop-model'
|
} 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 { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||||
import { useDictStore } from '@/store/modules/dict'
|
import { useDictStore } from '@/store/modules/dict'
|
||||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||||
@@ -190,7 +205,13 @@
|
|||||||
const sourceOptions = computed(() => plan.value?.sourceOptions || [])
|
const sourceOptions = computed(() => plan.value?.sourceOptions || [])
|
||||||
const faceCards = computed(() => {
|
const faceCards = computed(() => {
|
||||||
const seen = new Set<string>()
|
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) => {
|
const push = (kind: string, id: string, certNo: string) => {
|
||||||
if ((kind !== 'purchase' && kind !== 'transport') || !id || seen.has(`${kind}:${id}`)) return
|
if ((kind !== 'purchase' && kind !== 'transport') || !id || seen.has(`${kind}:${id}`)) return
|
||||||
seen.add(`${kind}:${id}`)
|
seen.add(`${kind}:${id}`)
|
||||||
@@ -200,7 +221,8 @@
|
|||||||
key: `${kind}:${id}`,
|
key: `${kind}:${id}`,
|
||||||
kind,
|
kind,
|
||||||
cert,
|
cert,
|
||||||
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
|
certNo: number,
|
||||||
|
label: `${certKindLabel(kind)} ${number}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const row of plan.value?.stopProducts || []) {
|
for (const row of plan.value?.stopProducts || []) {
|
||||||
|
|||||||
@@ -60,7 +60,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</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">
|
<template v-if="certId">
|
||||||
<h3>适用产品</h3>
|
<h3>适用产品</h3>
|
||||||
@@ -139,6 +146,7 @@
|
|||||||
type QuotaCert,
|
type QuotaCert,
|
||||||
type ScopeProduct
|
type ScopeProduct
|
||||||
} from '@/api/pl/quota-cert'
|
} 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 { bindingPayload, replaceCertProducts } from '@/views/pl/quota-cert/model'
|
||||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||||
import { useDictStore } from '@/store/modules/dict'
|
import { useDictStore } from '@/store/modules/dict'
|
||||||
@@ -178,9 +186,6 @@
|
|||||||
let epoch = 0
|
let epoch = 0
|
||||||
let searchTimer = 0
|
let searchTimer = 0
|
||||||
|
|
||||||
const currentLabel = computed(() =>
|
|
||||||
[certType.value === 'purchase' ? '购买证' : '运输证', certNo.value].filter(Boolean).join(' ')
|
|
||||||
)
|
|
||||||
const pickerRows = computed(() => {
|
const pickerRows = computed(() => {
|
||||||
const used = new Set(draft.value.map((product) => product.contractProductId))
|
const used = new Set(draft.value.map((product) => product.contractProductId))
|
||||||
return lines.value.filter((row) => row.id && !used.has(row.id))
|
return lines.value.filter((row) => row.id && !used.has(row.id))
|
||||||
|
|||||||
@@ -15,9 +15,13 @@
|
|||||||
@click="open(row)"
|
@click="open(row)"
|
||||||
>
|
>
|
||||||
<div class="mp-wv-card__head">
|
<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__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>
|
</div>
|
||||||
<span class="status-tag" :class="`status-tag--${row.status === 'active' ? 'success' : 'info'}`">
|
<span class="status-tag" :class="`status-tag--${row.status === 'active' ? 'success' : 'info'}`">
|
||||||
{{ certListStatus(row.status) }}
|
{{ certListStatus(row.status) }}
|
||||||
@@ -61,7 +65,11 @@
|
|||||||
import { useDictStore } from '@/store/modules/dict'
|
import { useDictStore } from '@/store/modules/dict'
|
||||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||||
import { certListStatus } from './usage-model'
|
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 props = defineProps<{ keyword: string; kind: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -37,6 +37,24 @@
|
|||||||
min-height: calc(100dvh - 132px);
|
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-plan-cert-list .mp-wv-flat-card {
|
||||||
.mp-cert-card__main {
|
.mp-cert-card__main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -46,10 +64,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mp-cert-kind-tag {
|
.mp-cert-kind-tag {
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mp-wv-card__no {
|
.mp-wv-card__no {
|
||||||
|
|||||||
@@ -48,7 +48,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mp-cert-tags">
|
<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'}`">
|
<span class="status-tag" :class="`status-tag--${cert.linkStatus === 'active' ? 'success' : 'info'}`">
|
||||||
{{ linkLabel(cert.linkStatus) }}
|
{{ linkLabel(cert.linkStatus) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -145,7 +149,14 @@
|
|||||||
<div class="mp-wv-card__kv">
|
<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>{{ 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>{{ 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>{{ 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>{{ unit(row.certUnit) || '—' }}</span></div>
|
||||||
<div class="mp-wv-card__kv-row"><span>产品预占</span><span>{{ qty(row.reservedQty) }}</span></div>
|
<div class="mp-wv-card__kv-row"><span>产品预占</span><span>{{ qty(row.reservedQty) }}</span></div>
|
||||||
@@ -188,7 +199,11 @@
|
|||||||
type StatisticsProduct
|
type StatisticsProduct
|
||||||
} from '@/api/pl/quota-cert'
|
} from '@/api/pl/quota-cert'
|
||||||
import { bindingPayload, formatCertQty } from '@/views/pl/quota-cert/model'
|
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 { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
|
||||||
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
|
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
|
||||||
import CertLinkSheet from './CertLinkSheet.vue'
|
import CertLinkSheet from './CertLinkSheet.vue'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ContractStatistics } from '@/api/pl/quota-cert'
|
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'
|
import { statusLabel } from '@/views/pl/quota-statistics/labels'
|
||||||
|
|
||||||
export function certDetailStatus(status?: string | null) {
|
export function certDetailStatus(status?: string | null) {
|
||||||
@@ -19,7 +19,8 @@ export function contractProductRows(detail: ContractStatistics) {
|
|||||||
productName: product.productName || '—',
|
productName: product.productName || '—',
|
||||||
model: product.model || '—',
|
model: product.model || '—',
|
||||||
unit: product.unit,
|
unit: product.unit,
|
||||||
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
|
certType: cert.certType,
|
||||||
|
certKind: certKindLabel(cert.certType),
|
||||||
certNo: cert.certNo || '—',
|
certNo: cert.certNo || '—',
|
||||||
certUnit: cert.quantityUnit,
|
certUnit: cert.quantityUnit,
|
||||||
reservedQty: product.reservedQty,
|
reservedQty: product.reservedQty,
|
||||||
|
|||||||
@@ -9,7 +9,14 @@
|
|||||||
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
|
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<section class="mp-wv-section">
|
<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="detail-field-grid">
|
||||||
<div class="mp-wv-kv"><span>证面品名</span><span>{{ detail.productNameOnCert || '—' }}</span></div>
|
<div class="mp-wv-kv"><span>证面品名</span><span>{{ detail.productNameOnCert || '—' }}</span></div>
|
||||||
<div class="mp-wv-kv">
|
<div class="mp-wv-kv">
|
||||||
@@ -101,6 +108,7 @@
|
|||||||
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
||||||
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
||||||
import { quotaStatisticsApi, type CertificateStatistics, type CertType } from '@/api/pl/quota-cert'
|
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 { formatCertQty } from '@/views/pl/quota-cert/model'
|
||||||
import { useDictStore } from '@/store/modules/dict'
|
import { useDictStore } from '@/store/modules/dict'
|
||||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||||
|
|||||||
@@ -116,6 +116,13 @@
|
|||||||
:readonly="c.props.readonly"
|
:readonly="c.props.readonly"
|
||||||
@update:model-value="c.patchDelivery(index, { notes: $event })"
|
@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
|
<van-cell
|
||||||
class="mp-stop-toggle"
|
class="mp-stop-toggle"
|
||||||
title="生成地磅单"
|
title="生成地磅单"
|
||||||
|
|||||||
@@ -61,6 +61,13 @@
|
|||||||
:readonly="c.props.readonly"
|
:readonly="c.props.readonly"
|
||||||
@update:model-value="c.patch({ notes: $event })"
|
@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"
|
<van-cell class="mp-stop-toggle" title="生成地磅单" :label="c.dibangDirectionLabel"
|
||||||
><template #right-icon
|
><template #right-icon
|
||||||
><van-switch
|
><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()) {
|
export function expiryWarning(validTo?: string | null, today = new Date()) {
|
||||||
const target = calendarDate(validTo)
|
const target = calendarDate(validTo)
|
||||||
if (!target) return null
|
if (!target) return null
|
||||||
|
|||||||
@@ -66,8 +66,8 @@
|
|||||||
><el-tag
|
><el-tag
|
||||||
v-else-if="column.prop === 'certType'"
|
v-else-if="column.prop === 'certType'"
|
||||||
size="small"
|
size="small"
|
||||||
:type="row.certType === 'transport' ? 'warning' : 'success'"
|
:type="certKindTagType(row.certType)"
|
||||||
>{{ row.certType === 'transport' ? '运输证' : '购买证' }}</el-tag
|
>{{ certKindLabel(row.certType) }}</el-tag
|
||||||
><el-tag
|
><el-tag
|
||||||
v-else-if="column.prop === 'status'"
|
v-else-if="column.prop === 'status'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -629,7 +629,14 @@
|
|||||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||||
import CertContractBinder from './CertContractBinder.vue'
|
import CertContractBinder from './CertContractBinder.vue'
|
||||||
import { bindingsFromPurchaseCerts, matchPurchaseCert } from './contract-binder-rules'
|
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 ContractScopeDialog from './ContractScopeDialog.vue'
|
||||||
import SystemViewDialog, {
|
import SystemViewDialog, {
|
||||||
type ViewField,
|
type ViewField,
|
||||||
@@ -1089,11 +1096,7 @@
|
|||||||
rows.value = [
|
rows.value = [
|
||||||
...purchases.map((row) => presentCert(row, 'purchase')),
|
...purchases.map((row) => presentCert(row, 'purchase')),
|
||||||
...transports.map((row) => presentCert(row, 'transport'))
|
...transports.map((row) => presentCert(row, 'transport'))
|
||||||
].sort(
|
].sort(compareCertUploadTime)
|
||||||
(a, b) =>
|
|
||||||
String(b.createTime || '').localeCompare(String(a.createTime || '')) ||
|
|
||||||
String(b.id).localeCompare(String(a.id))
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
if (current === listEpoch && !disposed) loading.value = false
|
if (current === listEpoch && !disposed) loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,10 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</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
|
<CertContractBinder
|
||||||
v-if="ready && certId && contractId"
|
v-if="ready && certId && contractId"
|
||||||
:key="binderKey"
|
:key="binderKey"
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
type ScopeProduct
|
type ScopeProduct
|
||||||
} from '@/api/pl/quota-cert'
|
} from '@/api/pl/quota-cert'
|
||||||
import CertContractBinder from '../quota-cert/CertContractBinder.vue'
|
import CertContractBinder from '../quota-cert/CertContractBinder.vue'
|
||||||
|
import { certKindLabel, certKindTagType } from '../quota-cert/cert-list-face'
|
||||||
import { bindingPayload, replaceCertProducts } from '../quota-cert/model'
|
import { bindingPayload, replaceCertProducts } from '../quota-cert/model'
|
||||||
|
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
@@ -91,10 +95,6 @@
|
|||||||
() =>
|
() =>
|
||||||
`${mode.value}|${certType.value}|${certId.value}|${savedProducts.value.map((row) => row.contractProductId).join(',')}`
|
`${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) {
|
function certOptionLabel(item: QuotaCert) {
|
||||||
const name = [item.certNo, item.productNameOnCert].filter(Boolean).join(' · ')
|
const name = [item.certNo, item.productNameOnCert].filter(Boolean).join(' · ')
|
||||||
if (item.status === 'void') return `${name}(已作废)`
|
if (item.status === 'void') return `${name}(已作废)`
|
||||||
@@ -209,6 +209,10 @@
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.quota-contract-cert-dialog__current {
|
.quota-contract-cert-dialog__current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -6,8 +6,15 @@
|
|||||||
v-for="cert in contract?.certificates || []"
|
v-for="cert in contract?.certificates || []"
|
||||||
:key="certKey(cert)"
|
:key="certKey(cert)"
|
||||||
:name="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">
|
<section class="quota-cert-summary">
|
||||||
<header class="quota-cert-summary__head">
|
<header class="quota-cert-summary__head">
|
||||||
<strong>{{ cert.certNo }}</strong>
|
<strong>{{ cert.certNo }}</strong>
|
||||||
@@ -16,8 +23,8 @@
|
|||||||
有效期:{{ cert.validFrom || '—' }} 至 {{ cert.validTo || '—' }}
|
有效期:{{ cert.validFrom || '—' }} 至 {{ cert.validTo || '—' }}
|
||||||
</span>
|
</span>
|
||||||
<div class="quota-cert-summary__tags">
|
<div class="quota-cert-summary__tags">
|
||||||
<el-tag size="small">{{
|
<el-tag size="small" :type="certKindTagType(cert.certType)">{{
|
||||||
cert.certType === 'purchase' ? '购买证' : '运输证'
|
certKindLabel(cert.certType)
|
||||||
}}</el-tag>
|
}}</el-tag>
|
||||||
<el-tag size="small" :type="cert.linkStatus === 'active' ? 'success' : 'info'">{{
|
<el-tag size="small" :type="cert.linkStatus === 'active' ? 'success' : 'info'">{{
|
||||||
linkLabel(cert.linkStatus)
|
linkLabel(cert.linkStatus)
|
||||||
@@ -90,7 +97,13 @@
|
|||||||
<el-table-column prop="productName" label="产品" min-width="160" />
|
<el-table-column prop="productName" label="产品" min-width="160" />
|
||||||
<el-table-column prop="model" label="规格" min-width="120" />
|
<el-table-column prop="model" label="规格" min-width="120" />
|
||||||
<el-table-column prop="unit" label="产品单位" min-width="100" />
|
<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="certNo" label="证件编号" min-width="160" />
|
||||||
<el-table-column prop="certUnit" label="证面单位" min-width="100" />
|
<el-table-column prop="certUnit" label="证面单位" min-width="100" />
|
||||||
<el-table-column label="产品预占" min-width="120"
|
<el-table-column label="产品预占" min-width="120"
|
||||||
@@ -230,7 +243,12 @@
|
|||||||
import { useDictStore } from '@/store/modules/dict'
|
import { useDictStore } from '@/store/modules/dict'
|
||||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||||
import { formatCertQty } from '../quota-cert/model'
|
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 StatisticsProducts from './StatisticsProducts.vue'
|
||||||
import { statusLabel } from './labels'
|
import { statusLabel } from './labels'
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -252,10 +270,6 @@
|
|||||||
function certKey(cert: { certType: string; certId: string }) {
|
function certKey(cert: { certType: string; certId: string }) {
|
||||||
return `${cert.certType}:${cert.certId}`
|
return `${cert.certType}:${cert.certId}`
|
||||||
}
|
}
|
||||||
function certTabLabel(cert: ContractStatistics['certificates'][number]) {
|
|
||||||
const kind = cert.certType === 'purchase' ? '购买证' : '运输证'
|
|
||||||
return `${kind} ${cert.certNo || ''}`.trim()
|
|
||||||
}
|
|
||||||
watch(
|
watch(
|
||||||
() =>
|
() =>
|
||||||
props.mode === 'contract'
|
props.mode === 'contract'
|
||||||
@@ -282,7 +296,8 @@
|
|||||||
productName: product.productName || '—',
|
productName: product.productName || '—',
|
||||||
model: product.model || '—',
|
model: product.model || '—',
|
||||||
unit: unitLabel(product.unit) || '—',
|
unit: unitLabel(product.unit) || '—',
|
||||||
certKind: cert.certType === 'purchase' ? '购买证' : '运输证',
|
certType: cert.certType,
|
||||||
|
certKind: certKindLabel(cert.certType),
|
||||||
certNo: cert.certNo || '—',
|
certNo: cert.certNo || '—',
|
||||||
certUnit: unitLabel(cert.quantityUnit) || '—',
|
certUnit: unitLabel(cert.quantityUnit) || '—',
|
||||||
reservedQty: product.reservedQty,
|
reservedQty: product.reservedQty,
|
||||||
@@ -475,4 +490,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quota-cert-tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -144,9 +144,11 @@
|
|||||||
/> </div
|
/> </div
|
||||||
></template>
|
></template>
|
||||||
<template #default="{ row }"
|
<template #default="{ row }"
|
||||||
><span v-if="column.prop === 'certType'">{{
|
><el-tag
|
||||||
row.certType === 'transport' ? '运输证' : '购买证'
|
v-if="column.prop === 'certType'"
|
||||||
}}</span
|
size="small"
|
||||||
|
:type="certKindTagType(row.certType)"
|
||||||
|
>{{ certKindLabel(row.certType) }}</el-tag
|
||||||
><el-tag
|
><el-tag
|
||||||
v-else-if="column.prop === 'status'"
|
v-else-if="column.prop === 'status'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -205,7 +207,7 @@
|
|||||||
import StatisticsDetail from './StatisticsDetail.vue'
|
import StatisticsDetail from './StatisticsDetail.vue'
|
||||||
import { createStatisticsLoader } from './loader'
|
import { createStatisticsLoader } from './loader'
|
||||||
import { statusLabel, contractTypeLabel } from './labels'
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
mode: 'contract' | 'certificate'
|
mode: 'contract' | 'certificate'
|
||||||
|
|||||||
Reference in new issue
Block a user