Compare commits

..
Author SHA1 Message Date
zlwuandCursor e2e9059c17 fix(backend): sync protocol void with sample reception discard
When a cell-storage or research-donation protocol is voided, mark all
related sample receptions as discarded; when every sample is discarded,
auto-void the protocol as 实验室废弃 (sysfq). Covers issue #31.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:37:58 +08:00
cykj 65aaaae885 Merge pull request 'fix(web-admin): 样本接收是否接收选项拒收改为废弃' (#39) from codex/issue-27-reject-to-discard-f4470ba481b0 into V2.0
Merge pull request 'fix(web-admin): 样本接收是否接收选项拒收改为废弃' (#39) from codex/issue-27-reject-to-discard-f4470ba481b0 into V2.0
2026-11-05 03:14:35 +00:00
zlwuandCursor 71051461a2 fix(web-admin): 样本接收「是否接收」选项拒收改为废弃
质检样本接收登记弹窗与结论标签文案对齐工单 #27,取值 bj 不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:08:18 +08:00
zlwuandCursor 25c12ad4ff chore(agents): point Figma config to web and mobile design systems
Replace the demo Figma reference with the confirmed web/mobile authority files and status notes so agent UI work follows the current design systems.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:00:53 +08:00
zlwu 5b440df17e Merge origin/V2.0: include list column order fix from #37 2026-11-05 10:55:34 +08:00
cykj 67846493fa Merge pull request 'fix(web-admin): 列表所属项目/项目组列移到序号之后' (#37) from codex/issue-34-list-col-order-20662a540e4e into V2.0
fix(web-admin): 列表所属项目/项目组列移到序号之后 (#37)

Refs: #34
2026-11-05 02:55:07 +00:00
zlwu 81154f4c94 Merge remote-tracking branch 'origin/V2.0' into V2.0 2026-11-05 10:50:41 +08:00
cykj 125398a3f5 Merge pull request 'fix(web): 加宽项目组新增编辑弹窗并调整字段布局' (#36) from codex/issue-33-team-dialog into V2.0 2026-11-05 02:49:53 +00:00
zlwu af730ad050 Merge remote-tracking branch 'origin/V2.0' into codex/issue-33-team-dialog 2026-11-05 10:48:32 +08:00
zlwu 2e21884d65 Merge remote-tracking branch 'origin/V2.0' into V2.0 2026-11-05 10:40:34 +08:00
cykj 6292badf8e Merge pull request 'fix(oa): 审批单 PDF 补齐评论、提及及附件链接' (#35) from codex/issue-30-oa-print-comments into V2.0
User explicitly requested merging issue #30 before user testing. Acceptance remains pending; keep issue open.
2026-11-05 02:40:21 +00:00
zlwu 5a55e9f574 fix(web): widen project team edit dialogs and give multi-select fields full rows 2026-11-05 10:35:08 +08:00
zlwu a511004812 fix(oa): include discussion comments and attachment links in approval PDFs
Merge discussion comments into the existing printed approval flow on Web and mobile OA, preserving detail timeline order and hiding revoked content. Include mentions and absolute attachment URLs, and add PDF link annotations so rasterized exports retain usable links. Add regression tests for ordering, attachments, mentions and revoked comments. Related to Gitea issue #30.
2026-11-05 10:27:39 +08:00
zlwu f1b4a5ce26 fix(web-admin): place project columns after index in lists
Move 所属项目/项目组 after selection and serial-number columns so multi-select and index stay leftmost across affected business lists.
2026-11-05 10:26:59 +08:00
zlwuandCursor 01fef051da chore(cursor): enable the Figma plugin
The screen baseline is drawn in the shared Figma file, so the workspace plugin needs to stay enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 06:45:27 +08:00
cykj 7ac2ff282c Merge pull request 'feat(project): add a standalone presign statistics column' (#22) from codex/issue-13-project-statistics into V2.0 2026-11-04 06:55:22 +00:00
26 changed files with 1220 additions and 52 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}
@@ -53,6 +53,7 @@ _DONATION_STATUS_LABELS = {
_VOID_TYPE_FALLBACK_LABELS = {
"khhc": "客户毁存",
"jcbhg": "检测不合格",
"sysfq": "实验室废弃",
}
# 非法来源状态视为终态,不允许再转出
+14
View File
@@ -2642,6 +2642,7 @@ def update_collection_protocol(
source_presign_order_id: Optional[str] = None,
remark: Optional[str] = None,
commit: bool = True,
sync_reception_discard: bool = True,
) -> BizCollectionProtocol:
obj = get_collection_protocol(
db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id
@@ -2650,6 +2651,7 @@ def update_collection_protocol(
raise ValueError("协议单不存在或无权操作")
from_status = (obj.protocol_status or "").strip()
status_log_payload: Optional[Dict[str, Any]] = None
voided_now = False
if protocol_no is not None:
pn = protocol_no.strip()
if pn != (obj.protocol_no or ""):
@@ -2660,6 +2662,8 @@ def update_collection_protocol(
next_status = protocol_status.strip()
assert_protocol_status_transition(obj.protocol_status or "", next_status, kind="protocol")
obj.protocol_status = next_status
if next_status == "yzf" and from_status != "yzf":
voided_now = True
if next_status == "yzf" and void_time is not None:
if void_type is None or not str(void_type).strip():
raise ValueError("请选择作废类型")
@@ -2749,6 +2753,16 @@ def update_collection_protocol(
tenant_no=tenant_no,
user_id=user_id,
)
if voided_now and sync_reception_discard:
from app.services.protocol_reception_discard_sync import after_protocol_voided
after_protocol_voided(
db,
source="cell_storage",
protocol_id=obj.id,
tenant_no=tenant_no,
user_id=user_id,
)
if commit:
db.commit()
db.refresh(obj)
+10
View File
@@ -1686,6 +1686,16 @@ def change_status(
)
db.add(obj)
if ns == "yzf" and (current or "").strip() != "yzf":
from app.services.protocol_reception_discard_sync import after_protocol_voided
after_protocol_voided(
db,
source="research_donation",
protocol_id=obj.id,
tenant_no=tenant_no,
user_id=user_id,
)
db.commit()
db.refresh(obj)
return obj
+12
View File
@@ -1033,6 +1033,12 @@ def create_sample_reception(
user_id=user_id,
create_dept_id=create_dept_id or "",
)
if conclusion == "bj":
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
after_sample_reception_saved(
db, reception=obj, tenant_no=tenant_no, user_id=user_id
)
db.commit()
db.refresh(obj)
return obj
@@ -1151,6 +1157,12 @@ def update_sample_reception(
)
check_result_svc.sync_legacy_columns_from_results(db, rec, saved_rows)
db.add(rec)
if conclusion == "bj":
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
after_sample_reception_saved(
db, reception=rec, tenant_no=tenant_no, user_id=user_id
)
db.commit()
db.refresh(rec)
return rec
@@ -0,0 +1,421 @@
"""市场协议作废 ↔ 质检样本接收废弃双向联动。
规则(细胞存储 / 科研捐赠):
1. 协议变为已作废(yzf)时,协议下全部样本接收结论自动为废弃(bj)。
2. 协议下全部有效样本均已废弃/拒收(bj)时,协议自动已作废,作废类型为「实验室废弃」。
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizCollectionProtocol,
BizProtocolSample,
BizSampleReception,
SysDictData,
)
from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
LAB_VOID_TYPE_CODE = "sysfq"
LAB_VOID_TYPE_LABEL = "实验室废弃"
VOID_TYPE_DICT = "xyzflx"
_AUTO_DISCARD_REMARK = "协议作废自动废弃"
_AUTO_VOID_DESC = "全部样本接收废弃/拒收,系统自动作废"
def ensure_lab_void_type(db: Session, *, tenant_no: str) -> str:
"""确保租户字典 xyzflx 存在「实验室废弃」,返回 dict_value。"""
tenant = (tenant_no or "").strip()
if not tenant:
raise ValueError("租户编号不能为空")
by_value = (
db.query(SysDictData)
.filter(
SysDictData.dict_type == VOID_TYPE_DICT,
SysDictData.dict_value == LAB_VOID_TYPE_CODE,
SysDictData.tenant_no == tenant,
SysDictData.del_flag == 0,
)
.first()
)
if by_value:
return LAB_VOID_TYPE_CODE
by_label = (
db.query(SysDictData)
.filter(
SysDictData.dict_type == VOID_TYPE_DICT,
SysDictData.dict_label == LAB_VOID_TYPE_LABEL,
SysDictData.tenant_no == tenant,
SysDictData.del_flag == 0,
)
.first()
)
if by_label and (by_label.dict_value or "").strip():
return (by_label.dict_value or "").strip()
now = datetime.now()
db.add(
SysDictData(
id=generate_uuid(),
dict_label=LAB_VOID_TYPE_LABEL,
dict_value=LAB_VOID_TYPE_CODE,
dict_type=VOID_TYPE_DICT,
tenant_no=tenant,
order_num=90,
remark="issue#31 实验室废弃自动作废类型",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.flush()
return LAB_VOID_TYPE_CODE
def _list_cell_samples(db: Session, *, protocol_id: str, tenant_no: str) -> List[BizProtocolSample]:
return (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.collection_protocol_id == protocol_id,
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
)
.order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
.all()
)
def _list_donation_samples(
db: Session, *, donation_id: str, tenant_no: str
) -> List[BizResearchDonationSample]:
return (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.research_donation_id == donation_id,
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
)
.order_by(
BizResearchDonationSample.order_num.asc(),
BizResearchDonationSample.create_time.asc(),
)
.all()
)
def _find_reception(
db: Session,
*,
tenant_no: str,
source: str,
sample_code: str,
) -> Optional[BizSampleReception]:
code = (sample_code or "").strip()
if not code:
return None
return (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
BizSampleReception.reception_source == source,
BizSampleReception.sample_code == code,
)
.first()
)
def _upsert_discard_reception(
db: Session,
*,
source: str,
tenant_no: str,
user_id: Optional[str],
sample_code: str,
protocol_sample_id: str = "",
collection_protocol_id: str = "",
donation_sample_id: Optional[str] = None,
research_donation_id: Optional[str] = None,
now: Optional[datetime] = None,
) -> None:
code = (sample_code or "").strip()
if not code:
return
ts = now or datetime.now()
rec = _find_reception(db, tenant_no=tenant_no, source=source, sample_code=code)
if rec:
if (rec.accept_conclusion or "").strip() == "bj":
return
rec.accept_conclusion = "bj"
rec.update_by = user_id
rec.update_time = ts
if not (rec.remark or "").strip():
rec.remark = _AUTO_DISCARD_REMARK
db.add(rec)
return
obj = BizSampleReception(
id=generate_uuid(),
protocol_sample_id=protocol_sample_id or "",
collection_protocol_id=collection_protocol_id or "",
reception_time=ts,
handler_user_id=user_id,
handler_name=None,
receive_temperature=None,
accept_conclusion="bj",
receive_attachments=None,
reception_source=source,
donation_sample_id=donation_sample_id,
research_donation_id=research_donation_id,
sample_code=code,
lab_storage_code=None,
tenant_no=tenant_no,
remark=_AUTO_DISCARD_REMARK,
create_by=user_id,
create_dept_id="",
update_by=user_id,
create_time=ts,
update_time=ts,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
db.add(obj)
def discard_receptions_for_voided_protocol(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> int:
"""协议已作废后,将其下样本接收全部置为废弃。返回处理样本数。"""
src = (source or "").strip()
pid = (protocol_id or "").strip()
tenant = (tenant_no or "").strip()
if src not in ("cell_storage", "research_donation") or not pid or not tenant:
return 0
now = datetime.now()
if src == "cell_storage":
samples = _list_cell_samples(db, protocol_id=pid, tenant_no=tenant)
for sample in samples:
_upsert_discard_reception(
db,
source=src,
tenant_no=tenant,
user_id=user_id,
sample_code=sample.sample_code or "",
protocol_sample_id=sample.id,
collection_protocol_id=pid,
now=now,
)
else:
samples = _list_donation_samples(db, donation_id=pid, tenant_no=tenant)
for sample in samples:
_upsert_discard_reception(
db,
source=src,
tenant_no=tenant,
user_id=user_id,
sample_code=sample.sample_code or "",
donation_sample_id=sample.id,
research_donation_id=pid,
now=now,
)
db.flush()
return len(samples)
def _all_samples_discarded(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
) -> bool:
src = (source or "").strip()
if src == "cell_storage":
samples = _list_cell_samples(db, protocol_id=protocol_id, tenant_no=tenant_no)
else:
samples = _list_donation_samples(db, donation_id=protocol_id, tenant_no=tenant_no)
if not samples:
return False
for sample in samples:
code = (sample.sample_code or "").strip()
if not code:
return False
rec = _find_reception(db, tenant_no=tenant_no, source=src, sample_code=code)
if not rec or (rec.accept_conclusion or "").strip() != "bj":
return False
return True
def maybe_void_protocol_when_all_samples_discarded(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> bool:
"""若协议下样本全部废弃,则自动作废协议(作废类型=实验室废弃)。返回是否执行了作废。"""
src = (source or "").strip()
pid = (protocol_id or "").strip()
tenant = (tenant_no or "").strip()
if src not in ("cell_storage", "research_donation") or not pid or not tenant:
return False
if not _all_samples_discarded(db, source=src, protocol_id=pid, tenant_no=tenant):
return False
void_type = ensure_lab_void_type(db, tenant_no=tenant)
now = datetime.now()
if src == "cell_storage":
obj = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == pid,
BizCollectionProtocol.tenant_no == tenant,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if not obj:
return False
status = (obj.protocol_status or "").strip()
if status in ("yzf", "sdfq"):
return False
from app.core.protocol_status_display import assert_protocol_status_transition
from app.crud.collection_protocol import update_collection_protocol
try:
assert_protocol_status_transition(status, "yzf", kind="protocol")
except ValueError:
return False
update_collection_protocol(
db,
pid,
tenant_no=tenant,
user_id=user_id,
protocol_status="yzf",
void_time=now,
void_desc=_AUTO_VOID_DESC,
void_type=void_type,
commit=False,
sync_reception_discard=False,
)
return True
obj = (
db.query(BizResearchDonation)
.filter(
BizResearchDonation.id == pid,
BizResearchDonation.tenant_no == tenant,
BizResearchDonation.del_flag == 0,
)
.first()
)
if not obj:
return False
status = (obj.donation_status or "").strip()
if status in ("yzf", "sdfq"):
return False
# 捐赠 change_status 默认 commit;此处内联等价逻辑避免提前提交。
from app.core.protocol_status_display import assert_protocol_status_transition
from app.crud.dict_data import normalize_void_type
from app.crud.research_donation import (
_DONATION_STATUS_CHANGE_TYPE,
append_donation_status_change,
)
current = obj.donation_status or ""
try:
assert_protocol_status_transition(current, "yzf", kind="donation")
except ValueError:
return False
normalized = normalize_void_type(db, value=void_type, tenant_no=tenant)
obj.donation_status = "yzf"
obj.void_time = now
obj.void_desc = _AUTO_VOID_DESC
obj.void_type = normalized
obj.update_by = user_id
obj.update_time = now
change_type = _DONATION_STATUS_CHANGE_TYPE.get("yzf")
if change_type:
append_donation_status_change(
db,
research_donation_id=obj.id,
change_type=change_type,
from_status=current,
to_status="yzf",
change_time=now,
change_desc=_AUTO_VOID_DESC,
void_type=normalized,
tenant_no=tenant,
user_id=user_id,
)
db.add(obj)
db.flush()
return True
def after_sample_reception_saved(
db: Session,
*,
reception: BizSampleReception,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
"""样本接收保存后:若结论为废弃,尝试自动作废所属协议。"""
if (reception.accept_conclusion or "").strip() != "bj":
return
source = (reception.reception_source or "cell_storage").strip() or "cell_storage"
if source == "cell_storage":
protocol_id = (reception.collection_protocol_id or "").strip()
else:
protocol_id = (reception.research_donation_id or "").strip()
if not protocol_id:
return
maybe_void_protocol_when_all_samples_discarded(
db,
source=source,
protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
)
def after_protocol_voided(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
"""协议作废后:同步废弃其下全部样本接收。"""
discard_receptions_for_voided_protocol(
db,
source=source,
protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
)
@@ -0,0 +1,328 @@
"""协议作废 ↔ 样本接收废弃双向联动。"""
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.models.models import (
BizCollectionProtocol,
BizProtocolSample,
BizProtocolStatusChange,
BizSampleReception,
SysDictData,
)
from app.models.research_donation import (
BizDonationStatusChange,
BizResearchDonation,
BizResearchDonationSample,
)
from app.services.protocol_reception_discard_sync import (
LAB_VOID_TYPE_CODE,
after_protocol_voided,
after_sample_reception_saved,
ensure_lab_void_type,
)
def _session():
engine = create_engine("sqlite://")
Base.metadata.create_all(
bind=engine,
tables=[
SysDictData.__table__,
BizCollectionProtocol.__table__,
BizProtocolSample.__table__,
BizProtocolStatusChange.__table__,
BizSampleReception.__table__,
BizResearchDonation.__table__,
BizResearchDonationSample.__table__,
BizDonationStatusChange.__table__,
],
)
return sessionmaker(bind=engine)()
def _now():
return datetime(2026, 11, 5, 12, 0, 0)
class TestProtocolReceptionDiscardSync(unittest.TestCase):
def test_ensure_lab_void_type_creates_dict_row(self):
db = _session()
code = ensure_lab_void_type(db, tenant_no="tenant-a")
self.assertEqual(code, LAB_VOID_TYPE_CODE)
row = (
db.query(SysDictData)
.filter(
SysDictData.tenant_no == "tenant-a",
SysDictData.dict_type == "xyzflx",
SysDictData.dict_value == LAB_VOID_TYPE_CODE,
)
.one()
)
self.assertEqual(row.dict_label, "实验室废弃")
# idempotent
self.assertEqual(ensure_lab_void_type(db, tenant_no="tenant-a"), LAB_VOID_TYPE_CODE)
def test_protocol_void_marks_all_sample_receptions_discarded(self):
db = _session()
now = _now()
db.add(
BizCollectionProtocol(
id="p1",
protocol_no="CP-1",
protocol_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProtocolSample(
id="s1",
collection_protocol_id="p1",
sample_code="ZK001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProtocolSample(
id="s2",
collection_protocol_id="p1",
sample_code="ZK002",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizSampleReception(
id="r1",
protocol_sample_id="s1",
collection_protocol_id="p1",
reception_time=now,
accept_conclusion="js",
reception_source="cell_storage",
sample_code="ZK001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
)
db.commit()
after_protocol_voided(
db,
source="cell_storage",
protocol_id="p1",
tenant_no="tenant-a",
user_id="u1",
)
db.commit()
rows = (
db.query(BizSampleReception)
.filter(BizSampleReception.tenant_no == "tenant-a", BizSampleReception.del_flag == 0)
.all()
)
self.assertEqual(len(rows), 2)
by_code = {r.sample_code: r.accept_conclusion for r in rows}
self.assertEqual(by_code["ZK001"], "bj")
self.assertEqual(by_code["ZK002"], "bj")
def test_all_samples_discarded_auto_voids_protocol_with_lab_type(self):
db = _session()
now = _now()
db.add(
BizCollectionProtocol(
id="p2",
protocol_no="CP-2",
protocol_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProtocolSample(
id="s3",
collection_protocol_id="p2",
sample_code="ZK003",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
rec = BizSampleReception(
id="r2",
protocol_sample_id="s3",
collection_protocol_id="p2",
reception_time=now,
accept_conclusion="bj",
reception_source="cell_storage",
sample_code="ZK003",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
db.add(rec)
db.commit()
after_sample_reception_saved(
db, reception=rec, tenant_no="tenant-a", user_id="u1"
)
db.commit()
proto = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p2").one()
self.assertEqual(proto.protocol_status, "yzf")
self.assertEqual(proto.void_type, LAB_VOID_TYPE_CODE)
self.assertIn("全部样本接收", proto.void_desc or "")
def test_partial_discard_does_not_void_protocol(self):
db = _session()
now = _now()
db.add(
BizCollectionProtocol(
id="p3",
protocol_no="CP-3",
protocol_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
for sid, code in (("s4", "ZK004"), ("s5", "ZK005")):
db.add(
BizProtocolSample(
id=sid,
collection_protocol_id="p3",
sample_code=code,
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
rec = BizSampleReception(
id="r3",
protocol_sample_id="s4",
collection_protocol_id="p3",
reception_time=now,
accept_conclusion="bj",
reception_source="cell_storage",
sample_code="ZK004",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
db.add(rec)
db.commit()
after_sample_reception_saved(
db, reception=rec, tenant_no="tenant-a", user_id="u1"
)
db.commit()
proto = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p3").one()
self.assertEqual(proto.protocol_status, "sxz")
self.assertFalse(proto.void_type)
def test_donation_void_creates_discard_receptions(self):
db = _session()
now = _now()
db.add(
BizResearchDonation(
id="d1",
donation_no="RD-1",
donation_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizResearchDonationSample(
id="ds1",
research_donation_id="d1",
sample_code="RD001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.commit()
after_protocol_voided(
db,
source="research_donation",
protocol_id="d1",
tenant_no="tenant-a",
user_id="u1",
)
db.commit()
rec = (
db.query(BizSampleReception)
.filter(
BizSampleReception.sample_code == "RD001",
BizSampleReception.reception_source == "research_donation",
)
.one()
)
self.assertEqual(rec.accept_conclusion, "bj")
self.assertEqual(rec.research_donation_id, "d1")
if __name__ == "__main__":
unittest.main()
+10 -2
View File
@@ -60,9 +60,17 @@
},
"figma": {
"references": [
"https://www.figma.com/design/jc7D8Fe7lKNTVj4jM3AoGW?node-id=2-2"
"https://www.figma.com/design/M9XtXqXRCAKccMWD7BivIL",
"https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB"
],
"status": "user-confirmed-demo-only"
"status": "web-and-mobile-design-systems-v1",
"authority": {
"web-admin": "https://www.figma.com/design/M9XtXqXRCAKccMWD7BivIL",
"mobile-h5": "https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB",
"mobile-oa-h5": "https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB"
},
"archive": null,
"notes": "Web 用 Fresh Mint(#10b981);移动端用独立 Mobile Design System(规范品牌绿 #0a9f6d),禁止混色。旧整页镜像文件已删除。Figma 色板按规范,不以 styles/tokens.scss 的 #22c55e 覆盖。已补高频状态:Web StateBlock/FormAlert(P01 旁展示);Mobile Empty/ConfirmDialog/WebViewState。"
},
"delivery": {
"provider": "drone",
@@ -0,0 +1,32 @@
-- Issue #31:作废类型字典补充「实验室废弃」(sysfq)
-- 为已有 xyzflx 字典的租户补齐;可重复执行。
INSERT INTO sys_dict_data (
id, dict_label, dict_value, dict_type, tenant_no, order_num, remark,
create_dept_id, create_time, update_time, del_flag
)
SELECT
MD5(CONCAT(t.tenant_no, ':xyzflx:sysfq')),
'实验室废弃',
'sysfq',
'xyzflx',
t.tenant_no,
90,
'issue#31 实验室废弃',
'',
NOW(),
NOW(),
0
FROM (
SELECT DISTINCT tenant_no
FROM sys_dict_data
WHERE dict_type = 'xyzflx' AND del_flag = 0
) t
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data existing
WHERE existing.tenant_no = t.tenant_no
AND existing.dict_type = 'xyzflx'
AND existing.dict_value = 'sysfq'
AND existing.del_flag = 0
);
@@ -1,3 +1,4 @@
import { attachmentFullUrl } from '../../utils/attachment.js'
/**
* OA 审批单移动端打印(S6)
* 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;支持 html2canvas + jsPDF 导出。
@@ -66,7 +67,6 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;')
}
function formatDateOnly(createTime) {
const raw = String(createTime || '').trim()
if (!raw) return '-'
@@ -99,10 +99,12 @@ function formatScalar(value, sep = '、') {
return String(value)
}
if (Array.isArray(value)) {
return value
.map((x) => formatScalar(x, sep))
.filter((x) => x && x !== '-')
.join(sep) || '-'
return (
value
.map((x) => formatScalar(x, sep))
.filter((x) => x && x !== '-')
.join(sep) || '-'
)
}
if (typeof value === 'object') {
if (value.name != null) return String(value.name)
@@ -397,7 +399,13 @@ export function buildOaPrintModel(detail, opts = {}) {
stampClass,
stampText,
rows: [...headerRows, ...buildFormRows(detail)],
flowItems: buildFlowItems(detail),
flowItems: buildFlowItems(detail, (url) => {
try {
return new URL(attachmentFullUrl(url), window.location.origin).href
} catch {
return ''
}
}),
printTime,
printerName,
watermarkName: printerName === '-' ? '审批' : printerName
@@ -435,6 +443,9 @@ function flowCellHtml(f) {
} else if (f.comment) {
html += `<div class="oa-print-flow__comment">意见:${escapeHtml(f.comment)}</div>`
}
for (const url of f.attachmentLinks || []) {
html += `<div class="oa-print-url"><a data-oa-print-link href="${escapeHtml(url)}">${escapeHtml(url)}</a></div>`
}
return html
}
@@ -472,9 +483,10 @@ function buildPrintStylesContent() {
line-height: 1.5; word-break: break-all; }
.oa-print-table th { width: 118px; font-weight: 400; text-align: left; background: #fff; }
.oa-print-text { white-space: pre-wrap; }
.oa-print-url a { color: inherit; }
.oa-print-url { color: #3370ff; word-break: break-all; }
.oa-print-flow__row { display: flex; justify-content: space-between; gap: 12px; }
.oa-print-flow__left { flex: 1; min-width: 0; }
.oa-print-flow__left { white-space: pre-wrap; overflow-wrap: anywhere; flex: 1; min-width: 0; }
.oa-print-flow__right { flex: 0 0 auto; color: #646464; white-space: nowrap; }
.oa-print-flow__person-lines { margin-top: 4px; padding-left: 8px; }
.oa-print-flow__person-line { margin-top: 2px; font-size: 12px; }
@@ -602,7 +614,7 @@ function renderPageLabelCanvas(text) {
return c
}
async function canvasToPdfBlob(canvas) {
async function canvasToPdfBlob(canvas, sheet) {
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
@@ -620,6 +632,23 @@ async function canvasToPdfBlob(canvas) {
const position = -pageIndex * pageHeight
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
// html2canvas 只保留像素,单独把附件链接写为 PDF 注解;跨页部分按页裁切。
const sheetRect = sheet.getBoundingClientRect()
const ratio = pageWidth / sheetRect.width
for (const link of sheet.querySelectorAll('a[data-oa-print-link]')) {
if (!/^https?:\/\//i.test(link.href)) continue
for (const rect of link.getClientRects()) {
const top = (rect.top - sheetRect.top) * ratio - pageIndex * pageHeight
const bottom = Math.min(pageHeight, top + rect.height * ratio)
const y = Math.max(0, top)
if (bottom > y) {
pdf.link((rect.left - sheetRect.left) * ratio, y, rect.width * ratio, bottom - y, {
url: link.href
})
}
}
}
const label = `第${pageIndex + 1}页`
const labelCanvas = renderPageLabelCanvas(label)
if (labelCanvas.width > 1) {
@@ -664,7 +693,7 @@ export async function generateOaApprovalPdfBlob(detail, opts = {}) {
windowWidth: el.scrollWidth,
windowHeight: el.scrollHeight
})
const blob = await canvasToPdfBlob(canvas)
const blob = await canvasToPdfBlob(canvas, el)
return { blob, filename }
} finally {
host.remove()
@@ -0,0 +1,95 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildFlowItems } from './oa-print-flow.js'
describe('打印讨论评论', () => {
it('按时间展示正文、提及和附件,并排在待审节点前', () => {
const items = buildFlowItems({
progress: [
{
kind: 'submit',
title: '发起人',
status: 'submitted',
timestamp: '2026-11-01 10:00:00'
},
{
kind: 'pending_node',
title: '审批人',
status: 'pending',
timestamp: '2026-11-01 10:00:00'
}
],
comments: [
{
id: '2',
createByName: '李四',
createTime: '2026-11-01 12:00:00',
content: '',
attachmentRefs: [{ url: 'https://example.com/file.pdf', fileName: '附件.pdf' }]
},
{
id: '1',
createByName: '张三',
createTime: '2026-11-01 11:00:00',
content: '@李四 请处理\n第二行',
mentions: [{ userId: 'u2', userName: '李四' }]
}
]
})
assert.equal(items.length, 4)
assert.equal(items[1].left, '【评论】 张三')
assert.equal(items[1].right, '2026-11-01 11:00:00')
assert.deepEqual(items[1].personLines, [
{ left: '@李四 请处理\n第二行', right: '' },
{ left: '提到:@李四', right: '' }
])
assert.deepEqual(items[2].attachmentLinks, ['https://example.com/file.pdf'])
assert.match(items[3].left, /待处理/)
})
it('撤回评论不泄露正文、提及和附件', () => {
const [item] = buildFlowItems({
comments: [
{
id: '1',
createBy: 'u1',
status: 'revoked',
content: 'secret',
mentions: [{ userName: 'secret' }],
attachmentRefs: [{ url: 'https://example.com/secret' }]
}
]
})
assert.equal(item.left, '【评论】 u1 已撤回')
assert.equal(JSON.stringify(item).includes('secret'), false)
})
it('安全过滤附件协议,保留纯附件与纯提及评论', () => {
const items = buildFlowItems({
comments: [
{
id: '1',
mentions: [{ userId: 'u1' }],
attachmentRefs: [{ url: 'javascript:alert(1)' }, { url: 'https://example.com/a?x=1&y=2' }]
}
]
})
assert.deepEqual(items[0].attachmentLinks, ['https://example.com/a?x=1&y=2'])
assert.deepEqual(items[0].personLines, [{ left: '提到:@u1', right: '' }])
})
})
it('同秒节点使用详情页的稳定排序键', () => {
const progress = Array.from({ length: 12 }, (_, index) => ({
kind: 'task',
nodeKey: `node-${String(11 - index).padStart(2, '0')}`,
title: `节点${11 - index}`,
status: 'approved',
timestamp: '2026-11-01 10:00:00'
}))
const items = buildFlowItems({
progress,
comments: [{ id: 'comment', content: '讨论', createTime: '2026-11-01 10:00:00' }]
})
assert.equal(items[0].left, '【节点0】 已同意')
assert.equal(items[11].left, '【节点11】 已同意')
assert.equal(items[12].left, '【评论】 评论')
})
+46 -8
View File
@@ -1,3 +1,4 @@
import { mergeProgressWithComments } from '../oa-progress-merge-comments.js'
/**
* 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测)
*/
@@ -37,16 +38,22 @@ function isCcProgressItem(p) {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title)
}
export function buildFlowItems(detail) {
const list = Array.isArray(detail?.progress) ? detail.progress : []
export function buildFlowItems(detail, resolveUrl = (url) => url) {
const progress = Array.isArray(detail?.progress) ? detail.progress : []
// 沿用详情页的时间顺序;无讨论时保持原有审批节点顺序。
const list =
Array.isArray(detail?.comments) && detail.comments.length
? mergeProgressWithComments(
progress.map((p, index) => ({
...p,
key: `${p.kind}-${p.nodeKey || p.taskId || index}-${p.roundNo || 1}`
})),
detail.comments
)
: progress
const items = []
let ccBuf = null
const flushCc = () => {
@@ -70,6 +77,37 @@ export function buildFlowItems(detail) {
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (kind === 'comment') {
flushCc()
const revoked = p.status === 'revoked'
const author = String(p.assignee || p.title || '评论').trim()
const item = {
left: `【评论】 ${author}${revoked ? ' 已撤回' : ''}`,
right: ts
}
if (!revoked) {
const lines = []
if (comment) lines.push({ left: comment, right: '' })
const mentions = (p.mentions || [])
.map((m) => String(m.userName || m.userId || '').trim())
.filter(Boolean)
if (mentions.length)
lines.push({
left: `提到:${mentions.map((name) => `@${name}`).join('、')}`,
right: ''
})
if (lines.length) item.personLines = lines
const links = (p.attachmentRefs || [])
.map((file) => String(file.url || '').trim())
.filter((url) => url && !/^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(url))
.map(resolveUrl)
.filter((url) => /^https?:\/\//i.test(url))
if (links.length) item.attachmentLinks = links
}
items.push(item)
continue
}
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
@@ -86,6 +86,9 @@
<div v-else-if="f.comment" class="oa-print-flow__comment">
意见:{{ f.comment }}
</div>
<div v-for="url in f.attachmentLinks" :key="url" class="oa-print-url">
<a :href="url" data-oa-print-link>{{ url }}</a>
</div>
</td>
</tr>
</template>
@@ -221,6 +224,10 @@
background: #fff;
}
.oa-print-url a {
color: inherit;
}
.oa-print-url {
color: #3370ff;
word-break: break-all;
@@ -248,6 +255,8 @@
}
.oa-print-flow__left {
white-space: pre-wrap;
overflow-wrap: anywhere;
flex: 1 1 auto;
min-width: 0;
}
@@ -0,0 +1,90 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildFlowItems } from './oa-approval-print-flow'
describe('打印讨论评论', () => {
it('按时间展示正文、提及和附件,并排在待审节点前', () => {
const items = buildFlowItems({
progress: [
{ kind: 'submit', title: '发起人', status: 'submitted', timestamp: '2026-11-01 10:00:00' },
{
kind: 'pending_node',
title: '审批人',
status: 'pending',
timestamp: '2026-11-01 10:00:00'
}
],
comments: [
{
id: '2',
createByName: '李四',
createTime: '2026-11-01 12:00:00',
content: '',
attachmentRefs: [{ url: 'https://example.com/file.pdf', fileName: '附件.pdf' }]
},
{
id: '1',
createByName: '张三',
createTime: '2026-11-01 11:00:00',
content: '@李四 请处理\n第二行',
mentions: [{ userId: 'u2', userName: '李四' }]
}
]
})
assert.equal(items.length, 4)
assert.equal(items[1].left, '【评论】 张三')
assert.equal(items[1].right, '2026-11-01 11:00:00')
assert.deepEqual(items[1].personLines, [
{ left: '@李四 请处理\n第二行', right: '' },
{ left: '提到:@李四', right: '' }
])
assert.deepEqual(items[2].attachmentLinks, ['https://example.com/file.pdf'])
assert.match(items[3].left, /待处理/)
})
it('撤回评论不泄露正文、提及和附件', () => {
const [item] = buildFlowItems({
comments: [
{
id: '1',
createBy: 'u1',
status: 'revoked',
content: 'secret',
mentions: [{ userName: 'secret' }],
attachmentRefs: [{ url: 'https://example.com/secret' }]
}
]
})
assert.equal(item.left, '【评论】 u1 已撤回')
assert.equal(JSON.stringify(item).includes('secret'), false)
})
it('安全过滤附件协议,保留纯附件与纯提及评论', () => {
const items = buildFlowItems({
comments: [
{
id: '1',
mentions: [{ userId: 'u1' }],
attachmentRefs: [{ url: 'javascript:alert(1)' }, { url: 'https://example.com/a?x=1&y=2' }]
}
]
})
assert.deepEqual(items[0].attachmentLinks, ['https://example.com/a?x=1&y=2'])
assert.deepEqual(items[0].personLines, [{ left: '提到:@u1', right: '' }])
})
})
it('同秒节点使用详情页的稳定排序键', () => {
const progress = Array.from({ length: 12 }, (_, index) => ({
kind: 'task',
nodeKey: `node-${String(11 - index).padStart(2, '0')}`,
title: `节点${11 - index}`,
status: 'approved',
timestamp: '2026-11-01 10:00:00'
}))
const items = buildFlowItems({
progress,
comments: [{ id: 'comment', content: '讨论', createTime: '2026-11-01 10:00:00' }]
})
assert.equal(items[0].left, '【节点0】 已同意')
assert.equal(items[11].left, '【节点11】 已同意')
assert.equal(items[12].left, '【评论】 评论')
})
+50 -8
View File
@@ -1,3 +1,4 @@
import { mergeProgressWithComments } from './oa-progress-merge-comments'
/**
* OA 打印「审批流程」行组装(无 Vue/canvas 副作用,便于单测)
*/
@@ -11,6 +12,7 @@ export type OaPrintPersonLine = {
export type OaPrintFlowItem = {
left: string
right: string
attachmentLinks?: string[]
/** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
comment?: string
/** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */
@@ -66,20 +68,29 @@ function isCcProgressItem(p: any): boolean {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title)
}
/**
* 从 progress 组装审批流程行;抄送节点聚合为「抄送A, B, C」对齐钉钉导出。
* 若 progress 漏抄送,则用 tasks 兜底补一行。
*/
export function buildFlowItems(detail: any): OaPrintFlowItem[] {
const list = Array.isArray(detail?.progress) ? detail.progress : []
export function buildFlowItems(
detail: any,
resolveUrl: (url: string) => string = (url) => url
): OaPrintFlowItem[] {
const progress = Array.isArray(detail?.progress) ? detail.progress : []
// 沿用详情页的时间顺序;无讨论时保持原有审批节点顺序。
const list =
Array.isArray(detail?.comments) && detail.comments.length
? mergeProgressWithComments(
progress.map((p: any, index: number) => ({
...p,
key: `${p.kind}-${p.nodeKey || p.taskId || index}-${p.roundNo || 1}`
})),
detail.comments
)
: progress
const items: OaPrintFlowItem[] = []
let ccBuf: { names: string[]; ts: string } | null = null
@@ -104,6 +115,37 @@ export function buildFlowItems(detail: any): OaPrintFlowItem[] {
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (kind === 'comment') {
flushCc()
const revoked = p.status === 'revoked'
const author = String(p.assignee || p.title || '评论').trim()
const item: OaPrintFlowItem = {
left: `【评论】 ${author}${revoked ? ' 已撤回' : ''}`,
right: ts
}
if (!revoked) {
const lines = []
if (comment) lines.push({ left: comment, right: '' })
const mentions = (p.mentions || [])
.map((m: any) => String(m.userName || m.userId || '').trim())
.filter(Boolean)
if (mentions.length)
lines.push({
left: `提到:${mentions.map((name: string) => `@${name}`).join('、')}`,
right: ''
})
if (lines.length) item.personLines = lines
const links = (p.attachmentRefs || [])
.map((file: any) => String(file.url || '').trim())
.filter((url: string) => url && !/^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(url))
.map(resolveUrl)
.filter((url: string) => /^https?:\/\//i.test(url))
if (links.length) item.attachmentLinks = links
}
items.push(item)
continue
}
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
+27 -3
View File
@@ -1,3 +1,4 @@
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
/**
* OA 审批单钉钉风格 PDF 导出
*/
@@ -398,7 +399,13 @@ export async function buildOaPrintModel(
stampClass,
stampText,
rows: [...headerRows, ...formRows],
flowItems: buildFlowItems(detail),
flowItems: buildFlowItems(detail, (url) => {
try {
return new URL(attachmentFullUrl(url), window.location.origin).href
} catch {
return ''
}
}),
printTime,
printerName,
watermarkName: printerName === '-' ? '审批' : printerName
@@ -447,7 +454,7 @@ function renderPageLabelCanvas(text: string): HTMLCanvasElement {
return c
}
async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) {
async function canvasToPdf(canvas: HTMLCanvasElement, filename: string, sheet: HTMLElement) {
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
@@ -466,6 +473,23 @@ async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) {
const position = -pageIndex * pageHeight
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
// html2canvas 只保留像素,单独把附件链接写为 PDF 注解;跨页部分按页裁切。
const sheetRect = sheet.getBoundingClientRect()
const ratio = pageWidth / sheetRect.width
for (const link of sheet.querySelectorAll<HTMLAnchorElement>('a[data-oa-print-link]')) {
if (!/^https?:\/\//i.test(link.href)) continue
for (const rect of link.getClientRects()) {
const top = (rect.top - sheetRect.top) * ratio - pageIndex * pageHeight
const bottom = Math.min(pageHeight, top + rect.height * ratio)
const y = Math.max(0, top)
if (bottom > y) {
pdf.link((rect.left - sheetRect.left) * ratio, y, rect.width * ratio, bottom - y, {
url: link.href
})
}
}
}
const label = `第${pageIndex + 1}页`
const labelCanvas = renderPageLabelCanvas(label)
if (labelCanvas.width > 1) {
@@ -526,7 +550,7 @@ export async function exportOaApprovalPdf(
windowWidth: el.scrollWidth,
windowHeight: el.scrollHeight
})
await canvasToPdf(canvas, filename)
await canvasToPdf(canvas, filename, el)
} finally {
app.unmount()
host.remove()
+1 -1
View File
@@ -363,7 +363,7 @@ export const taskStatusTagType = createTagTypeGetter(taskStatus)
export const acceptConclusion: StatusRegistry = {
js: { label: '接收', tagType: 'success' },
bj: { label: '拒收', tagType: 'danger' }
bj: { label: '废弃', tagType: 'danger' }
}
export const acceptConclusionLabel = createLabelGetter(acceptConclusion)
export const acceptConclusionTagType = createTagTypeGetter(acceptConclusion)
+2 -1
View File
@@ -6,7 +6,8 @@ export const VOID_TYPE_DICT = 'xyzflx'
const FALLBACK_LABELS: Record<string, string> = {
khhc: '客户毁存',
jcbhg: '检测不合格'
jcbhg: '检测不合格',
sysfq: '实验室废弃'
}
function buildFallbackOptions(): Api.Dict.DictDataOption[] {
@@ -69,8 +69,7 @@
@selection-change="(rows: any[]) => (selectedRows = rows)"
@row-dblclick="handleEdit"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -79,6 +78,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="orderDate"
label="业务日期"
@@ -132,11 +132,12 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column label="序号" min-width="64" align="center" header-align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="collectDate"
label="采集日期"
@@ -144,11 +144,12 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column label="序号" min-width="64" align="center" header-align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="collectDate"
label="采集日期"
@@ -105,8 +105,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -115,6 +114,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="protocolStatus"
label="状态"
@@ -116,9 +116,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -127,6 +125,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="applicationNo"
label="申请单号"
@@ -118,7 +118,7 @@
<el-dialog
v-model="visible"
:title="`${readonly ? '查看' : editingId ? '编辑' : '新增'}${isTeam ? '项目组' : '项目'}`"
width="720px"
:width="isTeam && !readonly ? '960px' : '720px'"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
@@ -163,7 +163,7 @@
:label="user.name" /></el-select
></el-form-item>
<template v-if="isTeam">
<el-form-item label="成员"
<el-form-item label="成员" :class="{ 'form-col-span-2': !readonly }"
><el-select v-model="memberIds" multiple filterable
><el-option
v-for="user in refs.users"
@@ -171,7 +171,7 @@
:value="user.id"
:label="user.name" /></el-select
></el-form-item>
<el-form-item label="负责地区(信息记录)"
<el-form-item label="负责地区(信息记录)" :class="{ 'form-col-span-2': !readonly }"
><el-select v-model="regionIds" multiple filterable
><el-option
v-for="region in refs.regions"
@@ -179,7 +179,7 @@
:value="region.id"
:label="region.name" /></el-select
></el-form-item>
<el-form-item label="负责医院(信息记录)"
<el-form-item label="负责医院(信息记录)" :class="{ 'form-col-span-2': !readonly }"
><el-select v-model="hospitalIds" multiple filterable
><el-option
v-for="hospital in refs.hospitals"
@@ -187,7 +187,9 @@
:value="hospital.id"
:label="hospital.name" /></el-select
></el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
<el-form-item label="启用" :class="{ 'form-col-span-2': !readonly }"
><el-switch v-model="form.enabled"
/></el-form-item>
<div class="form-tip form-col-span-2"
>地区、医院仅记录项目组职责,不改变业务地点候选或数据权限。</div
>
@@ -290,7 +290,7 @@
@change="onReceiveAcceptConclusionChange"
>
<el-radio value="js">接收</el-radio>
<el-radio value="bj">拒收</el-radio>
<el-radio value="bj">废弃</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="实验室入库编码" prop="labStorageCode">
@@ -643,7 +643,7 @@
@change="() => onBatchAcceptConclusionChange(entry)"
>
<el-radio value="js">接收</el-radio>
<el-radio value="bj">拒收</el-radio>
<el-radio value="bj">废弃</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
@@ -93,8 +93,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -103,6 +102,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="donationStatus"
label="状态"