diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index 534ac61cc..c6900934d 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -424,6 +424,15 @@ export function prescriptionOrderEdit(params: Record) { return request.post({ url: '/tcm.prescriptionOrder/edit', params }) } +/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */ +export function prescriptionOrderDdcode(params: { + id: number + express_company: string + tracking_number: string +}) { + return request.post({ url: '/tcm.prescriptionOrder/ddcode', params }) +} + /** 业务订单详情:仅修改关联处方的患者姓名与手机号 */ export function prescriptionOrderPatchPrescriptionPatient(params: { id: number diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index 7dba0f51b..a0103da50 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -2255,6 +2255,7 @@ import { prescriptionOrderAuditPayment, prescriptionOrderAuditPrescription, prescriptionOrderDetail, + prescriptionOrderDdcode, prescriptionOrderEdit, prescriptionOrderLists, prescriptionOrderExport, @@ -3243,9 +3244,8 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1 } -function canQuickTrackRow(row: { fulfillment_status?: number }) { - // 仅已发货(5) 可修改快递单号;已签收(6) 不可再改 - return Number(row.fulfillment_status) === 5 +function canQuickTrackRow(_row: { fulfillment_status?: number }) { + return true } function canUploadPharmacyRow(row: { @@ -4094,33 +4094,8 @@ async function submitQuickTrack() { } quickTrackSaving.value = true try { - // 先拉详情,获取所有必填字段,再只覆盖快递信息 - const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value }) - const d = res?.data ?? res - if (!d) { - feedback.msgError('加载订单数据失败') - return - } - await prescriptionOrderEdit({ - id: d.id, - recipient_name: d.recipient_name || '', - recipient_phone: d.recipient_phone || '', - shipping_province: d.shipping_province || '', - shipping_city: d.shipping_city || '', - shipping_district: d.shipping_district || '', - shipping_address: d.shipping_address || '', - is_follow_up: d.is_follow_up ? 1 : 0, - medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '', - prev_staff: d.prev_staff || '', - service_channel: d.service_channel || '', - service_package: d.service_package || '', - fee_type: Number(d.fee_type) || 3, - amount: Number(d.amount) || 0, - remark_extra: d.remark_extra || '', - remark_assistant: d.remark_assistant || '', - internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '', - pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [], - // 仅覆盖快递字段 + await prescriptionOrderDdcode({ + id: quickTrackRowId.value, express_company: quickTrackForm.express_company || 'auto', tracking_number: quickTrackForm.tracking_number.trim() }) @@ -4129,17 +4104,6 @@ async function submitQuickTrack() { getLists() // 若详情 Drawer 打开且是同一订单,同步刷新 await detailDrawerRef.value?.refreshIfCurrent(quickTrackRowId.value) - // 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗 - if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) { - try { - await ElMessageBox.confirm( - `快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`, - '确认发货', - { type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' } - ) - openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company }) - } catch { /* 用户点了「稍后」 */ } - } } catch { /* 拦截器已提示 */ } finally { diff --git a/admin/src/views/consumer/prescription/order_list_h5.vue b/admin/src/views/consumer/prescription/order_list_h5.vue index 448f9e7f1..363f4a703 100644 --- a/admin/src/views/consumer/prescription/order_list_h5.vue +++ b/admin/src/views/consumer/prescription/order_list_h5.vue @@ -2713,6 +2713,7 @@ import { prescriptionOrderAuditPayment, prescriptionOrderAuditPrescription, prescriptionOrderDetail, + prescriptionOrderDdcode, prescriptionOrderEdit, prescriptionOrderLists, prescriptionOrderPaidPayOrders, @@ -3551,8 +3552,8 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1 } -function canQuickTrackRow(row: { fulfillment_status?: number }) { - return Number(row.fulfillment_status) === 5 +function canQuickTrackRow(_row: { fulfillment_status?: number }) { + return true } function canUploadPharmacyRow(row: { @@ -4900,33 +4901,8 @@ async function submitQuickTrack() { } quickTrackSaving.value = true try { - // 先拉详情,获取所有必填字段,再只覆盖快递信息 - const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value }) - const d = res?.data ?? res - if (!d) { - feedback.msgError('加载订单数据失败') - return - } - await prescriptionOrderEdit({ - id: d.id, - recipient_name: d.recipient_name || '', - recipient_phone: d.recipient_phone || '', - shipping_province: d.shipping_province || '', - shipping_city: d.shipping_city || '', - shipping_district: d.shipping_district || '', - shipping_address: d.shipping_address || '', - is_follow_up: d.is_follow_up ? 1 : 0, - medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '', - prev_staff: d.prev_staff || '', - service_channel: d.service_channel || '', - service_package: d.service_package || '', - fee_type: Number(d.fee_type) || 3, - amount: Number(d.amount) || 0, - remark_extra: d.remark_extra || '', - remark_assistant: d.remark_assistant || '', - internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '', - pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [], - // 仅覆盖快递字段 + await prescriptionOrderDdcode({ + id: quickTrackRowId.value, express_company: quickTrackForm.express_company || 'auto', tracking_number: quickTrackForm.tracking_number.trim() }) @@ -4939,19 +4915,9 @@ async function submitQuickTrack() { const r: any = await prescriptionOrderDetail({ id: quickTrackRowId.value }) const nd = r?.data ?? r ?? null if (nd) detailData.value = nd + await fetchLogs(quickTrackRowId.value) } catch { /* 静默 */ } } - // 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗 - if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) { - try { - await ElMessageBox.confirm( - `快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`, - '确认发货', - { type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' } - ) - openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company }) - } catch { /* 用户点了「稍后」 */ } - } } catch { /* 拦截器已提示 */ } finally { diff --git a/server/app/adminapi/controller/tcm/PrescriptionOrderController.php b/server/app/adminapi/controller/tcm/PrescriptionOrderController.php index 4fb157349..0f40c93d0 100755 --- a/server/app/adminapi/controller/tcm/PrescriptionOrderController.php +++ b/server/app/adminapi/controller/tcm/PrescriptionOrderController.php @@ -131,6 +131,26 @@ class PrescriptionOrderController extends BaseAdminController return $this->success('保存成功', $result); } + /** + * 仅修改承运商与快递单号,不受订单履约状态或远端药房快照锁限制。 + */ + public function ddcode() + { + $params = (new PrescriptionOrderValidate())->post()->goCheck('ddcode'); + $result = PrescriptionOrderLogic::ddcode( + (int) $params['id'], + (string) ($params['express_company'] ?? 'auto'), + (string) $params['tracking_number'], + $this->adminId, + $this->adminInfo + ); + if ($result === false) { + return $this->fail(PrescriptionOrderLogic::getError()); + } + + return $this->success('快递单号已保存', $result); + } + public function updateAmount() { $params = (new PrescriptionOrderValidate())->post()->goCheck('updateAmount'); diff --git a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php index 72ed42a64..b9cad68ce 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php @@ -1904,21 +1904,72 @@ class PrescriptionOrderLogic } /** - * 甘草已提交后仅更新物流字段(tracking_number、express_company),忽略金额/地址等其它请求参数。 + * 仅修改承运商与快递单号。物流信息不属于药房下单快照,因此允许在所有履约状态下修正。 * - * @param array $params * @return array|false */ - private static function editGancaoLogisticsOnly( - PrescriptionOrder $order, - array $params, + public static function ddcode( + int $id, + string $expressCompany, + string $trackingNumber, int $adminId, array $adminInfo ) { - $order->tracking_number = mb_substr(trim((string) ($params['tracking_number'] ?? '')), 0, 80); - if (array_key_exists('express_company', $params)) { - $order->express_company = self::normalizeExpressCompany($params['express_company']); + self::setError(''); + try { + return LockedPharmacySnapshotMutation::execute( + $id, + static fn () => self::ddcodeLocked( + $id, + $expressCompany, + $trackingNumber, + $adminId, + $adminInfo + ), + false + ); + } catch (\Throwable $exception) { + if (self::getError() === '') { + self::setError($exception->getMessage()); + } + return false; } + } + + /** @return array|false */ + private static function ddcodeLocked( + int $id, + string $expressCompany, + string $trackingNumber, + int $adminId, + array $adminInfo + ) { + self::$error = ''; + $trackingNumber = mb_substr(trim($trackingNumber), 0, 80); + if ($trackingNumber === '') { + self::$error = '快递单号不能为空'; + + return false; + } + + $order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find(); + if (!$order) { + self::$error = '订单不存在'; + + return false; + } + if (!self::canAccessOrder($order, $adminId, $adminInfo)) { + self::$error = '无权限操作'; + + return false; + } + + $oldTrackingNumber = trim((string) ($order->tracking_number ?? '')); + $oldExpressCompany = self::normalizeExpressCompany((string) ($order->express_company ?? 'auto')); + $newExpressCompany = self::normalizeExpressCompany($expressCompany); + + $order->tracking_number = $trackingNumber; + $order->express_company = $newExpressCompany; try { $order->save(); @@ -1932,8 +1983,15 @@ class PrescriptionOrderLogic (int) $order->id, $adminId, $adminInfo, - 'edit', - '甘草订单已提交,仅更新快递信息(单号/承运商)' + 'fill_tracking', + sprintf( + '修改快递信息:单号「%s」→「%s」,承运商「%s」→「%s」', + $oldTrackingNumber !== '' ? $oldTrackingNumber : '空', + $trackingNumber, + $oldExpressCompany, + $newExpressCompany + ), + true ); $out = $order->toArray(); @@ -5370,7 +5428,14 @@ class PrescriptionOrderLogic ]; } - private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void + private static function writeLog( + int $orderId, + int $adminId, + array $adminInfo, + string $action, + string $summary, + bool $strict = false + ): void { $adminName = $adminInfo['name'] ?? ''; if ($adminName === '' && $adminId > 0) { @@ -5388,7 +5453,10 @@ class PrescriptionOrderLogic try { $log->save(); } catch (\Throwable $e) { - // 忽略日志写入错误 + if ($strict) { + throw new \RuntimeException('操作日志写入失败,快递信息未保存', 0, $e); + } + // 非关键日志沿用历史容错行为 } } diff --git a/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php b/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php index 078fdaa06..bf366ea89 100755 --- a/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php +++ b/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php @@ -59,6 +59,7 @@ class PrescriptionOrderValidate extends BaseValidate 'action.require' => '请选择审核操作', 'patient_name.require' => '请输入患者姓名', 'phone.require' => '请输入手机号', + 'tracking_number.require' => '请输入快递单号', 'phone_tail.regex' => '手机后四位仅支持数字', 'reason.require' => '请填写退款原因', ]; @@ -75,6 +76,7 @@ class PrescriptionOrderValidate extends BaseValidate 'is_follow_up', 'prev_staff', 'service_channel', 'service_package', 'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids', ], + 'ddcode' => ['id', 'tracking_number', 'express_company'], 'logisticsTrace' => ['id', 'phone_tail'], 'logisticsJdUpdate' => ['id'], 'auditPrescription' => ['id', 'action', 'remark'], @@ -106,6 +108,14 @@ class PrescriptionOrderValidate extends BaseValidate ->append('amount', 'require|float|egt:0'); } + public function ddcode(): PrescriptionOrderValidate + { + return $this->only(['id', 'tracking_number', 'express_company']) + ->append('id', 'require|integer|gt:0') + ->append('tracking_number', 'require|max:80') + ->append('express_company', 'max:20'); + } + public function patchPrescriptionUsage(): PrescriptionOrderValidate { return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days']) diff --git a/server/database/migrations/2026_04_07_prescription_order_menu.sql b/server/database/migrations/2026_04_07_prescription_order_menu.sql index 1003de125..c62fbd854 100755 --- a/server/database/migrations/2026_04_07_prescription_order_menu.sql +++ b/server/database/migrations/2026_04_07_prescription_order_menu.sql @@ -59,6 +59,17 @@ FROM DUAL WHERE @po_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/edit'); +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @po_menu_id, 'A', '修改快递单号', '', 31, 'tcm.prescriptionOrder/ddcode', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @po_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/ddcode'); + INSERT INTO zyt_system_menu ( pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time diff --git a/server/sql/1.9.20260803/add_prescription_order_ddcode_menu.sql b/server/sql/1.9.20260803/add_prescription_order_ddcode_menu.sql new file mode 100644 index 000000000..37739ab80 --- /dev/null +++ b/server/sql/1.9.20260803/add_prescription_order_ddcode_menu.sql @@ -0,0 +1,23 @@ +-- 处方业务订单:独立修改快递单号权限。 +-- 执行后需在「角色权限」中为允许修改物流信息的角色勾选本按钮权限。 + +SET @po_menu_id := ( + SELECT id + FROM zyt_system_menu + WHERE perms = 'tcm.prescriptionOrder/lists' + LIMIT 1 +); + +INSERT INTO zyt_system_menu ( + pid, type, name, icon, sort, perms, paths, component, + selected, params, is_cache, is_show, is_disable, create_time, update_time +) +SELECT + @po_menu_id, 'A', '修改快递单号', '', 31, + 'tcm.prescriptionOrder/ddcode', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @po_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/ddcode' + ); diff --git a/server/tests/pharmacy/route_contracts.php b/server/tests/pharmacy/route_contracts.php index 4f5861726..2b8aaa14c 100644 --- a/server/tests/pharmacy/route_contracts.php +++ b/server/tests/pharmacy/route_contracts.php @@ -23,6 +23,10 @@ $assertTrue( str_contains($controllerSource, 'confirmGancaoSubmission'), 'admin API must expose an actionable Gancao reconciliation endpoint' ); +$assertTrue( + str_contains($controllerSource, 'public function ddcode()'), + 'admin API must expose a dedicated tracking correction endpoint' +); $assertTrue( str_contains($migrationSource, '`lease_expires_at`'), 'pharmacy submission claims must persist an explicit lease expiry' diff --git a/server/tests/pharmacy/run.php b/server/tests/pharmacy/run.php index e847571ad..cbb463b9f 100644 --- a/server/tests/pharmacy/run.php +++ b/server/tests/pharmacy/run.php @@ -799,14 +799,14 @@ $assertTrue( 'remote payload must reload the canonical order after acquiring the durable claim' ); $assertTrue( - substr_count($prescriptionOrderLogicSource, 'LockedPharmacySnapshotMutation::execute(') >= 7, - 'order edit, patient, ship, withdraw, audit revoke, ship-mode and usage mutations must run inside locked snapshot transactions' + substr_count($prescriptionOrderLogicSource, 'LockedPharmacySnapshotMutation::execute(') >= 8, + 'order edit, logistics, patient, ship, withdraw, audit revoke, ship-mode and usage mutations must run inside locked snapshot transactions' ); $assertTrue( substr_count($prescriptionLogicSource, 'LockedPharmacySnapshotMutation::executeForPrescription(') >= 4, 'prescription edit, patient patch, delete and void must lock every linked order and claim before saving' ); -foreach (['patchPrescriptionPatient', 'edit', 'ship', 'withdraw', 'revokeRxAudit', 'setShipMode', 'patchPrescriptionUsage'] as $method) { +foreach (['patchPrescriptionPatient', 'edit', 'ddcode', 'ship', 'withdraw', 'revokeRxAudit', 'setShipMode', 'patchPrescriptionUsage'] as $method) { $start = (int) strpos($prescriptionOrderLogicSource, 'public static function ' . $method . '('); $end = (int) strpos($prescriptionOrderLogicSource, 'private static function ' . $method . 'Locked(', $start); $wrapperSource = substr($prescriptionOrderLogicSource, $start, $end - $start); @@ -844,6 +844,35 @@ $assertTrue( !str_contains($shipLockedSource, '->ship_mode ='), 'shipping logistics must never overwrite the pharmacy target snapshot' ); +$ddcodeStart = (int) strpos($prescriptionOrderLogicSource, 'public static function ddcode('); +$ddcodeLockedStart = (int) strpos($prescriptionOrderLogicSource, 'private static function ddcodeLocked(', $ddcodeStart); +$ddcodeEnd = (int) strpos($prescriptionOrderLogicSource, 'public static function ship(', $ddcodeLockedStart); +$ddcodeWrapperSource = substr($prescriptionOrderLogicSource, $ddcodeStart, $ddcodeLockedStart - $ddcodeStart); +$ddcodeLockedSource = substr($prescriptionOrderLogicSource, $ddcodeLockedStart, $ddcodeEnd - $ddcodeLockedStart); +$assertTrue( + (bool) preg_match( + '/LockedPharmacySnapshotMutation::execute\(.*?self::ddcodeLocked\(.*?\),\s*false\s*\)/s', + $ddcodeWrapperSource + ), + 'tracking correction must keep row locks and transactions while bypassing only the remote snapshot mutability assertion' +); +preg_match_all('/\$order->([a-z_]+)\s*=/', $ddcodeLockedSource, $ddcodeAssignedFieldMatches); +$assertSame( + ['tracking_number', 'express_company'], + array_values(array_unique($ddcodeAssignedFieldMatches[1] ?? [])), + 'tracking correction must write only the tracking number and carrier fields' +); +$assertTrue( + !str_contains($ddcodeLockedSource, '$order->fulfillment_status') + && !str_contains($ddcodeLockedSource, 'assertRemoteSnapshotMutable'), + 'tracking correction must be available in every fulfillment state and for remotely submitted orders' +); +$assertTrue( + str_contains($ddcodeLockedSource, "'fill_tracking'") + && str_contains($ddcodeLockedSource, "'修改快递信息:单号") + && str_contains($ddcodeLockedSource, "\n true\n );"), + 'tracking correction must append a strict operation log containing old and new logistics values' +); $assertTrue( substr_count($prescriptionLogicSource, 'remoteSnapshotLockErrorForPrescription') >= 2, 'consumer prescription edit and patient patch must enforce the linked order snapshot lock' @@ -1459,6 +1488,9 @@ $assertTrue( $tcmApi = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/api/tcm.ts'); $desktopOrderPage = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/order_list.vue'); $mobileOrderPage = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/order_list_h5.vue'); +$ddcodeMigration = (string) file_get_contents( + dirname(__DIR__, 2) . '/sql/1.9.20260803/add_prescription_order_ddcode_menu.sql' +); $gancaoReconcileComponent = (string) file_get_contents( dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/components/GancaoSubmissionReconcileButton.vue' ); @@ -1486,6 +1518,18 @@ $assertTrue( str_contains($tcmApi, "url: '/tcm.prescriptionOrder/uploadToPharmacy'"), 'unified pharmacy API client must use the independently registered upload URI' ); +$assertTrue( + str_contains($tcmApi, "url: '/tcm.prescriptionOrder/ddcode'") + && str_contains($ddcodeMigration, "'tcm.prescriptionOrder/ddcode'"), + 'tracking correction API and its dedicated permission must be registered together' +); +$assertTrue( + str_contains($desktopOrderPage, 'await prescriptionOrderDdcode({') + && str_contains($mobileOrderPage, 'await prescriptionOrderDdcode({') + && (bool) preg_match('/function canQuickTrackRow\([^)]*\)\s*\{\s*return true\s*\}/', $desktopOrderPage) + && (bool) preg_match('/function canQuickTrackRow\([^)]*\)\s*\{\s*return true\s*\}/', $mobileOrderPage), + 'desktop and mobile tracking correction must use the dedicated API in every fulfillment state' +); $assertTrue( str_contains($desktopOrderPage, "v-perms=\"['tcm.prescriptionOrder/uploadToPharmacy']\""), 'unified pharmacy buttons must require the new upload permission'