From e2b0044b646bb112216b164fab2b70dc72c7a240 Mon Sep 17 00:00:00 2001 From: MiAn Date: Tue, 10 Nov 2026 11:01:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=AD=97=E5=85=B8=E4=B8=8E=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=AE=9A=E4=BB=B7=E4=BD=93=E7=B3=BB=E5=8F=8A?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E7=AB=AF=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema_phase14_showcase.sql | 33 + server/addons/wechat/library/Config.php | 37 + server/addons/wechat/library/Wechat.php | 21 + .../application/admin/controller/Category.php | 2 + .../application/admin/controller/Invoice.php | 142 +- .../application/admin/controller/Service.php | 132 +- .../application/admin/controller/Withdraw.php | 181 ++- .../admin/controller/service/Cat.php | 43 +- .../admin/controller/service/Type.php | 44 + .../application/admin/lang/zh-cn/invoice.php | 1 + .../application/admin/lang/zh-cn/service.php | 24 +- .../admin/lang/zh-cn/service/type.php | 12 + .../application/admin/model/service/Type.php | 36 + .../admin/view/dashboard/index.html | 2 +- .../application/admin/view/invoice/add.html | 140 -- .../application/admin/view/invoice/edit.html | 124 +- .../application/admin/view/invoice/index.html | 19 +- .../application/admin/view/service/add.html | 562 ++++--- .../admin/view/service/cat/index.html | 24 +- .../application/admin/view/service/edit.html | 912 +++++++----- .../application/admin/view/service/index.html | 32 +- .../admin/view/service/type/add.html | 95 ++ .../admin/view/service/type/edit.html | 95 ++ .../admin/view/service/type/index.html | 24 + .../application/admin/view/withdraw/add.html | 108 -- .../application/admin/view/withdraw/edit.html | 119 +- .../admin/view/withdraw/index.html | 19 +- server/application/api/controller/Auth.php | 118 +- server/application/api/controller/Base.php | 134 ++ server/application/api/controller/Home.php | 1272 ++++++++++++---- .../application/api/controller/Merchant.php | 126 +- server/application/api/controller/Order.php | 255 +++- server/application/api/controller/Review.php | 17 +- server/application/extra/site.php | 2 +- server/application/route.php | 6 + tests/local_report_data.json | 50 +- yjyf-app/components/AddonServiceDrawer.vue | 79 +- yjyf-app/components/GoodsPickerDrawer.vue | 34 +- yjyf-app/components/LogisticsDrawer.vue | 264 ++-- yjyf-app/components/ServiceTypeDrawer.vue | 181 ++- yjyf-app/components/user/UserHomeView.vue | 1302 +++++++++++++++-- yjyf-app/components/user/UserMineView.vue | 32 +- yjyf-app/components/user/UserOrdersView.vue | 32 +- yjyf-app/pages.json | 4 + yjyf-app/pages/index/index.vue | 8 +- yjyf-app/pages/merchant/invoice.vue | 471 +++++- yjyf-app/pages/mine/account.vue | 284 ++++ yjyf-app/pages/order/case.vue | 78 +- yjyf-app/pages/order/detail.vue | 219 ++- yjyf-app/pages/order/quick.vue | 640 +++++--- yjyf-app/pages/order/review.vue | 116 +- yjyf-app/static/icons/caret-down-dark.svg | 3 + yjyf-app/static/icons/caret-down-orange.svg | 3 + yjyf-app/static/icons/check-orange.svg | 6 +- yjyf-app/static/icons/chevron-down-gray.svg | 3 + yjyf-app/static/icons/chevron-down-orange.svg | 3 + yjyf-app/static/icons/filter-dark.svg | 3 + yjyf-app/static/icons/filter-orange.svg | 3 + yjyf-app/static/icons/menu-invoice.svg | 6 + yjyf-app/static/icons/showcase-orange.svg | 4 + yjyf-app/static/icons/showcase-white.svg | 4 + yjyf-app/static/icons/sort-asc.svg | 4 + yjyf-app/static/icons/sort-both.svg | 4 + yjyf-app/static/icons/sort-default.svg | 4 + yjyf-app/static/icons/sort-desc.svg | 4 + yjyf-app/utils/api.js | 4 + 66 files changed, 6628 insertions(+), 2137 deletions(-) create mode 100644 db/schema_phase14_showcase.sql create mode 100644 server/addons/wechat/library/Config.php create mode 100644 server/addons/wechat/library/Wechat.php create mode 100644 server/application/admin/controller/service/Type.php create mode 100644 server/application/admin/lang/zh-cn/service/type.php create mode 100644 server/application/admin/model/service/Type.php delete mode 100644 server/application/admin/view/invoice/add.html create mode 100644 server/application/admin/view/service/type/add.html create mode 100644 server/application/admin/view/service/type/edit.html create mode 100644 server/application/admin/view/service/type/index.html delete mode 100644 server/application/admin/view/withdraw/add.html create mode 100644 yjyf-app/pages/mine/account.vue create mode 100644 yjyf-app/static/icons/caret-down-dark.svg create mode 100644 yjyf-app/static/icons/caret-down-orange.svg create mode 100644 yjyf-app/static/icons/chevron-down-gray.svg create mode 100644 yjyf-app/static/icons/chevron-down-orange.svg create mode 100644 yjyf-app/static/icons/filter-dark.svg create mode 100644 yjyf-app/static/icons/filter-orange.svg create mode 100644 yjyf-app/static/icons/menu-invoice.svg create mode 100644 yjyf-app/static/icons/showcase-orange.svg create mode 100644 yjyf-app/static/icons/showcase-white.svg create mode 100644 yjyf-app/static/icons/sort-asc.svg create mode 100644 yjyf-app/static/icons/sort-both.svg create mode 100644 yjyf-app/static/icons/sort-default.svg create mode 100644 yjyf-app/static/icons/sort-desc.svg diff --git a/db/schema_phase14_showcase.sql b/db/schema_phase14_showcase.sql new file mode 100644 index 0000000..375c1e4 --- /dev/null +++ b/db/schema_phase14_showcase.sql @@ -0,0 +1,33 @@ +-- Phase 14: 独立业主晒单表 (fa_showcase) 与订单/评价晒单状态字段 +ALTER TABLE `fa_order` + ADD COLUMN `is_showcase` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否公开晒单:0=否,1=是', + ADD COLUMN `showcase_time` BIGINT(16) NULL DEFAULT NULL COMMENT '晒单时间'; + +ALTER TABLE `fa_review` + ADD COLUMN `is_showcase` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否同步晒单:0=否,1=是'; + +CREATE TABLE IF NOT EXISTS `fa_showcase` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '晒单ID', + `order_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '关联订单ID', + `user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID', + `user_name` varchar(50) NOT NULL DEFAULT '匿名业主' COMMENT '脱敏业主称呼', + `user_avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '业主头像', + `cat_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '服务类目ID', + `cat_name` varchar(50) NOT NULL DEFAULT '' COMMENT '服务类目名称', + `service_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '服务项目ID', + `service_name` varchar(100) NOT NULL DEFAULT '' COMMENT '服务项目名称', + `region` varchar(50) NOT NULL DEFAULT '同城服务' COMMENT '服务城市/区县', + `cover_image` varchar(255) NOT NULL DEFAULT '' COMMENT '晒单封面图', + `images` varchar(1000) NOT NULL DEFAULT '' COMMENT '晒单实拍图列表', + `score` tinyint(1) unsigned NOT NULL DEFAULT 5 COMMENT '评分1-5', + `tags` varchar(255) NOT NULL DEFAULT '' COMMENT '评价标签', + `content` varchar(500) NOT NULL DEFAULT '' COMMENT '晒单内容', + `master_name` varchar(50) NOT NULL DEFAULT '优匠师傅' COMMENT '脱敏师傅称呼', + `master_score` decimal(3,2) NOT NULL DEFAULT 5.00 COMMENT '师傅评分', + `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=公开,hidden=隐藏', + `createtime` bigint(16) DEFAULT NULL COMMENT '晒单时间', + `updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_id` (`order_id`), + KEY `idx_status_time` (`status`, `createtime`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业主主动晒单表'; diff --git a/server/addons/wechat/library/Config.php b/server/addons/wechat/library/Config.php new file mode 100644 index 0000000..07087c0 --- /dev/null +++ b/server/addons/wechat/library/Config.php @@ -0,0 +1,37 @@ + Env::get('wechat.appid', ''), + // AppSecret + 'secret' => Env::get('wechat.secret', ''), + // 服务端验证 Token(未配置时为空,接口调用需自行补充) + 'token' => Env::get('wechat.token', ''), + // 消息加解密 Key(未配置时为空) + 'aes_key' => Env::get('wechat.aes_key', ''), + // SDK 日志配置 + 'log' => [ + 'level' => 'debug', + 'file' => RUNTIME_PATH . 'log/wechat.log', + ], + ]; + } +} diff --git a/server/addons/wechat/library/Wechat.php b/server/addons/wechat/library/Wechat.php new file mode 100644 index 0000000..8a81356 --- /dev/null +++ b/server/addons/wechat/library/Wechat.php @@ -0,0 +1,21 @@ +view->assign("flagList", $this->model->getFlagList()); $this->view->assign("typeList", $typeList); $this->view->assign("parentList", $categorydata); + // 状态单选框数据(分类模型无独立状态枚举方法,此处按通用 normal/hidden 提供) + $this->view->assign("statusList", ['normal' => __('Normal'), 'hidden' => __('Hidden')]); $this->assignconfig('typeList', $typeList); } diff --git a/server/application/admin/controller/Invoice.php b/server/application/admin/controller/Invoice.php index 3d27f5d..3604203 100644 --- a/server/application/admin/controller/Invoice.php +++ b/server/application/admin/controller/Invoice.php @@ -3,11 +3,17 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use think\exception\PDOException; /** * 开票申请 * - * @icon fa fa-circle-o + * 开票申请仅由用户/商户端发起,后台只负责财务开具处理: + * 待开具(wait) → 已开票(issued,必填发票号)/驳回(reject,必填原因) + * 驳回后额度自动释放,并通知申请人 + * + * @icon fa fa-file-text-o */ class Invoice extends Backend { @@ -18,6 +24,15 @@ class Invoice extends Backend */ protected $model = null; + /** + * 开票处理状态机:当前状态 => 允许流转到的目标状态 + * issued/reject 为终态 + * @var array + */ + protected $statusFlow = [ + 'wait' => ['issued', 'reject'], + ]; + public function _initialize() { parent::_initialize(); @@ -26,18 +41,9 @@ class Invoice extends Backend $this->view->assign("statusList", $this->model->getStatusList()); } - - /** - * 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法 - * 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑 - * 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改 + * 开票申请列表:关联申请人/商户 */ - - - - protected $relationSearch = true; - public function index() { $this->request->filter(['strip_tags', 'trim']); @@ -56,4 +62,118 @@ class Invoice extends Backend $result = ['total' => $list->total(), 'rows' => $list->items()]; return json($result); } + + /** + * 财务开票处理:申请信息只读,仅允许填写开票结果 + * - 开票完成:必须填写发票号,状态置为已开票 + * - 驳回:必须填写原因,额度自动释放 + */ + public function edit($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (false === $this->request->isPost()) { + $this->view->assign('row', $row); + $this->view->assign('flowOptions', $this->statusFlow[$row['status']] ?? []); + return $this->view->fetch(); + } + + $status = (string)$this->request->post('row.status'); + $invoiceNo = trim((string)$this->request->post('row.invoice_no')); + $auditMsg = trim((string)$this->request->post('row.audit_msg')); + + // 状态机校验:终态禁止操作,流转目标必须合法 + if (!isset($this->statusFlow[$row['status']])) { + $this->error('该开票申请已处理完毕,不允许再次变更'); + } + if (!in_array($status, $this->statusFlow[$row['status']], true)) { + $this->error('状态流转不合法,请刷新后重试'); + } + if ($status === 'issued' && $invoiceNo === '') { + $this->error('标记开票完成必须填写发票号码'); + } + if ($status === 'reject' && $auditMsg === '') { + $this->error('驳回时必须填写驳回原因'); + } + + Db::startTrans(); + try { + // 乐观锁更新状态,防止并发重复处理 + $affected = Db::name('invoice') + ->where('id', $row['id']) + ->where('status', $row['status']) + ->update([ + 'status' => $status, + 'invoice_no' => $invoiceNo !== '' ? $invoiceNo : $row['invoice_no'], + 'audit_msg' => $auditMsg, + 'updatetime' => time(), + ]); + if (!$affected) { + throw new \Exception('开票申请状态已被其他人变更,请刷新后重试'); + } + + // 通知申请人(商户开票通知绑定账号用户) + $this->sendUserMessage( + (int)$row['user_id'], + $status === 'issued' ? '发票已开具' : '开票申请已驳回', + $status === 'issued' + ? '您申请的开票 ¥' . $row['amount'] . ' 已开具完成,发票号码:' . $invoiceNo . ',电子发票已发送至 ' . $row['email'] + : '您的开票申请 ¥' . $row['amount'] . ' 未通过审核:' . ($auditMsg !== '' ? $auditMsg : '无') + ); + Db::commit(); + } catch (PDOException|\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + $this->success(); + } + + /** + * 开票申请仅由用户/商户端发起,禁止后台手工添加 + */ + public function add() + { + $this->error('开票申请仅支持用户/商户端发起,后台不可手动添加'); + } + + /** + * 开票记录禁止删除,保障开票额度统计与账务完整 + */ + public function del($ids = null) + { + $this->error('开票申请记录禁止删除'); + } + + /** + * 禁止批量修改状态,必须逐单填写开票结果 + */ + public function multi($ids = null) + { + $this->error('开票处理不支持批量操作,请逐单填写发票号后处理'); + } + + /** + * 向申请人发送站内消息 + * @param int $userId 用户ID + * @param string $title 消息标题 + * @param string $content 消息内容 + */ + protected function sendUserMessage($userId, $title, $content) + { + if (!$userId) { + return; + } + Db::name('message')->insert([ + 'receive_type' => 'user', + 'receive_id' => $userId, + 'type' => 'system', + 'title' => mb_substr((string)$title, 0, 100), + 'content' => mb_substr((string)$content, 0, 500), + 'order_id' => 0, + 'is_read' => 0, + 'createtime' => time(), + ]); + } } diff --git a/server/application/admin/controller/Service.php b/server/application/admin/controller/Service.php index 4f4740e..db932cc 100644 --- a/server/application/admin/controller/Service.php +++ b/server/application/admin/controller/Service.php @@ -9,9 +9,9 @@ use think\exception\ValidateException; use Exception; /** - * 服务项目与商品管理 + * 商品管理(商品仅定义施工标的物与细分规格,服务价格统一配置在关联的主服务与增项服务上) * - * @icon fa fa-circle-o + * @icon fa fa-cubes */ class Service extends Backend { @@ -29,21 +29,23 @@ class Service extends Backend $this->view->assign("priceTypeList", $this->model->getPriceTypeList()); $this->view->assign("statusList", $this->model->getStatusList()); - // 获取全部可用的公共增项服务列表并关联所属品类 - $addonsPool = Db::name('service_addon') - ->alias('a') - ->leftJoin('service_cat c', 'a.cat_id = c.id') - ->field('a.*, COALESCE(c.name, "全平台通用") as cat_name') - ->where('a.status', 'normal') - ->order('a.cat_id asc, a.weigh desc, a.id asc') + // 获取统一服务字典表(fa_service_type,不绑定单一类目,主服务与增项服务同源) + $typesPool = Db::name('service_type') + ->where('status', 'normal') + ->order('weigh desc, id asc') ->select(); - $this->view->assign("addonsPool", $addonsPool); + $this->view->assign("typesPool", $typesPool); } public function index() { $this->request->filter(['strip_tags', 'trim']); if (false === $this->request->isAjax()) { + $cats = Db::name('service_cat') + ->where('status', 'normal') + ->order('weigh desc, id asc') + ->select(); + $this->view->assign('catList', $cats); return $this->view->fetch(); } if ($this->request->request('keyField')) { @@ -55,12 +57,53 @@ class Service extends Backend ->where($where) ->order($sort, $order) ->paginate($limit); - $result = ['total' => $list->total(), 'rows' => $list->items()]; + + $rows = $list->items(); + if (!empty($rows)) { + $serviceIds = []; + foreach ($rows as $r) { + $serviceIds[] = (int)$r['id']; + } + // 一次性批量查询当前页商品的细分规格,避免 N+1 查询 + $allSpecs = Db::name('service_spec') + ->field('id,service_id,name,description') + ->where('service_id', 'in', $serviceIds) + ->where('status', 'normal') + ->order('weigh desc, id asc') + ->select(); + $specMap = []; + foreach ($allSpecs as $sp) { + $sid = (int)$sp['service_id']; + if (!isset($specMap[$sid])) { + $specMap[$sid] = []; + } + $specMap[$sid][] = $sp['name']; + } + foreach ($rows as &$r) { + $sid = (int)$r['id']; + $r['specs_list'] = isset($specMap[$sid]) ? $specMap[$sid] : []; + + // 直接在服务端解析为数组返回,防止前端 bootstrap-table escape:true 将 JSON 双引号转义为 " 导致解析失败 + $stArr = []; + if (!empty($r['service_types'])) { + $stArr = is_array($r['service_types']) ? $r['service_types'] : (json_decode($r['service_types'], true) ?: []); + } + $adArr = []; + if (!empty($r['addons'])) { + $adArr = is_array($r['addons']) ? $r['addons'] : (json_decode($r['addons'], true) ?: []); + } + $r['service_types_list'] = $stArr; + $r['addons_list'] = $adArr; + } + unset($r); + } + + $result = ['total' => $list->total(), 'rows' => $rows]; return json($result); } /** - * 添加服务商品 + * 添加商品并配置关联服务定价 */ public function add() { @@ -73,7 +116,6 @@ class Service extends Backend $params[$this->dataLimitField] = $this->auth->id; } - // 处理工项类型与增项配置 $params = $this->formatServiceConfig($params); $result = false; @@ -82,8 +124,8 @@ class Service extends Backend $result = $this->model->allowField(true)->save($params); $serviceId = $this->model->id; - // 处理规格列表 - $this->syncSpecs($serviceId, $this->request->post('specs/a', [])); + // 处理商品规格列表 + $this->syncSpecs($serviceId, $this->request->post('specs/a', []), isset($params['base_price']) ? (float)$params['base_price'] : 0); Db::commit(); } catch (ValidateException|PDOException|Exception $e) { @@ -102,7 +144,7 @@ class Service extends Backend } /** - * 编辑服务商品(工项价格、规格价格、增项服务绑定) + * 编辑商品与关联服务定价 */ public function edit($ids = null) { @@ -118,8 +160,6 @@ class Service extends Backend $params = $this->request->post("row/a"); if ($params) { $params = $this->preExcludeFields($params); - - // 处理工项类型与增项配置 $params = $this->formatServiceConfig($params); $result = false; @@ -127,8 +167,8 @@ class Service extends Backend try { $result = $row->allowField(true)->save($params); - // 同步处理规格列表 - $this->syncSpecs($row->id, $this->request->post('specs/a', [])); + // 同步处理商品规格列表 + $this->syncSpecs($row->id, $this->request->post('specs/a', []), isset($params['base_price']) ? (float)$params['base_price'] : (float)$row->base_price); Db::commit(); } catch (ValidateException|PDOException|Exception $e) { @@ -144,7 +184,7 @@ class Service extends Backend $this->error(__('Parameter %s can not be empty', '')); } - // 解析工项与增项配置以供视图回显 + // 解析当前商品已关联的主服务与增项服务配置 $serviceTypes = []; if (!empty($row->service_types)) { $serviceTypes = is_array($row->service_types) ? $row->service_types : json_decode($row->service_types, true); @@ -154,14 +194,17 @@ class Service extends Backend $addons = is_array($row->addons) ? $row->addons : json_decode($row->addons, true); } - // 获取已有规格 + // 获取已有商品规格 $specs = Db::name('service_spec') ->where('service_id', $row->id) ->where('status', 'normal') ->order('weigh desc, id asc') ->select(); + $catName = Db::name('service_cat')->where('id', $row->cat_id)->value('name') ?: '未归类'; + $this->view->assign("row", $row); + $this->view->assign("catName", $catName); $this->view->assign("serviceTypes", $serviceTypes ?: []); $this->view->assign("assignedAddons", $addons ?: []); $this->view->assign("specs", $specs ?: []); @@ -169,44 +212,68 @@ class Service extends Backend } /** - * 格式化工项与增项输入数据 + * 格式化商品关联的主服务与增项服务 JSON,并自动提取默认主服务价格用于底层索引兼容 */ protected function formatServiceConfig($params) { - // 工项类型 JSON + $parsedTypes = []; if (isset($params['service_types_raw'])) { - $params['service_types'] = is_string($params['service_types_raw']) ? $params['service_types_raw'] : json_encode($params['service_types_raw'], JSON_UNESCAPED_UNICODE); + if (is_string($params['service_types_raw'])) { + $parsedTypes = json_decode($params['service_types_raw'], true) ?: []; + $params['service_types'] = $params['service_types_raw']; + } else { + $parsedTypes = is_array($params['service_types_raw']) ? $params['service_types_raw'] : []; + $params['service_types'] = json_encode($parsedTypes, JSON_UNESCAPED_UNICODE); + } unset($params['service_types_raw']); } - // 增项服务 JSON + if (isset($params['addons_raw'])) { $params['addons'] = is_string($params['addons_raw']) ? $params['addons_raw'] : json_encode($params['addons_raw'], JSON_UNESCAPED_UNICODE); unset($params['addons_raw']); } + + // 自动从关联的主服务(优先默认主服务或“安装”)同步底层 base_price 与 price_type,商品无需单独填价格 + if (!empty($parsedTypes)) { + $defItem = $parsedTypes[0]; + foreach ($parsedTypes as $pt) { + if (!empty($pt['is_default'])) { + $defItem = $pt; + break; + } + } + $supportFixed = isset($defItem['support_fixed']) ? (int)$defItem['support_fixed'] : 1; + $params['price_type'] = $supportFixed === 1 ? 'fixed' : 'quote'; + $params['base_price'] = $supportFixed === 1 ? (float)$defItem['price'] : 0.00; + } + return $params; } /** - * 同步商品规格表 + * 同步商品规格型号表(规格不设独立商品价格,仅标识具体施工标的物型号) */ - protected function syncSpecs($serviceId, $specsInput) + protected function syncSpecs($serviceId, $specsInput, $defaultBasePrice = 0.00) { if (empty($serviceId)) return; $now = time(); $keepIds = []; if (!empty($specsInput) && is_array($specsInput)) { + $idx = 0; foreach ($specsInput as $item) { $name = trim(isset($item['name']) ? $item['name'] : ''); if ($name === '') continue; - $specPrice = isset($item['base_price']) ? (float)$item['base_price'] : 0.00; $specDesc = isset($item['description']) ? trim($item['description']) : ''; $specId = isset($item['id']) ? (int)$item['id'] : 0; + $weigh = max(0, 100 - $idx * 5); + $idx++; if ($specId > 0) { Db::name('service_spec')->where('id', $specId)->where('service_id', $serviceId)->update([ 'name' => $name, - 'base_price' => $specPrice, + 'base_price' => $defaultBasePrice, 'description' => $specDesc, + 'weigh' => $weigh, 'updatetime' => $now, ]); $keepIds[] = $specId; @@ -214,9 +281,9 @@ class Service extends Backend $newId = Db::name('service_spec')->insertGetId([ 'service_id' => $serviceId, 'name' => $name, - 'base_price' => $specPrice, + 'base_price' => $defaultBasePrice, 'description' => $specDesc, - 'weigh' => 0, + 'weigh' => $weigh, 'status' => 'normal', 'createtime' => $now, 'updatetime' => $now, @@ -225,7 +292,6 @@ class Service extends Backend } } } - // 将未在列表中的历史规格软下架或删除 $delQuery = Db::name('service_spec')->where('service_id', $serviceId); if (!empty($keepIds)) { $delQuery->whereNotIn('id', $keepIds); diff --git a/server/application/admin/controller/Withdraw.php b/server/application/admin/controller/Withdraw.php index d2ffebe..1bf4a02 100644 --- a/server/application/admin/controller/Withdraw.php +++ b/server/application/admin/controller/Withdraw.php @@ -3,11 +3,17 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use think\exception\PDOException; /** * 提现申请 * - * @icon fa fa-circle-o + * 提现单仅由师傅端发起,后台只负责财务审核与打款标记: + * 待审核(wait) → 审核通过(pass)/驳回(reject);审核通过(pass) → 打款完成(done)/驳回(reject) + * 驳回自动解冻并退回师傅余额,打款完成自动划出冻结资金 + * + * @icon fa fa-rmb */ class Withdraw extends Backend { @@ -18,6 +24,16 @@ class Withdraw extends Backend */ protected $model = null; + /** + * 审核状态机:当前状态 => 允许流转到的目标状态 + * reject/done 为终态,不允许再变更 + * @var array + */ + protected $statusFlow = [ + 'wait' => ['pass', 'reject'], + 'pass' => ['done', 'reject'], + ]; + public function _initialize() { parent::_initialize(); @@ -26,18 +42,9 @@ class Withdraw extends Backend $this->view->assign("statusList", $this->model->getStatusList()); } - - /** - * 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法 - * 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑 - * 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改 + * 提现列表:关联师傅信息,展示联系方式与处理时间 */ - - - - protected $relationSearch = true; - public function index() { $this->request->filter(['strip_tags', 'trim']); @@ -56,4 +63,156 @@ class Withdraw extends Backend $result = ['total' => $list->total(), 'rows' => $list->items()]; return json($result); } + + /** + * 财务审核:申请信息只读,仅允许合法状态流转并做资金联动 + * - 驳回:退回师傅余额并解冻,写入钱包流水,通知师傅 + * - 通过:仅标记审核通过,等待财务打款 + * - 打款完成:划出冻结资金,写入钱包流水,通知师傅 + */ + public function edit($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (false === $this->request->isPost()) { + // 展示师傅当前钱包情况,辅助财务判断 + $master = Db::name('master')->where('id', (int)$row['master_id'])->field('id,realname,mobile,user_id,money,frozen_money')->find(); + $this->view->assign('row', $row); + $this->view->assign('master', $master ?: []); + $this->view->assign('flowOptions', $this->statusFlow[$row['status']] ?? []); + return $this->view->fetch(); + } + + $status = (string)$this->request->post('row.status'); + $auditMsg = trim((string)$this->request->post('row.audit_msg')); + + // 状态机校验:终态禁止操作,流转目标必须合法 + if (!isset($this->statusFlow[$row['status']])) { + $this->error('该提现单已处于终态(已驳回/已打款),不允许再次审核'); + } + if (!in_array($status, $this->statusFlow[$row['status']], true)) { + $this->error('状态流转不合法,请刷新后重试'); + } + if ($status === 'reject' && $auditMsg === '') { + $this->error('驳回时必须填写驳回原因'); + } + + $masterId = (int)$row['master_id']; + $amount = round((float)$row['amount'], 2); + Db::startTrans(); + try { + // 乐观锁更新状态,防止并发重复审核 + $affected = Db::name('withdraw') + ->where('id', $row['id']) + ->where('status', $row['status']) + ->update([ + 'status' => $status, + 'audit_msg' => $auditMsg, + 'updatetime' => time(), + ]); + if (!$affected) { + throw new \Exception('提现单状态已被其他人变更,请刷新后重试'); + } + + $master = Db::name('master')->where('id', $masterId)->lock(true)->find(); + if (!$master) { + throw new \Exception('提现师傅不存在'); + } + + if ($status === 'reject') { + // 驳回:解冻并退回余额,写入钱包流水 + $newMoney = round((float)$master['money'] + $amount, 2); + Db::name('master')->where('id', $masterId)->update([ + 'money' => $newMoney, + 'frozen_money' => round((float)$master['frozen_money'] - $amount, 2), + 'updatetime' => time(), + ]); + Db::name('wallet_log')->insert([ + 'user_type' => 'master', + 'user_id' => $masterId, + 'type' => 'refund', + 'amount' => $amount, + 'balance' => $newMoney, + 'order_id' => 0, + 'remark' => '提现驳回,申请金额退回钱包余额' . ($auditMsg !== '' ? '(原因:' . $auditMsg . ')' : ''), + 'createtime' => time(), + ]); + $this->sendMasterMessage((int)$master['user_id'], '提现申请已驳回', '您的提现申请 ¥' . $amount . ' 未通过财务审核:' . ($auditMsg !== '' ? $auditMsg : '无') . ',申请金额已退回钱包余额'); + } elseif ($status === 'pass') { + // 审核通过:仅标记,资金保持冻结,等待财务打款 + $this->sendMasterMessage((int)$master['user_id'], '提现审核通过', '您的提现申请 ¥' . $amount . ' 已通过审核,财务将尽快安排打款到 ' . ($row['channel'] === 'bank' ? '银行卡' : '微信钱包')); + } elseif ($status === 'done') { + // 打款完成:冻结资金划出 + Db::name('master')->where('id', $masterId)->update([ + 'frozen_money' => round((float)$master['frozen_money'] - $amount, 2), + 'updatetime' => time(), + ]); + Db::name('wallet_log')->insert([ + 'user_type' => 'master', + 'user_id' => $masterId, + 'type' => 'withdraw', + 'amount' => 0, + 'balance' => (float)$master['money'], + 'order_id' => 0, + 'remark' => '提现打款完成,冻结资金 ¥' . $amount . ' 划出(' . ($row['channel'] === 'bank' ? '银行储蓄卡' : '微信钱包') . ' ' . $row['account'] . ')', + 'createtime' => time(), + ]); + $this->sendMasterMessage((int)$master['user_id'], '提现打款完成', '您的提现 ¥' . $amount . ' 已打款成功,请留意到账通知'); + } + Db::commit(); + } catch (PDOException|\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + $this->success(); + } + + /** + * 提现单仅由师傅端发起,禁止后台手工添加 + */ + public function add() + { + $this->error('提现申请仅支持师傅端发起,后台不可手动添加'); + } + + /** + * 提现流水记录禁止删除,保障资金对账完整 + */ + public function del($ids = null) + { + $this->error('提现申请记录禁止删除'); + } + + /** + * 禁止批量修改状态,状态变更必须走审核流程以触发资金联动 + */ + public function multi($ids = null) + { + $this->error('提现审核不支持批量操作,请逐单审核处理'); + } + + /** + * 向师傅所属用户发送站内消息 + * @param int $userId 用户ID + * @param string $title 消息标题 + * @param string $content 消息内容 + */ + protected function sendMasterMessage($userId, $title, $content) + { + if (!$userId) { + return; + } + Db::name('message')->insert([ + 'receive_type' => 'user', + 'receive_id' => $userId, + 'type' => 'system', + 'title' => mb_substr((string)$title, 0, 100), + 'content' => mb_substr((string)$content, 0, 500), + 'order_id' => 0, + 'is_read' => 0, + 'createtime' => time(), + ]); + } } diff --git a/server/application/admin/controller/service/Cat.php b/server/application/admin/controller/service/Cat.php index f5af4f4..bb357fb 100644 --- a/server/application/admin/controller/service/Cat.php +++ b/server/application/admin/controller/service/Cat.php @@ -3,15 +3,15 @@ namespace app\admin\controller\service; use app\common\controller\Backend; +use think\Db; /** * 服务类目 * - * @icon fa fa-circle-o + * @icon fa fa-th-large */ class Cat extends Backend { - /** * Cat模型对象 * @var \app\admin\model\service\Cat @@ -25,13 +25,38 @@ class Cat extends Backend $this->view->assign("statusList", $this->model->getStatusList()); } + public function index() + { + $this->request->filter(['strip_tags', 'trim']); + if (false === $this->request->isAjax()) { + return $this->view->fetch(); + } + if ($this->request->request('keyField')) { + return $this->selectpage(); + } + [$where, $sort, $order, $offset, $limit] = $this->buildparams(); + $list = $this->model + ->where($where) + ->order($sort, $order) + ->paginate($limit); + $rows = $list->items(); + if (!empty($rows)) { + $catCountRows = Db::name('service') + ->field('cat_id, count(id) as cnt') + ->group('cat_id') + ->select(); + $catCounts = []; + foreach ($catCountRows as $cr) { + $catCounts[(int)$cr['cat_id']] = (int)$cr['cnt']; + } + foreach ($rows as &$r) { + $cid = (int)$r['id']; + $r['goods_count'] = isset($catCounts[$cid]) ? (int)$catCounts[$cid] : 0; + } + unset($r); + } - /** - * 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法 - * 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑 - * 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改 - */ - - + return json(['total' => $list->total(), 'rows' => $rows]); + } } diff --git a/server/application/admin/controller/service/Type.php b/server/application/admin/controller/service/Type.php new file mode 100644 index 0000000..d0e9f39 --- /dev/null +++ b/server/application/admin/controller/service/Type.php @@ -0,0 +1,44 @@ +model = new \app\admin\model\service\Type; + $this->view->assign("statusList", $this->model->getStatusList()); + } + + public function index() + { + $this->request->filter(['strip_tags', 'trim']); + if (false === $this->request->isAjax()) { + return $this->view->fetch(); + } + if ($this->request->request('keyField')) { + return $this->selectpage(); + } + [$where, $sort, $order, $offset, $limit] = $this->buildparams(); + $list = $this->model + ->where($where) + ->order($sort, $order) + ->paginate($limit); + $result = ['total' => $list->total(), 'rows' => $list->items()]; + return json($result); + } +} diff --git a/server/application/admin/lang/zh-cn/invoice.php b/server/application/admin/lang/zh-cn/invoice.php index 8fc630d..04a1afb 100644 --- a/server/application/admin/lang/zh-cn/invoice.php +++ b/server/application/admin/lang/zh-cn/invoice.php @@ -11,6 +11,7 @@ return [ 'Remark' => '开票申请备注', 'Status' => '开票处理状态', 'Invoice_no' => '发票代码与号码', + 'Audit_msg' => '财务处理说明/驳回原因', 'Ordinary' => '普通增值税电子发票', 'Special' => '增值税专用发票', 'Wait' => '待财务开具', diff --git a/server/application/admin/lang/zh-cn/service.php b/server/application/admin/lang/zh-cn/service.php index 3f2cbbe..72da9e3 100644 --- a/server/application/admin/lang/zh-cn/service.php +++ b/server/application/admin/lang/zh-cn/service.php @@ -1,16 +1,16 @@ '所属服务类目', - 'Name' => '服务商品名称', - 'Image' => '服务展示主图', - 'Price_type' => '计价方式', - 'Base_price' => '基础基准价(元)', - 'Max_price' => '区间上限价(元)', - 'Unit' => '计价单位', - 'Floor_fee' => '楼层费(元/层)', - 'Free_floor' => '免费楼层数', - 'Need_model' => '是否需填型号', - 'Description' => '服务详细说明', - 'Sales' => '累计销量', + 'Cat_id' => '所属服务类目', + 'Name' => '商品名称', + 'Image' => '商品缩略图', + 'Unit' => '数量单位', + 'Floor_fee' => '步梯楼层费(元/层)', + 'Free_floor' => '免费楼层数', + 'Description' => '商品施工说明', + 'Service_types' => '关联主服务及定价', + 'Addons' => '关联增项服务及加价', + 'Sales' => '累计单量', + 'Weigh' => '排序权重', + 'Status' => '状态', ]; diff --git a/server/application/admin/lang/zh-cn/service/type.php b/server/application/admin/lang/zh-cn/service/type.php new file mode 100644 index 0000000..8519b1e --- /dev/null +++ b/server/application/admin/lang/zh-cn/service/type.php @@ -0,0 +1,12 @@ + '服务唯一标识', + 'Name' => '服务名称', + 'Unit' => '默认单位', + 'Can_be_main' => '可作主服务项目', + 'Can_be_addon' => '可作增项服务', + 'Support_fixed' => '默认计价模式', + 'Weigh' => '排序权重', + 'Status' => '状态', +]; diff --git a/server/application/admin/model/service/Type.php b/server/application/admin/model/service/Type.php new file mode 100644 index 0000000..1f1e2d0 --- /dev/null +++ b/server/application/admin/model/service/Type.php @@ -0,0 +1,36 @@ + __('Normal'), 'hidden' => __('Hidden')]; + } + + public function getStatusTextAttr($value, $data) + { + $value = $value ? $value : (isset($data['status']) ? $data['status'] : ''); + $list = $this->getStatusList(); + return isset($list[$value]) ? $list[$value] : ''; + } +} diff --git a/server/application/admin/view/dashboard/index.html b/server/application/admin/view/dashboard/index.html index f6e9913..99aeb57 100755 --- a/server/application/admin/view/dashboard/index.html +++ b/server/application/admin/view/dashboard/index.html @@ -250,7 +250,7 @@ diff --git a/server/application/admin/view/invoice/add.html b/server/application/admin/view/invoice/add.html deleted file mode 100644 index 152eda5..0000000 --- a/server/application/admin/view/invoice/add.html +++ /dev/null @@ -1,140 +0,0 @@ -
- - -
-
-

开票主体与金额明细

-
-
-
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
- -
-
-
- -
-
- ¥ - -
-
-
-
-
-
- -
- -
-
-
-
-
-
- - -
-
-

开票抬头与资质信息

-
-
-
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
- -
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
-
-
- - -
-
-

财务开具与状态归档

-
-
-
-
-
- -
-
- {foreach name="statusList" item="vo"} - - {/foreach} -
-
-
-
-
-
- -
- -
-
-
-
-
-
- - -
diff --git a/server/application/admin/view/invoice/edit.html b/server/application/admin/view/invoice/edit.html index f141e9f..8768dda 100644 --- a/server/application/admin/view/invoice/edit.html +++ b/server/application/admin/view/invoice/edit.html @@ -1,134 +1,104 @@
- +
-

开票主体与金额明细

+

开票申请信息(只读)

- +
- +

{$row.user.nickname|default=$row.user_id} {if $row.merchant_id}· 商户「{$row.merchant.name|default=''}」{/if}

- +
- +

¥{$row.amount|htmlentities}({$typeList[$row.type]|default=$row.type})

-
- +
-
- ¥ - -
+

{$row.title|htmlentities}{if $row.tax_no} · 税号 {$row.tax_no|htmlentities}{/if}

- +
- +

{$row.email|htmlentities}

+ {if $row.remark} +
+ +
+

{$row.remark|htmlentities}

+
+
+ {/if}
- + + {if condition="!empty($flowOptions)"}
-

开票抬头与资质信息

+

财务开票处理

-
-
-
- -
- -
-
-
-
-
- -
- -
+

线上开具完成后标记「开票完成」并填写发票号码,系统将自动通知申请人;无法开票时请驳回并说明原因,额度将自动释放。

+ +
+ +
+
+ {foreach name="flowOptions" item="op"} + {if $op == 'issued'} + + {elseif $op == 'reject' /} + + {/if} + {/foreach}
-
-
-
- -
- -
-
+
+ +
+
-
-
- -
- -
-
+
+ +
+ +
+
- - + {else /}
-
-

财务开具与状态归档

-
-
-
-
- -
-
- {foreach name="statusList" item="vo"} - - {/foreach} -
-
-
-
-
-
- -
- -
-
-
-
+

该开票申请已处理完毕({:$statusList[$row.status]}),无需操作。

+ {/if}