From 6a2e43f829c13d8303d997d71cf412a84ca8b873 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 4 Jun 2026 11:08:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=B1=89=E5=8C=96=E7=89=A9=E8=B5=84?= =?UTF-8?q?=E4=B8=8E=E5=90=AF=E5=8A=A8=E6=8E=88=E6=9D=83=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。 2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。 3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。 4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。 --- backend/app/api/v1/drug_shipments.py | 21 +- backend/app/api/v1/startup.py | 3 +- backend/app/crud/startup.py | 3 + backend/app/schemas/drug_shipment.py | 82 +- backend/tests/test_drug_shipment_schema.py | 81 ++ database/init.sql | 52 +- frontend/src/router.test.ts | 21 + frontend/src/router/index.ts | 64 +- frontend/src/store/study.ts | 4 +- .../drug/DrugShipmentEditorDrawer.test.ts | 59 ++ .../views/drug/DrugShipmentEditorDrawer.vue | 462 ++++++++++ .../src/views/drug/ShipmentDetail.test.ts | 25 + frontend/src/views/drug/ShipmentDetail.vue | 183 +++- frontend/src/views/drug/ShipmentForm.vue | 273 ------ frontend/src/views/ia/DrugShipments.test.ts | 88 ++ frontend/src/views/ia/DrugShipments.vue | 854 ++++++++++++++---- .../src/views/ia/MaterialEquipment.test.ts | 71 ++ frontend/src/views/ia/MaterialEquipment.vue | 252 ++---- .../src/views/ia/StartupMeetingAuth.test.ts | 22 + frontend/src/views/ia/StartupMeetingAuth.vue | 23 +- .../materials/MaterialEquipmentDetail.test.ts | 47 + .../materials/MaterialEquipmentDetail.vue | 190 ++++ .../MaterialEquipmentEditorDrawer.test.ts | 33 + .../MaterialEquipmentEditorDrawer.vue | 344 +++++++ frontend/src/views/startup/KickoffDetail.vue | 384 +++----- .../src/views/startup/KickoffEditorDrawer.vue | 304 +++++++ .../startup/KickoffMemberPermissions.test.ts | 22 + .../startup/StartupAuthEditorDrawer.test.ts | 76 ++ .../startup/StartupAuthPermissions.test.ts | 125 +++ frontend/src/views/startup/TrainingDetail.vue | 48 +- .../views/startup/TrainingEditorDrawer.vue | 566 ++++++++++++ 31 files changed, 3773 insertions(+), 1009 deletions(-) create mode 100644 backend/tests/test_drug_shipment_schema.py create mode 100644 frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts create mode 100644 frontend/src/views/drug/DrugShipmentEditorDrawer.vue create mode 100644 frontend/src/views/drug/ShipmentDetail.test.ts delete mode 100644 frontend/src/views/drug/ShipmentForm.vue create mode 100644 frontend/src/views/ia/DrugShipments.test.ts create mode 100644 frontend/src/views/ia/MaterialEquipment.test.ts create mode 100644 frontend/src/views/ia/StartupMeetingAuth.test.ts create mode 100644 frontend/src/views/materials/MaterialEquipmentDetail.test.ts create mode 100644 frontend/src/views/materials/MaterialEquipmentDetail.vue create mode 100644 frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts create mode 100644 frontend/src/views/materials/MaterialEquipmentEditorDrawer.vue create mode 100644 frontend/src/views/startup/KickoffEditorDrawer.vue create mode 100644 frontend/src/views/startup/KickoffMemberPermissions.test.ts create mode 100644 frontend/src/views/startup/StartupAuthEditorDrawer.test.ts create mode 100644 frontend/src/views/startup/StartupAuthPermissions.test.ts create mode 100644 frontend/src/views/startup/TrainingEditorDrawer.vue diff --git a/backend/app/api/v1/drug_shipments.py b/backend/app/api/v1/drug_shipments.py index f3671f40..f172837c 100644 --- a/backend/app/api/v1/drug_shipments.py +++ b/backend/app/api/v1/drug_shipments.py @@ -8,7 +8,12 @@ from app.crud import audit as audit_crud from app.crud import drug_shipment as shipment_crud from app.crud import site as site_crud from app.crud import study as study_crud -from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate +from app.schemas.drug_shipment import ( + DrugShipmentCreate, + DrugShipmentRead, + DrugShipmentUpdate, + validate_drug_shipment_required_fields, +) router = APIRouter() @@ -143,6 +148,20 @@ async def update_shipment( shipment_in = shipment_in.model_copy(update={"site_name": site.name}) else: await _ensure_center_active(db, study_id, shipment.center_id) + update_data = shipment_in.model_dump(exclude_unset=True) + try: + validate_drug_shipment_required_fields( + update_data.get("status", shipment.status), + update_data.get("ship_date", shipment.ship_date), + update_data.get("receive_date", shipment.receive_date), + update_data.get("quantity", shipment.quantity), + update_data.get("batch_no", shipment.batch_no), + update_data.get("carrier", shipment.carrier), + update_data.get("tracking_no", shipment.tracking_no), + update_data.get("remark", shipment.remark), + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc shipment = await shipment_crud.update_shipment(db, shipment, shipment_in) await audit_crud.log_action( db, diff --git a/backend/app/api/v1/startup.py b/backend/app/api/v1/startup.py index 9fd8a9b0..dc145e04 100644 --- a/backend/app/api/v1/startup.py +++ b/backend/app/api/v1/startup.py @@ -468,13 +468,14 @@ async def list_training_authorizations( study_id: uuid.UUID, skip: int = 0, limit: int = 200, + site_name: str | None = None, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[TrainingAuthorizationRead]: await _ensure_study_exists(db, study_id) cra_scope = await get_cra_site_scope(db, study_id, current_user) site_names = cra_scope[1] if cra_scope else None - items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names) + items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names, site_name=site_name) return [TrainingAuthorizationRead.model_validate(item) for item in items] diff --git a/backend/app/crud/startup.py b/backend/app/crud/startup.py index e0fecb84..65d070df 100644 --- a/backend/app/crud/startup.py +++ b/backend/app/crud/startup.py @@ -243,6 +243,7 @@ async def list_training_authorizations( skip: int = 0, limit: int = 200, site_names: set[str] | None = None, + site_name: str | None = None, ) -> Sequence[TrainingAuthorization]: if site_names is not None and not site_names: return [] @@ -255,6 +256,8 @@ async def list_training_authorizations( ) if site_names is not None: stmt = stmt.where(TrainingAuthorization.site_name.in_(site_names)) + if site_name: + stmt = stmt.where(TrainingAuthorization.site_name == site_name) result = await db.execute(stmt) return result.scalars().all() diff --git a/backend/app/schemas/drug_shipment.py b/backend/app/schemas/drug_shipment.py index 5bdd18ec..bc28ba9b 100644 --- a/backend/app/schemas/drug_shipment.py +++ b/backend/app/schemas/drug_shipment.py @@ -1,22 +1,88 @@ +from __future__ import annotations + import uuid from datetime import date, datetime from typing import Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator + + +SHIPMENT_STATUSES = {"PENDING", "IN_TRANSIT", "SIGNED", "EXCEPTION"} +COMPLETED_SHIPMENT_STATUS = "SIGNED" +EXCEPTION_SHIPMENT_STATUS = "EXCEPTION" +PENDING_SHIPMENT_STATUS = "PENDING" + + +def shipment_requires_execution_details(status: str | None) -> bool: + return status != PENDING_SHIPMENT_STATUS + + +def shipment_requires_receive_date(status: str | None) -> bool: + return status == COMPLETED_SHIPMENT_STATUS + + +def shipment_requires_remark(status: str | None) -> bool: + return status == EXCEPTION_SHIPMENT_STATUS + + +def validate_drug_shipment_required_fields( + status: str | None, + ship_date: date | None, + receive_date: date | None, + quantity: int | None, + batch_no: str | None, + carrier: str | None, + tracking_no: str | None, + remark: str | None, +) -> None: + if status not in SHIPMENT_STATUSES: + raise ValueError("无效状态") + if shipment_requires_execution_details(status): + missing_execution_fields = [ + label + for label, value in ( + ("寄出/回收日期", ship_date), + ("数量", quantity), + ("批号", batch_no), + ("快递公司", carrier), + ("单号", tracking_no), + ) + if value is None or (isinstance(value, str) and not value.strip()) + ] + if missing_execution_fields: + raise ValueError(f"发运信息缺失:{', '.join(missing_execution_fields)}") + if shipment_requires_receive_date(status) and receive_date is None: + raise ValueError("已签收时必须填写接收日期") + if shipment_requires_remark(status) and not (remark or "").strip(): + raise ValueError("异常状态必须填写备注") class DrugShipmentCreate(BaseModel): direction: str center_id: uuid.UUID site_name: Optional[str] = None - ship_date: date - receive_date: date - quantity: int - batch_no: str - carrier: str - tracking_no: str + ship_date: Optional[date] = None + receive_date: Optional[date] = None + quantity: Optional[int] = None + batch_no: Optional[str] = None + carrier: Optional[str] = None + tracking_no: Optional[str] = None status: str - remark: str + remark: Optional[str] = None + + @model_validator(mode="after") + def validate_status_required_fields(self): + validate_drug_shipment_required_fields( + self.status, + self.ship_date, + self.receive_date, + self.quantity, + self.batch_no, + self.carrier, + self.tracking_no, + self.remark, + ) + return self class DrugShipmentUpdate(BaseModel): diff --git a/backend/tests/test_drug_shipment_schema.py b/backend/tests/test_drug_shipment_schema.py new file mode 100644 index 00000000..81b3c36b --- /dev/null +++ b/backend/tests/test_drug_shipment_schema.py @@ -0,0 +1,81 @@ +import uuid +from datetime import date + +import pytest +from pydantic import ValidationError + +from app.schemas.drug_shipment import DrugShipmentCreate + + +def shipment_payload(**overrides): + payload = { + "direction": "SEND", + "center_id": uuid.uuid4(), + "ship_date": date(2026, 6, 4), + "receive_date": None, + "quantity": 1, + "batch_no": "DP-001", + "carrier": "顺丰", + "tracking_no": "SF100003", + "status": "IN_TRANSIT", + "remark": None, + } + payload.update(overrides) + return payload + + +def test_create_allows_pending_shipment_execution_fields_to_be_empty(): + shipment = DrugShipmentCreate.model_validate( + shipment_payload( + status="PENDING", + ship_date=None, + quantity=None, + batch_no=None, + carrier=None, + tracking_no=None, + receive_date=None, + remark=None, + ) + ) + + assert shipment.ship_date is None + assert shipment.quantity is None + assert shipment.batch_no is None + assert shipment.carrier is None + assert shipment.tracking_no is None + + +@pytest.mark.parametrize("field_name", ["ship_date", "quantity", "batch_no", "carrier", "tracking_no"]) +def test_create_requires_shipment_execution_fields_after_pending(field_name): + with pytest.raises(ValidationError) as exc_info: + DrugShipmentCreate.model_validate(shipment_payload(status="IN_TRANSIT", **{field_name: None})) + + assert "发运信息" in str(exc_info.value) + + +def test_create_allows_pending_receipt_fields_to_be_empty(): + shipment = DrugShipmentCreate.model_validate(shipment_payload()) + + assert shipment.receive_date is None + assert shipment.remark is None + + +def test_create_requires_receive_date_when_signed(): + with pytest.raises(ValidationError) as exc_info: + DrugShipmentCreate.model_validate(shipment_payload(status="SIGNED", receive_date=None)) + + assert "接收日期" in str(exc_info.value) + + +def test_create_rejects_removed_returned_status(): + with pytest.raises(ValidationError) as exc_info: + DrugShipmentCreate.model_validate(shipment_payload(status="RETURNED", receive_date=date(2026, 6, 5))) + + assert "无效状态" in str(exc_info.value) + + +def test_create_requires_remark_when_exceptional(): + with pytest.raises(ValidationError) as exc_info: + DrugShipmentCreate.model_validate(shipment_payload(status="EXCEPTION", remark="")) + + assert "备注" in str(exc_info.value) diff --git a/database/init.sql b/database/init.sql index 4d3c374c..764874de 100644 --- a/database/init.sql +++ b/database/init.sql @@ -184,27 +184,15 @@ CREATE TABLE IF NOT EXISTS public.adverse_events ( CONSTRAINT fk_adverse_events_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); -CREATE TABLE IF NOT EXISTS public.finance_contracts ( - id uuid PRIMARY KEY, - study_id uuid NOT NULL, - site_name character varying(255) NOT NULL, - contract_no character varying(100) NOT NULL, - signed_date date, - amount numeric(12, 2) NOT NULL, - currency character varying(10) NOT NULL DEFAULT 'CNY', - remark text, - created_by uuid, - created_at timestamp with time zone NOT NULL DEFAULT now(), - updated_at timestamp with time zone NOT NULL DEFAULT now(), - CONSTRAINT fk_finance_contracts_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, - CONSTRAINT fk_finance_contracts_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT -); - CREATE TABLE IF NOT EXISTS public.contract_fees ( id uuid PRIMARY KEY, project_id uuid NOT NULL, center_id uuid NOT NULL, + contract_no character varying(100), + signed_date date, contract_amount numeric(12, 2) NOT NULL, + currency character varying(10) NOT NULL DEFAULT 'CNY', + remark text, contract_cases integer NOT NULL, actual_cases integer, settlement_amount numeric(12, 2), @@ -231,22 +219,6 @@ CREATE TABLE IF NOT EXISTS public.contract_fee_payments ( CONSTRAINT fk_contract_fee_payments_contract_fee_id FOREIGN KEY (contract_fee_id) REFERENCES public.contract_fees(id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS public.fee_attachments ( - id uuid PRIMARY KEY, - entity_type character varying(50) NOT NULL, - entity_id uuid NOT NULL, - file_type character varying(30) NOT NULL, - filename character varying(255) NOT NULL, - mime_type character varying(100), - size integer NOT NULL, - storage_key character varying(500), - url text, - uploaded_by uuid NOT NULL, - uploaded_at timestamp with time zone NOT NULL DEFAULT now(), - is_deleted boolean NOT NULL DEFAULT false, - CONSTRAINT fk_fee_attachments_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES public.users(id) ON DELETE RESTRICT -); - CREATE TABLE IF NOT EXISTS public.finance_items ( id uuid PRIMARY KEY, study_id uuid NOT NULL, @@ -498,7 +470,6 @@ CREATE INDEX IF NOT EXISTS ix_visits_subject_id ON public.visits (subject_id); CREATE INDEX IF NOT EXISTS ix_adverse_events_study_id ON public.adverse_events (study_id); CREATE INDEX IF NOT EXISTS ix_adverse_events_site_id ON public.adverse_events (site_id); CREATE INDEX IF NOT EXISTS ix_adverse_events_subject_id ON public.adverse_events (subject_id); -CREATE INDEX IF NOT EXISTS ix_finance_contracts_study_id ON public.finance_contracts (study_id); CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (study_id); CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id); CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id); @@ -590,13 +561,6 @@ INSERT INTO public.milestones ( ('20202020-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'LPI', '首例入组', '2025-02-01', NULL, 'NOT_STARTED', 'cccccccc-cccc-cccc-cccc-cccccccccccc', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '待完成', '2025-01-06 12:10:00+00', '2025-01-06 12:10:00+00') ON CONFLICT (id) DO NOTHING; -INSERT INTO public.finance_contracts ( - id, study_id, site_name, contract_no, signed_date, amount, currency, remark, created_by, created_at, updated_at -) VALUES - ('77777777-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', 'CON-001', '2025-01-08', 1200000.00, 'CNY', '首批合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-08 09:00:00+00', '2025-01-08 09:00:00+00'), - ('77777777-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', 'CON-002', '2025-01-10', 980000.00, 'CNY', '分中心合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 09:00:00+00', '2025-01-10 09:00:00+00') -ON CONFLICT (id) DO NOTHING; - INSERT INTO public.finance_items ( id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at ) VALUES @@ -607,10 +571,10 @@ ON CONFLICT (id) DO NOTHING; INSERT INTO public.drug_shipments ( id, study_id, direction, center_id, site_name, ship_date, receive_date, quantity, batch_no, carrier, tracking_no, status, remark, created_by, created_at, updated_at ) VALUES - ('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-01-12', NULL, 200, 'DP-001', '顺丰', 'SF100001', '运输中', '首批寄送:DP-001 片剂 50mg × 200 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'), - ('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-01-13', '2025-01-14', 150, 'DP-001', '京东物流', 'JD200002', '已签收', '分中心补货:DP-001 片剂 50mg × 150 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'), - ('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-02-05', '2025-02-06', NULL, 'DP-001', '顺丰', 'SF100003', '已回收', '首批回收:空药盒及剩余药品', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'), - ('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-02-08', NULL, NULL, 'DP-001', '德邦', 'DB300004', '运输中', '回收途中:冷链箱回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00') + ('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-01-12', NULL, 200, 'DP-001', '顺丰', 'SF100001', 'IN_TRANSIT', '首批寄送:DP-001 片剂 50mg × 200 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'), + ('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-01-13', '2025-01-14', 150, 'DP-001', '京东物流', 'JD200002', 'SIGNED', '分中心补货:DP-001 片剂 50mg × 150 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'), + ('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-02-05', '2025-02-06', 1, 'DP-001', '顺丰', 'SF100003', 'SIGNED', '首批回收:空药盒及剩余药品', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'), + ('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-02-08', NULL, 1, 'DP-001', '德邦', 'DB300004', 'IN_TRANSIT', '回收途中:冷链箱回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00') ON CONFLICT (id) DO NOTHING; INSERT INTO public.startup_feasibility ( diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index 83c07614..3d176654 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -65,6 +65,27 @@ describe("admin project route permissions", () => { expect(source).not.toContain("finance/contracts"); }); + it("does not register standalone contract fee create or edit pages", () => { + const source = readRouter(); + + expect(source).not.toContain("FeeContractForm"); + expect(source).not.toContain('name: "FeeContractNew"'); + expect(source).not.toContain('name: "FeeContractEdit"'); + expect(source).not.toContain('path: "fees/contracts/new"'); + expect(source).not.toContain('path: "fees/contracts/:contractId/edit"'); + }); + + it("does not register standalone startup auth edit pages", () => { + const source = readRouter(); + + expect(source).not.toContain('name: "StartupKickoffEdit"'); + expect(source).not.toContain('name: "StartupTrainingEdit"'); + expect(source).not.toContain('name: "StartupTrainingNew"'); + expect(source).not.toContain('path: "startup/kickoff/:meetingId/edit"'); + expect(source).not.toContain('path: "startup/training/new"'); + expect(source).not.toContain('path: "startup/training/:recordId/edit"'); + }); + it("uses precautions routes instead of knowledge note routes", () => { const source = readRouter(); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8ba70d57..c41073b7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -23,10 +23,11 @@ import ProfileSettings from "../views/ProfileSettings.vue"; import ProjectOverview from "../views/ia/ProjectOverview.vue"; import ProjectMilestones from "../views/ia/ProjectMilestones.vue"; import FeeContracts from "../views/fees/ContractFees.vue"; -import FeeContractForm from "../views/fees/ContractFeeForm.vue"; import FeeContractDetail from "../views/fees/ContractFeeDetail.vue"; import DrugShipments from "../views/ia/DrugShipments.vue"; +import ShipmentDetail from "../views/drug/ShipmentDetail.vue"; import MaterialEquipment from "../views/ia/MaterialEquipment.vue"; +import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue"; import FileVersionManagement from "../views/ia/FileVersionManagement.vue"; import DocumentList from "../views/documents/DocumentList.vue"; import DocumentDetail from "../views/documents/DocumentDetail.vue"; @@ -41,15 +42,12 @@ import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue"; import Precautions from "../views/ia/Precautions.vue"; import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue"; import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue"; -import ShipmentForm from "../views/drug/ShipmentForm.vue"; -import ShipmentDetail from "../views/drug/ShipmentDetail.vue"; import FeasibilityForm from "../views/startup/FeasibilityForm.vue"; import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue"; import EthicsForm from "../views/startup/EthicsForm.vue"; import EthicsDetail from "../views/startup/EthicsDetail.vue"; import KickoffForm from "../views/startup/KickoffForm.vue"; import KickoffDetail from "../views/startup/KickoffDetail.vue"; -import TrainingForm from "../views/startup/TrainingForm.vue"; import TrainingDetail from "../views/startup/TrainingDetail.vue"; import PrecautionForm from "../views/knowledge/PrecautionForm.vue"; import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue"; @@ -109,42 +107,18 @@ const routes: RouteRecordRaw[] = [ component: FeeContracts, meta: { title: TEXT.menu.feeContracts, requiresStudy: true }, }, - { - path: "fees/contracts/new", - name: "FeeContractNew", - component: FeeContractForm, - meta: { title: TEXT.common.actions.add + TEXT.menu.feeContracts, requiresStudy: true }, - }, { path: "fees/contracts/:contractId", name: "FeeContractDetail", component: FeeContractDetail, meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true }, }, - { - path: "fees/contracts/:contractId/edit", - name: "FeeContractEdit", - component: FeeContractForm, - meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true }, - }, { path: "drug/shipments", name: "DrugShipments", component: DrugShipments, meta: { title: TEXT.menu.drugShipments, requiresStudy: true }, }, - { - path: "materials/equipment", - name: "MaterialEquipment", - component: MaterialEquipment, - meta: { title: TEXT.menu.materialEquipment, requiresStudy: true }, - }, - { - path: "drug/shipments/new", - name: "DrugShipmentNew", - component: ShipmentForm, - meta: { title: TEXT.common.actions.add + TEXT.menu.drugShipments, requiresStudy: true }, - }, { path: "drug/shipments/:shipmentId", name: "DrugShipmentDetail", @@ -152,10 +126,16 @@ const routes: RouteRecordRaw[] = [ meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true }, }, { - path: "drug/shipments/:shipmentId/edit", - name: "DrugShipmentEdit", - component: ShipmentForm, - meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true }, + path: "materials/equipment", + name: "MaterialEquipment", + component: MaterialEquipment, + meta: { title: TEXT.menu.materialEquipment, requiresStudy: true }, + }, + { + path: "materials/equipment/:equipmentId", + name: "MaterialEquipmentDetail", + component: MaterialEquipmentDetail, + meta: { title: TEXT.modules.materialEquipment.detailTitle, requiresStudy: true }, }, { path: "file-versions", @@ -235,30 +215,12 @@ const routes: RouteRecordRaw[] = [ component: KickoffDetail, meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true }, }, - { - path: "startup/kickoff/:meetingId/edit", - name: "StartupKickoffEdit", - component: KickoffForm, - meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.kickoffTab, requiresStudy: true }, - }, - { - path: "startup/training/new", - name: "StartupTrainingNew", - component: TrainingForm, - meta: { title: TEXT.common.actions.add + TEXT.common.fields.person, requiresStudy: true }, - }, { path: "startup/training/:recordId", name: "StartupTrainingDetail", component: TrainingDetail, meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true }, }, - { - path: "startup/training/:recordId/edit", - name: "StartupTrainingEdit", - component: TrainingForm, - meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.trainingTab, requiresStudy: true }, - }, { path: "subjects", name: "SubjectManagement", @@ -447,7 +409,7 @@ const router = createRouter({ const ADMIN_PROJECT_OPERATION_KEYS: Record> = { audit_export: { read: "audit_logs:read", write: "audit_logs:export" }, - project_members: { read: "project_members:list", write: "project_members:update" }, + project_members: { read: "project_members:read", write: "project_members:update" }, sites: { read: "sites:read", write: "sites:update" }, }; diff --git a/frontend/src/store/study.ts b/frontend/src/store/study.ts index f8dd780c..4af63108 100644 --- a/frontend/src/store/study.ts +++ b/frontend/src/store/study.ts @@ -13,7 +13,7 @@ export const useStudyStore = defineStore("study", () => { const currentStudy = ref(null); const currentStudyRole = ref(null); const currentSite = ref(null); - const viewContext = ref<{ siteName?: string } | null>(null); + const viewContext = ref<{ siteName?: string; pageTitle?: string } | null>(null); const currentPermissions = ref(null); const normalizeUserKey = (value: string) => value.trim().toLowerCase(); @@ -229,7 +229,7 @@ export const useStudyStore = defineStore("study", () => { } }; - const setViewContext = (ctx: { siteName?: string } | null) => { + const setViewContext = (ctx: { siteName?: string; pageTitle?: string } | null) => { viewContext.value = ctx; }; diff --git a/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts b/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts new file mode 100644 index 00000000..9661349c --- /dev/null +++ b/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./DrugShipmentEditorDrawer.vue"), "utf8"); + +describe("DrugShipmentEditorDrawer attachments", () => { + it("uses a compact upload card instead of the full attachment table", () => { + const source = readSource(); + + expect(source).toContain("AttachmentList"); + expect(source).toContain('ref="attachmentPanelRef"'); + expect(source).toContain('entity-type="drug_shipment"'); + expect(source).toContain(':mode="\'upload\'"'); + expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); + expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)"); + expect(source).toContain("uploadPending"); + expect(source).not.toContain("uploadAttachment"); + expect(source).not.toContain("pendingFiles"); + expect(source).not.toContain("upload-card-label"); + expect(source).not.toContain(" { + it("keeps shipment execution fields optional while status is pending", () => { + const source = readSource(); + + expect(source).toContain("requiresShipmentDetails"); + expect(source).toContain('status !== "PENDING"'); + expect(source).not.toContain('ship_date: [{ required: true'); + expect(source).not.toContain('batch_no: [{ required: true'); + expect(source).not.toContain('carrier: [{ required: true'); + expect(source).not.toContain('tracking_no: [{ required: true'); + }); + + it("does not expose the removed returned status", () => { + const source = readSource(); + + expect(source).not.toContain("RETURNED"); + expect(source).not.toContain("已回收"); + }); + + it("requires receive date when shipment is signed", () => { + const source = readSource(); + + expect(source).toContain("requiresReceiveDate"); + expect(source).toContain('status === "SIGNED"'); + expect(source).not.toContain('receive_date: [{ required: true'); + }); + + it("keeps remark optional unless shipment is exceptional", () => { + const source = readSource(); + + expect(source).toContain("requiresRemark"); + expect(source).toContain('status === "EXCEPTION"'); + expect(source).not.toContain('remark: [{ required: true'); + }); +}); diff --git a/frontend/src/views/drug/DrugShipmentEditorDrawer.vue b/frontend/src/views/drug/DrugShipmentEditorDrawer.vue new file mode 100644 index 00000000..385b6676 --- /dev/null +++ b/frontend/src/views/drug/DrugShipmentEditorDrawer.vue @@ -0,0 +1,462 @@ + + + + + diff --git a/frontend/src/views/drug/ShipmentDetail.test.ts b/frontend/src/views/drug/ShipmentDetail.test.ts new file mode 100644 index 00000000..3eb6d670 --- /dev/null +++ b/frontend/src/views/drug/ShipmentDetail.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./ShipmentDetail.vue"), "utf8"); + +describe("ShipmentDetail edit drawer", () => { + it("shows an edit action and opens the shipment editor drawer on the detail page", () => { + const source = readSource(); + + expect(source).toContain(""); + expect(source).toContain('"drug_shipments:update"'); + expect(source).toContain(":readonly=\"isReadOnly\""); + }); + + it("hides the upload button in the detail attachment table", () => { + const source = readSource(); + + expect(source).toContain("
-
+
-
{{ TEXT.modules.drugShipments.detailTitle }}
-
- {{ TEXT.common.actions.edit }} - {{ TEXT.common.actions.back }} +
+
{{ TEXT.modules.drugShipments.detailTitle }}
+
{{ TEXT.modules.drugShipments.detailSubtitle }}
+
+
+ + + {{ TEXT.common.actions.edit }} +
+
{{ TEXT.common.labels.basicInfo }} - + {{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
{{ detail.site_name || TEXT.common.fallback }} - + {{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}
+
{{ TEXT.modules.drugShipments.recordLabel }}
@@ -36,70 +43,162 @@ {{ detail.remark || TEXT.common.fallback }}
+
+
diff --git a/frontend/src/views/drug/ShipmentForm.vue b/frontend/src/views/drug/ShipmentForm.vue deleted file mode 100644 index f3fc4795..00000000 --- a/frontend/src/views/drug/ShipmentForm.vue +++ /dev/null @@ -1,273 +0,0 @@ - - - - - diff --git a/frontend/src/views/ia/DrugShipments.test.ts b/frontend/src/views/ia/DrugShipments.test.ts new file mode 100644 index 00000000..03324c5f --- /dev/null +++ b/frontend/src/views/ia/DrugShipments.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./DrugShipments.vue"), "utf8"); + +describe("DrugShipments project permissions", () => { + it("hides edit and delete actions when the role lacks matching backend operation permissions", () => { + const source = readSource(); + + expect(source).toContain('["drug_shipments:create"]'); + expect(source).toContain('["drug_shipments:update"]'); + expect(source).toContain('["drug_shipments:delete"]'); + expect(source).toContain('v-if="canUpdate"'); + expect(source).toContain('v-if="canDelete"'); + expect(source).toContain("if (!canUpdate.value)"); + expect(source).toContain("if (!canDelete.value)"); + }); + + it("uses flexible data columns so the table fills the available page width", () => { + const source = readSource(); + + expect(source).toContain('class="ctms-table shipment-table"'); + expect(source).toContain('style="width: 100%"'); + expect(source).toContain('table-layout="fixed"'); + expect(source).toContain(':label="TEXT.common.labels.actions" width="130"'); + expect(source).not.toMatch(/prop="(?:site_name|direction|ship_date|receive_date|quantity|batch_no|status|remark)"[^>]+(?:min-width|width)=/); + }); + + it("keeps edit and delete actions on one line", () => { + const source = readSource(); + + expect(source).toContain('class="shipment-actions"'); + expect(source).toContain(".shipment-actions"); + expect(source).toContain("white-space: nowrap;"); + }); + + it("uses the compact pending upload area in the create/edit drawer", () => { + const source = readSource(); + + expect(source).toContain("AttachmentList"); + expect(source).toContain('ref="attachmentPanelRef"'); + expect(source).toContain('entity-type="drug_shipment"'); + expect(source).toContain(':entity-id="editingId"'); + expect(source).toContain(':mode="\'upload\'"'); + expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); + expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)"); + expect(source).toContain("uploadPending"); + expect(source).toContain("group-dot-attachment"); + expect(source).not.toContain("uploadAttachment"); + expect(source).not.toContain("pendingFiles"); + expect(source).not.toContain("upload-card-label"); + }); + + it("keeps shipment execution fields optional while status is pending", () => { + const source = readSource(); + + expect(source).toContain("requiresShipmentDetails"); + expect(source).toContain('status !== "PENDING"'); + expect(source).not.toContain('ship_date: [{ required: true'); + expect(source).not.toContain('batch_no: [{ required: true'); + expect(source).not.toContain('carrier: [{ required: true'); + expect(source).not.toContain('tracking_no: [{ required: true'); + }); + + it("does not expose the removed returned status", () => { + const source = readSource(); + + expect(source).not.toContain("RETURNED"); + expect(source).not.toContain("已回收"); + }); + + it("requires receive date when shipment is signed", () => { + const source = readSource(); + + expect(source).toContain("requiresReceiveDate"); + expect(source).toContain('status === "SIGNED"'); + expect(source).not.toContain('receive_date: [{ required: true'); + }); + + it("keeps remark optional unless shipment is exceptional", () => { + const source = readSource(); + + expect(source).toContain("requiresRemark"); + expect(source).toContain('status === "EXCEPTION"'); + expect(source).not.toContain('remark: [{ required: true'); + }); +}); diff --git a/frontend/src/views/ia/DrugShipments.vue b/frontend/src/views/ia/DrugShipments.vue index 132cff91..81df2bd2 100644 --- a/frontend/src/views/ia/DrugShipments.vue +++ b/frontend/src/views/ia/DrugShipments.vue @@ -1,196 +1,571 @@ + +